feat(frontends,security,api,ui): a frontend can hold a token scope of its own
This commit is contained in:
@@ -24,7 +24,10 @@ is a full setup built on them.
|
|||||||
the subscription budget.
|
the subscription budget.
|
||||||
- **frontends/** - the windows: Telegram (master = General, topic = branch),
|
- **frontends/** - the windows: Telegram (master = General, topic = branch),
|
||||||
markdown files in a vault, `/api` + the admin SPA, an Anthropic-compatible
|
markdown files in a vault, `/api` + the admin SPA, an Anthropic-compatible
|
||||||
`/anthropic/v1/messages`, MCP re-exposure at `/mcp/<name>`.
|
`/anthropic/v1/messages`, MCP re-exposure at `/mcp/<name>`, and
|
||||||
|
`WebhookFrontend` for a window a setup declares itself - its own request
|
||||||
|
and response schema, its own agent, its own token scope
|
||||||
|
(`docs/FRONTEND-PLUGINS.md`).
|
||||||
- **mcp/** - MCP servers a setup declares (stdio, http, python tools),
|
- **mcp/** - MCP servers a setup declares (stdio, http, python tools),
|
||||||
aggregated in-process and handed to agents by name.
|
aggregated in-process and handed to agents by name.
|
||||||
- **vault/** - watching a directory of notes for the envelope.
|
- **vault/** - watching a directory of notes for the envelope.
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ from beaver_gateway.frontends.bearer import require_token
|
|||||||
from beaver_gateway.frontends.root import build_root_app
|
from beaver_gateway.frontends.root import build_root_app
|
||||||
from beaver_gateway.jobs.scheduler import Scheduler
|
from beaver_gateway.jobs.scheduler import Scheduler
|
||||||
from beaver_gateway.mcp.internal_app import build_internal_app
|
from beaver_gateway.mcp.internal_app import build_internal_app
|
||||||
from beaver_gateway.security.auth import TokenStore
|
from beaver_gateway.security.auth import TokenStore, scopes_with
|
||||||
from beaver_gateway.storage import (
|
from beaver_gateway.storage import (
|
||||||
Database,
|
Database,
|
||||||
PostgresSessionStore,
|
PostgresSessionStore,
|
||||||
@@ -194,6 +194,7 @@ async def run(gateway: Gateway, settings: Settings) -> None:
|
|||||||
pool=pool,
|
pool=pool,
|
||||||
scheduler=scheduler,
|
scheduler=scheduler,
|
||||||
public_url=gateway.public_url.rstrip("/") if gateway.public_url else None,
|
public_url=gateway.public_url.rstrip("/") if gateway.public_url else None,
|
||||||
|
scopes=scopes_with(fe.scope for fe in gateway.frontends),
|
||||||
)
|
)
|
||||||
for fe in gateway.frontends:
|
for fe in gateway.frontends:
|
||||||
fe.configure(runtime)
|
fe.configure(runtime)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
import sys
|
import sys
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
@@ -30,6 +31,8 @@ if TYPE_CHECKING:
|
|||||||
|
|
||||||
__all__ = ["ConfigError", "Gateway", "load"]
|
__all__ = ["ConfigError", "Gateway", "load"]
|
||||||
|
|
||||||
|
_SCOPE = re.compile(r"[a-z][a-z0-9_-]*")
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
class Gateway:
|
class Gateway:
|
||||||
@@ -135,6 +138,12 @@ def _validate(gw: Gateway, path: Path) -> None:
|
|||||||
f"got {type(f).__name__}"
|
f"got {type(f).__name__}"
|
||||||
)
|
)
|
||||||
raise ConfigError(msg)
|
raise ConfigError(msg)
|
||||||
|
if f.scope is not None and not _SCOPE.fullmatch(f.scope):
|
||||||
|
msg = (
|
||||||
|
f"{path}: gateway.frontends[{i}] has scope {f.scope!r}; "
|
||||||
|
f"a frontend scope is a lowercase name like 'voice', never '*'"
|
||||||
|
)
|
||||||
|
raise ConfigError(msg)
|
||||||
names: set[str] = set()
|
names: set[str] = set()
|
||||||
for i, j in enumerate(gw.jobs):
|
for i, j in enumerate(gw.jobs):
|
||||||
if not isinstance(j, Job):
|
if not isinstance(j, Job):
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ from beaver_gateway.frontends.sse import (
|
|||||||
)
|
)
|
||||||
from beaver_gateway.frontends.urls import frontend_url
|
from beaver_gateway.frontends.urls import frontend_url
|
||||||
from beaver_gateway.security import audit
|
from beaver_gateway.security import audit
|
||||||
from beaver_gateway.security.auth import VALID_SCOPES, hash_token
|
from beaver_gateway.security.auth import hash_token
|
||||||
from beaver_gateway.storage import (
|
from beaver_gateway.storage import (
|
||||||
create_token,
|
create_token,
|
||||||
list_audit_records,
|
list_audit_records,
|
||||||
@@ -217,6 +217,7 @@ def build_app( # noqa: PLR0915
|
|||||||
k: fe.agent_for(k) for k in fe.kinds if fe.agent_for(k)
|
k: fe.agent_for(k) for k in fe.kinds if fe.agent_for(k)
|
||||||
},
|
},
|
||||||
"path": fe.path,
|
"path": fe.path,
|
||||||
|
"scope": fe.scope,
|
||||||
"url": frontend_url(request, runtime, fe),
|
"url": frontend_url(request, runtime, fe),
|
||||||
}
|
}
|
||||||
for fe in runtime.frontends
|
for fe in runtime.frontends
|
||||||
@@ -814,6 +815,19 @@ def build_app( # noqa: PLR0915
|
|||||||
"content": content,
|
"content": content,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@app.get("/scopes")
|
||||||
|
async def scopes(request: Request) -> dict[str, Any]:
|
||||||
|
await require_token(request, runtime, scope=ADMIN_SCOPE)
|
||||||
|
by_frontend = {
|
||||||
|
fe.scope: fe.name or fe.path or "" for fe in runtime.frontends if fe.scope
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"scopes": [
|
||||||
|
{"scope": s, "frontend": by_frontend.get(s)}
|
||||||
|
for s in sorted(runtime.scopes)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
@app.get("/tokens")
|
@app.get("/tokens")
|
||||||
async def tokens(request: Request) -> dict[str, Any]:
|
async def tokens(request: Request) -> dict[str, Any]:
|
||||||
await require_token(request, runtime, scope=ADMIN_SCOPE)
|
await require_token(request, runtime, scope=ADMIN_SCOPE)
|
||||||
@@ -828,7 +842,7 @@ def build_app( # noqa: PLR0915
|
|||||||
data = await body_of(request)
|
data = await body_of(request)
|
||||||
name = text_of(data, "name").strip()
|
name = text_of(data, "name").strip()
|
||||||
scope = str(data.get("scope") or "*")
|
scope = str(data.get("scope") or "*")
|
||||||
if scope not in VALID_SCOPES:
|
if scope not in runtime.scopes:
|
||||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, f"invalid scope {scope!r}")
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, f"invalid scope {scope!r}")
|
||||||
plaintext = secrets.token_urlsafe(32)
|
plaintext = secrets.token_urlsafe(32)
|
||||||
async with runtime.db.session() as session:
|
async with runtime.db.session() as session:
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ from abc import ABC, abstractmethod
|
|||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
from beaver_gateway.security.auth import BUILTIN_SCOPES
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import Awaitable, Callable, Mapping, Sequence
|
from collections.abc import Awaitable, Callable, Mapping, Sequence
|
||||||
|
|
||||||
@@ -54,6 +56,8 @@ class GatewayRuntime:
|
|||||||
scheduler: Any = None
|
scheduler: Any = None
|
||||||
public_url: str | None = None
|
public_url: str | None = None
|
||||||
"""``Gateway.public_url``; ``None`` derives the origin from the request."""
|
"""``Gateway.public_url``; ``None`` derives the origin from the request."""
|
||||||
|
scopes: frozenset[str] = BUILTIN_SCOPES
|
||||||
|
"""Every token scope this gateway knows: the builtins plus each frontend's."""
|
||||||
|
|
||||||
|
|
||||||
class Frontend(ABC):
|
class Frontend(ABC):
|
||||||
@@ -64,6 +68,10 @@ class Frontend(ABC):
|
|||||||
is for non-HTTP work (polling, vault mirrors) and defaults to nothing.
|
is for non-HTTP work (polling, vault mirrors) and defaults to nothing.
|
||||||
``landing`` marks the app that ``/`` redirects to.
|
``landing`` marks the app that ``/`` redirects to.
|
||||||
|
|
||||||
|
``scope`` is the token scope this frontend's routes are gated by; a
|
||||||
|
frontend that names its own scope gets keys nobody else's token opens,
|
||||||
|
and ``POST /api/tokens`` will mint them (see ``security/auth.py``).
|
||||||
|
|
||||||
A frontend that shows conversations declares ``name`` (the binding
|
A frontend that shows conversations declares ``name`` (the binding
|
||||||
key) and ``kinds`` (which conversation kinds it shows). The first
|
key) and ``kinds`` (which conversation kinds it shows). The first
|
||||||
frontend whose ``materialize`` returns a binding is the *home* of
|
frontend whose ``materialize`` returns a binding is the *home* of
|
||||||
@@ -76,6 +84,7 @@ class Frontend(ABC):
|
|||||||
kinds: tuple[Kind, ...] = ()
|
kinds: tuple[Kind, ...] = ()
|
||||||
path: str | None = None
|
path: str | None = None
|
||||||
landing: bool = False
|
landing: bool = False
|
||||||
|
scope: str | None = None
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def configure(self, runtime: GatewayRuntime) -> None: ...
|
def configure(self, runtime: GatewayRuntime) -> None: ...
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ from argon2.exceptions import InvalidHashError, VerifyMismatchError
|
|||||||
from beaver_gateway.storage import list_active_tokens, touch_token
|
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 Iterable, Mapping
|
||||||
|
|
||||||
from beaver_gateway.storage import Database
|
from beaver_gateway.storage import Database
|
||||||
|
|
||||||
@@ -35,8 +35,8 @@ class TokenStoreError(ValueError):
|
|||||||
"""Malformed ``BOOTSTRAP_TOKENS`` value or duplicate token."""
|
"""Malformed ``BOOTSTRAP_TOKENS`` value or duplicate token."""
|
||||||
|
|
||||||
|
|
||||||
VALID_SCOPES: frozenset[str] = frozenset({"*", "messages", "mcp", "admin", "api"})
|
BUILTIN_SCOPES: frozenset[str] = frozenset({"*", "messages", "mcp", "admin", "api"})
|
||||||
"""The scopes a ``Token.scope`` may hold.
|
"""The scopes the gateway's own frontends define.
|
||||||
|
|
||||||
* ``*`` — wildcard, may use any frontend
|
* ``*`` — wildcard, may use any frontend
|
||||||
* ``messages`` — Anthropic Messages frontend only
|
* ``messages`` — Anthropic Messages frontend only
|
||||||
@@ -45,16 +45,24 @@ VALID_SCOPES: frozenset[str] = frozenset({"*", "messages", "mcp", "admin", "api"
|
|||||||
* ``admin`` — reserved for programmatic admin access; the AdminFrontend
|
* ``admin`` — reserved for programmatic admin access; the AdminFrontend
|
||||||
itself authenticates via session cookies, not bearer tokens, so this
|
itself authenticates via session cookies, not bearer tokens, so this
|
||||||
scope is unused today.
|
scope is unused today.
|
||||||
|
|
||||||
|
A setup's own frontend adds its scope on top; see :func:`scopes_with` and
|
||||||
|
``GatewayRuntime.scopes``, which is what ``POST /api/tokens`` validates.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def scopes_with(extra: Iterable[str | None]) -> frozenset[str]:
|
||||||
|
"""The builtin scopes plus every frontend's own; ``None`` entries drop out."""
|
||||||
|
return BUILTIN_SCOPES | {s for s in extra if s}
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class TokenIdentity:
|
class TokenIdentity:
|
||||||
"""What :meth:`TokenStore.verify` resolves to on success.
|
"""What :meth:`TokenStore.verify` resolves to on success.
|
||||||
|
|
||||||
``token_id`` is the DB row id for persisted tokens, or ``None`` for
|
``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``
|
an env-bootstrap match (those have no DB row to touch). ``scope``
|
||||||
gates which frontend the token may hit (see :data:`VALID_SCOPES`);
|
gates which frontend the token may hit (see :data:`BUILTIN_SCOPES`);
|
||||||
bootstrap tokens implicitly get ``"*"``.
|
bootstrap tokens implicitly get ``"*"``.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -67,8 +75,8 @@ class TokenIdentity:
|
|||||||
|
|
||||||
``"*"`` is the wildcard; an exact match satisfies a single scope.
|
``"*"`` is the wildcard; an exact match satisfies a single scope.
|
||||||
Unknown ``required`` values intentionally fall through to a
|
Unknown ``required`` values intentionally fall through to a
|
||||||
strict equality check — callers should pass one of
|
strict equality check — callers should pass a scope the runtime
|
||||||
:data:`VALID_SCOPES`.
|
knows (:data:`BUILTIN_SCOPES` plus every frontend's own).
|
||||||
"""
|
"""
|
||||||
return self.scope in ("*", required)
|
return self.scope in ("*", required)
|
||||||
|
|
||||||
@@ -325,11 +333,12 @@ class TokenStore:
|
|||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"VALID_SCOPES",
|
"BUILTIN_SCOPES",
|
||||||
"TokenIdentity",
|
"TokenIdentity",
|
||||||
"TokenStore",
|
"TokenStore",
|
||||||
"TokenStoreError",
|
"TokenStoreError",
|
||||||
"hash_token",
|
"hash_token",
|
||||||
|
"scopes_with",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
from fastapi import FastAPI, Request
|
||||||
|
|
||||||
|
from beaver_gateway.app import AgentRegistry, McpRegistry
|
||||||
|
from beaver_gateway.frontends.api.frontend import build_app as build_api
|
||||||
|
from beaver_gateway.frontends.base import Frontend, GatewayRuntime
|
||||||
|
from beaver_gateway.frontends.bearer import require_token
|
||||||
|
from beaver_gateway.security.auth import BUILTIN_SCOPES, TokenStore, scopes_with
|
||||||
|
from beaver_gateway.storage import Database
|
||||||
|
|
||||||
|
ROOT = {"Authorization": "Bearer root-key"}
|
||||||
|
VOICE = {"Authorization": "Bearer voice-key"}
|
||||||
|
OPS = {"Authorization": "Bearer ops-key"}
|
||||||
|
|
||||||
|
|
||||||
|
class Speaker(Frontend):
|
||||||
|
"""A frontend a setup declares, gated by a scope of its own."""
|
||||||
|
|
||||||
|
name = "voice"
|
||||||
|
path = "/voice"
|
||||||
|
scope = "voice"
|
||||||
|
|
||||||
|
def configure(self, runtime: GatewayRuntime) -> None:
|
||||||
|
self.runtime = runtime
|
||||||
|
|
||||||
|
def app(self) -> FastAPI:
|
||||||
|
app = FastAPI()
|
||||||
|
|
||||||
|
@app.post("/")
|
||||||
|
async def ask(request: Request) -> dict[str, str]:
|
||||||
|
return {"by": await require_token(request, self.runtime, scope=self.scope)}
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def runtime() -> GatewayRuntime:
|
||||||
|
root = Path(tempfile.mkdtemp(prefix="beaver-scopes-"))
|
||||||
|
db = Database(f"sqlite:///{root / 's.db'}")
|
||||||
|
await db.create_all()
|
||||||
|
speaker = Speaker()
|
||||||
|
rt = GatewayRuntime(
|
||||||
|
agents=AgentRegistry([]),
|
||||||
|
mcps=McpRegistry([]),
|
||||||
|
backends={},
|
||||||
|
token_store=TokenStore(
|
||||||
|
db,
|
||||||
|
bootstrap={"root": "root-key", "voice": "voice-key", "ops": "ops-key"},
|
||||||
|
bootstrap_scopes={"voice": "voice", "ops": "api"},
|
||||||
|
),
|
||||||
|
db=db,
|
||||||
|
frontends=(speaker,),
|
||||||
|
scopes=scopes_with(fe.scope for fe in (speaker,)),
|
||||||
|
)
|
||||||
|
speaker.configure(rt)
|
||||||
|
yield rt
|
||||||
|
await db.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
def api(runtime: GatewayRuntime) -> httpx.AsyncClient:
|
||||||
|
return httpx.AsyncClient(
|
||||||
|
transport=httpx.ASGITransport(app=build_api(runtime)), base_url="http://t"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def frontend(runtime: GatewayRuntime) -> httpx.AsyncClient:
|
||||||
|
fe = runtime.frontends[0]
|
||||||
|
return httpx.AsyncClient(
|
||||||
|
transport=httpx.ASGITransport(app=fe.app()), base_url="http://t"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_frontend_scope_joins_the_builtin_vocabulary() -> None:
|
||||||
|
assert "voice" not in BUILTIN_SCOPES
|
||||||
|
assert scopes_with(["voice", None, "api"]) == BUILTIN_SCOPES | {"voice"}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_the_scope_of_a_frontend_is_listed_with_its_name(
|
||||||
|
runtime: GatewayRuntime,
|
||||||
|
) -> None:
|
||||||
|
async with api(runtime) as client:
|
||||||
|
listed = await client.get("/scopes", headers=ROOT)
|
||||||
|
scopes = listed.json()["scopes"]
|
||||||
|
assert {"scope": "voice", "frontend": "voice"} in scopes
|
||||||
|
assert {"scope": "api", "frontend": None} in scopes
|
||||||
|
|
||||||
|
|
||||||
|
async def test_tokens_mint_for_a_declared_scope_and_not_for_an_unknown_one(
|
||||||
|
runtime: GatewayRuntime,
|
||||||
|
) -> None:
|
||||||
|
async with api(runtime) as client:
|
||||||
|
minted = await client.post(
|
||||||
|
"/tokens", json={"name": "esp32-hall", "scope": "voice"}, headers=ROOT
|
||||||
|
)
|
||||||
|
unknown = await client.post(
|
||||||
|
"/tokens", json={"name": "nope", "scope": "kitchen"}, headers=ROOT
|
||||||
|
)
|
||||||
|
assert minted.status_code == 201
|
||||||
|
assert minted.json()["token"]["scope"] == "voice"
|
||||||
|
assert unknown.status_code == 400
|
||||||
|
assert "kitchen" in unknown.json()["error"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_the_route_opens_for_its_own_scope_only(runtime: GatewayRuntime) -> None:
|
||||||
|
async with frontend(runtime) as client:
|
||||||
|
own = await client.post("/", headers=VOICE)
|
||||||
|
other = await client.post("/", headers=OPS)
|
||||||
|
wildcard = await client.post("/", headers=ROOT)
|
||||||
|
anonymous = await client.post("/")
|
||||||
|
assert own.status_code == 200
|
||||||
|
assert own.json()["by"] == "voice"
|
||||||
|
assert other.status_code == 403
|
||||||
|
assert wildcard.status_code == 200
|
||||||
|
assert anonymous.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_minted_token_reaches_the_frontend_it_was_minted_for(
|
||||||
|
runtime: GatewayRuntime,
|
||||||
|
) -> None:
|
||||||
|
async with api(runtime) as client:
|
||||||
|
plaintext = (
|
||||||
|
await client.post(
|
||||||
|
"/tokens", json={"name": "esp32-hall", "scope": "voice"}, headers=ROOT
|
||||||
|
)
|
||||||
|
).json()["plaintext"]
|
||||||
|
async with frontend(runtime) as client:
|
||||||
|
opened = await client.post("/", headers={"X-Api-Key": plaintext})
|
||||||
|
assert opened.status_code == 200
|
||||||
|
assert opened.json()["by"] == "esp32-hall"
|
||||||
@@ -12,6 +12,7 @@ import type {
|
|||||||
LimitsResponse,
|
LimitsResponse,
|
||||||
MemoryFile,
|
MemoryFile,
|
||||||
MemoryTree,
|
MemoryTree,
|
||||||
|
ScopeRow,
|
||||||
SearchResponse,
|
SearchResponse,
|
||||||
SessionsResponse,
|
SessionsResponse,
|
||||||
TokenRow,
|
TokenRow,
|
||||||
@@ -351,6 +352,10 @@ export class ApiClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
scopes(): Promise<{ scopes: ScopeRow[] }> {
|
||||||
|
return this.get("/api/scopes");
|
||||||
|
}
|
||||||
|
|
||||||
createToken(
|
createToken(
|
||||||
name: string,
|
name: string,
|
||||||
scope: string
|
scope: string
|
||||||
|
|||||||
@@ -132,6 +132,7 @@ export interface FrontendInfo {
|
|||||||
kinds: Kind[];
|
kinds: Kind[];
|
||||||
name: string;
|
name: string;
|
||||||
path: string | null;
|
path: string | null;
|
||||||
|
scope: string | null;
|
||||||
type: string;
|
type: string;
|
||||||
url: string | null;
|
url: string | null;
|
||||||
}
|
}
|
||||||
@@ -245,6 +246,11 @@ export interface MemoryFile {
|
|||||||
size: number;
|
size: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ScopeRow {
|
||||||
|
frontend: string | null;
|
||||||
|
scope: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface TokenRow {
|
export interface TokenRow {
|
||||||
created_at: string;
|
created_at: string;
|
||||||
id: number;
|
id: number;
|
||||||
|
|||||||
@@ -117,7 +117,17 @@
|
|||||||
};
|
};
|
||||||
default:
|
default:
|
||||||
return {
|
return {
|
||||||
endpoints: base ? [{ hint: "", label: "base", url: base }] : [],
|
endpoints: base
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
hint: fe.scope
|
||||||
|
? `POST the frontend's own payload; bearer with scope ${fe.scope}`
|
||||||
|
: "",
|
||||||
|
label: "base",
|
||||||
|
url: base,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [],
|
||||||
frontend: fe,
|
frontend: fe,
|
||||||
note: "",
|
note: "",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import CopyIcon from "@lucide/svelte/icons/copy";
|
import CopyIcon from "@lucide/svelte/icons/copy";
|
||||||
import PlusIcon from "@lucide/svelte/icons/plus";
|
import PlusIcon from "@lucide/svelte/icons/plus";
|
||||||
import { toast } from "svelte-sonner";
|
import { toast } from "svelte-sonner";
|
||||||
import type { TokenRow } from "$lib/api/types";
|
import type { ScopeRow, TokenRow } from "$lib/api/types";
|
||||||
import EmptyState from "$lib/components/empty-state.svelte";
|
import EmptyState from "$lib/components/empty-state.svelte";
|
||||||
import ErrorNote from "$lib/components/error-note.svelte";
|
import ErrorNote from "$lib/components/error-note.svelte";
|
||||||
import { Button } from "$lib/components/ui/button";
|
import { Button } from "$lib/components/ui/button";
|
||||||
@@ -15,13 +15,23 @@
|
|||||||
import { session } from "$lib/session.svelte";
|
import { session } from "$lib/session.svelte";
|
||||||
import { cn } from "$lib/utils";
|
import { cn } from "$lib/utils";
|
||||||
|
|
||||||
const SCOPES = [
|
const HINTS: Record<string, string> = {
|
||||||
{ hint: "every frontend", value: "*" },
|
"*": "every frontend",
|
||||||
{ hint: "conversations API and SSE (panel, plugin)", value: "api" },
|
admin: "tokens and audit over the API",
|
||||||
{ hint: "Anthropic /v1/messages", value: "messages" },
|
api: "conversations API and SSE (panel, plugin)",
|
||||||
{ hint: "MCP server", value: "mcp" },
|
mcp: "MCP server",
|
||||||
{ hint: "tokens and audit over the API", value: "admin" },
|
messages: "Anthropic /v1/messages",
|
||||||
];
|
};
|
||||||
|
|
||||||
|
let scopes = $state<ScopeRow[]>([]);
|
||||||
|
const options = $derived(
|
||||||
|
scopes.map((row) => ({
|
||||||
|
hint: row.frontend
|
||||||
|
? `the ${row.frontend} frontend`
|
||||||
|
: (HINTS[row.scope] ?? ""),
|
||||||
|
value: row.scope,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
let tokens = $state<TokenRow[] | null>(null);
|
let tokens = $state<TokenRow[] | null>(null);
|
||||||
let includeRevoked = $state(false);
|
let includeRevoked = $state(false);
|
||||||
@@ -49,6 +59,21 @@
|
|||||||
load(includeRevoked);
|
load(includeRevoked);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const { client } = session;
|
||||||
|
if (!client) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
client
|
||||||
|
.scopes()
|
||||||
|
.then(({ scopes: rows }) => {
|
||||||
|
scopes = rows;
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
scopes = [];
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
async function create() {
|
async function create() {
|
||||||
const { client } = session;
|
const { client } = session;
|
||||||
if (!client) {
|
if (!client) {
|
||||||
@@ -254,7 +279,7 @@
|
|||||||
>
|
>
|
||||||
<Select.Trigger class="w-full">{scope}</Select.Trigger>
|
<Select.Trigger class="w-full">{scope}</Select.Trigger>
|
||||||
<Select.Content>
|
<Select.Content>
|
||||||
{#each SCOPES as option (option.value)}
|
{#each options as option (option.value)}
|
||||||
<Select.Item label={option.value} value={option.value}>
|
<Select.Item label={option.value} value={option.value}>
|
||||||
<span class="flex flex-col">
|
<span class="flex flex-col">
|
||||||
<span>{option.value}</span>
|
<span>{option.value}</span>
|
||||||
|
|||||||
Reference in New Issue
Block a user