docs: add Claude AI assistant rules and configuration
All checks were successful
CVE Scan & Docker Build / security-scan (push) Successful in 45s
CVE Scan & Docker Build / build-and-push (push) Successful in 1m53s

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:
2026-07-28 19:01:38 -04:00
parent 016d8be71d
commit 4a3c14d4af
40 changed files with 2851 additions and 202 deletions

View 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.

View 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.

View 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.

View 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.

View 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.

View 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
View 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
View 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.

View File

@@ -172,13 +172,17 @@ pip install -e ".[dev]"
```bash ```bash
cp .env.example .env cp .env.example .env
# Edit .env with your SIP trunk credentials, LLM endpoint, etc. # Edit .env with your SIP trunk credentials, LLM endpoint, etc.
# Required: DATABASE_URL, and API_TOKEN unless HOST=127.0.0.1 # Required: DATABASE_URL, plus either the Casdoor SSO settings
openssl rand -hex 32 # → API_TOKEN # (CASDOOR_* + OWNER_NAME) or CASDOOR_ENABLED=false with HOST=127.0.0.1.
``` ```
All REST, WebSocket, and MCP access requires `Authorization: Bearer The gateway is **owner-only**. The browser dashboard signs in via **Casdoor
$API_TOKEN` (WebSocket also accepts `?token=...`). An empty token is only SSO** (short-lived JWT); MCP and CLI clients use a **Personal Access Token**
permitted when bound to loopback. (`hs_pat_…`) minted from the dashboard's *API Tokens* menu. Both are presented as
`Authorization: Bearer <token>` (WebSocket and `<audio>` recording downloads also
accept `?token=…`). Only the user whose Casdoor username matches `OWNER_NAME` may
use any surface — everyone else gets 403. With `CASDOOR_ENABLED=false` the gateway
runs in dev-owner mode, permitted **only** on a loopback bind.
### 3. Build the dashboard (optional but recommended) ### 3. Build the dashboard (optional but recommended)
@@ -204,6 +208,27 @@ uvicorn main:app --host 0.0.0.0 --port 8000
pytest tests/ -v pytest tests/ -v
``` ```
## Docker
A single image bundles the FastAPI process and the built dashboard (the node
stage compiles the SPA; `pjsua2` is deliberately not built, so the media
pipeline runs in stub mode — see the `Dockerfile` header). `docker-compose.yaml`
brings up the app plus its own PostgreSQL:
```bash
cp .env.compose.example .env
# Fill in HS_DB_PASSWORD, and CASDOOR_CLIENT_ID/SECRET + OWNER_NAME.
docker compose up --build
# → http://localhost:21081
```
Because the published port binds the app to `0.0.0.0`, the compose stack must run
with **Casdoor SSO enabled** — dev-owner mode (`CASDOOR_ENABLED=false`) is
loopback-only and is refused at startup here. Register a `hold-slayer` app in
Casdoor (org `heluca`, redirect URI `<PUBLIC_BASE_URL>/auth/callback`) first. The
image runs with `USE_MOCK_SIP=true` by default (a real trunk needs the
`SIP_TRUNK_*` vars and `USE_MOCK_SIP=false`).
## Usage ## Usage
### REST API ### REST API
@@ -212,7 +237,7 @@ pytest tests/ -v
```bash ```bash
curl -X POST http://localhost:8000/api/v1/calls/hold-slayer \ curl -X POST http://localhost:8000/api/v1/calls/hold-slayer \
-H "Authorization: Bearer $API_TOKEN" \ -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{ -d '{
"number": "+18005551234", "number": "+18005551234",
@@ -264,7 +289,7 @@ curl -X PATCH http://localhost:8000/api/v1/routing/devices/dev_abc123/dnd \
### WebSocket — Real-Time Events ### WebSocket — Real-Time Events
```javascript ```javascript
const ws = new WebSocket(`ws://localhost:8000/ws/events?token=${API_TOKEN}`); const ws = new WebSocket(`ws://localhost:8000/ws/events?token=${token}`);
ws.onmessage = (msg) => { ws.onmessage = (msg) => {
const event = JSON.parse(msg.data); const event = JSON.parse(msg.data);
// event.type: "human_detected", "hold_detected", "ivr_step", etc. // event.type: "human_detected", "hold_detected", "ivr_step", etc.
@@ -276,11 +301,12 @@ ws.onmessage = (msg) => {
### MCP — AI Assistant Integration ### MCP — AI Assistant Integration
The MCP server is served over **streamable HTTP at `/mcp/`** (note the The MCP server is served over **streamable HTTP at `/mcp/`** (note the
trailing slash) and authenticates with the same bearer token: trailing slash) and authenticates with an owner-minted Personal Access Token
(mint one from the dashboard's *API Tokens* menu — it starts with `hs_pat_`):
```bash ```bash
claude mcp add hold-slayer --transport http http://localhost:8000/mcp/ \ claude mcp add hold-slayer --transport http http://localhost:8000/mcp/ \
--header "Authorization: Bearer $API_TOKEN" --header "Authorization: Bearer hs_pat_..."
``` ```
It exposes 15 tools and 3 resources (`gateway://status`, It exposes 15 tools and 3 resources (`gateway://status`,
@@ -333,7 +359,14 @@ All configuration is via environment variables (see `.env.example`):
| Variable | Description | Default | | Variable | Description | Default |
|----------|-------------|---------| |----------|-------------|---------|
| `DATABASE_URL` | PostgreSQL connection string | — (required) | | `DATABASE_URL` | PostgreSQL connection string | — (required) |
| `API_TOKEN` | Static bearer token for REST/WS/MCP | — (required unless `HOST=127.0.0.1`) | | `CASDOOR_ENABLED` | Enable Casdoor SSO (false → dev-owner, loopback only) | `false` |
| `CASDOOR_ENDPOINT` | Casdoor base URL | `https://id.ouranos.helu.ca` |
| `CASDOOR_CLIENT_ID` | Casdoor application client ID | — (required if SSO on) |
| `CASDOOR_CLIENT_SECRET` | Casdoor application client secret | — (required if SSO on) |
| `CASDOOR_ORG_NAME` | Casdoor organization | `heluca` |
| `CASDOOR_APP_NAME` | Casdoor application name | — |
| `OWNER_NAME` | Casdoor username of the single operator (owner) | — (required if SSO on) |
| `PUBLIC_BASE_URL` | Public base URL for OAuth discovery (else derived from headers) | — |
| `MAX_CONCURRENT_CALLS` | Cap on simultaneous outbound calls | `4` | | `MAX_CONCURRENT_CALLS` | Cap on simultaneous outbound calls | `4` |
| `SIP_TRUNK_HOST` | Your SIP provider hostname | — | | `SIP_TRUNK_HOST` | Your SIP provider hostname | — |
| `SIP_TRUNK_USERNAME` | SIP auth username | — | | `SIP_TRUNK_USERNAME` | SIP auth username | — |
@@ -408,13 +441,13 @@ Full documentation is in [`/docs`](docs/README.md):
### Phase 4: Production Hardening 🚧 ### Phase 4: Production Hardening 🚧
- [x] Alembic database migrations (baseline + upgrade-on-boot) - [x] Alembic database migrations (baseline + upgrade-on-boot)
- [x] API authentication — static bearer token across REST/WS/MCP - [x] API authentication — Casdoor SSO (browser JWT) + owner-minted PATs, owner-only across REST/WS/MCP
- [x] Emergency-number guard + concurrent-call cap on outbound calls - [x] Emergency-number guard + concurrent-call cap on outbound calls
- [ ] Rate limiting on API endpoints - [ ] Rate limiting on API endpoints
- [ ] Structured JSON logging - [ ] Structured JSON logging
- [x] Honest /health — engine mode, DB ping, trunk registration, STT/TTS availability - [x] Honest /health — engine mode, DB ping, trunk registration, STT/TTS availability
- [ ] Graceful degradation (classifier works without STT, etc.) - [ ] Graceful degradation (classifier works without STT, etc.)
- [ ] Docker Compose (Hold Slayer + PostgreSQL) - [x] Docker Compose (Hold Slayer + PostgreSQL)
### Phase 5: Additional Services 🚧 ### Phase 5: Additional Services 🚧

191
api/auth.py Normal file
View 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)

View File

@@ -1,12 +1,12 @@
""" """
API Dependencies — Shared dependency injection for all routes. API Dependencies — Shared dependency injection for all routes.
Auth is not here: the owner gate lives in `auth.py` (`get_current_owner` /
`OwnerUser`), applied as a router-level dependency in main.py.
""" """
import secrets from fastapi import HTTPException, Request
from fastapi import Header, HTTPException, Query, Request
from config import get_settings
from core.gateway import AIPSTNGateway from core.gateway import AIPSTNGateway
@@ -24,31 +24,3 @@ def get_routing_service(request: Request):
if routing is None: if routing is None:
raise HTTPException(status_code=503, detail="Routing service not ready") raise HTTPException(status_code=503, detail="Routing service not ready")
return routing return routing
def require_token(
authorization: str | None = Header(default=None),
token: str | None = Query(default=None),
) -> None:
"""
Enforce the static bearer token (API_TOKEN) on REST routes.
A `token` query parameter is accepted alongside the Authorization
header for clients that can't set headers — <audio>/<a> elements
fetching recordings — matching the WebSocket convention.
An empty configured token disables auth; startup refuses that
combination unless the server is bound to loopback.
"""
expected = get_settings().api_token.get_secret_value()
if not expected:
return
supplied = token or ""
if authorization and authorization.lower().startswith("bearer "):
supplied = authorization[7:]
if not secrets.compare_digest(supplied, expected):
raise HTTPException(
status_code=401,
detail="Missing or invalid bearer token",
headers={"WWW-Authenticate": "Bearer"},
)

107
api/tokens.py Normal file
View 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()

View File

@@ -1,13 +1,11 @@
"""WebSocket API — Real-time call events and audio classification stream.""" """WebSocket API — Real-time call events and audio classification stream."""
import asyncio
import logging import logging
import secrets
from fastapi import APIRouter, WebSocket, WebSocketDisconnect from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from api.deps import get_gateway from auth import is_owner, resolve_from_header_or_query
from config import get_settings from db.database import session_scope
from models.events import EventType, GatewayEvent from models.events import EventType, GatewayEvent
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -17,21 +15,21 @@ router = APIRouter()
async def _authorize(websocket: WebSocket) -> bool: async def _authorize(websocket: WebSocket) -> bool:
""" """
Check the static bearer token before accepting the socket. Require the owner before accepting the socket.
Browsers can't set headers on WebSocket connects, so a `token` Browsers can't set headers on WebSocket connects, so the Casdoor JWT (or
query parameter is accepted alongside the Authorization header. a PAT) is accepted on the `?token=` query param alongside the Authorization
header — the same narrow fallback the recording download uses. In dev mode
the owner resolves tokenlessly. A non-owner or absent credential closes the
socket with code 4401.
""" """
token = get_settings().api_token.get_secret_value() q_token = websocket.query_params.get("token")
if not token: auth_header = websocket.headers.get("authorization")
async with session_scope() as session:
user = await resolve_from_header_or_query(session, auth_header, q_token)
if user is not None and is_owner(user):
return True return True
supplied = websocket.query_params.get("token", "") await websocket.close(code=4401, reason="Owner authentication required")
auth = websocket.headers.get("authorization", "")
if auth.lower().startswith("bearer "):
supplied = auth[7:]
if secrets.compare_digest(supplied, token):
return True
await websocket.close(code=4401, reason="Missing or invalid bearer token")
return False return False

385
auth.py Normal file
View 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)]

View File

@@ -89,6 +89,25 @@ class TTSSettings(BaseSettings):
sample_rate: int = 16000 sample_rate: int = 16000
class CasdoorSettings(BaseSettings):
"""Casdoor SSO (OIDC) configuration.
When `enabled` is true the browser authenticates via Casdoor and every
surface is gated to the owner; the SDK is used only for the OAuth2 code
exchange in the /auth/callback route (JWTs are validated against the
endpoint's JWKS). When false, the app runs in dev-owner mode (loopback only).
"""
model_config = SettingsConfigDict(env_prefix="CASDOOR_", env_file=".env", extra="ignore")
enabled: bool = False
endpoint: str = "https://id.ouranos.helu.ca"
client_id: str = ""
client_secret: SecretStr = SecretStr("")
org_name: str = "heluca"
app_name: str = ""
class ReceptionistSettings(BaseSettings): class ReceptionistSettings(BaseSettings):
"""AI Receptionist behavior settings.""" """AI Receptionist behavior settings."""
@@ -126,9 +145,14 @@ class Settings(BaseSettings):
debug: bool = False debug: bool = False
log_level: str = "info" log_level: str = "info"
# Auth — one static bearer token shared by REST, WebSocket, and MCP. # Auth — Casdoor SSO for the browser + owner-minted PATs for MCP/CLI,
# Empty disables auth, which is only permitted on loopback binds. # gated to a single owner. `owner_name` is the Casdoor username that owns
api_token: SecretStr = SecretStr("") # this gateway (everyone else gets 403). `public_base_url` seeds the OAuth
# discovery URLs; blank derives them from request headers. Both cross-cut
# every surface, so they live on the root model (like DATABASE_URL); the
# Casdoor connection knobs live under the CASDOOR_ prefix.
owner_name: str = ""
public_base_url: str = ""
# Outbound-call safety cap (REST + MCP make_call) # Outbound-call safety cap (REST + MCP make_call)
max_concurrent_calls: int = 4 max_concurrent_calls: int = 4
@@ -150,6 +174,7 @@ class Settings(BaseSettings):
hold_slayer: HoldSlayerSettings = Field(default_factory=HoldSlayerSettings) hold_slayer: HoldSlayerSettings = Field(default_factory=HoldSlayerSettings)
tts: TTSSettings = Field(default_factory=TTSSettings) tts: TTSSettings = Field(default_factory=TTSSettings)
receptionist: ReceptionistSettings = Field(default_factory=ReceptionistSettings) receptionist: ReceptionistSettings = Field(default_factory=ReceptionistSettings)
casdoor: CasdoorSettings = Field(default_factory=CasdoorSettings)
# Singleton # Singleton

View File

@@ -13,6 +13,7 @@
"@sveltejs/vite-plugin-svelte": "^5.0.3", "@sveltejs/vite-plugin-svelte": "^5.0.3",
"@tailwindcss/vite": "^4.1.3", "@tailwindcss/vite": "^4.1.3",
"@types/node": "^25.8.0", "@types/node": "^25.8.0",
"daisyui": "^5.0.0",
"svelte": "^5.25.3", "svelte": "^5.25.3",
"svelte-check": "^4.1.4", "svelte-check": "^4.1.4",
"tailwindcss": "^4.1.3", "tailwindcss": "^4.1.3",
@@ -1262,6 +1263,16 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/daisyui": {
"version": "5.7.0",
"resolved": "https://registry.npmjs.org/daisyui/-/daisyui-5.7.0.tgz",
"integrity": "sha512-2/kYbxaKtv349lPrTyxMKC9SHsyA7fBULMSabJljDE82D079cjqz+UyAzsogWgy4sTs5NDvD000acfcFqbO1XA==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/saadeghi/daisyui?sponsor=1"
}
},
"node_modules/debug": { "node_modules/debug": {
"version": "4.4.3", "version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",

View File

@@ -15,6 +15,7 @@
"@sveltejs/vite-plugin-svelte": "^5.0.3", "@sveltejs/vite-plugin-svelte": "^5.0.3",
"@tailwindcss/vite": "^4.1.3", "@tailwindcss/vite": "^4.1.3",
"@types/node": "^25.8.0", "@types/node": "^25.8.0",
"daisyui": "^5.0.0",
"svelte": "^5.25.3", "svelte": "^5.25.3",
"svelte-check": "^4.1.4", "svelte-check": "^4.1.4",
"tailwindcss": "^4.1.3", "tailwindcss": "^4.1.3",

View File

@@ -1 +1,4 @@
@import 'tailwindcss'; @import 'tailwindcss';
@plugin 'daisyui' {
themes: light --default, dark --prefersdark;
}

View 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();

View File

@@ -1,4 +1,5 @@
import type { import type {
AccessToken,
CallHistoryRow, CallHistoryRow,
CallSummary, CallSummary,
DeviceStatus, DeviceStatus,
@@ -10,19 +11,12 @@ import type {
} from './types'; } from './types';
// --------------------------------------------------------------- // ---------------------------------------------------------------
// Bearer token — one static API_TOKEN shared with REST/WS/MCP. // Authed fetch — the browser holds a Casdoor JWT (or, for scripted use,
// Kept in localStorage; a 401 prompts once and retries. // a PAT) in localStorage via the auth store. On a 401 we attempt one
// silent refresh and retry; a second failure logs out.
// --------------------------------------------------------------- // ---------------------------------------------------------------
const TOKEN_KEY = 'hold-slayer-token'; import { auth, getToken } from './auth.svelte';
function getToken(): string {
return localStorage.getItem(TOKEN_KEY) ?? '';
}
export function setToken(value: string): void {
localStorage.setItem(TOKEN_KEY, value);
}
function withAuth(init: RequestInit): RequestInit { function withAuth(init: RequestInit): RequestInit {
const token = getToken(); const token = getToken();
@@ -36,10 +30,11 @@ function withAuth(init: RequestInit): RequestInit {
async function request(path: string, init: RequestInit = {}): Promise<Response> { async function request(path: string, init: RequestInit = {}): Promise<Response> {
let res = await fetch(path, withAuth(init)); let res = await fetch(path, withAuth(init));
if (res.status === 401) { if (res.status === 401) {
const supplied = window.prompt('Hold Slayer API token (API_TOKEN in .env):'); const refreshed = await auth.trySilentRefresh();
if (supplied !== null && supplied.trim()) { if (refreshed) {
setToken(supplied.trim());
res = await fetch(path, withAuth(init)); res = await fetch(path, withAuth(init));
} else {
auth.setUnauthenticated();
} }
} }
return res; return res;
@@ -89,8 +84,10 @@ export async function fetchTranscript(callId: string): Promise<TranscriptRow[]>
} }
export function recordingUrl(callId: string): string { export function recordingUrl(callId: string): string {
// <audio> can't send headers, so the token rides as a query param // <audio> can't send headers, so the current token (Casdoor JWT, or a PAT)
// (accepted server-side alongside the Authorization header). // rides as a query param — the same narrow fallback the WebSocket uses,
// accepted server-side alongside the Authorization header. The proactive
// refresh timer keeps the stored JWT valid, so it's fresh at click time.
const token = getToken(); const token = getToken();
const suffix = token ? `?token=${encodeURIComponent(token)}` : ''; const suffix = token ? `?token=${encodeURIComponent(token)}` : '';
return `/api/v1/calls/${callId}/recording${suffix}`; return `/api/v1/calls/${callId}/recording${suffix}`;
@@ -137,11 +134,35 @@ export async function setDeviceDnd(deviceId: string, enabled: boolean): Promise<
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`); if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
} }
// ---------------------------------------------------------------
// Personal Access Tokens (owner-only) — for MCP/CLI clients.
// ---------------------------------------------------------------
export async function fetchTokens(): Promise<AccessToken[]> {
return get<AccessToken[]>('/api/v1/tokens');
}
export async function createToken(name: string): Promise<AccessToken> {
const res = await request('/api/v1/tokens', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
});
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
return res.json() as Promise<AccessToken>;
}
export async function revokeToken(tokenId: string): Promise<void> {
const res = await request(`/api/v1/tokens/${tokenId}`, { method: 'DELETE' });
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
}
export function connectEventStream( export function connectEventStream(
onEvent: (e: GatewayEvent) => void, onEvent: (e: GatewayEvent) => void,
onClose: () => void, onClose: () => void,
): () => void { ): () => void {
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
// Browsers can't set headers on WS connects — the token rides as ?token=.
const token = getToken(); const token = getToken();
const suffix = token ? `?token=${encodeURIComponent(token)}` : ''; const suffix = token ? `?token=${encodeURIComponent(token)}` : '';
const ws = new WebSocket(`${proto}//${location.host}/ws/events${suffix}`); const ws = new WebSocket(`${proto}//${location.host}/ws/events${suffix}`);

View 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();

View 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>

View 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>

View 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}

View File

@@ -1,3 +1,23 @@
export interface User {
id: string;
name: string;
display_name: string | null;
email: string | null;
is_owner: boolean;
}
export interface AccessToken {
id: string;
name: string;
token_prefix: string;
created_at: string | null;
last_used_at: string | null;
expires_at: string | null;
revoked_at: string | null;
// Present only in the create response — the plaintext, shown once.
token?: string;
}
export interface GatewayStatus { export interface GatewayStatus {
name: string; name: string;
version: string; version: string;

View File

@@ -2,9 +2,15 @@
import '../app.css'; import '../app.css';
import { page } from '$app/stores'; import { page } from '$app/stores';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { auth } from '$lib/auth.svelte';
import LoginScreen from '$lib/components/LoginScreen.svelte';
import DeniedScreen from '$lib/components/DeniedScreen.svelte';
import TokensModal from '$lib/components/TokensModal.svelte';
let { children } = $props(); let { children } = $props();
let tokensOpen = $state(false);
type ThemeOverride = 'dark' | 'light' | null; type ThemeOverride = 'dark' | 'light' | null;
let override = $state<ThemeOverride>(null); let override = $state<ThemeOverride>(null);
let systemDark = $state(true); let systemDark = $state(true);
@@ -12,7 +18,10 @@
let isDark = $derived(override !== null ? override === 'dark' : systemDark); let isDark = $derived(override !== null ? override === 'dark' : systemDark);
$effect(() => { $effect(() => {
// Keep both theming systems in sync: Tailwind `dark:` variant (.dark class)
// for the existing pages, and DaisyUI `data-theme` for the SSO components.
document.documentElement.classList.toggle('dark', isDark); document.documentElement.classList.toggle('dark', isDark);
document.documentElement.setAttribute('data-theme', isDark ? 'dark' : 'light');
}); });
function toggleTheme() { function toggleTheme() {
@@ -27,6 +36,8 @@
} }
onMount(() => { onMount(() => {
void auth.init();
const mq = window.matchMedia('(prefers-color-scheme: dark)'); const mq = window.matchMedia('(prefers-color-scheme: dark)');
systemDark = mq.matches; systemDark = mq.matches;
@@ -49,40 +60,63 @@
]; ];
</script> </script>
<div class="min-h-screen bg-slate-50 dark:bg-gray-950 text-gray-900 dark:text-gray-100"> {#if auth.status === 'loading'}
<header <div class="fixed inset-0 flex items-center justify-center bg-slate-50 dark:bg-gray-950">
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" <span class="loading loading-spinner loading-lg text-orange-500"></span>
> </div>
<div class="mx-auto max-w-7xl px-4 py-3 flex items-center gap-6"> {:else if auth.status === 'unauthenticated'}
<div class="flex items-center gap-2"> <LoginScreen />
<span class="text-orange-500 text-lg leading-none">🔥</span> {:else if auth.status === 'denied'}
<span class="font-semibold text-gray-900 dark:text-white tracking-tight">Hold Slayer</span> <DeniedScreen />
<span class="text-gray-500 text-sm hidden sm:inline">Gateway</span> {:else}
</div> <div class="min-h-screen bg-slate-50 dark:bg-gray-950 text-gray-900 dark:text-gray-100">
<nav class="flex gap-1 ml-2"> <header
{#each nav as item} 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"
<a >
href={item.href} <div class="mx-auto max-w-7xl px-4 py-3 flex items-center gap-6">
class="px-3 py-1.5 rounded text-sm font-medium transition-colors {$page.url.pathname === <div class="flex items-center gap-2">
item.href <span class="text-orange-500 text-lg leading-none">🔥</span>
? 'bg-orange-600 text-white' <span class="font-semibold text-gray-900 dark:text-white tracking-tight">Hold Slayer</span>
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-800'}" <span class="text-gray-500 text-sm hidden sm:inline">Gateway</span>
</div>
<nav class="flex gap-1 ml-2">
{#each nav as item}
<a
href={item.href}
class="px-3 py-1.5 rounded text-sm font-medium transition-colors {$page.url.pathname ===
item.href
? 'bg-orange-600 text-white'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-800'}"
>
{item.label}
</a>
{/each}
</nav>
<div class="ml-auto flex items-center gap-2">
<button
onclick={toggleTheme}
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'}
> >
{item.label} {isDark ? 'Light' : 'Dark'}
</a> </button>
{/each} <div class="dropdown dropdown-end">
</nav> <button tabindex="0" class="btn btn-ghost btn-sm">
<button {auth.user?.display_name ?? auth.user?.name ?? 'Owner'}
onclick={toggleTheme} </button>
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" <ul class="dropdown-content menu bg-base-200 rounded-box z-20 w-48 p-2 shadow">
title={isDark ? 'Switch to light mode' : 'Switch to dark mode'} <li><button onclick={() => (tokensOpen = true)}>API Tokens</button></li>
> <li><a href="/auth/logout">Sign out</a></li>
{isDark ? 'Light' : 'Dark'} </ul>
</button> </div>
</div> </div>
</header> </div>
</header>
<main class="mx-auto max-w-7xl px-4 py-6"> <main class="mx-auto max-w-7xl px-4 py-6">
{@render children()} {@render children()}
</main> </main>
</div> </div>
<TokensModal bind:open={tokensOpen} />
{/if}

View File

@@ -14,6 +14,7 @@ from sqlalchemy import (
Column, Column,
DateTime, DateTime,
Float, Float,
ForeignKey,
Integer, Integer,
String, String,
Text, Text,
@@ -153,6 +154,48 @@ class RecordingRecord(Base):
return f"<Recording {self.id} call={self.call_id} {self.path}>" return f"<Recording {self.id} call={self.call_id} {self.path}>"
class User(Base):
"""An SSO-provisioned identity. The gateway is owner-only: the single
owner is the user whose `name` matches settings.owner_name; everyone else
is created on first login but reaches nothing (403 on every surface)."""
__tablename__ = "users"
id = Column(String, primary_key=True) # uuid4().hex, set in Python
name = Column(String, nullable=False) # Casdoor username — owner-match key
display_name = Column(String, nullable=True) # Casdoor display name (UI only)
email = Column(String, nullable=True, unique=True)
casdoor_sub = Column(String, nullable=True, unique=True) # OIDC subject claim
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
def __repr__(self) -> str:
return f"<User {self.id} {self.name}>"
class PersonalAccessToken(Base):
"""Long-lived bearer token for API/MCP clients (Claude Desktop, Cline)
that can't refresh a JWT. Plaintext is shown once at creation; only the
SHA-256 hash is persisted. Soft-revoked by setting revoked_at."""
__tablename__ = "personal_access_tokens"
id = Column(String, primary_key=True) # uuid4().hex
user_id = Column(
String, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
name = Column(String, nullable=False)
token_hash = Column(String, nullable=False, unique=True, index=True)
token_prefix = Column(String, nullable=False) # for display, not a secret
created_at = Column(DateTime, default=func.now())
last_used_at = Column(DateTime, nullable=True)
expires_at = Column(DateTime, nullable=True)
revoked_at = Column(DateTime, nullable=True)
def __repr__(self) -> str:
return f"<PersonalAccessToken {self.id} user={self.user_id}>"
# ============================================================ # ============================================================
# Engine & Session # Engine & Session
# ============================================================ # ============================================================

View 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
View 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:

View File

@@ -4,6 +4,25 @@ All configuration is via environment variables, loaded through Pydantic Settings
## Environment Variables ## Environment Variables
### Auth (Casdoor SSO + owner)
The gateway is **owner-only**: the browser signs in via Casdoor (JWT), MCP/CLI
clients use owner-minted PATs, and only `OWNER_NAME` may use any surface. With
`CASDOOR_ENABLED=false` the gateway runs in dev-owner mode — permitted **only** on
a loopback `HOST`. Startup refuses SSO-enabled-with-missing-config and
SSO-disabled-off-loopback.
| Variable | Description | Default | Required |
|----------|-------------|---------|----------|
| `CASDOOR_ENABLED` | Enable Casdoor SSO | `false` | No |
| `CASDOOR_ENDPOINT` | Casdoor base URL | `https://id.ouranos.helu.ca` | If SSO on |
| `CASDOOR_CLIENT_ID` | Casdoor application client ID | — | If SSO on |
| `CASDOOR_CLIENT_SECRET` | Casdoor application client secret | — | If SSO on |
| `CASDOOR_ORG_NAME` | Casdoor organization | `heluca` | No |
| `CASDOOR_APP_NAME` | Casdoor application name | — | No |
| `OWNER_NAME` | Casdoor username of the single operator | — | If SSO on |
| `PUBLIC_BASE_URL` | Public base URL for OAuth discovery (else derived) | — | No |
### SIP Trunk ### SIP Trunk
| Variable | Description | Default | Required | | Variable | Description | Default | Required |

View File

@@ -3,8 +3,11 @@
The MCP (Model Context Protocol) server lets any MCP-compatible AI assistant The MCP (Model Context Protocol) server lets any MCP-compatible AI assistant
control the Hold Slayer gateway. Built with [FastMCP](https://github.com/jlowin/fastmcp), control the Hold Slayer gateway. Built with [FastMCP](https://github.com/jlowin/fastmcp),
it is mounted on the FastAPI app at **`/mcp/`** (trailing slash) over it is mounted on the FastAPI app at **`/mcp/`** (trailing slash) over
**streamable HTTP** and authenticates with the same static bearer token as the **streamable HTTP** and authenticates with an owner-minted Personal Access Token
REST API and WebSocket. (`hs_pat_…`) — the same owner-only auth as the REST API and WebSocket. Auth is
enforced by an ASGI guard (`_owner_only_mcp` in `main.py`) that resolves the
bearer to the owner; a Casdoor JWT also works, but MCP clients can't refresh one,
so a PAT is the intended credential.
## Overview ## Overview
@@ -158,7 +161,7 @@ Claude Code:
```bash ```bash
claude mcp add hold-slayer --transport http http://localhost:8000/mcp/ \ claude mcp add hold-slayer --transport http http://localhost:8000/mcp/ \
--header "Authorization: Bearer $API_TOKEN" --header "Authorization: Bearer hs_pat_..."
``` ```
Generic MCP client configuration: Generic MCP client configuration:
@@ -168,7 +171,7 @@ Generic MCP client configuration:
"mcpServers": { "mcpServers": {
"hold-slayer": { "hold-slayer": {
"url": "http://localhost:8000/mcp/", "url": "http://localhost:8000/mcp/",
"headers": {"Authorization": "Bearer <API_TOKEN>"} "headers": {"Authorization": "Bearer hs_pat_..."}
} }
} }
} }

275
main.py
View File

@@ -12,17 +12,21 @@ Usage:
""" """
import logging import logging
import secrets
import sys import sys
import time
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from fastapi import Depends, FastAPI from fastapi import Depends, FastAPI, Request
from fastapi.responses import JSONResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from api import call_flows, call_history, calls, devices, routing, websocket from api import auth as auth_router
from api.deps import require_token from api import call_flows, call_history, calls, devices, routing, tokens, websocket
from auth import get_current_owner, init_jwks_client, is_owner, resolve_from_header_or_query
from config import Settings, get_settings from config import Settings, get_settings
from core.gateway import AIPSTNGateway, build_sip_engine from core.gateway import AIPSTNGateway, build_sip_engine
from db.database import close_db, init_db from db.database import close_db, init_db, session_scope
from mcp_server.server import create_mcp_server from mcp_server.server import create_mcp_server
from models.call import CallMode from models.call import CallMode
from services.audio_classifier import AudioClassifier from services.audio_classifier import AudioClassifier
@@ -106,16 +110,38 @@ def _check_startup_config(settings: Settings) -> None:
) )
sys.exit(1) sys.exit(1)
token = settings.api_token.get_secret_value() loopback = settings.host in ("127.0.0.1", "localhost", "::1")
if not token and settings.host not in ("127.0.0.1", "localhost", "::1"): if settings.casdoor.enabled:
c = settings.casdoor
missing = [
name
for name, val in (
("CASDOOR_ENDPOINT", c.endpoint),
("CASDOOR_CLIENT_ID", c.client_id),
("CASDOOR_CLIENT_SECRET", c.client_secret.get_secret_value()),
("OWNER_NAME", settings.owner_name),
)
if not val
]
if missing:
logger.critical(
"\n"
"❌ 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( logger.critical(
"\n" "\n"
"API_TOKEN is not set but HOST binds beyond loopback " "CASDOOR_ENABLED=false but HOST binds beyond loopback "
f"({settings.host}).\n" f"({settings.host}).\n"
" Every surface (REST, WebSocket, MCP make_call) would be open " " Every surface (REST, WebSocket, MCP make_call) would resolve "
"to the network.\n" "to the dev owner — open to the network.\n"
" Set API_TOKEN in .env (e.g. `openssl rand -hex 32`), or set " " Set CASDOOR_ENABLED=true (with the Casdoor + OWNER_NAME settings), "
"HOST=127.0.0.1 for tokenless local development." "or set HOST=127.0.0.1 for tokenless local development."
) )
sys.exit(1) sys.exit(1)
@@ -126,6 +152,10 @@ async def lifespan(app: FastAPI):
settings = get_settings() settings = get_settings()
_check_startup_config(settings) _check_startup_config(settings)
# Prefetch Casdoor's JWKS so the first authenticated request doesn't pay
# the network round-trip (no-op when SSO is disabled).
init_jwks_client()
# The MCP session manager lives in the mounted sub-app's lifespan; # The MCP session manager lives in the mounted sub-app's lifespan;
# without entering it, every /mcp request 500s. # without entering it, every /mcp request 500s.
async with mcp_http_app.lifespan(app): async with mcp_http_app.lifespan(app):
@@ -214,7 +244,11 @@ async def lifespan(app: FastAPI):
display_port = int(sys.argv[i + 1]) display_port = int(sys.argv[i + 1])
except ValueError: except ValueError:
pass pass
auth_state = "bearer token required" if settings.api_token.get_secret_value() else "auth disabled (loopback)" auth_state = (
f"Casdoor SSO (owner: {settings.owner_name or 'UNSET'})"
if settings.casdoor.enabled
else "dev-owner (loopback, no auth)"
)
logger.info(f" API: http://{display_host}:{display_port} [{auth_state}]") logger.info(f" API: http://{display_host}:{display_port} [{auth_state}]")
logger.info(f" API Docs: http://{display_host}:{display_port}/docs") logger.info(f" API Docs: http://{display_host}:{display_port}/docs")
logger.info(f" WebSocket: ws://{display_host}:{display_port}/ws/events") logger.info(f" WebSocket: ws://{display_host}:{display_port}/ws/events")
@@ -236,12 +270,92 @@ def _get_gateway_instance() -> AIPSTNGateway | None:
return getattr(app.state, "gateway", None) return getattr(app.state, "gateway", None)
mcp = create_mcp_server( mcp = create_mcp_server(_get_gateway_instance)
_get_gateway_instance,
api_token=get_settings().api_token.get_secret_value(),
)
mcp_http_app = mcp.http_app(path="/") mcp_http_app = mcp.http_app(path="/")
def _public_base_url(scope_or_request) -> str:
"""Resolve this service's public base URL (scheme + host), no trailing slash.
Precedence: explicit PUBLIC_BASE_URL override → X-Forwarded-Proto/Host
(nginx/HAProxy) → Host header → localhost. Accepts either a FastAPI
``Request`` or a raw ASGI ``scope`` so the ASGI MCP guard and the FastAPI
discovery endpoints share one implementation.
"""
settings = get_settings()
if settings.public_base_url:
return settings.public_base_url.rstrip("/")
if hasattr(scope_or_request, "headers"):
headers = {k.lower(): v for k, v in scope_or_request.headers.items()}
default_scheme = getattr(scope_or_request.url, "scheme", None) or "http"
else:
headers = {
k.decode("latin-1").lower(): v.decode("latin-1")
for k, v in scope_or_request.get("headers", [])
}
default_scheme = scope_or_request.get("scheme", "http")
proto = (headers.get("x-forwarded-proto") or default_scheme).split(",", 1)[0].strip()
host = (headers.get("x-forwarded-host") or headers.get("host") or "localhost")
host = host.split(",", 1)[0].strip()
return f"{proto}://{host}"
def _owner_only_mcp(inner_app):
"""Wrap the mounted MCP ASGI app to require an owner bearer token.
MCP tools reach state via the FastMCP lifespan context, not FastAPI's
dependency system, so ``Depends`` can't gate ``/mcp``. Instead we read the
ASGI scope's ``Authorization`` header, resolve it (Casdoor JWT or PAT)
against a fresh DB session, and short-circuit non-owner requests with
401/403. In dev mode this resolves to the dev owner, so local development
keeps working without a token.
"""
async def _send_status(send, scope, status: int, body: bytes) -> None:
base = _public_base_url(scope)
resource_metadata_url = f"{base}/.well-known/oauth-protected-resource/mcp"
await send(
{
"type": "http.response.start",
"status": status,
"headers": [
(b"content-type", b"application/json"),
(
b"www-authenticate",
f'Bearer realm="hold-slayer-mcp", '
f'resource_metadata="{resource_metadata_url}"'.encode(),
),
],
}
)
await send({"type": "http.response.body", "body": body})
async def app(scope, receive, send):
if scope["type"] != "http":
await inner_app(scope, receive, send)
return
authorization = None
for name, value in scope.get("headers", []):
if name == b"authorization":
authorization = value.decode("latin-1")
break
async with session_scope() as session:
user = await resolve_from_header_or_query(session, authorization, None)
if user is None:
await _send_status(send, scope, 401, b'{"detail":"Not authenticated"}')
return
if not is_owner(user):
await _send_status(send, scope, 403, b'{"detail":"Owner access required"}')
return
await inner_app(scope, receive, send)
return app
app = FastAPI( app = FastAPI(
title="Hold Slayer Gateway", title="Hold Slayer Gateway",
description=( description=(
@@ -258,9 +372,13 @@ app = FastAPI(
) )
# === API Routes === # === API Routes ===
# Every protected surface is gated to the owner (Casdoor JWT or PAT). The
# unauthenticated OIDC endpoints live on the /auth router (login/callback/…).
# call_history must register before calls: both live under /api/v1/calls and # call_history must register before calls: both live under /api/v1/calls and
# calls' GET /{call_id} would otherwise capture the literal path "history". # calls' GET /{call_id} would otherwise capture the literal path "history".
_auth = [Depends(require_token)] _auth = [Depends(get_current_owner)]
app.include_router(auth_router.router)
app.include_router(tokens.router, dependencies=_auth)
app.include_router( app.include_router(
call_history.router, prefix="/api/v1/calls", tags=["Call History"], dependencies=_auth call_history.router, prefix="/api/v1/calls", tags=["Call History"], dependencies=_auth
) )
@@ -270,11 +388,128 @@ app.include_router(
) )
app.include_router(devices.router, prefix="/api/v1/devices", tags=["Devices"], dependencies=_auth) app.include_router(devices.router, prefix="/api/v1/devices", tags=["Devices"], dependencies=_auth)
app.include_router(routing.router, prefix="/api/v1/routing", tags=["Routing"], dependencies=_auth) app.include_router(routing.router, prefix="/api/v1/routing", tags=["Routing"], dependencies=_auth)
# WebSocket endpoints check the token themselves (query param or header) # WebSocket endpoints check the owner themselves (query param or header)
app.include_router(websocket.router, prefix="/ws", tags=["WebSocket"]) app.include_router(websocket.router, prefix="/ws", tags=["WebSocket"])
# === MCP (streamable HTTP; clients connect to /mcp/ with the bearer token) === # === MCP (streamable HTTP; clients connect to /mcp/ with a PAT or JWT) ===
app.mount("/mcp", mcp_http_app) # The ASGI guard resolves the bearer to the owner before the inner app runs.
app.mount("/mcp", _owner_only_mcp(mcp_http_app))
# In-memory store of dynamically registered OAuth clients (RFC 7591). MCP
# clients re-register each session; the real gate is the bearer token.
_registered_clients: dict[str, dict] = {}
@app.get("/.well-known/oauth-protected-resource", include_in_schema=False)
@app.get("/.well-known/oauth-protected-resource/mcp", include_in_schema=False)
async def oauth_protected_resource_metadata(request: Request):
"""RFC 9728 Protected Resource Metadata — points MCP clients at the AS.
``resource`` advertises ``{base}/mcp`` (not the bare origin) because recent
``mcp-remote`` versions verify it matches the URL they connected to.
"""
base = _public_base_url(request)
return JSONResponse(
{
"resource": f"{base}/mcp",
"authorization_servers": [base],
"bearer_methods_supported": ["header"],
"resource_documentation": f"{base}/docs",
}
)
@app.get("/.well-known/oauth-authorization-server", include_in_schema=False)
async def oauth_authorization_server_metadata(request: Request):
"""RFC 8414 Authorization Server Metadata.
When Casdoor SSO is on, the real authorization server is Casdoor — advertise
its endpoints. In dev mode there's no OAuth server; clients supply a PAT
directly in their MCP configuration.
"""
base = _public_base_url(request)
settings = get_settings()
if settings.casdoor.enabled:
casdoor_base = settings.casdoor.endpoint.rstrip("/")
return JSONResponse(
{
"issuer": casdoor_base,
"authorization_endpoint": f"{casdoor_base}/login/oauth/authorize",
"token_endpoint": f"{casdoor_base}/api/login/oauth/access_token",
"jwks_uri": f"{casdoor_base}/.well-known/jwks",
"registration_endpoint": f"{base}/register",
"response_types_supported": ["code"],
"grant_types_supported": ["authorization_code"],
"token_endpoint_auth_methods_supported": ["client_secret_post"],
"scopes_supported": ["openid", "profile", "email"],
}
)
return JSONResponse(
{
"issuer": base,
"authorization_endpoint": f"{base}/auth/login",
"token_endpoint": f"{base}/auth/callback",
"registration_endpoint": f"{base}/register",
"response_types_supported": ["code"],
"grant_types_supported": ["authorization_code"],
}
)
@app.post("/register", include_in_schema=False)
async def oauth_dynamic_registration(request: Request):
"""RFC 7591 Dynamic Client Registration — accept any well-formed request.
Registered clients are held in memory (ephemeral); the real security gate
is the bearer token (PAT or Casdoor JWT) on every /mcp request.
"""
try:
body = await request.json()
except Exception:
return JSONResponse(
status_code=400,
content={
"error": "invalid_client_metadata",
"error_description": "Request body must be valid JSON.",
},
)
redirect_uris = body.get("redirect_uris")
if not redirect_uris or not isinstance(redirect_uris, list):
return JSONResponse(
status_code=400,
content={
"error": "invalid_redirect_uri",
"error_description": "redirect_uris is required and must be a non-empty list.",
},
)
client_id = secrets.token_hex(16)
now = int(time.time())
_registered_clients[client_id] = {
"client_id": client_id,
"client_id_issued_at": now,
"redirect_uris": redirect_uris,
"grant_types": body.get("grant_types", ["authorization_code"]),
"response_types": body.get("response_types", ["code"]),
"token_endpoint_auth_method": body.get("token_endpoint_auth_method", "none"),
"client_name": body.get("client_name"),
"scope": body.get("scope"),
}
logger.info("Registered OAuth client %s (name=%s)", client_id, body.get("client_name"))
return JSONResponse(
status_code=201,
content={
"client_id": client_id,
"client_id_issued_at": now,
"redirect_uris": redirect_uris,
"grant_types": _registered_clients[client_id]["grant_types"],
"response_types": _registered_clients[client_id]["response_types"],
"token_endpoint_auth_method": _registered_clients[client_id][
"token_endpoint_auth_method"
],
},
)
@app.get("/api/v1/status", tags=["System"], dependencies=_auth) @app.get("/api/v1/status", tags=["System"], dependencies=_auth)

View File

@@ -27,7 +27,6 @@ logger = logging.getLogger(__name__)
def create_mcp_server( def create_mcp_server(
get_gateway: Callable[[], Optional[AIPSTNGateway]], get_gateway: Callable[[], Optional[AIPSTNGateway]],
api_token: str = "",
) -> FastMCP: ) -> FastMCP:
""" """
Create and configure the MCP server with all tools and resources. Create and configure the MCP server with all tools and resources.
@@ -35,14 +34,13 @@ def create_mcp_server(
The gateway is resolved lazily per request via `get_gateway` so the The gateway is resolved lazily per request via `get_gateway` so the
server can be mounted at app construction, before the lifespan has server can be mounted at app construction, before the lifespan has
started the gateway. started the gateway.
Auth is **not** configured on the FastMCP instance: the mounted `/mcp`
ASGI app is gated by `_owner_only_mcp` in main.py, which resolves a
Casdoor JWT or PAT to the owner (one resolver shared with REST/WS) —
so PATs and JWTs both work here with a single code path.
""" """
auth = None mcp = FastMCP("Hold Slayer Gateway", auth=None)
if api_token:
from fastmcp.server.auth.providers.jwt import StaticTokenVerifier
auth = StaticTokenVerifier(tokens={api_token: {"client_id": "hold-slayer"}})
mcp = FastMCP("Hold Slayer Gateway", auth=auth)
def require_gateway() -> AIPSTNGateway: def require_gateway() -> AIPSTNGateway:
gateway = get_gateway() gateway = get_gateway()

View File

@@ -32,9 +32,13 @@ dependencies = [
# HTTP client (for Speaches STT) # HTTP client (for Speaches STT)
"httpx>=0.28.0", "httpx>=0.28.0",
# MCP server (3.x — http_app + StaticTokenVerifier) # MCP server (3.x — http_app + ASGI owner guard)
"fastmcp>=3.0.0", "fastmcp>=3.0.0",
# Auth: Casdoor SSO (OAuth code exchange) + RS256 JWT validation
"casdoor>=1.0",
"pyjwt[crypto]>=2.8",
# Utilities # Utilities
"python-slugify>=8.0.0", "python-slugify>=8.0.0",
] ]
@@ -47,6 +51,8 @@ dev = [
"httpx>=0.28.0", "httpx>=0.28.0",
"ruff>=0.8.0", "ruff>=0.8.0",
"aiosqlite>=0.22.0", "aiosqlite>=0.22.0",
# Mint RS256 JWTs in tests without a live Casdoor
"cryptography>=42.0",
] ]
[tool.setuptools.packages.find] [tool.setuptools.packages.find]

View File

@@ -1,24 +1,53 @@
""" """
API surface tests — bearer-token enforcement and route registration order. API surface tests — owner enforcement and route registration order.
The app is exercised without its lifespan: auth runs before any handler, The app is exercised without its lifespan: auth runs before any handler,
so a 503 ("Gateway not initialized") proves the token was accepted. so a 503 ("Gateway not initialized") proves the caller was accepted as
owner. Auth internals (JWT/PAT resolution) are covered in test_auth.py;
here we assert the routers are gated and the routes register in the right
order. In dev-owner mode (SSO disabled) a tokenless request is the owner.
""" """
import httpx import httpx
import pytest import pytest
from pydantic import SecretStr from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.pool import StaticPool
from starlette.routing import Match from starlette.routing import Match
import db.database as dbmod
import main import main
from config import get_settings from config import get_settings
from db.database import Base
TOKEN = "test-token-for-suite"
@pytest.fixture @pytest.fixture
def token_enabled(monkeypatch): async def mem_db(monkeypatch):
monkeypatch.setattr(get_settings(), "api_token", SecretStr(TOKEN)) engine = create_async_engine(
"sqlite+aiosqlite:///:memory:",
poolclass=StaticPool,
connect_args={"check_same_thread": False},
)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
factory = async_sessionmaker(engine, expire_on_commit=False)
monkeypatch.setattr(dbmod, "_engine", engine)
monkeypatch.setattr(dbmod, "_session_factory", factory)
yield factory
await engine.dispose()
@pytest.fixture
def dev_owner(monkeypatch):
"""SSO disabled — every request resolves to the dev owner."""
monkeypatch.setattr(get_settings().casdoor, "enabled", False)
@pytest.fixture
def sso_enabled(monkeypatch):
"""SSO enabled with no credentials supplied → 401 on protected routes."""
monkeypatch.setattr(get_settings().casdoor, "enabled", True)
monkeypatch.setattr(get_settings().casdoor, "endpoint", "https://id.example.test")
monkeypatch.setattr(get_settings(), "owner_name", "owner@example.test")
@pytest.fixture @pytest.fixture
@@ -28,45 +57,34 @@ async def client():
yield c yield c
class TestBearerToken: class TestOwnerGate:
async def test_missing_token_rejected(self, token_enabled, client): async def test_dev_owner_reaches_handler(self, dev_owner, mem_db, client):
resp = await client.get("/api/v1/calls/active")
assert resp.status_code == 503 # dev-owner accepted; handler 503s (no lifespan)
async def test_sso_missing_credentials_rejected(self, sso_enabled, mem_db, client):
resp = await client.get("/api/v1/calls/active") resp = await client.get("/api/v1/calls/active")
assert resp.status_code == 401 assert resp.status_code == 401
assert resp.headers["www-authenticate"] == "Bearer" assert resp.headers["www-authenticate"] == "Bearer"
async def test_wrong_token_rejected(self, token_enabled, client): async def test_all_api_routers_protected(self, sso_enabled, mem_db, client):
resp = await client.get( for path in (
"/api/v1/calls/active", headers={"Authorization": "Bearer wrong"} "/api/v1/calls/active",
) "/api/v1/call-flows/",
assert resp.status_code == 401 "/api/v1/devices/",
"/api/v1/routing/rules",
async def test_valid_token_reaches_handler(self, token_enabled, client): "/api/v1/calls/history",
resp = await client.get( "/api/v1/tokens",
"/api/v1/calls/active", headers={"Authorization": f"Bearer {TOKEN}"} ):
)
# No lifespan ran, so the handler itself 503s — auth was accepted
assert resp.status_code == 503
async def test_empty_token_disables_auth(self, monkeypatch, client):
monkeypatch.setattr(get_settings(), "api_token", SecretStr(""))
resp = await client.get("/api/v1/calls/active")
assert resp.status_code == 503
async def test_query_param_token_accepted(self, token_enabled, client):
"""<audio>/<a> elements can't set headers — ?token= must work."""
resp = await client.get(f"/api/v1/calls/active?token={TOKEN}")
assert resp.status_code == 503 # auth accepted, handler 503s (no lifespan)
async def test_wrong_query_param_token_rejected(self, token_enabled, client):
resp = await client.get("/api/v1/calls/active?token=wrong")
assert resp.status_code == 401
async def test_all_api_routers_protected(self, token_enabled, client):
for path in ("/api/v1/calls/active", "/api/v1/call-flows/", "/api/v1/devices/",
"/api/v1/routing/rules", "/api/v1/calls/history"):
resp = await client.get(path) resp = await client.get(path)
assert resp.status_code == 401, path assert resp.status_code == 401, path
async def test_auth_routes_are_public(self, sso_enabled, mem_db, client):
"""The OIDC endpoints must be reachable without a token."""
# /auth/me with no token → 401 (not 403); /auth/login → redirect to Casdoor
resp = await client.get("/auth/login", follow_redirects=False)
assert resp.status_code in (302, 307)
class TestRouteOrder: class TestRouteOrder:
def _resolve(self, path: str): def _resolve(self, path: str):

239
tests/test_auth.py Normal file
View 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

View File

@@ -6,12 +6,32 @@ Uses the FastMCP in-memory client (no network, no mounted app).
import pytest import pytest
from fastmcp import Client from fastmcp import Client
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.pool import StaticPool
from config import Settings import db.database as dbmod
from config import Settings, get_settings
from core.dial_plan import is_emergency_number from core.dial_plan import is_emergency_number
from core.gateway import AIPSTNGateway from core.gateway import AIPSTNGateway
from db.database import Base
from mcp_server.server import create_mcp_server from mcp_server.server import create_mcp_server
@pytest.fixture
async def mem_db(monkeypatch):
engine = create_async_engine(
"sqlite+aiosqlite:///:memory:",
poolclass=StaticPool,
connect_args={"check_same_thread": False},
)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
factory = async_sessionmaker(engine, expire_on_commit=False)
monkeypatch.setattr(dbmod, "_engine", engine)
monkeypatch.setattr(dbmod, "_session_factory", factory)
yield factory
await engine.dispose()
EXPECTED_TOOLS = { EXPECTED_TOOLS = {
"make_call", "make_call",
"get_call_status", "get_call_status",
@@ -43,11 +63,64 @@ class TestToolSurface:
tools = {t.name for t in await client.list_tools()} tools = {t.name for t in await client.list_tools()}
assert tools == EXPECTED_TOOLS assert tools == EXPECTED_TOOLS
async def test_auth_configured_when_token_given(self): async def test_no_fastmcp_auth_configured(self):
assert create_mcp_server(lambda: None, api_token="sekrit").auth is not None # Auth for /mcp is enforced by the ASGI _owner_only_mcp guard in
# main.py, not on the FastMCP instance itself.
assert create_mcp_server(lambda: None).auth is None assert create_mcp_server(lambda: None).auth is None
class TestMcpOwnerGuard:
"""The ASGI wrapper gates /mcp before the inner app runs."""
def _wrapped(self):
import main
calls = {"inner": 0}
async def inner(scope, receive, send):
calls["inner"] += 1
await send({"type": "http.response.start", "status": 200, "headers": []})
await send({"type": "http.response.body", "body": b"ok"})
return main._owner_only_mcp(inner), calls
async def _run(self, app, headers):
sent = []
async def receive():
return {"type": "http.request", "body": b"", "more_body": False}
async def send(msg):
sent.append(msg)
scope = {
"type": "http",
"method": "POST",
"path": "/mcp/",
"headers": headers,
"query_string": b"",
}
await app(scope, receive, send)
status = next(m["status"] for m in sent if m["type"] == "http.response.start")
return status
async def test_missing_token_401(self, monkeypatch, mem_db):
monkeypatch.setattr(get_settings().casdoor, "enabled", True)
monkeypatch.setattr(get_settings().casdoor, "endpoint", "https://id.example.test")
monkeypatch.setattr(get_settings(), "owner_name", "owner@example.test")
app, calls = self._wrapped()
status = await self._run(app, headers=[])
assert status == 401
assert calls["inner"] == 0
async def test_dev_owner_passes(self, monkeypatch, mem_db):
monkeypatch.setattr(get_settings().casdoor, "enabled", False)
app, calls = self._wrapped()
status = await self._run(app, headers=[])
assert status == 200
assert calls["inner"] == 1
class TestGatewayResolution: class TestGatewayResolution:
async def test_tool_errors_cleanly_before_gateway_ready(self): async def test_tool_errors_cleanly_before_gateway_ready(self):
mcp = create_mcp_server(lambda: None) mcp = create_mcp_server(lambda: None)

View 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

View File

@@ -9,7 +9,6 @@ through the shared data layer in services/call_persistence.py.
import httpx import httpx
import pytest import pytest
from pydantic import SecretStr
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.pool import StaticPool from sqlalchemy.pool import StaticPool
@@ -116,7 +115,9 @@ class TestInboundPolicy:
@pytest.fixture @pytest.fixture
async def client(monkeypatch): async def client(monkeypatch):
monkeypatch.setattr(get_settings(), "api_token", SecretStr("")) # Dev-owner mode: tokenless requests resolve to the owner. auth's DB
# session comes through the same get_db override below.
monkeypatch.setattr(get_settings().casdoor, "enabled", False)
engine = create_async_engine( engine = create_async_engine(
"sqlite+aiosqlite:///:memory:", "sqlite+aiosqlite:///:memory:",

View File

@@ -1,27 +1,57 @@
""" """
WebSocket event-stream tests. WebSocket event-stream tests.
The socket is refused (4401) without the bearer token, and an The socket is owner-gated: refused (4401) when SSO is enabled and no
authorized client immediately receives the synthetic trunk-status credential is supplied, and — in dev-owner mode (SSO disabled) — an
event followed by the replayed recent history. authorized client immediately receives the synthetic trunk-status event
followed by the replayed recent history.
The WS `_authorize` resolves the owner via a DB session, so an in-memory
SQLite database is wired in (StaticPool, shared across the TestClient
thread) mirroring tests/test_data_layer.py.
""" """
import asyncio import asyncio
import pytest import pytest
from pydantic import SecretStr from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.pool import StaticPool
from starlette.testclient import TestClient from starlette.testclient import TestClient
from starlette.websockets import WebSocketDisconnect from starlette.websockets import WebSocketDisconnect
import db.database as dbmod
import main import main
from config import Settings, get_settings from config import Settings, get_settings
from core.gateway import AIPSTNGateway from core.gateway import AIPSTNGateway
from db.database import Base
from models.events import EventType, GatewayEvent from models.events import EventType, GatewayEvent
@pytest.fixture @pytest.fixture
def ws_app(monkeypatch): def mem_db(monkeypatch):
monkeypatch.setattr(get_settings(), "api_token", SecretStr("tok")) """Synchronous setup of an in-memory SQLite DB shared with the app."""
engine = create_async_engine(
"sqlite+aiosqlite:///:memory:",
poolclass=StaticPool,
connect_args={"check_same_thread": False},
)
async def _create():
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
asyncio.run(_create())
factory = async_sessionmaker(engine, expire_on_commit=False)
monkeypatch.setattr(dbmod, "_engine", engine)
monkeypatch.setattr(dbmod, "_session_factory", factory)
yield
asyncio.run(engine.dispose())
@pytest.fixture
def ws_app(monkeypatch, mem_db):
"""Dev-owner mode: a tokenless WS connect resolves the owner."""
monkeypatch.setattr(get_settings().casdoor, "enabled", False)
gateway = AIPSTNGateway(settings=Settings()) gateway = AIPSTNGateway(settings=Settings())
main.app.state.gateway = gateway main.app.state.gateway = gateway
yield gateway yield gateway
@@ -38,7 +68,11 @@ def _publish(gateway, call_id: str) -> None:
class TestEventStream: class TestEventStream:
def test_refused_without_token(self, ws_app): def test_refused_without_credential(self, monkeypatch, mem_db):
"""SSO enabled + no token → the socket is closed with 4401."""
monkeypatch.setattr(get_settings().casdoor, "enabled", True)
monkeypatch.setattr(get_settings().casdoor, "endpoint", "https://id.example.test")
monkeypatch.setattr(get_settings(), "owner_name", "owner@example.test")
client = TestClient(main.app) client = TestClient(main.app)
with pytest.raises(WebSocketDisconnect) as exc: with pytest.raises(WebSocketDisconnect) as exc:
with client.websocket_connect("/ws/events"): with client.websocket_connect("/ws/events"):
@@ -50,7 +84,7 @@ class TestEventStream:
_publish(ws_app, "call_ws2") _publish(ws_app, "call_ws2")
client = TestClient(main.app) client = TestClient(main.app)
with client.websocket_connect("/ws/events?token=tok") as ws: with client.websocket_connect("/ws/events") as ws:
first = ws.receive_json() first = ws.receive_json()
assert first["type"] == EventType.SIP_TRUNK_REGISTRATION_FAILED.value assert first["type"] == EventType.SIP_TRUNK_REGISTRATION_FAILED.value
replayed = [ws.receive_json() for _ in range(2)] replayed = [ws.receive_json() for _ in range(2)]
@@ -58,9 +92,7 @@ class TestEventStream:
def test_per_call_stream_filters(self, ws_app): def test_per_call_stream_filters(self, ws_app):
client = TestClient(main.app) client = TestClient(main.app)
with client.websocket_connect( with client.websocket_connect("/ws/calls/call_target/events") as ws:
"/ws/calls/call_target/events?token=tok"
) as ws:
_publish(ws_app, "call_other") _publish(ws_app, "call_other")
_publish(ws_app, "call_target") _publish(ws_app, "call_target")
msg = ws.receive_json() msg = ws.receive_json()