feat: add admin panel
This commit is contained in:
@@ -17,3 +17,4 @@ docs/
|
|||||||
|
|
||||||
# Local env
|
# Local env
|
||||||
.env
|
.env
|
||||||
|
db.db
|
||||||
|
|||||||
+35
-4
@@ -12,6 +12,7 @@ from beaver_gateway.agents.base import ExposedMcp
|
|||||||
from beaver_gateway.agents.claude import ClaudeAgent
|
from beaver_gateway.agents.claude import ClaudeAgent
|
||||||
from beaver_gateway.agents.raycast import RaycastAgent, RemoteTool, UserPreferences
|
from beaver_gateway.agents.raycast import RaycastAgent, RemoteTool, UserPreferences
|
||||||
from beaver_gateway.core.registry import Gateway
|
from beaver_gateway.core.registry import Gateway
|
||||||
|
from beaver_gateway.frontends.admin import AdminFrontend
|
||||||
from beaver_gateway.frontends.anthropic import AnthropicMessagesFrontend
|
from beaver_gateway.frontends.anthropic import AnthropicMessagesFrontend
|
||||||
from beaver_gateway.frontends.mcp_server import McpServerFrontend
|
from beaver_gateway.frontends.mcp_server import McpServerFrontend
|
||||||
from beaver_gateway.mcp.types import McpServer
|
from beaver_gateway.mcp.types import McpServer
|
||||||
@@ -28,6 +29,7 @@ def current_time() -> str:
|
|||||||
|
|
||||||
return datetime.now().astimezone().isoformat()
|
return datetime.now().astimezone().isoformat()
|
||||||
|
|
||||||
|
|
||||||
gateway = Gateway(
|
gateway = Gateway(
|
||||||
agents=[
|
agents=[
|
||||||
# Phase 2.2 — ClaudeCodeBackendAdapter routes this agent's
|
# Phase 2.2 — ClaudeCodeBackendAdapter routes this agent's
|
||||||
@@ -98,7 +100,7 @@ gateway = Gateway(
|
|||||||
# ClaudeCode adapter forwards that URL into
|
# ClaudeCode adapter forwards that URL into
|
||||||
# ``BackendOptions.mcp_servers``. Phase 3's ``McpServerFrontend``
|
# ``BackendOptions.mcp_servers``. Phase 3's ``McpServerFrontend``
|
||||||
# reverse-proxies the same internal URL out to external clients.
|
# reverse-proxies the same internal URL out to external clients.
|
||||||
McpServer.python_tool(name="time", tools=[current_time]),
|
McpServer.python_tool(name="time", tools=[current_time])
|
||||||
# Phase 3 — illustrates the ``lenient`` flag. Real-world stdio MCPs
|
# Phase 3 — illustrates the ``lenient`` flag. Real-world stdio MCPs
|
||||||
# sometimes print "Processing..." or other chatter to stdout before
|
# sometimes print "Processing..." or other chatter to stdout before
|
||||||
# their actual JSON-RPC frames; the default mcp client forwards
|
# their actual JSON-RPC frames; the default mcp client forwards
|
||||||
@@ -124,12 +126,41 @@ gateway = Gateway(
|
|||||||
# Phase 1.4 — expose the agents as `model=<name>` on an
|
# Phase 1.4 — expose the agents as `model=<name>` on an
|
||||||
# Anthropic-compatible Messages endpoint. Auth comes from
|
# Anthropic-compatible Messages endpoint. Auth comes from
|
||||||
# `BOOTSTRAP_TOKENS` in the env (`name1:value1,name2:value2`).
|
# `BOOTSTRAP_TOKENS` in the env (`name1:value1,name2:value2`).
|
||||||
|
#
|
||||||
|
# Behind a reverse proxy (Caddy / nginx / Cloudflare) pass
|
||||||
|
# `public_base_url=` so the admin dashboard advertises the
|
||||||
|
# outside URL instead of `host:port`. Caddy strips its own
|
||||||
|
# prefix and the frontend's internal paths (`/v1/messages`,
|
||||||
|
# `/v1/models`) get appended:
|
||||||
|
# Caddy: handle_path /ai/* { reverse_proxy localhost:8000 }
|
||||||
|
# Config: AnthropicMessagesFrontend(
|
||||||
|
# port=8000,
|
||||||
|
# public_base_url="https://domain.com/ai")
|
||||||
|
# Result: https://domain.com/ai/v1/messages
|
||||||
AnthropicMessagesFrontend(host="0.0.0.0", port=8000),
|
AnthropicMessagesFrontend(host="0.0.0.0", port=8000),
|
||||||
# Phase 3 — re-exposes every declared `McpServer` outside the
|
# Phase 3 — re-exposes every declared `McpServer` outside the
|
||||||
# gateway with bearer auth + audit log. Per-namespace endpoints
|
# gateway with bearer auth + audit log. Each namespace lives
|
||||||
# at `/mcp/<name>/`; flat bundle at `/mcp/all/`. Discovery page
|
# at `/<name>/` on this port (the port itself disambiguates
|
||||||
# (HTML, auth-gated) at `/` with copy-pastable Cursor / Claude
|
# MCP traffic — no extra `/mcp` segment in the route); a flat
|
||||||
|
# bundle is published at `/all/`. Discovery page (HTML,
|
||||||
|
# auth-gated) at `/` with copy-pastable Cursor / Claude
|
||||||
# Desktop snippets. Auth re-uses `BOOTSTRAP_TOKENS`.
|
# Desktop snippets. Auth re-uses `BOOTSTRAP_TOKENS`.
|
||||||
|
#
|
||||||
|
# Same `public_base_url=` knob as above. Caddy strips its
|
||||||
|
# prefix; the frontend's `/<name>/` segment gets appended:
|
||||||
|
# Caddy: handle_path /mcp/* { reverse_proxy localhost:8001 }
|
||||||
|
# Config: McpServerFrontend(
|
||||||
|
# port=8001,
|
||||||
|
# public_base_url="https://domain.com/mcp")
|
||||||
|
# Result: https://domain.com/mcp/<name>/ (and /mcp/all/)
|
||||||
McpServerFrontend(host="0.0.0.0", port=8001),
|
McpServerFrontend(host="0.0.0.0", port=8001),
|
||||||
|
# Phase 4.3 — browser admin UI. Creds come from
|
||||||
|
# `ADMIN_USER`/`ADMIN_PASS`; the session cookie is signed with
|
||||||
|
# `SESSION_SECRET`. Use it to mint tokens (Argon2-hashed in
|
||||||
|
# the DB), revoke them, and watch the audit log. Scope is
|
||||||
|
# enforced on the bearer frontends: tokens minted with scope
|
||||||
|
# `messages` only work on `/v1/messages`; `mcp` only on
|
||||||
|
# `/mcp/<name>`; `*` works everywhere.
|
||||||
|
AdminFrontend(host="0.0.0.0", port=8002),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -24,6 +24,11 @@ services:
|
|||||||
# config.py declares one, so set these (or remove the agent)
|
# config.py declares one, so set these (or remove the agent)
|
||||||
# before exposing port 8000.
|
# before exposing port 8000.
|
||||||
ports:
|
ports:
|
||||||
|
# /v1/messages frontend
|
||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
|
# MCP server frontend
|
||||||
|
- "8001:8001"
|
||||||
|
# Admin UI (Phase 4.3) — change ADMIN_USER/ADMIN_PASS/SESSION_SECRET
|
||||||
|
- "8002:8002"
|
||||||
volumes:
|
volumes:
|
||||||
- ./config.py:/config/config.py:ro
|
- ./config.py:/config/config.py:ro
|
||||||
|
|||||||
@@ -8,11 +8,13 @@ authors = [
|
|||||||
requires-python = ">=3.13"
|
requires-python = ">=3.13"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aiohttp>=3.13.5",
|
"aiohttp>=3.13.5",
|
||||||
|
"aiosqlite>=0.22.1",
|
||||||
"anthropic>=0.103.0",
|
"anthropic>=0.103.0",
|
||||||
"anyio>=4.13.0",
|
"anyio>=4.13.0",
|
||||||
"argon2-cffi>=25.1.0",
|
"argon2-cffi>=25.1.0",
|
||||||
"fastapi>=0.136.1",
|
"fastapi>=0.136.1",
|
||||||
"fastmcp>=3.3.1",
|
"fastmcp>=3.3.1",
|
||||||
|
"greenlet>=3.5.0",
|
||||||
"itsdangerous>=2.2.0",
|
"itsdangerous>=2.2.0",
|
||||||
"jinja2>=3.1.6",
|
"jinja2>=3.1.6",
|
||||||
"psycopg[binary]>=3.3.4",
|
"psycopg[binary]>=3.3.4",
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"venvPath": ".",
|
||||||
|
"venv": ".venv",
|
||||||
|
"pythonVersion": "3.13",
|
||||||
|
"include": ["src"],
|
||||||
|
"exclude": [".venv", "**/__pycache__", ".pytest_cache"],
|
||||||
|
"extraPaths": ["."],
|
||||||
|
"reportMissingImports": "error",
|
||||||
|
"reportMissingTypeStubs": "none"
|
||||||
|
}
|
||||||
@@ -40,6 +40,7 @@ from beaver_gateway.core.registry import AgentRegistry, McpRegistry
|
|||||||
from beaver_gateway.frontends.base import GatewayRuntime
|
from beaver_gateway.frontends.base import GatewayRuntime
|
||||||
from beaver_gateway.mcp.internal_app import build_internal_app
|
from beaver_gateway.mcp.internal_app import build_internal_app
|
||||||
from beaver_gateway.settings import Settings
|
from beaver_gateway.settings import Settings
|
||||||
|
from beaver_gateway.storage import Database
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from starlette.applications import Starlette
|
from starlette.applications import Starlette
|
||||||
@@ -65,9 +66,24 @@ async def _async_main() -> None:
|
|||||||
|
|
||||||
agents = AgentRegistry(gateway.agents)
|
agents = AgentRegistry(gateway.agents)
|
||||||
mcps = McpRegistry(gateway.mcps)
|
mcps = McpRegistry(gateway.mcps)
|
||||||
token_store = TokenStore.from_env(settings.bootstrap_tokens)
|
|
||||||
|
# Phase 4.1 — open the async DB and run create_all once. Engine
|
||||||
|
# pool is process-wide; ``dispose()`` after the TaskGroup unwinds.
|
||||||
|
db = Database(settings.database_url)
|
||||||
|
await db.create_all()
|
||||||
|
|
||||||
|
# Phase 4.2 — TokenStore now reads from the DB (in-memory cache
|
||||||
|
# primed at start, TTL-refreshed, last_used_at flushed by a
|
||||||
|
# background task). BOOTSTRAP_TOKENS layers on top so first-run /
|
||||||
|
# examples still work without DB writes.
|
||||||
|
token_store = TokenStore(
|
||||||
|
db, bootstrap=TokenStore.parse_bootstrap(settings.bootstrap_tokens)
|
||||||
|
)
|
||||||
|
|
||||||
async with AsyncExitStack() as stack:
|
async with AsyncExitStack() as stack:
|
||||||
|
stack.push_async_callback(db.dispose)
|
||||||
|
await token_store.start()
|
||||||
|
stack.push_async_callback(token_store.stop)
|
||||||
# Internal MCP URLs must exist before we construct any
|
# Internal MCP URLs must exist before we construct any
|
||||||
# ClaudeCodeBackendAdapter — adapters bake the URLs into their
|
# ClaudeCodeBackendAdapter — adapters bake the URLs into their
|
||||||
# ``BackendOptions.mcp_servers`` at construction time.
|
# ``BackendOptions.mcp_servers`` at construction time.
|
||||||
@@ -87,7 +103,12 @@ async def _async_main() -> None:
|
|||||||
mcps=mcps,
|
mcps=mcps,
|
||||||
backends=backends,
|
backends=backends,
|
||||||
token_store=token_store,
|
token_store=token_store,
|
||||||
|
db=db,
|
||||||
mcp_internal_urls=internal_urls,
|
mcp_internal_urls=internal_urls,
|
||||||
|
admin_user=settings.admin_user,
|
||||||
|
admin_pass=settings.admin_pass,
|
||||||
|
session_secret=settings.session_secret,
|
||||||
|
frontends=tuple(gateway.frontends),
|
||||||
)
|
)
|
||||||
|
|
||||||
for fe in gateway.frontends:
|
for fe in gateway.frontends:
|
||||||
@@ -115,9 +136,7 @@ async def _async_main() -> None:
|
|||||||
|
|
||||||
async with asyncio.TaskGroup() as tg:
|
async with asyncio.TaskGroup() as tg:
|
||||||
if internal_app is not None:
|
if internal_app is not None:
|
||||||
tg.create_task(
|
tg.create_task(_serve_internal_mcp(internal_app, settings=settings))
|
||||||
_serve_internal_mcp(internal_app, settings=settings)
|
|
||||||
)
|
|
||||||
for fe in gateway.frontends:
|
for fe in gateway.frontends:
|
||||||
tg.create_task(fe.serve())
|
tg.create_task(fe.serve())
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
"""Single entry point for writing :class:`AuditLog` rows.
|
||||||
|
|
||||||
|
Every frontend ends up needing the same three-line pattern — open a DB
|
||||||
|
session, append a row, swallow failures so the user-visible request
|
||||||
|
still succeeds. Phase 4.3 inlined that pattern in the admin frontend
|
||||||
|
under a private ``_audit()`` helper; Phase 4.4 lifts it here so the
|
||||||
|
Messages and MCP frontends can call the same function and so the
|
||||||
|
swallow-and-log policy lives in one place.
|
||||||
|
|
||||||
|
The contract:
|
||||||
|
|
||||||
|
* ``log(runtime, actor=..., kind=...)`` is fire-and-forget. It awaits
|
||||||
|
the DB write (so callers can ``await`` it before responding and get
|
||||||
|
ordering), but never raises — if the audit insert fails, the function
|
||||||
|
emits an ``exception`` log line and returns.
|
||||||
|
* ``actor`` is a free-form string. By convention: ``"token:<name>"``
|
||||||
|
for bearer-authenticated traffic, ``"admin:<user>"`` for admin-UI
|
||||||
|
actions, ``"anon"`` for failed-auth paths we still want to record.
|
||||||
|
* ``kind`` is a short tag — see :data:`KNOWN_KINDS` for the set the
|
||||||
|
current frontends emit; new tags don't need a code change here, the
|
||||||
|
column is free-form.
|
||||||
|
* ``**detail`` is JSON-serialised by :func:`append_audit`. Keep it
|
||||||
|
small: paths, methods, status codes — not request bodies. Anything
|
||||||
|
passed here lands in ``AuditLog.detail_json`` verbatim.
|
||||||
|
|
||||||
|
Why a thin wrapper rather than ``append_audit`` directly: callers want
|
||||||
|
"write if you can, otherwise carry on", and pulling the try/except into
|
||||||
|
every frontend was already starting to drift (admin had it, bearer
|
||||||
|
frontends would have copy-pasted). One module, one policy.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
from beaver_gateway.storage import append_audit
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from beaver_gateway.frontends.base import GatewayRuntime
|
||||||
|
|
||||||
|
|
||||||
|
_log = logging.getLogger("beaver_gateway.audit")
|
||||||
|
|
||||||
|
|
||||||
|
# Tags currently emitted by the gateway. The set is informational —
|
||||||
|
# ``AuditLog.kind`` is free-form so new code can introduce new tags
|
||||||
|
# without touching this list — but listing them here gives the admin UI
|
||||||
|
# and any downstream log consumers one canonical reference.
|
||||||
|
KNOWN_KINDS: frozenset[str] = frozenset(
|
||||||
|
{
|
||||||
|
"messages", # POST /v1/messages accepted
|
||||||
|
"mcp_call", # /mcp/<ns>/... proxied
|
||||||
|
"login_ok",
|
||||||
|
"login_failed",
|
||||||
|
"logout",
|
||||||
|
"token_create",
|
||||||
|
"token_revoke",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def log(
|
||||||
|
runtime: GatewayRuntime,
|
||||||
|
*,
|
||||||
|
actor: str,
|
||||||
|
kind: str,
|
||||||
|
agent_name: str | None = None,
|
||||||
|
**detail: Any,
|
||||||
|
) -> None:
|
||||||
|
"""Best-effort audit insert. Never raises.
|
||||||
|
|
||||||
|
Opens its own short-lived :class:`AsyncSession` so callers don't
|
||||||
|
have to thread one through. If the DB hiccups (table missing,
|
||||||
|
disk full, connection drop), we log and move on — the audit trail
|
||||||
|
is observability, not a hard precondition for serving the request.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
async with runtime.db.session() as session:
|
||||||
|
await append_audit(
|
||||||
|
session,
|
||||||
|
actor=actor,
|
||||||
|
kind=kind,
|
||||||
|
agent_name=agent_name,
|
||||||
|
detail=detail or None,
|
||||||
|
)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
_log.exception(
|
||||||
|
"audit write failed: actor=%s kind=%s agent=%s", actor, kind, agent_name
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["KNOWN_KINDS", "log"]
|
||||||
+305
-36
@@ -1,63 +1,195 @@
|
|||||||
"""Bearer-token verification (Phase 1.3 — in-memory only).
|
"""Bearer-token verification (Phase 4.2 — DB-backed with in-memory cache).
|
||||||
|
|
||||||
Phase 4 will replace this with a DB-backed store (PRD §8 ``tokens``
|
The store is fed by two sources:
|
||||||
table, Argon2 hashes, scopes, ``last_used_at`` batching). Until then,
|
|
||||||
frontends authenticate callers against an in-memory ``{value: name}``
|
|
||||||
dict seeded from the ``BOOTSTRAP_TOKENS`` env var.
|
|
||||||
|
|
||||||
Format::
|
1. **DB** (``Token`` table from Phase 4.1) — the primary source. Rows
|
||||||
|
carry Argon2id hashes; the admin UI (Phase 4.3) will be the only
|
||||||
|
writer at steady state.
|
||||||
|
2. **`BOOTSTRAP_TOKENS`** env — a name→plaintext map kept around for
|
||||||
|
first-run, disaster-recovery, and ``examples/`` smoke tests. These
|
||||||
|
entries live alongside DB rows in the cache and are never persisted.
|
||||||
|
|
||||||
BOOTSTRAP_TOKENS=cursor:s3cret,laptop:hunter2
|
Hot path is in-memory: at :meth:`start` we pull every non-revoked DB
|
||||||
|
row and stash it in a list; subsequent :meth:`verify` calls re-load
|
||||||
|
when the cache is older than ``ttl_seconds``. ``last_used_at`` updates
|
||||||
|
are coalesced into a small dict and flushed by a background task every
|
||||||
|
``flush_interval`` seconds — one transaction per flush rather than one
|
||||||
|
per request.
|
||||||
|
|
||||||
The *name* side is for audit lines — :py:meth:`TokenStore.verify`
|
We can't index DB rows by a derived plaintext key because Argon2 salts
|
||||||
returns it on hit so callers can attribute the request without
|
are random — so verify does a linear scan over candidates, calling
|
||||||
exposing the raw secret. ``None`` means "no such token"; callers
|
``argon2.PasswordHasher.verify`` on each. N is small by design (single
|
||||||
turn that into 401.
|
operator, ~10 tokens at most); the cost is irrelevant. The scan runs
|
||||||
|
through ``asyncio.to_thread`` to keep the event loop free of the ~50ms
|
||||||
|
KDF block.
|
||||||
|
|
||||||
This module deliberately knows nothing about HTTP frameworks — it
|
The module knows nothing about HTTP frameworks. It takes a raw token
|
||||||
takes a raw token (or the verbatim ``Authorization`` header value)
|
(or a verbatim ``Authorization`` header value) and returns a
|
||||||
and returns a name-or-None. Frontends own the response shape.
|
:class:`TokenIdentity` (name + scope + db-id), or ``None`` for a miss.
|
||||||
|
Frontends own the 401 response shape.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import contextlib
|
||||||
|
import hmac
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import UTC, datetime
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from argon2 import PasswordHasher
|
||||||
|
from argon2.exceptions import InvalidHashError, VerifyMismatchError
|
||||||
|
|
||||||
|
from beaver_gateway.storage import list_active_tokens, touch_token
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
|
|
||||||
|
from beaver_gateway.storage import Database
|
||||||
|
|
||||||
|
|
||||||
|
_log = logging.getLogger("beaver_gateway.auth")
|
||||||
|
|
||||||
|
_BOOTSTRAP_SCOPE = "*"
|
||||||
|
|
||||||
|
|
||||||
class TokenStoreError(ValueError):
|
class TokenStoreError(ValueError):
|
||||||
"""Malformed ``BOOTSTRAP_TOKENS`` value."""
|
"""Malformed ``BOOTSTRAP_TOKENS`` value or duplicate token."""
|
||||||
|
|
||||||
|
|
||||||
|
VALID_SCOPES: frozenset[str] = frozenset({"*", "messages", "mcp", "admin"})
|
||||||
|
"""The scopes a ``Token.scope`` may hold (Phase 4.3 admin UI enforces).
|
||||||
|
|
||||||
|
* ``*`` — wildcard, may use any frontend
|
||||||
|
* ``messages`` — Anthropic Messages frontend only
|
||||||
|
* ``mcp`` — MCP server frontend only
|
||||||
|
* ``admin`` — reserved for programmatic admin access; the AdminFrontend
|
||||||
|
itself authenticates via session cookies, not bearer tokens, so this
|
||||||
|
scope is unused today and kept for forward compatibility.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class TokenIdentity:
|
||||||
|
"""What :meth:`TokenStore.verify` resolves to on success.
|
||||||
|
|
||||||
|
``token_id`` is the DB row id for persisted tokens, or ``None`` for
|
||||||
|
an env-bootstrap match (those have no DB row to touch). ``scope``
|
||||||
|
gates which frontend the token may hit (see :data:`VALID_SCOPES`);
|
||||||
|
bootstrap tokens implicitly get ``"*"``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
scope: str
|
||||||
|
token_id: int | None
|
||||||
|
|
||||||
|
def allows(self, required: str) -> bool:
|
||||||
|
"""``True`` when this identity may access a route gated by ``required``.
|
||||||
|
|
||||||
|
``"*"`` is the wildcard; an exact match satisfies a single scope.
|
||||||
|
Unknown ``required`` values intentionally fall through to a
|
||||||
|
strict equality check — callers should pass one of
|
||||||
|
:data:`VALID_SCOPES`.
|
||||||
|
"""
|
||||||
|
return self.scope in ("*", required)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class _CachedToken:
|
||||||
|
"""One non-revoked row, copied out of the DB into the hot-path cache."""
|
||||||
|
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
scope: str
|
||||||
|
hashed_value: str
|
||||||
|
|
||||||
|
|
||||||
|
# Default Argon2id parameters from ``argon2-cffi`` are fine for our scope.
|
||||||
|
# They target ~50ms on a modern CPU — enough to make a stolen-hash brute
|
||||||
|
# force expensive, cheap enough to verify a handful per request.
|
||||||
|
_HASHER = PasswordHasher()
|
||||||
|
|
||||||
|
|
||||||
|
def hash_token(plaintext: str) -> str:
|
||||||
|
"""Return an Argon2id hash for ``plaintext`` (admin / seed-only path).
|
||||||
|
|
||||||
|
Phase 4.3 will call this when the admin creates a token; Phase 4.2
|
||||||
|
exposes it so smoke scripts can seed the DB without re-implementing
|
||||||
|
the same line.
|
||||||
|
"""
|
||||||
|
return _HASHER.hash(plaintext)
|
||||||
|
|
||||||
|
|
||||||
class TokenStore:
|
class TokenStore:
|
||||||
"""Constant-time-ish bearer verification over a static name→value map.
|
"""DB-backed verifier with in-memory cache + TTL + batched touches.
|
||||||
|
|
||||||
The store is keyed by value internally (``verify`` lookup is O(1))
|
Construct in ``cli.main`` after :class:`Database` is up, then
|
||||||
but constructed from a name→value mapping because that's how the
|
``await store.start()`` to prime the cache and spin up the flusher
|
||||||
user thinks about it — one human-readable label per caller.
|
task. ``await store.stop()`` on shutdown drains the touch queue.
|
||||||
|
|
||||||
|
Bootstrap entries (from ``BOOTSTRAP_TOKENS``) sit alongside DB rows
|
||||||
|
in the same lookup path; we check them first, in constant time, so
|
||||||
|
they remain usable even if the DB is unreachable. They never appear
|
||||||
|
in ``last_used_at`` flushes because they have no DB row.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__slots__ = ("_by_value",)
|
__slots__ = (
|
||||||
|
"_bootstrap_by_value",
|
||||||
|
"_bootstrap_scopes",
|
||||||
|
"_cache",
|
||||||
|
"_db",
|
||||||
|
"_flush_interval",
|
||||||
|
"_flusher_task",
|
||||||
|
"_loaded_at",
|
||||||
|
"_lock",
|
||||||
|
"_touch_queue",
|
||||||
|
"_ttl",
|
||||||
|
)
|
||||||
|
|
||||||
def __init__(self, tokens: Mapping[str, str]) -> None:
|
def __init__(
|
||||||
|
self,
|
||||||
|
db: Database | None = None,
|
||||||
|
*,
|
||||||
|
bootstrap: Mapping[str, str] | None = None,
|
||||||
|
ttl_seconds: float = 30.0,
|
||||||
|
flush_interval: float = 5.0,
|
||||||
|
) -> None:
|
||||||
|
# Bootstrap is keyed by value internally so verify is O(1) over
|
||||||
|
# plaintext. Each value also keeps its name for audit lines.
|
||||||
by_value: dict[str, str] = {}
|
by_value: dict[str, str] = {}
|
||||||
for name, value in tokens.items():
|
for name, value in (bootstrap or {}).items():
|
||||||
if not name or not value:
|
if not name or not value:
|
||||||
msg = f"empty name or value in token map (name={name!r})"
|
msg = f"empty name or value in bootstrap map (name={name!r})"
|
||||||
raise TokenStoreError(msg)
|
raise TokenStoreError(msg)
|
||||||
if value in by_value:
|
if value in by_value:
|
||||||
msg = (
|
msg = (
|
||||||
f"duplicate token value for names "
|
f"duplicate bootstrap token value for names "
|
||||||
f"{by_value[value]!r} and {name!r}"
|
f"{by_value[value]!r} and {name!r}"
|
||||||
)
|
)
|
||||||
raise TokenStoreError(msg)
|
raise TokenStoreError(msg)
|
||||||
by_value[value] = name
|
by_value[value] = name
|
||||||
self._by_value = by_value
|
self._bootstrap_by_value: dict[str, str] = by_value
|
||||||
|
self._bootstrap_scopes: dict[str, str] = dict.fromkeys(
|
||||||
|
by_value.values(), _BOOTSTRAP_SCOPE
|
||||||
|
)
|
||||||
|
|
||||||
@classmethod
|
self._db = db
|
||||||
def from_env(cls, raw: str) -> TokenStore:
|
self._ttl = ttl_seconds
|
||||||
|
self._flush_interval = flush_interval
|
||||||
|
|
||||||
|
self._cache: list[_CachedToken] = []
|
||||||
|
self._loaded_at: float = 0.0
|
||||||
|
self._lock = asyncio.Lock()
|
||||||
|
self._touch_queue: dict[int, datetime] = {}
|
||||||
|
self._flusher_task: asyncio.Task[None] | None = None
|
||||||
|
|
||||||
|
# ---- bootstrap parsing (kept for `cli` / tests) ---------------------
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def parse_bootstrap(raw: str) -> dict[str, str]:
|
||||||
"""Parse ``name1:value1,name2:value2`` (the ``BOOTSTRAP_TOKENS`` form)."""
|
"""Parse ``name1:value1,name2:value2`` (the ``BOOTSTRAP_TOKENS`` form)."""
|
||||||
tokens: dict[str, str] = {}
|
tokens: dict[str, str] = {}
|
||||||
for chunk in raw.split(","):
|
for chunk in raw.split(","):
|
||||||
@@ -73,15 +205,82 @@ class TokenStore:
|
|||||||
msg = f"duplicate token name: {name!r}"
|
msg = f"duplicate token name: {name!r}"
|
||||||
raise TokenStoreError(msg)
|
raise TokenStoreError(msg)
|
||||||
tokens[name] = value
|
tokens[name] = value
|
||||||
return cls(tokens)
|
return tokens
|
||||||
|
|
||||||
def verify(self, token: str | None) -> str | None:
|
@classmethod
|
||||||
"""Return the token's name if known, else ``None``."""
|
def from_env(cls, raw: str, db: Database | None = None) -> TokenStore:
|
||||||
|
"""Legacy entrypoint: bootstrap-only (or bootstrap + db).
|
||||||
|
|
||||||
|
Phase 1.3 call sites still expect a one-liner; we keep the
|
||||||
|
classmethod so they don't have to learn the new constructor.
|
||||||
|
"""
|
||||||
|
return cls(db, bootstrap=cls.parse_bootstrap(raw))
|
||||||
|
|
||||||
|
# ---- lifecycle ------------------------------------------------------
|
||||||
|
|
||||||
|
async def start(self) -> None:
|
||||||
|
"""Prime the cache and (if a DB is attached) start the flusher loop."""
|
||||||
|
await self._refresh()
|
||||||
|
if self._db is not None:
|
||||||
|
self._flusher_task = asyncio.create_task(
|
||||||
|
self._flusher_loop(), name="beaver-gateway.token-flusher"
|
||||||
|
)
|
||||||
|
|
||||||
|
async def stop(self) -> None:
|
||||||
|
"""Cancel the flusher and run one final drain."""
|
||||||
|
if self._flusher_task is not None:
|
||||||
|
self._flusher_task.cancel()
|
||||||
|
with contextlib.suppress(asyncio.CancelledError):
|
||||||
|
await self._flusher_task
|
||||||
|
self._flusher_task = None
|
||||||
|
await self._flush_now()
|
||||||
|
|
||||||
|
async def invalidate(self) -> None:
|
||||||
|
"""Force the next verify to re-read from DB (Phase 4.3 admin hook)."""
|
||||||
|
self._loaded_at = 0.0
|
||||||
|
|
||||||
|
# ---- verify path ----------------------------------------------------
|
||||||
|
|
||||||
|
async def verify(self, token: str | None) -> TokenIdentity | None:
|
||||||
|
"""Return the matching identity, or ``None`` for unknown/empty tokens."""
|
||||||
if not token:
|
if not token:
|
||||||
return None
|
return None
|
||||||
return self._by_value.get(token)
|
|
||||||
|
|
||||||
def verify_bearer(self, authorization: str | None) -> str | None:
|
# Bootstrap first: constant-time compare per entry, never hits DB.
|
||||||
|
# `compare_digest` is overkill for a name→value lookup but cheap
|
||||||
|
# and removes one timing variable for free.
|
||||||
|
for value, name in self._bootstrap_by_value.items():
|
||||||
|
if hmac.compare_digest(token, value):
|
||||||
|
return TokenIdentity(
|
||||||
|
name=name,
|
||||||
|
scope=self._bootstrap_scopes.get(name, _BOOTSTRAP_SCOPE),
|
||||||
|
token_id=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
if self._db is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
await self._ensure_fresh()
|
||||||
|
|
||||||
|
# Snapshot the cache reference so a refresh mid-scan doesn't
|
||||||
|
# surprise us. List itself is immutable per refresh (we swap,
|
||||||
|
# not mutate).
|
||||||
|
cache = self._cache
|
||||||
|
for entry in cache:
|
||||||
|
try:
|
||||||
|
await asyncio.to_thread(_HASHER.verify, entry.hashed_value, token)
|
||||||
|
except VerifyMismatchError:
|
||||||
|
continue
|
||||||
|
except InvalidHashError:
|
||||||
|
_log.warning(
|
||||||
|
"token row %d has an unparseable hash — skipping", entry.id
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
self._touch_queue[entry.id] = datetime.now(UTC)
|
||||||
|
return TokenIdentity(name=entry.name, scope=entry.scope, token_id=entry.id)
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def verify_bearer(self, authorization: str | None) -> TokenIdentity | None:
|
||||||
"""Strip the ``Bearer`` prefix (case-insensitive) then verify.
|
"""Strip the ``Bearer`` prefix (case-insensitive) then verify.
|
||||||
|
|
||||||
Accepts a bare token too — Cursor's MCP transport sometimes
|
Accepts a bare token too — Cursor's MCP transport sometimes
|
||||||
@@ -93,13 +292,83 @@ class TokenStore:
|
|||||||
return None
|
return None
|
||||||
head, sep, rest = authorization.partition(" ")
|
head, sep, rest = authorization.partition(" ")
|
||||||
token = rest.strip() if sep and head.lower() == "bearer" else authorization
|
token = rest.strip() if sep and head.lower() == "bearer" else authorization
|
||||||
return self.verify(token)
|
return await self.verify(token)
|
||||||
|
|
||||||
def __len__(self) -> int:
|
def __len__(self) -> int:
|
||||||
return len(self._by_value)
|
return len(self._cache) + len(self._bootstrap_by_value)
|
||||||
|
|
||||||
def __bool__(self) -> bool:
|
def __bool__(self) -> bool:
|
||||||
return bool(self._by_value)
|
return bool(self._cache) or bool(self._bootstrap_by_value)
|
||||||
|
|
||||||
|
# ---- internals ------------------------------------------------------
|
||||||
|
|
||||||
|
async def _ensure_fresh(self) -> None:
|
||||||
|
if self._db is None:
|
||||||
|
return
|
||||||
|
now = time.monotonic()
|
||||||
|
if now - self._loaded_at <= self._ttl:
|
||||||
|
return
|
||||||
|
async with self._lock:
|
||||||
|
# Re-check under the lock — first arrival reloaded, others
|
||||||
|
# should fall through.
|
||||||
|
now = time.monotonic()
|
||||||
|
if now - self._loaded_at <= self._ttl:
|
||||||
|
return
|
||||||
|
await self._refresh()
|
||||||
|
|
||||||
|
async def _refresh(self) -> None:
|
||||||
|
if self._db is None:
|
||||||
|
self._loaded_at = time.monotonic()
|
||||||
|
return
|
||||||
|
async with self._db.session() as session:
|
||||||
|
rows = await list_active_tokens(session)
|
||||||
|
next_cache: list[_CachedToken] = []
|
||||||
|
for row in rows:
|
||||||
|
if row.id is None:
|
||||||
|
# Defensive: SQLModel will assign an id on insert; a
|
||||||
|
# None here would mean someone handed us an unsaved row.
|
||||||
|
continue
|
||||||
|
next_cache.append(
|
||||||
|
_CachedToken(
|
||||||
|
id=row.id,
|
||||||
|
name=row.name,
|
||||||
|
scope=row.scope,
|
||||||
|
hashed_value=row.hashed_value,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self._cache = next_cache
|
||||||
|
self._loaded_at = time.monotonic()
|
||||||
|
_log.debug("token cache refreshed: %d active row(s)", len(next_cache))
|
||||||
|
|
||||||
|
async def _flusher_loop(self) -> None:
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
await asyncio.sleep(self._flush_interval)
|
||||||
|
await self._flush_now()
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception: # noqa: BLE001 — never let the flusher die silently
|
||||||
|
_log.exception("token flusher crashed; touches will stop")
|
||||||
|
|
||||||
|
async def _flush_now(self) -> None:
|
||||||
|
if self._db is None or not self._touch_queue:
|
||||||
|
return
|
||||||
|
# Detach the queue so concurrent verify() writes don't bleed
|
||||||
|
# into the in-flight transaction.
|
||||||
|
pending, self._touch_queue = self._touch_queue, {}
|
||||||
|
async with self._db.session() as session:
|
||||||
|
for token_id in pending:
|
||||||
|
# We don't pass the timestamp through — `touch_token`
|
||||||
|
# stamps `now` itself, and we'd rather have one source
|
||||||
|
# of truth than reconcile clocks.
|
||||||
|
await touch_token(session, token_id=token_id)
|
||||||
|
_log.debug("flushed %d token touch(es)", len(pending))
|
||||||
|
|
||||||
|
|
||||||
__all__ = ["TokenStore", "TokenStoreError"]
|
__all__ = [
|
||||||
|
"VALID_SCOPES",
|
||||||
|
"TokenIdentity",
|
||||||
|
"TokenStore",
|
||||||
|
"TokenStoreError",
|
||||||
|
"hash_token",
|
||||||
|
]
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
"""Admin UI (Phase 4.3).
|
||||||
|
|
||||||
|
Browser-facing console: login, dashboard, token CRUD, audit viewer.
|
||||||
|
Templates live in ``./templates``; the package loader picks them up via
|
||||||
|
``importlib.resources`` so the layout works editable and inside the
|
||||||
|
Docker image without copy hacks.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from beaver_gateway.frontends.admin.frontend import AdminFrontend
|
||||||
|
|
||||||
|
__all__ = ["AdminFrontend"]
|
||||||
@@ -0,0 +1,707 @@
|
|||||||
|
"""Single-operator admin console.
|
||||||
|
|
||||||
|
Jinja2 + HTMX, no SPA framework. Session cookies signed with
|
||||||
|
``SESSION_SECRET`` via ``itsdangerous`` (8h TTL). CSRF is double-submit:
|
||||||
|
the signed session payload carries a random token that must echo back
|
||||||
|
on every state-changing request as a ``csrf_token`` form field.
|
||||||
|
|
||||||
|
What lives in here:
|
||||||
|
|
||||||
|
* ``GET /login`` / ``POST /login`` — env-cred check (``ADMIN_USER`` /
|
||||||
|
``ADMIN_PASS`` compared with :func:`hmac.compare_digest`).
|
||||||
|
* ``POST /logout`` — clears the cookie.
|
||||||
|
* ``GET /`` — dashboard: declared agents + MCP namespaces + the last
|
||||||
|
audit slice.
|
||||||
|
* ``GET /tokens`` + ``POST /tokens`` + ``POST /tokens/{id}/revoke`` —
|
||||||
|
bearer-token CRUD. Plaintext is rendered exactly once at creation; the
|
||||||
|
DB only ever holds the Argon2 hash. HTMX fragments swap rows in place.
|
||||||
|
* ``GET /audit`` — paginated audit list (id-cursor).
|
||||||
|
|
||||||
|
Things the admin frontend is *not* responsible for: HTTP-token verify
|
||||||
|
on ``/v1/messages`` and ``/mcp`` — that lives on those frontends and
|
||||||
|
uses :class:`TokenStore`. Admin's only authentication path is the
|
||||||
|
cookie session.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import secrets
|
||||||
|
import time
|
||||||
|
from collections import deque
|
||||||
|
from typing import TYPE_CHECKING, Annotated, Any
|
||||||
|
|
||||||
|
import itsdangerous
|
||||||
|
from fastapi import FastAPI, Form, HTTPException, Request, status
|
||||||
|
from fastapi.responses import (
|
||||||
|
HTMLResponse,
|
||||||
|
RedirectResponse,
|
||||||
|
Response,
|
||||||
|
StreamingResponse,
|
||||||
|
)
|
||||||
|
from jinja2 import Environment, PackageLoader, select_autoescape
|
||||||
|
|
||||||
|
from beaver_gateway.core import audit
|
||||||
|
from beaver_gateway.core.auth import VALID_SCOPES, hash_token
|
||||||
|
from beaver_gateway.frontends.base import Frontend
|
||||||
|
from beaver_gateway.storage import (
|
||||||
|
create_token,
|
||||||
|
list_audit_records,
|
||||||
|
list_tokens,
|
||||||
|
revoke_token,
|
||||||
|
)
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from beaver_gateway.core.events import MessageStreamEvent
|
||||||
|
from beaver_gateway.frontends.base import GatewayRuntime
|
||||||
|
|
||||||
|
|
||||||
|
_log = logging.getLogger("beaver_gateway.frontends.admin")
|
||||||
|
|
||||||
|
# Cookie name is namespaced so a user can run multiple gateway instances
|
||||||
|
# behind one host header without sessions colliding. ``_v1`` lets us
|
||||||
|
# bump the payload schema later by invalidating old cookies via salt.
|
||||||
|
SESSION_COOKIE = "beaver_admin_session"
|
||||||
|
SESSION_MAX_AGE = 8 * 3600
|
||||||
|
SESSION_SALT = "beaver-gateway.admin.session.v1"
|
||||||
|
|
||||||
|
# Scopes the admin is allowed to mint. ``admin`` is reserved (no bearer
|
||||||
|
# route consumes it yet, but listing it keeps future programmatic
|
||||||
|
# admin access first-class) — see ``core.auth.VALID_SCOPES``.
|
||||||
|
CREATABLE_SCOPES = ("*", "messages", "mcp", "admin")
|
||||||
|
|
||||||
|
# Audit-log page size — also used by the dashboard's "recent activity"
|
||||||
|
# panel. Small enough to keep the dashboard cheap, big enough to be
|
||||||
|
# useful at a glance.
|
||||||
|
AUDIT_PAGE_SIZE = 50
|
||||||
|
|
||||||
|
# /login brute-force ceiling: 5 failed attempts per source IP within
|
||||||
|
# LOGIN_WINDOW_SECONDS shuts the door with a 429 until the oldest
|
||||||
|
# failure ages out. Single-operator admin, so a legit user only ever
|
||||||
|
# burns one IP — a forgiving window beats a tight one with a lockout.
|
||||||
|
LOGIN_MAX_ATTEMPTS = 5
|
||||||
|
LOGIN_WINDOW_SECONDS = 300.0
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["AdminFrontend"]
|
||||||
|
|
||||||
|
|
||||||
|
class AdminFrontend(Frontend):
|
||||||
|
"""FastAPI app behind ``/login`` / ``/tokens`` / ``/audit`` / ``/``."""
|
||||||
|
|
||||||
|
def __init__(self, *, host: str = "0.0.0.0", port: int = 8002) -> None: # noqa: S104
|
||||||
|
self.host = host
|
||||||
|
self.port = port
|
||||||
|
self._runtime: GatewayRuntime | None = None
|
||||||
|
self._app: FastAPI | None = None
|
||||||
|
|
||||||
|
def configure(self, runtime: GatewayRuntime) -> None:
|
||||||
|
# Refuse to wire up if the env didn't carry the bits we need —
|
||||||
|
# an admin UI with empty creds is a footgun, and an unsigned
|
||||||
|
# session cookie is no session at all.
|
||||||
|
if not runtime.session_secret:
|
||||||
|
msg = "AdminFrontend requires SESSION_SECRET in env"
|
||||||
|
raise RuntimeError(msg)
|
||||||
|
if not runtime.admin_user or not runtime.admin_pass:
|
||||||
|
msg = "AdminFrontend requires ADMIN_USER and ADMIN_PASS in env"
|
||||||
|
raise RuntimeError(msg)
|
||||||
|
self._runtime = runtime
|
||||||
|
self._app = self._build_app(runtime)
|
||||||
|
|
||||||
|
async def serve(self) -> None:
|
||||||
|
import uvicorn
|
||||||
|
|
||||||
|
if self._app is None:
|
||||||
|
msg = "configure() must be called before serve()"
|
||||||
|
raise RuntimeError(msg)
|
||||||
|
config = uvicorn.Config(
|
||||||
|
self._app, host=self.host, port=self.port, log_level="info"
|
||||||
|
)
|
||||||
|
await uvicorn.Server(config).serve()
|
||||||
|
|
||||||
|
# ---- app construction ----------------------------------------------
|
||||||
|
|
||||||
|
def _build_app(self, runtime: GatewayRuntime) -> FastAPI: # noqa: PLR0915
|
||||||
|
# Routes are declared inline so they close over ``runtime`` /
|
||||||
|
# ``signer`` / ``templates`` instead of threading them through
|
||||||
|
# self-state. Splitting the function would just trade size for
|
||||||
|
# bookkeeping — same total surface, harder to follow.
|
||||||
|
templates = _build_template_env()
|
||||||
|
signer = itsdangerous.URLSafeTimedSerializer(
|
||||||
|
runtime.session_secret, salt=SESSION_SALT
|
||||||
|
)
|
||||||
|
login_limit = _LoginRateLimit(
|
||||||
|
max_attempts=LOGIN_MAX_ATTEMPTS, window=LOGIN_WINDOW_SECONDS
|
||||||
|
)
|
||||||
|
app = FastAPI(title="beaver-gateway / admin", docs_url=None, redoc_url=None)
|
||||||
|
|
||||||
|
def render(name: str, **ctx: Any) -> str:
|
||||||
|
return templates.get_template(name).render(**ctx)
|
||||||
|
|
||||||
|
# ---- login ----
|
||||||
|
|
||||||
|
@app.get("/login", response_class=HTMLResponse)
|
||||||
|
async def login_page(request: Request) -> Response:
|
||||||
|
# Already-signed-in users skip the form. Login flash is
|
||||||
|
# carried in a one-shot query param so a failed POST can
|
||||||
|
# redirect back here without leaking creds in the URL.
|
||||||
|
if _current_user(request, signer):
|
||||||
|
return RedirectResponse("/", status_code=status.HTTP_303_SEE_OTHER)
|
||||||
|
error = request.query_params.get("error")
|
||||||
|
return HTMLResponse(render("login.html", error=error))
|
||||||
|
|
||||||
|
@app.post("/login", response_class=HTMLResponse)
|
||||||
|
async def login_submit(
|
||||||
|
request: Request,
|
||||||
|
username: Annotated[str, Form(...)],
|
||||||
|
password: Annotated[str, Form(...)],
|
||||||
|
) -> Response:
|
||||||
|
ip = _client_ip(request)
|
||||||
|
if not login_limit.check(ip):
|
||||||
|
_log.warning("admin login rate-limited: ip=%s user=%r", ip, username)
|
||||||
|
await audit.log(
|
||||||
|
runtime,
|
||||||
|
actor=f"admin:{username}",
|
||||||
|
kind="login_failed",
|
||||||
|
reason="rate_limited",
|
||||||
|
ip=ip,
|
||||||
|
)
|
||||||
|
# 429 carries the rendered form back inline so the user
|
||||||
|
# sees the same page with an error banner — no redirect
|
||||||
|
# roundtrip, and an explicit Retry-After for any well-
|
||||||
|
# behaved client (or Caddy doing its own bookkeeping).
|
||||||
|
return HTMLResponse(
|
||||||
|
render(
|
||||||
|
"login.html",
|
||||||
|
error="too many attempts; try again in a few minutes",
|
||||||
|
),
|
||||||
|
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||||
|
headers={"Retry-After": str(int(LOGIN_WINDOW_SECONDS))},
|
||||||
|
)
|
||||||
|
ok = hmac.compare_digest(
|
||||||
|
username, runtime.admin_user
|
||||||
|
) and hmac.compare_digest(password, runtime.admin_pass)
|
||||||
|
if not ok:
|
||||||
|
login_limit.record_failure(ip)
|
||||||
|
_log.info("admin login failed: user=%r ip=%s", username, ip)
|
||||||
|
await audit.log(
|
||||||
|
runtime,
|
||||||
|
actor=f"admin:{username}",
|
||||||
|
kind="login_failed",
|
||||||
|
reason="bad_credentials",
|
||||||
|
ip=ip,
|
||||||
|
)
|
||||||
|
# 303-redirect after POST so the browser doesn't replay
|
||||||
|
# the form on refresh. Status-303 forces GET on the
|
||||||
|
# follow-up regardless of the original method.
|
||||||
|
return RedirectResponse(
|
||||||
|
"/login?error=invalid+credentials",
|
||||||
|
status_code=status.HTTP_303_SEE_OTHER,
|
||||||
|
)
|
||||||
|
login_limit.clear(ip)
|
||||||
|
csrf = secrets.token_urlsafe(24)
|
||||||
|
cookie = signer.dumps({"user": username, "csrf": csrf})
|
||||||
|
response = RedirectResponse("/", status_code=status.HTTP_303_SEE_OTHER)
|
||||||
|
_set_session_cookie(response, cookie)
|
||||||
|
_log.info("admin login ok: user=%s ip=%s", username, ip)
|
||||||
|
await audit.log(runtime, actor=f"admin:{username}", kind="login_ok", ip=ip)
|
||||||
|
return response
|
||||||
|
|
||||||
|
@app.post("/logout")
|
||||||
|
async def logout(request: Request) -> Response:
|
||||||
|
session = _require_session(request, signer)
|
||||||
|
await _require_csrf(request, session)
|
||||||
|
response = RedirectResponse("/login", status_code=status.HTTP_303_SEE_OTHER)
|
||||||
|
response.delete_cookie(SESSION_COOKIE)
|
||||||
|
await audit.log(runtime, actor=f"admin:{session['user']}", kind="logout")
|
||||||
|
return response
|
||||||
|
|
||||||
|
# ---- dashboard ----
|
||||||
|
|
||||||
|
@app.get("/", response_class=HTMLResponse)
|
||||||
|
async def dashboard(request: Request) -> Response:
|
||||||
|
session = _require_session(request, signer)
|
||||||
|
async with runtime.db.session() as db_session:
|
||||||
|
audit = await list_audit_records(db_session, limit=AUDIT_PAGE_SIZE)
|
||||||
|
tokens = await list_tokens(db_session, include_revoked=False)
|
||||||
|
endpoints = _build_endpoint_catalog(request, runtime)
|
||||||
|
return HTMLResponse(
|
||||||
|
render(
|
||||||
|
"dashboard.html",
|
||||||
|
user=session["user"],
|
||||||
|
csrf=session["csrf"],
|
||||||
|
agents=list(runtime.agents),
|
||||||
|
mcps=list(runtime.mcps),
|
||||||
|
audit=audit,
|
||||||
|
tokens=tokens,
|
||||||
|
endpoints=endpoints,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---- tokens ----
|
||||||
|
|
||||||
|
@app.get("/tokens", response_class=HTMLResponse)
|
||||||
|
async def tokens_page(request: Request) -> Response:
|
||||||
|
session = _require_session(request, signer)
|
||||||
|
include_revoked = request.query_params.get("include_revoked") == "1"
|
||||||
|
async with runtime.db.session() as db_session:
|
||||||
|
tokens = await list_tokens(db_session, include_revoked=include_revoked)
|
||||||
|
return HTMLResponse(
|
||||||
|
render(
|
||||||
|
"tokens.html",
|
||||||
|
user=session["user"],
|
||||||
|
csrf=session["csrf"],
|
||||||
|
tokens=tokens,
|
||||||
|
scopes=CREATABLE_SCOPES,
|
||||||
|
include_revoked=include_revoked,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.post("/tokens", response_class=HTMLResponse)
|
||||||
|
async def tokens_create(
|
||||||
|
request: Request,
|
||||||
|
name: Annotated[str, Form(...)],
|
||||||
|
scope: Annotated[str, Form(...)],
|
||||||
|
) -> Response:
|
||||||
|
session = _require_session(request, signer)
|
||||||
|
await _require_csrf(request, session)
|
||||||
|
name = name.strip()
|
||||||
|
if not name:
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_400_BAD_REQUEST, "token name is required"
|
||||||
|
)
|
||||||
|
if scope not in VALID_SCOPES:
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_400_BAD_REQUEST, f"invalid scope: {scope!r}"
|
||||||
|
)
|
||||||
|
# Generate the plaintext server-side — clients never pick
|
||||||
|
# their own. URL-safe so it copies cleanly into ``.env``.
|
||||||
|
plaintext = secrets.token_urlsafe(32)
|
||||||
|
hashed = hash_token(plaintext)
|
||||||
|
async with runtime.db.session() as db_session:
|
||||||
|
try:
|
||||||
|
row = await create_token(
|
||||||
|
db_session, name=name, scope=scope, hashed_value=hashed
|
||||||
|
)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
# Likely UNIQUE(name) violation; the column is
|
||||||
|
# indexed so collisions are common. We surface a
|
||||||
|
# readable HTMX-friendly response rather than 500.
|
||||||
|
_log.warning("token create failed: %s", exc)
|
||||||
|
return HTMLResponse(
|
||||||
|
render(
|
||||||
|
"_token_error.html",
|
||||||
|
message=f"could not create token {name!r}: {exc}",
|
||||||
|
),
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
)
|
||||||
|
await runtime.token_store.invalidate()
|
||||||
|
await audit.log(
|
||||||
|
runtime,
|
||||||
|
actor=f"admin:{session['user']}",
|
||||||
|
kind="token_create",
|
||||||
|
name=name,
|
||||||
|
scope=scope,
|
||||||
|
token_id=row.id,
|
||||||
|
)
|
||||||
|
return HTMLResponse(
|
||||||
|
render(
|
||||||
|
"_token_created.html",
|
||||||
|
token=row,
|
||||||
|
plaintext=plaintext,
|
||||||
|
csrf=session["csrf"],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.post("/tokens/{token_id}/revoke", response_class=HTMLResponse)
|
||||||
|
async def tokens_revoke(request: Request, token_id: int) -> Response:
|
||||||
|
session = _require_session(request, signer)
|
||||||
|
await _require_csrf(request, session)
|
||||||
|
async with runtime.db.session() as db_session:
|
||||||
|
ok = await revoke_token(db_session, token_id=token_id)
|
||||||
|
if not ok:
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_404_NOT_FOUND, f"no active token with id {token_id}"
|
||||||
|
)
|
||||||
|
await runtime.token_store.invalidate()
|
||||||
|
await audit.log(
|
||||||
|
runtime,
|
||||||
|
actor=f"admin:{session['user']}",
|
||||||
|
kind="token_revoke",
|
||||||
|
token_id=token_id,
|
||||||
|
)
|
||||||
|
# Re-fetch so the row reflects the just-stamped revoked_at.
|
||||||
|
async with runtime.db.session() as db_session:
|
||||||
|
rows = await list_tokens(db_session, include_revoked=True)
|
||||||
|
row = next((r for r in rows if r.id == token_id), None)
|
||||||
|
if row is None:
|
||||||
|
# Shouldn't happen — revoke_token said ok — but stay
|
||||||
|
# honest: respond empty so the row disappears from the
|
||||||
|
# table.
|
||||||
|
return HTMLResponse("")
|
||||||
|
return HTMLResponse(
|
||||||
|
render("_token_row.html", token=row, csrf=session["csrf"])
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---- audit ----
|
||||||
|
|
||||||
|
@app.get("/audit", response_class=HTMLResponse)
|
||||||
|
async def audit_page(request: Request) -> Response:
|
||||||
|
session = _require_session(request, signer)
|
||||||
|
before_raw = request.query_params.get("before")
|
||||||
|
before_id: int | None = None
|
||||||
|
if before_raw and before_raw.isdigit():
|
||||||
|
before_id = int(before_raw)
|
||||||
|
async with runtime.db.session() as db_session:
|
||||||
|
rows = await list_audit_records(
|
||||||
|
db_session, limit=AUDIT_PAGE_SIZE, before_id=before_id
|
||||||
|
)
|
||||||
|
# Cursor for the "next page" link — the smallest id on this
|
||||||
|
# page; ``None`` if we've run out of rows.
|
||||||
|
next_before = rows[-1].id if rows and len(rows) == AUDIT_PAGE_SIZE else None
|
||||||
|
return HTMLResponse(
|
||||||
|
render(
|
||||||
|
"audit.html",
|
||||||
|
user=session["user"],
|
||||||
|
csrf=session["csrf"],
|
||||||
|
audit=rows,
|
||||||
|
next_before=next_before,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---- chat ----
|
||||||
|
|
||||||
|
# In-process playground: lets the operator drive any configured
|
||||||
|
# agent without minting a bearer token. Auth comes from the
|
||||||
|
# admin cookie; CSRF rides an ``X-CSRF-Token`` header because
|
||||||
|
# the body is JSON (no form to read). We call the backend
|
||||||
|
# directly — no HTTP hop to ``/v1/messages`` — so a chat turn
|
||||||
|
# bypasses the token store but is still audited as
|
||||||
|
# ``kind="messages"`` with ``actor="admin:<user>"`` and
|
||||||
|
# ``source="admin_chat"`` in the detail.
|
||||||
|
@app.get("/chat", response_class=HTMLResponse)
|
||||||
|
async def chat_page(request: Request) -> Response:
|
||||||
|
session = _require_session(request, signer)
|
||||||
|
available = [
|
||||||
|
a for a in runtime.agents if runtime.backends.get(a.name) is not None
|
||||||
|
]
|
||||||
|
return HTMLResponse(
|
||||||
|
render(
|
||||||
|
"chat.html",
|
||||||
|
user=session["user"],
|
||||||
|
csrf=session["csrf"],
|
||||||
|
agents=available,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.post("/chat/send")
|
||||||
|
async def chat_send(request: Request) -> Response:
|
||||||
|
session = _require_session(request, signer)
|
||||||
|
submitted = request.headers.get("x-csrf-token")
|
||||||
|
if not isinstance(submitted, str) or not hmac.compare_digest(
|
||||||
|
submitted, session["csrf"]
|
||||||
|
):
|
||||||
|
raise HTTPException(status.HTTP_403_FORBIDDEN, "csrf check failed")
|
||||||
|
try:
|
||||||
|
body = await request.json()
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_400_BAD_REQUEST, f"invalid JSON: {exc}"
|
||||||
|
) from exc
|
||||||
|
model = body.get("model")
|
||||||
|
if not isinstance(model, str):
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_400_BAD_REQUEST, "missing or non-string `model`"
|
||||||
|
)
|
||||||
|
agent = runtime.agents.get(model)
|
||||||
|
if agent is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_404_NOT_FOUND, f"unknown agent: {model!r}"
|
||||||
|
)
|
||||||
|
backend = runtime.backends.get(agent.name)
|
||||||
|
if backend is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
f"no backend configured for agent {agent.name!r}",
|
||||||
|
)
|
||||||
|
messages = body.get("messages") or []
|
||||||
|
if not isinstance(messages, list):
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_400_BAD_REQUEST, "`messages` must be a list"
|
||||||
|
)
|
||||||
|
system = body.get("system")
|
||||||
|
await audit.log(
|
||||||
|
runtime,
|
||||||
|
actor=f"admin:{session['user']}",
|
||||||
|
kind="messages",
|
||||||
|
agent_name=agent.name,
|
||||||
|
source="admin_chat",
|
||||||
|
msgs=len(messages),
|
||||||
|
)
|
||||||
|
events = backend.complete(
|
||||||
|
agent=agent,
|
||||||
|
messages=messages,
|
||||||
|
system=system if isinstance(system, str) else None,
|
||||||
|
)
|
||||||
|
return StreamingResponse(
|
||||||
|
_sse_events(events), media_type="text/event-stream"
|
||||||
|
)
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
# ---- helpers ------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _build_template_env() -> Environment:
|
||||||
|
env = Environment(
|
||||||
|
loader=PackageLoader("beaver_gateway.frontends.admin", "templates"),
|
||||||
|
autoescape=select_autoescape(["html"]),
|
||||||
|
trim_blocks=True,
|
||||||
|
lstrip_blocks=True,
|
||||||
|
)
|
||||||
|
env.filters["fmt_dt"] = _fmt_dt
|
||||||
|
env.filters["fmt_detail"] = _fmt_detail
|
||||||
|
return env
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt_dt(value: datetime | None) -> str:
|
||||||
|
if value is None:
|
||||||
|
return "—"
|
||||||
|
# ISO without microseconds, with explicit ``Z`` when UTC — easier to
|
||||||
|
# scan than the default ``+00:00``.
|
||||||
|
s = value.replace(microsecond=0).isoformat()
|
||||||
|
return s.replace("+00:00", "Z")
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt_detail(raw: str) -> str:
|
||||||
|
"""Pretty-print the audit detail blob; leave invalid JSON alone."""
|
||||||
|
if not raw or raw == "{}":
|
||||||
|
return ""
|
||||||
|
try:
|
||||||
|
return json.dumps(json.loads(raw), separators=(", ", ": "))
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return raw
|
||||||
|
|
||||||
|
|
||||||
|
def _build_endpoint_catalog(
|
||||||
|
request: Request, runtime: GatewayRuntime
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Collect copy-pastable URLs for sibling bearer frontends.
|
||||||
|
|
||||||
|
Precedence per frontend:
|
||||||
|
|
||||||
|
1. ``frontend.public_base_url`` if set — the operator's explicit
|
||||||
|
statement of "this is the URL my reverse proxy (Caddy / nginx /
|
||||||
|
Cloudflare / …) puts in front of me". Used verbatim with the
|
||||||
|
internal path (``/v1/messages``, ``/<ns>/`` for MCP) appended.
|
||||||
|
2. ``{scheme}://{request_hostname}:{frontend_port}`` — derived from
|
||||||
|
the browser's own request so dev / no-proxy setups Just Work.
|
||||||
|
Scheme honours ``X-Forwarded-Proto`` so a TLS terminator in
|
||||||
|
front of the admin gets the right protocol.
|
||||||
|
|
||||||
|
Imports happen inside the function to avoid a hard dep from
|
||||||
|
``admin.frontend`` on the other frontend modules — they're optional
|
||||||
|
and may have non-trivial transitive deps (aiohttp etc.).
|
||||||
|
"""
|
||||||
|
from beaver_gateway.frontends.anthropic import AnthropicMessagesFrontend
|
||||||
|
from beaver_gateway.frontends.mcp_server import McpServerFrontend
|
||||||
|
|
||||||
|
scheme = request.headers.get("x-forwarded-proto") or request.url.scheme
|
||||||
|
hostname = request.url.hostname or "localhost"
|
||||||
|
|
||||||
|
def _base_for(fe: AnthropicMessagesFrontend | McpServerFrontend) -> str:
|
||||||
|
if fe.public_base_url:
|
||||||
|
return fe.public_base_url
|
||||||
|
return f"{scheme}://{hostname}:{fe.port}"
|
||||||
|
|
||||||
|
anthropic_base: str | None = None
|
||||||
|
mcp_base: str | None = None
|
||||||
|
for fe in runtime.frontends:
|
||||||
|
if isinstance(fe, AnthropicMessagesFrontend) and anthropic_base is None:
|
||||||
|
anthropic_base = _base_for(fe)
|
||||||
|
elif isinstance(fe, McpServerFrontend) and mcp_base is None:
|
||||||
|
mcp_base = _base_for(fe)
|
||||||
|
|
||||||
|
agent_rows: list[dict[str, Any]] = []
|
||||||
|
if anthropic_base is not None:
|
||||||
|
messages_url = f"{anthropic_base}/v1/messages"
|
||||||
|
agent_rows.extend(
|
||||||
|
{
|
||||||
|
"agent": a.name,
|
||||||
|
"model": a.model,
|
||||||
|
"agent_type": a.__class__.__name__,
|
||||||
|
"url": messages_url,
|
||||||
|
}
|
||||||
|
for a in runtime.agents
|
||||||
|
)
|
||||||
|
|
||||||
|
mcp_rows: list[dict[str, Any]] = []
|
||||||
|
if mcp_base is not None:
|
||||||
|
mcp_rows.extend(
|
||||||
|
{"namespace": m.name, "kind": m.kind, "url": f"{mcp_base}/{m.name}/"}
|
||||||
|
for m in runtime.mcps
|
||||||
|
)
|
||||||
|
# ``all`` is synthesised by the aggregator whenever at least
|
||||||
|
# one MCP is configured — see McpServerFrontend._upstream_url.
|
||||||
|
if runtime.mcps:
|
||||||
|
mcp_rows.append(
|
||||||
|
{"namespace": "all", "kind": "bundle", "url": f"{mcp_base}/all/"}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"anthropic_base": anthropic_base,
|
||||||
|
"mcp_base": mcp_base,
|
||||||
|
"agents": agent_rows,
|
||||||
|
"mcps": mcp_rows,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _sse_events(
|
||||||
|
events: AsyncIterator[MessageStreamEvent],
|
||||||
|
) -> AsyncIterator[bytes]:
|
||||||
|
r"""Serialize a backend stream into Anthropic's ``text/event-stream`` form.
|
||||||
|
|
||||||
|
Same wire shape as :mod:`beaver_gateway.frontends.anthropic` —
|
||||||
|
duplicated rather than imported so the admin frontend stays
|
||||||
|
independent of that module's private helpers, and so a mid-stream
|
||||||
|
failure surfaces as an in-band ``error`` event the chat UI can
|
||||||
|
render rather than a dangling connection.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
async for ev in events:
|
||||||
|
payload = ev.model_dump_json()
|
||||||
|
yield f"event: {ev.type}\ndata: {payload}\n\n".encode()
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
_log.exception("admin chat backend stream failed")
|
||||||
|
err = json.dumps(
|
||||||
|
{"type": "error", "error": {"type": "api_error", "message": str(exc)}}
|
||||||
|
)
|
||||||
|
yield f"event: error\ndata: {err}\n\n".encode()
|
||||||
|
|
||||||
|
|
||||||
|
def _set_session_cookie(response: Response, value: str) -> None:
|
||||||
|
# ``samesite=lax`` keeps the cookie out of cross-site POSTs but
|
||||||
|
# follows top-level navigation; ``httponly`` keeps it out of JS;
|
||||||
|
# ``secure`` is gated on the deployment scheme — toggled by reverse
|
||||||
|
# proxies in front. Skip the secure flag here so localhost dev
|
||||||
|
# works over plain HTTP; production deployments behind a TLS
|
||||||
|
# terminator should set ``Secure`` via the proxy.
|
||||||
|
response.set_cookie(
|
||||||
|
SESSION_COOKIE,
|
||||||
|
value,
|
||||||
|
max_age=SESSION_MAX_AGE,
|
||||||
|
httponly=True,
|
||||||
|
samesite="lax",
|
||||||
|
path="/",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _current_user(
|
||||||
|
request: Request, signer: itsdangerous.URLSafeTimedSerializer
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
raw = request.cookies.get(SESSION_COOKIE)
|
||||||
|
if not raw:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
payload = signer.loads(raw, max_age=SESSION_MAX_AGE)
|
||||||
|
except itsdangerous.BadSignature:
|
||||||
|
return None
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
return None
|
||||||
|
user = payload.get("user")
|
||||||
|
csrf = payload.get("csrf")
|
||||||
|
if not isinstance(user, str) or not isinstance(csrf, str):
|
||||||
|
return None
|
||||||
|
return {"user": user, "csrf": csrf}
|
||||||
|
|
||||||
|
|
||||||
|
def _require_session(
|
||||||
|
request: Request, signer: itsdangerous.URLSafeTimedSerializer
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
session = _current_user(request, signer)
|
||||||
|
if session is None:
|
||||||
|
# GET endpoints want a redirect (so the browser walks the user
|
||||||
|
# to the login form), not a JSON 401. Mutating endpoints will
|
||||||
|
# still trip CSRF below, so the redirect is harmless for those.
|
||||||
|
raise HTTPException(status.HTTP_303_SEE_OTHER, headers={"Location": "/login"})
|
||||||
|
return session
|
||||||
|
|
||||||
|
|
||||||
|
def _client_ip(request: Request) -> str:
|
||||||
|
"""Best-effort source IP, in precedence order.
|
||||||
|
|
||||||
|
1. ``Cf-Connecting-IP`` — Cloudflare's edge writes this and strips
|
||||||
|
anything inbound, so when it's present it's authoritative.
|
||||||
|
2. ``X-Forwarded-For`` leftmost entry — what Caddy / nginx set when
|
||||||
|
they're the only proxy. We trust this because the deploy plan
|
||||||
|
puts Caddy directly in front; if the chain ever grows untrusted
|
||||||
|
hops, this header becomes spoofable from the public side.
|
||||||
|
3. Socket peer — direct Tailscale / localhost hits.
|
||||||
|
"""
|
||||||
|
cf = request.headers.get("cf-connecting-ip")
|
||||||
|
if cf:
|
||||||
|
return cf.strip()
|
||||||
|
xff = request.headers.get("x-forwarded-for")
|
||||||
|
if xff:
|
||||||
|
first = xff.split(",", 1)[0].strip()
|
||||||
|
if first:
|
||||||
|
return first
|
||||||
|
return request.client.host if request.client else "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
class _LoginRateLimit:
|
||||||
|
"""In-memory sliding-window failure counter for ``POST /login``.
|
||||||
|
|
||||||
|
Keyed by source IP (see :func:`_client_ip`). Each failure appends a
|
||||||
|
monotonic timestamp; :meth:`check` drops timestamps older than
|
||||||
|
``window`` and refuses once ``max_attempts`` remain in the bucket.
|
||||||
|
A successful login calls :meth:`clear` to wipe the IP's bucket.
|
||||||
|
|
||||||
|
No external store: a single-operator admin only needs to survive
|
||||||
|
process lifetime. Lives entirely on the event loop thread, so no
|
||||||
|
lock is needed — every method is synchronous and doesn't ``await``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__slots__ = ("_failures", "_max_attempts", "_window")
|
||||||
|
|
||||||
|
def __init__(self, *, max_attempts: int, window: float) -> None:
|
||||||
|
self._failures: dict[str, deque[float]] = {}
|
||||||
|
self._max_attempts = max_attempts
|
||||||
|
self._window = window
|
||||||
|
|
||||||
|
def check(self, ip: str) -> bool:
|
||||||
|
bucket = self._failures.get(ip)
|
||||||
|
if bucket is None:
|
||||||
|
return True
|
||||||
|
cutoff = time.monotonic() - self._window
|
||||||
|
while bucket and bucket[0] < cutoff:
|
||||||
|
bucket.popleft()
|
||||||
|
if not bucket:
|
||||||
|
self._failures.pop(ip, None)
|
||||||
|
return True
|
||||||
|
return len(bucket) < self._max_attempts
|
||||||
|
|
||||||
|
def record_failure(self, ip: str) -> None:
|
||||||
|
bucket = self._failures.setdefault(ip, deque())
|
||||||
|
cutoff = time.monotonic() - self._window
|
||||||
|
while bucket and bucket[0] < cutoff:
|
||||||
|
bucket.popleft()
|
||||||
|
bucket.append(time.monotonic())
|
||||||
|
|
||||||
|
def clear(self, ip: str) -> None:
|
||||||
|
self._failures.pop(ip, None)
|
||||||
|
|
||||||
|
|
||||||
|
async def _require_csrf(request: Request, session: dict[str, Any]) -> None:
|
||||||
|
form = await request.form()
|
||||||
|
submitted = form.get("csrf_token")
|
||||||
|
if not isinstance(submitted, str) or not hmac.compare_digest(
|
||||||
|
submitted, session["csrf"]
|
||||||
|
):
|
||||||
|
raise HTTPException(status.HTTP_403_FORBIDDEN, "csrf check failed")
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>{% block title %}beaver-gateway · admin{% endblock %}</title>
|
||||||
|
<script src="https://unpkg.com/htmx.org@2.0.4" defer></script>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #fbfbfd;
|
||||||
|
--fg: #1d1d1f;
|
||||||
|
--muted: #6e6e73;
|
||||||
|
--line: #e5e5ea;
|
||||||
|
--accent: #0071e3;
|
||||||
|
--danger: #d70015;
|
||||||
|
--surface: #ffffff;
|
||||||
|
--code-bg: #f5f5f7;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html, body { margin: 0; padding: 0; background: var(--bg); color: var(--fg); }
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, "SF Pro Display", "SF Pro Text",
|
||||||
|
BlinkMacSystemFont, system-ui, sans-serif;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.55;
|
||||||
|
letter-spacing: -0.005em;
|
||||||
|
}
|
||||||
|
a { color: var(--accent); text-decoration: none; }
|
||||||
|
a:hover { text-decoration: underline; }
|
||||||
|
code, pre, kbd, samp {
|
||||||
|
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
|
||||||
|
font-size: 0.92em;
|
||||||
|
}
|
||||||
|
pre {
|
||||||
|
background: var(--code-bg);
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
header.top {
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
header.top .inner {
|
||||||
|
max-width: 1080px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1.25rem;
|
||||||
|
}
|
||||||
|
header.top h1 {
|
||||||
|
font-size: 1.05rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
nav.tabs { display: flex; gap: 1.1rem; flex: 1; }
|
||||||
|
nav.tabs a {
|
||||||
|
color: var(--fg);
|
||||||
|
padding: 0.25rem 0.1rem;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
}
|
||||||
|
nav.tabs a.active {
|
||||||
|
border-bottom-color: var(--fg);
|
||||||
|
}
|
||||||
|
header.top .actor {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
main {
|
||||||
|
max-width: 1080px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2.25rem 1.5rem 4rem;
|
||||||
|
}
|
||||||
|
h2 {
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.015em;
|
||||||
|
margin: 2rem 0 0.75rem;
|
||||||
|
}
|
||||||
|
h2:first-of-type { margin-top: 0; }
|
||||||
|
.muted { color: var(--muted); }
|
||||||
|
.card {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 1.25rem 1.5rem;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
table { width: 100%; border-collapse: collapse; }
|
||||||
|
th, td {
|
||||||
|
text-align: left;
|
||||||
|
padding: 0.6rem 0.85rem;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
th {
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.85em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
tr:last-child td { border-bottom: none; }
|
||||||
|
.revoked td { color: var(--muted); }
|
||||||
|
button, .btn {
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.92em;
|
||||||
|
padding: 0.45rem 0.95rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--fg);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 120ms ease;
|
||||||
|
}
|
||||||
|
button:hover, .btn:hover { background: var(--code-bg); }
|
||||||
|
button.primary, .btn.primary {
|
||||||
|
background: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
button.primary:hover, .btn.primary:hover {
|
||||||
|
background: #005bb5;
|
||||||
|
}
|
||||||
|
button.danger { color: var(--danger); border-color: #f0c5c9; }
|
||||||
|
button.danger:hover { background: #fdeff1; }
|
||||||
|
form.inline { display: inline; margin: 0; }
|
||||||
|
.form-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(180px, 1fr) 160px auto;
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-items: end;
|
||||||
|
}
|
||||||
|
.form-grid label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.8em;
|
||||||
|
color: var(--muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
margin-bottom: 0.3rem;
|
||||||
|
}
|
||||||
|
input[type="text"], input[type="password"], select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.55rem 0.7rem;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.95em;
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--fg);
|
||||||
|
}
|
||||||
|
input:focus, select:focus { outline: 2px solid var(--accent); outline-offset: 1px; }
|
||||||
|
.banner {
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #f0f9ff;
|
||||||
|
border: 1px solid #cfe8ff;
|
||||||
|
margin: 0 0 1.25rem;
|
||||||
|
}
|
||||||
|
.banner.error {
|
||||||
|
background: #fff0f0;
|
||||||
|
border-color: #f5c2c5;
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
.banner.warn {
|
||||||
|
background: #fff7e6;
|
||||||
|
border-color: #fadf9c;
|
||||||
|
}
|
||||||
|
.pill {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.1rem 0.55rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--code-bg);
|
||||||
|
font-size: 0.78em;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.pill.scope-wild { background: #ecfdf3; color: #027a48; }
|
||||||
|
.pill.scope-admin { background: #fff4e6; color: #b54708; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{% block header %}
|
||||||
|
<header class="top">
|
||||||
|
<div class="inner">
|
||||||
|
<h1>beaver-gateway</h1>
|
||||||
|
<nav class="tabs">
|
||||||
|
<a href="/" class="{% if active == 'dashboard' %}active{% endif %}">Dashboard</a>
|
||||||
|
<a href="/chat" class="{% if active == 'chat' %}active{% endif %}">Chat</a>
|
||||||
|
<a href="/tokens" class="{% if active == 'tokens' %}active{% endif %}">Tokens</a>
|
||||||
|
<a href="/audit" class="{% if active == 'audit' %}active{% endif %}">Audit</a>
|
||||||
|
</nav>
|
||||||
|
<span class="actor">Signed in as <strong>{{ user }}</strong></span>
|
||||||
|
<form class="inline" method="post" action="/logout">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf }}">
|
||||||
|
<button type="submit">Log out</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
{% endblock %}
|
||||||
|
<main>
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{# Renders into #token-create-result (hx-target on the form). The
|
||||||
|
two OOB swaps reach the rest of the page: prepend the new row,
|
||||||
|
and erase the empty-state placeholder if it's still in the DOM. #}
|
||||||
|
<div class="banner warn">
|
||||||
|
<strong>Token created: {{ token.name }}</strong>
|
||||||
|
<p class="muted" style="margin:0.25rem 0 0.5rem;">Copy it now — this is the only time it will be shown.</p>
|
||||||
|
<pre style="margin:0;">{{ plaintext }}</pre>
|
||||||
|
</div>
|
||||||
|
<tbody id="tokens-rows" hx-swap-oob="afterbegin">
|
||||||
|
{% include "_token_row.html" %}
|
||||||
|
</tbody>
|
||||||
|
<tr id="tokens-empty" hx-swap-oob="delete"></tr>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<div class="banner error">{{ message }}</div>
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<tr id="token-row-{{ token.id }}" {% if token.revoked_at %}class="revoked"{% endif %}>
|
||||||
|
<td><strong>{{ token.name }}</strong></td>
|
||||||
|
<td>
|
||||||
|
<span class="pill {% if token.scope == '*' %}scope-wild{% elif token.scope == 'admin' %}scope-admin{% endif %}">{{ token.scope }}</span>
|
||||||
|
</td>
|
||||||
|
<td><code>{{ token.created_at | fmt_dt }}</code></td>
|
||||||
|
<td><code>{{ token.last_used_at | fmt_dt }}</code></td>
|
||||||
|
<td><code>{{ token.revoked_at | fmt_dt }}</code></td>
|
||||||
|
<td style="text-align:right;">
|
||||||
|
{% if not token.revoked_at %}
|
||||||
|
<form
|
||||||
|
class="inline"
|
||||||
|
hx-post="/tokens/{{ token.id }}/revoke"
|
||||||
|
hx-target="#token-row-{{ token.id }}"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
hx-confirm="Revoke token {{ token.name }}? This cannot be undone."
|
||||||
|
>
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf }}">
|
||||||
|
<button class="danger" type="submit">Revoke</button>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<span class="muted">revoked</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
{% extends "_layout.html" %}
|
||||||
|
{% set active = "audit" %}
|
||||||
|
{% block title %}beaver-gateway · audit{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h2>Audit log</h2>
|
||||||
|
<div class="card">
|
||||||
|
{% if audit %}
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Time</th><th>Actor</th><th>Kind</th><th>Agent</th><th>Detail</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for row in audit %}
|
||||||
|
<tr>
|
||||||
|
<td><code>{{ row.ts | fmt_dt }}</code></td>
|
||||||
|
<td>{{ row.actor }}</td>
|
||||||
|
<td><span class="pill">{{ row.kind }}</span></td>
|
||||||
|
<td>{{ row.agent_name or "—" }}</td>
|
||||||
|
<td><code>{{ row.detail_json | fmt_detail }}</code></td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<p class="muted" style="margin-top:1rem;">
|
||||||
|
{% if next_before %}
|
||||||
|
<a href="/audit?before={{ next_before }}">Older entries →</a>
|
||||||
|
{% else %}
|
||||||
|
End of log.
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
{% else %}
|
||||||
|
<p class="muted">Nothing logged yet.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,354 @@
|
|||||||
|
{% extends "_layout.html" %}
|
||||||
|
{% set active = "chat" %}
|
||||||
|
{% block title %}beaver-gateway · chat{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="chat-wrap">
|
||||||
|
<div class="chat-toolbar">
|
||||||
|
<label>
|
||||||
|
<span>Agent</span>
|
||||||
|
<select id="agent-select">
|
||||||
|
{% for a in agents %}
|
||||||
|
<option value="{{ a.name }}">{{ a.name }} · {{ a.model }}</option>
|
||||||
|
{% else %}
|
||||||
|
<option disabled>no agents with a backend</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<div class="spacer"></div>
|
||||||
|
<button id="new-chat-btn" type="button">New chat</button>
|
||||||
|
</div>
|
||||||
|
<div id="messages" class="chat-messages" aria-live="polite"></div>
|
||||||
|
<form id="chat-form" class="chat-input">
|
||||||
|
<textarea id="chat-text" rows="3" placeholder="Message… (⌘/Ctrl+Enter to send)" required></textarea>
|
||||||
|
<button type="submit" class="primary" id="send-btn">Send</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.chat-wrap {
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
height: calc(100vh - 200px); min-height: 480px;
|
||||||
|
}
|
||||||
|
.chat-toolbar {
|
||||||
|
display: flex; gap: 0.85rem; align-items: end;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.chat-toolbar .spacer { flex: 1; }
|
||||||
|
.chat-toolbar label {
|
||||||
|
display: flex; flex-direction: column; gap: 0.3rem;
|
||||||
|
min-width: 260px;
|
||||||
|
}
|
||||||
|
.chat-toolbar label > span {
|
||||||
|
font-size: 0.8em; color: var(--muted);
|
||||||
|
text-transform: uppercase; letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
.chat-messages {
|
||||||
|
flex: 1; overflow-y: auto;
|
||||||
|
background: var(--surface); border: 1px solid var(--line);
|
||||||
|
border-radius: 14px; padding: 1.25rem;
|
||||||
|
display: flex; flex-direction: column; gap: 1rem;
|
||||||
|
}
|
||||||
|
.chat-empty { color: var(--muted); margin: auto; }
|
||||||
|
.msg { display: flex; }
|
||||||
|
.msg.user { justify-content: flex-end; }
|
||||||
|
.msg.user .bubble {
|
||||||
|
max-width: 78%; padding: 0.7rem 0.95rem; border-radius: 14px;
|
||||||
|
background: var(--accent); color: white;
|
||||||
|
white-space: pre-wrap; word-wrap: break-word;
|
||||||
|
}
|
||||||
|
.msg.assistant .blocks {
|
||||||
|
display: flex; flex-direction: column; gap: 0.5rem;
|
||||||
|
max-width: 88%;
|
||||||
|
}
|
||||||
|
.block-text {
|
||||||
|
background: var(--code-bg); padding: 0.7rem 0.95rem;
|
||||||
|
border-radius: 14px;
|
||||||
|
white-space: pre-wrap; word-wrap: break-word;
|
||||||
|
}
|
||||||
|
details.tool-call, details.thinking-block {
|
||||||
|
border: 1px solid var(--line); border-radius: 10px;
|
||||||
|
background: var(--surface); padding: 0 0.85rem;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
details.tool-call > summary,
|
||||||
|
details.thinking-block > summary {
|
||||||
|
cursor: pointer; padding: 0.55rem 0;
|
||||||
|
list-style: none;
|
||||||
|
display: flex; align-items: center; gap: 0.55rem;
|
||||||
|
}
|
||||||
|
details > summary::-webkit-details-marker { display: none; }
|
||||||
|
details.tool-call > summary::before,
|
||||||
|
details.thinking-block > summary::before {
|
||||||
|
content: "▸"; color: var(--muted); font-size: 0.78em;
|
||||||
|
}
|
||||||
|
details.tool-call[open] > summary::before,
|
||||||
|
details.thinking-block[open] > summary::before { content: "▾"; }
|
||||||
|
.tool-name {
|
||||||
|
font-family: ui-monospace, "SF Mono", Menlo, monospace;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.tool-id, .tool-label {
|
||||||
|
color: var(--muted); font-size: 0.78em;
|
||||||
|
font-family: ui-monospace, "SF Mono", Menlo, monospace;
|
||||||
|
}
|
||||||
|
details.tool-call pre, details.thinking-block pre {
|
||||||
|
margin: 0 0 0.6rem; padding: 0.7rem 0.85rem;
|
||||||
|
background: var(--code-bg); border-radius: 8px;
|
||||||
|
font-size: 0.85em; max-height: 360px; overflow: auto;
|
||||||
|
}
|
||||||
|
.tool-label { display: block; margin: 0.15rem 0 0.25rem; }
|
||||||
|
.chat-input { display: flex; gap: 0.75rem; margin-top: 1rem; }
|
||||||
|
.chat-input textarea {
|
||||||
|
flex: 1; padding: 0.7rem 0.9rem;
|
||||||
|
border: 1px solid var(--line); border-radius: 12px;
|
||||||
|
font-family: inherit; font-size: 0.95em;
|
||||||
|
background: var(--surface); color: var(--fg); resize: vertical;
|
||||||
|
}
|
||||||
|
.chat-input textarea:focus { outline: 2px solid var(--accent); outline-offset: 1px; }
|
||||||
|
.chat-input button { align-self: stretch; padding-left: 1.4rem; padding-right: 1.4rem; }
|
||||||
|
.chat-error {
|
||||||
|
color: var(--danger); font-size: 0.85em;
|
||||||
|
padding: 0.55rem 0.75rem;
|
||||||
|
background: #fff0f0; border-radius: 10px;
|
||||||
|
border: 1px solid #f5c2c5;
|
||||||
|
}
|
||||||
|
.chat-input button:disabled { opacity: 0.55; cursor: progress; }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(() => {
|
||||||
|
const CSRF = {{ csrf | tojson }};
|
||||||
|
const agentSelect = document.getElementById("agent-select");
|
||||||
|
const messagesEl = document.getElementById("messages");
|
||||||
|
const form = document.getElementById("chat-form");
|
||||||
|
const textEl = document.getElementById("chat-text");
|
||||||
|
const sendBtn = document.getElementById("send-btn");
|
||||||
|
const newBtn = document.getElementById("new-chat-btn");
|
||||||
|
|
||||||
|
// Anthropic-style history sent to the backend. We keep assistant
|
||||||
|
// content text-only — tool_use blocks can't round-trip without
|
||||||
|
// matching tool_result, and backends (claude-code) run tools
|
||||||
|
// internally anyway.
|
||||||
|
let apiMessages = [];
|
||||||
|
|
||||||
|
function renderEmpty() {
|
||||||
|
messagesEl.innerHTML = '<div class="chat-empty">No messages yet — say something.</div>';
|
||||||
|
}
|
||||||
|
renderEmpty();
|
||||||
|
|
||||||
|
newBtn.addEventListener("click", () => {
|
||||||
|
apiMessages = [];
|
||||||
|
renderEmpty();
|
||||||
|
});
|
||||||
|
|
||||||
|
function clearEmpty() {
|
||||||
|
const e = messagesEl.querySelector(".chat-empty");
|
||||||
|
if (e) e.remove();
|
||||||
|
}
|
||||||
|
function scrollDown() { messagesEl.scrollTop = messagesEl.scrollHeight; }
|
||||||
|
|
||||||
|
function appendUser(text) {
|
||||||
|
clearEmpty();
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "msg user";
|
||||||
|
const bub = document.createElement("div");
|
||||||
|
bub.className = "bubble";
|
||||||
|
bub.textContent = text;
|
||||||
|
row.appendChild(bub);
|
||||||
|
messagesEl.appendChild(row);
|
||||||
|
scrollDown();
|
||||||
|
}
|
||||||
|
function appendAssistant() {
|
||||||
|
clearEmpty();
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "msg assistant";
|
||||||
|
const blocks = document.createElement("div");
|
||||||
|
blocks.className = "blocks";
|
||||||
|
row.appendChild(blocks);
|
||||||
|
messagesEl.appendChild(row);
|
||||||
|
scrollDown();
|
||||||
|
return blocks;
|
||||||
|
}
|
||||||
|
function appendError(text) {
|
||||||
|
clearEmpty();
|
||||||
|
const e = document.createElement("div");
|
||||||
|
e.className = "chat-error";
|
||||||
|
e.textContent = text;
|
||||||
|
messagesEl.appendChild(e);
|
||||||
|
scrollDown();
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureBlock(state, index, type, extra) {
|
||||||
|
if (state.blocks[index]) return state.blocks[index];
|
||||||
|
const block = { type };
|
||||||
|
if (type === "text") {
|
||||||
|
const el = document.createElement("div");
|
||||||
|
el.className = "block-text";
|
||||||
|
state.container.appendChild(el);
|
||||||
|
block.el = el;
|
||||||
|
} else if (type === "thinking") {
|
||||||
|
const det = document.createElement("details");
|
||||||
|
det.className = "thinking-block";
|
||||||
|
const sum = document.createElement("summary");
|
||||||
|
sum.textContent = "Thinking";
|
||||||
|
det.appendChild(sum);
|
||||||
|
const pre = document.createElement("pre");
|
||||||
|
det.appendChild(pre);
|
||||||
|
state.container.appendChild(det);
|
||||||
|
block.el = pre;
|
||||||
|
} else if (type === "tool_use") {
|
||||||
|
const det = document.createElement("details");
|
||||||
|
det.className = "tool-call";
|
||||||
|
const sum = document.createElement("summary");
|
||||||
|
const name = document.createElement("span");
|
||||||
|
name.className = "tool-name";
|
||||||
|
name.textContent = "🔧 " + (extra.name || "tool");
|
||||||
|
sum.appendChild(name);
|
||||||
|
if (extra.id) {
|
||||||
|
const idEl = document.createElement("span");
|
||||||
|
idEl.className = "tool-id";
|
||||||
|
idEl.textContent = extra.id;
|
||||||
|
sum.appendChild(idEl);
|
||||||
|
}
|
||||||
|
det.appendChild(sum);
|
||||||
|
const label = document.createElement("span");
|
||||||
|
label.className = "tool-label";
|
||||||
|
label.textContent = "input";
|
||||||
|
det.appendChild(label);
|
||||||
|
const pre = document.createElement("pre");
|
||||||
|
det.appendChild(pre);
|
||||||
|
state.container.appendChild(det);
|
||||||
|
block.el = pre;
|
||||||
|
block.jsonBuf = "";
|
||||||
|
block.seedInput = extra.input;
|
||||||
|
}
|
||||||
|
state.blocks[index] = block;
|
||||||
|
return block;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyEvent(state, ev) {
|
||||||
|
const t = ev.type;
|
||||||
|
if (t === "content_block_start") {
|
||||||
|
const cb = ev.content_block || {};
|
||||||
|
if (cb.type === "text") {
|
||||||
|
ensureBlock(state, ev.index, "text", {});
|
||||||
|
} else if (cb.type === "thinking") {
|
||||||
|
ensureBlock(state, ev.index, "thinking", {});
|
||||||
|
} else if (cb.type === "tool_use") {
|
||||||
|
ensureBlock(state, ev.index, "tool_use",
|
||||||
|
{ name: cb.name, id: cb.id, input: cb.input });
|
||||||
|
}
|
||||||
|
} else if (t === "content_block_delta") {
|
||||||
|
const d = ev.delta || {};
|
||||||
|
const b = state.blocks[ev.index];
|
||||||
|
if (!b) return;
|
||||||
|
if (d.type === "text_delta") {
|
||||||
|
b.el.textContent += d.text || "";
|
||||||
|
state.assistantText += d.text || "";
|
||||||
|
scrollDown();
|
||||||
|
} else if (d.type === "thinking_delta") {
|
||||||
|
b.el.textContent += d.thinking || "";
|
||||||
|
scrollDown();
|
||||||
|
} else if (d.type === "input_json_delta") {
|
||||||
|
b.jsonBuf += d.partial_json || "";
|
||||||
|
}
|
||||||
|
} else if (t === "content_block_stop") {
|
||||||
|
const b = state.blocks[ev.index];
|
||||||
|
if (!b) return;
|
||||||
|
if (b.type === "tool_use") {
|
||||||
|
let input = null;
|
||||||
|
if (b.jsonBuf && b.jsonBuf.trim()) {
|
||||||
|
try { input = JSON.parse(b.jsonBuf); }
|
||||||
|
catch { input = b.jsonBuf; }
|
||||||
|
} else if (b.seedInput !== undefined && b.seedInput !== null) {
|
||||||
|
input = b.seedInput;
|
||||||
|
}
|
||||||
|
b.el.textContent = input === null
|
||||||
|
? "(no input)"
|
||||||
|
: (typeof input === "string"
|
||||||
|
? input
|
||||||
|
: JSON.stringify(input, null, 2));
|
||||||
|
}
|
||||||
|
} else if (t === "error") {
|
||||||
|
const msg = (ev.error && ev.error.message) || "stream error";
|
||||||
|
appendError(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function send() {
|
||||||
|
const text = textEl.value.trim();
|
||||||
|
if (!text) return;
|
||||||
|
const model = agentSelect.value;
|
||||||
|
if (!model) { appendError("no agent selected"); return; }
|
||||||
|
|
||||||
|
apiMessages.push({ role: "user", content: text });
|
||||||
|
appendUser(text);
|
||||||
|
textEl.value = "";
|
||||||
|
sendBtn.disabled = true;
|
||||||
|
|
||||||
|
const container = appendAssistant();
|
||||||
|
const state = { container, blocks: {}, assistantText: "" };
|
||||||
|
|
||||||
|
let resp;
|
||||||
|
try {
|
||||||
|
resp = await fetch("/chat/send", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-CSRF-Token": CSRF,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ model, messages: apiMessages }),
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
appendError("network error: " + e.message);
|
||||||
|
sendBtn.disabled = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!resp.ok || !resp.body) {
|
||||||
|
let msg = resp.status + " " + resp.statusText;
|
||||||
|
try { const body = await resp.text(); if (body) msg = body; } catch {}
|
||||||
|
appendError(msg);
|
||||||
|
sendBtn.disabled = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = resp.body.getReader();
|
||||||
|
const dec = new TextDecoder();
|
||||||
|
let buf = "";
|
||||||
|
while (true) {
|
||||||
|
const { value, done } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
buf += dec.decode(value, { stream: true });
|
||||||
|
let i;
|
||||||
|
while ((i = buf.indexOf("\n\n")) >= 0) {
|
||||||
|
const raw = buf.slice(0, i);
|
||||||
|
buf = buf.slice(i + 2);
|
||||||
|
if (!raw) continue;
|
||||||
|
let dataLine = "";
|
||||||
|
for (const line of raw.split("\n")) {
|
||||||
|
if (line.startsWith("data:")) dataLine += line.slice(5).trimStart();
|
||||||
|
}
|
||||||
|
if (!dataLine) continue;
|
||||||
|
let payload;
|
||||||
|
try { payload = JSON.parse(dataLine); } catch { continue; }
|
||||||
|
applyEvent(state, payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.assistantText) {
|
||||||
|
apiMessages.push({ role: "assistant", content: state.assistantText });
|
||||||
|
}
|
||||||
|
sendBtn.disabled = false;
|
||||||
|
textEl.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
form.addEventListener("submit", (e) => { e.preventDefault(); send(); });
|
||||||
|
textEl.addEventListener("keydown", (e) => {
|
||||||
|
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
send();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,430 @@
|
|||||||
|
{% extends "_layout.html" %}
|
||||||
|
{% set active = "dashboard" %}
|
||||||
|
{% block title %}beaver-gateway · dashboard{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h2>Agents</h2>
|
||||||
|
<div class="card">
|
||||||
|
{% if agents %}
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Name</th><th>Type</th><th>Model</th><th>Exposed MCPs</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for a in agents %}
|
||||||
|
<tr>
|
||||||
|
<td><code>{{ a.name }}</code></td>
|
||||||
|
<td><span class="pill">{{ a.__class__.__name__ }}</span></td>
|
||||||
|
<td><code>{{ a.model }}</code></td>
|
||||||
|
<td>
|
||||||
|
{% for em in a.expose_mcps %}<code>{{ em.name }}</code>{% if not loop.last %}, {% endif %}{% else %}<span class="muted">—</span>{% endfor %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<p class="muted">No agents configured.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>MCP namespaces</h2>
|
||||||
|
<div class="card">
|
||||||
|
{% if mcps %}
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Name</th><th>Kind</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for m in mcps %}
|
||||||
|
<tr>
|
||||||
|
<td><code>{{ m.name }}</code></td>
|
||||||
|
<td><span class="pill">{{ m.kind }}</span></td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<p class="muted">No MCP servers configured.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Endpoints</h2>
|
||||||
|
<div class="card endpoints">
|
||||||
|
{% if endpoints.agents or endpoints.mcps %}
|
||||||
|
<div class="ep-controls">
|
||||||
|
<label class="ep-token">
|
||||||
|
<span>Token (hint)</span>
|
||||||
|
<select id="ep-token-select">
|
||||||
|
<option value="" data-scope="">— select a known token —</option>
|
||||||
|
{% for t in tokens %}
|
||||||
|
<option value="{{ t.name }}" data-scope="{{ t.scope }}">{{ t.name }} · scope {{ t.scope }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="ep-secret">
|
||||||
|
<span>Bearer secret</span>
|
||||||
|
<input type="password" id="ep-token-secret" autocomplete="off" spellcheck="false"
|
||||||
|
placeholder="paste plaintext (we only store the Argon2 hash)">
|
||||||
|
</label>
|
||||||
|
<label class="ep-show">
|
||||||
|
<input type="checkbox" id="ep-show-secret"> <span>show</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<p class="muted ep-note">
|
||||||
|
Beaver stores only an Argon2 hash of each token, so the plaintext can't be reconstructed.
|
||||||
|
Paste the value you saved at creation; if you've lost it, <a href="/tokens">mint a new one</a>.
|
||||||
|
Below: pick a token to see which endpoints its scope covers, paste the secret to fill it
|
||||||
|
into URL / curl, then click Copy.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{% if endpoints.agents %}
|
||||||
|
<h3 class="ep-h3">Agents — <code>POST /v1/messages</code></h3>
|
||||||
|
<table class="ep-table" data-required-scope="messages">
|
||||||
|
<thead><tr><th>Agent</th><th>Model</th><th>URL</th><th class="ep-actions-th"></th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for ep in endpoints.agents %}
|
||||||
|
<tr class="ep-row" data-kind="messages" data-agent="{{ ep.agent }}" data-url="{{ ep.url }}">
|
||||||
|
<td>
|
||||||
|
<code>{{ ep.agent }}</code>
|
||||||
|
<span class="pill">{{ ep.agent_type }}</span>
|
||||||
|
<span class="ep-scope-warn" hidden>scope mismatch</span>
|
||||||
|
</td>
|
||||||
|
<td><code>{{ ep.model }}</code></td>
|
||||||
|
<td><code class="ep-url">{{ ep.url }}</code></td>
|
||||||
|
<td class="ep-actions">
|
||||||
|
<button type="button" data-action="copy-url">Copy URL</button>
|
||||||
|
<button type="button" data-action="copy-curl">Copy curl</button>
|
||||||
|
<button type="button" data-action="toggle-curl" aria-expanded="false">▸ curl</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="ep-curl-row" hidden><td colspan="4"><pre class="ep-curl"></pre></td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% elif endpoints.anthropic_base is none and agents %}
|
||||||
|
<p class="muted">
|
||||||
|
Agents are declared but no <code>AnthropicMessagesFrontend</code> is configured —
|
||||||
|
add one to <code>Gateway(frontends=[...])</code> to expose them over HTTP.
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if endpoints.mcps %}
|
||||||
|
<h3 class="ep-h3">MCP — streamable HTTP</h3>
|
||||||
|
<table class="ep-table" data-required-scope="mcp">
|
||||||
|
<thead><tr><th>Namespace</th><th>Kind</th><th>URL</th><th class="ep-actions-th"></th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for ep in endpoints.mcps %}
|
||||||
|
<tr class="ep-row" data-kind="mcp" data-namespace="{{ ep.namespace }}" data-url="{{ ep.url }}">
|
||||||
|
<td>
|
||||||
|
<code>{{ ep.namespace }}</code>
|
||||||
|
<span class="ep-scope-warn" hidden>scope mismatch</span>
|
||||||
|
</td>
|
||||||
|
<td><span class="pill">{{ ep.kind }}</span></td>
|
||||||
|
<td><code class="ep-url">{{ ep.url }}</code></td>
|
||||||
|
<td class="ep-actions">
|
||||||
|
<button type="button" data-action="copy-url">Copy URL</button>
|
||||||
|
<button type="button" data-action="copy-url-token">Copy URL+token</button>
|
||||||
|
<button type="button" data-action="copy-curl">Copy curl</button>
|
||||||
|
<button type="button" data-action="toggle-curl" aria-expanded="false">▸ curl</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="ep-curl-row" hidden><td colspan="4"><pre class="ep-curl"></pre></td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% elif endpoints.mcp_base is none and mcps %}
|
||||||
|
<p class="muted">
|
||||||
|
MCP servers are declared but no <code>McpServerFrontend</code> is configured —
|
||||||
|
add one to <code>Gateway(frontends=[...])</code> to expose them over HTTP.
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<p class="muted">
|
||||||
|
Nothing exposed yet — declare agents / MCPs and the matching frontends
|
||||||
|
(<code>AnthropicMessagesFrontend</code>, <code>McpServerFrontend</code>) in your config.
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Recent activity</h2>
|
||||||
|
<div class="card">
|
||||||
|
{% if audit %}
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Time</th><th>Actor</th><th>Kind</th><th>Agent</th><th>Detail</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for row in audit %}
|
||||||
|
<tr>
|
||||||
|
<td><code>{{ row.ts | fmt_dt }}</code></td>
|
||||||
|
<td>{{ row.actor }}</td>
|
||||||
|
<td><span class="pill">{{ row.kind }}</span></td>
|
||||||
|
<td>{{ row.agent_name or "—" }}</td>
|
||||||
|
<td><code>{{ row.detail_json | fmt_detail }}</code></td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<p class="muted" style="margin-top:0.85rem;"><a href="/audit">Full log →</a></p>
|
||||||
|
{% else %}
|
||||||
|
<p class="muted">Nothing logged yet.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.endpoints .ep-controls {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(220px, 1fr) 2fr auto;
|
||||||
|
gap: 0.85rem;
|
||||||
|
align-items: end;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
.endpoints .ep-controls label > span {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.8em; color: var(--muted);
|
||||||
|
text-transform: uppercase; letter-spacing: 0.05em;
|
||||||
|
margin-bottom: 0.3rem;
|
||||||
|
}
|
||||||
|
.endpoints .ep-show {
|
||||||
|
display: flex; align-items: center; gap: 0.4rem;
|
||||||
|
padding-bottom: 0.6rem; color: var(--muted); font-size: 0.9em;
|
||||||
|
}
|
||||||
|
.endpoints .ep-note {
|
||||||
|
font-size: 0.85em; margin: 0.25rem 0 1.2rem;
|
||||||
|
}
|
||||||
|
.endpoints .ep-h3 {
|
||||||
|
font-size: 0.95rem; font-weight: 600;
|
||||||
|
margin: 1.5rem 0 0.55rem;
|
||||||
|
}
|
||||||
|
.endpoints .ep-table { table-layout: auto; }
|
||||||
|
.endpoints .ep-table th.ep-actions-th { width: 1%; }
|
||||||
|
.endpoints .ep-url {
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
.endpoints .ep-actions {
|
||||||
|
white-space: nowrap;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
.endpoints .ep-actions button {
|
||||||
|
padding: 0.32rem 0.7rem;
|
||||||
|
font-size: 0.85em;
|
||||||
|
margin-left: 0.3rem;
|
||||||
|
}
|
||||||
|
.endpoints .ep-scope-warn {
|
||||||
|
display: inline-block;
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
padding: 0.05rem 0.45rem;
|
||||||
|
background: #fff7e6;
|
||||||
|
border: 1px solid #fadf9c;
|
||||||
|
color: #8a5a00;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.72em;
|
||||||
|
}
|
||||||
|
.endpoints .ep-row.scope-mismatch td:not(.ep-actions) { opacity: 0.55; }
|
||||||
|
.endpoints .ep-row.scope-mismatch .ep-actions button { opacity: 0.7; }
|
||||||
|
.endpoints .ep-curl-row td { padding-top: 0; padding-bottom: 0; }
|
||||||
|
.endpoints .ep-curl {
|
||||||
|
margin: 0.4rem 0 1rem;
|
||||||
|
font-size: 0.82em;
|
||||||
|
max-height: 280px;
|
||||||
|
}
|
||||||
|
.endpoints button[data-copied="1"] {
|
||||||
|
background: #ecfdf3 !important;
|
||||||
|
border-color: #b6e6cb !important;
|
||||||
|
color: #027a48;
|
||||||
|
}
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.endpoints .ep-controls { grid-template-columns: 1fr; }
|
||||||
|
.endpoints .ep-actions { text-align: left; padding-top: 0.4rem; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(() => {
|
||||||
|
const card = document.querySelector(".endpoints");
|
||||||
|
if (!card) return;
|
||||||
|
const sel = card.querySelector("#ep-token-select");
|
||||||
|
const secret = card.querySelector("#ep-token-secret");
|
||||||
|
const showCb = card.querySelector("#ep-show-secret");
|
||||||
|
if (!sel || !secret || !showCb) return;
|
||||||
|
|
||||||
|
const PLACEHOLDER = "<YOUR_TOKEN>";
|
||||||
|
|
||||||
|
function currentToken() {
|
||||||
|
const v = secret.value;
|
||||||
|
return v && v.length > 0 ? v : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectedScope() {
|
||||||
|
const o = sel.options[sel.selectedIndex];
|
||||||
|
return o ? (o.getAttribute("data-scope") || "") : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function scopeCovers(have, need) {
|
||||||
|
if (!have) return true; // no token selected — don't grey anything out
|
||||||
|
if (have === "*") return true;
|
||||||
|
return have === need;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escSh(s) {
|
||||||
|
// single-quote shell escape: close, escape, reopen.
|
||||||
|
return "'" + String(s).replace(/'/g, "'\\''") + "'";
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCurl(row) {
|
||||||
|
const kind = row.getAttribute("data-kind");
|
||||||
|
const url = row.getAttribute("data-url");
|
||||||
|
const tok = currentToken() || PLACEHOLDER;
|
||||||
|
if (kind === "messages") {
|
||||||
|
const agent = row.getAttribute("data-agent");
|
||||||
|
const body = JSON.stringify({
|
||||||
|
model: agent,
|
||||||
|
messages: [{ role: "user", content: "hello" }],
|
||||||
|
stream: true,
|
||||||
|
});
|
||||||
|
return [
|
||||||
|
"curl -N \\",
|
||||||
|
" -H " + escSh("x-api-key: " + tok) + " \\",
|
||||||
|
" -H 'content-type: application/json' \\",
|
||||||
|
" -d " + escSh(body) + " \\",
|
||||||
|
" " + url,
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
|
if (kind === "mcp") {
|
||||||
|
// Streamable-HTTP MCP wants `initialize` first — the response
|
||||||
|
// carries the `Mcp-Session-Id` header you must echo back on
|
||||||
|
// every subsequent call (tools/list, tools/call, ...). We use
|
||||||
|
// `-i` so the session-id is visible in the response, and ship
|
||||||
|
// the proper handshake body so a fresh paste actually works.
|
||||||
|
const body = JSON.stringify({
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
id: 1,
|
||||||
|
method: "initialize",
|
||||||
|
params: {
|
||||||
|
protocolVersion: "2024-11-05",
|
||||||
|
capabilities: {},
|
||||||
|
clientInfo: { name: "beaver-admin-curl", version: "0" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return [
|
||||||
|
"# 1) initialize — grab Mcp-Session-Id from the response headers",
|
||||||
|
"curl -N -i \\",
|
||||||
|
" -H " + escSh("Authorization: Bearer " + tok) + " \\",
|
||||||
|
" -H 'content-type: application/json' \\",
|
||||||
|
" -H 'accept: application/json, text/event-stream' \\",
|
||||||
|
" -d " + escSh(body) + " \\",
|
||||||
|
" " + url,
|
||||||
|
"",
|
||||||
|
"# 2) reuse that id on follow-up calls, e.g. tools/list:",
|
||||||
|
"# curl -N \\",
|
||||||
|
"# -H " + escSh("Authorization: Bearer " + tok) + " \\",
|
||||||
|
"# -H 'Mcp-Session-Id: <paste-from-step-1>' \\",
|
||||||
|
"# -H 'content-type: application/json' \\",
|
||||||
|
"# -H 'accept: application/json, text/event-stream' \\",
|
||||||
|
"# -d '{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/list\"}' \\",
|
||||||
|
"# " + url,
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshScopeMarks() {
|
||||||
|
const have = selectedScope();
|
||||||
|
card.querySelectorAll(".ep-table").forEach((tbl) => {
|
||||||
|
const need = tbl.getAttribute("data-required-scope") || "";
|
||||||
|
const ok = scopeCovers(have, need);
|
||||||
|
tbl.querySelectorAll("tr.ep-row").forEach((row) => {
|
||||||
|
const warn = row.querySelector(".ep-scope-warn");
|
||||||
|
if (!ok) {
|
||||||
|
row.classList.add("scope-mismatch");
|
||||||
|
if (warn) warn.hidden = false;
|
||||||
|
} else {
|
||||||
|
row.classList.remove("scope-mismatch");
|
||||||
|
if (warn) warn.hidden = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshOpenCurls() {
|
||||||
|
card.querySelectorAll("tr.ep-curl-row").forEach((cr) => {
|
||||||
|
if (cr.hidden) return;
|
||||||
|
const row = cr.previousElementSibling;
|
||||||
|
const pre = cr.querySelector(".ep-curl");
|
||||||
|
if (row && pre) pre.textContent = buildCurl(row);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyText(btn, text) {
|
||||||
|
const done = () => {
|
||||||
|
btn.setAttribute("data-copied", "1");
|
||||||
|
const prev = btn.textContent;
|
||||||
|
btn.textContent = "✓ copied";
|
||||||
|
setTimeout(() => {
|
||||||
|
btn.removeAttribute("data-copied");
|
||||||
|
btn.textContent = prev;
|
||||||
|
}, 1200);
|
||||||
|
};
|
||||||
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||||
|
navigator.clipboard.writeText(text).then(done, () => {
|
||||||
|
// Fallback for non-secure contexts.
|
||||||
|
fallbackCopy(text);
|
||||||
|
done();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
fallbackCopy(text);
|
||||||
|
done();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function fallbackCopy(text) {
|
||||||
|
const ta = document.createElement("textarea");
|
||||||
|
ta.value = text;
|
||||||
|
ta.style.position = "fixed"; ta.style.opacity = "0";
|
||||||
|
document.body.appendChild(ta);
|
||||||
|
ta.select();
|
||||||
|
try { document.execCommand("copy"); } catch {}
|
||||||
|
document.body.removeChild(ta);
|
||||||
|
}
|
||||||
|
|
||||||
|
sel.addEventListener("change", () => {
|
||||||
|
const name = sel.value;
|
||||||
|
secret.placeholder = name
|
||||||
|
? "paste plaintext for '" + name + "' (we only store the hash)"
|
||||||
|
: "paste plaintext (we only store the Argon2 hash)";
|
||||||
|
refreshScopeMarks();
|
||||||
|
});
|
||||||
|
secret.addEventListener("input", refreshOpenCurls);
|
||||||
|
showCb.addEventListener("change", () => {
|
||||||
|
secret.type = showCb.checked ? "text" : "password";
|
||||||
|
});
|
||||||
|
|
||||||
|
card.addEventListener("click", (ev) => {
|
||||||
|
const btn = ev.target.closest("button[data-action]");
|
||||||
|
if (!btn) return;
|
||||||
|
const row = btn.closest("tr.ep-row");
|
||||||
|
if (!row) return;
|
||||||
|
const action = btn.getAttribute("data-action");
|
||||||
|
const url = row.getAttribute("data-url");
|
||||||
|
const tok = currentToken() || PLACEHOLDER;
|
||||||
|
if (action === "copy-url") {
|
||||||
|
copyText(btn, url);
|
||||||
|
} else if (action === "copy-url-token") {
|
||||||
|
const sep = url.indexOf("?") >= 0 ? "&" : "?";
|
||||||
|
copyText(btn, url + sep + "token=" + encodeURIComponent(tok));
|
||||||
|
} else if (action === "copy-curl") {
|
||||||
|
copyText(btn, buildCurl(row));
|
||||||
|
} else if (action === "toggle-curl") {
|
||||||
|
const curlRow = row.nextElementSibling;
|
||||||
|
if (!curlRow || !curlRow.classList.contains("ep-curl-row")) return;
|
||||||
|
const pre = curlRow.querySelector(".ep-curl");
|
||||||
|
if (curlRow.hidden) {
|
||||||
|
if (pre) pre.textContent = buildCurl(row);
|
||||||
|
curlRow.hidden = false;
|
||||||
|
btn.textContent = "▾ curl";
|
||||||
|
btn.setAttribute("aria-expanded", "true");
|
||||||
|
} else {
|
||||||
|
curlRow.hidden = true;
|
||||||
|
btn.textContent = "▸ curl";
|
||||||
|
btn.setAttribute("aria-expanded", "false");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
refreshScopeMarks();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>beaver-gateway · log in</title>
|
||||||
|
<style>
|
||||||
|
html, body { margin: 0; padding: 0; height: 100%; background: #fbfbfd; color: #1d1d1f; }
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, "SF Pro Display", system-ui, sans-serif;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e5e5ea;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 2rem 2.25rem;
|
||||||
|
width: min(360px, 92vw);
|
||||||
|
box-shadow: 0 10px 24px rgba(0,0,0,0.04);
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
margin: 0 0 0.25rem;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
p.muted { color: #6e6e73; margin: 0 0 1.4rem; font-size: 0.92em; }
|
||||||
|
label { display: block; margin: 0.85rem 0 0.3rem; font-size: 0.82em; color: #6e6e73; }
|
||||||
|
input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.6rem 0.75rem;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.95em;
|
||||||
|
border: 1px solid #e5e5ea;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
input:focus { outline: 2px solid #0071e3; outline-offset: 1px; }
|
||||||
|
button {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 1.25rem;
|
||||||
|
padding: 0.65rem;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.95em;
|
||||||
|
background: #0071e3;
|
||||||
|
border: 1px solid #0071e3;
|
||||||
|
color: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
button:hover { background: #005bb5; }
|
||||||
|
.error {
|
||||||
|
background: #fff0f0;
|
||||||
|
border: 1px solid #f5c2c5;
|
||||||
|
color: #d70015;
|
||||||
|
padding: 0.65rem 0.85rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.9em;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="card">
|
||||||
|
<h1>beaver-gateway</h1>
|
||||||
|
<p class="muted">Sign in to manage tokens and view audit logs.</p>
|
||||||
|
{% if error %}
|
||||||
|
<div class="error">{{ error }}</div>
|
||||||
|
{% endif %}
|
||||||
|
<form method="post" action="/login">
|
||||||
|
<label for="username">Username</label>
|
||||||
|
<input id="username" name="username" type="text" autocomplete="username" required autofocus>
|
||||||
|
<label for="password">Password</label>
|
||||||
|
<input id="password" name="password" type="password" autocomplete="current-password" required>
|
||||||
|
<button type="submit">Sign in</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
{% extends "_layout.html" %}
|
||||||
|
{% set active = "tokens" %}
|
||||||
|
{% block title %}beaver-gateway · tokens{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h2>Create token</h2>
|
||||||
|
<div class="card">
|
||||||
|
<p class="muted">Plaintext is shown <strong>once</strong>, immediately after creation. Copy it before you navigate away — the database only ever holds the Argon2 hash.</p>
|
||||||
|
<div id="token-create-result"></div>
|
||||||
|
<form
|
||||||
|
hx-post="/tokens"
|
||||||
|
hx-target="#token-create-result"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
hx-on::after-request="if(event.detail.successful){this.reset();}"
|
||||||
|
>
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf }}">
|
||||||
|
<div class="form-grid">
|
||||||
|
<div>
|
||||||
|
<label for="name">Name</label>
|
||||||
|
<input id="name" name="name" type="text" placeholder="cursor / claude-desktop / mobile …" required>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="scope">Scope</label>
|
||||||
|
<select id="scope" name="scope">
|
||||||
|
{% for s in scopes %}
|
||||||
|
<option value="{{ s }}" {% if s == "*" %}selected{% endif %}>{{ s }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button class="primary" type="submit">Create</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>
|
||||||
|
Tokens
|
||||||
|
<span class="muted" style="font-size:0.8em; font-weight:400; margin-left:0.5rem;">
|
||||||
|
{% if include_revoked %}
|
||||||
|
<a href="/tokens">Hide revoked</a>
|
||||||
|
{% else %}
|
||||||
|
<a href="/tokens?include_revoked=1">Show revoked</a>
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
|
</h2>
|
||||||
|
<div class="card">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th><th>Scope</th><th>Created</th><th>Last used</th><th>Revoked</th><th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
{# Render the tbody unconditionally so the HTMX OOB swap on
|
||||||
|
create has a target even when the table starts empty. #}
|
||||||
|
<tbody id="tokens-rows">
|
||||||
|
{% for token in tokens %}
|
||||||
|
{% include "_token_row.html" %}
|
||||||
|
{% else %}
|
||||||
|
<tr id="tokens-empty"><td colspan="6" class="muted">No tokens yet. Create one above.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -40,6 +40,7 @@ from anthropic.types import (
|
|||||||
from fastapi import FastAPI, HTTPException, Request, status
|
from fastapi import FastAPI, HTTPException, Request, status
|
||||||
from fastapi.responses import JSONResponse, StreamingResponse
|
from fastapi.responses import JSONResponse, StreamingResponse
|
||||||
|
|
||||||
|
from beaver_gateway.core import audit
|
||||||
from beaver_gateway.frontends.base import Frontend
|
from beaver_gateway.frontends.base import Frontend
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -58,9 +59,24 @@ __all__ = ["AnthropicMessagesFrontend"]
|
|||||||
class AnthropicMessagesFrontend(Frontend):
|
class AnthropicMessagesFrontend(Frontend):
|
||||||
"""FastAPI app behind ``POST /v1/messages`` + ``GET /v1/models``."""
|
"""FastAPI app behind ``POST /v1/messages`` + ``GET /v1/models``."""
|
||||||
|
|
||||||
def __init__(self, *, host: str = "0.0.0.0", port: int = 8000) -> None: # noqa: S104
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
host: str = "0.0.0.0", # noqa: S104
|
||||||
|
port: int = 8000,
|
||||||
|
public_base_url: str | None = None,
|
||||||
|
) -> None:
|
||||||
self.host = host
|
self.host = host
|
||||||
self.port = port
|
self.port = port
|
||||||
|
# External URL prefix the reverse proxy (Caddy/nginx/Cloudflare/…)
|
||||||
|
# uses to reach this frontend, e.g. ``https://api.example.com/ai``.
|
||||||
|
# The frontend's internal paths (``/v1/messages``, ``/v1/models``)
|
||||||
|
# are appended to it when the admin dashboard renders copy-pastable
|
||||||
|
# URLs. Trailing slash is stripped so the concatenation is
|
||||||
|
# idempotent. ``None`` means "advertise raw ``host:port``" (dev /
|
||||||
|
# no proxy) — the dashboard then derives the base from the
|
||||||
|
# browser's own request hostname.
|
||||||
|
self.public_base_url = public_base_url.rstrip("/") if public_base_url else None
|
||||||
self._runtime: GatewayRuntime | None = None
|
self._runtime: GatewayRuntime | None = None
|
||||||
self._app: FastAPI | None = None
|
self._app: FastAPI | None = None
|
||||||
|
|
||||||
@@ -93,7 +109,7 @@ class AnthropicMessagesFrontend(Frontend):
|
|||||||
|
|
||||||
@app.get("/v1/models")
|
@app.get("/v1/models")
|
||||||
async def list_models(request: Request) -> dict[str, Any]:
|
async def list_models(request: Request) -> dict[str, Any]:
|
||||||
_require_token(request, runtime)
|
await _require_token(request, runtime, scope="messages")
|
||||||
data = [
|
data = [
|
||||||
{
|
{
|
||||||
"type": "model",
|
"type": "model",
|
||||||
@@ -107,7 +123,7 @@ class AnthropicMessagesFrontend(Frontend):
|
|||||||
|
|
||||||
@app.post("/v1/messages")
|
@app.post("/v1/messages")
|
||||||
async def create_message(request: Request) -> Any:
|
async def create_message(request: Request) -> Any:
|
||||||
token_name = _require_token(request, runtime)
|
token_name = await _require_token(request, runtime, scope="messages")
|
||||||
try:
|
try:
|
||||||
body = await request.json()
|
body = await request.json()
|
||||||
except json.JSONDecodeError as exc:
|
except json.JSONDecodeError as exc:
|
||||||
@@ -148,6 +164,18 @@ class AnthropicMessagesFrontend(Frontend):
|
|||||||
stream_flag,
|
stream_flag,
|
||||||
len(messages),
|
len(messages),
|
||||||
)
|
)
|
||||||
|
# Record at request acceptance, not at stream completion:
|
||||||
|
# a long streaming response can be aborted mid-flight by
|
||||||
|
# the client, and we still want the row in the audit trail.
|
||||||
|
# Detail stays small — no message bodies, no system prompt.
|
||||||
|
await audit.log(
|
||||||
|
runtime,
|
||||||
|
actor=f"token:{token_name}",
|
||||||
|
kind="messages",
|
||||||
|
agent_name=agent.name,
|
||||||
|
stream=stream_flag,
|
||||||
|
msgs=len(messages),
|
||||||
|
)
|
||||||
|
|
||||||
# Forward per-request knobs the Anthropic body may carry —
|
# Forward per-request knobs the Anthropic body may carry —
|
||||||
# backend adapters layer these over per-agent defaults. Only
|
# backend adapters layer these over per-agent defaults. Only
|
||||||
@@ -166,37 +194,45 @@ class AnthropicMessagesFrontend(Frontend):
|
|||||||
)
|
)
|
||||||
|
|
||||||
if stream_flag:
|
if stream_flag:
|
||||||
return StreamingResponse(
|
return StreamingResponse(_sse(events), media_type="text/event-stream")
|
||||||
_sse(events), media_type="text/event-stream"
|
|
||||||
)
|
|
||||||
message = await _accumulate(events, model=model)
|
message = await _accumulate(events, model=model)
|
||||||
return JSONResponse(content=message.model_dump(mode="json"))
|
return JSONResponse(content=message.model_dump(mode="json"))
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|
||||||
|
|
||||||
def _require_token(request: Request, runtime: GatewayRuntime) -> str:
|
async def _require_token(
|
||||||
"""Verify the request's bearer and return the token's audit name.
|
request: Request, runtime: GatewayRuntime, *, scope: str
|
||||||
|
) -> str:
|
||||||
|
"""Verify the request's bearer + scope, return the token's audit name.
|
||||||
|
|
||||||
Accepts both ``X-Api-Key: <token>`` (what the official Anthropic
|
Accepts both ``X-Api-Key: <token>`` (what the official Anthropic
|
||||||
SDK sends — LibreChat, the CLI, third-party clients) and
|
SDK sends — LibreChat, the CLI, third-party clients) and
|
||||||
``Authorization: Bearer <token>`` (curl, Cursor). Raises 401 on
|
``Authorization: Bearer <token>`` (curl, Cursor). 401 on missing /
|
||||||
miss. ``TokenStore`` doesn't know about HTTP, so response shape
|
unknown token; 403 on a known token whose scope doesn't cover
|
||||||
is owned here.
|
``scope`` (Phase 4.3 — bootstrap tokens get ``"*"`` and pass
|
||||||
|
everything).
|
||||||
"""
|
"""
|
||||||
api_key = request.headers.get("x-api-key")
|
api_key = request.headers.get("x-api-key")
|
||||||
name = (
|
identity = (
|
||||||
runtime.token_store.verify(api_key)
|
await runtime.token_store.verify(api_key)
|
||||||
if api_key
|
if api_key
|
||||||
else runtime.token_store.verify_bearer(request.headers.get("authorization"))
|
else await runtime.token_store.verify_bearer(
|
||||||
|
request.headers.get("authorization")
|
||||||
|
)
|
||||||
)
|
)
|
||||||
if name is None:
|
if identity is None:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status.HTTP_401_UNAUTHORIZED,
|
status.HTTP_401_UNAUTHORIZED,
|
||||||
"invalid or missing bearer token",
|
"invalid or missing bearer token",
|
||||||
headers={"WWW-Authenticate": "Bearer"},
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
)
|
)
|
||||||
return name
|
if not identity.allows(scope):
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_403_FORBIDDEN,
|
||||||
|
f"token scope {identity.scope!r} does not cover {scope!r}",
|
||||||
|
)
|
||||||
|
return identity.name
|
||||||
|
|
||||||
|
|
||||||
async def _sse(events: AsyncIterator[MessageStreamEvent]) -> AsyncIterator[bytes]:
|
async def _sse(events: AsyncIterator[MessageStreamEvent]) -> AsyncIterator[bytes]:
|
||||||
|
|||||||
@@ -15,11 +15,12 @@ from dataclasses import dataclass, field
|
|||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import Mapping
|
from collections.abc import Mapping, Sequence
|
||||||
|
|
||||||
from beaver_gateway.backends.base import Backend
|
from beaver_gateway.backends.base import Backend
|
||||||
from beaver_gateway.core.auth import TokenStore
|
from beaver_gateway.core.auth import TokenStore
|
||||||
from beaver_gateway.core.registry import AgentRegistry, McpRegistry
|
from beaver_gateway.core.registry import AgentRegistry, McpRegistry
|
||||||
|
from beaver_gateway.storage import Database
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
@@ -35,13 +36,32 @@ class GatewayRuntime:
|
|||||||
declared ``McpServer`` so ``ClaudeCodeBackendAdapter`` (Phase 2.2)
|
declared ``McpServer`` so ``ClaudeCodeBackendAdapter`` (Phase 2.2)
|
||||||
can pass them to ``BackendOptions.mcp_servers`` without re-running
|
can pass them to ``BackendOptions.mcp_servers`` without re-running
|
||||||
discovery.
|
discovery.
|
||||||
|
|
||||||
|
``db`` (Phase 4.1) is the shared :class:`Database` handle. Phase 4.2
|
||||||
|
will switch ``TokenStore`` to read from it; Phase 4.3 admin/audit
|
||||||
|
write through it. Phase 4.1 only attaches it — existing frontends
|
||||||
|
ignore it.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
agents: AgentRegistry
|
agents: AgentRegistry
|
||||||
mcps: McpRegistry
|
mcps: McpRegistry
|
||||||
backends: dict[str, Backend]
|
backends: dict[str, Backend]
|
||||||
token_store: TokenStore
|
token_store: TokenStore
|
||||||
|
db: Database
|
||||||
mcp_internal_urls: Mapping[str, str] = field(default_factory=dict)
|
mcp_internal_urls: Mapping[str, str] = field(default_factory=dict)
|
||||||
|
# Phase 4.3 — AdminFrontend reads creds + cookie-signing key from
|
||||||
|
# the runtime so the user's ``config.py`` doesn't have to know
|
||||||
|
# anything about env wiring. Defaulted to empty so existing tests /
|
||||||
|
# call sites that don't touch the admin path keep building; the
|
||||||
|
# admin frontend ``configure()`` itself rejects empty values.
|
||||||
|
admin_user: str = ""
|
||||||
|
admin_pass: str = ""
|
||||||
|
session_secret: str = ""
|
||||||
|
# The full sibling-frontends list, in declaration order. AdminFrontend
|
||||||
|
# uses it to advertise concrete bearer-endpoint URLs (host/port) on
|
||||||
|
# the dashboard so the operator can copy ready-to-use links / curl
|
||||||
|
# snippets. Other frontends ignore it.
|
||||||
|
frontends: Sequence[Frontend] = field(default_factory=tuple)
|
||||||
|
|
||||||
|
|
||||||
class Frontend(ABC):
|
class Frontend(ABC):
|
||||||
|
|||||||
@@ -1,11 +1,18 @@
|
|||||||
"""External MCP frontend (Phase 3.1).
|
"""External MCP frontend (Phase 3.1).
|
||||||
|
|
||||||
A streamable-HTTP gateway in front of the internal MCP aggregator
|
A streamable-HTTP gateway in front of the internal MCP aggregator
|
||||||
(``beaver_gateway.mcp.internal_app``). The aggregator already hosts
|
(``beaver_gateway.mcp.internal_app``). The aggregator hosts every
|
||||||
every declared ``McpServer`` (``python_tool``, stdio proxy, HTTP proxy)
|
declared ``McpServer`` (``python_tool``, stdio proxy, HTTP proxy)
|
||||||
under ``/mcp/<name>`` plus a flat ``/mcp/all`` bundle on
|
under ``/mcp/<name>`` plus a flat ``/mcp/all`` bundle on
|
||||||
``127.0.0.1:INTERNAL_MCP_PORT``. This frontend re-exposes those URLs to
|
``127.0.0.1:INTERNAL_MCP_PORT`` — that's the *internal* shape.
|
||||||
external clients with three additions:
|
|
||||||
|
This frontend re-exposes those namespaces on its own port directly at
|
||||||
|
``/<name>/`` (no ``/mcp/`` prefix in the external routes — the port
|
||||||
|
itself already disambiguates). Caddy / nginx / Cloudflare in front
|
||||||
|
typically strips a prefix back on: ``domain.com/mcp/* → :8001/*``,
|
||||||
|
controlled by the operator's reverse-proxy config and surfaced to the
|
||||||
|
admin dashboard via ``public_base_url``. Three additions on top of the
|
||||||
|
raw aggregator:
|
||||||
|
|
||||||
* **Bearer auth** — ``Authorization: Bearer <token>``, ``X-Api-Key``,
|
* **Bearer auth** — ``Authorization: Bearer <token>``, ``X-Api-Key``,
|
||||||
or ``?token=<…>`` query string. All three forms verify against the
|
or ``?token=<…>`` query string. All three forms verify against the
|
||||||
@@ -39,6 +46,7 @@ from starlette.applications import Starlette
|
|||||||
from starlette.responses import HTMLResponse, JSONResponse, StreamingResponse
|
from starlette.responses import HTMLResponse, JSONResponse, StreamingResponse
|
||||||
from starlette.routing import Route
|
from starlette.routing import Route
|
||||||
|
|
||||||
|
from beaver_gateway.core import audit
|
||||||
from beaver_gateway.frontends.base import Frontend
|
from beaver_gateway.frontends.base import Frontend
|
||||||
from beaver_gateway.mcp.internal_app import ALL_NAMESPACE
|
from beaver_gateway.mcp.internal_app import ALL_NAMESPACE
|
||||||
|
|
||||||
@@ -86,9 +94,30 @@ __all__ = ["McpServerFrontend"]
|
|||||||
class McpServerFrontend(Frontend):
|
class McpServerFrontend(Frontend):
|
||||||
"""Auth + audit + reverse-proxy in front of the internal MCP aggregator."""
|
"""Auth + audit + reverse-proxy in front of the internal MCP aggregator."""
|
||||||
|
|
||||||
def __init__(self, *, host: str = "0.0.0.0", port: int = 8001) -> None: # noqa: S104
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
host: str = "0.0.0.0", # noqa: S104
|
||||||
|
port: int = 8001,
|
||||||
|
public_base_url: str | None = None,
|
||||||
|
) -> None:
|
||||||
self.host = host
|
self.host = host
|
||||||
self.port = port
|
self.port = port
|
||||||
|
# External URL prefix the reverse proxy uses to reach this
|
||||||
|
# frontend. Internal routes mount namespaces at the port root
|
||||||
|
# (``/<ns>/`` and ``/all/``) — Caddy / nginx / Cloudflare in
|
||||||
|
# front decides what external prefix they sit under. Typical
|
||||||
|
# symmetric setup:
|
||||||
|
#
|
||||||
|
# Caddy: handle_path /mcp/* { reverse_proxy localhost:8001 }
|
||||||
|
# config: public_base_url -> https://api.example.com/mcp
|
||||||
|
# dashboard advertises: https://api.example.com/mcp/<ns>/
|
||||||
|
#
|
||||||
|
# The frontend's ``/<ns>/`` segment gets appended verbatim. Set
|
||||||
|
# it to whatever matches your proxy. ``None`` means "advertise
|
||||||
|
# raw ``host:port`` derived from the inbound request" (dev /
|
||||||
|
# no proxy).
|
||||||
|
self.public_base_url = public_base_url.rstrip("/") if public_base_url else None
|
||||||
self._runtime: GatewayRuntime | None = None
|
self._runtime: GatewayRuntime | None = None
|
||||||
self._app: Starlette | None = None
|
self._app: Starlette | None = None
|
||||||
# Single shared aiohttp session, opened once when uvicorn starts
|
# Single shared aiohttp session, opened once when uvicorn starts
|
||||||
@@ -126,7 +155,7 @@ class McpServerFrontend(Frontend):
|
|||||||
# Long sock_read because MCP tool calls can take a while
|
# Long sock_read because MCP tool calls can take a while
|
||||||
# (a claude tool over HTTP can easily stretch beyond 30s
|
# (a claude tool over HTTP can easily stretch beyond 30s
|
||||||
# on a real tool).
|
# on a real tool).
|
||||||
timeout=aiohttp.ClientTimeout(total=None, sock_read=600),
|
timeout=aiohttp.ClientTimeout(total=None, sock_read=600)
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
yield
|
yield
|
||||||
@@ -138,16 +167,21 @@ class McpServerFrontend(Frontend):
|
|||||||
routes = [
|
routes = [
|
||||||
Route("/", self._discovery, methods=["GET"]),
|
Route("/", self._discovery, methods=["GET"]),
|
||||||
Route("/healthz", self._healthz, methods=["GET"]),
|
Route("/healthz", self._healthz, methods=["GET"]),
|
||||||
# Two routes per namespace so both the trailing-slash and
|
# Namespaces mount at the root of this port — the port
|
||||||
# sub-path forms work (``/mcp/time`` AND ``/mcp/time/foo``).
|
# itself already disambiguates this from any other gateway
|
||||||
# Starlette doesn't fold them into one route automatically.
|
# surface. Two routes per namespace so both the
|
||||||
|
# trailing-slash and sub-path forms work (``/time`` AND
|
||||||
|
# ``/time/foo``); Starlette doesn't fold them into one
|
||||||
|
# route automatically. The literal routes above (``/``,
|
||||||
|
# ``/healthz``) are listed first and win the match, so
|
||||||
|
# they're not eaten by ``/{namespace}``.
|
||||||
Route(
|
Route(
|
||||||
"/mcp/{namespace}",
|
"/{namespace}",
|
||||||
self._proxy_endpoint,
|
self._proxy_endpoint,
|
||||||
methods=["GET", "POST", "DELETE", "OPTIONS"],
|
methods=["GET", "POST", "DELETE", "OPTIONS"],
|
||||||
),
|
),
|
||||||
Route(
|
Route(
|
||||||
"/mcp/{namespace}/{path:path}",
|
"/{namespace}/{path:path}",
|
||||||
self._proxy_endpoint,
|
self._proxy_endpoint,
|
||||||
methods=["GET", "POST", "DELETE", "OPTIONS"],
|
methods=["GET", "POST", "DELETE", "OPTIONS"],
|
||||||
),
|
),
|
||||||
@@ -159,17 +193,19 @@ class McpServerFrontend(Frontend):
|
|||||||
|
|
||||||
async def _discovery(self, request: Request) -> HTMLResponse | JSONResponse:
|
async def _discovery(self, request: Request) -> HTMLResponse | JSONResponse:
|
||||||
runtime = self._require_runtime()
|
runtime = self._require_runtime()
|
||||||
token_name = _verify_request(request, runtime)
|
token_name, err = await _verify_request(request, runtime)
|
||||||
if token_name is None:
|
if err is not None:
|
||||||
return _unauthorized()
|
return err
|
||||||
# Use the request's own scheme+host so the snippets work behind
|
assert token_name is not None # noqa: S101 — narrow for ty
|
||||||
# reverse proxies / tunnels. Falls back to the configured
|
# ``public_base_url`` wins if configured — it's the operator's
|
||||||
# host:port if the client didn't send Host (curl --raw).
|
# explicit statement of "this is the URL my reverse proxy puts
|
||||||
base = _external_base_url(request, self.host, self.port)
|
# in front of me". Otherwise: use the request's own scheme+host
|
||||||
|
# so snippets work behind generic reverse proxies / tunnels;
|
||||||
|
# and fall back to the configured host:port if the client
|
||||||
|
# didn't send Host (curl --raw).
|
||||||
|
base = self.public_base_url or _external_base_url(request, self.host, self.port)
|
||||||
html = _render_discovery_page(
|
html = _render_discovery_page(
|
||||||
base_url=base,
|
base_url=base, namespaces=list(runtime.mcps), actor=token_name
|
||||||
namespaces=list(runtime.mcps),
|
|
||||||
actor=token_name,
|
|
||||||
)
|
)
|
||||||
return HTMLResponse(html)
|
return HTMLResponse(html)
|
||||||
|
|
||||||
@@ -177,9 +213,10 @@ class McpServerFrontend(Frontend):
|
|||||||
self, request: Request
|
self, request: Request
|
||||||
) -> StreamingResponse | JSONResponse:
|
) -> StreamingResponse | JSONResponse:
|
||||||
runtime = self._require_runtime()
|
runtime = self._require_runtime()
|
||||||
token_name = _verify_request(request, runtime)
|
token_name, err = await _verify_request(request, runtime)
|
||||||
if token_name is None:
|
if err is not None:
|
||||||
return _unauthorized()
|
return err
|
||||||
|
assert token_name is not None # noqa: S101 — narrow for ty
|
||||||
|
|
||||||
namespace = request.path_params["namespace"]
|
namespace = request.path_params["namespace"]
|
||||||
subpath = request.path_params.get("path", "")
|
subpath = request.path_params.get("path", "")
|
||||||
@@ -190,16 +227,21 @@ class McpServerFrontend(Frontend):
|
|||||||
token_name,
|
token_name,
|
||||||
namespace,
|
namespace,
|
||||||
)
|
)
|
||||||
|
await audit.log(
|
||||||
|
runtime,
|
||||||
|
actor=f"token:{token_name}",
|
||||||
|
kind="mcp_call",
|
||||||
|
namespace=namespace,
|
||||||
|
method=request.method,
|
||||||
|
status=404,
|
||||||
|
)
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
{"error": "unknown namespace", "namespace": namespace},
|
{"error": "unknown namespace", "namespace": namespace}, status_code=404
|
||||||
status_code=404,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if self._http is None:
|
if self._http is None:
|
||||||
# Lifespan hasn't run yet (shouldn't happen with uvicorn).
|
# Lifespan hasn't run yet (shouldn't happen with uvicorn).
|
||||||
return JSONResponse(
|
return JSONResponse({"error": "frontend not ready"}, status_code=503)
|
||||||
{"error": "frontend not ready"}, status_code=503
|
|
||||||
)
|
|
||||||
|
|
||||||
return await _reverse_proxy(
|
return await _reverse_proxy(
|
||||||
client=self._http,
|
client=self._http,
|
||||||
@@ -207,6 +249,7 @@ class McpServerFrontend(Frontend):
|
|||||||
upstream_url=upstream_url,
|
upstream_url=upstream_url,
|
||||||
namespace=namespace,
|
namespace=namespace,
|
||||||
actor=token_name,
|
actor=token_name,
|
||||||
|
runtime=runtime,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _upstream_url(self, namespace: str, subpath: str) -> str | None:
|
def _upstream_url(self, namespace: str, subpath: str) -> str | None:
|
||||||
@@ -233,23 +276,38 @@ class McpServerFrontend(Frontend):
|
|||||||
return self._runtime
|
return self._runtime
|
||||||
|
|
||||||
|
|
||||||
def _verify_request(request: Request, runtime: GatewayRuntime) -> str | None:
|
_MCP_SCOPE = "mcp"
|
||||||
|
|
||||||
|
|
||||||
|
async def _verify_request(
|
||||||
|
request: Request, runtime: GatewayRuntime
|
||||||
|
) -> tuple[str | None, JSONResponse | None]:
|
||||||
"""Accept ``Authorization: Bearer``, ``X-Api-Key``, or ``?token=``.
|
"""Accept ``Authorization: Bearer``, ``X-Api-Key``, or ``?token=``.
|
||||||
|
|
||||||
The third form is the escape hatch for clients that can only put
|
The third form is the escape hatch for clients that can only put
|
||||||
secrets in the URL (claude.ai's MCP config historically did this).
|
secrets in the URL (claude.ai's MCP config historically did this).
|
||||||
All three roads end at the same :class:`TokenStore`.
|
All three roads end at the same :class:`TokenStore`. Returns
|
||||||
|
``(actor_name, None)`` on success, ``(None, 401|403)`` otherwise
|
||||||
|
— the caller forwards the response as-is. Splitting auth vs scope
|
||||||
|
failures matters: 401 says "send me a token", 403 says "this token
|
||||||
|
is real but not for this endpoint".
|
||||||
"""
|
"""
|
||||||
api_key = request.headers.get("x-api-key")
|
api_key = request.headers.get("x-api-key")
|
||||||
if api_key:
|
if api_key:
|
||||||
return runtime.token_store.verify(api_key)
|
identity = await runtime.token_store.verify(api_key)
|
||||||
auth_header = request.headers.get("authorization")
|
else:
|
||||||
if auth_header:
|
auth_header = request.headers.get("authorization")
|
||||||
return runtime.token_store.verify_bearer(auth_header)
|
if auth_header:
|
||||||
qs_token = request.query_params.get("token")
|
identity = await runtime.token_store.verify_bearer(auth_header)
|
||||||
if qs_token:
|
else:
|
||||||
return runtime.token_store.verify(qs_token)
|
qs_token = request.query_params.get("token")
|
||||||
return None
|
identity = await runtime.token_store.verify(qs_token) if qs_token else None
|
||||||
|
|
||||||
|
if identity is None:
|
||||||
|
return None, _unauthorized()
|
||||||
|
if not identity.allows(_MCP_SCOPE):
|
||||||
|
return None, _forbidden(identity.scope, _MCP_SCOPE)
|
||||||
|
return identity.name, None
|
||||||
|
|
||||||
|
|
||||||
def _unauthorized() -> JSONResponse:
|
def _unauthorized() -> JSONResponse:
|
||||||
@@ -260,6 +318,13 @@ def _unauthorized() -> JSONResponse:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _forbidden(scope: str, required: str) -> JSONResponse:
|
||||||
|
return JSONResponse(
|
||||||
|
{"error": "insufficient scope", "scope": scope, "required": required},
|
||||||
|
status_code=403,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _join_subpath(base_url: str, subpath: str) -> str:
|
def _join_subpath(base_url: str, subpath: str) -> str:
|
||||||
"""Concatenate the loopback URL with the proxied sub-path.
|
"""Concatenate the loopback URL with the proxied sub-path.
|
||||||
|
|
||||||
@@ -287,6 +352,7 @@ async def _reverse_proxy(
|
|||||||
upstream_url: str,
|
upstream_url: str,
|
||||||
namespace: str,
|
namespace: str,
|
||||||
actor: str,
|
actor: str,
|
||||||
|
runtime: GatewayRuntime,
|
||||||
) -> StreamingResponse | JSONResponse:
|
) -> StreamingResponse | JSONResponse:
|
||||||
"""Bidirectionally stream an MCP request between client ↔ internal aggregator.
|
"""Bidirectionally stream an MCP request between client ↔ internal aggregator.
|
||||||
|
|
||||||
@@ -325,9 +391,16 @@ async def _reverse_proxy(
|
|||||||
namespace,
|
namespace,
|
||||||
exc,
|
exc,
|
||||||
)
|
)
|
||||||
|
await audit.log(
|
||||||
|
runtime,
|
||||||
|
actor=f"token:{actor}",
|
||||||
|
kind="mcp_call",
|
||||||
|
namespace=namespace,
|
||||||
|
method=request.method,
|
||||||
|
status=502,
|
||||||
|
)
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
{"error": "upstream MCP unreachable", "detail": str(exc)},
|
{"error": "upstream MCP unreachable", "detail": str(exc)}, status_code=502
|
||||||
status_code=502,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
_log.info(
|
_log.info(
|
||||||
@@ -338,6 +411,17 @@ async def _reverse_proxy(
|
|||||||
request.url.path,
|
request.url.path,
|
||||||
upstream_resp.status,
|
upstream_resp.status,
|
||||||
)
|
)
|
||||||
|
# Audit at upstream-response time: status reflects the MCP call's
|
||||||
|
# outcome (200 / tool-error / 4xx). Streaming relay below may be
|
||||||
|
# cut short by the client, but the row is already in by then.
|
||||||
|
await audit.log(
|
||||||
|
runtime,
|
||||||
|
actor=f"token:{actor}",
|
||||||
|
kind="mcp_call",
|
||||||
|
namespace=namespace,
|
||||||
|
method=request.method,
|
||||||
|
status=upstream_resp.status,
|
||||||
|
)
|
||||||
|
|
||||||
response_headers = _response_headers(upstream_resp.headers)
|
response_headers = _response_headers(upstream_resp.headers)
|
||||||
|
|
||||||
@@ -397,29 +481,31 @@ def _scrub_query(query: str, *, drop: frozenset[str] | set[str]) -> str:
|
|||||||
return urlencode(kept)
|
return urlencode(kept)
|
||||||
|
|
||||||
|
|
||||||
def _render_discovery_page(
|
def _render_discovery_page(*, base_url: str, namespaces: list[Any], actor: str) -> str:
|
||||||
*, base_url: str, namespaces: list[Any], actor: str
|
|
||||||
) -> str:
|
|
||||||
"""Render the auth-gated namespace + config-snippet page.
|
"""Render the auth-gated namespace + config-snippet page.
|
||||||
|
|
||||||
Inline HTML (no Jinja file) — keeps Phase 3 free of template-dir
|
Inline HTML (no Jinja file) — keeps Phase 3 free of template-dir
|
||||||
plumbing that Phase 4's AdminFrontend will own.
|
plumbing that Phase 4's AdminFrontend will own.
|
||||||
"""
|
"""
|
||||||
name_list = [getattr(ns, "name", str(ns)) for ns in namespaces]
|
name_list = [getattr(ns, "name", str(ns)) for ns in namespaces]
|
||||||
rows = "\n".join(
|
rows = (
|
||||||
f""" <tr>
|
"\n".join(
|
||||||
|
f""" <tr>
|
||||||
<td><code>{_escape(name)}</code></td>
|
<td><code>{_escape(name)}</code></td>
|
||||||
<td><code>{_escape(f"{base_url}/mcp/{name}/")}</code></td>
|
<td><code>{_escape(f"{base_url}/{name}/")}</code></td>
|
||||||
</tr>"""
|
</tr>"""
|
||||||
for name in name_list
|
for name in name_list
|
||||||
) or """ <tr><td colspan="2"><em>No MCP servers configured.</em></td></tr>"""
|
)
|
||||||
|
or """ \
|
||||||
|
<tr><td colspan="2"><em>No MCP servers configured.</em></td></tr>"""
|
||||||
|
)
|
||||||
cursor_snippet = _CURSOR_SNIPPET.format(base_url=base_url)
|
cursor_snippet = _CURSOR_SNIPPET.format(base_url=base_url)
|
||||||
claude_desktop_snippet = _CLAUDE_DESKTOP_SNIPPET.format(base_url=base_url)
|
claude_desktop_snippet = _CLAUDE_DESKTOP_SNIPPET.format(base_url=base_url)
|
||||||
return _DISCOVERY_TEMPLATE.format(
|
return _DISCOVERY_TEMPLATE.format(
|
||||||
actor=_escape(actor),
|
actor=_escape(actor),
|
||||||
base_url=_escape(base_url),
|
base_url=_escape(base_url),
|
||||||
rows=rows,
|
rows=rows,
|
||||||
all_url=_escape(f"{base_url}/mcp/{ALL_NAMESPACE}/"),
|
all_url=_escape(f"{base_url}/{ALL_NAMESPACE}/"),
|
||||||
cursor_snippet=_escape(cursor_snippet),
|
cursor_snippet=_escape(cursor_snippet),
|
||||||
claude_desktop_snippet=_escape(claude_desktop_snippet),
|
claude_desktop_snippet=_escape(claude_desktop_snippet),
|
||||||
)
|
)
|
||||||
@@ -443,8 +529,7 @@ _DISCOVERY_TEMPLATE = "\n".join( # noqa: FLY002 — readability beats one-strin
|
|||||||
" <title>beaver-gateway · MCP discovery</title>",
|
" <title>beaver-gateway · MCP discovery</title>",
|
||||||
" <style>",
|
" <style>",
|
||||||
" body {{",
|
" body {{",
|
||||||
" font-family: -apple-system, 'SF Pro Display', system-ui,"
|
" font-family: -apple-system, 'SF Pro Display', system-ui, sans-serif;",
|
||||||
" sans-serif;",
|
|
||||||
" max-width: 880px;",
|
" max-width: 880px;",
|
||||||
" margin: 3rem auto;",
|
" margin: 3rem auto;",
|
||||||
" padding: 0 1.25rem;",
|
" padding: 0 1.25rem;",
|
||||||
@@ -502,7 +587,7 @@ _DISCOVERY_TEMPLATE = "\n".join( # noqa: FLY002 — readability beats one-strin
|
|||||||
_CURSOR_SNIPPET = """{{
|
_CURSOR_SNIPPET = """{{
|
||||||
"mcpServers": {{
|
"mcpServers": {{
|
||||||
"beaver-time": {{
|
"beaver-time": {{
|
||||||
"url": "{base_url}/mcp/time/",
|
"url": "{base_url}/time/",
|
||||||
"headers": {{ "Authorization": "Bearer <YOUR_TOKEN>" }}
|
"headers": {{ "Authorization": "Bearer <YOUR_TOKEN>" }}
|
||||||
}}
|
}}
|
||||||
}}
|
}}
|
||||||
@@ -513,7 +598,7 @@ _CLAUDE_DESKTOP_SNIPPET = """{{
|
|||||||
"mcpServers": {{
|
"mcpServers": {{
|
||||||
"beaver-time": {{
|
"beaver-time": {{
|
||||||
"type": "http",
|
"type": "http",
|
||||||
"url": "{base_url}/mcp/time/",
|
"url": "{base_url}/time/",
|
||||||
"headers": {{ "Authorization": "Bearer <YOUR_TOKEN>" }}
|
"headers": {{ "Authorization": "Bearer <YOUR_TOKEN>" }}
|
||||||
}}
|
}}
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
"""SQLModel-backed persistence (Phase 4.1).
|
||||||
|
|
||||||
|
The storage layer carries three tables — :class:`Token`, :class:`Session`,
|
||||||
|
:class:`AuditLog` — and a thin :class:`Database` wrapper around a sync
|
||||||
|
SQLAlchemy engine. Phase 4.2 (auth migration) and Phase 4.3 (admin UI)
|
||||||
|
build on this; Phase 4.1 itself only schemas the data and exposes the
|
||||||
|
``Database`` on :class:`GatewayRuntime` so later phases can reach it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from beaver_gateway.storage.db import (
|
||||||
|
Database,
|
||||||
|
append_audit,
|
||||||
|
close_session,
|
||||||
|
create_token,
|
||||||
|
list_active_tokens,
|
||||||
|
list_audit_records,
|
||||||
|
list_tokens,
|
||||||
|
revoke_token,
|
||||||
|
touch_session,
|
||||||
|
touch_token,
|
||||||
|
upsert_session,
|
||||||
|
)
|
||||||
|
from beaver_gateway.storage.models import AuditLog, Session, Token
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"AuditLog",
|
||||||
|
"Database",
|
||||||
|
"Session",
|
||||||
|
"Token",
|
||||||
|
"append_audit",
|
||||||
|
"close_session",
|
||||||
|
"create_token",
|
||||||
|
"list_active_tokens",
|
||||||
|
"list_audit_records",
|
||||||
|
"list_tokens",
|
||||||
|
"revoke_token",
|
||||||
|
"touch_session",
|
||||||
|
"touch_token",
|
||||||
|
"upsert_session",
|
||||||
|
]
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
"""Async ``Database`` wrapper + the bare-minimum CRUD helpers.
|
||||||
|
|
||||||
|
Async to match the rest of the stack (aiohttp, uvicorn, claude-code-api).
|
||||||
|
psycopg3 has native async support — ``postgresql+psycopg://...`` works
|
||||||
|
with ``create_async_engine`` directly. SQLite goes through ``aiosqlite``
|
||||||
|
(``sqlite+aiosqlite://...``); user-facing config still uses the plain
|
||||||
|
``sqlite:///`` form and we normalise the URL here, so nothing leaks into
|
||||||
|
``.env`` / docker-compose.
|
||||||
|
|
||||||
|
No repository layer (PLAN §4.1 explicitly waives it). Helpers take an
|
||||||
|
``AsyncSession`` so callers can batch operations into one transaction
|
||||||
|
(e.g. touch ``last_used_at`` + write an audit line on the same request).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import create_async_engine
|
||||||
|
from sqlmodel import SQLModel, select
|
||||||
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
|
|
||||||
|
from beaver_gateway.storage.models import AuditLog, Session, Token
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncEngine
|
||||||
|
|
||||||
|
|
||||||
|
def _utcnow() -> datetime:
|
||||||
|
return datetime.now(UTC)
|
||||||
|
|
||||||
|
|
||||||
|
def _to_async_url(url: str) -> str:
|
||||||
|
"""Promote the user-facing sync URL to its async driver variant.
|
||||||
|
|
||||||
|
Users write ``sqlite:///gw.db`` or ``postgresql://...`` in ``.env``;
|
||||||
|
we translate to ``sqlite+aiosqlite://`` / ``postgresql+psycopg://``
|
||||||
|
so they don't have to know which driver we use internally.
|
||||||
|
"""
|
||||||
|
if url.startswith(("sqlite+aiosqlite://", "postgresql+psycopg://")):
|
||||||
|
return url
|
||||||
|
if url.startswith("sqlite://"):
|
||||||
|
return "sqlite+aiosqlite://" + url[len("sqlite://") :]
|
||||||
|
if url.startswith("postgresql://"):
|
||||||
|
return "postgresql+psycopg://" + url[len("postgresql://") :]
|
||||||
|
if url.startswith("postgres://"):
|
||||||
|
return "postgresql+psycopg://" + url[len("postgres://") :]
|
||||||
|
return url
|
||||||
|
|
||||||
|
|
||||||
|
class Database:
|
||||||
|
"""Owner of the async SQLAlchemy engine.
|
||||||
|
|
||||||
|
Construct once in ``cli`` from ``settings.database_url``, ``await``
|
||||||
|
:meth:`create_all` at startup, hand the instance to
|
||||||
|
:class:`GatewayRuntime`. Frontends grab sessions via
|
||||||
|
:meth:`session` (an async context manager).
|
||||||
|
|
||||||
|
``echo=False`` keeps SQL out of INFO logs (admin UI is the
|
||||||
|
user-facing view); ``connect_args={"check_same_thread": False}``
|
||||||
|
isn't needed under async sqlite — aiosqlite already runs each
|
||||||
|
connection on a dedicated thread.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__slots__ = ("_engine",)
|
||||||
|
|
||||||
|
def __init__(self, url: str) -> None:
|
||||||
|
self._engine: AsyncEngine = create_async_engine(_to_async_url(url), echo=False)
|
||||||
|
|
||||||
|
async def create_all(self) -> None:
|
||||||
|
"""Idempotent ``CREATE TABLE IF NOT EXISTS`` for every model."""
|
||||||
|
async with self._engine.begin() as conn:
|
||||||
|
await conn.run_sync(SQLModel.metadata.create_all)
|
||||||
|
|
||||||
|
def session(self) -> AsyncSession:
|
||||||
|
"""Open a fresh :class:`AsyncSession` (use as ``async with``).
|
||||||
|
|
||||||
|
Callers commit explicitly; we don't auto-commit on exit so a
|
||||||
|
request that fails mid-flight rolls back by simply not
|
||||||
|
committing.
|
||||||
|
"""
|
||||||
|
return AsyncSession(self._engine, expire_on_commit=False)
|
||||||
|
|
||||||
|
async def dispose(self) -> None:
|
||||||
|
"""Close the engine's connection pool. Idempotent."""
|
||||||
|
await self._engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Token CRUD ---------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def list_active_tokens(session: AsyncSession) -> Sequence[Token]:
|
||||||
|
"""Return every non-revoked token (Phase 4.2 seeds the cache from this)."""
|
||||||
|
stmt = select(Token).where(Token.revoked_at.is_(None)) # ty: ignore[unresolved-attribute]
|
||||||
|
result = await session.exec(stmt)
|
||||||
|
return result.all()
|
||||||
|
|
||||||
|
|
||||||
|
async def list_tokens(
|
||||||
|
session: AsyncSession, *, include_revoked: bool = False
|
||||||
|
) -> Sequence[Token]:
|
||||||
|
"""Return tokens ordered newest-first (Phase 4.3 admin table)."""
|
||||||
|
stmt = select(Token).order_by(Token.created_at.desc()) # ty: ignore[unresolved-attribute]
|
||||||
|
if not include_revoked:
|
||||||
|
stmt = stmt.where(Token.revoked_at.is_(None)) # ty: ignore[unresolved-attribute]
|
||||||
|
result = await session.exec(stmt)
|
||||||
|
return result.all()
|
||||||
|
|
||||||
|
|
||||||
|
async def create_token(
|
||||||
|
session: AsyncSession, *, name: str, scope: str, hashed_value: str
|
||||||
|
) -> Token:
|
||||||
|
"""Persist a new token. Caller hashes the plaintext before passing it in."""
|
||||||
|
row = Token(name=name, scope=scope, hashed_value=hashed_value)
|
||||||
|
session.add(row)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(row)
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
async def revoke_token(session: AsyncSession, *, token_id: int) -> bool:
|
||||||
|
"""Mark a token revoked. Returns ``False`` if no such row."""
|
||||||
|
row = await session.get(Token, token_id)
|
||||||
|
if row is None or row.revoked_at is not None:
|
||||||
|
return False
|
||||||
|
row.revoked_at = _utcnow()
|
||||||
|
session.add(row)
|
||||||
|
await session.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def touch_token(session: AsyncSession, *, token_id: int) -> None:
|
||||||
|
"""Bump ``last_used_at``. Phase 4.2 batches these — not per-request."""
|
||||||
|
row = await session.get(Token, token_id)
|
||||||
|
if row is None:
|
||||||
|
return
|
||||||
|
row.last_used_at = _utcnow()
|
||||||
|
session.add(row)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Session bookkeeping ------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def upsert_session(
|
||||||
|
session: AsyncSession, *, session_id: str, agent_name: str, fingerprint: str
|
||||||
|
) -> Session:
|
||||||
|
"""Insert-or-bump a Session row. Agent/fingerprint never change for an id."""
|
||||||
|
row = await session.get(Session, session_id)
|
||||||
|
if row is None:
|
||||||
|
row = Session(id=session_id, agent_name=agent_name, fingerprint=fingerprint)
|
||||||
|
else:
|
||||||
|
row.last_active_at = _utcnow()
|
||||||
|
session.add(row)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(row)
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
async def touch_session(session: AsyncSession, *, session_id: str) -> None:
|
||||||
|
"""Bump ``last_active_at`` without changing fingerprint/agent."""
|
||||||
|
row = await session.get(Session, session_id)
|
||||||
|
if row is None:
|
||||||
|
return
|
||||||
|
row.last_active_at = _utcnow()
|
||||||
|
session.add(row)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def close_session(session: AsyncSession, *, session_id: str) -> bool:
|
||||||
|
"""Mark a session closed. Returns ``False`` if no such row."""
|
||||||
|
row = await session.get(Session, session_id)
|
||||||
|
if row is None or row.closed_at is not None:
|
||||||
|
return False
|
||||||
|
row.closed_at = _utcnow()
|
||||||
|
session.add(row)
|
||||||
|
await session.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Audit --------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def append_audit(
|
||||||
|
session: AsyncSession,
|
||||||
|
*,
|
||||||
|
actor: str,
|
||||||
|
kind: str,
|
||||||
|
agent_name: str | None = None,
|
||||||
|
detail: dict[str, Any] | None = None,
|
||||||
|
) -> AuditLog:
|
||||||
|
"""Append-only insert. ``detail`` JSON-serialised here, not by callers."""
|
||||||
|
row = AuditLog(
|
||||||
|
actor=actor,
|
||||||
|
kind=kind,
|
||||||
|
agent_name=agent_name,
|
||||||
|
detail_json=json.dumps(detail or {}, separators=(",", ":")),
|
||||||
|
)
|
||||||
|
session.add(row)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(row)
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
async def list_audit_records(
|
||||||
|
session: AsyncSession, *, limit: int = 50, before_id: int | None = None
|
||||||
|
) -> Sequence[AuditLog]:
|
||||||
|
"""Return audit entries newest-first, optionally paginated by id cursor.
|
||||||
|
|
||||||
|
``before_id`` is a forward-only cursor: pass the smallest id from the
|
||||||
|
current page to fetch the next slice. Cheap because ``id`` is the
|
||||||
|
primary key (ordered insert).
|
||||||
|
"""
|
||||||
|
stmt = select(AuditLog).order_by(AuditLog.id.desc()).limit(limit) # ty: ignore[unresolved-attribute]
|
||||||
|
if before_id is not None:
|
||||||
|
stmt = stmt.where(AuditLog.id < before_id) # ty: ignore[unsupported-operator]
|
||||||
|
result = await session.exec(stmt)
|
||||||
|
return result.all()
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"Database",
|
||||||
|
"append_audit",
|
||||||
|
"close_session",
|
||||||
|
"create_token",
|
||||||
|
"list_active_tokens",
|
||||||
|
"list_audit_records",
|
||||||
|
"list_tokens",
|
||||||
|
"revoke_token",
|
||||||
|
"touch_session",
|
||||||
|
"touch_token",
|
||||||
|
"upsert_session",
|
||||||
|
]
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
"""SQLModel tables — see PRD §9.
|
||||||
|
|
||||||
|
Three tables, all flat, no relationships modelled yet (Phase 4 talks
|
||||||
|
about ``actor`` and ``agent_name`` as strings — joining audit→token by
|
||||||
|
name is fine at this volume; we'll introduce FKs when the admin UI
|
||||||
|
actually demands them).
|
||||||
|
|
||||||
|
Datetimes are stored UTC; we set ``default_factory`` rather than relying
|
||||||
|
on DB defaults so SQLite + Postgres behave identically. Every row that
|
||||||
|
needs an id uses ``Optional[int]`` so SQLAlchemy can autoincrement.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from sqlmodel import Field, SQLModel
|
||||||
|
|
||||||
|
|
||||||
|
def _utcnow() -> datetime:
|
||||||
|
return datetime.now(UTC)
|
||||||
|
|
||||||
|
|
||||||
|
class Token(SQLModel, table=True):
|
||||||
|
"""Bearer token issued to an external caller.
|
||||||
|
|
||||||
|
``hashed_value`` holds the Argon2 hash (Phase 4.2 — until then,
|
||||||
|
rows are written by tests / the admin UI, not by ``TokenStore``).
|
||||||
|
Plaintext is shown to the user **once** at creation and then
|
||||||
|
discarded.
|
||||||
|
"""
|
||||||
|
|
||||||
|
id: int | None = Field(default=None, primary_key=True)
|
||||||
|
name: str = Field(index=True, unique=True)
|
||||||
|
scope: str = Field(default="*")
|
||||||
|
hashed_value: str
|
||||||
|
created_at: datetime = Field(default_factory=_utcnow)
|
||||||
|
last_used_at: datetime | None = Field(default=None)
|
||||||
|
revoked_at: datetime | None = Field(default=None)
|
||||||
|
|
||||||
|
|
||||||
|
class Session(SQLModel, table=True):
|
||||||
|
"""Mirror of one live ``claude-code-api`` session.
|
||||||
|
|
||||||
|
The id is the ``session_id`` claude itself assigns on the first
|
||||||
|
turn; we don't generate it. Rows here are for admin observability
|
||||||
|
(live count, last activity) — the actual pool lives in
|
||||||
|
``claude_code_api.ClaudeCodeBackend`` and is the source of truth.
|
||||||
|
"""
|
||||||
|
|
||||||
|
id: str = Field(primary_key=True)
|
||||||
|
agent_name: str = Field(index=True)
|
||||||
|
fingerprint: str = Field(index=True)
|
||||||
|
created_at: datetime = Field(default_factory=_utcnow)
|
||||||
|
last_active_at: datetime = Field(default_factory=_utcnow)
|
||||||
|
closed_at: datetime | None = Field(default=None)
|
||||||
|
|
||||||
|
|
||||||
|
class AuditLog(SQLModel, table=True):
|
||||||
|
"""Append-only record of who-did-what.
|
||||||
|
|
||||||
|
``actor`` is ``"token:<name>"`` for inbound traffic or
|
||||||
|
``"admin:<user>"`` for admin-UI actions. ``kind`` is a short tag
|
||||||
|
(``"messages"`` / ``"mcp_call"`` / ``"token_create"`` / …);
|
||||||
|
free-form rather than enum so we can add new kinds without a
|
||||||
|
schema migration. ``detail_json`` is a JSON-encoded blob — keep
|
||||||
|
it small (paths, method, status), not full bodies.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "audit_log"
|
||||||
|
|
||||||
|
id: int | None = Field(default=None, primary_key=True)
|
||||||
|
ts: datetime = Field(default_factory=_utcnow, index=True)
|
||||||
|
actor: str = Field(index=True)
|
||||||
|
kind: str = Field(index=True)
|
||||||
|
agent_name: str | None = Field(default=None, index=True)
|
||||||
|
detail_json: str = Field(default="{}")
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["AuditLog", "Session", "Token"]
|
||||||
@@ -124,6 +124,15 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" },
|
{ url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "aiosqlite"
|
||||||
|
version = "0.22.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/4e/8a/64761f4005f17809769d23e518d915db74e6310474e733e3593cfc854ef1/aiosqlite-0.22.1.tar.gz", hash = "sha256:043e0bd78d32888c0a9ca90fc788b38796843360c855a7262a532813133a0650", size = 14821, upload-time = "2025-12-23T19:25:43.997Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/00/b7/e3bf5133d697a08128598c8d0abc5e16377b51465a33756de24fa7dee953/aiosqlite-0.22.1-py3-none-any.whl", hash = "sha256:21c002eb13823fad740196c5a2e9d8e62f6243bd9e7e4a1f87fb5e44ecb4fceb", size = 17405, upload-time = "2025-12-23T19:25:42.139Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "annotated-doc"
|
name = "annotated-doc"
|
||||||
version = "0.0.4"
|
version = "0.0.4"
|
||||||
@@ -253,11 +262,13 @@ version = "0.1.0"
|
|||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "aiohttp" },
|
{ name = "aiohttp" },
|
||||||
|
{ name = "aiosqlite" },
|
||||||
{ name = "anthropic" },
|
{ name = "anthropic" },
|
||||||
{ name = "anyio" },
|
{ name = "anyio" },
|
||||||
{ name = "argon2-cffi" },
|
{ name = "argon2-cffi" },
|
||||||
{ name = "fastapi" },
|
{ name = "fastapi" },
|
||||||
{ name = "fastmcp" },
|
{ name = "fastmcp" },
|
||||||
|
{ name = "greenlet" },
|
||||||
{ name = "itsdangerous" },
|
{ name = "itsdangerous" },
|
||||||
{ name = "jinja2" },
|
{ name = "jinja2" },
|
||||||
{ name = "psycopg", extra = ["binary"] },
|
{ name = "psycopg", extra = ["binary"] },
|
||||||
@@ -290,6 +301,7 @@ dev = [
|
|||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [
|
requires-dist = [
|
||||||
{ name = "aiohttp", specifier = ">=3.13.5" },
|
{ name = "aiohttp", specifier = ">=3.13.5" },
|
||||||
|
{ name = "aiosqlite", specifier = ">=0.22.1" },
|
||||||
{ name = "anthropic", specifier = ">=0.103.0" },
|
{ name = "anthropic", specifier = ">=0.103.0" },
|
||||||
{ name = "anyio", specifier = ">=4.13.0" },
|
{ name = "anyio", specifier = ">=4.13.0" },
|
||||||
{ name = "argon2-cffi", specifier = ">=25.1.0" },
|
{ name = "argon2-cffi", specifier = ">=25.1.0" },
|
||||||
@@ -297,6 +309,7 @@ requires-dist = [
|
|||||||
{ name = "claude-code-api", marker = "extra == 'prod'", git = "https://git.kotikot.com/beaver/claude-code-api.git" },
|
{ name = "claude-code-api", marker = "extra == 'prod'", git = "https://git.kotikot.com/beaver/claude-code-api.git" },
|
||||||
{ name = "fastapi", specifier = ">=0.136.1" },
|
{ name = "fastapi", specifier = ">=0.136.1" },
|
||||||
{ name = "fastmcp", specifier = ">=3.3.1" },
|
{ name = "fastmcp", specifier = ">=3.3.1" },
|
||||||
|
{ name = "greenlet", specifier = ">=3.5.0" },
|
||||||
{ name = "itsdangerous", specifier = ">=2.2.0" },
|
{ name = "itsdangerous", specifier = ">=2.2.0" },
|
||||||
{ name = "jinja2", specifier = ">=3.1.6" },
|
{ name = "jinja2", specifier = ">=3.1.6" },
|
||||||
{ name = "psycopg", extras = ["binary"], specifier = ">=3.3.4" },
|
{ name = "psycopg", extras = ["binary"], specifier = ">=3.3.4" },
|
||||||
|
|||||||
Reference in New Issue
Block a user