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
|
||||
cp .env.example .env
|
||||
# Edit .env with your SIP trunk credentials, LLM endpoint, etc.
|
||||
# Required: DATABASE_URL, and API_TOKEN unless HOST=127.0.0.1
|
||||
openssl rand -hex 32 # → API_TOKEN
|
||||
# Required: DATABASE_URL, plus either the Casdoor SSO settings
|
||||
# (CASDOOR_* + OWNER_NAME) or CASDOOR_ENABLED=false with HOST=127.0.0.1.
|
||||
```
|
||||
|
||||
All REST, WebSocket, and MCP access requires `Authorization: Bearer
|
||||
$API_TOKEN` (WebSocket also accepts `?token=...`). An empty token is only
|
||||
permitted when bound to loopback.
|
||||
The gateway is **owner-only**. The browser dashboard signs in via **Casdoor
|
||||
SSO** (short-lived JWT); MCP and CLI clients use a **Personal Access Token**
|
||||
(`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)
|
||||
|
||||
@@ -204,6 +208,27 @@ uvicorn main:app --host 0.0.0.0 --port 8000
|
||||
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
|
||||
|
||||
### REST API
|
||||
@@ -212,7 +237,7 @@ pytest tests/ -v
|
||||
|
||||
```bash
|
||||
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" \
|
||||
-d '{
|
||||
"number": "+18005551234",
|
||||
@@ -264,7 +289,7 @@ curl -X PATCH http://localhost:8000/api/v1/routing/devices/dev_abc123/dnd \
|
||||
### WebSocket — Real-Time Events
|
||||
|
||||
```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) => {
|
||||
const event = JSON.parse(msg.data);
|
||||
// event.type: "human_detected", "hold_detected", "ivr_step", etc.
|
||||
@@ -276,11 +301,12 @@ ws.onmessage = (msg) => {
|
||||
### MCP — AI Assistant Integration
|
||||
|
||||
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
|
||||
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`,
|
||||
@@ -333,7 +359,14 @@ All configuration is via environment variables (see `.env.example`):
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `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` |
|
||||
| `SIP_TRUNK_HOST` | Your SIP provider hostname | — |
|
||||
| `SIP_TRUNK_USERNAME` | SIP auth username | — |
|
||||
@@ -408,13 +441,13 @@ Full documentation is in [`/docs`](docs/README.md):
|
||||
### Phase 4: Production Hardening 🚧
|
||||
|
||||
- [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
|
||||
- [ ] Rate limiting on API endpoints
|
||||
- [ ] Structured JSON logging
|
||||
- [x] Honest /health — engine mode, DB ping, trunk registration, STT/TTS availability
|
||||
- [ ] Graceful degradation (classifier works without STT, etc.)
|
||||
- [ ] Docker Compose (Hold Slayer + PostgreSQL)
|
||||
- [x] Docker Compose (Hold Slayer + PostgreSQL)
|
||||
|
||||
### 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.
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -24,31 +24,3 @@ def get_routing_service(request: Request):
|
||||
if routing is None:
|
||||
raise HTTPException(status_code=503, detail="Routing service not ready")
|
||||
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."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import secrets
|
||||
|
||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||
|
||||
from api.deps import get_gateway
|
||||
from config import get_settings
|
||||
from auth import is_owner, resolve_from_header_or_query
|
||||
from db.database import session_scope
|
||||
from models.events import EventType, GatewayEvent
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -17,21 +15,21 @@ router = APIRouter()
|
||||
|
||||
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`
|
||||
query parameter is accepted alongside the Authorization header.
|
||||
Browsers can't set headers on WebSocket connects, so the Casdoor JWT (or
|
||||
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()
|
||||
if not token:
|
||||
q_token = websocket.query_params.get("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
|
||||
supplied = websocket.query_params.get("token", "")
|
||||
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")
|
||||
await websocket.close(code=4401, reason="Owner authentication required")
|
||||
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
|
||||
|
||||
|
||||
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):
|
||||
"""AI Receptionist behavior settings."""
|
||||
|
||||
@@ -126,9 +145,14 @@ class Settings(BaseSettings):
|
||||
debug: bool = False
|
||||
log_level: str = "info"
|
||||
|
||||
# Auth — one static bearer token shared by REST, WebSocket, and MCP.
|
||||
# Empty disables auth, which is only permitted on loopback binds.
|
||||
api_token: SecretStr = SecretStr("")
|
||||
# Auth — Casdoor SSO for the browser + owner-minted PATs for MCP/CLI,
|
||||
# gated to a single owner. `owner_name` is the Casdoor username that owns
|
||||
# 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)
|
||||
max_concurrent_calls: int = 4
|
||||
@@ -150,6 +174,7 @@ class Settings(BaseSettings):
|
||||
hold_slayer: HoldSlayerSettings = Field(default_factory=HoldSlayerSettings)
|
||||
tts: TTSSettings = Field(default_factory=TTSSettings)
|
||||
receptionist: ReceptionistSettings = Field(default_factory=ReceptionistSettings)
|
||||
casdoor: CasdoorSettings = Field(default_factory=CasdoorSettings)
|
||||
|
||||
|
||||
# Singleton
|
||||
|
||||
11
dashboard/package-lock.json
generated
11
dashboard/package-lock.json
generated
@@ -13,6 +13,7 @@
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.3",
|
||||
"@tailwindcss/vite": "^4.1.3",
|
||||
"@types/node": "^25.8.0",
|
||||
"daisyui": "^5.0.0",
|
||||
"svelte": "^5.25.3",
|
||||
"svelte-check": "^4.1.4",
|
||||
"tailwindcss": "^4.1.3",
|
||||
@@ -1262,6 +1263,16 @@
|
||||
"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": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.3",
|
||||
"@tailwindcss/vite": "^4.1.3",
|
||||
"@types/node": "^25.8.0",
|
||||
"daisyui": "^5.0.0",
|
||||
"svelte": "^5.25.3",
|
||||
"svelte-check": "^4.1.4",
|
||||
"tailwindcss": "^4.1.3",
|
||||
|
||||
@@ -1 +1,4 @@
|
||||
@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 {
|
||||
AccessToken,
|
||||
CallHistoryRow,
|
||||
CallSummary,
|
||||
DeviceStatus,
|
||||
@@ -10,19 +11,12 @@ import type {
|
||||
} from './types';
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Bearer token — one static API_TOKEN shared with REST/WS/MCP.
|
||||
// Kept in localStorage; a 401 prompts once and retries.
|
||||
// Authed fetch — the browser holds a Casdoor JWT (or, for scripted use,
|
||||
// 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';
|
||||
|
||||
function getToken(): string {
|
||||
return localStorage.getItem(TOKEN_KEY) ?? '';
|
||||
}
|
||||
|
||||
export function setToken(value: string): void {
|
||||
localStorage.setItem(TOKEN_KEY, value);
|
||||
}
|
||||
import { auth, getToken } from './auth.svelte';
|
||||
|
||||
function withAuth(init: RequestInit): RequestInit {
|
||||
const token = getToken();
|
||||
@@ -36,10 +30,11 @@ function withAuth(init: RequestInit): RequestInit {
|
||||
async function request(path: string, init: RequestInit = {}): Promise<Response> {
|
||||
let res = await fetch(path, withAuth(init));
|
||||
if (res.status === 401) {
|
||||
const supplied = window.prompt('Hold Slayer API token (API_TOKEN in .env):');
|
||||
if (supplied !== null && supplied.trim()) {
|
||||
setToken(supplied.trim());
|
||||
const refreshed = await auth.trySilentRefresh();
|
||||
if (refreshed) {
|
||||
res = await fetch(path, withAuth(init));
|
||||
} else {
|
||||
auth.setUnauthenticated();
|
||||
}
|
||||
}
|
||||
return res;
|
||||
@@ -89,8 +84,10 @@ export async function fetchTranscript(callId: string): Promise<TranscriptRow[]>
|
||||
}
|
||||
|
||||
export function recordingUrl(callId: string): string {
|
||||
// <audio> can't send headers, so the token rides as a query param
|
||||
// (accepted server-side alongside the Authorization header).
|
||||
// <audio> can't send headers, so the current token (Casdoor JWT, or a PAT)
|
||||
// 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 suffix = token ? `?token=${encodeURIComponent(token)}` : '';
|
||||
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}`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// 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(
|
||||
onEvent: (e: GatewayEvent) => void,
|
||||
onClose: () => void,
|
||||
): () => void {
|
||||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
// Browsers can't set headers on WS connects — the token rides as ?token=.
|
||||
const token = getToken();
|
||||
const suffix = token ? `?token=${encodeURIComponent(token)}` : '';
|
||||
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 {
|
||||
name: string;
|
||||
version: string;
|
||||
|
||||
@@ -2,9 +2,15 @@
|
||||
import '../app.css';
|
||||
import { page } from '$app/stores';
|
||||
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 tokensOpen = $state(false);
|
||||
|
||||
type ThemeOverride = 'dark' | 'light' | null;
|
||||
let override = $state<ThemeOverride>(null);
|
||||
let systemDark = $state(true);
|
||||
@@ -12,7 +18,10 @@
|
||||
let isDark = $derived(override !== null ? override === 'dark' : systemDark);
|
||||
|
||||
$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.setAttribute('data-theme', isDark ? 'dark' : 'light');
|
||||
});
|
||||
|
||||
function toggleTheme() {
|
||||
@@ -27,6 +36,8 @@
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
void auth.init();
|
||||
|
||||
const mq = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
systemDark = mq.matches;
|
||||
|
||||
@@ -49,7 +60,16 @@
|
||||
];
|
||||
</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
|
||||
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>
|
||||
{/each}
|
||||
</nav>
|
||||
<div class="ml-auto flex items-center gap-2">
|
||||
<button
|
||||
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'}
|
||||
>
|
||||
{isDark ? 'Light' : 'Dark'}
|
||||
</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>
|
||||
</header>
|
||||
|
||||
<main class="mx-auto max-w-7xl px-4 py-6">
|
||||
{@render children()}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TokensModal bind:open={tokensOpen} />
|
||||
{/if}
|
||||
|
||||
@@ -14,6 +14,7 @@ from sqlalchemy import (
|
||||
Column,
|
||||
DateTime,
|
||||
Float,
|
||||
ForeignKey,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
@@ -153,6 +154,48 @@ class RecordingRecord(Base):
|
||||
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
|
||||
# ============================================================
|
||||
|
||||
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
|
||||
|
||||
### 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
|
||||
|
||||
| Variable | Description | Default | Required |
|
||||
|
||||
@@ -3,8 +3,11 @@
|
||||
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),
|
||||
it is mounted on the FastAPI app at **`/mcp/`** (trailing slash) over
|
||||
**streamable HTTP** and authenticates with the same static bearer token as the
|
||||
REST API and WebSocket.
|
||||
**streamable HTTP** and authenticates with an owner-minted Personal Access Token
|
||||
(`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
|
||||
|
||||
@@ -158,7 +161,7 @@ Claude Code:
|
||||
|
||||
```bash
|
||||
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:
|
||||
@@ -168,7 +171,7 @@ Generic MCP client configuration:
|
||||
"mcpServers": {
|
||||
"hold-slayer": {
|
||||
"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 secrets
|
||||
import sys
|
||||
import time
|
||||
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 api import call_flows, call_history, calls, devices, routing, websocket
|
||||
from api.deps import require_token
|
||||
from api import auth as auth_router
|
||||
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 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 models.call import CallMode
|
||||
from services.audio_classifier import AudioClassifier
|
||||
@@ -106,16 +110,38 @@ def _check_startup_config(settings: Settings) -> None:
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
token = settings.api_token.get_secret_value()
|
||||
if not token and settings.host not in ("127.0.0.1", "localhost", "::1"):
|
||||
loopback = settings.host 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(
|
||||
"\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"
|
||||
" Every surface (REST, WebSocket, MCP make_call) would be open "
|
||||
"to the network.\n"
|
||||
" Set API_TOKEN in .env (e.g. `openssl rand -hex 32`), or set "
|
||||
"HOST=127.0.0.1 for tokenless local development."
|
||||
" Every surface (REST, WebSocket, MCP make_call) would resolve "
|
||||
"to the dev owner — open to the network.\n"
|
||||
" Set CASDOOR_ENABLED=true (with the Casdoor + OWNER_NAME settings), "
|
||||
"or set HOST=127.0.0.1 for tokenless local development."
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
@@ -126,6 +152,10 @@ async def lifespan(app: FastAPI):
|
||||
settings = get_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;
|
||||
# without entering it, every /mcp request 500s.
|
||||
async with mcp_http_app.lifespan(app):
|
||||
@@ -214,7 +244,11 @@ async def lifespan(app: FastAPI):
|
||||
display_port = int(sys.argv[i + 1])
|
||||
except ValueError:
|
||||
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 Docs: http://{display_host}:{display_port}/docs")
|
||||
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)
|
||||
|
||||
|
||||
mcp = create_mcp_server(
|
||||
_get_gateway_instance,
|
||||
api_token=get_settings().api_token.get_secret_value(),
|
||||
)
|
||||
mcp = create_mcp_server(_get_gateway_instance)
|
||||
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(
|
||||
title="Hold Slayer Gateway",
|
||||
description=(
|
||||
@@ -258,9 +372,13 @@ app = FastAPI(
|
||||
)
|
||||
|
||||
# === 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
|
||||
# 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(
|
||||
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(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"])
|
||||
|
||||
# === MCP (streamable HTTP; clients connect to /mcp/ with the bearer token) ===
|
||||
app.mount("/mcp", mcp_http_app)
|
||||
# === MCP (streamable HTTP; clients connect to /mcp/ with a PAT or JWT) ===
|
||||
# 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)
|
||||
|
||||
@@ -27,7 +27,6 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
def create_mcp_server(
|
||||
get_gateway: Callable[[], Optional[AIPSTNGateway]],
|
||||
api_token: str = "",
|
||||
) -> FastMCP:
|
||||
"""
|
||||
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
|
||||
server can be mounted at app construction, before the lifespan has
|
||||
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
|
||||
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)
|
||||
mcp = FastMCP("Hold Slayer Gateway", auth=None)
|
||||
|
||||
def require_gateway() -> AIPSTNGateway:
|
||||
gateway = get_gateway()
|
||||
|
||||
@@ -32,9 +32,13 @@ dependencies = [
|
||||
# HTTP client (for Speaches STT)
|
||||
"httpx>=0.28.0",
|
||||
|
||||
# MCP server (3.x — http_app + StaticTokenVerifier)
|
||||
# MCP server (3.x — http_app + ASGI owner guard)
|
||||
"fastmcp>=3.0.0",
|
||||
|
||||
# Auth: Casdoor SSO (OAuth code exchange) + RS256 JWT validation
|
||||
"casdoor>=1.0",
|
||||
"pyjwt[crypto]>=2.8",
|
||||
|
||||
# Utilities
|
||||
"python-slugify>=8.0.0",
|
||||
]
|
||||
@@ -47,6 +51,8 @@ dev = [
|
||||
"httpx>=0.28.0",
|
||||
"ruff>=0.8.0",
|
||||
"aiosqlite>=0.22.0",
|
||||
# Mint RS256 JWTs in tests without a live Casdoor
|
||||
"cryptography>=42.0",
|
||||
]
|
||||
|
||||
[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,
|
||||
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 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
|
||||
|
||||
import db.database as dbmod
|
||||
import main
|
||||
from config import get_settings
|
||||
|
||||
TOKEN = "test-token-for-suite"
|
||||
from db.database import Base
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def token_enabled(monkeypatch):
|
||||
monkeypatch.setattr(get_settings(), "api_token", SecretStr(TOKEN))
|
||||
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
|
||||
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
|
||||
@@ -28,45 +57,34 @@ async def client():
|
||||
yield c
|
||||
|
||||
|
||||
class TestBearerToken:
|
||||
async def test_missing_token_rejected(self, token_enabled, client):
|
||||
class TestOwnerGate:
|
||||
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")
|
||||
assert resp.status_code == 401
|
||||
assert resp.headers["www-authenticate"] == "Bearer"
|
||||
|
||||
async def test_wrong_token_rejected(self, token_enabled, client):
|
||||
resp = await client.get(
|
||||
"/api/v1/calls/active", headers={"Authorization": "Bearer wrong"}
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
|
||||
async def test_valid_token_reaches_handler(self, token_enabled, client):
|
||||
resp = await client.get(
|
||||
"/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"):
|
||||
async def test_all_api_routers_protected(self, sso_enabled, mem_db, client):
|
||||
for path in (
|
||||
"/api/v1/calls/active",
|
||||
"/api/v1/call-flows/",
|
||||
"/api/v1/devices/",
|
||||
"/api/v1/routing/rules",
|
||||
"/api/v1/calls/history",
|
||||
"/api/v1/tokens",
|
||||
):
|
||||
resp = await client.get(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:
|
||||
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
|
||||
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.gateway import AIPSTNGateway
|
||||
from db.database import Base
|
||||
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 = {
|
||||
"make_call",
|
||||
"get_call_status",
|
||||
@@ -43,11 +63,64 @@ class TestToolSurface:
|
||||
tools = {t.name for t in await client.list_tools()}
|
||||
assert tools == EXPECTED_TOOLS
|
||||
|
||||
async def test_auth_configured_when_token_given(self):
|
||||
assert create_mcp_server(lambda: None, api_token="sekrit").auth is not None
|
||||
async def test_no_fastmcp_auth_configured(self):
|
||||
# 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
|
||||
|
||||
|
||||
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:
|
||||
async def test_tool_errors_cleanly_before_gateway_ready(self):
|
||||
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 pytest
|
||||
from pydantic import SecretStr
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
@@ -116,7 +115,9 @@ class TestInboundPolicy:
|
||||
|
||||
@pytest.fixture
|
||||
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(
|
||||
"sqlite+aiosqlite:///:memory:",
|
||||
|
||||
@@ -1,27 +1,57 @@
|
||||
"""
|
||||
WebSocket event-stream tests.
|
||||
|
||||
The socket is refused (4401) without the bearer token, and an
|
||||
authorized client immediately receives the synthetic trunk-status
|
||||
event followed by the replayed recent history.
|
||||
The socket is owner-gated: refused (4401) when SSO is enabled and no
|
||||
credential is supplied, and — in dev-owner mode (SSO disabled) — an
|
||||
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 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.websockets import WebSocketDisconnect
|
||||
|
||||
import db.database as dbmod
|
||||
import main
|
||||
from config import Settings, get_settings
|
||||
from core.gateway import AIPSTNGateway
|
||||
from db.database import Base
|
||||
from models.events import EventType, GatewayEvent
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ws_app(monkeypatch):
|
||||
monkeypatch.setattr(get_settings(), "api_token", SecretStr("tok"))
|
||||
def mem_db(monkeypatch):
|
||||
"""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())
|
||||
main.app.state.gateway = gateway
|
||||
yield gateway
|
||||
@@ -38,7 +68,11 @@ def _publish(gateway, call_id: str) -> None:
|
||||
|
||||
|
||||
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)
|
||||
with pytest.raises(WebSocketDisconnect) as exc:
|
||||
with client.websocket_connect("/ws/events"):
|
||||
@@ -50,7 +84,7 @@ class TestEventStream:
|
||||
_publish(ws_app, "call_ws2")
|
||||
|
||||
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()
|
||||
assert first["type"] == EventType.SIP_TRUNK_REGISTRATION_FAILED.value
|
||||
replayed = [ws.receive_json() for _ in range(2)]
|
||||
@@ -58,9 +92,7 @@ class TestEventStream:
|
||||
|
||||
def test_per_call_stream_filters(self, ws_app):
|
||||
client = TestClient(main.app)
|
||||
with client.websocket_connect(
|
||||
"/ws/calls/call_target/events?token=tok"
|
||||
) as ws:
|
||||
with client.websocket_connect("/ws/calls/call_target/events") as ws:
|
||||
_publish(ws_app, "call_other")
|
||||
_publish(ws_app, "call_target")
|
||||
msg = ws.receive_json()
|
||||
|
||||
Reference in New Issue
Block a user