Merge origin/main into feat/discord-system (keep cameras + discord routers)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Logan Cusano
2026-10-04 18:32:26 -04:00
co-authored by Claude Opus 5.5
44 changed files with 4138 additions and 171 deletions
+51
View File
@@ -0,0 +1,51 @@
"""Public-camera endpoints for the live ops wall.
``GET /cameras`` -- any signed-in user (auth applied at include time in main.py):
cameras inside a bbox from every provider an admin has enabled, each with the
provider's required attribution, disclaimer and catalog freshness.
``/admin/camera-providers`` -- platform admin only: list every catalogued
provider with its licence status and health, and toggle it. Gated licences
need ``acknowledge_licence: true``; that acknowledgement is stored with who and
when and written to audit_log (see app/internal/cameras/registry.py).
"""
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from app.internal.auth import describe_actor, require_admin_token
from app.internal.cameras import registry
router = APIRouter(prefix="/cameras", tags=["cameras"])
admin_router = APIRouter(prefix="/admin/camera-providers", tags=["admin"])
@router.get("")
async def get_cameras(
south: float = Query(..., ge=-90, le=90),
west: float = Query(..., ge=-180, le=180),
north: float = Query(..., ge=-90, le=90),
east: float = Query(..., ge=-180, le=180),
):
if south > north or west > east:
raise HTTPException(400, "bbox must satisfy south<=north and west<=east")
return await registry.cameras_in_bbox((south, west, north, east))
class ProviderToggle(BaseModel):
enabled: bool
acknowledge_licence: bool = False
@admin_router.get("")
async def list_camera_providers(_=Depends(require_admin_token)):
return {"providers": await registry.list_providers()}
@admin_router.put("/{provider_id}")
async def set_camera_provider(provider_id: str, body: ProviderToggle, principal: dict = Depends(require_admin_token)):
try:
return await registry.set_enabled(provider_id, body.enabled, describe_actor(principal),
acknowledge_licence=body.acknowledge_licence)
except registry.ProviderError as e:
return JSONResponse(status_code=e.code, content={"detail": e.message, **e.extra})
+26 -1
View File
@@ -119,11 +119,36 @@ async def delete_node(node_id: str, _: dict = Depends(require_admin_token)):
@router.post("/{node_id}/reject")
async def reject_node(node_id: str, _: dict = Depends(require_admin_token)):
"""Reject = revoke (server-26#195). Before this, reject only flipped
``approval_status``, so an approved-then-rejected node kept its MQTT login
(dynsec client), its ``/upload`` key and ``/systems`` read access (both
keyed off ``node_keys``).
Order is fail-closed, the opposite of approve's: the status flips to
``rejected`` FIRST, so even if the broker call fails, ``/upload`` (which
now checks approval_status) and ``GET /nodes/{id}/credentials`` already
refuse the node, and ``dynsec.reconcile_all()`` will no longer re-create
its login on the next c2-core restart (it also deletes rejected nodes'
logins there). Then the broker login is deleted, then the key. A dynsec
failure returns 502 with node_keys left intact, and the admin retries:
every step is idempotent. Re-approving later mints a fresh key, because
node_keys is gone; the revoked key is never handed back.
"""
node = await fstore.doc_get("nodes", node_id)
if not node:
raise HTTPException(404, f"Node '{node_id}' not found.")
await fstore.doc_update("nodes", node_id, {"approval_status": "rejected"})
return {"ok": True}
try:
await dynsec.delete_node_client(node_id)
except dynsec.DynsecError as e:
logger.error(f"Reject {node_id!r}: dynsec deleteClient failed, node_keys NOT deleted: {e}")
raise HTTPException(
502,
f"Node marked rejected, but its MQTT login could not be revoked: {e}. Retry reject.",
)
await fstore.doc_delete("node_keys", node_id)
logger.info(f"Reject {node_id!r}: status rejected, MQTT login and API key revoked")
return {"ok": True, "revoked": True}
@router.post("/{node_id}/command")
+9
View File
@@ -4,6 +4,7 @@ from typing import Optional
from fastapi import APIRouter, HTTPException, Query
from app.internal import ny511
from app.internal.cameras import registry
router = APIRouter(prefix="/traffic", tags=["traffic"])
@@ -27,6 +28,13 @@ async def get_511(
if south > north or west > east:
raise HTTPException(400, "bbox must satisfy south<=north and west<=east")
wanted = {s.strip() for s in (layers or "").split(",") if s.strip()}
# 511NY data (events included) is covered by the same NYSDOT DAA as its
# cameras, so the whole feed follows the ny511 camera provider's switch,
# which defaults OFF and needs a licence acknowledgement (server-26#198).
if not await registry.is_serving("ny511"):
off = {"fetched_at": None, "error": None, "total_in_bbox": 0, "truncated": False, "disabled": True}
return {**{n: [] for n in ("cameras", "events") if n in wanted},
**{f"{n}_status": off for n in ("cameras", "events") if n in wanted}}
feeds = {name: getattr(ny511, name) for name in ("cameras", "events") if name in wanted}
await asyncio.gather(*(f.get() for f in feeds.values()))
@@ -35,4 +43,5 @@ async def get_511(
hits = ny511.in_bbox(feed.items, south, west, north, east)
out[name] = hits[:MAX_ITEMS]
out[f"{name}_status"] = {**_feed_status(feed), "total_in_bbox": len(hits), "truncated": len(hits) > MAX_ITEMS}
out["credit"] = registry.credit("ny511") # DAA: source credit + as-is disclaimer travel with the data
return out
+2 -2
View File
@@ -531,8 +531,8 @@ async def trip_chat(
events = await fstore.collection_list("trip_events", trip_id=trip_id)
from openai import AsyncOpenAI
oai = AsyncOpenAI(api_key=settings.openai_api_key)
from app.internal.ai_clients import async_openai_client
oai = async_openai_client()
# Strip history to only user/assistant roles to prevent prompt injection
safe_history = [
+16 -4
View File
@@ -47,6 +47,18 @@ async def upload_call_audio(
f"(received prefix: {credentials.credentials[:8]}...)"
)
raise HTTPException(401, "Invalid node API key")
# server-26#195: a valid key is not enough, the node must still be
# approved. reject_node() deletes node_keys too, but this closes the
# window where that delete failed or was never run (a node rejected before
# reject revoked anything). Uncached read: revocation must bite on the
# next upload, not after doc_get_cached's 5-minute TTL.
node = await fstore.doc_get("nodes", node_id)
if not node or node.get("approval_status") != "approved":
logger.warning(
f"Upload 403: node_id={node_id!r} has a valid key but approval_status="
f"{(node or {}).get('approval_status')!r}"
)
raise HTTPException(403, "Node is not approved")
data = await file.read()
if not data:
@@ -63,10 +75,10 @@ async def upload_call_audio(
# org_id is stamped defensively here too (not just in
# mqtt_handler.py's call_start/call_end): key_doc above proves this
# node_id is real and authenticated, so resolving org_id from the
# node doc here covers a call whose Firestore doc was somehow
# never written by call_start (the upload is otherwise the
# authoritative record of which node this audio came from).
node = await fstore.doc_get_cached("nodes", node_id)
# node doc (fetched by the approval gate above) here covers a call
# whose Firestore doc was somehow never written by call_start (the
# upload is otherwise the authoritative record of which node this
# audio came from).
updates = {"audio_gcs_uri": gcs_uri}
if node and node.get("org_id"):
updates["org_id"] = node["org_id"]