docs: add Claude AI assistant rules and configuration
Add comprehensive rule documentation for AI-assisted development covering authentication surfaces, outbound-call safety invariants, and other project conventions to guide Claude's understanding of critical system behaviors.
This commit is contained in:
69
.claude/rules/auth-surfaces.md
Normal file
69
.claude/rules/auth-surfaces.md
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
---
|
||||||
|
description: Casdoor SSO for the browser + owner-minted PATs for MCP/CLI; owner-only on every surface; one resolver; ?token= fallback; dev-owner on loopback
|
||||||
|
paths:
|
||||||
|
- "auth.py"
|
||||||
|
- "api/auth.py"
|
||||||
|
- "api/tokens.py"
|
||||||
|
- "api/deps.py"
|
||||||
|
- "api/websocket.py"
|
||||||
|
- "mcp_server/server.py"
|
||||||
|
- "main.py"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Authentication across the four surfaces
|
||||||
|
|
||||||
|
Auth is **Casdoor SSO for the browser + owner-minted Personal Access Tokens for
|
||||||
|
MCP/CLI**, and the gateway is **owner-only**: exactly one operator (the Casdoor
|
||||||
|
user whose name matches `OWNER_NAME`) may use any surface; every other identity
|
||||||
|
gets 403. There is one resolver behind all of it — don't add a second auth
|
||||||
|
mechanism, a per-surface token, or a bypass.
|
||||||
|
|
||||||
|
- **`resolve_bearer(session, raw_token)` in [auth.py](../../auth.py) is the single
|
||||||
|
resolver.** It turns a bearer string into a `User` (or `None`), classifying it
|
||||||
|
as a PAT (`hs_pat_` prefix → hash lookup) or a Casdoor JWT (RS256, validated
|
||||||
|
against the endpoint's JWKS). `resolve_from_header_or_query` wraps it to accept
|
||||||
|
the token from the `Authorization` header *or* a `?token=` query param. Every
|
||||||
|
surface funnels through these — REST, WebSocket, MCP.
|
||||||
|
|
||||||
|
- **Owner gating is `is_owner(user)` + `get_current_owner`.** `is_owner` matches
|
||||||
|
`user.name == OWNER_NAME` (SSO) or the dev-owner sub (dev mode). REST routers
|
||||||
|
carry `dependencies=[Depends(get_current_owner)]` (aliased `_auth` in
|
||||||
|
[main.py](../../main.py)) → 401 if unauthenticated, 403 if not owner. New
|
||||||
|
protected routers get the same dependency. `/auth/me` is the one exception: it
|
||||||
|
resolves the user *without* the owner gate so a signed-in non-owner sees
|
||||||
|
`is_owner:false` (the dashboard's "not authorized" screen) instead of a bare
|
||||||
|
401.
|
||||||
|
|
||||||
|
- **The `?token=` query-param fallback is intentional and narrow.** Browsers
|
||||||
|
can't set headers on a WebSocket connect or on an `<audio src>`/`<a href>`
|
||||||
|
recording download, so the current token (Casdoor JWT, or a PAT) rides as
|
||||||
|
`?token=`. It's validated by the same resolver as the header. Don't widen it or
|
||||||
|
remove it without accounting for those two consumers.
|
||||||
|
|
||||||
|
- **WebSocket checks ownership itself** ([api/websocket.py](../../api/websocket.py)
|
||||||
|
`_authorize`) rather than via a router dependency, because WS handshakes don't
|
||||||
|
run FastAPI dependencies the same way. It opens a `session_scope`, resolves the
|
||||||
|
bearer (header or `?token=`), and closes with **4401** unless the caller is the
|
||||||
|
owner. Keep the check **before** `websocket.accept()`.
|
||||||
|
|
||||||
|
- **MCP is gated by the ASGI `_owner_only_mcp` wrapper in main.py**, not by
|
||||||
|
FastMCP auth. `create_mcp_server` builds `FastMCP(auth=None)`; the wrapper reads
|
||||||
|
the ASGI scope's `Authorization` header, resolves it via the same
|
||||||
|
`resolve_from_header_or_query` + `is_owner`, and short-circuits non-owner
|
||||||
|
requests with 401/403 (plus an RFC 9728 `WWW-Authenticate` header pointing at
|
||||||
|
`/.well-known/oauth-protected-resource/mcp`). This is why PATs *and* JWTs both
|
||||||
|
work on `/mcp` with one code path. The nested `mcp_http_app.lifespan` still runs
|
||||||
|
— the wrapper is pure middleware around the inner app.
|
||||||
|
|
||||||
|
- **Dev mode is the loopback bypass, not a token.** `CASDOOR_ENABLED=false` makes
|
||||||
|
every request resolve to the dev owner — permitted **only** on a loopback bind.
|
||||||
|
`_check_startup_config` in [main.py](../../main.py) exits if SSO is disabled and
|
||||||
|
`HOST` is off-loopback (the network would see a dev-owner-open gateway), and
|
||||||
|
exits if SSO is enabled but `CASDOOR_ENDPOINT`/`CLIENT_ID`/`CLIENT_SECRET`/
|
||||||
|
`OWNER_NAME` is missing. **Never weaken these to "warn and continue" — a
|
||||||
|
startup misconfiguration must stop the service.**
|
||||||
|
|
||||||
|
- **Never log a token.** `client_secret` is a `SecretStr`; read it via
|
||||||
|
`.get_secret_value()` only where you hand it to the Casdoor SDK. PAT plaintext
|
||||||
|
is shown once at creation and only its SHA-256 hash is stored — never log the
|
||||||
|
plaintext, a JWT, or a hash.
|
||||||
45
.claude/rules/call-safety.md
Normal file
45
.claude/rules/call-safety.md
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
---
|
||||||
|
description: emergency-number guard, concurrent-call cap, dial-path discipline — the outbound safety invariants
|
||||||
|
paths:
|
||||||
|
- "core/dial_plan.py"
|
||||||
|
- "core/gateway.py"
|
||||||
|
- "mcp_server/server.py"
|
||||||
|
- "api/calls.py"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Outbound-call safety
|
||||||
|
|
||||||
|
This is the safety core. A defect here means an unwanted real phone call, a
|
||||||
|
runaway telephony bill, or — the one that matters most — an AI-initiated
|
||||||
|
emergency call that should have been impossible.
|
||||||
|
|
||||||
|
- **`is_emergency_number()` is the single source of truth for refusal.** It
|
||||||
|
lives in `core/dial_plan.py`, blocks `911`/`9911`/`112` and their E.164
|
||||||
|
mappings (`_BLOCKED` = keys ∪ values), and normalises the input (strips
|
||||||
|
spaces, dashes, dots) before comparing. If you learn of another dialled form
|
||||||
|
that reaches emergency services, add it to `EMERGENCY_NUMBERS` — never work
|
||||||
|
around the guard.
|
||||||
|
|
||||||
|
- **Every outbound path goes through `gateway.make_call`, and the guard is its
|
||||||
|
first check** — before the concurrency cap, before `create_call`, before any
|
||||||
|
SIP action. REST `make_call`, MCP `make_call`, receptionist ring-back, and any
|
||||||
|
transfer to an external number must funnel through it. **Do not introduce a
|
||||||
|
dial path that reaches `sip_engine.make_call` without passing the guard
|
||||||
|
first.** If a new feature needs to place a call, it calls `gateway.make_call`.
|
||||||
|
|
||||||
|
- **The concurrency cap is spend control, not decoration.** `max_concurrent_calls`
|
||||||
|
(default 4) is checked in `make_call` after the emergency guard and before
|
||||||
|
call creation, using `len(call_manager.active_calls)`. Keep the ordering:
|
||||||
|
refuse-emergency, then cap, then create. Don't move the count to after
|
||||||
|
creation (it would off-by-one) and don't remove it.
|
||||||
|
|
||||||
|
- **Refusals raise `ValueError` at the gateway; surfaces translate it.**
|
||||||
|
`make_call` raises `ValueError` for both refusals; the MCP tool converts it to
|
||||||
|
`ToolError`, and the REST layer maps it to a 4xx. Keep refusals as exceptions
|
||||||
|
from the gateway — a refused call must never look like a placed one.
|
||||||
|
|
||||||
|
- **The README's `[!CAUTION]` block is a contract, not decoration.** If you
|
||||||
|
change refusal behaviour, the README caution and this rule must stay true. A
|
||||||
|
system that quietly stops refusing emergency numbers is a serious regression
|
||||||
|
even if every test still passes — add/keep a test that asserts each blocked
|
||||||
|
form is refused.
|
||||||
55
.claude/rules/concurrency-threads.md
Normal file
55
.claude/rules/concurrency-threads.md
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
---
|
||||||
|
description: the Sippy/PJSUA2 OS-thread boundary — two funnels, who owns what state, never cross it directly
|
||||||
|
paths:
|
||||||
|
- "core/sippy_engine.py"
|
||||||
|
- "core/sip_engine.py"
|
||||||
|
- "core/media_pipeline.py"
|
||||||
|
- "core/call_manager.py"
|
||||||
|
- "core/gateway.py"
|
||||||
|
---
|
||||||
|
|
||||||
|
# The thread boundary (the invariant that keeps this app sane)
|
||||||
|
|
||||||
|
The README says "single-process async." That's true at the surface, but under
|
||||||
|
the SIP engine there are **two execution contexts**: the asyncio event loop, and
|
||||||
|
a dedicated **Sippy/PJSUA2 OS thread** running the `ED2` event dispatcher. Almost
|
||||||
|
every hard-to-debug class of bug in a telephony gateway comes from touching one
|
||||||
|
context's state from the other. This app avoids that with exactly one funnel each
|
||||||
|
way. Preserve them.
|
||||||
|
|
||||||
|
- **Who owns what:**
|
||||||
|
- *Sippy thread* owns the Sippy UA objects and the `ED2` dispatcher. State:
|
||||||
|
`_ed_ua_to_leg`, `_ed_leg_to_ua` (the "ED-thread-owned state" maps). Only
|
||||||
|
touch these from a Sippy handler or a `_run_on_sippy` closure.
|
||||||
|
- *asyncio loop* owns everything else: `_legs`, `_bridges`,
|
||||||
|
`_registered_devices`, the `EventBus`, the `CallManager`, the media pipeline
|
||||||
|
wiring.
|
||||||
|
|
||||||
|
- **Cross thread → loop only via `_post_from_ed`.** It calls
|
||||||
|
`asyncio.run_coroutine_threadsafe(self._on_engine_event(kind, data), self._loop)`.
|
||||||
|
`_on_engine_event` is **the single funnel** where Sippy-thread events mutate
|
||||||
|
loop-owned state, and it runs on the loop. New Sippy-side events post through
|
||||||
|
here with a new `kind`; they do **not** reach into `_legs`/`EventBus` directly
|
||||||
|
from the handler.
|
||||||
|
|
||||||
|
- **Cross loop → thread only via `_run_on_sippy`.** It uses `ED2.callFromThread(fn)`
|
||||||
|
so `fn` runs where the Sippy objects live. In simulation mode (no `sippy`
|
||||||
|
import) it runs `fn` inline — keep that fallback so tests and stub mode work
|
||||||
|
without the native library. Anything that manipulates a UA object goes through
|
||||||
|
here.
|
||||||
|
|
||||||
|
- **Never:** read/write a Sippy UA object from the loop; never mutate `_legs`,
|
||||||
|
publish an event, or touch the `CallManager` from inside a raw Sippy callback
|
||||||
|
without going through `_post_from_ed`. If you find yourself wanting to, you're
|
||||||
|
about to introduce a data race — add a `kind` to the funnel instead.
|
||||||
|
|
||||||
|
- **Background tasks are tracked, both sides.** The gateway's `spawn()` and the
|
||||||
|
engine's `_spawn()` add tasks to a `_tasks` set with a done-callback that
|
||||||
|
discards them, so shutdown can cancel them and the GC can't drop a live
|
||||||
|
coroutine. Launch per-call/background work through these, not a bare
|
||||||
|
`asyncio.create_task` you forget to hold a reference to.
|
||||||
|
|
||||||
|
- **`MockSIPEngine` has no thread.** Tests run against it; it satisfies the same
|
||||||
|
`SIPEngine` interface synchronously/async-inline. When you extend the real
|
||||||
|
engine's behaviour, extend the mock to match, or tests will pass against a
|
||||||
|
fiction.
|
||||||
63
.claude/rules/config-startup.md
Normal file
63
.claude/rules/config-startup.md
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
---
|
||||||
|
description: pydantic-settings nested sub-configs + get_settings() singleton, SecretStr discipline, startup refusals, .env hygiene
|
||||||
|
paths:
|
||||||
|
- "config.py"
|
||||||
|
- "main.py"
|
||||||
|
- ".env*"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Config & startup
|
||||||
|
|
||||||
|
Config is `Settings` in [config.py](../../config.py): a root `BaseSettings` with
|
||||||
|
**nested sub-config models**, each carrying its own `env_prefix`. Read it through
|
||||||
|
the `get_settings()` cached singleton.
|
||||||
|
|
||||||
|
- **`get_settings()` is the only accessor.** It memoises a single `Settings()`.
|
||||||
|
Don't construct `Settings()` elsewhere, and don't reach for `os.environ.get`
|
||||||
|
for Hold Slayer config — the whole point of the sub-config layout is that every
|
||||||
|
knob has one typed home.
|
||||||
|
|
||||||
|
- **Sub-configs own their prefixes.** `SIP_TRUNK_*` → `SIPTrunkSettings`, `LLM_*`
|
||||||
|
→ `LLMSettings`, `TTS_*` → `TTSSettings`, `RECEPTIONIST_*` →
|
||||||
|
`ReceptionistSettings`, `CASDOOR_*` → `CasdoorSettings`, `CLASSIFIER_*`,
|
||||||
|
`SPEACHES_*`, `GATEWAY_SIP_*`. Root vars (`DATABASE_URL`, `HOST`, `PORT`,
|
||||||
|
`MAX_CONCURRENT_CALLS`, `USE_MOCK_SIP`, `NOTIFY_SMS_NUMBER`, `DEBUG`,
|
||||||
|
`LOG_LEVEL`, and the auth cross-cutters `OWNER_NAME` + `PUBLIC_BASE_URL`) are
|
||||||
|
unprefixed on the root model. A new knob goes in the sub-config it belongs to;
|
||||||
|
a genuinely new subsystem gets its own sub-config + prefix, not flat root vars.
|
||||||
|
- `OWNER_NAME` (the owner's Casdoor username) and `PUBLIC_BASE_URL` (OAuth
|
||||||
|
discovery base) live on the root, not under `CASDOOR_`, because they cross-cut
|
||||||
|
every surface — like `DATABASE_URL`. The Casdoor *connection* knobs
|
||||||
|
(`enabled`/`endpoint`/`client_id`/`client_secret`/`org_name`/`app_name`) live
|
||||||
|
under `CASDOOR_`.
|
||||||
|
- `HoldSlayerSettings` uses `env_prefix_allow_empty=True` with explicit
|
||||||
|
`validation_alias`es (`DEFAULT_TRANSFER_DEVICE`, `MAX_HOLD_TIME`,
|
||||||
|
`HOLD_CHECK_INTERVAL`) — i.e. those three are read *unprefixed* by design.
|
||||||
|
Follow that pattern only if you deliberately want an unprefixed name.
|
||||||
|
|
||||||
|
- **Secrets are `SecretStr`.** `casdoor.client_secret`, `sip_trunk.password`,
|
||||||
|
`llm.api_key`, `tts.api_key`. Keep new secrets as `SecretStr`; call
|
||||||
|
`.get_secret_value()` only at the point of use (outbound header, SDK
|
||||||
|
construction) — never store the bare string, never log it.
|
||||||
|
|
||||||
|
- **Startup refuses bad configs loudly, then exits.** In [main.py](../../main.py):
|
||||||
|
- `_check_startup_config` exits if `DATABASE_URL` is unset; if `CASDOOR_ENABLED`
|
||||||
|
is true but any of `CASDOOR_ENDPOINT`/`CLIENT_ID`/`CLIENT_SECRET`/`OWNER_NAME`
|
||||||
|
is missing; or if `CASDOOR_ENABLED` is false while `HOST` is off-loopback
|
||||||
|
(dev-owner mode would be open to the network). See the
|
||||||
|
[auth-surfaces rule](auth-surfaces.md).
|
||||||
|
- `_handle_db_error` turns raw asyncpg failures into human-readable guidance
|
||||||
|
(wrong password, missing DB, connection refused, bad hostname) and `sys.exit(1)`.
|
||||||
|
- SIP engine build failure and mock-vs-real are surfaced, not swallowed.
|
||||||
|
**Keep the pattern: a misconfiguration stops the service with a message a human
|
||||||
|
can act on — never a silent degrade or a stack trace with no guidance.**
|
||||||
|
|
||||||
|
- **`use_mock_sip` is opt-in for a reason.** An unconfigured trunk without
|
||||||
|
`USE_MOCK_SIP=true` must fail startup rather than boot a gateway that silently
|
||||||
|
can't place real calls. Don't default it to `True`.
|
||||||
|
|
||||||
|
- **`.env` hygiene:** `.env` is gitignored and holds real secrets — never commit
|
||||||
|
it, never treat the checked-out `.env` as a template. Only `.env.example`
|
||||||
|
(placeholders) is committed, and it must stay in sync with the models here and
|
||||||
|
the README config table. Every new var lands in all three: model,
|
||||||
|
`.env.example`, README.
|
||||||
56
.claude/rules/lifespan-wiring.md
Normal file
56
.claude/rules/lifespan-wiring.md
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
---
|
||||||
|
description: composition-root lifespan, nested MCP http_app lifespan, app.state wiring, route/mount ordering, honest /health
|
||||||
|
paths:
|
||||||
|
- "main.py"
|
||||||
|
- "api/deps.py"
|
||||||
|
- "core/gateway.py"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Lifespan, composition root & route ordering
|
||||||
|
|
||||||
|
[main.py](../../main.py)'s `lifespan` is the **composition root**: it builds the
|
||||||
|
gateway and every service, wires them by constructor/registration, and hangs the
|
||||||
|
long-lived ones on `app.state`. This is the one place dependencies are
|
||||||
|
assembled.
|
||||||
|
|
||||||
|
- **Build services here, inject them — nothing self-constructs its deps.** The
|
||||||
|
gateway, classifier, transcription, TTS, routing, recording, receptionist, and
|
||||||
|
notification services are all constructed in the lifespan and wired together
|
||||||
|
(e.g. the receptionist receives tts/transcription/recording/routing;
|
||||||
|
`launch_hold_slayer` is registered as the `HOLD_SLAYER` mode handler). A new
|
||||||
|
service is built here and passed in, not instantiated deep in a call path.
|
||||||
|
|
||||||
|
- **The MCP sub-app's lifespan MUST be nested.** The lifespan opens
|
||||||
|
`async with mcp_http_app.lifespan(app):` around all startup. FastMCP's
|
||||||
|
streamable-HTTP session manager is initialised inside *its* lifespan; mount the
|
||||||
|
app without entering that context and every `/mcp` request 500s
|
||||||
|
("session manager not initialised" / "Task group is not initialized"). **Keep
|
||||||
|
the nesting.** This is the same landmine across the estate's mounted-MCP
|
||||||
|
services.
|
||||||
|
|
||||||
|
- **`app.state` is the handoff to request handlers.** The lifespan sets
|
||||||
|
`app.state.gateway`, `.routing_service`, `.transcription_service`,
|
||||||
|
`.notification_service`, `.recording_service`. Dependencies in
|
||||||
|
[api/deps.py](../../api/deps.py) read these and raise `503` if not yet set. MCP
|
||||||
|
tools reach the gateway via the lazy `_get_gateway_instance` resolver. Don't
|
||||||
|
reach for module-level globals; go through `app.state`.
|
||||||
|
|
||||||
|
- **Route/mount registration order is load-bearing:**
|
||||||
|
1. `call_history` router registers **before** `calls` — both live under
|
||||||
|
`/api/v1/calls`, and `calls`' `GET /{call_id}` would otherwise swallow the
|
||||||
|
literal path `history`. Keep history first.
|
||||||
|
2. The `"/mcp"` mount and all API/WS/health routes register **before** the
|
||||||
|
`"/"` static dashboard mount — a root mount matches every path, so anything
|
||||||
|
after it is unreachable. The dashboard mount stays last, and only when
|
||||||
|
`dashboard/build/` exists.
|
||||||
|
|
||||||
|
- **`/health` is honest by construction.** `healthy` = real (non-`MockSIPEngine`)
|
||||||
|
engine **and** registered trunk **and** reachable DB; it also reports STT/TTS
|
||||||
|
last-known reachability via `_availability`. Don't relax any of these to make a
|
||||||
|
probe pass — a degraded gateway must read as `degraded`, with the reason
|
||||||
|
visible.
|
||||||
|
|
||||||
|
- **Shutdown reverses startup.** Stop notifications, stop the gateway (which
|
||||||
|
cancels tracked tasks, ends active calls, stops SIP then media), close the DB.
|
||||||
|
New long-lived resources get a matching teardown here — don't leak a task or a
|
||||||
|
client across restarts.
|
||||||
56
.claude/rules/mcp-tools.md
Normal file
56
.claude/rules/mcp-tools.md
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
---
|
||||||
|
description: MCP tools return formatted strings; ToolError-vs-return-string convention; lazy gateway resolution; resources are JSON
|
||||||
|
paths:
|
||||||
|
- "mcp_server/server.py"
|
||||||
|
---
|
||||||
|
|
||||||
|
# MCP tools & resources
|
||||||
|
|
||||||
|
The MCP server ([mcp_server/server.py](../../mcp_server/server.py)) is the
|
||||||
|
AI-assistant control surface. It's built by `create_mcp_server(get_gateway)` and
|
||||||
|
mounted at `/mcp/` before the lifespan runs, so everything is resolved lazily.
|
||||||
|
|
||||||
|
- **Auth is the ASGI `_owner_only_mcp` wrapper in [main.py](../../main.py), not
|
||||||
|
FastMCP.** `create_mcp_server` builds `FastMCP(auth=None)`; the wrapper resolves
|
||||||
|
the `Authorization` bearer (Casdoor JWT or owner-minted PAT) via the shared
|
||||||
|
`resolve_from_header_or_query` + `is_owner` and returns 401/403 before the inner
|
||||||
|
app runs. Don't reintroduce a FastMCP verifier here — one resolver gates all
|
||||||
|
four surfaces (see the [auth-surfaces rule](auth-surfaces.md)). MCP clients use
|
||||||
|
a PAT (`hs_pat_…`) minted from the dashboard's Tokens modal.
|
||||||
|
|
||||||
|
- **Tools return plain formatted strings; resources return JSON strings.** Tools
|
||||||
|
produce human-readable text an assistant reads back to a user (`"Call abc123
|
||||||
|
initiated. …"`). Resources (`gateway://status`, `gateway://call-flows`,
|
||||||
|
`gateway://active-calls`) return `json.dumps(...)`. Don't blur these — a tool
|
||||||
|
that returns raw JSON, or a resource that returns prose, breaks the contract.
|
||||||
|
|
||||||
|
- **Error convention — match the two existing patterns:**
|
||||||
|
- **Raise `ToolError`** when the request is invalid or unsafe: emergency
|
||||||
|
number, bad mode, concurrency cap hit, or "gateway still starting"
|
||||||
|
(`require_gateway`). The assistant should treat these as errors.
|
||||||
|
- **Return an error string** for a lookup that simply found nothing or hit a
|
||||||
|
recoverable snag: `"Call {id} not found."`, `"No stored call flow for …"`,
|
||||||
|
`"Error looking up …: {e}"`. The assistant reads these as content.
|
||||||
|
- Rule of thumb: *"you asked for something invalid/unsafe" → raise; "I looked,
|
||||||
|
here's the (maybe empty/failed) answer" → return.*
|
||||||
|
|
||||||
|
- **`require_gateway()` gates every tool that needs the live gateway.** It raises
|
||||||
|
`ToolError("Gateway is still starting up …")` when `get_gateway()` returns
|
||||||
|
`None`. This is why the MCP app can mount before the lifespan builds the
|
||||||
|
gateway. Call it at the top of any tool that touches the gateway; never assume
|
||||||
|
the gateway exists.
|
||||||
|
|
||||||
|
- **`make_call` is the one tool that dials.** It maps the string `mode` to
|
||||||
|
`CallMode`, defaults unknown modes to `DIRECT`, and lets `gateway.make_call`'s
|
||||||
|
refusals (`ValueError`) surface as `ToolError`. The emergency guard and
|
||||||
|
concurrency cap live in the gateway, **not** here — don't reimplement or skip
|
||||||
|
them at the tool layer (see the call-safety rule).
|
||||||
|
|
||||||
|
- **DB-backed tools use `session_scope()`** and read from `call_persistence`.
|
||||||
|
Completed-call history, summaries, recordings, and stored flows come from the
|
||||||
|
database, not from `active_calls` (those are live only). Keep the
|
||||||
|
`async with session_scope() as session:` pattern; don't open ad-hoc sessions.
|
||||||
|
|
||||||
|
- **Keep the tool count and README table in sync.** There are 15 tools + 3
|
||||||
|
resources. If you add/remove one, update the README's MCP table and the
|
||||||
|
`docs/mcp-server.md` reference — a drifting tool list is a documented lie.
|
||||||
34
.env.compose.example
Normal file
34
.env.compose.example
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# Compose environment for `docker compose up` — copy to `.env` and fill in.
|
||||||
|
#
|
||||||
|
# cp .env.compose.example .env
|
||||||
|
#
|
||||||
|
# These values are substituted into docker-compose.yaml (${VAR}); they are NOT
|
||||||
|
# baked into the image (.env is gitignored and in .dockerignore). Distinct from
|
||||||
|
# the app's own .env used for a bare `uvicorn` run.
|
||||||
|
|
||||||
|
# --- Database (the bundled postgres:17 service) ---
|
||||||
|
HS_DB_USER=holdslayer
|
||||||
|
HS_DB_PASSWORD=change-me
|
||||||
|
HS_DB_NAME=holdslayer
|
||||||
|
|
||||||
|
# --- Published port on the host ---
|
||||||
|
HS_APP_PORT=21081
|
||||||
|
|
||||||
|
# --- SIP: mock by default (dev/local). Set false + fill SIP_TRUNK_* for a real trunk. ---
|
||||||
|
USE_MOCK_SIP=true
|
||||||
|
|
||||||
|
# --- Auth: Casdoor SSO (owner-only) ---
|
||||||
|
# Required: this stack publishes the port on 0.0.0.0, so dev-owner mode
|
||||||
|
# (CASDOOR_ENABLED=false) is refused at startup — it's loopback-only. Register a
|
||||||
|
# `hold-slayer` app in Casdoor (org heluca, redirect URI <PUBLIC_BASE_URL>/auth/callback).
|
||||||
|
CASDOOR_ENABLED=true
|
||||||
|
CASDOOR_ENDPOINT=https://id.ouranos.helu.ca
|
||||||
|
CASDOOR_CLIENT_ID=
|
||||||
|
CASDOOR_CLIENT_SECRET=
|
||||||
|
CASDOOR_ORG_NAME=heluca
|
||||||
|
CASDOOR_APP_NAME=hold-slayer
|
||||||
|
# The owner's Casdoor username — the only identity allowed on any surface.
|
||||||
|
OWNER_NAME=
|
||||||
|
# Public base URL the browser reaches (drives OAuth discovery + the Casdoor
|
||||||
|
# redirect_uri). E.g. http://localhost:21081 for a local run.
|
||||||
|
PUBLIC_BASE_URL=http://localhost:21081
|
||||||
240
CLAUDE.md
Normal file
240
CLAUDE.md
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
# CLAUDE.md — Hold Slayer 🔥🐾
|
||||||
|
|
||||||
|
Red Panda Standards for the Hold Slayer telephony gateway. This is an
|
||||||
|
**AI-powered PSTN gateway**: it places real phone calls, navigates IVR menus,
|
||||||
|
waits on hold, and rings a human's desk phone when a live person answers. It
|
||||||
|
also answers inbound calls with an AI receptionist and smart routing.
|
||||||
|
|
||||||
|
**It dials real numbers on a real SIP trunk and may incur telephony charges,
|
||||||
|
and an AI agent drives it.** Treat every change through that lens: a bug here
|
||||||
|
isn't a 500, it's an unwanted phone call — or a *refused emergency call that
|
||||||
|
should never have been attempted in the first place*. Read this file before
|
||||||
|
touching call placement, auth, the SIP thread boundary, or the emergency guard.
|
||||||
|
|
||||||
|
Lead with a paw print in this repo.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The shape of this thing (read once, then it's obvious)
|
||||||
|
|
||||||
|
One FastAPI process exposes four surfaces over the same port: **REST** (`/api/v1/*`),
|
||||||
|
**WebSocket** (`/ws/*`), an **MCP server** (streamable HTTP at `/mcp/`), and the
|
||||||
|
built **SvelteKit dashboard** at `/`. All four are **owner-only**, gated by Casdoor
|
||||||
|
SSO (browser JWT) or an owner-minted PAT through one shared resolver.
|
||||||
|
|
||||||
|
Under them sits a **composition root** in [main.py](main.py)'s `lifespan`: the
|
||||||
|
gateway and every service are constructed and wired there, then hung on
|
||||||
|
`app.state`. Nothing constructs its own dependencies — if you need a new
|
||||||
|
service, build it in the lifespan and pass it in.
|
||||||
|
|
||||||
|
Below the services is the part that makes this app unusual: a **`SippyB2BUAEngine`
|
||||||
|
that runs the SIP/PJSUA2 event loop on its own OS thread**, not the asyncio loop.
|
||||||
|
This is the highest-leverage invariant in the codebase. See
|
||||||
|
[the concurrency rule](.claude/rules/concurrency-threads.md) — the README's
|
||||||
|
"single-process async" line is a simplification; there are two execution
|
||||||
|
contexts and exactly one funnel between them.
|
||||||
|
|
||||||
|
```
|
||||||
|
REST / WS / MCP / Dashboard (asyncio, FastAPI)
|
||||||
|
│
|
||||||
|
composition root (lifespan) → services → gateway
|
||||||
|
│
|
||||||
|
SippyB2BUAEngine ──┬── asyncio side (legs, bridges, event bus)
|
||||||
|
└── Sippy thread (UA objects, ED2 dispatcher)
|
||||||
|
↑ crossed only via _post_from_ed / _run_on_sippy
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Red Panda Approval™ — what it means here
|
||||||
|
|
||||||
|
1. **Fresh Environment Test** — `cp .env.example .env`, set `DATABASE_URL` and
|
||||||
|
either `CASDOOR_*` + `OWNER_NAME` (SSO) or `CASDOOR_ENABLED=false` with
|
||||||
|
`HOST=127.0.0.1` (dev-owner, loopback only), `pip install -e ".[dev]"`,
|
||||||
|
`uvicorn main:app`. It must boot to a clear log banner or **exit with a
|
||||||
|
human-readable reason** (see `_check_startup_config` / `_handle_db_error` in
|
||||||
|
[main.py](main.py)). It must *never* boot into a state where it silently can't
|
||||||
|
place calls — that's why `use_mock_sip` is opt-in and an unconfigured trunk
|
||||||
|
fails startup.
|
||||||
|
2. **Elegant Simplicity** — the composition root wires; services do one job;
|
||||||
|
the thread boundary has exactly one funnel each way. Don't add a second path
|
||||||
|
across the thread line, a second auth mechanism, or a service that reaches
|
||||||
|
into another service's internals.
|
||||||
|
3. **Observable & Debuggable** — `/health` is *honest*: it reports `degraded`
|
||||||
|
with the reason (mock engine, unregistered trunk, DB down, STT/TTS
|
||||||
|
unreachable) rather than a green light that lies. Keep it honest. Events flow
|
||||||
|
through the typed `EventBus`; new call-lifecycle facts become typed events,
|
||||||
|
not `print`s.
|
||||||
|
4. **Consistent Patterns** — config via pydantic-settings sub-configs; MCP tools
|
||||||
|
return formatted strings; REST returns Pydantic models; DB access via
|
||||||
|
`session_scope()`. Match the neighbours.
|
||||||
|
5. **Actually Works** — `pytest tests/ -v` (146 tests across 16 files). A change
|
||||||
|
to call placement, routing, the classifier, or auth needs a test. The suite
|
||||||
|
runs against SQLite (`aiosqlite`) and the mock SIP engine — no trunk, no
|
||||||
|
Postgres required to test.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Invariants that must not be weakened
|
||||||
|
|
||||||
|
These are safety- and correctness-critical. Loosening one is never a casual
|
||||||
|
refactor — it needs an explicit rationale and, where it deviates from a stated
|
||||||
|
standard, a note (there is no `docs/EXCEPTIONS.md` yet; if you start
|
||||||
|
accumulating documented deviations, create one rather than letting them go
|
||||||
|
unrecorded).
|
||||||
|
|
||||||
|
- **The emergency-number guard is absolute.** `is_emergency_number()` in
|
||||||
|
[core/dial_plan.py](core/dial_plan.py) blocks `911`/`9911`/`112` (and their
|
||||||
|
E.164 forms, whitespace/dashes stripped) at `gateway.make_call`, **before**
|
||||||
|
the concurrency check and before any SIP action. Every dialling path — REST
|
||||||
|
`make_call`, MCP `make_call`, receptionist call-back, transfer to an external
|
||||||
|
number — must pass through a guard that refuses these. An AI agent must never
|
||||||
|
place an emergency call, and API calls carry no E911 location. **Do not add a
|
||||||
|
dial path that bypasses `make_call`'s guard.** If you add a new outbound path,
|
||||||
|
it calls the guard first. See [the safety rule](.claude/rules/call-safety.md).
|
||||||
|
|
||||||
|
- **The concurrency cap is real spend control.** `max_concurrent_calls` (default
|
||||||
|
4) caps simultaneous outbound calls in `gateway.make_call`. It's checked after
|
||||||
|
the emergency guard, before creating the call. Don't remove it or move it
|
||||||
|
below call creation.
|
||||||
|
|
||||||
|
- **The Sippy thread boundary is crossed only through the two funnels.** Sippy
|
||||||
|
UA objects and the `ED2` dispatcher live on the Sippy thread; legs, bridges,
|
||||||
|
and the event bus live on the asyncio loop. Cross **thread→loop** only via
|
||||||
|
`_post_from_ed` (→ `run_coroutine_threadsafe` → the single `_on_engine_event`
|
||||||
|
funnel) and **loop→thread** only via `_run_on_sippy` (→ `ED2.callFromThread`).
|
||||||
|
Never touch a Sippy UA object from the loop; never mutate loop-owned state from
|
||||||
|
a Sippy handler. This is the whole reason the app is thread-safe.
|
||||||
|
|
||||||
|
- **Auth is Casdoor SSO + owner-minted PATs, owner-only, one resolver.** The
|
||||||
|
browser signs in via Casdoor (short-lived JWT); MCP/CLI clients use owner-minted
|
||||||
|
PATs (`hs_pat_…`). `resolve_bearer` in [auth.py](auth.py) turns either into a
|
||||||
|
`User`, and `is_owner`/`get_current_owner` gate every surface to the single
|
||||||
|
operator (`OWNER_NAME`) — non-owners get 403. Dev mode (`CASDOOR_ENABLED=false`)
|
||||||
|
resolves the dev owner and is **only** permitted on a loopback bind;
|
||||||
|
`_check_startup_config` refuses SSO-off-loopback and SSO-on-with-missing-config.
|
||||||
|
Keep those refusals — a silent dev-owner-open-on-0.0.0.0 is the failure mode
|
||||||
|
they exist to prevent. See [the auth rule](.claude/rules/auth-surfaces.md).
|
||||||
|
|
||||||
|
- **`/health` tells the truth.** `healthy` requires a real (non-mock) engine, a
|
||||||
|
registered trunk, and a reachable DB. Don't relax it to make a probe go green;
|
||||||
|
a gateway that can't place calls is not healthy, and the dashboard/operator
|
||||||
|
needs to see that.
|
||||||
|
|
||||||
|
- **The database is the source of truth for history; live state is in memory.**
|
||||||
|
Active calls live in the `CallManager`; completed calls + transcripts are
|
||||||
|
persisted on hangup via `call_persistence`. MCP/REST history and summaries read
|
||||||
|
from the DB through `session_scope()`. Don't confuse the two — a call that
|
||||||
|
ended is gone from `active_calls` and only exists in the DB.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Config — pydantic-settings, nested, singleton
|
||||||
|
|
||||||
|
Config is `Settings` in [config.py](config.py): a root `BaseSettings` with
|
||||||
|
**nested sub-config models** (`SIPTrunkSettings`, `LLMSettings`, `TTSSettings`,
|
||||||
|
`ReceptionistSettings`, …), each with its own `env_prefix`
|
||||||
|
(`SIP_TRUNK_`, `LLM_`, `TTS_`, `RECEPTIONIST_`, …). Read config through
|
||||||
|
`get_settings()` (a cached singleton) — don't call `os.environ.get` for
|
||||||
|
Hold Slayer settings, and don't construct `Settings()` yourself outside that
|
||||||
|
accessor.
|
||||||
|
|
||||||
|
> **Estate note (not a defect):** unlike the `KERNOS_`/`ARGOS_`/`NIKE_`-style
|
||||||
|
> single-prefix services, Hold Slayer has **no one umbrella prefix** — root vars
|
||||||
|
> (`DATABASE_URL`, `HOST`, `MAX_CONCURRENT_CALLS`, `OWNER_NAME`) are unprefixed
|
||||||
|
> and each subsystem carries its own (`CASDOOR_` for the SSO connection). That's a
|
||||||
|
> deliberate readability choice for
|
||||||
|
> a config with this many subsystems; keep new vars consistent with the
|
||||||
|
> sub-config they belong to, and if you add a new subsystem, give it its own
|
||||||
|
> sub-config + prefix rather than piling flat vars onto the root.
|
||||||
|
|
||||||
|
Secrets (`casdoor.client_secret`, `sip_trunk.password`, `llm.api_key`,
|
||||||
|
`tts.api_key`) are
|
||||||
|
`SecretStr` — keep them so, and read via `.get_secret_value()` only at the point
|
||||||
|
of use. Every new var also needs a row in [.env.example](.env.example) and the
|
||||||
|
README's config table. See [the config rule](.claude/rules/config-startup.md).
|
||||||
|
|
||||||
|
**`.env` hygiene:** `.env` is gitignored and holds real secrets — never commit
|
||||||
|
it, never treat the local `.env` as a template. Only `.env.example`
|
||||||
|
(placeholders) is committed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## MCP tools — formatted strings, and the error convention to know
|
||||||
|
|
||||||
|
MCP tools ([mcp_server/server.py](mcp_server/server.py)) return **plain
|
||||||
|
human-readable strings** (not JSON, not Pydantic models — that's the REST
|
||||||
|
layer's job). Resources (`gateway://status`, `gateway://call-flows`,
|
||||||
|
`gateway://active-calls`) return JSON strings.
|
||||||
|
|
||||||
|
There's a **deliberate but uneven error convention** worth understanding before
|
||||||
|
you add a tool:
|
||||||
|
|
||||||
|
- `make_call` raises `ToolError` on a bad request (emergency number, bad mode,
|
||||||
|
cap hit) — a hard failure the assistant should treat as an error.
|
||||||
|
- Most read/lookup tools **return** an error *string* (`"Call … not found."`,
|
||||||
|
`"Error looking up …: {e}"`) instead of raising — a soft "here's what
|
||||||
|
happened" the assistant reads as content.
|
||||||
|
|
||||||
|
When you add a tool: raise `ToolError` for "you asked for something invalid or
|
||||||
|
unsafe"; return a plain string for "I looked and here's the (possibly empty)
|
||||||
|
answer." The gateway is resolved lazily per call via `require_gateway()` (it
|
||||||
|
raises `ToolError` while the gateway is still starting) — keep that, because the
|
||||||
|
MCP app is mounted before the lifespan builds the gateway. See
|
||||||
|
[the MCP rule](.claude/rules/mcp-tools.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What's real vs. stubbed (don't mistake one for the other)
|
||||||
|
|
||||||
|
- **PJSUA2 media pipeline runs in stub mode** unless the `pjsua2` bindings are
|
||||||
|
built from pjproject (not pip-installable). Signaling works; audio
|
||||||
|
routing/recording is a no-op stub without them. Code that assumes real audio
|
||||||
|
must degrade honestly, and `/health`/engine-mode must reflect stub vs real.
|
||||||
|
- **The mock SIP engine (`MockSIPEngine`) must be asked for** (`USE_MOCK_SIP=true`).
|
||||||
|
It exists for tests and local dev. Production must not silently run on it —
|
||||||
|
`/health` reports `engine: mock` and refuses `healthy`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Known gaps (flag, don't fold — these are not your task unless asked)
|
||||||
|
|
||||||
|
Surfaced honestly so you don't rediscover them as surprises. The README's
|
||||||
|
Phase 4/5/6 checklists track most of these; **don't fold fixes into unrelated
|
||||||
|
work** — raise them.
|
||||||
|
|
||||||
|
- **No structured JSON logging.** Logging is plain `logging.basicConfig` in
|
||||||
|
[main.py](main.py); there's no `LOG_FORMAT`/JSON path (README Phase 4 has this
|
||||||
|
unchecked). If Heluca observability wants JSON logs shipped to a collector,
|
||||||
|
that's a deliberate piece of work, not a drive-by.
|
||||||
|
- **No `/metrics` endpoint and no Prometheus.** Unlike the metrics-bearing
|
||||||
|
estate services, there's no exposition endpoint here yet.
|
||||||
|
- **No health-probe access-log filter.** Every `/health` poll hits the access
|
||||||
|
log. Other estate services suppress probe noise; this one doesn't.
|
||||||
|
- **No rate limiting** on API endpoints (README Phase 4, unchecked).
|
||||||
|
- **Docker: single-image `Dockerfile` + `docker-compose.yaml`** (app +
|
||||||
|
`postgres:17`) ship in-repo; the Gitea CI (`cve-scan-docker-build.yml`) builds
|
||||||
|
the image on push to `main`. The compose stack requires SSO enabled
|
||||||
|
(published port ⇒ 0.0.0.0 ⇒ dev-owner mode refused). No systemd unit in-repo.
|
||||||
|
- **A committed `.DS_Store` is *not* present** (good), but do check `git status`
|
||||||
|
stays clean of OS cruft; `.gitignore` already lists it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Working here
|
||||||
|
|
||||||
|
- **Run:** `uvicorn main:app --host 0.0.0.0 --port 8000` (or `python main.py`).
|
||||||
|
The CLI `--port` wins over `settings.port` in the startup banner logic — a real
|
||||||
|
gotcha the banner code already accounts for; don't "fix" it into disagreement.
|
||||||
|
- **Test:** `pytest tests/ -v`. Fast, no external services (SQLite + mock SIP).
|
||||||
|
- **Lint:** `ruff check .` (line length 100, `py312` target).
|
||||||
|
- **Dashboard:** `cd dashboard && npm install && npm run build` → served at `/`
|
||||||
|
when `dashboard/build/` exists. The API/WS/health/MCP routes are registered
|
||||||
|
**before** the `"/"` static mount because a root mount matches everything —
|
||||||
|
keep that ordering.
|
||||||
|
- **DB migrations:** Alembic (`db/migrations/`), upgrade-on-boot via `init_db`.
|
||||||
|
Schema changes are migrations, never ad-hoc `CREATE`.
|
||||||
|
|
||||||
|
Path-scoped rules in [.claude/rules/](.claude/rules/) load automatically when you
|
||||||
|
edit the files they cover. They carry the fine-grained "don't break this"
|
||||||
|
detail; this file is the map.
|
||||||
57
README.md
57
README.md
@@ -172,13 +172,17 @@ pip install -e ".[dev]"
|
|||||||
```bash
|
```bash
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
# Edit .env with your SIP trunk credentials, LLM endpoint, etc.
|
# Edit .env with your SIP trunk credentials, LLM endpoint, etc.
|
||||||
# Required: DATABASE_URL, and API_TOKEN unless HOST=127.0.0.1
|
# Required: DATABASE_URL, plus either the Casdoor SSO settings
|
||||||
openssl rand -hex 32 # → API_TOKEN
|
# (CASDOOR_* + OWNER_NAME) or CASDOOR_ENABLED=false with HOST=127.0.0.1.
|
||||||
```
|
```
|
||||||
|
|
||||||
All REST, WebSocket, and MCP access requires `Authorization: Bearer
|
The gateway is **owner-only**. The browser dashboard signs in via **Casdoor
|
||||||
$API_TOKEN` (WebSocket also accepts `?token=...`). An empty token is only
|
SSO** (short-lived JWT); MCP and CLI clients use a **Personal Access Token**
|
||||||
permitted when bound to loopback.
|
(`hs_pat_…`) minted from the dashboard's *API Tokens* menu. Both are presented as
|
||||||
|
`Authorization: Bearer <token>` (WebSocket and `<audio>` recording downloads also
|
||||||
|
accept `?token=…`). Only the user whose Casdoor username matches `OWNER_NAME` may
|
||||||
|
use any surface — everyone else gets 403. With `CASDOOR_ENABLED=false` the gateway
|
||||||
|
runs in dev-owner mode, permitted **only** on a loopback bind.
|
||||||
|
|
||||||
### 3. Build the dashboard (optional but recommended)
|
### 3. Build the dashboard (optional but recommended)
|
||||||
|
|
||||||
@@ -204,6 +208,27 @@ uvicorn main:app --host 0.0.0.0 --port 8000
|
|||||||
pytest tests/ -v
|
pytest tests/ -v
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Docker
|
||||||
|
|
||||||
|
A single image bundles the FastAPI process and the built dashboard (the node
|
||||||
|
stage compiles the SPA; `pjsua2` is deliberately not built, so the media
|
||||||
|
pipeline runs in stub mode — see the `Dockerfile` header). `docker-compose.yaml`
|
||||||
|
brings up the app plus its own PostgreSQL:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.compose.example .env
|
||||||
|
# Fill in HS_DB_PASSWORD, and CASDOOR_CLIENT_ID/SECRET + OWNER_NAME.
|
||||||
|
docker compose up --build
|
||||||
|
# → http://localhost:21081
|
||||||
|
```
|
||||||
|
|
||||||
|
Because the published port binds the app to `0.0.0.0`, the compose stack must run
|
||||||
|
with **Casdoor SSO enabled** — dev-owner mode (`CASDOOR_ENABLED=false`) is
|
||||||
|
loopback-only and is refused at startup here. Register a `hold-slayer` app in
|
||||||
|
Casdoor (org `heluca`, redirect URI `<PUBLIC_BASE_URL>/auth/callback`) first. The
|
||||||
|
image runs with `USE_MOCK_SIP=true` by default (a real trunk needs the
|
||||||
|
`SIP_TRUNK_*` vars and `USE_MOCK_SIP=false`).
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
### REST API
|
### REST API
|
||||||
@@ -212,7 +237,7 @@ pytest tests/ -v
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -X POST http://localhost:8000/api/v1/calls/hold-slayer \
|
curl -X POST http://localhost:8000/api/v1/calls/hold-slayer \
|
||||||
-H "Authorization: Bearer $API_TOKEN" \
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{
|
-d '{
|
||||||
"number": "+18005551234",
|
"number": "+18005551234",
|
||||||
@@ -264,7 +289,7 @@ curl -X PATCH http://localhost:8000/api/v1/routing/devices/dev_abc123/dnd \
|
|||||||
### WebSocket — Real-Time Events
|
### WebSocket — Real-Time Events
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
const ws = new WebSocket(`ws://localhost:8000/ws/events?token=${API_TOKEN}`);
|
const ws = new WebSocket(`ws://localhost:8000/ws/events?token=${token}`);
|
||||||
ws.onmessage = (msg) => {
|
ws.onmessage = (msg) => {
|
||||||
const event = JSON.parse(msg.data);
|
const event = JSON.parse(msg.data);
|
||||||
// event.type: "human_detected", "hold_detected", "ivr_step", etc.
|
// event.type: "human_detected", "hold_detected", "ivr_step", etc.
|
||||||
@@ -276,11 +301,12 @@ ws.onmessage = (msg) => {
|
|||||||
### MCP — AI Assistant Integration
|
### MCP — AI Assistant Integration
|
||||||
|
|
||||||
The MCP server is served over **streamable HTTP at `/mcp/`** (note the
|
The MCP server is served over **streamable HTTP at `/mcp/`** (note the
|
||||||
trailing slash) and authenticates with the same bearer token:
|
trailing slash) and authenticates with an owner-minted Personal Access Token
|
||||||
|
(mint one from the dashboard's *API Tokens* menu — it starts with `hs_pat_`):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
claude mcp add hold-slayer --transport http http://localhost:8000/mcp/ \
|
claude mcp add hold-slayer --transport http http://localhost:8000/mcp/ \
|
||||||
--header "Authorization: Bearer $API_TOKEN"
|
--header "Authorization: Bearer hs_pat_..."
|
||||||
```
|
```
|
||||||
|
|
||||||
It exposes 15 tools and 3 resources (`gateway://status`,
|
It exposes 15 tools and 3 resources (`gateway://status`,
|
||||||
@@ -333,7 +359,14 @@ All configuration is via environment variables (see `.env.example`):
|
|||||||
| Variable | Description | Default |
|
| Variable | Description | Default |
|
||||||
|----------|-------------|---------|
|
|----------|-------------|---------|
|
||||||
| `DATABASE_URL` | PostgreSQL connection string | — (required) |
|
| `DATABASE_URL` | PostgreSQL connection string | — (required) |
|
||||||
| `API_TOKEN` | Static bearer token for REST/WS/MCP | — (required unless `HOST=127.0.0.1`) |
|
| `CASDOOR_ENABLED` | Enable Casdoor SSO (false → dev-owner, loopback only) | `false` |
|
||||||
|
| `CASDOOR_ENDPOINT` | Casdoor base URL | `https://id.ouranos.helu.ca` |
|
||||||
|
| `CASDOOR_CLIENT_ID` | Casdoor application client ID | — (required if SSO on) |
|
||||||
|
| `CASDOOR_CLIENT_SECRET` | Casdoor application client secret | — (required if SSO on) |
|
||||||
|
| `CASDOOR_ORG_NAME` | Casdoor organization | `heluca` |
|
||||||
|
| `CASDOOR_APP_NAME` | Casdoor application name | — |
|
||||||
|
| `OWNER_NAME` | Casdoor username of the single operator (owner) | — (required if SSO on) |
|
||||||
|
| `PUBLIC_BASE_URL` | Public base URL for OAuth discovery (else derived from headers) | — |
|
||||||
| `MAX_CONCURRENT_CALLS` | Cap on simultaneous outbound calls | `4` |
|
| `MAX_CONCURRENT_CALLS` | Cap on simultaneous outbound calls | `4` |
|
||||||
| `SIP_TRUNK_HOST` | Your SIP provider hostname | — |
|
| `SIP_TRUNK_HOST` | Your SIP provider hostname | — |
|
||||||
| `SIP_TRUNK_USERNAME` | SIP auth username | — |
|
| `SIP_TRUNK_USERNAME` | SIP auth username | — |
|
||||||
@@ -408,13 +441,13 @@ Full documentation is in [`/docs`](docs/README.md):
|
|||||||
### Phase 4: Production Hardening 🚧
|
### Phase 4: Production Hardening 🚧
|
||||||
|
|
||||||
- [x] Alembic database migrations (baseline + upgrade-on-boot)
|
- [x] Alembic database migrations (baseline + upgrade-on-boot)
|
||||||
- [x] API authentication — static bearer token across REST/WS/MCP
|
- [x] API authentication — Casdoor SSO (browser JWT) + owner-minted PATs, owner-only across REST/WS/MCP
|
||||||
- [x] Emergency-number guard + concurrent-call cap on outbound calls
|
- [x] Emergency-number guard + concurrent-call cap on outbound calls
|
||||||
- [ ] Rate limiting on API endpoints
|
- [ ] Rate limiting on API endpoints
|
||||||
- [ ] Structured JSON logging
|
- [ ] Structured JSON logging
|
||||||
- [x] Honest /health — engine mode, DB ping, trunk registration, STT/TTS availability
|
- [x] Honest /health — engine mode, DB ping, trunk registration, STT/TTS availability
|
||||||
- [ ] Graceful degradation (classifier works without STT, etc.)
|
- [ ] Graceful degradation (classifier works without STT, etc.)
|
||||||
- [ ] Docker Compose (Hold Slayer + PostgreSQL)
|
- [x] Docker Compose (Hold Slayer + PostgreSQL)
|
||||||
|
|
||||||
### Phase 5: Additional Services 🚧
|
### Phase 5: Additional Services 🚧
|
||||||
|
|
||||||
|
|||||||
191
api/auth.py
Normal file
191
api/auth.py
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
"""
|
||||||
|
OIDC authentication endpoints (Casdoor SSO).
|
||||||
|
|
||||||
|
GET /auth/login → redirect to Casdoor authorization URL
|
||||||
|
GET /auth/callback → exchange code for tokens, redirect to UI with token
|
||||||
|
GET /auth/me → return current user info (requires Bearer token)
|
||||||
|
GET /auth/silent-refresh → hidden-iframe refresh (re-auth with existing session)
|
||||||
|
GET /auth/refresh-callback → post the refreshed token to the parent window
|
||||||
|
GET /auth/logout → redirect to Casdoor logout URL
|
||||||
|
|
||||||
|
The dashboard is owner-only; ``/auth/me`` returns ``is_owner`` so a signed-in
|
||||||
|
non-owner sees an "access denied" screen instead of a bare 401.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import secrets
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, Query, Request
|
||||||
|
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
||||||
|
|
||||||
|
from auth import get_sdk, is_owner, resolve_from_header_or_query
|
||||||
|
from config import get_settings
|
||||||
|
from db.database import session_scope
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||||
|
|
||||||
|
|
||||||
|
def _build_casdoor_auth_url(
|
||||||
|
callback: str,
|
||||||
|
*,
|
||||||
|
scope: str = "openid profile email",
|
||||||
|
state: str | None = None,
|
||||||
|
prompt: str | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Build the Casdoor authorization URL directly.
|
||||||
|
|
||||||
|
The SDK's get_auth_link() doesn't support the ``prompt`` parameter that
|
||||||
|
silent refresh needs, so build the URL manually.
|
||||||
|
"""
|
||||||
|
c = get_settings().casdoor
|
||||||
|
params = {
|
||||||
|
"client_id": c.client_id,
|
||||||
|
"response_type": "code",
|
||||||
|
"redirect_uri": callback,
|
||||||
|
"scope": scope,
|
||||||
|
"state": state or secrets.token_urlsafe(16),
|
||||||
|
}
|
||||||
|
if prompt:
|
||||||
|
params["prompt"] = prompt
|
||||||
|
return f"{c.endpoint.rstrip('/')}/login/oauth/authorize?{urlencode(params)}"
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/login")
|
||||||
|
async def login(request: Request, redirect_uri: str = Query(None)):
|
||||||
|
"""Redirect the browser to the Casdoor authorization page.
|
||||||
|
|
||||||
|
No ``prompt=login`` — an existing Casdoor session auto-redirects back with
|
||||||
|
a code without showing the login form (silent SSO across *.helu.ca).
|
||||||
|
"""
|
||||||
|
if not get_settings().casdoor.enabled:
|
||||||
|
raise HTTPException(400, "Casdoor SSO is not enabled")
|
||||||
|
|
||||||
|
callback = redirect_uri or f"{request.base_url}auth/callback"
|
||||||
|
return RedirectResponse(url=_build_casdoor_auth_url(callback))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/callback")
|
||||||
|
async def callback(
|
||||||
|
code: str = Query(...),
|
||||||
|
state: str = Query(None),
|
||||||
|
redirect_uri: str = Query(None),
|
||||||
|
):
|
||||||
|
"""Exchange the authorization code for tokens.
|
||||||
|
|
||||||
|
Redirects to the dashboard with the access token in the URL *fragment*
|
||||||
|
(``/#token=...``) so the token stays client-side and is stored in
|
||||||
|
localStorage.
|
||||||
|
"""
|
||||||
|
if not get_settings().casdoor.enabled:
|
||||||
|
raise HTTPException(400, "Casdoor SSO is not enabled")
|
||||||
|
|
||||||
|
sdk = get_sdk()
|
||||||
|
try:
|
||||||
|
token = await sdk.get_oauth_token(code=code)
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(400, f"Token exchange failed: {exc}") from exc
|
||||||
|
|
||||||
|
access_token = token.get("access_token", "")
|
||||||
|
return RedirectResponse(url=f"/#token={access_token}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/silent-refresh")
|
||||||
|
async def silent_refresh(request: Request):
|
||||||
|
"""Start a silent token refresh via hidden iframe (``prompt=none``).
|
||||||
|
|
||||||
|
If the Casdoor session is still active, Casdoor redirects back to
|
||||||
|
``/auth/refresh-callback`` with a fresh code — no login form. Otherwise it
|
||||||
|
returns an error and the iframe tells the parent to show the login overlay.
|
||||||
|
"""
|
||||||
|
if not get_settings().casdoor.enabled:
|
||||||
|
raise HTTPException(400, "Casdoor SSO is not enabled")
|
||||||
|
|
||||||
|
callback = f"{request.base_url}auth/refresh-callback"
|
||||||
|
return RedirectResponse(url=_build_casdoor_auth_url(callback, prompt="none"))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/refresh-callback")
|
||||||
|
async def refresh_callback(
|
||||||
|
code: str = Query(None),
|
||||||
|
error: str = Query(None),
|
||||||
|
state: str = Query(None),
|
||||||
|
):
|
||||||
|
"""Handle the silent-refresh callback inside the hidden iframe.
|
||||||
|
|
||||||
|
On success posts the new token to the parent window; on failure posts an
|
||||||
|
error so the parent shows the login overlay.
|
||||||
|
"""
|
||||||
|
if not get_settings().casdoor.enabled:
|
||||||
|
raise HTTPException(400, "Casdoor SSO is not enabled")
|
||||||
|
|
||||||
|
if error or not code:
|
||||||
|
return HTMLResponse(
|
||||||
|
'<script>window.parent.postMessage('
|
||||||
|
'{type:"hold-slayer-refresh",error:true},"*");</script>'
|
||||||
|
)
|
||||||
|
|
||||||
|
sdk = get_sdk()
|
||||||
|
try:
|
||||||
|
token = await sdk.get_oauth_token(code=code)
|
||||||
|
access_token = token.get("access_token", "")
|
||||||
|
except Exception:
|
||||||
|
return HTMLResponse(
|
||||||
|
'<script>window.parent.postMessage('
|
||||||
|
'{type:"hold-slayer-refresh",error:true},"*");</script>'
|
||||||
|
)
|
||||||
|
|
||||||
|
return HTMLResponse(
|
||||||
|
f'<script>window.parent.postMessage('
|
||||||
|
f'{{type:"hold-slayer-refresh",token:"{access_token}"}},"*");</script>'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me")
|
||||||
|
async def me(request: Request):
|
||||||
|
"""Return the current authenticated user's profile + ``is_owner``.
|
||||||
|
|
||||||
|
Resolved manually (not via the ``OwnerUser`` gate) so a signed-in
|
||||||
|
non-owner gets a 200 with ``is_owner:false`` — the dashboard uses that to
|
||||||
|
show the "not authorized" screen rather than treating it as a hard 401.
|
||||||
|
"""
|
||||||
|
auth_header = request.headers.get("authorization")
|
||||||
|
q_token = request.query_params.get("token")
|
||||||
|
|
||||||
|
async with session_scope() as session:
|
||||||
|
user = await resolve_from_header_or_query(session, auth_header, q_token)
|
||||||
|
if user is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=401,
|
||||||
|
detail="Not authenticated",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
return JSONResponse(
|
||||||
|
{
|
||||||
|
"id": user.id,
|
||||||
|
"name": user.name,
|
||||||
|
"display_name": user.display_name,
|
||||||
|
"email": user.email,
|
||||||
|
"is_owner": is_owner(user),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/logout")
|
||||||
|
async def logout(request: Request):
|
||||||
|
"""Clear the Casdoor session and redirect back to the app.
|
||||||
|
|
||||||
|
``post_logout_redirect_uri`` must be absolute — Casdoor won't follow a
|
||||||
|
relative ``/`` — so it's derived from ``request.base_url`` (works behind
|
||||||
|
HAProxy/nginx with X-Forwarded-Proto/Host).
|
||||||
|
"""
|
||||||
|
c = get_settings().casdoor
|
||||||
|
if not c.enabled:
|
||||||
|
return RedirectResponse(url="/")
|
||||||
|
|
||||||
|
app_url = str(request.base_url).rstrip("/")
|
||||||
|
logout_url = (
|
||||||
|
f"{c.endpoint.rstrip('/')}/login/oauth/logout"
|
||||||
|
f"?client_id={c.client_id}"
|
||||||
|
f"&post_logout_redirect_uri={app_url}/auth/login"
|
||||||
|
)
|
||||||
|
return RedirectResponse(url=logout_url)
|
||||||
36
api/deps.py
36
api/deps.py
@@ -1,12 +1,12 @@
|
|||||||
"""
|
"""
|
||||||
API Dependencies — Shared dependency injection for all routes.
|
API Dependencies — Shared dependency injection for all routes.
|
||||||
|
|
||||||
|
Auth is not here: the owner gate lives in `auth.py` (`get_current_owner` /
|
||||||
|
`OwnerUser`), applied as a router-level dependency in main.py.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import secrets
|
from fastapi import HTTPException, Request
|
||||||
|
|
||||||
from fastapi import Header, HTTPException, Query, Request
|
|
||||||
|
|
||||||
from config import get_settings
|
|
||||||
from core.gateway import AIPSTNGateway
|
from core.gateway import AIPSTNGateway
|
||||||
|
|
||||||
|
|
||||||
@@ -24,31 +24,3 @@ def get_routing_service(request: Request):
|
|||||||
if routing is None:
|
if routing is None:
|
||||||
raise HTTPException(status_code=503, detail="Routing service not ready")
|
raise HTTPException(status_code=503, detail="Routing service not ready")
|
||||||
return routing
|
return routing
|
||||||
|
|
||||||
|
|
||||||
def require_token(
|
|
||||||
authorization: str | None = Header(default=None),
|
|
||||||
token: str | None = Query(default=None),
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
Enforce the static bearer token (API_TOKEN) on REST routes.
|
|
||||||
|
|
||||||
A `token` query parameter is accepted alongside the Authorization
|
|
||||||
header for clients that can't set headers — <audio>/<a> elements
|
|
||||||
fetching recordings — matching the WebSocket convention.
|
|
||||||
|
|
||||||
An empty configured token disables auth; startup refuses that
|
|
||||||
combination unless the server is bound to loopback.
|
|
||||||
"""
|
|
||||||
expected = get_settings().api_token.get_secret_value()
|
|
||||||
if not expected:
|
|
||||||
return
|
|
||||||
supplied = token or ""
|
|
||||||
if authorization and authorization.lower().startswith("bearer "):
|
|
||||||
supplied = authorization[7:]
|
|
||||||
if not secrets.compare_digest(supplied, expected):
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=401,
|
|
||||||
detail="Missing or invalid bearer token",
|
|
||||||
headers={"WWW-Authenticate": "Bearer"},
|
|
||||||
)
|
|
||||||
|
|||||||
107
api/tokens.py
Normal file
107
api/tokens.py
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
"""Owner-only CRUD for personal access tokens (PATs).
|
||||||
|
|
||||||
|
PATs are long-lived bearer tokens for MCP/CLI clients (Claude Desktop, Cline)
|
||||||
|
and scripted API consumers that can't refresh a short-lived Casdoor JWT. The
|
||||||
|
plaintext is shown to the caller exactly once at creation; only its SHA-256
|
||||||
|
hash is stored.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import secrets
|
||||||
|
import uuid
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from auth import PAT_PREFIX, OwnerUser, hash_token
|
||||||
|
from db.database import PersonalAccessToken, get_db
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1/tokens", tags=["tokens"])
|
||||||
|
|
||||||
|
|
||||||
|
class TokenCreate(BaseModel):
|
||||||
|
name: str = Field(..., min_length=1, max_length=200)
|
||||||
|
|
||||||
|
|
||||||
|
class TokenOut(BaseModel):
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
token_prefix: str
|
||||||
|
created_at: str | None = None
|
||||||
|
last_used_at: str | None = None
|
||||||
|
expires_at: str | None = None
|
||||||
|
revoked_at: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class TokenCreated(TokenOut):
|
||||||
|
token: str = Field(..., description="Plaintext token — shown only once. Store it now.")
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize(pat: PersonalAccessToken) -> dict:
|
||||||
|
return {
|
||||||
|
"id": pat.id,
|
||||||
|
"name": pat.name,
|
||||||
|
"token_prefix": pat.token_prefix,
|
||||||
|
"created_at": pat.created_at.isoformat() if pat.created_at else None,
|
||||||
|
"last_used_at": pat.last_used_at.isoformat() if pat.last_used_at else None,
|
||||||
|
"expires_at": pat.expires_at.isoformat() if pat.expires_at else None,
|
||||||
|
"revoked_at": pat.revoked_at.isoformat() if pat.revoked_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[TokenOut])
|
||||||
|
async def list_tokens(
|
||||||
|
user: OwnerUser,
|
||||||
|
session: AsyncSession = Depends(get_db),
|
||||||
|
) -> list[dict]:
|
||||||
|
"""List the owner's personal access tokens (no plaintext)."""
|
||||||
|
result = await session.execute(
|
||||||
|
select(PersonalAccessToken)
|
||||||
|
.where(PersonalAccessToken.user_id == user.id)
|
||||||
|
.order_by(PersonalAccessToken.created_at.desc())
|
||||||
|
)
|
||||||
|
return [_serialize(pat) for pat in result.scalars().all()]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=TokenCreated, status_code=201)
|
||||||
|
async def create_token(
|
||||||
|
payload: TokenCreate,
|
||||||
|
user: OwnerUser,
|
||||||
|
session: AsyncSession = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
"""Mint a new PAT. The plaintext is returned ONCE in the response."""
|
||||||
|
plaintext = PAT_PREFIX + secrets.token_urlsafe(32)
|
||||||
|
pat = PersonalAccessToken(
|
||||||
|
id=uuid.uuid4().hex,
|
||||||
|
user_id=user.id,
|
||||||
|
name=payload.name,
|
||||||
|
token_hash=hash_token(plaintext),
|
||||||
|
token_prefix=plaintext[: len(PAT_PREFIX) + 4],
|
||||||
|
)
|
||||||
|
session.add(pat)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(pat)
|
||||||
|
return {**_serialize(pat), "token": plaintext}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{token_id}", status_code=204)
|
||||||
|
async def revoke_token(
|
||||||
|
token_id: str,
|
||||||
|
user: OwnerUser,
|
||||||
|
session: AsyncSession = Depends(get_db),
|
||||||
|
) -> None:
|
||||||
|
"""Soft-revoke a PAT (sets revoked_at)."""
|
||||||
|
result = await session.execute(
|
||||||
|
select(PersonalAccessToken).where(
|
||||||
|
PersonalAccessToken.id == token_id,
|
||||||
|
PersonalAccessToken.user_id == user.id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
pat = result.scalar_one_or_none()
|
||||||
|
if pat is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Token not found")
|
||||||
|
if pat.revoked_at is None:
|
||||||
|
pat.revoked_at = datetime.now(UTC)
|
||||||
|
await session.commit()
|
||||||
@@ -1,13 +1,11 @@
|
|||||||
"""WebSocket API — Real-time call events and audio classification stream."""
|
"""WebSocket API — Real-time call events and audio classification stream."""
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import logging
|
import logging
|
||||||
import secrets
|
|
||||||
|
|
||||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||||
|
|
||||||
from api.deps import get_gateway
|
from auth import is_owner, resolve_from_header_or_query
|
||||||
from config import get_settings
|
from db.database import session_scope
|
||||||
from models.events import EventType, GatewayEvent
|
from models.events import EventType, GatewayEvent
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -17,21 +15,21 @@ router = APIRouter()
|
|||||||
|
|
||||||
async def _authorize(websocket: WebSocket) -> bool:
|
async def _authorize(websocket: WebSocket) -> bool:
|
||||||
"""
|
"""
|
||||||
Check the static bearer token before accepting the socket.
|
Require the owner before accepting the socket.
|
||||||
|
|
||||||
Browsers can't set headers on WebSocket connects, so a `token`
|
Browsers can't set headers on WebSocket connects, so the Casdoor JWT (or
|
||||||
query parameter is accepted alongside the Authorization header.
|
a PAT) is accepted on the `?token=` query param alongside the Authorization
|
||||||
|
header — the same narrow fallback the recording download uses. In dev mode
|
||||||
|
the owner resolves tokenlessly. A non-owner or absent credential closes the
|
||||||
|
socket with code 4401.
|
||||||
"""
|
"""
|
||||||
token = get_settings().api_token.get_secret_value()
|
q_token = websocket.query_params.get("token")
|
||||||
if not token:
|
auth_header = websocket.headers.get("authorization")
|
||||||
|
async with session_scope() as session:
|
||||||
|
user = await resolve_from_header_or_query(session, auth_header, q_token)
|
||||||
|
if user is not None and is_owner(user):
|
||||||
return True
|
return True
|
||||||
supplied = websocket.query_params.get("token", "")
|
await websocket.close(code=4401, reason="Owner authentication required")
|
||||||
auth = websocket.headers.get("authorization", "")
|
|
||||||
if auth.lower().startswith("bearer "):
|
|
||||||
supplied = auth[7:]
|
|
||||||
if secrets.compare_digest(supplied, token):
|
|
||||||
return True
|
|
||||||
await websocket.close(code=4401, reason="Missing or invalid bearer token")
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
385
auth.py
Normal file
385
auth.py
Normal file
@@ -0,0 +1,385 @@
|
|||||||
|
"""
|
||||||
|
Authentication and authorisation for Hold Slayer.
|
||||||
|
|
||||||
|
This gateway is **owner-only**. It dials real phones and spends money, so
|
||||||
|
there are no guest/shared resources: exactly one operator (the Casdoor user
|
||||||
|
whose name matches ``OWNER_NAME``) may use any surface; every other identity
|
||||||
|
gets 403.
|
||||||
|
|
||||||
|
Two bearer-token kinds are accepted on ``Authorization: Bearer <token>``
|
||||||
|
(or, for the two browser consumers that can't set headers — the WebSocket
|
||||||
|
connect and ``<audio>`` recording downloads — on a ``?token=`` query param):
|
||||||
|
|
||||||
|
1. **Casdoor JWT** — short-lived, signed by Casdoor. Validated against the
|
||||||
|
public keys served at ``${CASDOOR_ENDPOINT}/.well-known/jwks`` (PyJWKClient
|
||||||
|
cache, RS256). Used by the browser dashboard after OIDC login.
|
||||||
|
2. **Personal Access Token** — long-lived ``hs_pat_<random>`` token, minted
|
||||||
|
from the owner-only dashboard and stored hashed in
|
||||||
|
``personal_access_tokens``. Used by MCP/CLI clients (Claude Desktop, Cline)
|
||||||
|
that can't refresh a JWT.
|
||||||
|
|
||||||
|
When ``CASDOOR_ENABLED=false`` (dev, loopback only) every request resolves to
|
||||||
|
the dev owner — no token required.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
import jwt
|
||||||
|
from casdoor import AsyncCasdoorSDK
|
||||||
|
from fastapi import Depends, HTTPException, Query
|
||||||
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from config import get_settings
|
||||||
|
from db.database import PersonalAccessToken, User, get_db
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# ── Constants ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_DEV_OWNER_SUB = "dev-owner"
|
||||||
|
PAT_PREFIX = "hs_pat_"
|
||||||
|
|
||||||
|
# ── Casdoor SDK singleton (for OAuth code exchange in /auth/callback) ─────────
|
||||||
|
|
||||||
|
_sdk: AsyncCasdoorSDK | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_sdk() -> AsyncCasdoorSDK:
|
||||||
|
"""Build the Casdoor SDK lazily.
|
||||||
|
|
||||||
|
Used only for the OAuth2 code-exchange step in ``/auth/callback`` — JWT
|
||||||
|
validation happens via PyJWKClient below. The certificate parameter is
|
||||||
|
unused for code exchange but the constructor requires *something*; we pass
|
||||||
|
an empty bytestring.
|
||||||
|
"""
|
||||||
|
global _sdk
|
||||||
|
if _sdk is None:
|
||||||
|
c = get_settings().casdoor
|
||||||
|
_sdk = AsyncCasdoorSDK(
|
||||||
|
endpoint=c.endpoint,
|
||||||
|
client_id=c.client_id,
|
||||||
|
client_secret=c.client_secret.get_secret_value(),
|
||||||
|
certificate=b"",
|
||||||
|
org_name=c.org_name,
|
||||||
|
application_name=c.app_name,
|
||||||
|
)
|
||||||
|
return _sdk
|
||||||
|
|
||||||
|
|
||||||
|
# ── JWKS client (for Casdoor JWT validation) ─────────────────────────────────
|
||||||
|
|
||||||
|
_jwks_client: jwt.PyJWKClient | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def init_jwks_client() -> None:
|
||||||
|
"""Construct the PyJWKClient pointed at Casdoor's JWKS endpoint.
|
||||||
|
|
||||||
|
Called once from the app lifespan before requests are served. Pre-fetches
|
||||||
|
the keys so the network round-trip happens at startup rather than on the
|
||||||
|
first authenticated request. A no-op when SSO is disabled; a failed
|
||||||
|
prefetch is non-fatal (keys are fetched lazily on first use).
|
||||||
|
"""
|
||||||
|
global _jwks_client
|
||||||
|
if not get_settings().casdoor.enabled:
|
||||||
|
return
|
||||||
|
endpoint = get_settings().casdoor.endpoint.rstrip("/")
|
||||||
|
jwks_uri = f"{endpoint}/.well-known/jwks"
|
||||||
|
_jwks_client = jwt.PyJWKClient(jwks_uri, cache_keys=True, lifespan=3600)
|
||||||
|
try:
|
||||||
|
_jwks_client.fetch_data()
|
||||||
|
logger.info("Casdoor JWKS prefetched from %s", jwks_uri)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Casdoor JWKS prefetch failed (%s); will retry on first request", exc)
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_casdoor_jwt(token: str) -> dict:
|
||||||
|
"""Validate a Casdoor RS256 JWT against the cached JWKS.
|
||||||
|
|
||||||
|
Refreshes the key cache once on unknown-kid before giving up. Audience
|
||||||
|
verification is disabled because Casdoor sets ``aud`` to the application
|
||||||
|
name, which differs from the client_id; the signature check against
|
||||||
|
Casdoor's key is the primary control.
|
||||||
|
"""
|
||||||
|
if _jwks_client is None:
|
||||||
|
raise HTTPException(status_code=503, detail="Auth subsystem not ready")
|
||||||
|
|
||||||
|
issuer = get_settings().casdoor.endpoint.rstrip("/")
|
||||||
|
|
||||||
|
def _decode_with_current_keys() -> dict:
|
||||||
|
signing_key = _jwks_client.get_signing_key_from_jwt(token)
|
||||||
|
return jwt.decode(
|
||||||
|
token,
|
||||||
|
signing_key.key,
|
||||||
|
algorithms=["RS256"],
|
||||||
|
issuer=issuer,
|
||||||
|
options={"verify_aud": False},
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
return _decode_with_current_keys()
|
||||||
|
except jwt.ExpiredSignatureError as exc:
|
||||||
|
raise HTTPException(status_code=401, detail="Token has expired") from exc
|
||||||
|
except jwt.PyJWKClientError as exc:
|
||||||
|
logger.warning("Unknown JWKS key (%s); refreshing", exc)
|
||||||
|
try:
|
||||||
|
_jwks_client.fetch_data()
|
||||||
|
return _decode_with_current_keys()
|
||||||
|
except Exception as inner:
|
||||||
|
raise HTTPException(status_code=401, detail=f"Invalid token: {inner}") from inner
|
||||||
|
except jwt.InvalidTokenError as exc:
|
||||||
|
raise HTTPException(status_code=401, detail=f"Invalid token: {exc}") from exc
|
||||||
|
|
||||||
|
|
||||||
|
# ── PAT helpers ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def hash_token(plaintext: str) -> str:
|
||||||
|
"""SHA-256 hex digest of a plaintext PAT."""
|
||||||
|
return hashlib.sha256(plaintext.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
async def _validate_pat(session: AsyncSession, plaintext: str) -> User:
|
||||||
|
"""Look up a PAT by hash, check it's active, return the owning user."""
|
||||||
|
digest = hash_token(plaintext)
|
||||||
|
result = await session.execute(
|
||||||
|
select(PersonalAccessToken).where(PersonalAccessToken.token_hash == digest)
|
||||||
|
)
|
||||||
|
pat = result.scalar_one_or_none()
|
||||||
|
if pat is None or pat.revoked_at is not None:
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid token")
|
||||||
|
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
if pat.expires_at is not None:
|
||||||
|
# DateTime columns come back naive on SQLite (and on a Postgres
|
||||||
|
# TIMESTAMP without tz); treat a naive value as UTC before comparing.
|
||||||
|
expires_at = pat.expires_at
|
||||||
|
if expires_at.tzinfo is None:
|
||||||
|
expires_at = expires_at.replace(tzinfo=UTC)
|
||||||
|
if expires_at <= now:
|
||||||
|
raise HTTPException(status_code=401, detail="Token has expired")
|
||||||
|
|
||||||
|
pat.last_used_at = now
|
||||||
|
try:
|
||||||
|
await session.commit()
|
||||||
|
except Exception:
|
||||||
|
await session.rollback()
|
||||||
|
|
||||||
|
user_result = await session.execute(select(User).where(User.id == pat.user_id))
|
||||||
|
user = user_result.scalar_one_or_none()
|
||||||
|
if user is None:
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid token")
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
# ── User provisioning ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_or_create_dev_owner(session: AsyncSession) -> User:
|
||||||
|
"""Return the dev-mode owner user row, creating it if it doesn't exist."""
|
||||||
|
result = await session.execute(select(User).where(User.casdoor_sub == _DEV_OWNER_SUB))
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
if user is None:
|
||||||
|
user = User(id=uuid.uuid4().hex, name="Owner", casdoor_sub=_DEV_OWNER_SUB)
|
||||||
|
session.add(user)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(user)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
async def _find_or_create_user(
|
||||||
|
session: AsyncSession,
|
||||||
|
casdoor_sub: str,
|
||||||
|
name: str,
|
||||||
|
display_name: str,
|
||||||
|
email: str | None,
|
||||||
|
) -> User:
|
||||||
|
"""Look up a user by casdoor_sub; create a new row on first login.
|
||||||
|
|
||||||
|
Lookup priority, so identity survives a Casdoor redeploy:
|
||||||
|
1. casdoor_sub — the OIDC subject claim (primary SSO identity).
|
||||||
|
2. name — the Casdoor username (stable, unique). Relinks a changed sub.
|
||||||
|
3. email — pre-SSO users logging in via Casdoor for the first time.
|
||||||
|
|
||||||
|
Non-owner users are still provisioned (so ``is_owner`` can say "no"), but
|
||||||
|
they reach nothing — every surface is owner-gated.
|
||||||
|
"""
|
||||||
|
result = await session.execute(select(User).where(User.casdoor_sub == casdoor_sub))
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
if user is not None:
|
||||||
|
changed = False
|
||||||
|
if user.name != name:
|
||||||
|
user.name = name
|
||||||
|
changed = True
|
||||||
|
if user.display_name != display_name:
|
||||||
|
user.display_name = display_name
|
||||||
|
changed = True
|
||||||
|
if changed:
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(user)
|
||||||
|
return user
|
||||||
|
|
||||||
|
result = await session.execute(select(User).where(User.name == name))
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
if user is not None:
|
||||||
|
logger.info(
|
||||||
|
"Linking user %s (id=%s) to new casdoor_sub %s (was %s)",
|
||||||
|
name, user.id, casdoor_sub, user.casdoor_sub,
|
||||||
|
)
|
||||||
|
user.casdoor_sub = casdoor_sub
|
||||||
|
user.display_name = display_name
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(user)
|
||||||
|
return user
|
||||||
|
|
||||||
|
if email:
|
||||||
|
result = await session.execute(select(User).where(User.email == email))
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
if user is not None:
|
||||||
|
user.casdoor_sub = casdoor_sub
|
||||||
|
user.name = name
|
||||||
|
user.display_name = display_name
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(user)
|
||||||
|
return user
|
||||||
|
|
||||||
|
user = User(
|
||||||
|
id=uuid.uuid4().hex,
|
||||||
|
name=name,
|
||||||
|
display_name=display_name,
|
||||||
|
email=email,
|
||||||
|
casdoor_sub=casdoor_sub,
|
||||||
|
)
|
||||||
|
session.add(user)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(user)
|
||||||
|
logger.info("Created new user: %s (id=%s)", name, user.id)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def _claims_to_identity(claims: dict) -> tuple[str, str, str, str | None]:
|
||||||
|
"""Pull (sub, name, display_name, email) out of Casdoor JWT claims."""
|
||||||
|
sub = claims.get("sub") or claims.get("name") or ""
|
||||||
|
name = claims.get("name") or sub
|
||||||
|
display_name = claims.get("displayName") or claims.get("name") or sub
|
||||||
|
email = claims.get("email") or None
|
||||||
|
return sub, name, display_name, email
|
||||||
|
|
||||||
|
|
||||||
|
# ── The single resolver — used by every surface ──────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
async def resolve_bearer(session: AsyncSession, raw_token: str | None) -> User | None:
|
||||||
|
"""Resolve a bare bearer-token string to a User, or None on any failure.
|
||||||
|
|
||||||
|
This is the one place a token becomes an identity. It never raises — the
|
||||||
|
caller decides how to respond (401/403 for REST, 4401 close for WS). The
|
||||||
|
header-based REST path and the ``?token=`` query path both funnel here.
|
||||||
|
|
||||||
|
Dev mode (SSO disabled) ignores the token and returns the dev owner.
|
||||||
|
"""
|
||||||
|
if not get_settings().casdoor.enabled:
|
||||||
|
return await _get_or_create_dev_owner(session)
|
||||||
|
|
||||||
|
if not raw_token:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
if raw_token.startswith(PAT_PREFIX):
|
||||||
|
return await _validate_pat(session, raw_token)
|
||||||
|
claims = _decode_casdoor_jwt(raw_token)
|
||||||
|
except HTTPException:
|
||||||
|
return None
|
||||||
|
|
||||||
|
sub, name, display_name, email = _claims_to_identity(claims)
|
||||||
|
if not sub:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return await _find_or_create_user(session, sub, name, display_name, email)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _token_from_header(authorization_header: str | None) -> str | None:
|
||||||
|
"""Extract the bearer token from an Authorization header, or None."""
|
||||||
|
if not authorization_header:
|
||||||
|
return None
|
||||||
|
parts = authorization_header.split(None, 1)
|
||||||
|
if len(parts) != 2 or parts[0].lower() != "bearer":
|
||||||
|
return None
|
||||||
|
token = parts[1].strip()
|
||||||
|
return token or None
|
||||||
|
|
||||||
|
|
||||||
|
async def resolve_from_header_or_query(
|
||||||
|
session: AsyncSession,
|
||||||
|
authorization_header: str | None,
|
||||||
|
query_token: str | None,
|
||||||
|
) -> User | None:
|
||||||
|
"""Resolve a User from an Authorization header, falling back to ``?token=``.
|
||||||
|
|
||||||
|
The query fallback exists only for the two browser consumers that can't
|
||||||
|
set headers — the WebSocket connect and ``<audio>`` recording downloads —
|
||||||
|
matching Hold Slayer's long-standing narrow ``?token=`` convention. The
|
||||||
|
header wins when both are present.
|
||||||
|
"""
|
||||||
|
raw = _token_from_header(authorization_header) or query_token
|
||||||
|
return await resolve_bearer(session, raw)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Ownership ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def is_owner(user: User) -> bool:
|
||||||
|
"""Whether the user owns this gateway.
|
||||||
|
|
||||||
|
Dev mode: the dev-owner sub is always the owner. SSO mode: the owner is
|
||||||
|
the user whose Casdoor username (``user.name``) matches ``OWNER_NAME``.
|
||||||
|
"""
|
||||||
|
if not get_settings().casdoor.enabled:
|
||||||
|
return user.casdoor_sub == _DEV_OWNER_SUB
|
||||||
|
owner_name = get_settings().owner_name
|
||||||
|
return bool(owner_name and user.name == owner_name)
|
||||||
|
|
||||||
|
|
||||||
|
# ── FastAPI dependencies ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_bearer = HTTPBearer(auto_error=False)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_current_user(
|
||||||
|
credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(_bearer)] = None,
|
||||||
|
token: str | None = Query(default=None),
|
||||||
|
session: AsyncSession = Depends(get_db),
|
||||||
|
) -> User:
|
||||||
|
"""Resolve the authenticated user (owner or not) — 401 if unauthenticated.
|
||||||
|
|
||||||
|
Used by ``/auth/me`` so a signed-in non-owner sees ``is_owner:false``
|
||||||
|
rather than a bare 401. Owner-gating is a separate step.
|
||||||
|
"""
|
||||||
|
header = f"Bearer {credentials.credentials}" if credentials else None
|
||||||
|
user = await resolve_from_header_or_query(session, header, token)
|
||||||
|
if user is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=401,
|
||||||
|
detail="Not authenticated",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
async def get_current_owner(user: Annotated[User, Depends(get_current_user)]) -> User:
|
||||||
|
"""The one gate for protected surfaces — 401 if unauthenticated, 403 if not owner."""
|
||||||
|
if not is_owner(user):
|
||||||
|
raise HTTPException(status_code=403, detail="Owner access required")
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
OwnerUser = Annotated[User, Depends(get_current_owner)]
|
||||||
31
config.py
31
config.py
@@ -89,6 +89,25 @@ class TTSSettings(BaseSettings):
|
|||||||
sample_rate: int = 16000
|
sample_rate: int = 16000
|
||||||
|
|
||||||
|
|
||||||
|
class CasdoorSettings(BaseSettings):
|
||||||
|
"""Casdoor SSO (OIDC) configuration.
|
||||||
|
|
||||||
|
When `enabled` is true the browser authenticates via Casdoor and every
|
||||||
|
surface is gated to the owner; the SDK is used only for the OAuth2 code
|
||||||
|
exchange in the /auth/callback route (JWTs are validated against the
|
||||||
|
endpoint's JWKS). When false, the app runs in dev-owner mode (loopback only).
|
||||||
|
"""
|
||||||
|
|
||||||
|
model_config = SettingsConfigDict(env_prefix="CASDOOR_", env_file=".env", extra="ignore")
|
||||||
|
|
||||||
|
enabled: bool = False
|
||||||
|
endpoint: str = "https://id.ouranos.helu.ca"
|
||||||
|
client_id: str = ""
|
||||||
|
client_secret: SecretStr = SecretStr("")
|
||||||
|
org_name: str = "heluca"
|
||||||
|
app_name: str = ""
|
||||||
|
|
||||||
|
|
||||||
class ReceptionistSettings(BaseSettings):
|
class ReceptionistSettings(BaseSettings):
|
||||||
"""AI Receptionist behavior settings."""
|
"""AI Receptionist behavior settings."""
|
||||||
|
|
||||||
@@ -126,9 +145,14 @@ class Settings(BaseSettings):
|
|||||||
debug: bool = False
|
debug: bool = False
|
||||||
log_level: str = "info"
|
log_level: str = "info"
|
||||||
|
|
||||||
# Auth — one static bearer token shared by REST, WebSocket, and MCP.
|
# Auth — Casdoor SSO for the browser + owner-minted PATs for MCP/CLI,
|
||||||
# Empty disables auth, which is only permitted on loopback binds.
|
# gated to a single owner. `owner_name` is the Casdoor username that owns
|
||||||
api_token: SecretStr = SecretStr("")
|
# this gateway (everyone else gets 403). `public_base_url` seeds the OAuth
|
||||||
|
# discovery URLs; blank derives them from request headers. Both cross-cut
|
||||||
|
# every surface, so they live on the root model (like DATABASE_URL); the
|
||||||
|
# Casdoor connection knobs live under the CASDOOR_ prefix.
|
||||||
|
owner_name: str = ""
|
||||||
|
public_base_url: str = ""
|
||||||
|
|
||||||
# Outbound-call safety cap (REST + MCP make_call)
|
# Outbound-call safety cap (REST + MCP make_call)
|
||||||
max_concurrent_calls: int = 4
|
max_concurrent_calls: int = 4
|
||||||
@@ -150,6 +174,7 @@ class Settings(BaseSettings):
|
|||||||
hold_slayer: HoldSlayerSettings = Field(default_factory=HoldSlayerSettings)
|
hold_slayer: HoldSlayerSettings = Field(default_factory=HoldSlayerSettings)
|
||||||
tts: TTSSettings = Field(default_factory=TTSSettings)
|
tts: TTSSettings = Field(default_factory=TTSSettings)
|
||||||
receptionist: ReceptionistSettings = Field(default_factory=ReceptionistSettings)
|
receptionist: ReceptionistSettings = Field(default_factory=ReceptionistSettings)
|
||||||
|
casdoor: CasdoorSettings = Field(default_factory=CasdoorSettings)
|
||||||
|
|
||||||
|
|
||||||
# Singleton
|
# Singleton
|
||||||
|
|||||||
11
dashboard/package-lock.json
generated
11
dashboard/package-lock.json
generated
@@ -13,6 +13,7 @@
|
|||||||
"@sveltejs/vite-plugin-svelte": "^5.0.3",
|
"@sveltejs/vite-plugin-svelte": "^5.0.3",
|
||||||
"@tailwindcss/vite": "^4.1.3",
|
"@tailwindcss/vite": "^4.1.3",
|
||||||
"@types/node": "^25.8.0",
|
"@types/node": "^25.8.0",
|
||||||
|
"daisyui": "^5.0.0",
|
||||||
"svelte": "^5.25.3",
|
"svelte": "^5.25.3",
|
||||||
"svelte-check": "^4.1.4",
|
"svelte-check": "^4.1.4",
|
||||||
"tailwindcss": "^4.1.3",
|
"tailwindcss": "^4.1.3",
|
||||||
@@ -1262,6 +1263,16 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/daisyui": {
|
||||||
|
"version": "5.7.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/daisyui/-/daisyui-5.7.0.tgz",
|
||||||
|
"integrity": "sha512-2/kYbxaKtv349lPrTyxMKC9SHsyA7fBULMSabJljDE82D079cjqz+UyAzsogWgy4sTs5NDvD000acfcFqbO1XA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/saadeghi/daisyui?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/debug": {
|
"node_modules/debug": {
|
||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
"@sveltejs/vite-plugin-svelte": "^5.0.3",
|
"@sveltejs/vite-plugin-svelte": "^5.0.3",
|
||||||
"@tailwindcss/vite": "^4.1.3",
|
"@tailwindcss/vite": "^4.1.3",
|
||||||
"@types/node": "^25.8.0",
|
"@types/node": "^25.8.0",
|
||||||
|
"daisyui": "^5.0.0",
|
||||||
"svelte": "^5.25.3",
|
"svelte": "^5.25.3",
|
||||||
"svelte-check": "^4.1.4",
|
"svelte-check": "^4.1.4",
|
||||||
"tailwindcss": "^4.1.3",
|
"tailwindcss": "^4.1.3",
|
||||||
|
|||||||
@@ -1 +1,4 @@
|
|||||||
@import 'tailwindcss';
|
@import 'tailwindcss';
|
||||||
|
@plugin 'daisyui' {
|
||||||
|
themes: light --default, dark --prefersdark;
|
||||||
|
}
|
||||||
|
|||||||
5
dashboard/src/hooks.client.ts
Normal file
5
dashboard/src/hooks.client.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { auth } from '$lib/auth.svelte';
|
||||||
|
|
||||||
|
// Runs once, before the app mounts: capture the Casdoor callback token from
|
||||||
|
// the URL fragment before any /auth/me call or render.
|
||||||
|
auth.captureFragmentToken();
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import type {
|
import type {
|
||||||
|
AccessToken,
|
||||||
CallHistoryRow,
|
CallHistoryRow,
|
||||||
CallSummary,
|
CallSummary,
|
||||||
DeviceStatus,
|
DeviceStatus,
|
||||||
@@ -10,19 +11,12 @@ import type {
|
|||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
// ---------------------------------------------------------------
|
// ---------------------------------------------------------------
|
||||||
// Bearer token — one static API_TOKEN shared with REST/WS/MCP.
|
// Authed fetch — the browser holds a Casdoor JWT (or, for scripted use,
|
||||||
// Kept in localStorage; a 401 prompts once and retries.
|
// a PAT) in localStorage via the auth store. On a 401 we attempt one
|
||||||
|
// silent refresh and retry; a second failure logs out.
|
||||||
// ---------------------------------------------------------------
|
// ---------------------------------------------------------------
|
||||||
|
|
||||||
const TOKEN_KEY = 'hold-slayer-token';
|
import { auth, getToken } from './auth.svelte';
|
||||||
|
|
||||||
function getToken(): string {
|
|
||||||
return localStorage.getItem(TOKEN_KEY) ?? '';
|
|
||||||
}
|
|
||||||
|
|
||||||
export function setToken(value: string): void {
|
|
||||||
localStorage.setItem(TOKEN_KEY, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
function withAuth(init: RequestInit): RequestInit {
|
function withAuth(init: RequestInit): RequestInit {
|
||||||
const token = getToken();
|
const token = getToken();
|
||||||
@@ -36,10 +30,11 @@ function withAuth(init: RequestInit): RequestInit {
|
|||||||
async function request(path: string, init: RequestInit = {}): Promise<Response> {
|
async function request(path: string, init: RequestInit = {}): Promise<Response> {
|
||||||
let res = await fetch(path, withAuth(init));
|
let res = await fetch(path, withAuth(init));
|
||||||
if (res.status === 401) {
|
if (res.status === 401) {
|
||||||
const supplied = window.prompt('Hold Slayer API token (API_TOKEN in .env):');
|
const refreshed = await auth.trySilentRefresh();
|
||||||
if (supplied !== null && supplied.trim()) {
|
if (refreshed) {
|
||||||
setToken(supplied.trim());
|
|
||||||
res = await fetch(path, withAuth(init));
|
res = await fetch(path, withAuth(init));
|
||||||
|
} else {
|
||||||
|
auth.setUnauthenticated();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return res;
|
return res;
|
||||||
@@ -89,8 +84,10 @@ export async function fetchTranscript(callId: string): Promise<TranscriptRow[]>
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function recordingUrl(callId: string): string {
|
export function recordingUrl(callId: string): string {
|
||||||
// <audio> can't send headers, so the token rides as a query param
|
// <audio> can't send headers, so the current token (Casdoor JWT, or a PAT)
|
||||||
// (accepted server-side alongside the Authorization header).
|
// rides as a query param — the same narrow fallback the WebSocket uses,
|
||||||
|
// accepted server-side alongside the Authorization header. The proactive
|
||||||
|
// refresh timer keeps the stored JWT valid, so it's fresh at click time.
|
||||||
const token = getToken();
|
const token = getToken();
|
||||||
const suffix = token ? `?token=${encodeURIComponent(token)}` : '';
|
const suffix = token ? `?token=${encodeURIComponent(token)}` : '';
|
||||||
return `/api/v1/calls/${callId}/recording${suffix}`;
|
return `/api/v1/calls/${callId}/recording${suffix}`;
|
||||||
@@ -137,11 +134,35 @@ export async function setDeviceDnd(deviceId: string, enabled: boolean): Promise<
|
|||||||
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
|
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
// Personal Access Tokens (owner-only) — for MCP/CLI clients.
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
|
||||||
|
export async function fetchTokens(): Promise<AccessToken[]> {
|
||||||
|
return get<AccessToken[]>('/api/v1/tokens');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createToken(name: string): Promise<AccessToken> {
|
||||||
|
const res = await request('/api/v1/tokens', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
|
||||||
|
return res.json() as Promise<AccessToken>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function revokeToken(tokenId: string): Promise<void> {
|
||||||
|
const res = await request(`/api/v1/tokens/${tokenId}`, { method: 'DELETE' });
|
||||||
|
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
export function connectEventStream(
|
export function connectEventStream(
|
||||||
onEvent: (e: GatewayEvent) => void,
|
onEvent: (e: GatewayEvent) => void,
|
||||||
onClose: () => void,
|
onClose: () => void,
|
||||||
): () => void {
|
): () => void {
|
||||||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
// Browsers can't set headers on WS connects — the token rides as ?token=.
|
||||||
const token = getToken();
|
const token = getToken();
|
||||||
const suffix = token ? `?token=${encodeURIComponent(token)}` : '';
|
const suffix = token ? `?token=${encodeURIComponent(token)}` : '';
|
||||||
const ws = new WebSocket(`${proto}//${location.host}/ws/events${suffix}`);
|
const ws = new WebSocket(`${proto}//${location.host}/ws/events${suffix}`);
|
||||||
|
|||||||
146
dashboard/src/lib/auth.svelte.ts
Normal file
146
dashboard/src/lib/auth.svelte.ts
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import type { User } from './types';
|
||||||
|
|
||||||
|
const TOKEN_KEY = 'hold-slayer-token';
|
||||||
|
|
||||||
|
// 'denied' = authenticated with Casdoor but not the owner of this gateway.
|
||||||
|
export type AuthStatus = 'loading' | 'authed' | 'unauthenticated' | 'denied';
|
||||||
|
|
||||||
|
export function getToken(): string {
|
||||||
|
return localStorage.getItem(TOKEN_KEY) || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function setToken(token: string) {
|
||||||
|
localStorage.setItem(TOKEN_KEY, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearToken() {
|
||||||
|
localStorage.removeItem(TOKEN_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
class AuthStore {
|
||||||
|
status = $state<AuthStatus>('loading');
|
||||||
|
user = $state<User | null>(null);
|
||||||
|
|
||||||
|
private refreshTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
get isOwner(): boolean {
|
||||||
|
return this.user?.is_owner ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Capture a `#token=...` fragment left by the Casdoor callback, persist it,
|
||||||
|
* and scrub it from the URL. Runs before the first /auth/me call.
|
||||||
|
*/
|
||||||
|
captureFragmentToken() {
|
||||||
|
const hash = window.location.hash;
|
||||||
|
if (hash.startsWith('#token=')) {
|
||||||
|
const token = hash.slice(7);
|
||||||
|
if (token) setToken(token);
|
||||||
|
history.replaceState(null, '', window.location.pathname + window.location.search);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Determine auth state on boot. */
|
||||||
|
async init(): Promise<void> {
|
||||||
|
const token = getToken();
|
||||||
|
if (!token) {
|
||||||
|
// No token: maybe SSO is disabled (dev mode) — /auth/me succeeds tokenless.
|
||||||
|
try {
|
||||||
|
const res = await fetch('/auth/me');
|
||||||
|
if (res.ok) {
|
||||||
|
this.applyUser(await res.json());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* fall through to unauthenticated */
|
||||||
|
}
|
||||||
|
this.status = 'unauthenticated';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/auth/me', {
|
||||||
|
headers: { Authorization: `Bearer ${token}` }
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
// Token invalid — try silent refresh once, then retry.
|
||||||
|
const refreshed = await this.trySilentRefresh();
|
||||||
|
if (refreshed) return this.init();
|
||||||
|
clearToken();
|
||||||
|
this.status = 'unauthenticated';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.applyUser(await res.json());
|
||||||
|
this.scheduleTokenRefresh(token);
|
||||||
|
} catch {
|
||||||
|
this.status = 'unauthenticated';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Set user + status from an /auth/me payload. Non-owners are denied. */
|
||||||
|
private applyUser(user: User) {
|
||||||
|
this.user = user;
|
||||||
|
this.status = user.is_owner ? 'authed' : 'denied';
|
||||||
|
}
|
||||||
|
|
||||||
|
setUnauthenticated() {
|
||||||
|
clearToken();
|
||||||
|
this.user = null;
|
||||||
|
this.status = 'unauthenticated';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Silent token refresh via a hidden iframe + postMessage. */
|
||||||
|
trySilentRefresh(): Promise<boolean> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const iframe = document.createElement('iframe');
|
||||||
|
iframe.style.display = 'none';
|
||||||
|
iframe.src = '/auth/silent-refresh';
|
||||||
|
let resolved = false;
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
if (resolved) return;
|
||||||
|
resolved = true;
|
||||||
|
window.removeEventListener('message', onMessage);
|
||||||
|
iframe.remove();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onMessage = (event: MessageEvent) => {
|
||||||
|
if (!event.data || event.data.type !== 'hold-slayer-refresh') return;
|
||||||
|
cleanup();
|
||||||
|
if (event.data.token) {
|
||||||
|
setToken(event.data.token);
|
||||||
|
this.scheduleTokenRefresh(event.data.token);
|
||||||
|
resolve(true);
|
||||||
|
} else {
|
||||||
|
resolve(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('message', onMessage);
|
||||||
|
document.body.appendChild(iframe);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
cleanup();
|
||||||
|
resolve(false);
|
||||||
|
}, 10000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Proactively refresh 5 minutes before JWT expiry. PATs are skipped. */
|
||||||
|
scheduleTokenRefresh(token: string) {
|
||||||
|
if (this.refreshTimer) clearTimeout(this.refreshTimer);
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(atob(token.split('.')[1]));
|
||||||
|
const exp = payload.exp * 1000;
|
||||||
|
const refreshIn = Math.max(exp - Date.now() - 5 * 60 * 1000, 30 * 1000);
|
||||||
|
this.refreshTimer = setTimeout(async () => {
|
||||||
|
const ok = await this.trySilentRefresh();
|
||||||
|
if (!ok) this.setUnauthenticated();
|
||||||
|
}, refreshIn);
|
||||||
|
} catch {
|
||||||
|
// Not a JWT (e.g. a PAT) — no refresh needed.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const auth = new AuthStore();
|
||||||
17
dashboard/src/lib/components/DeniedScreen.svelte
Normal file
17
dashboard/src/lib/components/DeniedScreen.svelte
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { auth } from '$lib/auth.svelte';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="bg-base-100 fixed inset-0 z-[9999] flex items-center justify-center p-4">
|
||||||
|
<div class="card bg-base-200 w-96 shadow-xl">
|
||||||
|
<div class="card-body items-center gap-4 text-center">
|
||||||
|
<h1 class="text-2xl font-bold">Not authorized</h1>
|
||||||
|
<p class="text-sm opacity-70">
|
||||||
|
Hold Slayer is a single-operator gateway. You're signed in as
|
||||||
|
<span class="font-medium">{auth.user?.display_name ?? auth.user?.name}</span>,
|
||||||
|
but this gateway is reserved for its owner.
|
||||||
|
</p>
|
||||||
|
<a href="/auth/logout" class="btn btn-outline w-full">Sign out</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
17
dashboard/src/lib/components/LoginScreen.svelte
Normal file
17
dashboard/src/lib/components/LoginScreen.svelte
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
// A full-screen sign-in gate. The anchor is a real navigation to the
|
||||||
|
// server-side /auth/login route (which 302s to Casdoor), not a fetch.
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="bg-base-100 fixed inset-0 z-[9999] flex items-center justify-center p-4">
|
||||||
|
<div class="card bg-base-200 w-80 shadow-xl">
|
||||||
|
<div class="card-body items-center gap-4 text-center">
|
||||||
|
<h1 class="flex items-center justify-center gap-2 text-3xl font-bold">
|
||||||
|
<span class="text-orange-500">🔥</span>
|
||||||
|
Hold Slayer
|
||||||
|
</h1>
|
||||||
|
<p class="text-sm opacity-60">Sign in to the gateway</p>
|
||||||
|
<a href="/auth/login" class="btn btn-primary w-full">Sign in with SSO</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
162
dashboard/src/lib/components/TokensModal.svelte
Normal file
162
dashboard/src/lib/components/TokensModal.svelte
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { AccessToken } from '$lib/types';
|
||||||
|
import { createToken, fetchTokens, revokeToken } from '$lib/api';
|
||||||
|
|
||||||
|
let { open = $bindable(false) }: { open?: boolean } = $props();
|
||||||
|
|
||||||
|
let tokens = $state<AccessToken[]>([]);
|
||||||
|
let loading = $state(false);
|
||||||
|
let error = $state<string | null>(null);
|
||||||
|
let newName = $state('');
|
||||||
|
let creating = $state(false);
|
||||||
|
// The plaintext of a just-created token — shown once, never re-fetchable.
|
||||||
|
let created = $state<AccessToken | null>(null);
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading = true;
|
||||||
|
error = null;
|
||||||
|
try {
|
||||||
|
tokens = await fetchTokens();
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof Error ? e.message : String(e);
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (open) {
|
||||||
|
created = null;
|
||||||
|
void load();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function create() {
|
||||||
|
if (!newName.trim()) return;
|
||||||
|
creating = true;
|
||||||
|
error = null;
|
||||||
|
try {
|
||||||
|
created = await createToken(newName.trim());
|
||||||
|
newName = '';
|
||||||
|
await load();
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof Error ? e.message : String(e);
|
||||||
|
} finally {
|
||||||
|
creating = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function revoke(id: string) {
|
||||||
|
error = null;
|
||||||
|
try {
|
||||||
|
await revokeToken(id);
|
||||||
|
await load();
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof Error ? e.message : String(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mcpConfig(plaintext: string): string {
|
||||||
|
const url = `${location.origin}/mcp`;
|
||||||
|
return JSON.stringify(
|
||||||
|
{
|
||||||
|
mcpServers: {
|
||||||
|
'hold-slayer': {
|
||||||
|
type: 'streamable-http',
|
||||||
|
url,
|
||||||
|
headers: { Authorization: `Bearer ${plaintext}` }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function copy(text: string) {
|
||||||
|
void navigator.clipboard.writeText(text);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if open}
|
||||||
|
<div class="modal modal-open">
|
||||||
|
<div class="modal-box max-w-2xl">
|
||||||
|
<h3 class="text-lg font-bold">API Tokens</h3>
|
||||||
|
<p class="py-1 text-sm opacity-60">
|
||||||
|
Personal access tokens for MCP/CLI clients (Claude Desktop, Cline). The
|
||||||
|
plaintext is shown once — store it now.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<div class="alert alert-error my-2 text-sm">{error}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if created?.token}
|
||||||
|
<div class="alert alert-success my-3 flex-col items-start gap-2">
|
||||||
|
<span class="font-medium">Token created — copy it now, it won't be shown again.</span>
|
||||||
|
<code class="bg-base-300 w-full break-all rounded p-2 text-xs">{created.token}</code>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button class="btn btn-xs" onclick={() => copy(created!.token!)}>Copy token</button>
|
||||||
|
<button class="btn btn-xs" onclick={() => copy(mcpConfig(created!.token!))}
|
||||||
|
>Copy MCP config</button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="my-3 flex gap-2">
|
||||||
|
<input
|
||||||
|
class="input input-bordered flex-1"
|
||||||
|
placeholder="Token name (e.g. Claude Desktop)"
|
||||||
|
bind:value={newName}
|
||||||
|
onkeydown={(e) => e.key === 'Enter' && create()}
|
||||||
|
/>
|
||||||
|
<button class="btn btn-primary" disabled={creating || !newName.trim()} onclick={create}>
|
||||||
|
{creating ? 'Creating…' : 'Create'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<div class="py-4 text-center opacity-60">Loading…</div>
|
||||||
|
{:else if tokens.length === 0}
|
||||||
|
<div class="py-4 text-center opacity-60">No tokens yet.</div>
|
||||||
|
{:else}
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="table table-sm">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Prefix</th>
|
||||||
|
<th>Last used</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each tokens as t (t.id)}
|
||||||
|
<tr class:opacity-50={t.revoked_at}>
|
||||||
|
<td>{t.name}</td>
|
||||||
|
<td><code class="text-xs">{t.token_prefix}…</code></td>
|
||||||
|
<td class="text-xs">{t.last_used_at?.slice(0, 10) ?? '—'}</td>
|
||||||
|
<td class="text-right">
|
||||||
|
{#if t.revoked_at}
|
||||||
|
<span class="badge badge-ghost badge-sm">revoked</span>
|
||||||
|
{:else}
|
||||||
|
<button class="btn btn-ghost btn-xs text-error" onclick={() => revoke(t.id)}>
|
||||||
|
Revoke
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="modal-action">
|
||||||
|
<button class="btn" onclick={() => (open = false)}>Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="modal-backdrop" onclick={() => (open = false)} aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@@ -1,3 +1,23 @@
|
|||||||
|
export interface User {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
display_name: string | null;
|
||||||
|
email: string | null;
|
||||||
|
is_owner: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AccessToken {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
token_prefix: string;
|
||||||
|
created_at: string | null;
|
||||||
|
last_used_at: string | null;
|
||||||
|
expires_at: string | null;
|
||||||
|
revoked_at: string | null;
|
||||||
|
// Present only in the create response — the plaintext, shown once.
|
||||||
|
token?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface GatewayStatus {
|
export interface GatewayStatus {
|
||||||
name: string;
|
name: string;
|
||||||
version: string;
|
version: string;
|
||||||
|
|||||||
@@ -2,9 +2,15 @@
|
|||||||
import '../app.css';
|
import '../app.css';
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
|
import { auth } from '$lib/auth.svelte';
|
||||||
|
import LoginScreen from '$lib/components/LoginScreen.svelte';
|
||||||
|
import DeniedScreen from '$lib/components/DeniedScreen.svelte';
|
||||||
|
import TokensModal from '$lib/components/TokensModal.svelte';
|
||||||
|
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
|
|
||||||
|
let tokensOpen = $state(false);
|
||||||
|
|
||||||
type ThemeOverride = 'dark' | 'light' | null;
|
type ThemeOverride = 'dark' | 'light' | null;
|
||||||
let override = $state<ThemeOverride>(null);
|
let override = $state<ThemeOverride>(null);
|
||||||
let systemDark = $state(true);
|
let systemDark = $state(true);
|
||||||
@@ -12,7 +18,10 @@
|
|||||||
let isDark = $derived(override !== null ? override === 'dark' : systemDark);
|
let isDark = $derived(override !== null ? override === 'dark' : systemDark);
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
|
// Keep both theming systems in sync: Tailwind `dark:` variant (.dark class)
|
||||||
|
// for the existing pages, and DaisyUI `data-theme` for the SSO components.
|
||||||
document.documentElement.classList.toggle('dark', isDark);
|
document.documentElement.classList.toggle('dark', isDark);
|
||||||
|
document.documentElement.setAttribute('data-theme', isDark ? 'dark' : 'light');
|
||||||
});
|
});
|
||||||
|
|
||||||
function toggleTheme() {
|
function toggleTheme() {
|
||||||
@@ -27,6 +36,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
|
void auth.init();
|
||||||
|
|
||||||
const mq = window.matchMedia('(prefers-color-scheme: dark)');
|
const mq = window.matchMedia('(prefers-color-scheme: dark)');
|
||||||
systemDark = mq.matches;
|
systemDark = mq.matches;
|
||||||
|
|
||||||
@@ -49,7 +60,16 @@
|
|||||||
];
|
];
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="min-h-screen bg-slate-50 dark:bg-gray-950 text-gray-900 dark:text-gray-100">
|
{#if auth.status === 'loading'}
|
||||||
|
<div class="fixed inset-0 flex items-center justify-center bg-slate-50 dark:bg-gray-950">
|
||||||
|
<span class="loading loading-spinner loading-lg text-orange-500"></span>
|
||||||
|
</div>
|
||||||
|
{:else if auth.status === 'unauthenticated'}
|
||||||
|
<LoginScreen />
|
||||||
|
{:else if auth.status === 'denied'}
|
||||||
|
<DeniedScreen />
|
||||||
|
{:else}
|
||||||
|
<div class="min-h-screen bg-slate-50 dark:bg-gray-950 text-gray-900 dark:text-gray-100">
|
||||||
<header
|
<header
|
||||||
class="border-b border-gray-200 dark:border-gray-800 bg-white/80 dark:bg-gray-900/80 backdrop-blur sticky top-0 z-10"
|
class="border-b border-gray-200 dark:border-gray-800 bg-white/80 dark:bg-gray-900/80 backdrop-blur sticky top-0 z-10"
|
||||||
>
|
>
|
||||||
@@ -72,17 +92,31 @@
|
|||||||
</a>
|
</a>
|
||||||
{/each}
|
{/each}
|
||||||
</nav>
|
</nav>
|
||||||
|
<div class="ml-auto flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
onclick={toggleTheme}
|
onclick={toggleTheme}
|
||||||
class="ml-auto text-xs px-2.5 py-1 rounded-full bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 border border-gray-200 dark:border-gray-700 hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
|
class="text-xs px-2.5 py-1 rounded-full bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 border border-gray-200 dark:border-gray-700 hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
|
||||||
title={isDark ? 'Switch to light mode' : 'Switch to dark mode'}
|
title={isDark ? 'Switch to light mode' : 'Switch to dark mode'}
|
||||||
>
|
>
|
||||||
{isDark ? 'Light' : 'Dark'}
|
{isDark ? 'Light' : 'Dark'}
|
||||||
</button>
|
</button>
|
||||||
|
<div class="dropdown dropdown-end">
|
||||||
|
<button tabindex="0" class="btn btn-ghost btn-sm">
|
||||||
|
{auth.user?.display_name ?? auth.user?.name ?? 'Owner'}
|
||||||
|
</button>
|
||||||
|
<ul class="dropdown-content menu bg-base-200 rounded-box z-20 w-48 p-2 shadow">
|
||||||
|
<li><button onclick={() => (tokensOpen = true)}>API Tokens</button></li>
|
||||||
|
<li><a href="/auth/logout">Sign out</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main class="mx-auto max-w-7xl px-4 py-6">
|
<main class="mx-auto max-w-7xl px-4 py-6">
|
||||||
{@render children()}
|
{@render children()}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<TokensModal bind:open={tokensOpen} />
|
||||||
|
{/if}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from sqlalchemy import (
|
|||||||
Column,
|
Column,
|
||||||
DateTime,
|
DateTime,
|
||||||
Float,
|
Float,
|
||||||
|
ForeignKey,
|
||||||
Integer,
|
Integer,
|
||||||
String,
|
String,
|
||||||
Text,
|
Text,
|
||||||
@@ -153,6 +154,48 @@ class RecordingRecord(Base):
|
|||||||
return f"<Recording {self.id} call={self.call_id} {self.path}>"
|
return f"<Recording {self.id} call={self.call_id} {self.path}>"
|
||||||
|
|
||||||
|
|
||||||
|
class User(Base):
|
||||||
|
"""An SSO-provisioned identity. The gateway is owner-only: the single
|
||||||
|
owner is the user whose `name` matches settings.owner_name; everyone else
|
||||||
|
is created on first login but reaches nothing (403 on every surface)."""
|
||||||
|
|
||||||
|
__tablename__ = "users"
|
||||||
|
|
||||||
|
id = Column(String, primary_key=True) # uuid4().hex, set in Python
|
||||||
|
name = Column(String, nullable=False) # Casdoor username — owner-match key
|
||||||
|
display_name = Column(String, nullable=True) # Casdoor display name (UI only)
|
||||||
|
email = Column(String, nullable=True, unique=True)
|
||||||
|
casdoor_sub = Column(String, nullable=True, unique=True) # OIDC subject claim
|
||||||
|
created_at = Column(DateTime, default=func.now())
|
||||||
|
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<User {self.id} {self.name}>"
|
||||||
|
|
||||||
|
|
||||||
|
class PersonalAccessToken(Base):
|
||||||
|
"""Long-lived bearer token for API/MCP clients (Claude Desktop, Cline)
|
||||||
|
that can't refresh a JWT. Plaintext is shown once at creation; only the
|
||||||
|
SHA-256 hash is persisted. Soft-revoked by setting revoked_at."""
|
||||||
|
|
||||||
|
__tablename__ = "personal_access_tokens"
|
||||||
|
|
||||||
|
id = Column(String, primary_key=True) # uuid4().hex
|
||||||
|
user_id = Column(
|
||||||
|
String, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
name = Column(String, nullable=False)
|
||||||
|
token_hash = Column(String, nullable=False, unique=True, index=True)
|
||||||
|
token_prefix = Column(String, nullable=False) # for display, not a secret
|
||||||
|
created_at = Column(DateTime, default=func.now())
|
||||||
|
last_used_at = Column(DateTime, nullable=True)
|
||||||
|
expires_at = Column(DateTime, nullable=True)
|
||||||
|
revoked_at = Column(DateTime, nullable=True)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<PersonalAccessToken {self.id} user={self.user_id}>"
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# Engine & Session
|
# Engine & Session
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|||||||
55
db/migrations/versions/a1b2c3d4e5f6_users_and_pats.py
Normal file
55
db/migrations/versions/a1b2c3d4e5f6_users_and_pats.py
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
"""users and personal access tokens
|
||||||
|
|
||||||
|
Revision ID: a1b2c3d4e5f6
|
||||||
|
Revises: 5187577efc23
|
||||||
|
Create Date: 2026-07-22 00:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = 'a1b2c3d4e5f6'
|
||||||
|
down_revision: Union[str, None] = '5187577efc23'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table('users',
|
||||||
|
sa.Column('id', sa.String(), nullable=False),
|
||||||
|
sa.Column('name', sa.String(), nullable=False),
|
||||||
|
sa.Column('display_name', sa.String(), nullable=True),
|
||||||
|
sa.Column('email', sa.String(), nullable=True),
|
||||||
|
sa.Column('casdoor_sub', sa.String(), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=True),
|
||||||
|
sa.Column('updated_at', sa.DateTime(), nullable=True),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('email'),
|
||||||
|
sa.UniqueConstraint('casdoor_sub')
|
||||||
|
)
|
||||||
|
op.create_table('personal_access_tokens',
|
||||||
|
sa.Column('id', sa.String(), nullable=False),
|
||||||
|
sa.Column('user_id', sa.String(), nullable=False),
|
||||||
|
sa.Column('name', sa.String(), nullable=False),
|
||||||
|
sa.Column('token_hash', sa.String(), nullable=False),
|
||||||
|
sa.Column('token_prefix', sa.String(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=True),
|
||||||
|
sa.Column('last_used_at', sa.DateTime(), nullable=True),
|
||||||
|
sa.Column('expires_at', sa.DateTime(), nullable=True),
|
||||||
|
sa.Column('revoked_at', sa.DateTime(), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('token_hash')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_personal_access_tokens_token_hash'), 'personal_access_tokens', ['token_hash'], unique=True)
|
||||||
|
op.create_index(op.f('ix_personal_access_tokens_user_id'), 'personal_access_tokens', ['user_id'], unique=False)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index(op.f('ix_personal_access_tokens_user_id'), table_name='personal_access_tokens')
|
||||||
|
op.drop_index(op.f('ix_personal_access_tokens_token_hash'), table_name='personal_access_tokens')
|
||||||
|
op.drop_table('personal_access_tokens')
|
||||||
|
op.drop_table('users')
|
||||||
84
docker-compose.yaml
Normal file
84
docker-compose.yaml
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
# Local Hold Slayer stack: the single app image + its own PostgreSQL.
|
||||||
|
#
|
||||||
|
# Hold Slayer is ONE FastAPI process exposing REST/WS/MCP and serving its built
|
||||||
|
# SvelteKit dashboard at "/" — no separate web/nginx service (the Dockerfile's
|
||||||
|
# node stage builds the dashboard into the image).
|
||||||
|
#
|
||||||
|
# Auth is Casdoor SSO (owner-only). Because the published port binds the app to
|
||||||
|
# 0.0.0.0, dev-owner mode (CASDOOR_ENABLED=false) is intentionally REFUSED at
|
||||||
|
# startup here — that mode is loopback-only. So the stack expects the CASDOOR_*
|
||||||
|
# + OWNER_NAME vars set (see .env.compose.example). MCP/CLI clients then use an
|
||||||
|
# owner-minted PAT.
|
||||||
|
#
|
||||||
|
# cp .env.compose.example .env
|
||||||
|
# # fill in CASDOOR_* + OWNER_NAME (+ HS_DB_PASSWORD)
|
||||||
|
# docker compose up --build
|
||||||
|
|
||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: postgres:17
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: ${HS_DB_USER:-holdslayer}
|
||||||
|
POSTGRES_PASSWORD: ${HS_DB_PASSWORD:?set HS_DB_PASSWORD in .env}
|
||||||
|
POSTGRES_DB: ${HS_DB_NAME:-holdslayer}
|
||||||
|
volumes:
|
||||||
|
- hs_pgdata:/var/lib/postgresql/data
|
||||||
|
# json-file + Alloy docker-socket discovery is the estate pattern; no
|
||||||
|
# syslog driver / 514xx listener (which would block container creation when
|
||||||
|
# the listener is absent). See ouranos Rosalind/Virgo logging convention.
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ${HS_DB_USER:-holdslayer} -d ${HS_DB_NAME:-holdslayer}"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
app:
|
||||||
|
build: .
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
# Migrations run in the app's own init_db() on boot; it just needs to
|
||||||
|
# reach the db service. asyncpg URL points at the compose service name.
|
||||||
|
DATABASE_URL: postgresql+asyncpg://${HS_DB_USER:-holdslayer}:${HS_DB_PASSWORD}@db:5432/${HS_DB_NAME:-holdslayer}
|
||||||
|
HOST: "0.0.0.0"
|
||||||
|
PORT: "21081"
|
||||||
|
# Mock SIP: this stack is a dev/local deploy, not a real trunk. /health
|
||||||
|
# honestly reports engine=mock as "degraded". Flip to false + fill the
|
||||||
|
# SIP_TRUNK_* vars for a real-trunk deploy.
|
||||||
|
USE_MOCK_SIP: ${USE_MOCK_SIP:-true}
|
||||||
|
# --- Auth: Casdoor SSO (owner-only) ---
|
||||||
|
CASDOOR_ENABLED: ${CASDOOR_ENABLED:-true}
|
||||||
|
CASDOOR_ENDPOINT: ${CASDOOR_ENDPOINT:-https://id.ouranos.helu.ca}
|
||||||
|
CASDOOR_CLIENT_ID: ${CASDOOR_CLIENT_ID}
|
||||||
|
CASDOOR_CLIENT_SECRET: ${CASDOOR_CLIENT_SECRET}
|
||||||
|
CASDOOR_ORG_NAME: ${CASDOOR_ORG_NAME:-heluca}
|
||||||
|
CASDOOR_APP_NAME: ${CASDOOR_APP_NAME:-hold-slayer}
|
||||||
|
OWNER_NAME: ${OWNER_NAME:?set OWNER_NAME (the owner's Casdoor username)}
|
||||||
|
PUBLIC_BASE_URL: ${PUBLIC_BASE_URL:-}
|
||||||
|
ports:
|
||||||
|
- "${HS_APP_PORT:-21081}:21081"
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
|
healthcheck:
|
||||||
|
# /health returns 200 even when "degraded" (mock engine / unregistered
|
||||||
|
# trunk) — so a 200 means the process is up and serving, which is the
|
||||||
|
# right liveness signal for a mock-SIP dev stack.
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:21081/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 40s
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
hs_pgdata:
|
||||||
@@ -4,6 +4,25 @@ All configuration is via environment variables, loaded through Pydantic Settings
|
|||||||
|
|
||||||
## Environment Variables
|
## Environment Variables
|
||||||
|
|
||||||
|
### Auth (Casdoor SSO + owner)
|
||||||
|
|
||||||
|
The gateway is **owner-only**: the browser signs in via Casdoor (JWT), MCP/CLI
|
||||||
|
clients use owner-minted PATs, and only `OWNER_NAME` may use any surface. With
|
||||||
|
`CASDOOR_ENABLED=false` the gateway runs in dev-owner mode — permitted **only** on
|
||||||
|
a loopback `HOST`. Startup refuses SSO-enabled-with-missing-config and
|
||||||
|
SSO-disabled-off-loopback.
|
||||||
|
|
||||||
|
| Variable | Description | Default | Required |
|
||||||
|
|----------|-------------|---------|----------|
|
||||||
|
| `CASDOOR_ENABLED` | Enable Casdoor SSO | `false` | No |
|
||||||
|
| `CASDOOR_ENDPOINT` | Casdoor base URL | `https://id.ouranos.helu.ca` | If SSO on |
|
||||||
|
| `CASDOOR_CLIENT_ID` | Casdoor application client ID | — | If SSO on |
|
||||||
|
| `CASDOOR_CLIENT_SECRET` | Casdoor application client secret | — | If SSO on |
|
||||||
|
| `CASDOOR_ORG_NAME` | Casdoor organization | `heluca` | No |
|
||||||
|
| `CASDOOR_APP_NAME` | Casdoor application name | — | No |
|
||||||
|
| `OWNER_NAME` | Casdoor username of the single operator | — | If SSO on |
|
||||||
|
| `PUBLIC_BASE_URL` | Public base URL for OAuth discovery (else derived) | — | No |
|
||||||
|
|
||||||
### SIP Trunk
|
### SIP Trunk
|
||||||
|
|
||||||
| Variable | Description | Default | Required |
|
| Variable | Description | Default | Required |
|
||||||
|
|||||||
@@ -3,8 +3,11 @@
|
|||||||
The MCP (Model Context Protocol) server lets any MCP-compatible AI assistant
|
The MCP (Model Context Protocol) server lets any MCP-compatible AI assistant
|
||||||
control the Hold Slayer gateway. Built with [FastMCP](https://github.com/jlowin/fastmcp),
|
control the Hold Slayer gateway. Built with [FastMCP](https://github.com/jlowin/fastmcp),
|
||||||
it is mounted on the FastAPI app at **`/mcp/`** (trailing slash) over
|
it is mounted on the FastAPI app at **`/mcp/`** (trailing slash) over
|
||||||
**streamable HTTP** and authenticates with the same static bearer token as the
|
**streamable HTTP** and authenticates with an owner-minted Personal Access Token
|
||||||
REST API and WebSocket.
|
(`hs_pat_…`) — the same owner-only auth as the REST API and WebSocket. Auth is
|
||||||
|
enforced by an ASGI guard (`_owner_only_mcp` in `main.py`) that resolves the
|
||||||
|
bearer to the owner; a Casdoor JWT also works, but MCP clients can't refresh one,
|
||||||
|
so a PAT is the intended credential.
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
@@ -158,7 +161,7 @@ Claude Code:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
claude mcp add hold-slayer --transport http http://localhost:8000/mcp/ \
|
claude mcp add hold-slayer --transport http http://localhost:8000/mcp/ \
|
||||||
--header "Authorization: Bearer $API_TOKEN"
|
--header "Authorization: Bearer hs_pat_..."
|
||||||
```
|
```
|
||||||
|
|
||||||
Generic MCP client configuration:
|
Generic MCP client configuration:
|
||||||
@@ -168,7 +171,7 @@ Generic MCP client configuration:
|
|||||||
"mcpServers": {
|
"mcpServers": {
|
||||||
"hold-slayer": {
|
"hold-slayer": {
|
||||||
"url": "http://localhost:8000/mcp/",
|
"url": "http://localhost:8000/mcp/",
|
||||||
"headers": {"Authorization": "Bearer <API_TOKEN>"}
|
"headers": {"Authorization": "Bearer hs_pat_..."}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
275
main.py
275
main.py
@@ -12,17 +12,21 @@ Usage:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import secrets
|
||||||
import sys
|
import sys
|
||||||
|
import time
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
from fastapi import Depends, FastAPI
|
from fastapi import Depends, FastAPI, Request
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
from api import call_flows, call_history, calls, devices, routing, websocket
|
from api import auth as auth_router
|
||||||
from api.deps import require_token
|
from api import call_flows, call_history, calls, devices, routing, tokens, websocket
|
||||||
|
from auth import get_current_owner, init_jwks_client, is_owner, resolve_from_header_or_query
|
||||||
from config import Settings, get_settings
|
from config import Settings, get_settings
|
||||||
from core.gateway import AIPSTNGateway, build_sip_engine
|
from core.gateway import AIPSTNGateway, build_sip_engine
|
||||||
from db.database import close_db, init_db
|
from db.database import close_db, init_db, session_scope
|
||||||
from mcp_server.server import create_mcp_server
|
from mcp_server.server import create_mcp_server
|
||||||
from models.call import CallMode
|
from models.call import CallMode
|
||||||
from services.audio_classifier import AudioClassifier
|
from services.audio_classifier import AudioClassifier
|
||||||
@@ -106,16 +110,38 @@ def _check_startup_config(settings: Settings) -> None:
|
|||||||
)
|
)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
token = settings.api_token.get_secret_value()
|
loopback = settings.host in ("127.0.0.1", "localhost", "::1")
|
||||||
if not token and settings.host not in ("127.0.0.1", "localhost", "::1"):
|
if settings.casdoor.enabled:
|
||||||
|
c = settings.casdoor
|
||||||
|
missing = [
|
||||||
|
name
|
||||||
|
for name, val in (
|
||||||
|
("CASDOOR_ENDPOINT", c.endpoint),
|
||||||
|
("CASDOOR_CLIENT_ID", c.client_id),
|
||||||
|
("CASDOOR_CLIENT_SECRET", c.client_secret.get_secret_value()),
|
||||||
|
("OWNER_NAME", settings.owner_name),
|
||||||
|
)
|
||||||
|
if not val
|
||||||
|
]
|
||||||
|
if missing:
|
||||||
logger.critical(
|
logger.critical(
|
||||||
"\n"
|
"\n"
|
||||||
"❌ API_TOKEN is not set but HOST binds beyond loopback "
|
"❌ CASDOOR_ENABLED=true but required settings are missing:\n"
|
||||||
|
f" {', '.join(missing)}\n"
|
||||||
|
" Set them in .env (Casdoor app credentials + the owner's "
|
||||||
|
"Casdoor username), or set CASDOOR_ENABLED=false with HOST=127.0.0.1 "
|
||||||
|
"for tokenless local development."
|
||||||
|
)
|
||||||
|
sys.exit(1)
|
||||||
|
elif not loopback:
|
||||||
|
logger.critical(
|
||||||
|
"\n"
|
||||||
|
"❌ CASDOOR_ENABLED=false but HOST binds beyond loopback "
|
||||||
f"({settings.host}).\n"
|
f"({settings.host}).\n"
|
||||||
" Every surface (REST, WebSocket, MCP make_call) would be open "
|
" Every surface (REST, WebSocket, MCP make_call) would resolve "
|
||||||
"to the network.\n"
|
"to the dev owner — open to the network.\n"
|
||||||
" Set API_TOKEN in .env (e.g. `openssl rand -hex 32`), or set "
|
" Set CASDOOR_ENABLED=true (with the Casdoor + OWNER_NAME settings), "
|
||||||
"HOST=127.0.0.1 for tokenless local development."
|
"or set HOST=127.0.0.1 for tokenless local development."
|
||||||
)
|
)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
@@ -126,6 +152,10 @@ async def lifespan(app: FastAPI):
|
|||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
_check_startup_config(settings)
|
_check_startup_config(settings)
|
||||||
|
|
||||||
|
# Prefetch Casdoor's JWKS so the first authenticated request doesn't pay
|
||||||
|
# the network round-trip (no-op when SSO is disabled).
|
||||||
|
init_jwks_client()
|
||||||
|
|
||||||
# The MCP session manager lives in the mounted sub-app's lifespan;
|
# The MCP session manager lives in the mounted sub-app's lifespan;
|
||||||
# without entering it, every /mcp request 500s.
|
# without entering it, every /mcp request 500s.
|
||||||
async with mcp_http_app.lifespan(app):
|
async with mcp_http_app.lifespan(app):
|
||||||
@@ -214,7 +244,11 @@ async def lifespan(app: FastAPI):
|
|||||||
display_port = int(sys.argv[i + 1])
|
display_port = int(sys.argv[i + 1])
|
||||||
except ValueError:
|
except ValueError:
|
||||||
pass
|
pass
|
||||||
auth_state = "bearer token required" if settings.api_token.get_secret_value() else "auth disabled (loopback)"
|
auth_state = (
|
||||||
|
f"Casdoor SSO (owner: {settings.owner_name or 'UNSET'})"
|
||||||
|
if settings.casdoor.enabled
|
||||||
|
else "dev-owner (loopback, no auth)"
|
||||||
|
)
|
||||||
logger.info(f" API: http://{display_host}:{display_port} [{auth_state}]")
|
logger.info(f" API: http://{display_host}:{display_port} [{auth_state}]")
|
||||||
logger.info(f" API Docs: http://{display_host}:{display_port}/docs")
|
logger.info(f" API Docs: http://{display_host}:{display_port}/docs")
|
||||||
logger.info(f" WebSocket: ws://{display_host}:{display_port}/ws/events")
|
logger.info(f" WebSocket: ws://{display_host}:{display_port}/ws/events")
|
||||||
@@ -236,12 +270,92 @@ def _get_gateway_instance() -> AIPSTNGateway | None:
|
|||||||
return getattr(app.state, "gateway", None)
|
return getattr(app.state, "gateway", None)
|
||||||
|
|
||||||
|
|
||||||
mcp = create_mcp_server(
|
mcp = create_mcp_server(_get_gateway_instance)
|
||||||
_get_gateway_instance,
|
|
||||||
api_token=get_settings().api_token.get_secret_value(),
|
|
||||||
)
|
|
||||||
mcp_http_app = mcp.http_app(path="/")
|
mcp_http_app = mcp.http_app(path="/")
|
||||||
|
|
||||||
|
|
||||||
|
def _public_base_url(scope_or_request) -> str:
|
||||||
|
"""Resolve this service's public base URL (scheme + host), no trailing slash.
|
||||||
|
|
||||||
|
Precedence: explicit PUBLIC_BASE_URL override → X-Forwarded-Proto/Host
|
||||||
|
(nginx/HAProxy) → Host header → localhost. Accepts either a FastAPI
|
||||||
|
``Request`` or a raw ASGI ``scope`` so the ASGI MCP guard and the FastAPI
|
||||||
|
discovery endpoints share one implementation.
|
||||||
|
"""
|
||||||
|
settings = get_settings()
|
||||||
|
if settings.public_base_url:
|
||||||
|
return settings.public_base_url.rstrip("/")
|
||||||
|
|
||||||
|
if hasattr(scope_or_request, "headers"):
|
||||||
|
headers = {k.lower(): v for k, v in scope_or_request.headers.items()}
|
||||||
|
default_scheme = getattr(scope_or_request.url, "scheme", None) or "http"
|
||||||
|
else:
|
||||||
|
headers = {
|
||||||
|
k.decode("latin-1").lower(): v.decode("latin-1")
|
||||||
|
for k, v in scope_or_request.get("headers", [])
|
||||||
|
}
|
||||||
|
default_scheme = scope_or_request.get("scheme", "http")
|
||||||
|
|
||||||
|
proto = (headers.get("x-forwarded-proto") or default_scheme).split(",", 1)[0].strip()
|
||||||
|
host = (headers.get("x-forwarded-host") or headers.get("host") or "localhost")
|
||||||
|
host = host.split(",", 1)[0].strip()
|
||||||
|
return f"{proto}://{host}"
|
||||||
|
|
||||||
|
|
||||||
|
def _owner_only_mcp(inner_app):
|
||||||
|
"""Wrap the mounted MCP ASGI app to require an owner bearer token.
|
||||||
|
|
||||||
|
MCP tools reach state via the FastMCP lifespan context, not FastAPI's
|
||||||
|
dependency system, so ``Depends`` can't gate ``/mcp``. Instead we read the
|
||||||
|
ASGI scope's ``Authorization`` header, resolve it (Casdoor JWT or PAT)
|
||||||
|
against a fresh DB session, and short-circuit non-owner requests with
|
||||||
|
401/403. In dev mode this resolves to the dev owner, so local development
|
||||||
|
keeps working without a token.
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def _send_status(send, scope, status: int, body: bytes) -> None:
|
||||||
|
base = _public_base_url(scope)
|
||||||
|
resource_metadata_url = f"{base}/.well-known/oauth-protected-resource/mcp"
|
||||||
|
await send(
|
||||||
|
{
|
||||||
|
"type": "http.response.start",
|
||||||
|
"status": status,
|
||||||
|
"headers": [
|
||||||
|
(b"content-type", b"application/json"),
|
||||||
|
(
|
||||||
|
b"www-authenticate",
|
||||||
|
f'Bearer realm="hold-slayer-mcp", '
|
||||||
|
f'resource_metadata="{resource_metadata_url}"'.encode(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
await send({"type": "http.response.body", "body": body})
|
||||||
|
|
||||||
|
async def app(scope, receive, send):
|
||||||
|
if scope["type"] != "http":
|
||||||
|
await inner_app(scope, receive, send)
|
||||||
|
return
|
||||||
|
|
||||||
|
authorization = None
|
||||||
|
for name, value in scope.get("headers", []):
|
||||||
|
if name == b"authorization":
|
||||||
|
authorization = value.decode("latin-1")
|
||||||
|
break
|
||||||
|
|
||||||
|
async with session_scope() as session:
|
||||||
|
user = await resolve_from_header_or_query(session, authorization, None)
|
||||||
|
if user is None:
|
||||||
|
await _send_status(send, scope, 401, b'{"detail":"Not authenticated"}')
|
||||||
|
return
|
||||||
|
if not is_owner(user):
|
||||||
|
await _send_status(send, scope, 403, b'{"detail":"Owner access required"}')
|
||||||
|
return
|
||||||
|
|
||||||
|
await inner_app(scope, receive, send)
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="Hold Slayer Gateway",
|
title="Hold Slayer Gateway",
|
||||||
description=(
|
description=(
|
||||||
@@ -258,9 +372,13 @@ app = FastAPI(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# === API Routes ===
|
# === API Routes ===
|
||||||
|
# Every protected surface is gated to the owner (Casdoor JWT or PAT). The
|
||||||
|
# unauthenticated OIDC endpoints live on the /auth router (login/callback/…).
|
||||||
# call_history must register before calls: both live under /api/v1/calls and
|
# call_history must register before calls: both live under /api/v1/calls and
|
||||||
# calls' GET /{call_id} would otherwise capture the literal path "history".
|
# calls' GET /{call_id} would otherwise capture the literal path "history".
|
||||||
_auth = [Depends(require_token)]
|
_auth = [Depends(get_current_owner)]
|
||||||
|
app.include_router(auth_router.router)
|
||||||
|
app.include_router(tokens.router, dependencies=_auth)
|
||||||
app.include_router(
|
app.include_router(
|
||||||
call_history.router, prefix="/api/v1/calls", tags=["Call History"], dependencies=_auth
|
call_history.router, prefix="/api/v1/calls", tags=["Call History"], dependencies=_auth
|
||||||
)
|
)
|
||||||
@@ -270,11 +388,128 @@ app.include_router(
|
|||||||
)
|
)
|
||||||
app.include_router(devices.router, prefix="/api/v1/devices", tags=["Devices"], dependencies=_auth)
|
app.include_router(devices.router, prefix="/api/v1/devices", tags=["Devices"], dependencies=_auth)
|
||||||
app.include_router(routing.router, prefix="/api/v1/routing", tags=["Routing"], dependencies=_auth)
|
app.include_router(routing.router, prefix="/api/v1/routing", tags=["Routing"], dependencies=_auth)
|
||||||
# WebSocket endpoints check the token themselves (query param or header)
|
# WebSocket endpoints check the owner themselves (query param or header)
|
||||||
app.include_router(websocket.router, prefix="/ws", tags=["WebSocket"])
|
app.include_router(websocket.router, prefix="/ws", tags=["WebSocket"])
|
||||||
|
|
||||||
# === MCP (streamable HTTP; clients connect to /mcp/ with the bearer token) ===
|
# === MCP (streamable HTTP; clients connect to /mcp/ with a PAT or JWT) ===
|
||||||
app.mount("/mcp", mcp_http_app)
|
# The ASGI guard resolves the bearer to the owner before the inner app runs.
|
||||||
|
app.mount("/mcp", _owner_only_mcp(mcp_http_app))
|
||||||
|
|
||||||
|
# In-memory store of dynamically registered OAuth clients (RFC 7591). MCP
|
||||||
|
# clients re-register each session; the real gate is the bearer token.
|
||||||
|
_registered_clients: dict[str, dict] = {}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/.well-known/oauth-protected-resource", include_in_schema=False)
|
||||||
|
@app.get("/.well-known/oauth-protected-resource/mcp", include_in_schema=False)
|
||||||
|
async def oauth_protected_resource_metadata(request: Request):
|
||||||
|
"""RFC 9728 Protected Resource Metadata — points MCP clients at the AS.
|
||||||
|
|
||||||
|
``resource`` advertises ``{base}/mcp`` (not the bare origin) because recent
|
||||||
|
``mcp-remote`` versions verify it matches the URL they connected to.
|
||||||
|
"""
|
||||||
|
base = _public_base_url(request)
|
||||||
|
return JSONResponse(
|
||||||
|
{
|
||||||
|
"resource": f"{base}/mcp",
|
||||||
|
"authorization_servers": [base],
|
||||||
|
"bearer_methods_supported": ["header"],
|
||||||
|
"resource_documentation": f"{base}/docs",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/.well-known/oauth-authorization-server", include_in_schema=False)
|
||||||
|
async def oauth_authorization_server_metadata(request: Request):
|
||||||
|
"""RFC 8414 Authorization Server Metadata.
|
||||||
|
|
||||||
|
When Casdoor SSO is on, the real authorization server is Casdoor — advertise
|
||||||
|
its endpoints. In dev mode there's no OAuth server; clients supply a PAT
|
||||||
|
directly in their MCP configuration.
|
||||||
|
"""
|
||||||
|
base = _public_base_url(request)
|
||||||
|
settings = get_settings()
|
||||||
|
if settings.casdoor.enabled:
|
||||||
|
casdoor_base = settings.casdoor.endpoint.rstrip("/")
|
||||||
|
return JSONResponse(
|
||||||
|
{
|
||||||
|
"issuer": casdoor_base,
|
||||||
|
"authorization_endpoint": f"{casdoor_base}/login/oauth/authorize",
|
||||||
|
"token_endpoint": f"{casdoor_base}/api/login/oauth/access_token",
|
||||||
|
"jwks_uri": f"{casdoor_base}/.well-known/jwks",
|
||||||
|
"registration_endpoint": f"{base}/register",
|
||||||
|
"response_types_supported": ["code"],
|
||||||
|
"grant_types_supported": ["authorization_code"],
|
||||||
|
"token_endpoint_auth_methods_supported": ["client_secret_post"],
|
||||||
|
"scopes_supported": ["openid", "profile", "email"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return JSONResponse(
|
||||||
|
{
|
||||||
|
"issuer": base,
|
||||||
|
"authorization_endpoint": f"{base}/auth/login",
|
||||||
|
"token_endpoint": f"{base}/auth/callback",
|
||||||
|
"registration_endpoint": f"{base}/register",
|
||||||
|
"response_types_supported": ["code"],
|
||||||
|
"grant_types_supported": ["authorization_code"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/register", include_in_schema=False)
|
||||||
|
async def oauth_dynamic_registration(request: Request):
|
||||||
|
"""RFC 7591 Dynamic Client Registration — accept any well-formed request.
|
||||||
|
|
||||||
|
Registered clients are held in memory (ephemeral); the real security gate
|
||||||
|
is the bearer token (PAT or Casdoor JWT) on every /mcp request.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
body = await request.json()
|
||||||
|
except Exception:
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=400,
|
||||||
|
content={
|
||||||
|
"error": "invalid_client_metadata",
|
||||||
|
"error_description": "Request body must be valid JSON.",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
redirect_uris = body.get("redirect_uris")
|
||||||
|
if not redirect_uris or not isinstance(redirect_uris, list):
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=400,
|
||||||
|
content={
|
||||||
|
"error": "invalid_redirect_uri",
|
||||||
|
"error_description": "redirect_uris is required and must be a non-empty list.",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
client_id = secrets.token_hex(16)
|
||||||
|
now = int(time.time())
|
||||||
|
_registered_clients[client_id] = {
|
||||||
|
"client_id": client_id,
|
||||||
|
"client_id_issued_at": now,
|
||||||
|
"redirect_uris": redirect_uris,
|
||||||
|
"grant_types": body.get("grant_types", ["authorization_code"]),
|
||||||
|
"response_types": body.get("response_types", ["code"]),
|
||||||
|
"token_endpoint_auth_method": body.get("token_endpoint_auth_method", "none"),
|
||||||
|
"client_name": body.get("client_name"),
|
||||||
|
"scope": body.get("scope"),
|
||||||
|
}
|
||||||
|
logger.info("Registered OAuth client %s (name=%s)", client_id, body.get("client_name"))
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=201,
|
||||||
|
content={
|
||||||
|
"client_id": client_id,
|
||||||
|
"client_id_issued_at": now,
|
||||||
|
"redirect_uris": redirect_uris,
|
||||||
|
"grant_types": _registered_clients[client_id]["grant_types"],
|
||||||
|
"response_types": _registered_clients[client_id]["response_types"],
|
||||||
|
"token_endpoint_auth_method": _registered_clients[client_id][
|
||||||
|
"token_endpoint_auth_method"
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/v1/status", tags=["System"], dependencies=_auth)
|
@app.get("/api/v1/status", tags=["System"], dependencies=_auth)
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
def create_mcp_server(
|
def create_mcp_server(
|
||||||
get_gateway: Callable[[], Optional[AIPSTNGateway]],
|
get_gateway: Callable[[], Optional[AIPSTNGateway]],
|
||||||
api_token: str = "",
|
|
||||||
) -> FastMCP:
|
) -> FastMCP:
|
||||||
"""
|
"""
|
||||||
Create and configure the MCP server with all tools and resources.
|
Create and configure the MCP server with all tools and resources.
|
||||||
@@ -35,14 +34,13 @@ def create_mcp_server(
|
|||||||
The gateway is resolved lazily per request via `get_gateway` so the
|
The gateway is resolved lazily per request via `get_gateway` so the
|
||||||
server can be mounted at app construction, before the lifespan has
|
server can be mounted at app construction, before the lifespan has
|
||||||
started the gateway.
|
started the gateway.
|
||||||
|
|
||||||
|
Auth is **not** configured on the FastMCP instance: the mounted `/mcp`
|
||||||
|
ASGI app is gated by `_owner_only_mcp` in main.py, which resolves a
|
||||||
|
Casdoor JWT or PAT to the owner (one resolver shared with REST/WS) —
|
||||||
|
so PATs and JWTs both work here with a single code path.
|
||||||
"""
|
"""
|
||||||
auth = None
|
mcp = FastMCP("Hold Slayer Gateway", auth=None)
|
||||||
if api_token:
|
|
||||||
from fastmcp.server.auth.providers.jwt import StaticTokenVerifier
|
|
||||||
|
|
||||||
auth = StaticTokenVerifier(tokens={api_token: {"client_id": "hold-slayer"}})
|
|
||||||
|
|
||||||
mcp = FastMCP("Hold Slayer Gateway", auth=auth)
|
|
||||||
|
|
||||||
def require_gateway() -> AIPSTNGateway:
|
def require_gateway() -> AIPSTNGateway:
|
||||||
gateway = get_gateway()
|
gateway = get_gateway()
|
||||||
|
|||||||
@@ -32,9 +32,13 @@ dependencies = [
|
|||||||
# HTTP client (for Speaches STT)
|
# HTTP client (for Speaches STT)
|
||||||
"httpx>=0.28.0",
|
"httpx>=0.28.0",
|
||||||
|
|
||||||
# MCP server (3.x — http_app + StaticTokenVerifier)
|
# MCP server (3.x — http_app + ASGI owner guard)
|
||||||
"fastmcp>=3.0.0",
|
"fastmcp>=3.0.0",
|
||||||
|
|
||||||
|
# Auth: Casdoor SSO (OAuth code exchange) + RS256 JWT validation
|
||||||
|
"casdoor>=1.0",
|
||||||
|
"pyjwt[crypto]>=2.8",
|
||||||
|
|
||||||
# Utilities
|
# Utilities
|
||||||
"python-slugify>=8.0.0",
|
"python-slugify>=8.0.0",
|
||||||
]
|
]
|
||||||
@@ -47,6 +51,8 @@ dev = [
|
|||||||
"httpx>=0.28.0",
|
"httpx>=0.28.0",
|
||||||
"ruff>=0.8.0",
|
"ruff>=0.8.0",
|
||||||
"aiosqlite>=0.22.0",
|
"aiosqlite>=0.22.0",
|
||||||
|
# Mint RS256 JWTs in tests without a live Casdoor
|
||||||
|
"cryptography>=42.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
|
|||||||
@@ -1,24 +1,53 @@
|
|||||||
"""
|
"""
|
||||||
API surface tests — bearer-token enforcement and route registration order.
|
API surface tests — owner enforcement and route registration order.
|
||||||
|
|
||||||
The app is exercised without its lifespan: auth runs before any handler,
|
The app is exercised without its lifespan: auth runs before any handler,
|
||||||
so a 503 ("Gateway not initialized") proves the token was accepted.
|
so a 503 ("Gateway not initialized") proves the caller was accepted as
|
||||||
|
owner. Auth internals (JWT/PAT resolution) are covered in test_auth.py;
|
||||||
|
here we assert the routers are gated and the routes register in the right
|
||||||
|
order. In dev-owner mode (SSO disabled) a tokenless request is the owner.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
import pytest
|
import pytest
|
||||||
from pydantic import SecretStr
|
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||||
|
from sqlalchemy.pool import StaticPool
|
||||||
from starlette.routing import Match
|
from starlette.routing import Match
|
||||||
|
|
||||||
|
import db.database as dbmod
|
||||||
import main
|
import main
|
||||||
from config import get_settings
|
from config import get_settings
|
||||||
|
from db.database import Base
|
||||||
TOKEN = "test-token-for-suite"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def token_enabled(monkeypatch):
|
async def mem_db(monkeypatch):
|
||||||
monkeypatch.setattr(get_settings(), "api_token", SecretStr(TOKEN))
|
engine = create_async_engine(
|
||||||
|
"sqlite+aiosqlite:///:memory:",
|
||||||
|
poolclass=StaticPool,
|
||||||
|
connect_args={"check_same_thread": False},
|
||||||
|
)
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
factory = async_sessionmaker(engine, expire_on_commit=False)
|
||||||
|
monkeypatch.setattr(dbmod, "_engine", engine)
|
||||||
|
monkeypatch.setattr(dbmod, "_session_factory", factory)
|
||||||
|
yield factory
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def dev_owner(monkeypatch):
|
||||||
|
"""SSO disabled — every request resolves to the dev owner."""
|
||||||
|
monkeypatch.setattr(get_settings().casdoor, "enabled", False)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sso_enabled(monkeypatch):
|
||||||
|
"""SSO enabled with no credentials supplied → 401 on protected routes."""
|
||||||
|
monkeypatch.setattr(get_settings().casdoor, "enabled", True)
|
||||||
|
monkeypatch.setattr(get_settings().casdoor, "endpoint", "https://id.example.test")
|
||||||
|
monkeypatch.setattr(get_settings(), "owner_name", "owner@example.test")
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@@ -28,45 +57,34 @@ async def client():
|
|||||||
yield c
|
yield c
|
||||||
|
|
||||||
|
|
||||||
class TestBearerToken:
|
class TestOwnerGate:
|
||||||
async def test_missing_token_rejected(self, token_enabled, client):
|
async def test_dev_owner_reaches_handler(self, dev_owner, mem_db, client):
|
||||||
|
resp = await client.get("/api/v1/calls/active")
|
||||||
|
assert resp.status_code == 503 # dev-owner accepted; handler 503s (no lifespan)
|
||||||
|
|
||||||
|
async def test_sso_missing_credentials_rejected(self, sso_enabled, mem_db, client):
|
||||||
resp = await client.get("/api/v1/calls/active")
|
resp = await client.get("/api/v1/calls/active")
|
||||||
assert resp.status_code == 401
|
assert resp.status_code == 401
|
||||||
assert resp.headers["www-authenticate"] == "Bearer"
|
assert resp.headers["www-authenticate"] == "Bearer"
|
||||||
|
|
||||||
async def test_wrong_token_rejected(self, token_enabled, client):
|
async def test_all_api_routers_protected(self, sso_enabled, mem_db, client):
|
||||||
resp = await client.get(
|
for path in (
|
||||||
"/api/v1/calls/active", headers={"Authorization": "Bearer wrong"}
|
"/api/v1/calls/active",
|
||||||
)
|
"/api/v1/call-flows/",
|
||||||
assert resp.status_code == 401
|
"/api/v1/devices/",
|
||||||
|
"/api/v1/routing/rules",
|
||||||
async def test_valid_token_reaches_handler(self, token_enabled, client):
|
"/api/v1/calls/history",
|
||||||
resp = await client.get(
|
"/api/v1/tokens",
|
||||||
"/api/v1/calls/active", headers={"Authorization": f"Bearer {TOKEN}"}
|
):
|
||||||
)
|
|
||||||
# No lifespan ran, so the handler itself 503s — auth was accepted
|
|
||||||
assert resp.status_code == 503
|
|
||||||
|
|
||||||
async def test_empty_token_disables_auth(self, monkeypatch, client):
|
|
||||||
monkeypatch.setattr(get_settings(), "api_token", SecretStr(""))
|
|
||||||
resp = await client.get("/api/v1/calls/active")
|
|
||||||
assert resp.status_code == 503
|
|
||||||
|
|
||||||
async def test_query_param_token_accepted(self, token_enabled, client):
|
|
||||||
"""<audio>/<a> elements can't set headers — ?token= must work."""
|
|
||||||
resp = await client.get(f"/api/v1/calls/active?token={TOKEN}")
|
|
||||||
assert resp.status_code == 503 # auth accepted, handler 503s (no lifespan)
|
|
||||||
|
|
||||||
async def test_wrong_query_param_token_rejected(self, token_enabled, client):
|
|
||||||
resp = await client.get("/api/v1/calls/active?token=wrong")
|
|
||||||
assert resp.status_code == 401
|
|
||||||
|
|
||||||
async def test_all_api_routers_protected(self, token_enabled, client):
|
|
||||||
for path in ("/api/v1/calls/active", "/api/v1/call-flows/", "/api/v1/devices/",
|
|
||||||
"/api/v1/routing/rules", "/api/v1/calls/history"):
|
|
||||||
resp = await client.get(path)
|
resp = await client.get(path)
|
||||||
assert resp.status_code == 401, path
|
assert resp.status_code == 401, path
|
||||||
|
|
||||||
|
async def test_auth_routes_are_public(self, sso_enabled, mem_db, client):
|
||||||
|
"""The OIDC endpoints must be reachable without a token."""
|
||||||
|
# /auth/me with no token → 401 (not 403); /auth/login → redirect to Casdoor
|
||||||
|
resp = await client.get("/auth/login", follow_redirects=False)
|
||||||
|
assert resp.status_code in (302, 307)
|
||||||
|
|
||||||
|
|
||||||
class TestRouteOrder:
|
class TestRouteOrder:
|
||||||
def _resolve(self, path: str):
|
def _resolve(self, path: str):
|
||||||
|
|||||||
239
tests/test_auth.py
Normal file
239
tests/test_auth.py
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
"""
|
||||||
|
Auth tests — Casdoor JWT + PAT resolution, owner gating, dev-owner mode.
|
||||||
|
|
||||||
|
No live Casdoor: we generate an RSA keypair, stub the JWKS client so
|
||||||
|
`_decode_casdoor_jwt` trusts our public key, and mint RS256 JWTs locally.
|
||||||
|
The app is exercised without its lifespan, so a 503 ("Gateway not
|
||||||
|
initialized") proves auth was accepted and the request reached a handler.
|
||||||
|
|
||||||
|
DB access (resolve_bearer → users/PATs) hits an in-memory SQLite database
|
||||||
|
wired in via the `mem_db` fixture, mirroring tests/test_data_layer.py.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import jwt
|
||||||
|
import pytest
|
||||||
|
from cryptography.hazmat.primitives import serialization
|
||||||
|
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||||
|
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||||
|
from sqlalchemy.pool import StaticPool
|
||||||
|
|
||||||
|
import auth as authmod
|
||||||
|
import db.database as dbmod
|
||||||
|
import main
|
||||||
|
from config import get_settings
|
||||||
|
from db.database import Base, PersonalAccessToken, User
|
||||||
|
|
||||||
|
ENDPOINT = "https://id.example.test"
|
||||||
|
OWNER = "owner@example.test"
|
||||||
|
|
||||||
|
|
||||||
|
# ── RSA keypair + JWKS stub ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def keypair():
|
||||||
|
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||||
|
private_pem = key.private_bytes(
|
||||||
|
serialization.Encoding.PEM,
|
||||||
|
serialization.PrivateFormat.PKCS8,
|
||||||
|
serialization.NoEncryption(),
|
||||||
|
)
|
||||||
|
return private_pem, key.public_key()
|
||||||
|
|
||||||
|
|
||||||
|
def _mint(private_pem, *, sub, name, email=None, exp_delta=3600):
|
||||||
|
claims = {
|
||||||
|
"iss": ENDPOINT,
|
||||||
|
"sub": sub,
|
||||||
|
"name": name,
|
||||||
|
"displayName": name,
|
||||||
|
"exp": int(time.time()) + exp_delta,
|
||||||
|
"iat": int(time.time()),
|
||||||
|
}
|
||||||
|
if email:
|
||||||
|
claims["email"] = email
|
||||||
|
return jwt.encode(claims, private_pem, algorithm="RS256")
|
||||||
|
|
||||||
|
|
||||||
|
class _StubJWKS:
|
||||||
|
"""Stands in for jwt.PyJWKClient — returns our fixed public key."""
|
||||||
|
|
||||||
|
def __init__(self, public_key):
|
||||||
|
self._key = public_key
|
||||||
|
|
||||||
|
def get_signing_key_from_jwt(self, token):
|
||||||
|
class _K:
|
||||||
|
key = self._key
|
||||||
|
|
||||||
|
return _K()
|
||||||
|
|
||||||
|
def fetch_data(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sso_enabled(monkeypatch, keypair):
|
||||||
|
"""Enable Casdoor SSO with a known owner and a stubbed JWKS client."""
|
||||||
|
_, public_key = keypair
|
||||||
|
settings = get_settings()
|
||||||
|
monkeypatch.setattr(settings.casdoor, "enabled", True)
|
||||||
|
monkeypatch.setattr(settings.casdoor, "endpoint", ENDPOINT)
|
||||||
|
monkeypatch.setattr(settings, "owner_name", OWNER)
|
||||||
|
monkeypatch.setattr(authmod, "_jwks_client", _StubJWKS(public_key))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def mem_db(monkeypatch):
|
||||||
|
engine = create_async_engine(
|
||||||
|
"sqlite+aiosqlite:///:memory:",
|
||||||
|
poolclass=StaticPool,
|
||||||
|
connect_args={"check_same_thread": False},
|
||||||
|
)
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
factory = async_sessionmaker(engine, expire_on_commit=False)
|
||||||
|
monkeypatch.setattr(dbmod, "_engine", engine)
|
||||||
|
monkeypatch.setattr(dbmod, "_session_factory", factory)
|
||||||
|
yield factory
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def client():
|
||||||
|
transport = httpx.ASGITransport(app=main.app)
|
||||||
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as c:
|
||||||
|
yield c
|
||||||
|
|
||||||
|
|
||||||
|
async def _seed_user(factory, *, name, casdoor_sub=None, email=None) -> str:
|
||||||
|
uid = uuid.uuid4().hex
|
||||||
|
async with factory() as session:
|
||||||
|
session.add(
|
||||||
|
User(id=uid, name=name, display_name=name, email=email, casdoor_sub=casdoor_sub)
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
return uid
|
||||||
|
|
||||||
|
|
||||||
|
async def _seed_pat(factory, user_id, *, revoked=False, expires_at=None) -> str:
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
plaintext = authmod.PAT_PREFIX + uuid.uuid4().hex
|
||||||
|
async with factory() as session:
|
||||||
|
pat = PersonalAccessToken(
|
||||||
|
id=uuid.uuid4().hex,
|
||||||
|
user_id=user_id,
|
||||||
|
name="test",
|
||||||
|
token_hash=authmod.hash_token(plaintext),
|
||||||
|
token_prefix=plaintext[: len(authmod.PAT_PREFIX) + 4],
|
||||||
|
revoked_at=(datetime.now(UTC) if revoked else None),
|
||||||
|
expires_at=expires_at,
|
||||||
|
)
|
||||||
|
session.add(pat)
|
||||||
|
await session.commit()
|
||||||
|
return plaintext
|
||||||
|
|
||||||
|
|
||||||
|
PROTECTED = "/api/v1/calls/active"
|
||||||
|
|
||||||
|
|
||||||
|
# ── JWT paths ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestCasdoorJWT:
|
||||||
|
async def test_owner_jwt_reaches_handler(self, sso_enabled, mem_db, client, keypair):
|
||||||
|
private_pem, _ = keypair
|
||||||
|
token = _mint(private_pem, sub="s-owner", name=OWNER, email=OWNER)
|
||||||
|
resp = await client.get(PROTECTED, headers={"Authorization": f"Bearer {token}"})
|
||||||
|
assert resp.status_code == 503 # auth accepted; no lifespan → handler 503s
|
||||||
|
|
||||||
|
async def test_non_owner_jwt_forbidden(self, sso_enabled, mem_db, client, keypair):
|
||||||
|
private_pem, _ = keypair
|
||||||
|
token = _mint(private_pem, sub="s-guest", name="guest@example.test")
|
||||||
|
resp = await client.get(PROTECTED, headers={"Authorization": f"Bearer {token}"})
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
async def test_expired_jwt_unauthenticated(self, sso_enabled, mem_db, client, keypair):
|
||||||
|
private_pem, _ = keypair
|
||||||
|
token = _mint(private_pem, sub="s-owner", name=OWNER, exp_delta=-10)
|
||||||
|
resp = await client.get(PROTECTED, headers={"Authorization": f"Bearer {token}"})
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
async def test_garbage_token_unauthenticated(self, sso_enabled, mem_db, client):
|
||||||
|
resp = await client.get(PROTECTED, headers={"Authorization": "Bearer not.a.jwt"})
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
async def test_no_credentials_unauthenticated(self, sso_enabled, mem_db, client):
|
||||||
|
resp = await client.get(PROTECTED)
|
||||||
|
assert resp.status_code == 401
|
||||||
|
assert resp.headers["www-authenticate"] == "Bearer"
|
||||||
|
|
||||||
|
|
||||||
|
# ── PAT paths ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestPAT:
|
||||||
|
async def test_owner_pat_reaches_handler(self, sso_enabled, mem_db, client):
|
||||||
|
uid = await _seed_user(mem_db, name=OWNER, casdoor_sub="s-owner", email=OWNER)
|
||||||
|
pat = await _seed_pat(mem_db, uid)
|
||||||
|
resp = await client.get(PROTECTED, headers={"Authorization": f"Bearer {pat}"})
|
||||||
|
assert resp.status_code == 503
|
||||||
|
|
||||||
|
async def test_non_owner_pat_forbidden(self, sso_enabled, mem_db, client):
|
||||||
|
uid = await _seed_user(mem_db, name="guest@example.test", casdoor_sub="s-guest")
|
||||||
|
pat = await _seed_pat(mem_db, uid)
|
||||||
|
resp = await client.get(PROTECTED, headers={"Authorization": f"Bearer {pat}"})
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
async def test_revoked_pat_unauthenticated(self, sso_enabled, mem_db, client):
|
||||||
|
uid = await _seed_user(mem_db, name=OWNER, casdoor_sub="s-owner", email=OWNER)
|
||||||
|
pat = await _seed_pat(mem_db, uid, revoked=True)
|
||||||
|
resp = await client.get(PROTECTED, headers={"Authorization": f"Bearer {pat}"})
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
async def test_expired_pat_unauthenticated(self, sso_enabled, mem_db, client):
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
|
uid = await _seed_user(mem_db, name=OWNER, casdoor_sub="s-owner", email=OWNER)
|
||||||
|
pat = await _seed_pat(mem_db, uid, expires_at=datetime.now(UTC) - timedelta(minutes=1))
|
||||||
|
resp = await client.get(PROTECTED, headers={"Authorization": f"Bearer {pat}"})
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
async def test_unknown_pat_unauthenticated(self, sso_enabled, mem_db, client):
|
||||||
|
resp = await client.get(
|
||||||
|
PROTECTED, headers={"Authorization": f"Bearer {authmod.PAT_PREFIX}nope"}
|
||||||
|
)
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
# ── Dev-owner mode (SSO disabled) ────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestDevOwnerMode:
|
||||||
|
async def test_tokenless_request_is_owner(self, monkeypatch, mem_db, client):
|
||||||
|
monkeypatch.setattr(get_settings().casdoor, "enabled", False)
|
||||||
|
resp = await client.get(PROTECTED)
|
||||||
|
assert resp.status_code == 503 # dev-owner resolved; handler 503s (no lifespan)
|
||||||
|
|
||||||
|
async def test_auth_me_reports_owner(self, monkeypatch, mem_db, client):
|
||||||
|
monkeypatch.setattr(get_settings().casdoor, "enabled", False)
|
||||||
|
resp = await client.get("/auth/me")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert body["is_owner"] is True
|
||||||
|
|
||||||
|
|
||||||
|
# ── /auth/me for a non-owner (200 + is_owner:false, not a hard 401) ──────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestAuthMe:
|
||||||
|
async def test_non_owner_gets_200_not_owner(self, sso_enabled, mem_db, client, keypair):
|
||||||
|
private_pem, _ = keypair
|
||||||
|
token = _mint(private_pem, sub="s-guest", name="guest@example.test")
|
||||||
|
resp = await client.get("/auth/me", headers={"Authorization": f"Bearer {token}"})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["is_owner"] is False
|
||||||
@@ -6,12 +6,32 @@ Uses the FastMCP in-memory client (no network, no mounted app).
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from fastmcp import Client
|
from fastmcp import Client
|
||||||
|
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||||
|
from sqlalchemy.pool import StaticPool
|
||||||
|
|
||||||
from config import Settings
|
import db.database as dbmod
|
||||||
|
from config import Settings, get_settings
|
||||||
from core.dial_plan import is_emergency_number
|
from core.dial_plan import is_emergency_number
|
||||||
from core.gateway import AIPSTNGateway
|
from core.gateway import AIPSTNGateway
|
||||||
|
from db.database import Base
|
||||||
from mcp_server.server import create_mcp_server
|
from mcp_server.server import create_mcp_server
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def mem_db(monkeypatch):
|
||||||
|
engine = create_async_engine(
|
||||||
|
"sqlite+aiosqlite:///:memory:",
|
||||||
|
poolclass=StaticPool,
|
||||||
|
connect_args={"check_same_thread": False},
|
||||||
|
)
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
factory = async_sessionmaker(engine, expire_on_commit=False)
|
||||||
|
monkeypatch.setattr(dbmod, "_engine", engine)
|
||||||
|
monkeypatch.setattr(dbmod, "_session_factory", factory)
|
||||||
|
yield factory
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
EXPECTED_TOOLS = {
|
EXPECTED_TOOLS = {
|
||||||
"make_call",
|
"make_call",
|
||||||
"get_call_status",
|
"get_call_status",
|
||||||
@@ -43,11 +63,64 @@ class TestToolSurface:
|
|||||||
tools = {t.name for t in await client.list_tools()}
|
tools = {t.name for t in await client.list_tools()}
|
||||||
assert tools == EXPECTED_TOOLS
|
assert tools == EXPECTED_TOOLS
|
||||||
|
|
||||||
async def test_auth_configured_when_token_given(self):
|
async def test_no_fastmcp_auth_configured(self):
|
||||||
assert create_mcp_server(lambda: None, api_token="sekrit").auth is not None
|
# Auth for /mcp is enforced by the ASGI _owner_only_mcp guard in
|
||||||
|
# main.py, not on the FastMCP instance itself.
|
||||||
assert create_mcp_server(lambda: None).auth is None
|
assert create_mcp_server(lambda: None).auth is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestMcpOwnerGuard:
|
||||||
|
"""The ASGI wrapper gates /mcp before the inner app runs."""
|
||||||
|
|
||||||
|
def _wrapped(self):
|
||||||
|
import main
|
||||||
|
|
||||||
|
calls = {"inner": 0}
|
||||||
|
|
||||||
|
async def inner(scope, receive, send):
|
||||||
|
calls["inner"] += 1
|
||||||
|
await send({"type": "http.response.start", "status": 200, "headers": []})
|
||||||
|
await send({"type": "http.response.body", "body": b"ok"})
|
||||||
|
|
||||||
|
return main._owner_only_mcp(inner), calls
|
||||||
|
|
||||||
|
async def _run(self, app, headers):
|
||||||
|
sent = []
|
||||||
|
|
||||||
|
async def receive():
|
||||||
|
return {"type": "http.request", "body": b"", "more_body": False}
|
||||||
|
|
||||||
|
async def send(msg):
|
||||||
|
sent.append(msg)
|
||||||
|
|
||||||
|
scope = {
|
||||||
|
"type": "http",
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/mcp/",
|
||||||
|
"headers": headers,
|
||||||
|
"query_string": b"",
|
||||||
|
}
|
||||||
|
await app(scope, receive, send)
|
||||||
|
status = next(m["status"] for m in sent if m["type"] == "http.response.start")
|
||||||
|
return status
|
||||||
|
|
||||||
|
async def test_missing_token_401(self, monkeypatch, mem_db):
|
||||||
|
monkeypatch.setattr(get_settings().casdoor, "enabled", True)
|
||||||
|
monkeypatch.setattr(get_settings().casdoor, "endpoint", "https://id.example.test")
|
||||||
|
monkeypatch.setattr(get_settings(), "owner_name", "owner@example.test")
|
||||||
|
app, calls = self._wrapped()
|
||||||
|
status = await self._run(app, headers=[])
|
||||||
|
assert status == 401
|
||||||
|
assert calls["inner"] == 0
|
||||||
|
|
||||||
|
async def test_dev_owner_passes(self, monkeypatch, mem_db):
|
||||||
|
monkeypatch.setattr(get_settings().casdoor, "enabled", False)
|
||||||
|
app, calls = self._wrapped()
|
||||||
|
status = await self._run(app, headers=[])
|
||||||
|
assert status == 200
|
||||||
|
assert calls["inner"] == 1
|
||||||
|
|
||||||
|
|
||||||
class TestGatewayResolution:
|
class TestGatewayResolution:
|
||||||
async def test_tool_errors_cleanly_before_gateway_ready(self):
|
async def test_tool_errors_cleanly_before_gateway_ready(self):
|
||||||
mcp = create_mcp_server(lambda: None)
|
mcp = create_mcp_server(lambda: None)
|
||||||
|
|||||||
77
tests/test_oauth_metadata.py
Normal file
77
tests/test_oauth_metadata.py
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
"""
|
||||||
|
OAuth discovery metadata tests (RFC 9728 / RFC 8414 / RFC 7591).
|
||||||
|
|
||||||
|
MCP clients that get a 401 from /mcp perform OAuth discovery. These
|
||||||
|
endpoints are unauthenticated and served straight from main.app.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import main
|
||||||
|
from config import get_settings
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def client():
|
||||||
|
transport = httpx.ASGITransport(app=main.app)
|
||||||
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as c:
|
||||||
|
yield c
|
||||||
|
|
||||||
|
|
||||||
|
class TestProtectedResourceMetadata:
|
||||||
|
async def test_resource_advertises_mcp_path(self, client):
|
||||||
|
resp = await client.get("/.well-known/oauth-protected-resource")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
# mcp-remote verifies this matches the URL it connected to.
|
||||||
|
assert body["resource"] == "http://test/mcp"
|
||||||
|
assert body["authorization_servers"] == ["http://test"]
|
||||||
|
|
||||||
|
async def test_mcp_suffixed_variant(self, client):
|
||||||
|
resp = await client.get("/.well-known/oauth-protected-resource/mcp")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["resource"] == "http://test/mcp"
|
||||||
|
|
||||||
|
|
||||||
|
class TestAuthorizationServerMetadata:
|
||||||
|
async def test_advertises_casdoor_when_enabled(self, monkeypatch, client):
|
||||||
|
monkeypatch.setattr(get_settings().casdoor, "enabled", True)
|
||||||
|
monkeypatch.setattr(get_settings().casdoor, "endpoint", "https://id.example.test")
|
||||||
|
resp = await client.get("/.well-known/oauth-authorization-server")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert body["issuer"] == "https://id.example.test"
|
||||||
|
assert body["jwks_uri"] == "https://id.example.test/.well-known/jwks"
|
||||||
|
assert body["registration_endpoint"] == "http://test/register"
|
||||||
|
|
||||||
|
async def test_dev_mode_advertises_local(self, monkeypatch, client):
|
||||||
|
monkeypatch.setattr(get_settings().casdoor, "enabled", False)
|
||||||
|
resp = await client.get("/.well-known/oauth-authorization-server")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert body["issuer"] == "http://test"
|
||||||
|
assert body["authorization_endpoint"] == "http://test/auth/login"
|
||||||
|
|
||||||
|
|
||||||
|
class TestDynamicRegistration:
|
||||||
|
async def test_registers_client(self, client):
|
||||||
|
resp = await client.post(
|
||||||
|
"/register",
|
||||||
|
json={"redirect_uris": ["http://localhost/cb"], "client_name": "test"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
body = resp.json()
|
||||||
|
assert "client_id" in body
|
||||||
|
assert body["redirect_uris"] == ["http://localhost/cb"]
|
||||||
|
|
||||||
|
async def test_rejects_missing_redirect_uris(self, client):
|
||||||
|
resp = await client.post("/register", json={"client_name": "test"})
|
||||||
|
assert resp.status_code == 400
|
||||||
|
assert resp.json()["error"] == "invalid_redirect_uri"
|
||||||
|
|
||||||
|
async def test_rejects_non_json(self, client):
|
||||||
|
resp = await client.post(
|
||||||
|
"/register", content=b"not json", headers={"content-type": "application/json"}
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
@@ -9,7 +9,6 @@ through the shared data layer in services/call_persistence.py.
|
|||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
import pytest
|
import pytest
|
||||||
from pydantic import SecretStr
|
|
||||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||||
from sqlalchemy.pool import StaticPool
|
from sqlalchemy.pool import StaticPool
|
||||||
|
|
||||||
@@ -116,7 +115,9 @@ class TestInboundPolicy:
|
|||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
async def client(monkeypatch):
|
async def client(monkeypatch):
|
||||||
monkeypatch.setattr(get_settings(), "api_token", SecretStr(""))
|
# Dev-owner mode: tokenless requests resolve to the owner. auth's DB
|
||||||
|
# session comes through the same get_db override below.
|
||||||
|
monkeypatch.setattr(get_settings().casdoor, "enabled", False)
|
||||||
|
|
||||||
engine = create_async_engine(
|
engine = create_async_engine(
|
||||||
"sqlite+aiosqlite:///:memory:",
|
"sqlite+aiosqlite:///:memory:",
|
||||||
|
|||||||
@@ -1,27 +1,57 @@
|
|||||||
"""
|
"""
|
||||||
WebSocket event-stream tests.
|
WebSocket event-stream tests.
|
||||||
|
|
||||||
The socket is refused (4401) without the bearer token, and an
|
The socket is owner-gated: refused (4401) when SSO is enabled and no
|
||||||
authorized client immediately receives the synthetic trunk-status
|
credential is supplied, and — in dev-owner mode (SSO disabled) — an
|
||||||
event followed by the replayed recent history.
|
authorized client immediately receives the synthetic trunk-status event
|
||||||
|
followed by the replayed recent history.
|
||||||
|
|
||||||
|
The WS `_authorize` resolves the owner via a DB session, so an in-memory
|
||||||
|
SQLite database is wired in (StaticPool, shared across the TestClient
|
||||||
|
thread) mirroring tests/test_data_layer.py.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from pydantic import SecretStr
|
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||||
|
from sqlalchemy.pool import StaticPool
|
||||||
from starlette.testclient import TestClient
|
from starlette.testclient import TestClient
|
||||||
from starlette.websockets import WebSocketDisconnect
|
from starlette.websockets import WebSocketDisconnect
|
||||||
|
|
||||||
|
import db.database as dbmod
|
||||||
import main
|
import main
|
||||||
from config import Settings, get_settings
|
from config import Settings, get_settings
|
||||||
from core.gateway import AIPSTNGateway
|
from core.gateway import AIPSTNGateway
|
||||||
|
from db.database import Base
|
||||||
from models.events import EventType, GatewayEvent
|
from models.events import EventType, GatewayEvent
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def ws_app(monkeypatch):
|
def mem_db(monkeypatch):
|
||||||
monkeypatch.setattr(get_settings(), "api_token", SecretStr("tok"))
|
"""Synchronous setup of an in-memory SQLite DB shared with the app."""
|
||||||
|
engine = create_async_engine(
|
||||||
|
"sqlite+aiosqlite:///:memory:",
|
||||||
|
poolclass=StaticPool,
|
||||||
|
connect_args={"check_same_thread": False},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _create():
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
|
||||||
|
asyncio.run(_create())
|
||||||
|
factory = async_sessionmaker(engine, expire_on_commit=False)
|
||||||
|
monkeypatch.setattr(dbmod, "_engine", engine)
|
||||||
|
monkeypatch.setattr(dbmod, "_session_factory", factory)
|
||||||
|
yield
|
||||||
|
asyncio.run(engine.dispose())
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def ws_app(monkeypatch, mem_db):
|
||||||
|
"""Dev-owner mode: a tokenless WS connect resolves the owner."""
|
||||||
|
monkeypatch.setattr(get_settings().casdoor, "enabled", False)
|
||||||
gateway = AIPSTNGateway(settings=Settings())
|
gateway = AIPSTNGateway(settings=Settings())
|
||||||
main.app.state.gateway = gateway
|
main.app.state.gateway = gateway
|
||||||
yield gateway
|
yield gateway
|
||||||
@@ -38,7 +68,11 @@ def _publish(gateway, call_id: str) -> None:
|
|||||||
|
|
||||||
|
|
||||||
class TestEventStream:
|
class TestEventStream:
|
||||||
def test_refused_without_token(self, ws_app):
|
def test_refused_without_credential(self, monkeypatch, mem_db):
|
||||||
|
"""SSO enabled + no token → the socket is closed with 4401."""
|
||||||
|
monkeypatch.setattr(get_settings().casdoor, "enabled", True)
|
||||||
|
monkeypatch.setattr(get_settings().casdoor, "endpoint", "https://id.example.test")
|
||||||
|
monkeypatch.setattr(get_settings(), "owner_name", "owner@example.test")
|
||||||
client = TestClient(main.app)
|
client = TestClient(main.app)
|
||||||
with pytest.raises(WebSocketDisconnect) as exc:
|
with pytest.raises(WebSocketDisconnect) as exc:
|
||||||
with client.websocket_connect("/ws/events"):
|
with client.websocket_connect("/ws/events"):
|
||||||
@@ -50,7 +84,7 @@ class TestEventStream:
|
|||||||
_publish(ws_app, "call_ws2")
|
_publish(ws_app, "call_ws2")
|
||||||
|
|
||||||
client = TestClient(main.app)
|
client = TestClient(main.app)
|
||||||
with client.websocket_connect("/ws/events?token=tok") as ws:
|
with client.websocket_connect("/ws/events") as ws:
|
||||||
first = ws.receive_json()
|
first = ws.receive_json()
|
||||||
assert first["type"] == EventType.SIP_TRUNK_REGISTRATION_FAILED.value
|
assert first["type"] == EventType.SIP_TRUNK_REGISTRATION_FAILED.value
|
||||||
replayed = [ws.receive_json() for _ in range(2)]
|
replayed = [ws.receive_json() for _ in range(2)]
|
||||||
@@ -58,9 +92,7 @@ class TestEventStream:
|
|||||||
|
|
||||||
def test_per_call_stream_filters(self, ws_app):
|
def test_per_call_stream_filters(self, ws_app):
|
||||||
client = TestClient(main.app)
|
client = TestClient(main.app)
|
||||||
with client.websocket_connect(
|
with client.websocket_connect("/ws/calls/call_target/events") as ws:
|
||||||
"/ws/calls/call_target/events?token=tok"
|
|
||||||
) as ws:
|
|
||||||
_publish(ws_app, "call_other")
|
_publish(ws_app, "call_other")
|
||||||
_publish(ws_app, "call_target")
|
_publish(ws_app, "call_target")
|
||||||
msg = ws.receive_json()
|
msg = ws.receive_json()
|
||||||
|
|||||||
Reference in New Issue
Block a user