Capability Gateway (WS-B)¶
Browser-facing FastAPI edge over the existing services — the partner to the Brain Builder SPA
(web/brain-builder/). Adds CORS + REST (GET/POST/PATCH) + WebSocket chat + SSE and mirrors
web/brain-builder/src/lib/api/contract.ts.
Auth (Phase 5, JWT + refresh tokens)¶
- Login:
POST /v1/auth/login {email,password}→{access_token, refresh_token, expires_in, token_type:"bearer", principal}. - Refresh:
POST /v1/auth/refresh {refresh_token}→ same bundle with a NEW access + NEW refresh (rotating, single-use). Invalid/expired/reused refresh →401 invalid_refresh(reuse revokes the whole chain — theft mitigation). Logout:POST /v1/auth/logout {refresh_token}→{ok:true}. GET /v1/auth/me(Bearer) → principal.POST /v1/auth/accounts(platform admin) provisions accounts.- Refresh tokens are opaque + stored hashed (
app.refresh_token); access JWTs stay short-lived (JWT_EXPIRE_SECONDS); refresh TTL =REFRESH_EXPIRE_SECONDS(default 7d). - REST:
Authorization: Bearer <jwt>. WS + SSE:?token=<jwt>(browsers can't set those headers). - Two account types:
tenant_user(bound to onebrain_id+ role Admin|Curator|Reader) andplatform_admin(superuser — any brain, bypasses role→verb gating, plus tenant managementGET/POST /v1/brains). A tenant user touching another brain →403 brain_forbidden; a role that can't run a verb →403 role_forbidden; missing/invalid token →401. - Seed demo accounts:
bb account seed(provision more withbb account create): | email | password | type/role | |---|---|---| | admin@polaris.io | polaris-admin-pw | platform_admin | | admin@meridian.legal | meridian-admin-pw | tenant Admin | | curator@meridian.legal | meridian-curator-pw | tenant Curator | | reader@meridian.legal | meridian-reader-pw | tenant Reader | - Deferred: per-verb/per-credential rate limiting (plan E7 AC).
Run¶
# stack must be up + a brain certified (so serve has a published version)
./scripts/start-compose.sh
APP_CONFIG=$(pwd)/env/local/app.env uv run python -m app.gateway_service # :8000
# if :8000 is taken, pick another port:
APP_CONFIG=$(pwd)/env/local/app.env uv run uvicorn app.gateway.app:app --port 8088
Surface (all under /v1, scoped by X-Brain-Id / X-Role)¶
- REST:
GET .../overview|corpus|sources|change-sets|change-sets/{id}|versions|lifecycle|manifests|audit|ops/{op_id},POST .../sources|ingest/process|certify|rebuild,PATCH .../change-sets/{id}({decision: approve|reject|edit}). Role-gated;403 {error:{code:"role_forbidden"}}on violation. - WebSocket chat:
ws://host/v1/brains/{brain_id}/agents/{agent}/chat?role=— send{type:"query",verb,query,conversation_id}; receiverouted→ 7×trace(pre-gate→fsm→retrieve→assemble→reason→cite-then-verify→post-gate, instrumented fromserve()via itson_stagehook) →envelope(+artifactfor assess/redline) →dashboard. Free-text routed to a verb; role-gated. - SSE:
GET .../events(EventSource) —source.ingest,change_set.created|updated,curation.approve|reject,lifecycle.certify,brain.published,op.progress,op.done. Fed by gateway-performed actions (long ops + mutations). Frames go on the defaultmessagechannel (data: {SseEvent}withtypein the body — the SPA readsonmessage, not named events). - List shape: list endpoints (change-sets, sources, coverage, audit) return
{items: [...], next_cursor}; single objects (overview, brain, lifecycle, change-set detail) are returned bare.
Wire the SPA to the real gateway¶
In web/brain-builder/.env (or .env.development): set VITE_USE_MOCKS=false and point the three
bases at the gateway (adjust port if not 8000):
VITE_USE_MOCKS=false
VITE_API_BASE=http://localhost:8000/v1
VITE_WS_BASE=ws://localhost:8000/v1
VITE_SSE_BASE=http://localhost:8000/v1
No SPA code changes — the contract types are the seam.
Notes / follow-ups¶
- In-process event hub (single worker). A Redis-backed fan-out generalizes to multiple workers.
draftverb returnsnot_implemented(backend stub). Librarian/Sentinel chat verbs return a lightweight ack — real Librarian work goes through REST (PATCH change-sets,POST certify).- Long ops (certify/rebuild/ingest_process) run in a background task with progress on SSE +
pull fallback at
GET .../ops/{op_id}. serve()gained an optionalon_stagecallback (defaultNone→ unchanged behavior); the WS endpoint passes it to stream the trace.