18d96193ab
auth.py
secrets.compare_digest replaces == for service key comparison (timing-safe)
Added require_service_key — bot-only endpoints (trip/event join/leave)
Added require_service_key_or_admin — node commands/config (bot via service key OR dashboard admin via Firebase)
Added _RateLimiter with three shared instances: trip_chat_limiter (20/5min per user), summarize_limiter (5/10min per incident), bootstrap_limiter (2/hr per system)
nodes.py
send_command and assign_system now require require_service_key_or_admin — the Discord bot can still call them via service key, but regular Firebase users are blocked
tokens.py
add_token, flush_tokens, set_preferred_system, delete_token all require require_admin_token
Token masking changed from token[:10] + "…" + token[-4:] to "•••" + token[-4:]
systems.py
All write endpoints (create, update, delete, ai-flags, ten-codes, vocabulary writes, bootstrap) now require require_admin_token
bootstrap_vocabulary also calls bootstrap_limiter.check(system_id)
incidents.py
POST /incidents/summarize (bulk) now requires require_admin_token
POST /incidents/{id}/summarize now calls summarize_limiter.check(incident_id)
trips.py
join_trip, leave_trip, join_event, leave_event require require_service_key — only the Discord bot can set Discord attendee identity
delete_trip, delete_event require require_service_key_or_admin
trip_chat rate-limited per caller UID, history stripped to user/assistant roles only, user message truncated to 2000 chars, Maps query strings capped at 200 chars
upload.py
Rejects files larger than settings.upload_max_bytes (default 100MB) with 413
storage.py
_safe_audio_filename() derives GCS object name from call_id + allowlisted extension, completely ignoring the client-supplied filename
config.py
Added upload_max_bytes: int = 100 * 1024 * 1024
Both Dockerfiles — python:3.14-slim → python:3.12-slim
112 lines
4.0 KiB
Python
112 lines
4.0 KiB
Python
import uuid
|
|
from datetime import datetime, timezone
|
|
from typing import Optional
|
|
from fastapi import APIRouter, BackgroundTasks, HTTPException, Depends
|
|
from app.models import IncidentCreate, IncidentUpdate
|
|
from app.internal import firestore as fstore
|
|
from app.internal.auth import require_admin_token, require_service_or_firebase_token, summarize_limiter
|
|
|
|
router = APIRouter(prefix="/incidents", tags=["incidents"])
|
|
|
|
|
|
@router.get("")
|
|
async def list_incidents(status: Optional[str] = None, type: Optional[str] = None):
|
|
filters = {}
|
|
if status:
|
|
filters["status"] = status
|
|
if type:
|
|
filters["type"] = type
|
|
return await fstore.collection_list("incidents", **filters)
|
|
|
|
|
|
@router.post("/summarize")
|
|
async def summarize_all_stale(
|
|
background_tasks: BackgroundTasks,
|
|
_: dict = Depends(require_admin_token),
|
|
):
|
|
"""Immediately run the summarizer pass on all stale incidents (don't wait for the next interval)."""
|
|
from app.internal.summarizer import _run_summary_pass
|
|
background_tasks.add_task(_run_summary_pass)
|
|
return {"ok": True}
|
|
|
|
|
|
@router.get("/{incident_id}")
|
|
async def get_incident(incident_id: str):
|
|
doc = await fstore.doc_get("incidents", incident_id)
|
|
if not doc:
|
|
raise HTTPException(404, f"Incident '{incident_id}' not found.")
|
|
return doc
|
|
|
|
|
|
@router.post("")
|
|
async def create_incident(body: IncidentCreate, _: dict = Depends(require_admin_token)):
|
|
now = datetime.now(timezone.utc).isoformat()
|
|
incident_id = str(uuid.uuid4())
|
|
doc = {
|
|
"incident_id": incident_id,
|
|
"title": body.title,
|
|
"type": body.type,
|
|
"status": body.status,
|
|
"location": body.location,
|
|
"call_ids": body.call_ids,
|
|
"summary": body.summary,
|
|
"tags": body.tags,
|
|
"started_at": now,
|
|
"updated_at": now,
|
|
}
|
|
await fstore.doc_set("incidents", incident_id, doc, merge=False)
|
|
return doc
|
|
|
|
|
|
@router.put("/{incident_id}")
|
|
async def update_incident(incident_id: str, body: IncidentUpdate, _: dict = Depends(require_admin_token)):
|
|
doc = await fstore.doc_get("incidents", incident_id)
|
|
if not doc:
|
|
raise HTTPException(404, f"Incident '{incident_id}' not found.")
|
|
updates = body.model_dump(exclude_none=True)
|
|
updates["updated_at"] = datetime.now(timezone.utc).isoformat()
|
|
await fstore.doc_update("incidents", incident_id, updates)
|
|
return {**doc, **updates}
|
|
|
|
|
|
@router.delete("/{incident_id}")
|
|
async def delete_incident(incident_id: str, _: dict = Depends(require_admin_token)):
|
|
doc = await fstore.doc_get("incidents", incident_id)
|
|
if not doc:
|
|
raise HTTPException(404, f"Incident '{incident_id}' not found.")
|
|
await fstore.doc_delete("incidents", incident_id)
|
|
return {"ok": True}
|
|
|
|
|
|
@router.post("/{incident_id}/summarize")
|
|
async def summarize_incident(
|
|
incident_id: str,
|
|
background_tasks: BackgroundTasks,
|
|
decoded: dict = Depends(require_service_or_firebase_token),
|
|
):
|
|
"""Immediately run the summarizer for a specific incident."""
|
|
from app.internal.summarizer import _summarize_incident
|
|
inc = await fstore.doc_get("incidents", incident_id)
|
|
if not inc:
|
|
raise HTTPException(404, f"Incident '{incident_id}' not found.")
|
|
# Rate limit by incident ID to prevent repeated expensive LLM calls
|
|
summarize_limiter.check(incident_id)
|
|
background_tasks.add_task(_summarize_incident, inc)
|
|
return {"ok": True, "incident_id": incident_id}
|
|
|
|
|
|
@router.post("/{incident_id}/calls/{call_id}")
|
|
async def link_call_to_incident(incident_id: str, call_id: str, _: dict = Depends(require_admin_token)):
|
|
doc = await fstore.doc_get("incidents", incident_id)
|
|
if not doc:
|
|
raise HTTPException(404, f"Incident '{incident_id}' not found.")
|
|
call_ids = doc.get("call_ids", [])
|
|
if call_id not in call_ids:
|
|
call_ids.append(call_id)
|
|
await fstore.doc_update("incidents", incident_id, {
|
|
"call_ids": call_ids,
|
|
"updated_at": datetime.now(timezone.utc).isoformat(),
|
|
})
|
|
await fstore.doc_update("calls", call_id, {"incident_id": incident_id})
|
|
return {"ok": True}
|