jang0294 commited on
Commit
e280d04
Β·
verified Β·
1 Parent(s): dfdfc61

Upload folder using huggingface_hub

Browse files
Files changed (7) hide show
  1. api/atp.py +429 -0
  2. api/auth.py +162 -33
  3. api/billing.py +512 -0
  4. api/identity.py +474 -0
  5. api/knowledge.py +262 -0
  6. api/ops.py +496 -0
  7. api/server.py +161 -38
api/atp.py ADDED
@@ -0,0 +1,429 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ ATP (Agentic Training Platform) API routes β€” see docs/ATP.md Β§5.
3
+
4
+ Mounted by api/server.py:
5
+ from api.atp import router as atp_router
6
+ app.include_router(atp_router)
7
+
8
+ Endpoints (all bearer-gated by the default auth middleware β€” api/auth.py):
9
+ GET /atp/data β€” full ATP_DATA blob (T0 public seed)
10
+ GET /atp/agents β€” agent roster
11
+ GET /atp/agents/{agent_id} β€” one agent profile (404 if unknown)
12
+ GET /atp/agents/{agent_id}/report.pdf β€” official report-card PDF
13
+ (Phase 6; any authenticated role;
14
+ live awards org-scoped; 404 if unknown)
15
+ GET /atp/certs β€” certification catalog
16
+ GET /atp/certs/{cert_id} β€” one cert (404 if unknown)
17
+ GET /atp/standard β€” the ATP standard (label grammar + principles)
18
+ GET /atp/marketplace β€” marketplace listings
19
+ GET /atp/hitl β€” HITL reward log, latest first (org-scoped)
20
+ POST /atp/hitl β€” body {agentId, layer, signal, reason, rater}
21
+ β†’ appended (enriched) event [admin|sme]
22
+ GET /atp/requests β€” expert-composition requests (org-scoped)
23
+ POST /atp/requests β€” body {major, specialty, badgeIds, packId}
24
+ β†’ appended request [admin|sme]
25
+
26
+ Certification engine (Phase 3 β€” docs/HARDENING.md):
27
+ POST /atp/exams/run β€” body {certId, candidateSpec?, agentId?,
28
+ dryRun=true} β†’ {jobId} (poll /jobs/{id};
29
+ the job result is the award dict) [admin|sme]
30
+ GET /atp/awards β€” org-scoped cert awards, latest first
31
+ GET /atp/evidence/{id} β€” one signed evidence row (org-scoped; 404
32
+ cross-tenant/absent β€” no existence oracle)
33
+ GET /atp/evidence/{id}/verify β€” {ok, sigValid, chainPosition}
34
+ GET /atp/chain/verify β€” full evidence-chain verification for the org
35
+
36
+ Tenancy (Phase 2 β€” docs/TENANCY.md):
37
+ * The seed catalog (data/agents/certs/standard/marketplace) is T0 public:
38
+ read-only, identical for every org β€” never merged with tenant rows.
39
+ * atp_hitl / atp_requests are T1 org-internal: reads and writes go through
40
+ the store with org_id taken ONLY from the verified request state
41
+ (request.state.org_id, set by the auth middleware from the JWT) β€” never
42
+ from a client-supplied body/query value. Fallback 'org-demo' covers
43
+ BU_AUTH_DISABLED dev mode and legacy single-admin tokens.
44
+ * The demo seed rows (RL.hitlLog / EXPERT_REQUESTS) belong to 'org-demo'
45
+ (TENANCY.md leak surface #1), so they are appended only for that org.
46
+ * Writes require role admin|sme via api.auth.require_role; GET endpoints
47
+ are open to any authenticated role (viewer included). Under
48
+ BU_AUTH_DISABLED=1 the middleware runs every request as org-demo/admin,
49
+ so dev mode passes every role gate (pre-Phase-2 behavior preserved).
50
+ """
51
+
52
+ from __future__ import annotations
53
+
54
+ import json
55
+ import threading
56
+
57
+ from fastapi import APIRouter, Depends, HTTPException, Request, Response
58
+ from pydantic import BaseModel
59
+
60
+ # require_role(*roles) β†’ FastAPI dependency that 403s when the verified
61
+ # session role is not in `roles` (401 when the middleware never ran).
62
+ from api.auth import require_role
63
+ from atp import store, tenant_db
64
+
65
+ router = APIRouter(prefix="/atp")
66
+
67
+
68
+ # ── Tenancy helpers ────────────────────────────────────────────────────────
69
+
70
+ def _org(request: Request) -> str:
71
+ """Org for this request β€” ONLY from verified auth state (TENANCY.md Β§4
72
+ layer 1). 'org-demo' when auth is disabled / legacy single-admin token."""
73
+ return getattr(request.state, "org_id", None) or "org-demo"
74
+
75
+
76
+ # ── Read endpoints β€” T0 public seed ────────────────────────────────────────
77
+
78
+ @router.get("/data")
79
+ def atp_data():
80
+ """Full ATP_DATA blob (same shape as window.ATP_DATA, minus JS helpers).
81
+ T0 public seed β€” never merge tenant rows into it (TENANCY.md #9)."""
82
+ return store.get_data()
83
+
84
+
85
+ @router.get("/agents")
86
+ def atp_agents():
87
+ return {"agents": store.get_data().get("AGENTS", [])}
88
+
89
+
90
+ @router.get("/agents/{agent_id}")
91
+ def atp_agent(agent_id: str):
92
+ for a in store.get_data().get("AGENTS", []):
93
+ if a.get("id") == agent_id:
94
+ return a
95
+ raise HTTPException(404, f"agent {agent_id} not found")
96
+
97
+
98
+ @router.get("/agents/{agent_id}/report.pdf")
99
+ def atp_agent_report_pdf(agent_id: str, request: Request):
100
+ """Official report-card PDF (Phase 6, docs/HARDENING.md).
101
+
102
+ Any authenticated role β€” the PDF is built from the T0 public seed plus
103
+ the CALLER ORG's live cert awards (org_id from verified request state
104
+ only, TENANCY.md layer 1; atp/reportcard.py reads them through
105
+ atp/tenant_db.py). Served as an attachment download; 404 for agents
106
+ not in the seed roster. Deliberately NOT under /videos or /media β€”
107
+ those prefixes are public-cached (TENANCY.md leak surface #4).
108
+ """
109
+ # Lazy import: reportlab stays an on-demand dependency β€” the server
110
+ # boots (and every other route works) even if it is not installed.
111
+ from atp import reportcard
112
+
113
+ try:
114
+ pdf = reportcard.build_report_card_pdf(agent_id, org_id=_org(request))
115
+ except reportcard.UnknownAgentError:
116
+ raise HTTPException(404, f"agent {agent_id} not found")
117
+ return Response(
118
+ content=pdf,
119
+ media_type="application/pdf",
120
+ headers={
121
+ "Content-Disposition":
122
+ f'attachment; filename="agent-{agent_id}-report-card.pdf"',
123
+ },
124
+ )
125
+
126
+
127
+ @router.get("/certs")
128
+ def atp_certs():
129
+ return {"certs": store.get_data().get("CERTS", [])}
130
+
131
+
132
+ @router.get("/certs/{cert_id}")
133
+ def atp_cert(cert_id: str):
134
+ for c in store.get_data().get("CERTS", []):
135
+ if c.get("id") == cert_id:
136
+ return c
137
+ raise HTTPException(404, f"cert {cert_id} not found")
138
+
139
+
140
+ @router.get("/standard")
141
+ def atp_standard():
142
+ return store.get_data().get("STANDARD", {})
143
+
144
+
145
+ @router.get("/marketplace")
146
+ def atp_marketplace():
147
+ return store.get_data().get("MARKETPLACE", {"listings": []})
148
+
149
+
150
+ # ── HITL reward log β€” T1 org-internal ──────────────────────────────────────
151
+
152
+ @router.get("/hitl")
153
+ def atp_hitl_log(request: Request, limit: int = 100):
154
+ """HITL reward log, latest first: the requesting org's live events
155
+ (store.read_hitl already returns latest-first) followed by the seed audit
156
+ history (stored oldest β†’ newest, so reversed here). The seed rows belong
157
+ to 'org-demo' only β€” other tenants never see them."""
158
+ org = _org(request)
159
+ live = store.read_hitl(limit=limit, org_id=org)
160
+ seed = store.get_data().get("RL", {}).get("hitlLog", []) if org == "org-demo" else []
161
+ return {"events": (live + list(reversed(seed)))[:limit]}
162
+
163
+
164
+ class HitlBody(BaseModel):
165
+ agentId: str
166
+ layer: int
167
+ signal: int
168
+ reason: str = ""
169
+ rater: str = "anon"
170
+
171
+
172
+ @router.post("/hitl", dependencies=[Depends(require_role("admin", "sme"))])
173
+ def atp_hitl_submit(body: HitlBody, request: Request):
174
+ """Append a human reward signal (role admin|sme). store.append_hitl
175
+ computes weightedDelta per the RL weighting in docs/ATP.md Β§1 and logs it
176
+ append-only under the caller's org."""
177
+ if not 1 <= body.layer <= 7:
178
+ raise HTTPException(400, "layer must be an integer in 1..7")
179
+ if body.signal not in (1, -1):
180
+ raise HTTPException(400, "signal must be 1 or -1")
181
+ return store.append_hitl({
182
+ "agentId": body.agentId,
183
+ "layer": body.layer,
184
+ "signal": body.signal,
185
+ "reason": body.reason,
186
+ "rater": body.rater,
187
+ }, org_id=_org(request))
188
+
189
+
190
+ # ── Expert composition / train-to-order (docs/ATP.md Β§7) ───────────────────
191
+
192
+ @router.get("/requests")
193
+ def atp_requests(request: Request, limit: int = 100):
194
+ """Expert-composition requests: the requesting org's live commissions
195
+ (store.read_requests already returns latest-first) followed by the seed
196
+ board β€” which belongs to 'org-demo' only."""
197
+ org = _org(request)
198
+ live = store.read_requests(limit=limit, org_id=org)
199
+ seed = store.get_data().get("EXPERT_REQUESTS", []) if org == "org-demo" else []
200
+ return {"requests": live + seed}
201
+
202
+
203
+ class ComposeBody(BaseModel):
204
+ major: str
205
+ specialty: str = ""
206
+ badgeIds: list[str] = []
207
+ packId: str | None = None
208
+
209
+
210
+ @router.post("/requests", dependencies=[Depends(require_role("admin", "sme"))])
211
+ def atp_request_submit(body: ComposeBody, request: Request):
212
+ """Commission an expert (role admin|sme β€” viewers are read-only,
213
+ TENANCY.md Β§Principals). store.append_request runs the matching rule
214
+ against seed agents, builds the pipeline stages, and appends the request
215
+ to the atp_requests table under the caller's org."""
216
+ return store.append_request({
217
+ "major": body.major,
218
+ "specialty": body.specialty,
219
+ "badgeIds": body.badgeIds,
220
+ "packId": body.packId,
221
+ }, org_id=_org(request))
222
+
223
+
224
+ # ── Certification engine (Phase 3 β€” docs/HARDENING.md) ─────────────────────
225
+ #
226
+ # Exam runs execute in the background (agents/jobs β€” they call the candidate
227
+ # AND the judge model, 30-120s live) and write signed, chained rows into the
228
+ # append-only atp_evidence / atp_cert_awards tables (migration 002; org_id +
229
+ # RLS from 005). atp/exams.py + atp/signing.py own the exam/signing logic;
230
+ # these routes are the org-scoped HTTP surface over them. Reads go through
231
+ # atp/tenant_db.py ONLY (TENANCY.md layer 2) with org_id from verified
232
+ # request state β€” never from the client payload.
233
+
234
+ _DB_READY = False
235
+ _DB_LOCK = threading.Lock()
236
+ _EVIDENCE_COLNAMES: set[str] | None = None
237
+
238
+ # camelCase key ↔ snake_case column, same mapping idiom as atp/store.py.
239
+ _AWARD_COLS = {
240
+ "id": "id",
241
+ "ts": "ts",
242
+ "agentId": "agent_id",
243
+ "certId": "cert_id",
244
+ "score": "score",
245
+ "sectionScores": "section_scores",
246
+ "itemBreakdown": "item_breakdown",
247
+ "evidenceIds": "evidence_ids",
248
+ }
249
+ _AWARD_JSON_COLS = {"section_scores", "item_breakdown", "evidence_ids"}
250
+
251
+ _EVIDENCE_COLS = {
252
+ "id": "id",
253
+ "ts": "ts",
254
+ "agentId": "agent_id",
255
+ "certId": "cert_id",
256
+ "kind": "kind",
257
+ "payload": "payload",
258
+ "sig": "sig",
259
+ "prevHash": "prev_hash",
260
+ }
261
+
262
+
263
+ def _ensure_db() -> None:
264
+ """Engine + migrations up before the first direct tenant_db read here.
265
+ run_migrations() is idempotent (schema_migrations bookkeeping), so this
266
+ is a no-op when the server startup / atp.store already ran it."""
267
+ global _DB_READY
268
+ if _DB_READY:
269
+ return
270
+ with _DB_LOCK:
271
+ if not _DB_READY:
272
+ from atp import db
273
+ db.run_migrations()
274
+ _DB_READY = True
275
+
276
+
277
+ def _loads_maybe(v):
278
+ """json.loads a JSON-text column, tolerating NULL / non-JSON text."""
279
+ if v is None or not isinstance(v, str):
280
+ return v
281
+ try:
282
+ return json.loads(v)
283
+ except (json.JSONDecodeError, ValueError):
284
+ return v
285
+
286
+
287
+ def _row_to_camel(row: dict, cols: dict, json_cols: set = frozenset()) -> dict:
288
+ return {key: (_loads_maybe(row.get(col)) if col in json_cols else row.get(col))
289
+ for key, col in cols.items()}
290
+
291
+
292
+ class ExamRunBody(BaseModel):
293
+ certId: str
294
+ candidateSpec: str | None = None # agents/backend.get_backend spec string
295
+ agentId: str | None = None
296
+ dryRun: bool = True # deterministic stub model (CI-safe)
297
+
298
+
299
+ @router.post("/exams/run", dependencies=[Depends(require_role("admin", "sme"))])
300
+ def atp_exam_run(body: ExamRunBody, request: Request):
301
+ """Enqueue a certification exam run (role admin|sme).
302
+
303
+ Returns {jobId}; poll GET /jobs/{jobId} β€” when status='done' the job
304
+ result is the award dict (score, sectionScores, itemBreakdown,
305
+ evidenceIds, passed…). The run itself enforces the ATP standard promises
306
+ (docs/ATP.md Β§2) inside atp/exams.py: judge/candidate model-family
307
+ separation and recorded seed/temperature/run-count reproducibility.
308
+ dryRun (default true) uses the deterministic stub model β€” no live LLM.
309
+ """
310
+ from agents import jobs
311
+
312
+ org = _org(request)
313
+ cert_id = body.certId
314
+ if not any(c.get("id") == cert_id
315
+ for c in store.get_data().get("CERTS", [])):
316
+ raise HTTPException(404, f"cert {cert_id} not found")
317
+
318
+ candidate_spec = body.candidateSpec
319
+ agent_id = body.agentId
320
+ dry_run = body.dryRun
321
+
322
+ def _task():
323
+ from atp import exams
324
+ return exams.run_exam(
325
+ cert_id,
326
+ candidate_spec,
327
+ org_id=org,
328
+ dry_run=dry_run,
329
+ agent_id=agent_id,
330
+ )
331
+
332
+ job_id = jobs.submit("atp_exam", _task, org_id=org)
333
+ # jobId is the documented key; job_id keeps BU_API.runJob() compatible.
334
+ return {"jobId": job_id, "job_id": job_id}
335
+
336
+
337
+ @router.get("/awards")
338
+ def atp_awards(request: Request, limit: int = 100):
339
+ """Org-scoped cert awards, latest first (any authenticated role)."""
340
+ org = _org(request)
341
+ _ensure_db()
342
+ sel = ", ".join(_AWARD_COLS.values())
343
+ rows = tenant_db.scoped_query(
344
+ org,
345
+ f"SELECT {sel} FROM atp_cert_awards"
346
+ f" WHERE org_id = :org ORDER BY id DESC LIMIT :n",
347
+ {"org": org, "n": int(limit)})
348
+ return {"awards": [_row_to_camel(r, _AWARD_COLS, _AWARD_JSON_COLS)
349
+ for r in rows]}
350
+
351
+
352
+ def _evidence_where() -> str:
353
+ """Row-match clause for a path id. The surrogate pk is an integer
354
+ (BIGSERIAL / AUTOINCREMENT β€” migration 002) so match its text form; if
355
+ the schema also carries a string business id (evidence_id), accept that
356
+ too so award.evidenceIds resolve regardless of which form they use."""
357
+ global _EVIDENCE_COLNAMES
358
+ if _EVIDENCE_COLNAMES is None:
359
+ from sqlalchemy import inspect as sa_inspect
360
+
361
+ from atp import db
362
+ _EVIDENCE_COLNAMES = {
363
+ c["name"] for c in sa_inspect(db.get_engine()).get_columns("atp_evidence")}
364
+ if "evidence_id" in _EVIDENCE_COLNAMES:
365
+ return "(evidence_id = :eid OR CAST(id AS TEXT) = :eid)"
366
+ return "CAST(id AS TEXT) = :eid"
367
+
368
+
369
+ def _evidence_row(org: str, evidence_id: str) -> dict | None:
370
+ """One org-scoped evidence row (raw snake_case columns), or None."""
371
+ _ensure_db()
372
+ rows = tenant_db.scoped_query(
373
+ org,
374
+ f"SELECT * FROM atp_evidence"
375
+ f" WHERE org_id = :org AND {_evidence_where()} LIMIT 1",
376
+ {"org": org, "eid": str(evidence_id)})
377
+ return rows[0] if rows else None
378
+
379
+
380
+ @router.get("/evidence/{evidence_id}")
381
+ def atp_evidence(evidence_id: str, request: Request):
382
+ """One signed evidence row. Cross-tenant ids 404 with the SAME message
383
+ as unknown ids β€” no existence oracle (TENANCY.md #6)."""
384
+ row = _evidence_row(_org(request), evidence_id)
385
+ if row is None:
386
+ raise HTTPException(404, f"evidence {evidence_id} not found")
387
+ return _row_to_camel(row, _EVIDENCE_COLS, {"payload"})
388
+
389
+
390
+ @router.get("/evidence/{evidence_id}/verify")
391
+ def atp_evidence_verify(evidence_id: str, request: Request):
392
+ """Verify one evidence row: {ok, sigValid, chainPosition}.
393
+
394
+ sigValid β€” HMAC signature over the row's canonical payload checks
395
+ out (atp.signing.verify_row).
396
+ chainPosition β€” 1-based position of the row in the org's evidence chain.
397
+ ok β€” sigValid AND the whole org chain verifies (a valid row
398
+ inside a tampered ledger is NOT ok).
399
+ """
400
+ from atp import signing
401
+
402
+ org = _org(request)
403
+ row = _evidence_row(org, evidence_id)
404
+ if row is None:
405
+ raise HTTPException(404, f"evidence {evidence_id} not found")
406
+
407
+ sig_valid = bool(signing.verify_row(row))
408
+ position = tenant_db.scoped_query(
409
+ org,
410
+ "SELECT COUNT(*) AS n FROM atp_evidence"
411
+ " WHERE org_id = :org AND id <= :rid",
412
+ {"org": org, "rid": row["id"]})[0]["n"]
413
+ chain = signing.verify_chain(org)
414
+ chain_ok = bool(chain.get("ok")) if isinstance(chain, dict) else bool(chain)
415
+ return {"ok": sig_valid and chain_ok,
416
+ "sigValid": sig_valid,
417
+ "chainPosition": position}
418
+
419
+
420
+ @router.get("/chain/verify")
421
+ def atp_chain_verify(request: Request):
422
+ """Verify the caller org's whole evidence chain (sig + prev_hash links).
423
+ Returns atp.signing.verify_chain's result dict (tamper-evidence promise,
424
+ docs/HARDENING.md Phase 3)."""
425
+ from atp import signing
426
+
427
+ _ensure_db()
428
+ result = signing.verify_chain(_org(request))
429
+ return result if isinstance(result, dict) else {"ok": bool(result)}
api/auth.py CHANGED
@@ -1,22 +1,34 @@
1
  """
2
  Bearer-token authentication for the Brain University FastAPI app.
3
 
4
- Token format (URL-safe base64):
5
- <username>|<expiry_unix>|<hmac_sha256(username|expiry, BU_SECRET)>
6
 
7
- Stateless, no DB. Verification recomputes the HMAC and checks expiry.
 
 
 
 
 
 
 
 
 
8
 
9
  Env vars (read at process start):
10
  BU_AUTH_USER β€” single allowed username (default "admin")
11
- BU_AUTH_PASSWORD β€” required; refusing to start without it in production
12
- BU_AUTH_SECRET β€” HMAC key; auto-generated random fallback if unset
13
- (tokens invalidate on restart, fine for single-instance)
 
 
14
  BU_AUTH_TTL_HOURS β€” token lifetime, default 24
15
- BU_AUTH_DISABLED β€” "1" to bypass auth entirely (dev only)
 
 
16
 
17
  Routes protected by middleware are everything except an allowlist:
18
- /health, /login, /docs, /openapi.json, /redoc, /videos/{...}, /media/{...},
19
- OPTIONS preflight.
20
  """
21
 
22
  from __future__ import annotations
@@ -26,8 +38,9 @@ import hashlib
26
  import hmac
27
  import os
28
  import secrets
 
29
  import time
30
- import warnings
31
 
32
  from fastapi import HTTPException, Request
33
 
@@ -40,29 +53,71 @@ AUTH_SECRET = os.environ.get("BU_AUTH_SECRET", "").encode("utf-8")
40
  AUTH_TTL_S = int(os.environ.get("BU_AUTH_TTL_HOURS", "24")) * 3600
41
  AUTH_DISABLED = os.environ.get("BU_AUTH_DISABLED", "") == "1"
42
 
43
- if not AUTH_SECRET:
44
- AUTH_SECRET = secrets.token_bytes(32)
45
- warnings.warn(
46
- "BU_AUTH_SECRET not set; using ephemeral random key. Tokens invalidate "
47
- "on restart. Set BU_AUTH_SECRET to a 32+ byte random string in prod.",
48
- RuntimeWarning,
49
- )
50
-
51
- if not AUTH_PASSWORD and not AUTH_DISABLED:
52
- # Soft-fail: keep dev usable but loudly warn. Production deploy scripts
53
- # MUST set BU_AUTH_PASSWORD.
54
- AUTH_PASSWORD = "password"
55
- warnings.warn(
56
- "BU_AUTH_PASSWORD not set; defaulting to 'admin/password'. "
57
- "Set BU_AUTH_PASSWORD before exposing this server publicly.",
58
- RuntimeWarning,
59
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
60
 
61
 
62
  # ── Token mint / verify ────────────────────────────────────────────────────
63
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
64
  def mint_token(username: str, ttl_s: int | None = None) -> str:
65
- """Issue a fresh bearer token."""
66
  ttl = ttl_s or AUTH_TTL_S
67
  expiry = int(time.time()) + ttl
68
  payload = f"{username}|{expiry}"
@@ -71,8 +126,37 @@ def mint_token(username: str, ttl_s: int | None = None) -> str:
71
  return base64.urlsafe_b64encode(raw.encode()).decode().rstrip("=")
72
 
73
 
74
- def verify_token(token: str) -> str | None:
75
- """Return username if token valid + unexpired; else None."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
76
  if not token:
77
  return None
78
  try:
@@ -109,6 +193,7 @@ def check_password(username: str, password: str) -> bool:
109
 
110
  PUBLIC_PATHS = {
111
  "/", "/health", "/login",
 
112
  "/docs", "/openapi.json", "/redoc",
113
  }
114
  PUBLIC_PREFIXES = ("/videos/", "/media/") # static cached media
@@ -125,9 +210,22 @@ def is_public(path: str, method: str) -> bool:
125
  return False
126
 
127
 
 
 
 
 
 
 
 
128
  async def auth_middleware(request: Request, call_next):
129
  """FastAPI ASGI middleware β€” reject unauthenticated requests."""
130
  if AUTH_DISABLED:
 
 
 
 
 
 
131
  return await call_next(request)
132
  if is_public(request.url.path, request.method):
133
  return await call_next(request)
@@ -140,13 +238,44 @@ async def auth_middleware(request: Request, call_next):
140
  headers={"WWW-Authenticate": "Bearer"},
141
  )
142
  token = auth_hdr.split(" ", 1)[1].strip()
143
- user = verify_token(token)
144
- if not user:
145
  from fastapi.responses import JSONResponse
146
  return JSONResponse(
147
  {"detail": "invalid or expired token"}, status_code=401,
148
  headers={"WWW-Authenticate": "Bearer"},
149
  )
150
- # Pass username through so downstream routes can read it
151
- request.state.user = user
152
  return await call_next(request)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  """
2
  Bearer-token authentication for the Brain University FastAPI app.
3
 
4
+ Two token formats are accepted (Phase 2, docs/TENANCY.md):
 
5
 
6
+ * Org session JWT (api/identity.py) β€” HS256 over BU_AUTH_SECRET, claims
7
+ {sub, org_id, role, exp}. Tried FIRST by verify_token().
8
+ * Legacy single-admin HMAC token (URL-safe base64):
9
+ <username>|<expiry_unix>|<hmac_sha256(username|expiry, BU_SECRET)>
10
+ Fallback β€” kept for backward compatibility with the deployed demo; a
11
+ legacy token maps to the bootstrap org 'org-demo' with role 'admin'.
12
+
13
+ Stateless, no DB. Verification recomputes the HMAC / JWT signature and
14
+ checks expiry. On success the middleware sets request.state.user /
15
+ .user_id / .org_id / .role; route handlers gate roles with require_role().
16
 
17
  Env vars (read at process start):
18
  BU_AUTH_USER β€” single allowed username (default "admin")
19
+ BU_AUTH_PASSWORD β€” REQUIRED; process refuses to start without it
20
+ (unless BU_AUTH_DISABLED=1)
21
+ BU_AUTH_SECRET β€” HMAC key; REQUIRED; process refuses to start without
22
+ it (unless BU_AUTH_DISABLED=1, which uses an ephemeral
23
+ random key β€” tokens invalidate on restart)
24
  BU_AUTH_TTL_HOURS β€” token lifetime, default 24
25
+ BU_AUTH_DISABLED β€” "1" to bypass auth entirely (dev only; used by
26
+ scripts/demo_start.sh). Requests run as
27
+ org-demo/admin so org-scoped routes keep working.
28
 
29
  Routes protected by middleware are everything except an allowlist:
30
+ /health, /login, /auth/login, /auth/oidc/{login,callback}, /docs,
31
+ /openapi.json, /redoc, /videos/{...}, /media/{...}, OPTIONS preflight.
32
  """
33
 
34
  from __future__ import annotations
 
38
  import hmac
39
  import os
40
  import secrets
41
+ import sys
42
  import time
43
+ from typing import NamedTuple
44
 
45
  from fastapi import HTTPException, Request
46
 
 
53
  AUTH_TTL_S = int(os.environ.get("BU_AUTH_TTL_HOURS", "24")) * 3600
54
  AUTH_DISABLED = os.environ.get("BU_AUTH_DISABLED", "") == "1"
55
 
56
+ if AUTH_DISABLED:
57
+ # Explicit dev/demo opt-in (scripts/demo_start.sh, .claude/launch.json).
58
+ # Keep usable defaults so the login gate (which always POSTs /login)
59
+ # works in demo mode without any env setup.
60
+ print(
61
+ "=" * 72 + "\n"
62
+ "WARNING: BU_AUTH_DISABLED=1 β€” AUTHENTICATION IS BYPASSED (dev/demo\n"
63
+ "only). The login gate accepts the dev default credentials, and the\n"
64
+ "HMAC key is ephemeral (all tokens invalidate on restart). NEVER set\n"
65
+ "BU_AUTH_DISABLED in production. Unset it and provide\n"
66
+ "BU_AUTH_PASSWORD + BU_AUTH_SECRET to re-enable auth.\n"
67
+ + "=" * 72,
68
+ file=sys.stderr,
 
 
 
69
  )
70
+ if not AUTH_SECRET:
71
+ AUTH_SECRET = secrets.token_bytes(32) # ephemeral; fine for dev
72
+ if not AUTH_PASSWORD:
73
+ AUTH_PASSWORD = "password" # dev-only default; enabled path has NO default
74
+ else:
75
+ _missing = [name for name, val in (
76
+ ("BU_AUTH_PASSWORD", AUTH_PASSWORD),
77
+ ("BU_AUTH_SECRET", AUTH_SECRET),
78
+ ) if not val]
79
+ if _missing:
80
+ raise RuntimeError(
81
+ "Refusing to start: required auth env var(s) unset: "
82
+ + ", ".join(_missing)
83
+ + ". Set them in the deployment environment (see render.yaml / "
84
+ "DEPLOY.md), or export BU_AUTH_DISABLED=1 for local dev only "
85
+ "(scripts/demo_start.sh does this)."
86
+ )
87
+ if len(AUTH_SECRET) < 32:
88
+ # HS256 signing key β€” short keys are brute-forceable. Same fail-loud
89
+ # posture as the missing-var check above.
90
+ raise RuntimeError(
91
+ "Refusing to start: BU_AUTH_SECRET is shorter than 32 bytes. "
92
+ "Generate one with: python3 -c \"import secrets; "
93
+ "print(secrets.token_urlsafe(48))\""
94
+ )
95
 
96
 
97
  # ── Token mint / verify ────────────────────────────────────────────────────
98
 
99
+ # Bootstrap org for legacy single-admin tokens (seeded by migrations/004).
100
+ LEGACY_ORG_ID = "org-demo"
101
+ LEGACY_ROLE = "admin"
102
+
103
+
104
+ class Principal(NamedTuple):
105
+ """Verified caller identity, copied onto request.state by the middleware.
106
+
107
+ user β€” display identifier (org users: user id / legacy: username)
108
+ user_id β€” users.user_id for org sessions; None for legacy tokens
109
+ org_id β€” tenant id (legacy tokens map to 'org-demo')
110
+ role β€” admin | sme | viewer (legacy tokens map to 'admin')
111
+ """
112
+
113
+ user: str
114
+ user_id: str | None
115
+ org_id: str
116
+ role: str
117
+
118
+
119
  def mint_token(username: str, ttl_s: int | None = None) -> str:
120
+ """Issue a fresh LEGACY bearer token (single-admin HMAC format)."""
121
  ttl = ttl_s or AUTH_TTL_S
122
  expiry = int(time.time()) + ttl
123
  payload = f"{username}|{expiry}"
 
126
  return base64.urlsafe_b64encode(raw.encode()).decode().rstrip("=")
127
 
128
 
129
+ def verify_token(token: str) -> Principal | None:
130
+ """Verify a bearer token of either format β†’ Principal, or None.
131
+
132
+ Order: org session JWT (api.identity.verify_session) first, then the
133
+ legacy single-admin HMAC token β€” which maps to org-demo/admin so the
134
+ pre-Phase-2 deployed demo keeps working unchanged.
135
+ """
136
+ if not token:
137
+ return None
138
+ try:
139
+ from api import identity
140
+ except ImportError: # identity deps not installed β€” legacy still works
141
+ identity = None
142
+ if identity is not None:
143
+ claims = identity.verify_session(token)
144
+ if claims:
145
+ return Principal(
146
+ user=claims["sub"],
147
+ user_id=claims["sub"],
148
+ org_id=claims["org_id"],
149
+ role=claims["role"],
150
+ )
151
+ username = _verify_legacy_token(token)
152
+ if username is not None:
153
+ return Principal(user=username, user_id=None,
154
+ org_id=LEGACY_ORG_ID, role=LEGACY_ROLE)
155
+ return None
156
+
157
+
158
+ def _verify_legacy_token(token: str) -> str | None:
159
+ """Return username if the legacy HMAC token is valid + unexpired."""
160
  if not token:
161
  return None
162
  try:
 
193
 
194
  PUBLIC_PATHS = {
195
  "/", "/health", "/login",
196
+ "/auth/login", "/auth/oidc/login", "/auth/oidc/callback",
197
  "/docs", "/openapi.json", "/redoc",
198
  }
199
  PUBLIC_PREFIXES = ("/videos/", "/media/") # static cached media
 
210
  return False
211
 
212
 
213
+ def _set_principal(request: Request, principal: Principal) -> None:
214
+ request.state.user = principal.user
215
+ request.state.user_id = principal.user_id
216
+ request.state.org_id = principal.org_id
217
+ request.state.role = principal.role
218
+
219
+
220
  async def auth_middleware(request: Request, call_next):
221
  """FastAPI ASGI middleware β€” reject unauthenticated requests."""
222
  if AUTH_DISABLED:
223
+ # Dev bypass runs as the legacy admin principal so org-scoped
224
+ # routes (request.state.org_id / require_role) keep working.
225
+ _set_principal(request, Principal(
226
+ user=AUTH_USER, user_id=None,
227
+ org_id=LEGACY_ORG_ID, role=LEGACY_ROLE,
228
+ ))
229
  return await call_next(request)
230
  if is_public(request.url.path, request.method):
231
  return await call_next(request)
 
238
  headers={"WWW-Authenticate": "Bearer"},
239
  )
240
  token = auth_hdr.split(" ", 1)[1].strip()
241
+ principal = verify_token(token)
242
+ if not principal:
243
  from fastapi.responses import JSONResponse
244
  return JSONResponse(
245
  {"detail": "invalid or expired token"}, status_code=401,
246
  headers={"WWW-Authenticate": "Bearer"},
247
  )
248
+ # Pass identity through so downstream routes can read it
249
+ _set_principal(request, principal)
250
  return await call_next(request)
251
+
252
+
253
+ # ── Role gating β€” FastAPI dependency factory ───────────────────────────────
254
+
255
+ def require_role(*roles: str):
256
+ """Dependency factory: require the verified session role to be in `roles`.
257
+
258
+ Usage:
259
+ @router.post("/thing", dependencies=[Depends(require_role("admin"))])
260
+ # or, to read the role:
261
+ def handler(role: str = Depends(require_role("admin", "sme"))): ...
262
+
263
+ Roles are matched exactly (no implicit hierarchy) β€” pass every role that
264
+ may call the route. With no arguments it only requires authentication.
265
+ Legacy tokens and the BU_AUTH_DISABLED=1 dev bypass carry role 'admin'.
266
+ """
267
+
268
+ def _dependency(request: Request) -> str:
269
+ role = getattr(request.state, "role", None)
270
+ if role is None:
271
+ # Public/unauthenticated path β€” the middleware never ran a
272
+ # verification for this request.
273
+ raise HTTPException(
274
+ status_code=401, detail="authentication required",
275
+ headers={"WWW-Authenticate": "Bearer"},
276
+ )
277
+ if roles and role not in roles:
278
+ raise HTTPException(status_code=403, detail="insufficient role")
279
+ return role
280
+
281
+ return _dependency
api/billing.py ADDED
@@ -0,0 +1,512 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Billing + marketplace licensing API β€” docs/HARDENING.md Phase 4.
3
+
4
+ Two routers, both mounted by api/server.py:
5
+
6
+ router β€” /billing/* license admin (org-scoped, T1 per
7
+ docs/TENANCY.md) + optional Stripe self-serve checkout
8
+ experts_router β€” /experts/{agent_id}/chat license-key-gated delivery
9
+ proxy (metered per call)
10
+
11
+ Stripe is OPTIONAL by design. Without STRIPE_API_KEY the platform still
12
+ issues 'manual' licenses via POST /billing/licenses (design partners pay by
13
+ invoice) and all gating / metering / revocation behaves identically; the
14
+ Stripe endpoints (/billing/checkout, /billing/webhook) then 404 cleanly.
15
+ With STRIPE_API_KEY (+ STRIPE_WEBHOOK_SECRET for the webhook) Stripe adds
16
+ self-serve subscription checkout on top: checkout.session.completed issues
17
+ the license, customer.subscription.deleted revokes it.
18
+
19
+ Key handling (atp/licensing.py stores only the key_id half β€” never the
20
+ secret):
21
+ * The plaintext licenseKey is returned exactly ONCE β€” in the POST
22
+ /billing/licenses response. Save it then or re-issue.
23
+ * Webhook-issued keys are DISCARDED here after issuance (the webhook
24
+ response goes to Stripe, not the customer) β€” delivery happens
25
+ out-of-band via the billing portal / support until a customer portal
26
+ ships.
27
+ * GET /billing/licenses never returns key material (keyId, the public
28
+ identifier half, is not key material).
29
+
30
+ Enforcement model for /experts/{agent_id}/chat: the route is on the
31
+ auth-middleware allowlist (bearer optional) but the handler calls
32
+ atp.licensing.check_license on EVERY request β€” a DB status read per call is
33
+ the enforcement, there is deliberately NO cache of check results, so
34
+ revoking a license cuts this path off on the very next call.
35
+
36
+ Org scoping: /billing/* admin routes take the org ONLY from verified
37
+ request.state (set by the auth middleware from the JWT β€” TENANCY.md layer 1).
38
+ The expert gate authenticates by license key instead; the license ROW
39
+ carries the org its usage is metered under.
40
+ """
41
+
42
+ from __future__ import annotations
43
+
44
+ import os
45
+ import re
46
+ import sys
47
+
48
+ from fastapi import APIRouter, Depends, Header, HTTPException, Request
49
+ from pydantic import BaseModel
50
+
51
+ from api.auth import require_role
52
+ from atp import licensing, store
53
+
54
+ router = APIRouter(prefix="/billing")
55
+ experts_router = APIRouter(prefix="/experts")
56
+
57
+ #: Backend spec for the expert delivery proxy (agents/backend.get_backend).
58
+ #: The dry-run default keeps the delivery path CI-exercisable with no keys.
59
+ EXPERT_SPEC_ENV = "ATP_EXPERT_SPEC"
60
+ EXPERT_SPEC_DEFAULT = "dryrun:candidate"
61
+
62
+
63
+ # ── Tenancy helper (same idiom as api/atp.py) ───────────────────────────────
64
+
65
+ def _org(request: Request) -> str:
66
+ """Org for this request β€” ONLY from verified auth state (TENANCY.md Β§4
67
+ layer 1). 'org-demo' when auth is disabled / legacy single-admin token."""
68
+ return getattr(request.state, "org_id", None) or "org-demo"
69
+
70
+
71
+ # ── Seed catalog helpers (T0 β€” atp/store.get_data) ─────────────────────────
72
+
73
+ def _agent(agent_id: str) -> dict | None:
74
+ for a in store.get_data().get("AGENTS", []):
75
+ if a.get("id") == agent_id:
76
+ return a
77
+ return None
78
+
79
+
80
+ def _top_cert_label(agent: dict) -> str:
81
+ """Label of the agent's highest-layer cert (Stripe product naming)."""
82
+ certs = {c.get("id"): c for c in store.get_data().get("CERTS", [])}
83
+ held = [certs[cid] for cid in (agent.get("certIds") or []) if cid in certs]
84
+ if not held:
85
+ return ""
86
+ top = max(held, key=lambda c: c.get("layer") or 0)
87
+ return str(top.get("label") or "")
88
+
89
+
90
+ def _unit_amount_usd(price: str | None) -> int | None:
91
+ """Seed licensing.price ('$4,800/mo') β†’ Stripe unit_amount cents."""
92
+ m = re.search(r"\$?\s*([0-9][0-9,]*(?:\.[0-9]+)?)", price or "")
93
+ if not m:
94
+ return None
95
+ return int(round(float(m.group(1).replace(",", "")) * 100))
96
+
97
+
98
+ def _license_for_subscription(org_id: str,
99
+ subscription_id: str | None) -> dict | None:
100
+ """The org's license bound to a Stripe subscription id, if any."""
101
+ if not subscription_id:
102
+ return None
103
+ for row in licensing.list_licenses(org_id):
104
+ if row.get("stripeSubscription") == subscription_id:
105
+ return row
106
+ return None
107
+
108
+
109
+ # ── Stripe (optional β€” endpoints 404 when unconfigured) ────────────────────
110
+
111
+ def _stripe_key() -> str | None:
112
+ return os.environ.get("STRIPE_API_KEY") or None
113
+
114
+
115
+ def _stripe_configured_or_404():
116
+ """Stripe endpoints don't exist without config β€” plain 404, no oracle."""
117
+ if not _stripe_key():
118
+ raise HTTPException(404, "Not Found")
119
+ try:
120
+ import stripe
121
+ except ImportError as e: # pragma: no cover β€” requirements.txt has it
122
+ raise HTTPException(
123
+ 503, "stripe library not installed (pip install stripe)") from e
124
+ stripe.api_key = _stripe_key()
125
+ return stripe
126
+
127
+
128
+ def _cancel_subscription_best_effort(subscription_id: str | None) -> bool:
129
+ """Cancel the Stripe subscription behind a revoked license. Best-effort
130
+ by contract: the license is already revoked in OUR ledger (the expert
131
+ gate is cut off regardless); a Stripe hiccup must not un-revoke it."""
132
+ if not subscription_id or not _stripe_key():
133
+ return False
134
+ try:
135
+ import stripe
136
+ stripe.api_key = _stripe_key()
137
+ stripe.Subscription.cancel(subscription_id)
138
+ return True
139
+ except Exception as e: # noqa: BLE001 β€” best-effort by contract
140
+ print(f"WARNING: stripe cancel of {subscription_id} failed: {e}",
141
+ file=sys.stderr)
142
+ return False
143
+
144
+
145
+ # ── License admin routes ────────────────────────────────────────────────────
146
+
147
+ class IssueBody(BaseModel):
148
+ agentId: str
149
+ kind: str = "production" # licensing.VALID_KINDS
150
+ expiresAt: str | None = None # ISO-8601 UTC; None = perpetual
151
+
152
+
153
+ @router.post("/licenses", dependencies=[Depends(require_role("admin"))])
154
+ def billing_issue(body: IssueBody, request: Request):
155
+ """Issue a 'manual' license for a marketplace agent (role admin).
156
+
157
+ The Stripe-less path (design partners pay by invoice). Returns
158
+ {license, licenseKey}; the plaintext licenseKey appears ONLY here β€”
159
+ the engine stores just its key_id half.
160
+ """
161
+ try:
162
+ return licensing.issue_license(_org(request), body.agentId, body.kind,
163
+ expires_at=body.expiresAt)
164
+ except licensing.UnknownAgentError as e:
165
+ raise HTTPException(404, str(e)) from e
166
+ except licensing.AgentNotLicensableError as e:
167
+ raise HTTPException(400, str(e)) from e
168
+ except ValueError as e: # bad kind / expires_at
169
+ raise HTTPException(400, str(e)) from e
170
+ except licensing.LicenseSigningKeyError as e:
171
+ raise HTTPException(503, str(e)) from e
172
+
173
+
174
+ @router.post("/licenses/{license_id}/reissue",
175
+ dependencies=[Depends(require_role("admin"))])
176
+ def billing_reissue(license_id: str, request: Request):
177
+ """Mint a fresh key for an active license (role admin). The previous key
178
+ stops working immediately. This is how Stripe self-serve customers get
179
+ their key: the webhook stores no plaintext, an org admin re-issues here
180
+ and delivers it. Key shown ONCE."""
181
+ try:
182
+ return licensing.reissue_key(_org(request), license_id)
183
+ except licensing.LicenseNotFoundError as e:
184
+ raise HTTPException(404, str(e)) from e
185
+ except ValueError as e:
186
+ raise HTTPException(400, str(e)) from e
187
+ except licensing.LicenseSigningKeyError as e:
188
+ raise HTTPException(503, str(e)) from e
189
+
190
+
191
+ @router.get("/licenses",
192
+ dependencies=[Depends(require_role("admin", "viewer"))])
193
+ def billing_licenses(request: Request):
194
+ """Org's licenses, latest-first. Never contains key material β€” keys are
195
+ shown once, at issue time (keyId is the public identifier half only)."""
196
+ return {"licenses": licensing.list_licenses(_org(request))}
197
+
198
+
199
+ class RevokeBody(BaseModel):
200
+ reason: str = ""
201
+
202
+
203
+ @router.post("/licenses/{license_id}/revoke",
204
+ dependencies=[Depends(require_role("admin"))])
205
+ def billing_revoke(license_id: str, body: RevokeBody, request: Request):
206
+ """Revoke a license (role admin, idempotent). Takes effect on the very
207
+ next /experts call β€” the gate re-reads DB state every time. Cross-tenant
208
+ ids 404 identically to unknown ids (TENANCY.md #6). When the license was
209
+ Stripe-issued AND Stripe is configured, the subscription is cancelled
210
+ too, best-effort."""
211
+ try:
212
+ revoked = licensing.revoke_license(
213
+ _org(request), license_id, body.reason.strip() or "revoked by admin")
214
+ except licensing.LicenseNotFoundError as e:
215
+ raise HTTPException(404, f"license {license_id} not found") from e
216
+ revoked["stripeCancelled"] = _cancel_subscription_best_effort(
217
+ revoked.get("stripeSubscription"))
218
+ return revoked
219
+
220
+
221
+ @router.get("/usage", dependencies=[Depends(require_role("admin", "viewer"))])
222
+ def billing_usage(request: Request, licenseId: str | None = None):
223
+ """Metered usage for the org (optionally one license):
224
+ {calls, tokensIn, tokensOut, byAgent}."""
225
+ return licensing.usage_summary(_org(request), license_id=licenseId)
226
+
227
+
228
+ # ── Stripe self-serve checkout ──────────────────────────────────────────────
229
+
230
+ class CheckoutBody(BaseModel):
231
+ agentId: str
232
+ kind: str = "production"
233
+
234
+
235
+ @router.post("/checkout", dependencies=[Depends(require_role("admin"))])
236
+ def billing_checkout(body: CheckoutBody, request: Request):
237
+ """Create a Stripe subscription Checkout Session for a seed listing.
238
+
239
+ 404 when STRIPE_API_KEY is unset (manual licensing still fully works).
240
+ Price comes from the seed agent's licensing.price; the license itself is
241
+ issued by the checkout.session.completed webhook, keyed by the metadata
242
+ written here ({org_id, agent_id, kind} β€” org from verified state ONLY,
243
+ so a tampered client cannot buy a license into another org).
244
+ """
245
+ stripe = _stripe_configured_or_404()
246
+ org = _org(request)
247
+ agent = _agent(body.agentId)
248
+ if agent is None:
249
+ raise HTTPException(404, f"agent {body.agentId} not found")
250
+ if body.kind not in licensing.VALID_KINDS:
251
+ raise HTTPException(
252
+ 400, f"kind must be one of {sorted(licensing.VALID_KINDS)}")
253
+ seed_licensing = agent.get("licensing") or {}
254
+ if not seed_licensing.get("available"):
255
+ raise HTTPException(400, f"agent {body.agentId} is not licensable")
256
+ unit_amount = _unit_amount_usd(seed_licensing.get("price"))
257
+ if not unit_amount:
258
+ raise HTTPException(
259
+ 400, f"agent {body.agentId} has no parseable price")
260
+
261
+ label = _top_cert_label(agent)
262
+ product_name = agent.get("name") or body.agentId
263
+ if label:
264
+ product_name = f"{product_name} β€” {label}"
265
+ metadata = {"org_id": org, "agent_id": body.agentId, "kind": body.kind}
266
+ base = (os.environ.get("BU_PUBLIC_URL")
267
+ or request.headers.get("origin")
268
+ or str(request.base_url)).rstrip("/")
269
+ try:
270
+ session = stripe.checkout.Session.create(
271
+ mode="subscription",
272
+ line_items=[{
273
+ "quantity": 1,
274
+ "price_data": {
275
+ "currency": "usd",
276
+ "unit_amount": unit_amount,
277
+ "recurring": {"interval": "month"},
278
+ "product_data": {"name": product_name},
279
+ },
280
+ }],
281
+ # Session metadata drives checkout.session.completed issuance;
282
+ # subscription_data.metadata puts the SAME keys on the
283
+ # subscription object so customer.subscription.deleted can
284
+ # resolve the org + license to revoke.
285
+ metadata=metadata,
286
+ subscription_data={"metadata": metadata},
287
+ success_url=(base +
288
+ "/#/billing/success?session_id={CHECKOUT_SESSION_ID}"),
289
+ cancel_url=base + "/#/billing/cancelled",
290
+ )
291
+ except Exception as e: # noqa: BLE001 β€” stripe SDK error zoo
292
+ raise HTTPException(502, f"stripe checkout failed: {e}") from e
293
+ return {"url": session.url, "sessionId": session.id}
294
+
295
+
296
+ # ── Stripe webhook (public path β€” the signature IS the authentication) ─────
297
+
298
+ @router.post("/webhook")
299
+ async def billing_webhook(request: Request):
300
+ """Stripe event sink. api/server.py adds this exact path to the auth
301
+ allowlist: Stripe cannot send a bearer β€” every delivery is authenticated
302
+ by its Stripe-Signature header, verified against STRIPE_WEBHOOK_SECRET
303
+ (invalid/missing signature β†’ 400; Stripe unconfigured β†’ 404).
304
+
305
+ Handled events (anything else is acknowledged and ignored):
306
+ checkout.session.completed β†’ issue the license from the session
307
+ metadata ({org_id, agent_id, kind} written by /billing/checkout),
308
+ storing the Stripe customer/subscription ids. Idempotent across
309
+ Stripe's retries (one license per subscription). The plaintext key
310
+ is DISCARDED here β€” only its key_id persists, so it is not
311
+ retrievable later (out-of-band delivery via the billing portal).
312
+ customer.subscription.deleted β†’ revoke the matching license
313
+ (reason 'subscription cancelled').
314
+
315
+ Responds 200 fast β€” handling is local DB writes only, no network calls.
316
+ """
317
+ if not _stripe_key() or not os.environ.get("STRIPE_WEBHOOK_SECRET"):
318
+ raise HTTPException(404, "Not Found")
319
+ import stripe
320
+
321
+ payload = await request.body()
322
+ sig_header = request.headers.get("stripe-signature", "")
323
+ try:
324
+ stripe.Webhook.construct_event(
325
+ payload, sig_header, os.environ["STRIPE_WEBHOOK_SECRET"])
326
+ except Exception as e: # noqa: BLE001 β€” bad payload OR bad signature
327
+ raise HTTPException(400, "invalid webhook signature") from e
328
+
329
+ # construct_event verified signature + JSON; work on the verified bytes
330
+ # as plain dicts (StripeObject's dict-likeness varies across versions).
331
+ import json
332
+ event = json.loads(payload)
333
+ etype = event.get("type", "")
334
+ obj = (event.get("data") or {}).get("object") or {}
335
+
336
+ if etype == "checkout.session.completed":
337
+ md = obj.get("metadata") or {}
338
+ org, agent_id = md.get("org_id"), md.get("agent_id")
339
+ kind = md.get("kind") or "production"
340
+ if not (org and agent_id):
341
+ return {"received": True, "handled": etype,
342
+ "skipped": "missing metadata"}
343
+ subscription = obj.get("subscription")
344
+ # Stripe retries deliveries β€” never double-issue for one sub.
345
+ if subscription and _license_for_subscription(org, subscription):
346
+ return {"received": True, "handled": etype,
347
+ "skipped": "already issued"}
348
+ try:
349
+ issued = licensing.issue_license(
350
+ org, agent_id, kind,
351
+ stripe_customer=obj.get("customer"),
352
+ stripe_subscription=subscription)
353
+ except (licensing.LicensingError, ValueError) as e:
354
+ # Ack (200) so Stripe stops retrying a permanently-bad event,
355
+ # but say what was wrong for the webhook log.
356
+ print(f"WARNING: webhook license issue failed: {e}",
357
+ file=sys.stderr)
358
+ return {"received": True, "handled": etype, "skipped": str(e)}
359
+ # Hash-only persistence: the plaintext key never leaves this scope.
360
+ return {"received": True, "handled": etype,
361
+ "licenseId": issued["license"]["id"]}
362
+
363
+ if etype == "customer.subscription.deleted":
364
+ md = obj.get("metadata") or {}
365
+ org, sub_id = md.get("org_id"), obj.get("id")
366
+ if not (org and sub_id):
367
+ return {"received": True, "handled": etype,
368
+ "skipped": "missing metadata"}
369
+ row = _license_for_subscription(org, sub_id)
370
+ if row is None:
371
+ return {"received": True, "handled": etype,
372
+ "skipped": "no matching license"}
373
+ licensing.revoke_license(org, row["id"], "subscription cancelled")
374
+ return {"received": True, "handled": etype, "licenseId": row["id"]}
375
+
376
+ return {"received": True, "ignored": etype}
377
+
378
+
379
+ # ── Expert delivery gate β€” /experts/{agent_id}/chat ─────────────────────────
380
+
381
+ class ChatBody(BaseModel):
382
+ messages: list[dict]
383
+
384
+
385
+ def _persona(agent: dict) -> str:
386
+ """Agent persona system prompt from T0 seed fields only (no tenant
387
+ data β€” TENANCY.md leak surface #5)."""
388
+ name = agent.get("name") or agent.get("id")
389
+ level = agent.get("level")
390
+ domains = ", ".join(agent.get("domains") or []) or "general"
391
+ skills = ", ".join(
392
+ s.get("name", "") for s in (agent.get("skills") or [])[:8])
393
+ summary = (agent.get("reportCard") or {}).get("summary", "")
394
+ boundaries = " ".join(
395
+ f.get("boundary", "") for f in (agent.get("failures") or [])[:2])
396
+ parts = [
397
+ f"You are {name}, an ATP Level {level} certified expert agent "
398
+ f"(domains: {domains}).",
399
+ f"Certified skills: {skills}." if skills else "",
400
+ summary,
401
+ f"Known boundaries: {boundaries}" if boundaries else "",
402
+ "Answer within your certified scope; when a request falls outside "
403
+ "it, say so plainly and recommend human review.",
404
+ ]
405
+ return " ".join(p for p in parts if p)
406
+
407
+
408
+ def _flatten_messages(messages: list[dict]) -> str:
409
+ """[{role, content}, ...] β†’ one transcript string (backends take a
410
+ single system + user pair, agents/backend.Backend.complete)."""
411
+ parts = []
412
+ for m in messages:
413
+ if not isinstance(m, dict):
414
+ continue
415
+ role = str(m.get("role", "user")).strip().lower() or "user"
416
+ content = str(m.get("content", "")).strip()
417
+ if content:
418
+ parts.append(f"{role.capitalize()}: {content}")
419
+ return "\n\n".join(parts)
420
+
421
+
422
+ def _license_org(license_key: str) -> str | None:
423
+ """Org the checked license row belongs to.
424
+
425
+ check_license()'s public license dict intentionally carries no org key
426
+ (atp/store.py convention), but metering must land under the license
427
+ ROW's org (T1) β€” never under a caller-supplied value, and the keyed
428
+ machine caller has no bearer/org at all. Resolve it through the same
429
+ key-id lookup check_license used; the in-package coupling to
430
+ licensing's _parse_key/_load_by_key_id is deliberate β€” the alternative
431
+ (raw licenses SQL here) would break the DAL-only convention
432
+ (TENANCY.md layer 2).
433
+ """
434
+ parsed = licensing._parse_key(license_key)
435
+ if not parsed:
436
+ return None
437
+ row = licensing._load_by_key_id(parsed[0])
438
+ return (row or {}).get("org_id")
439
+
440
+
441
+ _DENIED_403 = ("revoked", "expired")
442
+
443
+
444
+ @experts_router.post("/{agent_id}/chat")
445
+ def expert_chat(
446
+ agent_id: str,
447
+ body: ChatBody,
448
+ request: Request,
449
+ license_key: str | None = Header(default=None, alias="X-ATP-License-Key"),
450
+ ):
451
+ """Licensed expert delivery (HARDENING.md Phase 4).
452
+
453
+ Authentication is the LICENSE KEY (X-ATP-License-Key header), not a
454
+ bearer β€” api/server.py puts '/experts/' on the middleware allowlist and
455
+ THIS handler enforces instead: atp.licensing.check_license runs on EVERY
456
+ request (a DB status read β€” deliberately uncached, so revocation cuts
457
+ the path off on the very next call). 401 invalid key / 403 revoked or
458
+ expired (with the reason). Every successful call appends a usage_events
459
+ row (metering).
460
+ """
461
+ if not license_key or not license_key.strip():
462
+ raise HTTPException(
463
+ 401, "missing license key (X-ATP-License-Key header)")
464
+ license_key = license_key.strip()
465
+
466
+ try:
467
+ check = licensing.check_license(license_key)
468
+ except licensing.LicenseSigningKeyError as e:
469
+ raise HTTPException(503, str(e)) from e
470
+ if not check.get("ok"):
471
+ reason = str(check.get("reason") or "invalid")
472
+ if reason in _DENIED_403:
473
+ raise HTTPException(403, f"license {reason}")
474
+ raise HTTPException(401, f"invalid license key ({reason})")
475
+ lic = check.get("license") or {}
476
+
477
+ if lic.get("agentId") != agent_id:
478
+ raise HTTPException(
479
+ 403, f"license is for agent {lic.get('agentId')}, not {agent_id}")
480
+
481
+ agent = _agent(agent_id)
482
+ if agent is None: # licensed against a since-removed seed agent
483
+ raise HTTPException(404, f"agent {agent_id} not found")
484
+
485
+ transcript = _flatten_messages(body.messages or [])
486
+ if not transcript:
487
+ raise HTTPException(400, "messages must contain at least one "
488
+ "non-empty {role, content} entry")
489
+
490
+ from agents.backend import get_backend
491
+ spec = os.environ.get(EXPERT_SPEC_ENV, EXPERT_SPEC_DEFAULT)
492
+ system = _persona(agent)
493
+ try:
494
+ reply = get_backend(spec).complete(system, transcript)
495
+ except Exception as e: # noqa: BLE001 β€” backend/network failures
496
+ raise HTTPException(503, f"expert backend unavailable: {e}") from e
497
+
498
+ # Metering β€” approx token counts (chars/4). No usage row, no reply:
499
+ # usage_events is the append-only billing record (HARDENING.md Phase 4).
500
+ org = _license_org(license_key) or "org-demo"
501
+ try:
502
+ usage = licensing.record_usage(
503
+ org, lic.get("id"), agent_id,
504
+ endpoint=f"/experts/{agent_id}/chat",
505
+ tokens_in=max(1, (len(system) + len(transcript)) // 4),
506
+ tokens_out=max(1, len(reply or "") // 4),
507
+ status="ok",
508
+ )
509
+ except Exception as e: # noqa: BLE001
510
+ raise HTTPException(503, f"usage metering failed: {e}") from e
511
+
512
+ return {"reply": reply, "usage": usage}
api/identity.py ADDED
@@ -0,0 +1,474 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Org-scoped identity for Brain University β€” Phase 2 (docs/TENANCY.md).
3
+
4
+ Three ways to obtain a session:
5
+ 1. POST /auth/login {email, password} β€” local accounts (argon2id)
6
+ 2. GET /auth/oidc/login β†’ GET /auth/oidc/callback β€” OIDC authorization-code
7
+ flow (issuer/client from env; endpoints 404 when OIDC_ISSUER is unset)
8
+ 3. Legacy POST /login (api/server.py) β€” single-admin HMAC token;
9
+ api.auth.verify_token() maps it to org 'org-demo' with role 'admin', so
10
+ the deployed demo keeps working with zero new env.
11
+
12
+ Session = JWT (HS256 over BU_AUTH_SECRET), claims {sub, org_id, role, exp}.
13
+ `sub` is users.user_id (the identity spec's `id` β€” see migrations/004).
14
+
15
+ Env (all optional; partial sets fail loudly, P0 style):
16
+ BU_BOOTSTRAP_ADMIN_EMAIL β€” with _PASSWORD: provision this local admin in
17
+ BU_BOOTSTRAP_ADMIN_PASSWORD org 'org-demo' on first use if absent.
18
+ One without the other = RuntimeError.
19
+ OIDC_ISSUER β€” enables /auth/oidc/*; discovery at
20
+ {OIDC_ISSUER}/.well-known/openid-configuration
21
+ OIDC_CLIENT_ID β€” REQUIRED once OIDC_ISSUER is set
22
+ OIDC_CLIENT_SECRET β€” REQUIRED once OIDC_ISSUER is set
23
+ OIDC_REDIRECT_URL β€” REQUIRED once OIDC_ISSUER is set; must match
24
+ the IdP-registered callback
25
+ (https://host/auth/oidc/callback)
26
+
27
+ OIDC org resolution: user linked by (iss, sub); on first login the verified
28
+ email's domain must match an orgs.domain row, else 403 'no org for domain'.
29
+ OIDC users default to role 'viewer'.
30
+
31
+ Mounted by api/server.py:
32
+ from api.identity import router as identity_router
33
+ app.include_router(identity_router)
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ import os
39
+ import secrets
40
+ import threading
41
+ import time
42
+ from urllib.parse import urlencode
43
+
44
+ import httpx
45
+ import jwt
46
+ from argon2 import PasswordHasher
47
+ from argon2.exceptions import InvalidHashError, VerificationError, VerifyMismatchError
48
+ from fastapi import APIRouter, HTTPException
49
+ from fastapi.responses import RedirectResponse
50
+ from pydantic import BaseModel
51
+
52
+ from api import auth as _auth # BU_AUTH_SECRET / TTL; auth does NOT import us at module level
53
+
54
+ BOOTSTRAP_ORG_ID = "org-demo" # seeded by migrations/004
55
+ ROLES = ("admin", "sme", "viewer")
56
+
57
+ SESSION_ALGO = "HS256"
58
+ STATE_TTL_S = 600 # OIDC state/nonce validity: 10 minutes
59
+ _STATE_AUD = "bu-oidc-state" # aud claim keeps state JWTs unusable as sessions
60
+ _ID_TOKEN_ALGOS = ["RS256", "RS384", "RS512", "ES256", "ES384", "PS256"]
61
+
62
+ # ── Env (validated at import β€” P0 fail-loud style) ──────────────────────────
63
+
64
+ OIDC_ISSUER = os.environ.get("OIDC_ISSUER", "").strip()
65
+ OIDC_CLIENT_ID = os.environ.get("OIDC_CLIENT_ID", "").strip()
66
+ OIDC_CLIENT_SECRET = os.environ.get("OIDC_CLIENT_SECRET", "")
67
+ OIDC_REDIRECT_URL = os.environ.get("OIDC_REDIRECT_URL", "").strip()
68
+
69
+ if OIDC_ISSUER:
70
+ _missing = [name for name, val in (
71
+ ("OIDC_CLIENT_ID", OIDC_CLIENT_ID),
72
+ ("OIDC_CLIENT_SECRET", OIDC_CLIENT_SECRET),
73
+ ("OIDC_REDIRECT_URL", OIDC_REDIRECT_URL),
74
+ ) if not val]
75
+ if _missing:
76
+ raise RuntimeError(
77
+ "Refusing to start: OIDC_ISSUER is set but required OIDC env "
78
+ "var(s) unset: " + ", ".join(_missing)
79
+ + ". Set them all, or unset OIDC_ISSUER to disable OIDC "
80
+ "(local + legacy logins keep working without it)."
81
+ )
82
+
83
+
84
+ def _bootstrap_env() -> tuple[str, str] | None:
85
+ """(email, password) when both set, None when both unset; partial raises."""
86
+ email = os.environ.get("BU_BOOTSTRAP_ADMIN_EMAIL", "").strip()
87
+ password = os.environ.get("BU_BOOTSTRAP_ADMIN_PASSWORD", "")
88
+ if not email and not password:
89
+ return None
90
+ if not (email and password):
91
+ missing = "BU_BOOTSTRAP_ADMIN_PASSWORD" if email else "BU_BOOTSTRAP_ADMIN_EMAIL"
92
+ raise RuntimeError(
93
+ f"Refusing to start: {missing} unset while its counterpart is "
94
+ "set. Set both BU_BOOTSTRAP_ADMIN_EMAIL and "
95
+ "BU_BOOTSTRAP_ADMIN_PASSWORD, or neither."
96
+ )
97
+ return email.lower(), password
98
+
99
+
100
+ _bootstrap_env() # fail loudly at import on partial env
101
+
102
+
103
+ # ── Password hashing (argon2id) ─────────────────────────────────────────────
104
+
105
+ _hasher = PasswordHasher() # argon2id, library-default parameters
106
+
107
+
108
+ def hash_password(password: str) -> str:
109
+ return _hasher.hash(password)
110
+
111
+
112
+ def verify_password(pw_hash: str, password: str) -> bool:
113
+ try:
114
+ return _hasher.verify(pw_hash, password)
115
+ except (VerifyMismatchError, VerificationError, InvalidHashError):
116
+ return False
117
+
118
+
119
+ # ── Session JWTs β€” claims {sub, org_id, role, exp} ──────────────────────────
120
+
121
+ def mint_session(user: dict, ttl_s: int | None = None) -> str:
122
+ """Issue a session JWT for a users row (dict with id/org_id/role)."""
123
+ exp = int(time.time()) + (ttl_s or _auth.AUTH_TTL_S)
124
+ claims = {
125
+ "sub": user["id"],
126
+ "org_id": user["org_id"],
127
+ "role": user["role"],
128
+ "exp": exp,
129
+ }
130
+ return jwt.encode(claims, _auth.AUTH_SECRET, algorithm=SESSION_ALGO)
131
+
132
+
133
+ def verify_session(token: str) -> dict | None:
134
+ """Return the session claims when `token` is a valid, unexpired org JWT.
135
+
136
+ None for anything else (legacy HMAC tokens, state tokens, garbage) β€”
137
+ api.auth.verify_token() then falls back to the legacy verifier.
138
+ """
139
+ if not token or token.count(".") != 2: # fast path: not a JWT at all
140
+ return None
141
+ try:
142
+ claims = jwt.decode(token, _auth.AUTH_SECRET, algorithms=[SESSION_ALGO])
143
+ except jwt.InvalidTokenError:
144
+ return None
145
+ if not all(claims.get(k) for k in ("sub", "org_id", "role")):
146
+ return None
147
+ if claims["role"] not in ROLES:
148
+ return None
149
+ return claims
150
+
151
+
152
+ # ── DB helpers (users/orgs β€” migrations/004; users.user_id is the id) ───────
153
+
154
+ _USER_COLS = ("user_id, display_name, created_at, org_id, email, role, "
155
+ "oidc_iss, oidc_sub, pw_hash")
156
+
157
+
158
+ def _db():
159
+ from atp import db
160
+ return db
161
+
162
+
163
+ _MIGRATED = False
164
+
165
+
166
+ def _ensure_db() -> None:
167
+ """Idempotent lazy migration (same pattern as atp/store.py)."""
168
+ global _MIGRATED
169
+ if not _MIGRATED:
170
+ _db().run_migrations()
171
+ _MIGRATED = True
172
+
173
+
174
+ def _to_user(row: dict) -> dict:
175
+ user = dict(row)
176
+ user["id"] = user.pop("user_id")
177
+ return user
178
+
179
+
180
+ def get_user_by_email(email: str) -> dict | None:
181
+ rows = _db().query(
182
+ f"SELECT {_USER_COLS} FROM users WHERE email = :email",
183
+ {"email": email.strip().lower()},
184
+ )
185
+ return _to_user(rows[0]) if rows else None
186
+
187
+
188
+ def get_user_by_oidc(iss: str, sub: str) -> dict | None:
189
+ rows = _db().query(
190
+ f"SELECT {_USER_COLS} FROM users"
191
+ " WHERE oidc_iss = :iss AND oidc_sub = :sub",
192
+ {"iss": iss, "sub": sub},
193
+ )
194
+ return _to_user(rows[0]) if rows else None
195
+
196
+
197
+ def get_org_by_domain(domain: str) -> dict | None:
198
+ rows = _db().query(
199
+ "SELECT id, name, domain, created_at FROM orgs"
200
+ " WHERE domain IS NOT NULL AND lower(domain) = lower(:domain)",
201
+ {"domain": domain},
202
+ )
203
+ return dict(rows[0]) if rows else None
204
+
205
+
206
+ def create_user(*, email: str, org_id: str, role: str,
207
+ oidc_iss: str | None = None, oidc_sub: str | None = None,
208
+ pw_hash: str | None = None,
209
+ display_name: str | None = None) -> dict:
210
+ """Insert an identity user; returns the created (or racing-winner) row."""
211
+ if role not in ROLES:
212
+ raise ValueError(f"role must be one of {ROLES}, got {role!r}")
213
+ email = email.strip().lower()
214
+ try:
215
+ _db().execute(
216
+ "INSERT INTO users (user_id, display_name, created_at, org_id,"
217
+ " email, role, oidc_iss, oidc_sub, pw_hash)"
218
+ " VALUES (:id, :name, :ts, :org, :email, :role, :iss, :sub, :pw)",
219
+ {
220
+ "id": "u-" + secrets.token_hex(8),
221
+ "name": display_name or email,
222
+ "ts": time.time(), # users.created_at is REAL (001)
223
+ "org": org_id,
224
+ "email": email,
225
+ "role": role,
226
+ "iss": oidc_iss,
227
+ "sub": oidc_sub,
228
+ "pw": pw_hash,
229
+ },
230
+ )
231
+ except Exception:
232
+ # Unique-email race: another worker inserted first β€” use theirs.
233
+ existing = get_user_by_email(email)
234
+ if existing is None:
235
+ raise
236
+ return existing
237
+ user = get_user_by_email(email)
238
+ assert user is not None
239
+ return user
240
+
241
+
242
+ def link_user_oidc(user_id: str, iss: str, sub: str) -> None:
243
+ _db().execute(
244
+ "UPDATE users SET oidc_iss = :iss, oidc_sub = :sub"
245
+ " WHERE user_id = :id",
246
+ {"iss": iss, "sub": sub, "id": user_id},
247
+ )
248
+
249
+
250
+ # ── Bootstrap admin (local fallback account, org-demo) ──────────────────────
251
+
252
+ def provision_bootstrap_admin() -> dict | None:
253
+ """Create the BU_BOOTSTRAP_ADMIN_* local admin in org-demo if absent.
254
+
255
+ Returns the user row (existing or created), or None when the env pair is
256
+ unset. Partial env = RuntimeError (P0 fail-loud style). Idempotent.
257
+ """
258
+ pair = _bootstrap_env()
259
+ if pair is None:
260
+ return None
261
+ email, password = pair
262
+ _ensure_db()
263
+ existing = get_user_by_email(email)
264
+ if existing is not None:
265
+ return existing
266
+ return create_user(
267
+ email=email,
268
+ org_id=BOOTSTRAP_ORG_ID,
269
+ role="admin",
270
+ pw_hash=hash_password(password),
271
+ display_name="Bootstrap admin",
272
+ )
273
+
274
+
275
+ _READY = False
276
+ _READY_LOCK = threading.Lock()
277
+
278
+
279
+ def _ensure_ready() -> None:
280
+ """Migrations + bootstrap provisioning, once per process, thread-safe."""
281
+ global _READY
282
+ if _READY:
283
+ return
284
+ with _READY_LOCK:
285
+ if _READY:
286
+ return
287
+ _ensure_db()
288
+ provision_bootstrap_admin()
289
+ _READY = True
290
+
291
+
292
+ # ── OIDC β€” discovery, JWKS, signed state ────────────────────────────────────
293
+
294
+ _DISCOVERY_TTL_S = 3600
295
+ _discovery_cache: dict = {}
296
+ _jwks_clients: dict[str, jwt.PyJWKClient] = {}
297
+
298
+
299
+ def oidc_enabled() -> bool:
300
+ return bool(OIDC_ISSUER)
301
+
302
+
303
+ def _discovery() -> dict:
304
+ """Fetch (and cache) the issuer's openid-configuration."""
305
+ now = time.time()
306
+ if _discovery_cache.get("cfg") and now - _discovery_cache["fetched"] < _DISCOVERY_TTL_S:
307
+ return _discovery_cache["cfg"]
308
+ url = OIDC_ISSUER.rstrip("/") + "/.well-known/openid-configuration"
309
+ try:
310
+ resp = httpx.get(url, timeout=10.0)
311
+ resp.raise_for_status()
312
+ cfg = resp.json()
313
+ except httpx.HTTPError as e:
314
+ raise HTTPException(502, f"OIDC discovery failed: {e.__class__.__name__}") from e
315
+ for key in ("authorization_endpoint", "token_endpoint", "jwks_uri", "issuer"):
316
+ if key not in cfg:
317
+ raise HTTPException(502, f"OIDC discovery document missing {key!r}")
318
+ _discovery_cache.update(cfg=cfg, fetched=now)
319
+ return cfg
320
+
321
+
322
+ def _jwks_client(jwks_uri: str) -> jwt.PyJWKClient:
323
+ client = _jwks_clients.get(jwks_uri)
324
+ if client is None:
325
+ client = jwt.PyJWKClient(jwks_uri, cache_keys=True)
326
+ _jwks_clients[jwks_uri] = client
327
+ return client
328
+
329
+
330
+ def _mint_state(nonce: str) -> str:
331
+ """Signed CSRF state, 10 min TTL. aud keeps it unusable as a session."""
332
+ claims = {"aud": _STATE_AUD, "nonce": nonce,
333
+ "exp": int(time.time()) + STATE_TTL_S}
334
+ return jwt.encode(claims, _auth.AUTH_SECRET, algorithm=SESSION_ALGO)
335
+
336
+
337
+ def _verify_state(state: str) -> dict | None:
338
+ if not state:
339
+ return None
340
+ try:
341
+ return jwt.decode(state, _auth.AUTH_SECRET,
342
+ algorithms=[SESSION_ALGO], audience=_STATE_AUD)
343
+ except jwt.InvalidTokenError:
344
+ return None
345
+
346
+
347
+ # ── Routes ──────────────────────────────────────────────────────────────────
348
+
349
+ router = APIRouter()
350
+
351
+
352
+ def _public_user(user: dict) -> dict:
353
+ return {"id": user["id"], "email": user["email"],
354
+ "org_id": user["org_id"], "role": user["role"]}
355
+
356
+
357
+ class LoginBody(BaseModel):
358
+ email: str
359
+ password: str
360
+
361
+
362
+ @router.post("/auth/login")
363
+ def local_login(body: LoginBody):
364
+ """Local-account login β†’ session JWT with {sub, org_id, role, exp}."""
365
+ _ensure_ready()
366
+ user = get_user_by_email(body.email)
367
+ ok = (user is not None and user.get("pw_hash")
368
+ and verify_password(user["pw_hash"], body.password))
369
+ if not ok:
370
+ time.sleep(0.25) # match legacy /login: slow credential stuffing
371
+ raise HTTPException(401, "invalid credentials")
372
+ return {"token": mint_session(user), "user": _public_user(user)}
373
+
374
+
375
+ @router.get("/auth/oidc/login")
376
+ def oidc_login():
377
+ """Redirect to the IdP's authorization endpoint (code flow)."""
378
+ if not oidc_enabled():
379
+ raise HTTPException(404, "Not Found")
380
+ cfg = _discovery()
381
+ nonce = secrets.token_urlsafe(16)
382
+ params = {
383
+ "response_type": "code",
384
+ "client_id": OIDC_CLIENT_ID,
385
+ "redirect_uri": OIDC_REDIRECT_URL,
386
+ "scope": "openid email profile",
387
+ "state": _mint_state(nonce),
388
+ "nonce": nonce,
389
+ }
390
+ return RedirectResponse(
391
+ cfg["authorization_endpoint"] + "?" + urlencode(params),
392
+ status_code=302,
393
+ )
394
+
395
+
396
+ @router.get("/auth/oidc/callback")
397
+ def oidc_callback(code: str = "", state: str = "", error: str = "",
398
+ error_description: str = ""):
399
+ """Exchange the code, verify the id_token, provision/link, mint session."""
400
+ if not oidc_enabled():
401
+ raise HTTPException(404, "Not Found")
402
+ _ensure_ready()
403
+ if error:
404
+ raise HTTPException(400, f"OIDC error: {error} {error_description}".strip())
405
+ st = _verify_state(state)
406
+ if st is None:
407
+ raise HTTPException(400, "invalid or expired state")
408
+ if not code:
409
+ raise HTTPException(400, "missing code")
410
+
411
+ cfg = _discovery()
412
+ try:
413
+ resp = httpx.post(
414
+ cfg["token_endpoint"],
415
+ data={
416
+ "grant_type": "authorization_code",
417
+ "code": code,
418
+ "redirect_uri": OIDC_REDIRECT_URL,
419
+ "client_id": OIDC_CLIENT_ID,
420
+ "client_secret": OIDC_CLIENT_SECRET,
421
+ },
422
+ timeout=10.0,
423
+ )
424
+ resp.raise_for_status()
425
+ id_token = resp.json().get("id_token", "")
426
+ except httpx.HTTPError as e:
427
+ raise HTTPException(502, f"OIDC token exchange failed: {e.__class__.__name__}") from e
428
+ if not id_token:
429
+ raise HTTPException(502, "OIDC token response had no id_token")
430
+
431
+ try:
432
+ signing_key = _jwks_client(cfg["jwks_uri"]).get_signing_key_from_jwt(id_token)
433
+ claims = jwt.decode(
434
+ id_token,
435
+ signing_key.key,
436
+ algorithms=_ID_TOKEN_ALGOS,
437
+ audience=OIDC_CLIENT_ID,
438
+ issuer=cfg["issuer"],
439
+ leeway=30,
440
+ )
441
+ except jwt.PyJWKClientError as e:
442
+ raise HTTPException(502, f"OIDC JWKS fetch failed: {e.__class__.__name__}") from e
443
+ except jwt.InvalidTokenError:
444
+ raise HTTPException(401, "invalid id_token")
445
+ if claims.get("nonce") != st["nonce"]:
446
+ raise HTTPException(401, "nonce mismatch")
447
+
448
+ iss, sub = claims["iss"], claims["sub"]
449
+ user = get_user_by_oidc(iss, sub)
450
+ if user is None:
451
+ email = (claims.get("email") or "").strip().lower()
452
+ if not email:
453
+ raise HTTPException(403, "id_token has no email claim")
454
+ if claims.get("email_verified") is False:
455
+ raise HTTPException(403, "email not verified by identity provider")
456
+ existing = get_user_by_email(email)
457
+ if existing is not None:
458
+ # Same verified email as an existing account β†’ link identities.
459
+ link_user_oidc(existing["id"], iss, sub)
460
+ user = get_user_by_email(email)
461
+ else:
462
+ domain = email.split("@", 1)[1]
463
+ org = get_org_by_domain(domain)
464
+ if org is None:
465
+ raise HTTPException(403, "no org for domain")
466
+ user = create_user(
467
+ email=email,
468
+ org_id=org["id"],
469
+ role="viewer", # OIDC default; org admins promote later
470
+ oidc_iss=iss,
471
+ oidc_sub=sub,
472
+ display_name=claims.get("name") or email,
473
+ )
474
+ return {"token": mint_session(user), "user": _public_user(user)}
api/knowledge.py ADDED
@@ -0,0 +1,262 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Company-knowledge API routes β€” Phase 6 (docs/HARDENING.md first-customer UX).
3
+
4
+ Mounted by api/server.py:
5
+ from api.knowledge import router as knowledge_router
6
+ app.include_router(knowledge_router)
7
+
8
+ Endpoints (bearer-gated by the default auth middleware β€” api/auth.py):
9
+ POST /knowledge/docs β€” multipart upload (md/txt/pdf,
10
+ <= 5 MB) β†’ doc dict [admin|sme]
11
+ GET /knowledge/docs β€” org's docs, summaries only [any role]
12
+ GET /knowledge/docs/{id} β€” full decrypted text [admin|sme]
13
+ POST /knowledge/drafts β€” {docIds, title} β†’ generated L5
14
+ draft (blueprint + decrypted items
15
+ for review) [admin|sme]
16
+ GET /knowledge/drafts β€” org's drafts [any role; items (T2)
17
+ included only for admin|sme]
18
+ POST /knowledge/drafts/{id}/approve β€” {note} β†’ sign-off dict [sme ONLY]
19
+ POST /knowledge/drafts/{id}/reject β€” {note} β†’ sign-off dict [sme ONLY]
20
+ POST /knowledge/drafts/{id}/exam β€” {candidateSpec?, agentId?,
21
+ dryRun=true} β†’ {jobId}; job-backed
22
+ like /atp/exams/run [admin|sme]
23
+
24
+ Tenancy (docs/TENANCY.md β€” this is T2 "crown jewels" data):
25
+ * org_id comes ONLY from verified request state (request.state.org_id, set
26
+ by the auth middleware from the JWT) β€” never from body/query. All storage
27
+ is org-scoped + encrypted at rest in atp/knowledge.py.
28
+ * Uploads are stored as encrypted DB columns ONLY β€” no file ever lands on
29
+ disk, and in particular never under the public /videos or /media
30
+ prefixes (leak surface #4).
31
+ * Cross-tenant/unknown ids β†’ the same 404 (no existence oracle, #6).
32
+
33
+ Separation of duties: admins (or SMEs) manage docs and generate drafts, but
34
+ ONLY role 'sme' can approve/reject β€” an admin must not be able to sign off
35
+ the exam content they themselves assembled. The BU_AUTH_DISABLED=1 dev
36
+ bypass (which runs every request as org-demo with role 'admin') is exempted
37
+ from that one gate so the demo flow keeps working end-to-end; with auth
38
+ enabled there is no exemption.
39
+ """
40
+
41
+ from __future__ import annotations
42
+
43
+ from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile
44
+ from pydantic import BaseModel
45
+
46
+ from api.auth import require_role
47
+ from atp import knowledge
48
+
49
+ router = APIRouter(prefix="/knowledge")
50
+
51
+ #: Upload size cap (bytes) β€” Phase 6 contract: 5 MB.
52
+ MAX_UPLOAD_BYTES = 5 * 1024 * 1024
53
+
54
+
55
+ # ── Helpers ──────────────────────────────────────────────────────────────────
56
+
57
+ def _org(request: Request) -> str:
58
+ """Org for this request β€” ONLY from verified auth state (TENANCY.md Β§4
59
+ layer 1). 'org-demo' when auth is disabled / legacy single-admin token."""
60
+ return getattr(request.state, "org_id", None) or "org-demo"
61
+
62
+
63
+ def _user(request: Request) -> str:
64
+ return getattr(request.state, "user", None) or "anon"
65
+
66
+
67
+ def require_sme(request: Request) -> str:
68
+ """Sign-off gate: role 'sme' ONLY (separation of duties β€” admins manage,
69
+ SMEs sign). Dev exception: the BU_AUTH_DISABLED=1 bypass carries role
70
+ 'admin' for every request (api/auth.py), so it is allowed through here β€”
71
+ otherwise the demo could never exercise approval. With auth enabled the
72
+ gate is strict."""
73
+ role = getattr(request.state, "role", None)
74
+ if role is None:
75
+ raise HTTPException(
76
+ status_code=401, detail="authentication required",
77
+ headers={"WWW-Authenticate": "Bearer"},
78
+ )
79
+ from api import auth as _auth
80
+ if role != "sme" and not _auth.AUTH_DISABLED:
81
+ raise HTTPException(
82
+ 403, "draft sign-off requires role 'sme' (separation of duties: "
83
+ "admins manage documents and drafts; SMEs sign)")
84
+ return role
85
+
86
+
87
+ def _http_errors(fn, *args, **kwargs):
88
+ """Run a knowledge-module call, translating its typed errors to HTTP.
89
+
90
+ UnsupportedDocumentError β†’ 415, KnowledgeError β†’ 400, DraftStateError β†’
91
+ 409. The fail-loud missing-DATA_ENCRYPTION_KEY RuntimeError from
92
+ atp/crypto.py becomes a 503 with an actionable message (T2 content is
93
+ NEVER written or served as plaintext instead)."""
94
+ try:
95
+ return fn(*args, **kwargs)
96
+ except knowledge.UnsupportedDocumentError as e:
97
+ raise HTTPException(415, str(e)) from None
98
+ except knowledge.KnowledgeError as e:
99
+ raise HTTPException(400, str(e)) from None
100
+ except knowledge.DraftStateError as e:
101
+ raise HTTPException(409, str(e)) from None
102
+ except RuntimeError as e:
103
+ if "DATA_ENCRYPTION_KEY" in str(e):
104
+ raise HTTPException(
105
+ 503, "DATA_ENCRYPTION_KEY is not configured on this "
106
+ "deployment β€” company-knowledge content is T2 and is "
107
+ "only ever stored/served encrypted (docs/TENANCY.md)",
108
+ ) from None
109
+ raise
110
+
111
+
112
+ # ── Documents ────────────────────────────────────────────────────────────────
113
+
114
+ @router.post("/docs", dependencies=[Depends(require_role("admin", "sme"))])
115
+ async def upload_doc(request: Request, file: UploadFile = File(...)):
116
+ """Upload one company document (md/txt/pdf, <= 5 MB) into the org vault.
117
+
118
+ Content is encrypted under the org's subkey before it touches the DB; the
119
+ response returns metadata + the heuristic summary, not the full text.
120
+ """
121
+ if knowledge.doc_kind(file.filename, file.content_type) is None:
122
+ raise HTTPException(
123
+ 415, f"unsupported document type (filename={file.filename!r}, "
124
+ f"content-type={file.content_type!r}) β€” allowed: .md, .txt, "
125
+ f".pdf")
126
+ data = await file.read(MAX_UPLOAD_BYTES + 1)
127
+ if len(data) > MAX_UPLOAD_BYTES:
128
+ raise HTTPException(
129
+ 413, f"file exceeds the {MAX_UPLOAD_BYTES // (1024 * 1024)} MB "
130
+ f"upload cap")
131
+ if not data:
132
+ raise HTTPException(400, "empty upload")
133
+ return _http_errors(
134
+ knowledge.ingest_doc, _org(request), file.filename,
135
+ file.content_type, data, uploaded_by=_user(request))
136
+
137
+
138
+ @router.get("/docs", dependencies=[Depends(require_role())])
139
+ def get_docs(request: Request):
140
+ """Org's vault docs, latest first β€” decrypted SUMMARIES only (any
141
+ authenticated role; full text stays behind the admin|sme route)."""
142
+ return {"docs": _http_errors(knowledge.list_docs, _org(request))}
143
+
144
+
145
+ @router.get("/docs/{doc_id}",
146
+ dependencies=[Depends(require_role("admin", "sme"))])
147
+ def get_doc(doc_id: str, request: Request):
148
+ """One doc with the full decrypted text (admin|sme). Cross-tenant ids
149
+ 404 with the SAME message as unknown ids (TENANCY.md #6)."""
150
+ doc = _http_errors(knowledge.get_doc, _org(request), doc_id)
151
+ if doc is None:
152
+ raise HTTPException(404, f"doc {doc_id} not found")
153
+ return doc
154
+
155
+
156
+ # ── L5 drafts ────────────────────────────────────────────────────────────────
157
+
158
+ class DraftBody(BaseModel):
159
+ docIds: list[str]
160
+ title: str = ""
161
+
162
+
163
+ @router.post("/drafts", dependencies=[Depends(require_role("admin", "sme"))])
164
+ def create_draft(body: DraftBody, request: Request):
165
+ """Generate a deterministic L5 exam draft from the given vault docs
166
+ (blueprint sections = doc titles; >= 8 recall/apply items). The response
167
+ includes the DECRYPTED items so the reviewer can read them β€” this route
168
+ is admin|sme for exactly that reason."""
169
+ return _http_errors(
170
+ knowledge.generate_l5_draft, _org(request), body.docIds,
171
+ created_by=_user(request), title=body.title)
172
+
173
+
174
+ @router.get("/drafts")
175
+ def get_drafts(request: Request, role: str = Depends(require_role())):
176
+ """Org's drafts, latest first (any authenticated role). The decrypted
177
+ items (T2 β€” verbatim company passages) are included only for admin|sme;
178
+ viewers get blueprint + status metadata."""
179
+ include_items = role in ("admin", "sme")
180
+ return {"drafts": _http_errors(
181
+ knowledge.list_drafts, _org(request), include_items=include_items)}
182
+
183
+
184
+ class ReviewBody(BaseModel):
185
+ note: str = ""
186
+
187
+
188
+ @router.post("/drafts/{draft_id}/approve",
189
+ dependencies=[Depends(require_sme)])
190
+ def approve_draft(draft_id: str, body: ReviewBody, request: Request):
191
+ """SME sign-off (role sme ONLY): flips the draft to 'approved', appends a
192
+ signed 'sme_signoff' evidence row to the org chain, and returns the
193
+ evidence id + examCertRef + examRunnable flag."""
194
+ out = _http_errors(
195
+ knowledge.approve_draft, _org(request), draft_id,
196
+ reviewer=_user(request), note=body.note)
197
+ if out is None:
198
+ raise HTTPException(404, f"draft {draft_id} not found")
199
+ return out
200
+
201
+
202
+ @router.post("/drafts/{draft_id}/reject",
203
+ dependencies=[Depends(require_sme)])
204
+ def reject_draft(draft_id: str, body: ReviewBody, request: Request):
205
+ """SME rejection (role sme ONLY) β€” same evidence trail as approval,
206
+ payload outcome 'rejected'."""
207
+ out = _http_errors(
208
+ knowledge.reject_draft, _org(request), draft_id,
209
+ reviewer=_user(request), note=body.note)
210
+ if out is None:
211
+ raise HTTPException(404, f"draft {draft_id} not found")
212
+ return out
213
+
214
+
215
+ # ── Exam over the approved draft bank ────────────────────────────────────────
216
+
217
+ class DraftExamBody(BaseModel):
218
+ candidateSpec: str | None = None # agents/backend.get_backend spec string
219
+ agentId: str | None = None
220
+ dryRun: bool = True # deterministic stub model (CI-safe)
221
+
222
+
223
+ @router.post("/drafts/{draft_id}/exam",
224
+ dependencies=[Depends(require_role("admin", "sme"))])
225
+ def run_draft_exam(draft_id: str, body: DraftExamBody, request: Request):
226
+ """Run the certification exam against the org's APPROVED draft bank.
227
+
228
+ Job-backed exactly like POST /atp/exams/run: returns {jobId}; poll
229
+ GET /jobs/{jobId} β€” when status='done' the result is the award dict.
230
+ The bank is resolved through atp/exams.py's Phase 6 loader path
231
+ (ORG_CERT_PREFIX ref β†’ atp/knowledge.py in-memory bank; judge/candidate
232
+ separation and evidence signing apply unchanged).
233
+ """
234
+ from agents import jobs
235
+
236
+ org = _org(request)
237
+ draft = _http_errors(knowledge.get_draft, org, draft_id)
238
+ if draft is None:
239
+ raise HTTPException(404, f"draft {draft_id} not found")
240
+ if draft["status"] != "approved":
241
+ raise HTTPException(
242
+ 409, f"draft {draft_id} is '{draft['status']}' β€” only an "
243
+ f"SME-approved draft can be examined")
244
+
245
+ cert_ref = knowledge.EXAM_CERT_PREFIX + draft_id
246
+ candidate_spec = body.candidateSpec
247
+ agent_id = body.agentId
248
+ dry_run = body.dryRun
249
+
250
+ def _task():
251
+ from atp import exams
252
+ return exams.run_exam(
253
+ cert_ref,
254
+ candidate_spec,
255
+ org_id=org,
256
+ dry_run=dry_run,
257
+ agent_id=agent_id,
258
+ )
259
+
260
+ job_id = jobs.submit("l5_draft_exam", _task, org_id=org)
261
+ # jobId is the documented key; job_id keeps BU_API.runJob() compatible.
262
+ return {"jobId": job_id, "job_id": job_id, "certRef": cert_ref}
api/ops.py ADDED
@@ -0,0 +1,496 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Runtime ops for the Brain University API β€” Phase 5 (docs/HARDENING.md).
3
+
4
+ Three concerns, one middleware (`ops_middleware`) plus one renderer
5
+ (`metrics_response` for the /metrics route in api/server.py):
6
+
7
+ 1. RATE LIMITING β€” in-process token bucket, keyed (client IP, token subject).
8
+ * Env `BU_RATE_LIMIT` = "<requests>/<window_seconds>", default "120/60"
9
+ (120 requests per rolling 60s per key). `BU_RATE_LIMIT=0` disables.
10
+ Malformed values warn to stderr and fall back to the default (an ops
11
+ typo must never boot an unlimited OR a bricked API).
12
+ * The middleware is registered so it runs AFTER auth (see api/server.py):
13
+ request.state.user is already populated, so one NATed office IP full of
14
+ distinct authenticated users doesn't share a single bucket β€” but an
15
+ unauthenticated scanner (no subject) still collapses to its IP.
16
+ * Over-limit β†’ 429 with a `Retry-After: <seconds>` header (seconds until
17
+ the bucket next holds a full token).
18
+ * Exempt: /health (liveness probes must never 429), /metrics (scrapers),
19
+ and OPTIONS (CORS preflight carries no credentials to key on).
20
+ * Buckets live in a bounded LRU (`MAX_BUCKET_KEYS` = 10k keys, least-
21
+ recently-seen evicted) so hostile key churn can't grow memory unbounded.
22
+ * Client IP is request.client.host β€” behind a reverse proxy (Render,
23
+ Netlify redirects) that is the proxy's address unless uvicorn runs with
24
+ --proxy-headers (or ProxyHeadersMiddleware) so X-Forwarded-For rewrites
25
+ scope['client']. Without it, all remote clients share one IP and the
26
+ bucket key degrades to per-token-subject β€” still functional, coarser.
27
+ * SCOPE: single-instance only, by design. Buckets are per-process memory β€”
28
+ N instances behind a load balancer enforce NΓ—limit, and a restart resets
29
+ all buckets. Good enough for the current single-box Render deploy; for
30
+ multi-instance, swap `TokenBucketLimiter` for a Redis-backed bucket
31
+ (INCR + EXPIRE, or a Lua token bucket) β€” the middleware contract
32
+ (`acquire(key) -> (allowed, retry_after_s)`) is the seam.
33
+ * BU_AUTH_DISABLED=1 demo mode is untouched: the limiter still runs (it
34
+ needs no auth), keyed on (ip, dev-admin); set BU_RATE_LIMIT=0 to switch
35
+ it off entirely.
36
+
37
+ 2. /metrics β€” Prometheus text exposition format 0.0.4, HAND-ROLLED on
38
+ purpose: no `prometheus-client` dependency (documented decision β€” the
39
+ series below are counters/gauges/one histogram with a small fixed label
40
+ set; a registry library buys nothing and requirements.txt is heavy
41
+ enough). Series:
42
+ bu_up 1 β€” process serving requests
43
+ bu_requests_total{method,path,status} β€” counter
44
+ bu_request_duration_seconds*{path,status} β€” histogram (+_sum/_count)
45
+ bu_rate_limited_total β€” counter (429s issued here)
46
+ bu_jobs{status} β€” gauge, DB, cached 5s
47
+ bu_evidence_chain_length β€” gauge, DB, cached 30s
48
+ bu_licenses{status} β€” gauge, DB, cached 30s
49
+ Label cardinality is BOUNDED: `path` is always the ROUTE TEMPLATE
50
+ (`/atp/evidence/{evidence_id}`, never the raw id), and any path that
51
+ matches no registered route collapses to the single sentinel
52
+ `/_unmatched` β€” a scanner spraying random URLs cannot mint new series.
53
+ DB-backed gauges query through atp/tenant_db (org-demo scope β€” cheap
54
+ bootstrap-org aggregates, NOT cross-tenant sums) and every failure is
55
+ swallowed: a broken DB drops those series from the scrape, it never 500s
56
+ /metrics.
57
+ VISIBILITY TRADEOFF: /metrics is added to the auth allowlist by default
58
+ because Prometheus scrapers don't hold bearers and the payload carries no
59
+ tenant data (aggregate counts + route templates only). It DOES leak
60
+ coarse operational shape (traffic volume, error rates, license counts) to
61
+ anyone who can reach the port β€” set `BU_METRICS_PUBLIC=0` to keep it
62
+ behind the bearer gate and give the scraper a token instead.
63
+
64
+ 3. STRUCTURED REQUEST LOGS β€” one JSON object per line to stdout:
65
+ {"ts", "method", "path", "status", "ms", "org", "user", "ip"}
66
+ * `path` is the route template (same bounded collapse as metrics).
67
+ * Opt-in via `BU_LOG_JSON=1` (default OFF so `uvicorn --reload` dev logs
68
+ stay human-readable). Read per-request, so tests can flip it.
69
+ * REDACTION BY CONSTRUCTION: the log record is built from the fixed field
70
+ list above β€” request bodies, query strings, and headers (Authorization,
71
+ X-ATP-License-Key, cookies, ...) are never read by the logger at all.
72
+ Keep it that way: add fields to `_JSON_LOG_FIELDS`, never dump
73
+ request.headers / body. Covered by a redaction test.
74
+
75
+ Wiring (api/server.py): `app.middleware("http")(ops_middleware)` registered
76
+ BETWEEN the access-log middleware and auth_middleware. Starlette runs the
77
+ last-registered http middleware first, so the runtime order is
78
+ auth β†’ ops β†’ tenant-access-log οΏ½οΏ½ CORS β†’ routes: identity is available for
79
+ the bucket key, and a 429 short-circuits BEFORE the tenant access log, so a
80
+ flood can't amplify into per-request DB writes.
81
+ """
82
+
83
+ from __future__ import annotations
84
+
85
+ import json
86
+ import math
87
+ import os
88
+ import sys
89
+ import threading
90
+ import time
91
+ from collections import OrderedDict
92
+ from datetime import datetime, timezone
93
+
94
+ from fastapi import Request
95
+ from fastapi.responses import JSONResponse, PlainTextResponse
96
+
97
+ # ── Config ─────────────────────────────────────────────────────────────────
98
+
99
+ DEFAULT_RATE_LIMIT = "120/60"
100
+ MAX_BUCKET_KEYS = 10_000
101
+
102
+ #: Paths the rate limiter never touches (liveness probes + scrapers).
103
+ RATE_LIMIT_EXEMPT_PATHS = frozenset({"/health", "/metrics"})
104
+
105
+ #: /metrics on the auth allowlist? Default yes β€” see module docstring
106
+ #: tradeoff. BU_METRICS_PUBLIC=0 keeps it behind the bearer gate.
107
+ METRICS_PUBLIC = os.environ.get("BU_METRICS_PUBLIC", "1") != "0"
108
+
109
+ METRICS_CONTENT_TYPE = "text/plain; version=0.0.4; charset=utf-8"
110
+
111
+ #: The ONLY fields a JSON request-log line may carry (redaction by
112
+ #: construction β€” see module docstring Β§3).
113
+ _JSON_LOG_FIELDS = ("ts", "method", "path", "status", "ms", "org", "user", "ip")
114
+
115
+
116
+ def _log_json_enabled() -> bool:
117
+ """BU_LOG_JSON read per-request: costs ~Β΅s, keeps tests/env flips live."""
118
+ return os.environ.get("BU_LOG_JSON", "") == "1"
119
+
120
+
121
+ # ── Token-bucket rate limiter (bounded LRU of buckets) ─────────────────────
122
+
123
+ class TokenBucketLimiter:
124
+ """Classic token bucket per key, stored in a size-bounded LRU.
125
+
126
+ capacity = `limit` tokens, refilled continuously at limit/window per
127
+ second β€” a full burst of `limit` is allowed instantly, sustained rate
128
+ converges to limit/window. Single-process scope (module docstring Β§1).
129
+ """
130
+
131
+ def __init__(self, limit: int, window_s: int,
132
+ max_keys: int = MAX_BUCKET_KEYS):
133
+ if limit <= 0 or window_s <= 0:
134
+ raise ValueError("limit and window must be positive")
135
+ self.limit = limit
136
+ self.window_s = window_s
137
+ self.capacity = float(limit)
138
+ self.rate = limit / window_s # tokens per second
139
+ self.max_keys = max_keys
140
+ self._buckets: OrderedDict[str, tuple[float, float]] = OrderedDict()
141
+ self._lock = threading.Lock() # sync routes run off-loop; be safe
142
+
143
+ def acquire(self, key: str, now: float | None = None) -> tuple[bool, int]:
144
+ """Try to take one token for `key` β†’ (allowed, retry_after_seconds).
145
+
146
+ retry_after is 0 when allowed, else the whole seconds until the
147
+ bucket next holds >= 1 token (what we put in Retry-After).
148
+ """
149
+ if now is None:
150
+ now = time.monotonic()
151
+ with self._lock:
152
+ tokens, last = self._buckets.get(key, (self.capacity, now))
153
+ tokens = min(self.capacity, tokens + (now - last) * self.rate)
154
+ if tokens >= 1.0:
155
+ tokens -= 1.0
156
+ allowed, retry = True, 0
157
+ else:
158
+ allowed = False
159
+ retry = max(1, math.ceil((1.0 - tokens) / self.rate))
160
+ self._buckets[key] = (tokens, now)
161
+ self._buckets.move_to_end(key)
162
+ while len(self._buckets) > self.max_keys: # bounded memory
163
+ self._buckets.popitem(last=False) # evict least-recent
164
+ return allowed, retry
165
+
166
+ def __len__(self) -> int:
167
+ return len(self._buckets)
168
+
169
+
170
+ def _parse_rate_limit(spec: str) -> tuple[int, int] | None:
171
+ """'120/60' β†’ (120, 60); '0' β†’ None (disabled). Raises on garbage."""
172
+ spec = (spec or "").strip()
173
+ if spec == "0":
174
+ return None
175
+ n, _, w = spec.partition("/")
176
+ limit, window = int(n), int(w)
177
+ if limit <= 0 or window <= 0:
178
+ raise ValueError(spec)
179
+ return limit, window
180
+
181
+
182
+ _LIMITER: TokenBucketLimiter | None = None
183
+
184
+
185
+ def set_rate_limit(spec: str | None = None) -> None:
186
+ """(Re)configure the limiter from `spec`, or from env when None.
187
+
188
+ Called once at import; exported so tests (and an ops REPL) can retune
189
+ without reloading the module. Malformed spec β†’ warn + default, never
190
+ crash or silently disable.
191
+ """
192
+ global _LIMITER
193
+ if spec is None:
194
+ spec = os.environ.get("BU_RATE_LIMIT", DEFAULT_RATE_LIMIT)
195
+ try:
196
+ parsed = _parse_rate_limit(spec)
197
+ except (ValueError, TypeError):
198
+ print(f"WARNING: BU_RATE_LIMIT={spec!r} is malformed β€” expected "
199
+ f"'<requests>/<seconds>' or '0'; using default "
200
+ f"{DEFAULT_RATE_LIMIT!r}.", file=sys.stderr)
201
+ parsed = _parse_rate_limit(DEFAULT_RATE_LIMIT)
202
+ _LIMITER = None if parsed is None else TokenBucketLimiter(*parsed)
203
+
204
+
205
+ set_rate_limit()
206
+
207
+
208
+ # ── Metrics registry (stdlib, one lock, bounded labels) ──────────────────��─
209
+
210
+ #: Histogram bucket upper bounds (seconds). +Inf is implicit.
211
+ HIST_BUCKETS = (0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0)
212
+
213
+ _KNOWN_METHODS = frozenset(
214
+ {"GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"})
215
+
216
+ _METRICS_LOCK = threading.Lock()
217
+ _STARTED_AT = time.time()
218
+ _REQUESTS: dict[tuple[str, str, int], int] = {} # (method,path,status)
219
+ _HIST: dict[tuple[str, int], list] = {} # (path,status) β†’
220
+ # [bucket_counts, sum, count]
221
+ _RATE_LIMITED = 0
222
+
223
+
224
+ def _observe(method: str, path_tmpl: str, status: int, elapsed_s: float) -> None:
225
+ method = method if method in _KNOWN_METHODS else "OTHER"
226
+ rkey = (method, path_tmpl, status)
227
+ hkey = (path_tmpl, status)
228
+ with _METRICS_LOCK:
229
+ _REQUESTS[rkey] = _REQUESTS.get(rkey, 0) + 1
230
+ hist = _HIST.get(hkey)
231
+ if hist is None:
232
+ hist = _HIST[hkey] = [[0] * (len(HIST_BUCKETS) + 1), 0.0, 0]
233
+ counts, _, _ = hist
234
+ for i, le in enumerate(HIST_BUCKETS):
235
+ if elapsed_s <= le:
236
+ counts[i] += 1
237
+ counts[-1] += 1 # +Inf
238
+ hist[1] += elapsed_s
239
+ hist[2] += 1
240
+
241
+
242
+ def _count_rate_limited() -> None:
243
+ global _RATE_LIMITED
244
+ with _METRICS_LOCK:
245
+ _RATE_LIMITED += 1
246
+
247
+
248
+ # ── Path templating (bounded label set) ────────────────────────────────────
249
+
250
+ _ROUTE_TABLE: list | None = None
251
+
252
+
253
+ def _path_template(request: Request) -> str:
254
+ """Collapse the raw path to its route template, else '/_unmatched'.
255
+
256
+ Fast path: FastAPI puts the matched route in request.scope['route'].
257
+ Fallback (unrouted: 429 short-circuits, 404s, OPTIONS): match against
258
+ every registered route's compiled path_regex β€” one-time table build.
259
+ Everything that matches nothing shares ONE sentinel label so arbitrary
260
+ client paths can never mint new series.
261
+ """
262
+ route = request.scope.get("route")
263
+ fmt = getattr(route, "path_format", None)
264
+ if fmt:
265
+ return fmt
266
+ global _ROUTE_TABLE
267
+ if _ROUTE_TABLE is None:
268
+ table = []
269
+ for r in request.app.routes: # fully registered before 1st request
270
+ rx = getattr(r, "path_regex", None)
271
+ f = getattr(r, "path_format", None)
272
+ if rx is not None and f:
273
+ table.append((rx, f))
274
+ _ROUTE_TABLE = table
275
+ path = request.url.path
276
+ for rx, f in _ROUTE_TABLE:
277
+ if rx.match(path):
278
+ return f
279
+ return "/_unmatched"
280
+
281
+
282
+ # ── DB-backed gauges (cached; failures drop the series, never 500) ─────────
283
+
284
+ _DB_CACHE: dict[str, tuple[float, object]] = {}
285
+ _DB_CACHE_LOCK = threading.Lock()
286
+
287
+
288
+ def _cached(name: str, ttl_s: float, fn):
289
+ """Value of fn(), refreshed at most every ttl_s. On error: serve the
290
+ last good value if any (marked fresh again, so a broken DB is re-probed
291
+ only once per ttl β€” /metrics never hammers or propagates)."""
292
+ now = time.time()
293
+ with _DB_CACHE_LOCK:
294
+ hit = _DB_CACHE.get(name)
295
+ if hit is not None and hit[0] > now:
296
+ return hit[1]
297
+ try:
298
+ value = fn()
299
+ except Exception: # noqa: BLE001 β€” metrics must never take the API down
300
+ value = hit[1] if hit is not None else None
301
+ with _DB_CACHE_LOCK:
302
+ _DB_CACHE[name] = (now + ttl_s, value)
303
+ return value
304
+
305
+
306
+ def _jobs_by_status() -> dict[str, int]:
307
+ from atp import tenant_db
308
+ org = tenant_db.DEFAULT_ORG
309
+ rows = tenant_db.scoped_query(
310
+ org,
311
+ "SELECT status, COUNT(*) AS n FROM jobs "
312
+ "WHERE org_id = :org GROUP BY status",
313
+ {"org": org},
314
+ )
315
+ out = {"queued": 0, "running": 0, "done": 0, "error": 0}
316
+ for r in rows:
317
+ out[str(r["status"])] = int(r["n"])
318
+ return out
319
+
320
+
321
+ def _evidence_chain_length() -> int:
322
+ from atp import tenant_db
323
+ org = tenant_db.DEFAULT_ORG
324
+ rows = tenant_db.scoped_query(
325
+ org,
326
+ "SELECT COUNT(*) AS n FROM atp_evidence WHERE org_id = :org",
327
+ {"org": org},
328
+ )
329
+ return int(rows[0]["n"])
330
+
331
+
332
+ def _licenses_by_status() -> dict[str, int]:
333
+ from atp import tenant_db
334
+ org = tenant_db.DEFAULT_ORG
335
+ rows = tenant_db.scoped_query(
336
+ org,
337
+ "SELECT status, COUNT(*) AS n FROM licenses "
338
+ "WHERE org_id = :org GROUP BY status",
339
+ {"org": org},
340
+ )
341
+ out = {"active": 0, "revoked": 0} # always emit the two headline series
342
+ for r in rows:
343
+ out[str(r["status"])] = int(r["n"])
344
+ return out
345
+
346
+
347
+ # ── Exposition (Prometheus text format 0.0.4) ──────────────────────────────
348
+
349
+ def _esc(v: str) -> str:
350
+ """Escape a label value per the exposition format."""
351
+ return v.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n")
352
+
353
+
354
+ def render_metrics() -> str:
355
+ lines: list[str] = []
356
+ add = lines.append
357
+
358
+ add("# HELP bu_up 1 while the API process is up and serving.")
359
+ add("# TYPE bu_up gauge")
360
+ add("bu_up 1")
361
+ add("# HELP bu_process_start_time_seconds Unix time the process started.")
362
+ add("# TYPE bu_process_start_time_seconds gauge")
363
+ add(f"bu_process_start_time_seconds {_STARTED_AT:.3f}")
364
+
365
+ with _METRICS_LOCK:
366
+ requests = dict(_REQUESTS)
367
+ hist = {k: [list(v[0]), v[1], v[2]] for k, v in _HIST.items()}
368
+ rate_limited = _RATE_LIMITED
369
+
370
+ add("# HELP bu_requests_total HTTP requests by method, route template, "
371
+ "and status.")
372
+ add("# TYPE bu_requests_total counter")
373
+ for (method, path, status), n in sorted(requests.items(),
374
+ key=lambda kv: kv[0]):
375
+ add(f'bu_requests_total{{method="{_esc(method)}",path="{_esc(path)}",'
376
+ f'status="{status}"}} {n}')
377
+
378
+ add("# HELP bu_request_duration_seconds HTTP request latency by route "
379
+ "template and status.")
380
+ add("# TYPE bu_request_duration_seconds histogram")
381
+ for (path, status), (counts, total, count) in sorted(
382
+ hist.items(), key=lambda kv: kv[0]):
383
+ labels = f'path="{_esc(path)}",status="{status}"'
384
+ for i, le in enumerate(HIST_BUCKETS):
385
+ add(f'bu_request_duration_seconds_bucket{{{labels},le="{le}"}} '
386
+ f"{counts[i]}")
387
+ add(f'bu_request_duration_seconds_bucket{{{labels},le="+Inf"}} '
388
+ f"{counts[-1]}")
389
+ add(f"bu_request_duration_seconds_sum{{{labels}}} {total:.6f}")
390
+ add(f"bu_request_duration_seconds_count{{{labels}}} {count}")
391
+
392
+ add("# HELP bu_rate_limited_total Requests rejected with 429 by the "
393
+ "token-bucket limiter.")
394
+ add("# TYPE bu_rate_limited_total counter")
395
+ add(f"bu_rate_limited_total {rate_limited}")
396
+
397
+ jobs = _cached("jobs", 5.0, _jobs_by_status)
398
+ if jobs is not None:
399
+ add("# HELP bu_jobs Background jobs by status (org-demo scope, "
400
+ "cached 5s).")
401
+ add("# TYPE bu_jobs gauge")
402
+ for status, n in sorted(jobs.items()):
403
+ add(f'bu_jobs{{status="{_esc(status)}"}} {n}')
404
+
405
+ chain = _cached("evidence_chain", 30.0, _evidence_chain_length)
406
+ if chain is not None:
407
+ add("# HELP bu_evidence_chain_length Rows in the signed evidence "
408
+ "chain (org-demo scope, cached 30s).")
409
+ add("# TYPE bu_evidence_chain_length gauge")
410
+ add(f"bu_evidence_chain_length {chain}")
411
+
412
+ licenses = _cached("licenses", 30.0, _licenses_by_status)
413
+ if licenses is not None:
414
+ add("# HELP bu_licenses Marketplace licenses by status (org-demo "
415
+ "scope, cached 30s).")
416
+ add("# TYPE bu_licenses gauge")
417
+ for status, n in sorted(licenses.items()):
418
+ add(f'bu_licenses{{status="{_esc(status)}"}} {n}')
419
+
420
+ return "\n".join(lines) + "\n"
421
+
422
+
423
+ def metrics_response() -> PlainTextResponse:
424
+ """Response for the /metrics route (api/server.py owns the route def)."""
425
+ return PlainTextResponse(render_metrics(), media_type=METRICS_CONTENT_TYPE)
426
+
427
+
428
+ # ── JSON request log ───────────────────────────────────────────────────────
429
+
430
+ def _emit_json_log(request: Request, path_tmpl: str, status: int,
431
+ elapsed_s: float) -> None:
432
+ """One JSON line to stdout. Fields fixed by _JSON_LOG_FIELDS β€” never add
433
+ headers or bodies here (redaction by construction; see docstring Β§3)."""
434
+ record = {
435
+ "ts": datetime.now(timezone.utc).isoformat(timespec="milliseconds"),
436
+ "method": request.method,
437
+ "path": path_tmpl,
438
+ "status": status,
439
+ "ms": round(elapsed_s * 1000.0, 2),
440
+ "org": getattr(request.state, "org_id", None),
441
+ "user": getattr(request.state, "user", None),
442
+ "ip": request.client.host if request.client else None,
443
+ }
444
+ assert set(record) == set(_JSON_LOG_FIELDS)
445
+ print(json.dumps(record, separators=(",", ":")), file=sys.stdout,
446
+ flush=True)
447
+
448
+
449
+ # ── The middleware ─────────────────────────────────────────────────────────
450
+
451
+ async def ops_middleware(request: Request, call_next):
452
+ """Rate limit (post-auth identity) + metrics + optional JSON log.
453
+
454
+ Runs INSIDE auth (api/server.py registration order), so
455
+ request.state.user/org_id are set for authenticated requests and 401s
456
+ never reach us. A 429 returns straight from here β€” inner layers (tenant
457
+ access log, CORS, routes) never run for rate-limited requests.
458
+ """
459
+ start = time.perf_counter()
460
+ method = request.method
461
+ raw_path = request.url.path
462
+
463
+ limiter = _LIMITER
464
+ if (limiter is not None and method != "OPTIONS"
465
+ and raw_path not in RATE_LIMIT_EXEMPT_PATHS):
466
+ ip = request.client.host if request.client else "unknown"
467
+ subject = getattr(request.state, "user", None) or "-"
468
+ allowed, retry_after = limiter.acquire(f"{ip}|{subject}")
469
+ if not allowed:
470
+ _count_rate_limited()
471
+ elapsed = time.perf_counter() - start
472
+ tmpl = _path_template(request)
473
+ _observe(method, tmpl, 429, elapsed)
474
+ if _log_json_enabled():
475
+ _emit_json_log(request, tmpl, 429, elapsed)
476
+ return JSONResponse(
477
+ {"detail": "rate limit exceeded"},
478
+ status_code=429,
479
+ headers={"Retry-After": str(retry_after)},
480
+ )
481
+
482
+ try:
483
+ response = await call_next(request)
484
+ except Exception:
485
+ elapsed = time.perf_counter() - start
486
+ tmpl = _path_template(request)
487
+ _observe(method, tmpl, 500, elapsed)
488
+ if _log_json_enabled():
489
+ _emit_json_log(request, tmpl, 500, elapsed)
490
+ raise
491
+ elapsed = time.perf_counter() - start
492
+ tmpl = _path_template(request)
493
+ _observe(method, tmpl, response.status_code, elapsed)
494
+ if _log_json_enabled():
495
+ _emit_json_log(request, tmpl, response.status_code, elapsed)
496
+ return response
api/server.py CHANGED
@@ -35,7 +35,7 @@ from pathlib import Path
35
  from typing import Any
36
 
37
  try:
38
- from fastapi import FastAPI, HTTPException, UploadFile, File, Form
39
  from fastapi.middleware.cors import CORSMiddleware
40
  from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
41
  from pydantic import BaseModel
@@ -64,12 +64,139 @@ app.add_middleware(
64
  allow_headers=["*"],
65
  )
66
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
67
  # Bearer-token auth on every route except an allowlist (/health, /login,
68
  # /docs, /videos, /media, OPTIONS preflight). Configure via env vars
69
  # BU_AUTH_USER / BU_AUTH_PASSWORD / BU_AUTH_SECRET. See api/auth.py.
70
  from api.auth import auth_middleware, mint_token, check_password
71
  app.middleware("http")(auth_middleware)
72
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
73
 
74
  class LoginBody(BaseModel):
75
  username: str
@@ -95,12 +222,11 @@ def _graph():
95
  """Build the merged corpus graph + cluster list once and cache."""
96
  if "G" in _GRAPH_CACHE:
97
  return _GRAPH_CACHE
98
- from graph.corpus_graph import build_corpus_graph
99
- from graph.clusters import cluster_graph
100
- from graph.foundations import rank_foundations
101
- G = build_corpus_graph()
102
- clusters = cluster_graph(G)
103
- foundations = rank_foundations(G, clusters)
104
  _GRAPH_CACHE.update(G=G, clusters=clusters, foundations=foundations)
105
  return _GRAPH_CACHE
106
 
@@ -268,7 +394,7 @@ def podcast_local_interject(body: LocalInterjectBody):
268
  # ── Background jobs (async podcast render) ────────────────────────────────
269
 
270
  @app.post("/podcast/render/async")
271
- def podcast_render_async(body: PodcastRenderBody):
272
  """Enqueue a cloud podcast render; returns a job_id to poll at /jobs/{id}."""
273
  from agents import jobs, podcast_video
274
 
@@ -278,11 +404,12 @@ def podcast_render_async(body: PodcastRenderBody):
278
  raise RuntimeError("render failed (check moviepy/gTTS install)")
279
  return {k: str(v.relative_to(PROJECT_ROOT)) for k, v in r.items()}
280
 
281
- return {"job_id": jobs.submit("podcast_render", task)}
 
282
 
283
 
284
  @app.post("/podcast/local/async")
285
- def podcast_local_async(body: LocalPodcastBody):
286
  """Enqueue a fully-local podcast render; poll at /jobs/{id}."""
287
  from agents import jobs
288
  from agents.local_podcast import generate_local
@@ -294,13 +421,17 @@ def podcast_local_async(body: LocalPodcastBody):
294
  raise RuntimeError("local podcast generation failed")
295
  return {"mp3": str(p.relative_to(PROJECT_ROOT))}
296
 
297
- return {"job_id": jobs.submit("podcast_local", task)}
 
298
 
299
 
300
  @app.get("/jobs/{job_id}")
301
- def job_status(job_id: str):
 
 
 
302
  from agents import jobs
303
- j = jobs.get(job_id)
304
  if not j:
305
  raise HTTPException(404, f"no such job {job_id}")
306
  return j
@@ -311,6 +442,14 @@ def health():
311
  return {"ok": True}
312
 
313
 
 
 
 
 
 
 
 
 
314
  @app.get("/graph")
315
  def graph_endpoint():
316
  g = _graph()["G"]
@@ -330,7 +469,7 @@ def trailhead(user_id: str = "anon", interest: str = "", n: int = 5):
330
 
331
  def _foundation_card(f) -> dict:
332
  return {
333
- "node": getattr(f, "node", str(f)),
334
  "score": getattr(f, "score", None),
335
  "rationale": getattr(f, "rationale", []),
336
  "cluster_id": getattr(f, "cluster_id", None),
@@ -465,40 +604,24 @@ def briefing_endpoint(node: str, interest: str = ""):
465
  @app.post("/socratic/start")
466
  def socratic_start(body: SocraticStartBody):
467
  from agents.socratic import start_session
468
- from agents.persistence import open_db
469
  import uuid
470
  state = start_session(body.node)
471
  sid = f"soc_{uuid.uuid4().hex[:12]}"
472
- db = open_db()
473
- db.execute(
474
- """INSERT INTO socratic_sessions
475
- (session_id, user_id, node, state_json, updated_at)
476
- VALUES (?, ?, ?, ?, ?)""",
477
- (sid, body.user_id, body.node, json.dumps(state),
478
- _now()),
479
- )
480
- db.commit()
481
  return {"session_id": sid, "state": state}
482
 
483
 
484
  @app.post("/socratic/step")
485
  def socratic_step(body: SocraticStepBody):
486
  from agents.socratic import step
487
- from agents.persistence import open_db
488
- db = open_db()
489
- row = db.execute(
490
- "SELECT state_json FROM socratic_sessions WHERE session_id = ?",
491
- (body.session_id,),
492
- ).fetchone()
493
- if not row:
494
  raise HTTPException(404, "session not found")
495
- state = json.loads(row[0])
496
- new_state = step(state, body.answer)
497
- db.execute(
498
- "UPDATE socratic_sessions SET state_json = ?, updated_at = ? WHERE session_id = ?",
499
- (json.dumps(new_state), _now(), body.session_id),
500
- )
501
- db.commit()
502
  return new_state
503
 
504
 
 
35
  from typing import Any
36
 
37
  try:
38
+ from fastapi import FastAPI, HTTPException, Request, UploadFile, File, Form
39
  from fastapi.middleware.cors import CORSMiddleware
40
  from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
41
  from pydantic import BaseModel
 
64
  allow_headers=["*"],
65
  )
66
 
67
+ # ── Tenant access audit (Phase 2 β€” docs/TENANCY.md Β§4 layer 4) ────────────
68
+ # Starlette runs the LAST-registered http middleware FIRST, so registering
69
+ # this BEFORE auth_middleware (below) makes it the inner layer: on the
70
+ # request path it runs AFTER auth β€” request.state.{user,org_id,role} are
71
+ # already populated and 401-rejected requests never reach it. Rows are
72
+ # written fire-and-forget on a single-worker pool so the response is never
73
+ # blocked, and every failure (including atp/tenant_db.py not being deployed
74
+ # yet) is swallowed β€” the audit trail must never take the API down.
75
+ from concurrent.futures import ThreadPoolExecutor as _AccessLogPool
76
+
77
+ _ACCESS_LOG_POOL = _AccessLogPool(max_workers=1, thread_name_prefix="bu-accesslog")
78
+
79
+
80
+ @app.middleware("http")
81
+ async def access_log_middleware(request: Request, call_next):
82
+ response = await call_next(request)
83
+ try:
84
+ import api.auth as _auth
85
+ if _auth.AUTH_DISABLED: # dev mode: no audit rows
86
+ return response
87
+ if _auth.is_public(request.url.path, request.method):
88
+ return response # public surface: not tenant data
89
+ user = getattr(request.state, "user", None)
90
+ if user is None: # unauthenticated (shouldn't happen
91
+ return response # behind the auth middleware)
92
+ org_id = getattr(request.state, "org_id", None) or "org-demo"
93
+ role = getattr(request.state, "role", None) or "admin"
94
+ method, path = request.method, request.url.path
95
+ status = response.status_code
96
+
97
+ def _write() -> None:
98
+ try:
99
+ from atp import tenant_db
100
+ tenant_db.log_access(
101
+ org_id=org_id, user=user, role=role,
102
+ method=method, path=path, status=status,
103
+ )
104
+ except Exception: # noqa: BLE001 β€” fire-and-forget by contract
105
+ pass
106
+
107
+ _ACCESS_LOG_POOL.submit(_write)
108
+ except Exception: # noqa: BLE001 β€” auditing must never block a response
109
+ pass
110
+ return response
111
+
112
+
113
+ # ── Ops middleware (Phase 5 β€” api/ops.py: rate limit + metrics + logs) ────
114
+ # Registered AFTER access_log_middleware and BEFORE auth_middleware, so the
115
+ # runtime order (last-registered runs first) is:
116
+ # auth β†’ ops β†’ tenant-access-log β†’ CORS β†’ routes
117
+ # i.e. the token-bucket key can include the verified token subject, 401s
118
+ # never reach the limiter, and a 429 short-circuits BEFORE the tenant access
119
+ # log (a flood can't amplify into per-request DB writes). Env knobs:
120
+ # BU_RATE_LIMIT ('120/60'; '0' disables), BU_LOG_JSON=1, BU_METRICS_PUBLIC=0.
121
+ from api import ops as _ops
122
+ app.middleware("http")(_ops.ops_middleware)
123
+
124
  # Bearer-token auth on every route except an allowlist (/health, /login,
125
  # /docs, /videos, /media, OPTIONS preflight). Configure via env vars
126
  # BU_AUTH_USER / BU_AUTH_PASSWORD / BU_AUTH_SECRET. See api/auth.py.
127
  from api.auth import auth_middleware, mint_token, check_password
128
  app.middleware("http")(auth_middleware)
129
 
130
+ # /metrics is on the auth allowlist by default: Prometheus scrapers don't
131
+ # hold bearers and the exposition carries no tenant payload (aggregate
132
+ # counts + route templates only β€” see api/ops.py Β§2 for the tradeoff).
133
+ # Set BU_METRICS_PUBLIC=0 to keep it behind the bearer gate instead.
134
+ if _ops.METRICS_PUBLIC:
135
+ import api.auth as _auth_mod
136
+ _auth_mod.PUBLIC_PATHS.add("/metrics")
137
+
138
+ # ATP (Agent University) routes β€” /atp/* (see api/atp.py + docs/ATP.md Β§5).
139
+ from api.atp import router as atp_router
140
+ app.include_router(atp_router)
141
+
142
+ # Identity routes (Phase 2 β€” OIDC code flow, local org accounts, org-scoped
143
+ # JWT sessions; see api/identity.py + docs/TENANCY.md). Guarded import so the
144
+ # legacy single-admin demo keeps booting if this file ships first.
145
+ try:
146
+ from api.identity import router as identity_router
147
+ except ImportError: # pragma: no cover β€” api/identity.py not deployed yet
148
+ identity_router = None
149
+ import sys as _sys
150
+ print("WARNING: api/identity.py not present β€” identity routes not "
151
+ "mounted; legacy /login only.", file=_sys.stderr)
152
+ if identity_router is not None:
153
+ app.include_router(identity_router)
154
+
155
+ # Billing + licensed expert delivery (Phase 4 β€” docs/HARDENING.md; see
156
+ # api/billing.py). Guarded import, same idiom as identity above.
157
+ try:
158
+ from api.billing import experts_router, router as billing_router
159
+ except ImportError: # pragma: no cover β€” api/billing.py not deployed yet
160
+ billing_router = experts_router = None
161
+ import sys as _sys
162
+ print("WARNING: api/billing.py not present β€” billing/expert routes not "
163
+ "mounted.", file=_sys.stderr)
164
+ if billing_router is not None:
165
+ app.include_router(billing_router)
166
+ app.include_router(experts_router)
167
+ # Auth-allowlist wiring for the two non-bearer surfaces (api/auth.py owns
168
+ # the sets; these additions live here with the routes they serve):
169
+ # * /billing/webhook β€” Stripe cannot send a bearer; every delivery is
170
+ # authenticated by its Stripe-Signature header inside the handler.
171
+ # * /experts/* β€” authenticated per-request by license key
172
+ # (X-ATP-License-Key); the handler hits the DB on EVERY call, so
173
+ # revocation is enforced immediately (bearer optional).
174
+ import api.auth as _auth
175
+ _auth.PUBLIC_PATHS.add("/billing/webhook")
176
+ _auth.PUBLIC_PREFIXES = _auth.PUBLIC_PREFIXES + ("/experts/",)
177
+
178
+ # Company-knowledge ingestion + L5 exam drafting (Phase 6 β€”
179
+ # docs/HARDENING.md first-customer UX; see api/knowledge.py). Guarded
180
+ # import, same idiom as identity/billing above. Every /knowledge route is
181
+ # bearer-gated (no public-path additions β€” uploads are T2 crown jewels).
182
+ try:
183
+ from api.knowledge import router as knowledge_router
184
+ except ImportError: # pragma: no cover β€” api/knowledge.py not deployed yet
185
+ knowledge_router = None
186
+ import sys as _sys
187
+ print("WARNING: api/knowledge.py not present β€” company-knowledge routes "
188
+ "not mounted.", file=_sys.stderr)
189
+ if knowledge_router is not None:
190
+ app.include_router(knowledge_router)
191
+
192
+
193
+ def _request_org(request: Request) -> str:
194
+ """Tenant for this request β€” ONLY ever from verified auth state
195
+ (request.state.org_id, set by the auth middleware from the JWT; TENANCY.md
196
+ Β§4 layer 1), never from client-supplied values. The 'org-demo' fallback
197
+ covers BU_AUTH_DISABLED dev mode and legacy single-admin tokens."""
198
+ return getattr(request.state, "org_id", None) or "org-demo"
199
+
200
 
201
  class LoginBody(BaseModel):
202
  username: str
 
222
  """Build the merged corpus graph + cluster list once and cache."""
223
  if "G" in _GRAPH_CACHE:
224
  return _GRAPH_CACHE
225
+ from graph.clusters import build_clusters, load_graph
226
+ from graph.foundations import recommend
227
+ clusters = build_clusters() # cached to data/sessions/clusters.json
228
+ G = load_graph()
229
+ foundations = recommend(limit=8)
 
230
  _GRAPH_CACHE.update(G=G, clusters=clusters, foundations=foundations)
231
  return _GRAPH_CACHE
232
 
 
394
  # ── Background jobs (async podcast render) ────────────────────────────────
395
 
396
  @app.post("/podcast/render/async")
397
+ def podcast_render_async(body: PodcastRenderBody, request: Request):
398
  """Enqueue a cloud podcast render; returns a job_id to poll at /jobs/{id}."""
399
  from agents import jobs, podcast_video
400
 
 
404
  raise RuntimeError("render failed (check moviepy/gTTS install)")
405
  return {k: str(v.relative_to(PROJECT_ROOT)) for k, v in r.items()}
406
 
407
+ return {"job_id": jobs.submit("podcast_render", task,
408
+ org_id=_request_org(request))}
409
 
410
 
411
  @app.post("/podcast/local/async")
412
+ def podcast_local_async(body: LocalPodcastBody, request: Request):
413
  """Enqueue a fully-local podcast render; poll at /jobs/{id}."""
414
  from agents import jobs
415
  from agents.local_podcast import generate_local
 
421
  raise RuntimeError("local podcast generation failed")
422
  return {"mp3": str(p.relative_to(PROJECT_ROOT))}
423
 
424
+ return {"job_id": jobs.submit("podcast_local", task,
425
+ org_id=_request_org(request))}
426
 
427
 
428
  @app.get("/jobs/{job_id}")
429
+ def job_status(job_id: str, request: Request):
430
+ """Job status, scoped to the caller's org (jobs.get only sees the org's
431
+ own rows β€” RLS-backed on Postgres). A cross-tenant id 404s with the SAME
432
+ message as an unknown id β€” no existence oracle (TENANCY.md #6)."""
433
  from agents import jobs
434
+ j = jobs.get(job_id, org_id=_request_org(request))
435
  if not j:
436
  raise HTTPException(404, f"no such job {job_id}")
437
  return j
 
442
  return {"ok": True}
443
 
444
 
445
+ @app.get("/metrics")
446
+ def metrics():
447
+ """Prometheus text exposition β€” hand-rolled, bounded label set (Phase 5,
448
+ api/ops.py). Public by default (BU_METRICS_PUBLIC=0 gates it behind
449
+ auth); exempt from rate limiting so scrapers never see a 429."""
450
+ return _ops.metrics_response()
451
+
452
+
453
  @app.get("/graph")
454
  def graph_endpoint():
455
  g = _graph()["G"]
 
469
 
470
  def _foundation_card(f) -> dict:
471
  return {
472
+ "node": getattr(f, "raw_name", None) or getattr(f, "name", str(f)),
473
  "score": getattr(f, "score", None),
474
  "rationale": getattr(f, "rationale", []),
475
  "cluster_id": getattr(f, "cluster_id", None),
 
604
  @app.post("/socratic/start")
605
  def socratic_start(body: SocraticStartBody):
606
  from agents.socratic import start_session
607
+ from agents.persistence import save_socratic_session
608
  import uuid
609
  state = start_session(body.node)
610
  sid = f"soc_{uuid.uuid4().hex[:12]}"
611
+ save_socratic_session(sid, body.user_id, body.node, state)
 
 
 
 
 
 
 
 
612
  return {"session_id": sid, "state": state}
613
 
614
 
615
  @app.post("/socratic/step")
616
  def socratic_step(body: SocraticStepBody):
617
  from agents.socratic import step
618
+ from agents.persistence import (load_socratic_session,
619
+ update_socratic_session)
620
+ session = load_socratic_session(body.session_id)
621
+ if not session:
 
 
 
622
  raise HTTPException(404, "session not found")
623
+ new_state = step(session["state"], body.answer)
624
+ update_socratic_session(body.session_id, new_state)
 
 
 
 
 
625
  return new_state
626
 
627