Merge remote-tracking branch 'origin/main' into feat/225-s7-oauth-ui

This commit is contained in:
Logan Cusano
2026-10-05 03:03:26 -04:00
38 changed files with 7775 additions and 246 deletions
+109 -25
View File
@@ -27,6 +27,16 @@ Firestore layout:
unlinked_at|None, unlinked_by|None unlinked_at|None, unlinked_by|None
Person names are never stored: the redeemer is kept as a Discord user id. Person names are never stored: the redeemer is kept as a Discord user id.
Every way a server gets linked — ``/drb link code:`` (``redeem_and_settle``),
the platform admin's ``PUT /discord/guilds/{id}/link`` and the per-bot OAuth
invite (server-26#225 S6) (both ``link_and_settle``) — writes the guild doc
through ONE transaction body (``_apply_link``) and then runs ONE settle step
(``_settle``): a server that was acting for a different org (in practice the
``DISCORD_UNLINKED_GUILD_ORG_ID`` fallback) has its live radio channels
stopped, because they were placed on that org's nodes. The org it acted for
before is read inside the same transaction as the write, so the stop decision
can't race a concurrent link.
""" """
from __future__ import annotations from __future__ import annotations
@@ -35,6 +45,7 @@ import secrets
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from typing import Optional from typing import Optional
from app.internal import discord_streams as streams
from app.internal import firestore as fstore from app.internal import firestore as fstore
CODES = "discord_link_codes" CODES = "discord_link_codes"
@@ -91,6 +102,63 @@ async def mint_code(org_id: str, uid: str) -> dict:
"expires_minutes": CODE_TTL_MINUTES} "expires_minutes": CODE_TTL_MINUTES}
# Stop reasons the settle step stamps on the streams it stops.
RELINK_STOP_REASON = "relinked"
ADMIN_RELINK_STOP_REASON = "server linked to another organization"
def _apply_link(txn, guild_ref, guild: Optional[dict], *, org_id: str, guild_id: str,
guild_name: Optional[str], linked_by: str, via: str, now: datetime,
allow_move: bool = False, extra: Optional[dict] = None):
"""The guild-side half of every link, inside a transaction whose reads are
all done. Returns a LinkError (nothing written) or the result dict:
``{org_id, already_linked, previous_org_id}`` — ``previous_org_id`` is the
org the server acted for before (its link, else the fallback org).
``allow_move`` is the platform admin's support override; every self-serve
path refuses to move a server another org has linked."""
current = (guild or {}).get("org_id")
if current and current != org_id and not allow_move:
return LinkError(409, "linked_elsewhere",
"This server is already linked to a different DRB organization. "
"That organization has to unlink it first.")
iso = now.isoformat()
name = (guild_name or "").strip()[:100]
txn.set(guild_ref, {
"guild_id": guild_id,
"org_id": org_id,
"guild_name": name or (guild or {}).get("guild_name"),
"linked_by": linked_by,
"linked_via": via,
"linked_at": ((guild or {}).get("linked_at") if current == org_id else None) or iso,
"unlinked_at": None,
"unlinked_by": None,
# A server an admin removed (streams.remove_guild) comes back on link.
"removed_at": None,
"removed_by": None,
**(extra or {}),
}, merge=True)
return {"org_id": org_id, "already_linked": current == org_id,
"previous_org_id": streams.acting_org_of(guild)}
def link_guild_txn(org_id: str, guild_id: str, guild_name: Optional[str], linked_by: str, via: str, *,
allow_move: bool = False, extra: Optional[dict] = None, now: Optional[datetime] = None):
"""Transaction body that links ``guild_id`` to ``org_id`` directly (no code)."""
db = fstore.db
when = now or _now()
def _txn(txn):
guild_ref = db.collection(GUILDS).document(guild_id)
snap = guild_ref.get(transaction=txn)
guild = snap.to_dict() if snap.exists else None
out = _apply_link(txn, guild_ref, guild, org_id=org_id, guild_id=guild_id, guild_name=guild_name,
linked_by=linked_by, via=via, now=when, allow_move=allow_move, extra=extra)
return {"error": out} if isinstance(out, LinkError) else out
return _txn
def _redeem_txn(code: str, guild_id: str, guild_name: Optional[str], discord_user_id: str, now: datetime): def _redeem_txn(code: str, guild_id: str, guild_name: Optional[str], discord_user_id: str, now: datetime):
"""Build the transaction body. Every read happens before any write.""" """Build the transaction body. Every read happens before any write."""
db = fstore.db db = fstore.db
@@ -113,37 +181,20 @@ def _redeem_txn(code: str, guild_id: str, guild_name: Optional[str], discord_use
return {"error": LinkError(410, "code_expired", return {"error": LinkError(410, "code_expired",
"That link code has expired. Generate a new one in DRB → " "That link code has expired. Generate a new one in DRB → "
"Settings → Organization.")} "Settings → Organization.")}
org_id = doc["org_id"] out = _apply_link(txn, guild_ref, guild, org_id=doc["org_id"], guild_id=guild_id,
current = (guild or {}).get("org_id") guild_name=guild_name, linked_by=f"discord:{discord_user_id}", via="code", now=now)
if current and current != org_id: if isinstance(out, LinkError):
return {"error": LinkError(409, "linked_elsewhere", return {"error": out} # the code is NOT consumed: its org can use it elsewhere
"This server is already linked to a different DRB organization. " txn.update(code_ref, {"redeemed_at": now.isoformat(), "redeemed_guild_id": guild_id,
"That organization has to unlink it first.")}
iso = now.isoformat()
txn.update(code_ref, {"redeemed_at": iso, "redeemed_guild_id": guild_id,
"redeemed_by_discord_id": discord_user_id}) "redeemed_by_discord_id": discord_user_id})
txn.set(guild_ref, { return {**out, "created_by_uid": doc.get("created_by_uid")}
"guild_id": guild_id,
"org_id": org_id,
"guild_name": (guild_name or "")[:100] or (guild or {}).get("guild_name"),
"linked_by": f"discord:{discord_user_id}",
"linked_via": "code",
"linked_at": ((guild or {}).get("linked_at") if current == org_id else None) or iso,
"unlinked_at": None,
"unlinked_by": None,
# A server an admin removed (streams.remove_guild) comes back on link.
"removed_at": None,
"removed_by": None,
}, merge=True)
return {"org_id": org_id, "already_linked": current == org_id,
"created_by_uid": doc.get("created_by_uid")}
return _txn return _txn
async def redeem_code(raw_code: str, guild_id: str, guild_name: Optional[str], discord_user_id: str) -> dict: async def redeem_code(raw_code: str, guild_id: str, guild_name: Optional[str], discord_user_id: str) -> dict:
"""Link ``guild_id`` to the code's org. Raises LinkError on refusal.""" """Link ``guild_id`` to the code's org. Raises LinkError on refusal.
Does NOT stop anything — routes call ``redeem_and_settle``."""
code = normalize(raw_code) code = normalize(raw_code)
if len(code) != _CODE_LEN: if len(code) != _CODE_LEN:
raise LinkError(404, "invalid_code", raise LinkError(404, "invalid_code",
@@ -155,6 +206,39 @@ async def redeem_code(raw_code: str, guild_id: str, guild_name: Optional[str], d
return result return result
async def _settle(guild_id: str, result: dict, stop_reason: str) -> dict:
"""After a link committed: a server that was acting for another org (the
fallback org, or — admin override only — another linked org) has its live
radio channels stopped; they were placed on that org's nodes, and left
running the reconciler would keep re-placing them there
(server-26#225 plan finding 3: this lived in two routers before)."""
before = result.get("previous_org_id")
stopped: list = []
if before and before != result["org_id"]:
stopped = await streams.stop_guild(str(guild_id), stop_reason)
return {**result, "streams_stopped": stopped}
async def link_and_settle(org_id: str, guild_id: str, guild_name: Optional[str], linked_by: str, via: str, *,
allow_move: bool = False, extra: Optional[dict] = None,
stop_reason: str = RELINK_STOP_REASON) -> dict:
"""Link a server to an org directly (admin, OAuth) and settle. Raises
LinkError (``linked_elsewhere``) unless ``allow_move``. Returns
``{org_id, already_linked, previous_org_id, streams_stopped}``."""
result = await fstore.run_transaction(
link_guild_txn(org_id, str(guild_id), guild_name, linked_by, via, allow_move=allow_move, extra=extra))
if "error" in result:
raise result["error"]
return await _settle(guild_id, result, stop_reason)
async def redeem_and_settle(raw_code: str, guild_id: str, guild_name: Optional[str],
discord_user_id: str) -> dict:
"""``/drb link code:`` — redeem, then settle. Raises LinkError."""
result = await redeem_code(raw_code, guild_id, guild_name, discord_user_id)
return await _settle(guild_id, result, RELINK_STOP_REASON)
async def list_org_guilds(org_id: str) -> list[dict]: async def list_org_guilds(org_id: str) -> list[dict]:
return [ return [
{"guild_id": g.get("guild_id"), "guild_name": g.get("guild_name"), {"guild_id": g.get("guild_id"), "guild_name": g.get("guild_name"),
+246
View File
@@ -0,0 +1,246 @@
"""
Per-bot OAuth "Connect a Discord server" (server-26#225 S6, owner decision Q1).
An org owner links a Discord server by inviting ONE radio bot through
Discord's OAuth2 bot invite. The invite's ``state`` carries a single-use,
short-lived nonce bound to the org and the user who started it; Discord
redirects back with ``code`` (and ``guild_id``), C2 exchanges the code with
THAT bot application's client secret, and the exchange's ``guild`` object —
never the query string — says which server to link.
Secrets:
bot_oauth/{token_id} server-only (Firestore rules deny all
client_secret, enabled, client reads; c2-core's admin SDK only)
set_by, set_at, checked_at
The secret is never on ``bot_tokens`` (GET /tokens returns an allowlist of
those fields), never returned by any route, never logged, never put in an
error string. Firestore encrypts it at rest like every other document; the
codebase has no application-level encryption pattern (bot tokens are
stored the same way), so none is invented here.
States:
discord_oauth_states/{sha256(nonce)} the nonce itself is never stored
org_id, uid, token_id, node_id, return_to, created_at, expires_at,
used_at|None, used_by|None
OAuth is off while ``settings.discord_oauth_redirect_uri`` is empty: every
route 404s and the UI hides the button.
HTTP goes through discord_rest's shared client (User-Agent, transport for
tests) and counts 401/403/429 answers against its process-wide breaker, so a
wrong secret retried in a loop can't contribute to an IP ban either.
"""
from __future__ import annotations
import hashlib
import secrets
import time
from datetime import datetime, timedelta, timezone
from typing import Any, Optional
from urllib.parse import urlencode
import httpx
from app.config import settings
from app.internal import discord_leases as leases
from app.internal import discord_rest
from app.internal import firestore as fstore
OAUTH = "bot_oauth"
STATES = "discord_oauth_states"
AUTHORIZE_URL = "https://discord.com/oauth2/authorize"
# Discord client secrets are 32 url-safe characters today; accept a margin.
SECRET_MIN, SECRET_MAX = 16, 128
RETURN_TO_MAX = 200
def enabled() -> bool:
return bool((settings.discord_oauth_redirect_uri or "").strip())
def _now() -> datetime:
return datetime.now(timezone.utc)
def state_key(nonce: str) -> str:
return hashlib.sha256((nonce or "").encode()).hexdigest()
def safe_return_to(value: Optional[str]) -> Optional[str]:
"""A same-site path only (``/nodes/abc``), never an absolute or
protocol-relative URL — the callback page redirects to it."""
v = (value or "").strip()
if not v or len(v) > RETURN_TO_MAX or not v.startswith("/") or v.startswith("//") or "\\" in v:
return None
if any(ord(ch) < 0x20 for ch in v):
return None
return v
# ---------------------------------------------------------------------------
# Secrets
# ---------------------------------------------------------------------------
async def enabled_token_ids() -> set[str]:
"""Bots whose OAuth secret is set and enabled."""
return {d.get("token_id") for d in await fstore.collection_list(OAUTH)
if d.get("token_id") and d.get("enabled") and d.get("client_secret")}
async def load_secret(token_id: str) -> Optional[str]:
doc = await fstore.doc_get(OAUTH, token_id)
if not doc or not doc.get("enabled") or not doc.get("client_secret"):
return None
return doc["client_secret"]
async def store_secret(token_id: str, client_secret: str, uid: str) -> None:
now = _now().isoformat()
await fstore.doc_set(OAUTH, token_id, {
"token_id": token_id, "client_secret": client_secret, "enabled": True,
"set_by": uid, "set_at": now, "checked_at": now,
}, merge=False)
async def clear_secret(token_id: str) -> bool:
if not await fstore.doc_get(OAUTH, token_id):
return False
await fstore.doc_delete(OAUTH, token_id)
return True
# ---------------------------------------------------------------------------
# Discord OAuth2 HTTP (client_id + client_secret, basic auth)
# ---------------------------------------------------------------------------
async def _post_form(path: str, data: dict, client_id: str, client_secret: str) -> discord_rest.RestResult:
"""POST an application-authenticated form. Never raises for a Discord or
network failure; the error string never holds the secret, the code or a
token (only Discord's short ``error`` field)."""
now = time.monotonic()
if discord_rest._breaker.tripped(now):
return discord_rest.RestResult(None, error="Discord REST paused (invalid-request budget reached)")
try:
r = await discord_rest._client().post(path, data=data, auth=(client_id, client_secret))
except httpx.HTTPError as e:
return discord_rest.RestResult(None, error=type(e).__name__)
status = r.status_code
if status in (401, 403) or (status == 429 and r.headers.get("x-ratelimit-scope") != "shared"):
discord_rest._breaker.record(time.monotonic())
body: Any = None
try:
body = r.json() if r.content else None
except ValueError:
body = None
if 200 <= status < 300:
return discord_rest.RestResult(status, body)
err = body.get("error") if isinstance(body, dict) and isinstance(body.get("error"), str) else None
return discord_rest.RestResult(status, None, error=f"Discord answered {status}" + (f" ({err[:40]})" if err else ""))
async def exchange_code(client_id: str, client_secret: str, code: str) -> discord_rest.RestResult:
return await _post_form("/oauth2/token", {
"grant_type": "authorization_code", "code": code,
"redirect_uri": settings.discord_oauth_redirect_uri.strip(),
}, client_id, client_secret)
async def revoke(client_id: str, client_secret: str, access_token: Optional[str]) -> None:
"""Best effort: C2 never uses the user's access token, so it is revoked
as soon as the guild has been read from the exchange."""
if access_token:
await _post_form("/oauth2/token/revoke", {"token": access_token, "token_type_hint": "access_token"},
client_id, client_secret)
async def check_secret(client_id: str, client_secret: str) -> discord_rest.RestResult:
"""Prove ``client_secret`` belongs to ``client_id`` with a client-
credentials grant (scope ``identify`` — the bot owner's own token), then
revoke the token it returns. Nothing is stored from the answer."""
r = await _post_form("/oauth2/token", {"grant_type": "client_credentials", "scope": "identify"},
client_id, client_secret)
if r.ok and isinstance(r.data, dict):
await revoke(client_id, client_secret, r.data.get("access_token"))
return r
# ---------------------------------------------------------------------------
# State + authorize URL
# ---------------------------------------------------------------------------
def authorize_url(application_id: str, nonce: str) -> str:
return AUTHORIZE_URL + "?" + urlencode({
"client_id": application_id,
"scope": "bot",
"permissions": str(leases.RADIO_BOT_PERMISSIONS),
"response_type": "code",
"redirect_uri": settings.discord_oauth_redirect_uri.strip(),
"state": nonce,
})
async def create_state(*, org_id: str, uid: str, token_id: str, node_id: Optional[str],
return_to: Optional[str]) -> tuple[str, datetime]:
nonce = secrets.token_urlsafe(32)
now = _now()
expires_at = now + timedelta(seconds=max(60, settings.discord_oauth_state_ttl_seconds))
await fstore.doc_set(STATES, state_key(nonce), {
"org_id": org_id, "uid": uid, "token_id": token_id, "node_id": node_id,
"return_to": return_to, "created_at": now.isoformat(), "expires_at": expires_at.isoformat(),
"used_at": None, "used_by": None,
}, merge=False)
return nonce, expires_at
class StateError(Exception):
def __init__(self, status: int, code: str, message: str):
super().__init__(message)
self.status, self.code, self.message = status, code, message
def consume_state_txn(nonce: str, uid: str, now: datetime):
"""Transaction body: the state must exist, be unexpired and unused, and
belong to ``uid`` (login-CSRF: someone else's state never links into the
caller's org, and the caller's own code can't be replayed by another
account). Any presentation of a live state burns it — a failure later in
the flow means starting again, never retrying the same state."""
db = fstore.db
key = state_key(nonce)
def _txn(txn):
ref = db.collection(STATES).document(key)
snap = ref.get(transaction=txn)
doc = snap.to_dict() if snap.exists else None
if not doc:
return {"error": StateError(400, "invalid_state",
"That Discord link is not valid. Start again from DRB.")}
if doc.get("used_at"):
return {"error": StateError(409, "state_used",
"That Discord link was already used. Start again from DRB.")}
txn.update(ref, {"used_at": now.isoformat(), "used_by": uid})
try:
expired = now > datetime.fromisoformat(doc.get("expires_at") or "")
except ValueError:
expired = True
if expired:
return {"error": StateError(410, "state_expired",
"That Discord link expired. Start again from DRB.")}
if doc.get("uid") != uid:
return {"error": StateError(403, "wrong_user",
"That Discord link was started by a different DRB account. "
"Sign in as that account, or start again.")}
return {"state": doc}
return _txn
async def consume_state(nonce: str, uid: str) -> dict:
if not nonce or len(nonce) > 200:
raise StateError(400, "invalid_state", "That Discord link is not valid. Start again from DRB.")
out = await fstore.run_transaction(consume_state_txn(nonce, uid, _now()))
if "error" in out:
raise out["error"]
return out["state"]
+97
View File
@@ -655,3 +655,100 @@ async def guild_snapshot(token_doc: dict, guild_id: str, *, refresh: bool = Fals
error=f"check failed ({type(e).__name__})") error=f"check failed ({type(e).__name__})")
return await access_cache.get_or_load(key, _load, lambda s: ttl_for(s.status), refresh=refresh) return await access_cache.get_or_load(key, _load, lambda s: ttl_for(s.status), refresh=refresh)
# ---------------------------------------------------------------------------
# Voice-channel directory from a radio bot's view (server-26#225 S5)
# ---------------------------------------------------------------------------
DIRECTORY_MAX_CHANNELS = 200 # same cap as PUT /discord/guilds/{id}/directory
_DIRECTORY_NAME_MAX = 100
VOICE_CHANNEL = 2
CATEGORY_CHANNEL = 4
def _int_or(value: Any, default: int = 0) -> int:
try:
return int(value)
except (TypeError, ValueError):
return default
def directory_from_snapshot(snap: Optional[GuildSnapshot]) -> Optional[dict]:
"""A server's name and voice channels as one radio bot's snapshot sees
them, in the shape the DRB server bot pushes (``PUT
/discord/guilds/{id}/directory``): ``{name, voice_channels: [{channel_id,
name, category, position}]}``, in Discord's sidebar order (channels with
no category first, then each category by position; channels by position,
then id). None unless the snapshot is OK.
Every voice channel in the list is kept, including any this bot can't
View, exactly like the server bot's gateway list: whether a bot may join
is the permission check's answer (discord_streams.evaluate_access), and
the picker shows it per channel. Stage channels are not voice channels
here either."""
if snap is None or snap.status != OK or not isinstance(snap.channels, list):
return None
chans = [c for c in snap.channels if isinstance(c, dict) and c.get("id") is not None]
cats = {str(c["id"]): c for c in chans if c.get("type") == CATEGORY_CHANNEL}
def parent(c: dict) -> Optional[dict]:
return cats.get(str(c.get("parent_id") or ""))
def order(c: dict) -> tuple:
p = parent(c)
top = (0, 0, 0) if p is None else (1, _int_or(p.get("position")), _int_or(p.get("id")))
return (*top, _int_or(c.get("position")), _int_or(c.get("id")))
out: list[dict] = []
for c in sorted((c for c in chans if c.get("type") == VOICE_CHANNEL), key=order)[:DIRECTORY_MAX_CHANNELS]:
p = parent(c)
category = str(p.get("name") or "")[:_DIRECTORY_NAME_MAX] if p else ""
out.append({
"channel_id": str(c["id"]),
"name": str(c.get("name") or "")[:_DIRECTORY_NAME_MAX],
"category": category or None,
"position": len(out),
})
name = snap.guild.get("name") if isinstance(snap.guild, dict) else None
return {"name": str(name)[:_DIRECTORY_NAME_MAX] if name else None, "voice_channels": out}
@dataclass
class DirectoryResult:
"""``guild_directory``'s answer. ``status``: OK (``name`` and
``voice_channels`` from ``token_id``'s snapshot), NOT_MEMBER (every bot
asked turned out not to be in the server, or its token is invalid) or
UNKNOWN (no usable answer — keep what is stored)."""
guild_id: str
status: str
name: Optional[str] = None
voice_channels: Optional[list] = None
token_id: Optional[str] = None
fetched_at: Optional[float] = None
async def guild_directory(token_docs: list[dict], guild_id: str, *, refresh: bool = False,
max_bots: int = 3) -> DirectoryResult:
"""The voice-channel list of one server, read from the first of
``token_docs`` (radio bots believed to be in it, best first) whose cached
snapshot is OK. It is the same snapshot the permission check reads, so
listing channels costs no extra Discord request. At most ``max_bots``
bots are asked, one after another. Never raises for a Discord failure.
The caller decides which server and which bots: this never looks a
server up by itself, so it can't discover one (a server DRB doesn't
already know is never listed from a radio bot's membership)."""
guild_id = str(guild_id)
statuses: list[str] = []
for doc in token_docs[:max(1, max_bots)]:
snap = await guild_snapshot(doc, guild_id, refresh=refresh)
statuses.append(snap.status)
d = directory_from_snapshot(snap)
if d is not None:
return DirectoryResult(guild_id=guild_id, status=OK, name=d["name"],
voice_channels=d["voice_channels"], token_id=snap.token_id,
fetched_at=snap.fetched_at)
if statuses and all(s in (NOT_MEMBER, INVALID) for s in statuses):
return DirectoryResult(guild_id=guild_id, status=NOT_MEMBER)
return DirectoryResult(guild_id=guild_id, status=UNKNOWN)
+52 -12
View File
@@ -53,6 +53,7 @@ A retained MQTT message therefore never stores a bot credential on the broker.
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import time
from datetime import datetime, timezone, timedelta from datetime import datetime, timezone, timedelta
from typing import Optional from typing import Optional
@@ -93,6 +94,17 @@ def _parse_ts(value) -> Optional[datetime]:
# Guild -> org # Guild -> org
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def acting_org_of(link: Optional[dict]) -> Optional[str]:
"""``resolve_guild_org`` for an already-read ``discord_guilds`` doc (None =
no doc). Pure, so a link transaction can decide from the snapshot it read
(discord_guild_link.link_guild_txn, server-26#225 S6)."""
if link and link.get("org_id"):
return link["org_id"]
if link and link.get("unlinked_at"):
return None
return settings.discord_unlinked_guild_org_id or None
async def resolve_guild_org(guild_id: str) -> Optional[str]: async def resolve_guild_org(guild_id: str) -> Optional[str]:
"""The org a Discord server acts for: its ``discord_guilds`` link, else """The org a Discord server acts for: its ``discord_guilds`` link, else
``settings.discord_unlinked_guild_org_id`` (None when that is empty). ``settings.discord_unlinked_guild_org_id`` (None when that is empty).
@@ -100,12 +112,7 @@ async def resolve_guild_org(guild_id: str) -> Optional[str]:
A server an org explicitly unlinked (``unlinked_at`` set, server-26#213) A server an org explicitly unlinked (``unlinked_at`` set, server-26#213)
gets no fallback: otherwise unlinking org B's server would quietly hand it gets no fallback: otherwise unlinking org B's server would quietly hand it
to the founding org.""" to the founding org."""
link = await fstore.doc_get(GUILDS, str(guild_id)) return acting_org_of(await fstore.doc_get(GUILDS, str(guild_id)))
if link and link.get("org_id"):
return link["org_id"]
if link and link.get("unlinked_at"):
return None
return settings.discord_unlinked_guild_org_id or None
async def _node_stream_counts(exclude_stream_id: Optional[str] = None) -> dict[str, int]: async def _node_stream_counts(exclude_stream_id: Optional[str] = None) -> dict[str, int]:
@@ -352,14 +359,39 @@ async def check_bots(tokens: dict[str, dict], token_ids: list[str], guild_id: st
for t in token_ids} for t in token_ids}
# server-26#251: an interactive start (a person pressed Start / ran /join)
# re-reads a bot's snapshot that says "denied" once it is this old, so a
# permission fixed in Discord a moment ago is seen without waiting out the
# cache. The reconciler never does (plan finding 4: no REST loop).
RECHECK_DENIED_AFTER_SECONDS = 10.0
async def _perm_check(*, org_id: str, guild_id: str, channel_id: str, stream_id: str, async def _perm_check(*, org_id: str, guild_id: str, channel_id: str, stream_id: str,
preferred_token_id: Optional[str], node_ids: set) -> Optional[dict]: preferred_token_id: Optional[str], node_ids: set,
recheck_denied: bool = False) -> Optional[dict]:
if not settings.discord_perm_check_enabled: if not settings.discord_perm_check_enabled:
return None return None
tokens = {t["token_id"]: t for t in await fstore.collection_list(leases.TOKENS) if t.get("token_id")} tokens = {t["token_id"]: t for t in await fstore.collection_list(leases.TOKENS) if t.get("token_id")}
ids = tokens_to_check(tokens, guild_id=guild_id, org_id=org_id, node_ids=node_ids, ids = tokens_to_check(tokens, guild_id=guild_id, org_id=org_id, node_ids=node_ids,
exclude_stream_id=stream_id, preferred_token_id=preferred_token_id) exclude_stream_id=stream_id, preferred_token_id=preferred_token_id)
return await check_bots(tokens, ids, guild_id, channel_id) snaps = await gather_snapshots(tokens, ids, guild_id)
def verdict(t: str) -> dict:
return evaluate_access(snaps[t], channel_id, bot=tokens[t].get("name") or t) if snaps.get(t) \
else _unanswered()
out = {t: verdict(t) for t in ids}
if recheck_denied:
now = time.time()
stale = [t for t in ids if out[t]["status"] == leases.PERM_DENIED
and now - (getattr(snaps.get(t), "fetched_at", None) or now) >= RECHECK_DENIED_AFTER_SECONDS]
if stale:
fresh = await gather_snapshots(tokens, stale, guild_id, refresh=True)
for t, snap in fresh.items():
if snap is not None: # not answered in time: keep the cached verdict
snaps[t] = snap
out[t] = verdict(t)
return out
def _channel_ref(perms: Optional[dict], channel_name: Optional[str]) -> str: def _channel_ref(perms: Optional[dict], channel_name: Optional[str]) -> str:
@@ -484,6 +516,7 @@ async def start_stream(
preferred_token_id: Optional[str] = None, preferred_token_id: Optional[str] = None,
max_streams: Optional[int] = None, max_streams: Optional[int] = None,
stream_id: Optional[str] = None, stream_id: Optional[str] = None,
interactive: bool = False,
) -> dict: ) -> dict:
"""Put ``system_id`` on a voice channel. """Put ``system_id`` on a voice channel.
@@ -504,6 +537,12 @@ async def start_stream(
channel first (``_perm_check``; server-26#225). Every entry point — the channel first (``_perm_check``; server-26#225). Every entry point — the
bot's /join, the web picker, the node command and the reconciler — bot's /join, the web picker, the node command and the reconciler —
comes through here, so none can hand a node a bot Discord will refuse. comes through here, so none can hand a node a bot Discord will refuse.
``interactive``: a person asked for this start (the routes pass True,
the reconciler never does). A bot whose cached check says "denied" is
then asked again if that answer is more than
``RECHECK_DENIED_AFTER_SECONDS`` old (server-26#251), so "fix it in
Discord, then try again" works on the next try.
""" """
if mode not in ("persistent", "adhoc"): if mode not in ("persistent", "adhoc"):
raise StreamError("bad_mode", "mode must be persistent or adhoc", status=400) raise StreamError("bad_mode", "mode must be persistent or adhoc", status=400)
@@ -527,14 +566,14 @@ async def start_stream(
org_id=org_id, guild_id=guild_id, channel_id=channel_id, system_id=system_id, mode=mode, org_id=org_id, guild_id=guild_id, channel_id=channel_id, system_id=system_id, mode=mode,
channel_name=channel_name, guild_name=guild_name, created_by=created_by, node_id=node_id, channel_name=channel_name, guild_name=guild_name, created_by=created_by, node_id=node_id,
preferred_token_id=preferred_token_id, max_streams=max_streams, stream_id=stream_id, preferred_token_id=preferred_token_id, max_streams=max_streams, stream_id=stream_id,
existing=existing, existing=existing, interactive=interactive,
) )
async def _start_locked(*, org_id: str, guild_id: str, channel_id: str, system_id: str, mode: str, async def _start_locked(*, org_id: str, guild_id: str, channel_id: str, system_id: str, mode: str,
channel_name: Optional[str], guild_name: Optional[str], created_by: Optional[str], channel_name: Optional[str], guild_name: Optional[str], created_by: Optional[str],
node_id: Optional[str], preferred_token_id: Optional[str], max_streams: Optional[int], node_id: Optional[str], preferred_token_id: Optional[str], max_streams: Optional[int],
stream_id: Optional[str], existing: dict) -> dict: stream_id: Optional[str], existing: dict, interactive: bool = False) -> dict:
already_on = False already_on = False
if stream_id: if stream_id:
existing = await fstore.doc_get(STREAMS, stream_id) or existing # re-read under the lock existing = await fstore.doc_get(STREAMS, stream_id) or existing # re-read under the lock
@@ -612,7 +651,8 @@ async def _start_locked(*, org_id: str, guild_id: str, channel_id: str, system_i
node_ids = {n["node_id"] for n in candidates} node_ids = {n["node_id"] for n in candidates}
perms = await _perm_check(org_id=org_id, guild_id=guild_id, channel_id=channel_id, stream_id=stream_id, perms = await _perm_check(org_id=org_id, guild_id=guild_id, channel_id=channel_id, stream_id=stream_id,
preferred_token_id=preferred_token_id, node_ids=node_ids) preferred_token_id=preferred_token_id, node_ids=node_ids,
recheck_denied=interactive)
if perms and any(p.get("status") == "bad_channel" for p in perms.values()): if perms and any(p.get("status") == "bad_channel" for p in perms.values()):
raise StreamError("bad_channel", f"{_channel_ref(perms, channel_name)} is not a voice channel. " raise StreamError("bad_channel", f"{_channel_ref(perms, channel_name)} is not a voice channel. "
"Radio can only play in a voice channel — pick one from the list.", "Radio can only play in a voice channel — pick one from the list.",
@@ -638,7 +678,7 @@ async def _start_locked(*, org_id: str, guild_id: str, channel_id: str, system_i
# Bots that just turned out to be members get checked too. # Bots that just turned out to be members get checked too.
perms = await _perm_check(org_id=org_id, guild_id=guild_id, channel_id=channel_id, perms = await _perm_check(org_id=org_id, guild_id=guild_id, channel_id=channel_id,
stream_id=stream_id, preferred_token_id=preferred_token_id, stream_id=stream_id, preferred_token_id=preferred_token_id,
node_ids=node_ids) node_ids=node_ids, recheck_denied=interactive)
res = await leases.lease_for_stream(stream_id, node_id=node["node_id"], org_id=org_id, res = await leases.lease_for_stream(stream_id, node_id=node["node_id"], org_id=org_id,
preferred_token_id=preferred_token_id, stream_fields=fields, preferred_token_id=preferred_token_id, stream_fields=fields,
soft_preferred_token_id=soft_pref, perms=perms) soft_preferred_token_id=soft_pref, perms=perms)
@@ -0,0 +1,8 @@
"""Pluggable power-outage providers (map layer, server-26#184).
Same shape as ``app.internal.cameras``: every source is one adapter carrying
its catalogued licence status, every provider defaults OFF, and a licence-gated
one serves only after an admin acknowledgement recorded in audit_log
(``registry``). Scraped utility storm-center JSON is never shipped -- those
sources are stubs until a data-sharing agreement exists.
"""
+201
View File
@@ -0,0 +1,201 @@
"""Outage provider interface, normalised record and the per-provider feed cache.
The licence model (``Licence``, ``LicenceInfo``, the GATED set) is the camera
layer's, imported -- one catalogue vocabulary for every licence-gated source.
Only the acknowledgement wording differs (outage data, not imagery), so
``OutageLicenceInfo`` overrides ``warning()`` and nothing else.
Outage feeds are public and identical for every org, so -- like the camera
catalogs -- they are cached in memory, fetched lazily on request, and never
written to Firestore. A failed refresh keeps serving the last good feed and
reports the error and its age: a dead feed must show as an error, never as an
empty layer (the AI-silent-failures lesson).
"""
import asyncio
import time
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional, Tuple
from app.internal.cameras.base import ( # noqa: F401 (re-exported for adapters)
BBox, Licence, LicenceInfo, bbox_intersects, finite, get_json, require_parsed, safe_error,
)
from app.internal.logger import logger
RETRY_AFTER_FAILURE_S = 60
DEFAULT_FEED_TTL_S = 5 * 60 # ODIN asks utilities for updates every 5-15 min
USER_AGENT = "DRB-c2-core/1.0 (+public power-outage data; contact via odin.ornl.gov)"
OutageArea = Dict[str, Any]
@dataclass(frozen=True)
class OutageLicenceInfo(LicenceInfo):
"""``LicenceInfo`` with the acknowledgement worded for outage data."""
def warning(self) -> Optional[str]:
if self.status == Licence.PROHIBITED:
return f"{self.label}. This source's terms forbid this use; it cannot be enabled."
if not self.gated:
return None
return (f"{self.label}. {self.terms} Enabling it means DRB shows this source's outage data "
f"to every signed-in user. Only confirm if DRB actually holds the right to do so.")
@dataclass(frozen=True)
class OutageProviderMeta:
id: str
name: str
region: str
coverage: BBox # rough bbox; a query outside it never triggers a fetch
licence: LicenceInfo
granularity: str # "county", "point/polygon", ...
implemented: bool
notes: str # what is verified vs unverified about this adapter
homepage: Optional[str] = None
ttl_s: int = DEFAULT_FEED_TTL_S
class OutageProvider:
"""Base adapter. Subclasses set ``meta`` and implement ``fetch``."""
meta: OutageProviderMeta
async def fetch(self) -> List[OutageArea]:
raise NotImplementedError(f"{self.meta.id}: adapter is a stub ({self.meta.notes})")
def coverage_note(self) -> Optional[str]:
"""What this source can and cannot see, shown next to its data."""
return None
class StubProvider(OutageProvider):
"""A catalogued source with no adapter -- listed so Admin shows its licence
status, but it can never be enabled."""
def __init__(self, meta: OutageProviderMeta):
assert not meta.implemented
self.meta = meta
class Feed:
"""In-memory cache of one provider's normalised outage areas."""
def __init__(self, provider: OutageProvider):
self.provider = provider
self.items: List[OutageArea] = []
self.fetched_at: Optional[float] = None # epoch s of last SUCCESSFUL fetch
self.last_attempt_at: Optional[float] = None
self.error: Optional[str] = None
self._next_attempt = 0.0
self._lock = asyncio.Lock()
async def get(self) -> "Feed":
if time.time() < self._next_attempt:
return self
async with self._lock:
if time.time() < self._next_attempt:
return self
ttl = self.provider.meta.ttl_s
self.last_attempt_at = time.time()
try:
items = await self.provider.fetch()
self.items = items
self.fetched_at = time.time()
self.error = None
self._next_attempt = time.time() + ttl
except Exception as e:
self._next_attempt = time.time() + min(ttl, RETRY_AFTER_FAILURE_S)
self.error = safe_error(e)
logger.warning(f"outages/{self.provider.meta.id}: refresh failed, serving {len(self.items)} cached: {self.error}")
return self
def status(self) -> dict:
return {"fetched_at": self.fetched_at, "last_attempt_at": self.last_attempt_at,
"error": self.error, "count": len(self.items)}
# ── helpers shared by adapters ───────────────────────────────────────────────
def make_area(provider_id: str, fips: str, *, county: Optional[str], state: Optional[str], customers_out: int,
customers_served: Optional[int] = None, etr: Optional[str] = None, since: Optional[str] = None,
updated_at: Optional[str] = None, lat: Optional[float] = None, lon: Optional[float] = None,
bbox: Optional[BBox] = None, utilities: Optional[List[dict]] = None,
stale_customers_out: int = 0) -> OutageArea:
"""One normalised county outage record. ``id`` is provider-prefixed.
``customers_out`` counts only live events. ``stale_customers_out`` counts
events the source still lists whose own restoration estimate passed long
ago (ODIN carries April rows in October) -- kept visible, never summed in.
``stale`` is True when every event in the county is stale."""
return {
"id": f"{provider_id}:{fips}",
"provider": provider_id,
"fips": fips, "county": county, "state": state,
"customers_out": customers_out,
"stale_customers_out": stale_customers_out,
"stale": customers_out == 0 and stale_customers_out > 0,
"customers_served": customers_served,
"etr": etr, # latest estimated restoration across the county's outages, if any
"since": since, # earliest reported start across the county's outages
"updated_at": updated_at, # newest time a reporting utility in this county last sent data
# Approximate: the centre of the county's bounding box, for placing a label.
"lat": lat, "lon": lon,
"bbox": list(bbox) if bbox else None, # south, west, north, east
"utilities": utilities or [],
}
def area_in_bbox(area: OutageArea, bbox: BBox) -> bool:
"""County bbox intersects the query; falls back to the label point."""
b = area.get("bbox")
if b and len(b) == 4:
return bbox_intersects((b[0], b[1], b[2], b[3]), bbox)
lat, lon = area.get("lat"), area.get("lon")
if lat is None or lon is None:
return False
s, w, n, e = bbox
return s <= lat <= n and w <= lon <= e
def parse_iso(v: Any) -> Optional[datetime]:
"""ISO-8601 (with Z or offset) -> aware UTC datetime; None when unparseable."""
if not isinstance(v, str) or not v:
return None
try:
d = datetime.fromisoformat(v.replace("Z", "+00:00"))
except ValueError:
return None
return (d if d.tzinfo else d.replace(tzinfo=timezone.utc)).astimezone(timezone.utc)
def iso(d: Optional[datetime]) -> Optional[str]:
return d.isoformat().replace("+00:00", "Z") if d else None
def geometry_bbox(geom: Any) -> Optional[BBox]:
"""Bounding box (south, west, north, east) of a GeoJSON Polygon/MultiPolygon."""
if not isinstance(geom, dict):
return None
lats: List[float] = []
lons: List[float] = []
def walk(c: Any) -> None:
if isinstance(c, list) and len(c) >= 2 and not isinstance(c[0], list):
lon, lat = finite(c[0]), finite(c[1])
if lon is not None and lat is not None and -180 <= lon <= 180 and -90 <= lat <= 90:
lons.append(lon)
lats.append(lat)
elif isinstance(c, list):
for x in c:
walk(x)
walk(geom.get("coordinates"))
if not lats:
return None
return (min(lats), min(lons), max(lats), max(lons))
def bbox_centre(b: Optional[BBox]) -> Tuple[Optional[float], Optional[float]]:
if not b:
return None, None
return (round((b[0] + b[2]) / 2, 5), round((b[1] + b[3]) / 2, 5))
@@ -0,0 +1,273 @@
"""Outage sources -- server-26#184 (source-terms table in its 2026-10-05 comment).
Only ODIN has an adapter. Every other source is listed so Admin shows its
licence status, and is a stub because DRB holds no right to use it: the utility
storm-center maps are undocumented JSON with no reuse licence (the panel ruling
stands -- do not ship scraped utility data commercially), PowerOutage.us
licenses its API for personal/non-commercial use only, and EAGLE-I's live feed
is a DOE partner system. Do not "fix" a stub by scraping.
ODIN, verified first-hand 2026-10-05 against https://odin.ornl.gov/v3/api-docs
(the OpenAPI document the ODIN developer guide points at) and live responses:
GET /odi?format=JSON public, no token. ``{"outage": [CIM Outage, ...]}``,
one row per outage event, every row aggregated to a
county: ``communityDescriptor`` = 5-digit county FIPS,
``metersAffected``, ``reportedStartTime``,
``estimatedRestorationTime.ert``, ``names`` carrying
``UtilityName`` / ``UtilityId`` (EIA id).
GET /odi/map?scale=COUNTY public. One row per county with active outages:
``id`` (FIPS), ``countyName``, ``stateName``,
``metersOut`` (= sum of /odi metersAffected, checked),
``feature`` (county Polygon/MultiPolygon).
GET /odi/status public. One row per reporting utility: ``eiaId``,
``name``, ``receivedDate``, ``hasError``.
``metersServed`` on /odi/map is the utility's system-wide total (National Grid
NY carries 1,686,884 on every county), so it is NOT a county denominator and
``customers_served`` stays None.
"""
import asyncio
import re
from datetime import datetime, timedelta, timezone
from typing import Dict, List, Optional, Tuple
from app.internal.outages.base import (
Licence, OutageArea, OutageLicenceInfo, OutageProvider, OutageProviderMeta, StubProvider, USER_AGENT,
bbox_centre, finite, geometry_bbox, get_json, iso, make_area, parse_iso, require_parsed,
)
US_BBOX = (17.5, -180.0, 72.0, -64.0) # CONUS + AK + HI + PR, rough
NY_BBOX = (40.4, -80.0, 45.1, -71.7)
ODIN_BASE = "https://odin.ornl.gov"
ODIN_OPENAPI = "https://odin.ornl.gov/v3/api-docs"
_FIPS = re.compile(r"^\d{5}$")
# An event whose own estimated restoration passed this long ago is stale: ODIN
# still listed Avangrid rows from April 2026 (ETR April/May) on 2026-10-05.
# Those are not live outages and must not be summed into one or set "since".
STALE_AFTER_ETR = timedelta(hours=24)
class OdinProvider(OutageProvider):
meta = OutageProviderMeta(
id="odin",
name="ODIN (Oak Ridge National Laboratory / DOE)",
region="United States -- participating utilities only (NY: National Grid, Central Hudson, Avangrid reporting 2026-10-05)",
coverage=US_BBOX,
licence=OutageLicenceInfo(
status=Licence.UNVERIFIED,
label="Licence unverified -- public county-level API, but no terms of use or reuse licence found",
terms=("ODIN documents GET /odi, /odi/map and /odi/status as public, non-authenticated endpoints "
"returning current outages aggregated to county FIPS (OpenAPI read first-hand 2026-10-05). "
"That document carries no licence or terms-of-service field, and no terms-of-use page was "
"found on odin.ornl.gov. ORNL is operated for DOE by a contractor and the data is "
"utility-reported, so federal public-domain status cannot be assumed. Confirm reuse terms "
"with ODIN (odin.ornl.gov/pages/contact.html) before relying on it commercially."),
terms_url=ODIN_OPENAPI,
attribution="Outage data: ODIN, Oak Ridge National Laboratory (utility-reported)",
attribution_url="https://odin.ornl.gov",
disclaimer=("County-level counts of meters reported out by utilities that participate in ODIN. "
"A utility that does not report to ODIN shows no outages here, even during one. "
"Restoration times are the utility's estimate, when given."),
issue="server-26#184",
),
granularity="county (FIPS)",
implemented=True,
notes=("Public API, verified first-hand 2026-10-05: /odi?format=JSON (counts, start, ETR, utility EIA id), "
"/odi/map?scale=COUNTY (county name, state, county polygon -> bbox and label point) and /odi/status "
"(utility last-received time). Counts are meters, used as customers. Con Edison / O&R are on ODIN's "
"participant list but were NOT reporting on 2026-10-05, so NYC/Westchester show nothing from ODIN "
"until they do. Schema canary: rows upstream but none parseable is an error, never an empty layer."),
homepage="https://odin.ornl.gov",
)
def __init__(self) -> None:
self.reporting_utilities: Optional[int] = None
self.utilities_with_error: Optional[int] = None
async def fetch(self) -> List[OutageArea]:
h = {"User-Agent": USER_AGENT}
odi, county_map, status = await asyncio.gather(
get_json(f"{ODIN_BASE}/odi", params={"format": "JSON"}, headers=h),
get_json(f"{ODIN_BASE}/odi/map", params={"scale": "COUNTY"}, headers=h),
get_json(f"{ODIN_BASE}/odi/status", headers=h),
)
areas, info = parse_odin(odi, county_map, status)
self.reporting_utilities = info["reporting_utilities"]
self.utilities_with_error = info["utilities_with_error"]
return areas
def coverage_note(self) -> Optional[str]:
n = self.reporting_utilities
base = "Only utilities that report to ODIN appear; one outside ODIN shows no outages here even during one."
if n is None:
return base
err = f", {self.utilities_with_error} with a feed error" if self.utilities_with_error else ""
return f"{base} {n} utilities reporting nationwide{err}."
def _utility(names: object) -> Tuple[Optional[str], Optional[str]]:
name = eia = None
for n in names if isinstance(names, list) else []:
if not isinstance(n, dict):
continue
if n.get("nameType") == "UtilityName" and isinstance(n.get("name"), str):
name = n["name"]
elif n.get("nameType") == "UtilityId" and n.get("name") is not None:
eia = str(n["name"])
return name, eia
def parse_odin(odi: object, county_map: object, status: object,
now: Optional[datetime] = None) -> Tuple[List[OutageArea], dict]:
"""The three public ODIN responses -> one normalised record per county.
Events whose estimated restoration is more than ``STALE_AFTER_ETR`` before
``now`` are counted separately (``stale_customers_out``), not as live.
Raises ValueError when a payload has the wrong top-level shape or when rows
came back but none could be read -- an upstream schema change must surface
as a feed error, not as an empty map."""
if not isinstance(odi, dict) or not isinstance(odi.get("outage"), list):
raise ValueError("ODIN /odi: expected {'outage': [...]} -- upstream schema change?")
if not isinstance(county_map, list):
raise ValueError("ODIN /odi/map: expected a list -- upstream schema change?")
if not isinstance(status, list):
raise ValueError("ODIN /odi/status: expected a list -- upstream schema change?")
# Utility freshness, by EIA id.
received: Dict[str, str] = {}
errors = 0
for u in status:
if not isinstance(u, dict):
continue
errors += 1 if u.get("hasError") is True else 0
eia, d = u.get("eiaId"), parse_iso(u.get("receivedDate"))
if eia is not None and d is not None:
received[str(eia)] = iso(d)
require_parsed(len(status), list(received), "ODIN /odi/status")
# County geography.
geo: Dict[str, dict] = {}
for row in county_map:
if not isinstance(row, dict):
continue
fips = str(row.get("id") or "")
if not _FIPS.match(fips):
continue
b = geometry_bbox(row.get("feature"))
geo[fips] = {"county": row.get("countyName") if isinstance(row.get("countyName"), str) else None,
"state": row.get("stateName") if isinstance(row.get("stateName"), str) else None,
"bbox": b}
require_parsed(len(county_map), list(geo), "ODIN /odi/map county")
# Outage events, aggregated per county.
stale_before = (now or datetime.now(timezone.utc)) - STALE_AFTER_ETR
agg: Dict[str, dict] = {}
parsed = stale_events = 0
for o in odi["outage"]:
if not isinstance(o, dict):
continue
fips = str(o.get("communityDescriptor") or "")
meters = finite(o.get("metersAffected"))
if not _FIPS.match(fips) or meters is None or meters < 0:
continue
parsed += 1
area = o.get("outageArea") if isinstance(o.get("outageArea"), dict) else {}
start = parse_iso(o.get("reportedStartTime")) or parse_iso(area.get("earliestReportedTime"))
ert_obj = o.get("estimatedRestorationTime") if isinstance(o.get("estimatedRestorationTime"), dict) else {}
ert = parse_iso(ert_obj.get("ert"))
if ert is not None and ert.year < 2000: # Puget Sound Energy sends 0001-01-01T00:00:00Z for "no ETR"
ert = None
stale = ert is not None and ert < stale_before
stale_events += stale
a = agg.setdefault(fips, {"out": 0, "stale_out": 0, "utilities": {},
"live": {"since": None, "etr": None}, "stale": {"since": None, "etr": None}})
a["stale_out" if stale else "out"] += int(meters)
t = a["stale" if stale else "live"] # since/etr come from live events whenever there are any
if start and (t["since"] is None or start < t["since"]):
t["since"] = start
if ert and (t["etr"] is None or ert > t["etr"]):
t["etr"] = ert
name, eia = _utility(o.get("names"))
key = eia or name or "unknown"
u = a["utilities"].setdefault(key, {"name": name, "eia_id": eia, "customers_out": 0,
"stale_customers_out": 0})
u["stale_customers_out" if stale else "customers_out"] += int(meters)
require_parsed(len(odi["outage"]), [None] * parsed, "ODIN /odi outage")
areas: List[OutageArea] = []
for fips, a in agg.items():
g = geo.get(fips, {})
utilities = sorted(a["utilities"].values(), key=lambda u: (-u["customers_out"], -u["stale_customers_out"]))
stamps = [received[u["eia_id"]] for u in utilities if u["eia_id"] in received]
lat, lon = bbox_centre(g.get("bbox"))
t = a["live"] if a["out"] else a["stale"]
areas.append(make_area(
"odin", fips, county=g.get("county"), state=g.get("state"), customers_out=a["out"],
stale_customers_out=a["stale_out"], customers_served=None, etr=iso(t["etr"]), since=iso(t["since"]),
updated_at=max(stamps) if stamps else None, lat=lat, lon=lon, bbox=g.get("bbox"),
utilities=utilities,
))
areas.sort(key=lambda r: (-r["customers_out"], -r["stale_customers_out"]))
return areas, {"reporting_utilities": len(status), "utilities_with_error": errors, "stale_events": stale_events}
# ── stubs: catalogued, never enable-able ─────────────────────────────────────
_STORM_CENTER_TERMS = ("The utility's public outage map is backed by undocumented JSON (vendor storm-center "
"endpoints) with no published licence for automated reuse; the endpoints can change "
"without notice. Panel ruling (server-26#184): do not ship scraped utility data "
"commercially. A direct data-sharing agreement is the licensed path (also a sales lead).")
def _stub(id: str, name: str, region: str, coverage, status: Licence, label: str, terms: str,
terms_url: Optional[str], granularity: str, notes: str, homepage: Optional[str]) -> StubProvider:
return StubProvider(OutageProviderMeta(
id=id, name=name, region=region, coverage=coverage,
licence=OutageLicenceInfo(status=status, label=label, terms=terms, terms_url=terms_url,
issue="server-26#184"),
granularity=granularity, implemented=False, notes=notes, homepage=homepage,
))
PROVIDERS: List[OutageProvider] = [
OdinProvider(),
_stub("poweroutage_us", "PowerOutage.us (FE Bluefire LLC)", "United States", US_BBOX, Licence.PAID,
"Paid API -- licence is personal/non-commercial (news media excepted); commercial use needs an agreement DRB does not hold",
("API & Content Terms: the licence is for \"personal and noncommercial use (except for News Media "
"Organizations)\"; commercial use needs a separate agreement; pricing by contact. Quoted from a "
"search summary -- the terms page returned 403 to a direct fetch, so re-read it first-hand before "
"any acknowledgement."),
"https://poweroutage.us/legal/apitermsofuse", "county / utility",
"No adapter: needs a commercial contract and its API documentation.", "https://poweroutage.us"),
_stub("coned_oru_stormcenter", "Con Edison / Orange & Rockland outage map", "NYC, Westchester, Orange & Rockland",
NY_BBOX, Licence.AGREEMENT_REQUIRED,
"Requires a data-sharing agreement -- undocumented storm-center JSON, no reuse licence",
_STORM_CENTER_TERMS, "https://www.coned.com/en/services-and-outages/outage-map-faq", "point / polygon + ETR",
("No adapter by design. Con Edison and O&R are on ODIN's participant list but were not reporting to "
"ODIN on 2026-10-05; if they start, the ODIN adapter covers them at county level."),
"https://outagemap.coned.com"),
_stub("nyseg_rge_stormcenter", "NYSEG / RG&E outage map (Avangrid)", "Upstate NY", NY_BBOX,
Licence.AGREEMENT_REQUIRED,
"Requires a data-sharing agreement -- undocumented storm-center JSON, no reuse licence",
_STORM_CENTER_TERMS, None, "point / polygon + ETR",
("No adapter by design. Avangrid (NYSEG/RG&E) reports to ODIN, so county-level data arrives through "
"the ODIN adapter."),
"https://www.nyseg.com"),
_stub("central_hudson_stormcenter", "Central Hudson outage map", "Hudson Valley, NY", NY_BBOX,
Licence.AGREEMENT_REQUIRED,
"Requires a data-sharing agreement -- undocumented storm-center JSON, no reuse licence",
_STORM_CENTER_TERMS, None, "point / polygon + ETR",
("No adapter by design. Central Hudson reports to ODIN, so county-level data arrives through the "
"ODIN adapter."),
"https://www.cenhud.com"),
_stub("eagle_i_live", "EAGLE-I live feed (ORNL / DOE)", "United States", US_BBOX, Licence.AGREEMENT_REQUIRED,
"Live feed is a DOE / government-partner system -- DRB has no access agreement",
("EAGLE-I's live 15-minute county outage feed is restricted to DOE and government partners. The public "
"ORNL Constellation dataset is historic only (no terms stated on the dataset) and is no use for a "
"live map."),
"https://doi.ccs.ornl.gov/dataset/d26451af-7ea4-577c-8285-78b72cd8a2dc", "county",
"No adapter: needs a DOE partner agreement.", "https://eagle-i.doe.gov"),
]
@@ -0,0 +1,216 @@
"""Outage provider registry: settings, licence gate, and bbox queries.
Mirrors ``app.internal.cameras.registry`` rule for rule. Settings live in
Firestore ``config/outage_providers``::
{"providers": {"<id>": {"enabled": bool, "updated_at": iso, "updated_by": {...},
"acknowledgement": {"by_uid", "by_email", "at", "licence_status", "warning"} | None}}}
Rules (owner decision #247 on server-26#184):
* every provider defaults OFF -- a missing doc/entry means off, and an
unreadable settings doc means everything is off (fail closed);
* a stub (no adapter) or a PROHIBITED source can never be enabled;
* a GATED licence (agreement required / non-commercial / unverified / paid)
can only be enabled with an explicit acknowledgement, recorded with who and
when in the settings doc AND audit_log. The audit write is a precondition
for a gated enable: no record, no enable;
* an acknowledgement is bound to the licence status it was given for. If a
provider's catalogued status changes in code, the old acknowledgement no
longer counts and the provider stops serving until re-acknowledged.
"""
import asyncio
import time
from datetime import datetime, timezone
from typing import Dict, List, Optional, Tuple
from app.internal import firestore as fstore
from app.internal.logger import logger
from app.internal.outages import providers as sources
from app.internal.outages.base import BBox, Feed, Licence, OutageArea, OutageProvider, area_in_bbox, bbox_intersects
COLLECTION, DOC_ID = "config", "outage_providers"
_TTL_S = 30.0
MAX_AREAS = 3000 # per response; ODIN's whole country is ~100-300 counties on a normal day
PROVIDERS: Dict[str, OutageProvider] = {p.meta.id: p for p in sources.PROVIDERS}
FEEDS: Dict[str, Feed] = {pid: Feed(p) for pid, p in PROVIDERS.items() if p.meta.implemented}
NO_SOURCE_MESSAGE = "No licensed outage source covers this area."
_cache: Dict[str, dict] = {}
_cache_ts = 0.0
class ProviderError(Exception):
"""A refused settings change. ``code`` is the HTTP status the router returns."""
def __init__(self, code: int, message: str, **extra):
super().__init__(message)
self.code, self.message, self.extra = code, message, extra
def invalidate_cache() -> None:
global _cache_ts
_cache_ts = 0.0
async def get_settings() -> Dict[str, dict]:
"""Per-provider settings. Unreadable Firestore means everything is OFF (fail closed)."""
global _cache, _cache_ts
if _cache_ts and time.monotonic() - _cache_ts < _TTL_S:
return _cache
try:
doc = await fstore.doc_get(COLLECTION, DOC_ID) or {}
provs = doc.get("providers") if isinstance(doc.get("providers"), dict) else {}
_cache = {k: v for k, v in provs.items() if isinstance(v, dict)}
except Exception as e:
logger.warning(f"outage providers: settings unreadable ({e}); treating every provider as OFF")
_cache = {}
_cache_ts = time.monotonic()
return _cache
def effective(provider: OutageProvider, entry: Optional[dict]) -> Tuple[bool, Optional[str]]:
"""(serving?, reason-if-not) for a provider given its settings entry."""
meta = provider.meta
if not meta.implemented:
return False, "adapter is a stub"
if meta.licence.status == Licence.PROHIBITED:
return False, "source terms forbid this use"
if not entry or not entry.get("enabled"):
return False, "disabled (default)"
if meta.licence.gated:
ack = entry.get("acknowledgement") or {}
if ack.get("licence_status") != meta.licence.status.value:
return False, "licence acknowledgement missing or out of date"
return True, None
async def is_serving(provider_id: str) -> bool:
p = PROVIDERS.get(provider_id)
if p is None:
return False
return effective(p, (await get_settings()).get(provider_id))[0]
async def set_enabled(provider_id: str, enabled: bool, actor: Tuple[str, str],
acknowledge_licence: bool = False) -> dict:
p = PROVIDERS.get(provider_id)
if p is None:
raise ProviderError(404, f"unknown outage provider '{provider_id}'")
meta, lic = p.meta, p.meta.licence
uid, email = actor
now = datetime.now(timezone.utc).isoformat()
entry: dict = {"enabled": bool(enabled), "updated_at": now, "updated_by": {"uid": uid, "email": email}}
if enabled:
if not meta.implemented:
raise ProviderError(409, f"{meta.name}: no working adapter -- {meta.notes}")
if lic.status == Licence.PROHIBITED:
raise ProviderError(409, lic.warning() or "prohibited")
if lic.gated:
if not acknowledge_licence:
raise ProviderError(409, f"{meta.name}: licence acknowledgement required",
requires_acknowledgement=True, warning=lic.warning(),
licence=lic.as_dict())
entry["acknowledgement"] = {"by_uid": uid, "by_email": email, "at": now,
"licence_status": lic.status.value, "warning": lic.warning()}
# Precondition, not a side effect: a gated enable with no audit
# record must not happen. A failure here aborts before the write.
try:
await _audit(actor, "outage_provider.enable_acknowledged", provider_id,
{"licence_status": lic.status.value, "licence_label": lic.label,
"warning": lic.warning(), "acknowledged_at": now})
except Exception as e:
logger.error(f"outage providers: audit write failed, refusing gated enable of {provider_id} ({e})")
raise ProviderError(503, "audit log unavailable -- a licence-gated provider cannot be enabled "
"without an audit record; try again")
else:
entry["acknowledgement"] = None # re-enabling a gated source needs a fresh acknowledgement
before = (await get_settings()).get(provider_id) or {}
await fstore.doc_set(COLLECTION, DOC_ID, {"providers": {provider_id: entry}}, merge=True)
invalidate_cache()
if not (enabled and lic.gated):
try:
await _audit(actor, "outage_provider.enable" if enabled else "outage_provider.disable", provider_id,
{"from": bool(before.get("enabled")), "to": bool(enabled),
"licence_status": lic.status.value})
except Exception as e: # the toggle already happened; never 500 a completed write
logger.error(f"outage providers: audit write failed for {provider_id} ({e})")
logger.info(f"outage provider {provider_id} -> enabled={enabled} by {email or uid}")
return await describe(provider_id)
async def _audit(actor: Tuple[str, str], action: str, provider_id: str, details: dict) -> None:
from app.internal import audit
await audit.write_audit(actor_uid=actor[0], actor_email=actor[1], action=action,
details={"provider_id": provider_id, **details})
async def describe(provider_id: str) -> dict:
p = PROVIDERS[provider_id]
entry = (await get_settings()).get(provider_id) or {}
serving, reason = effective(p, entry)
m = p.meta
feed = FEEDS.get(provider_id)
return {
"id": m.id, "name": m.name, "region": m.region, "granularity": m.granularity,
"implemented": m.implemented, "notes": m.notes, "homepage": m.homepage,
"licence": m.licence.as_dict(),
"enabled": bool(entry.get("enabled")),
"serving": serving, "not_serving_reason": reason,
"acknowledgement": entry.get("acknowledgement"),
"updated_at": entry.get("updated_at"), "updated_by": entry.get("updated_by"),
"coverage_note": p.coverage_note(),
"health": feed.status() if feed else None,
}
async def list_providers() -> List[dict]:
await get_settings()
return [await describe(pid) for pid in PROVIDERS]
def credit(provider_id: str) -> dict:
lic = PROVIDERS[provider_id].meta.licence
return {"attribution": lic.attribution, "attribution_url": lic.attribution_url, "disclaimer": lic.disclaimer}
async def outages_in_bbox(bbox: BBox) -> dict:
"""Outage areas from every SERVING provider whose coverage meets ``bbox``.
Providers that are off are never fetched.
``status`` says why the list is what it is, so an empty layer is never
ambiguous: ``none_enabled`` (no provider is on), ``no_source`` (none of the
serving providers covers this area), ``error`` (every covering provider
failed and has nothing cached), else ``ok`` -- which may legitimately
carry zero areas when nothing is out."""
conf = await get_settings()
serving = [pid for pid in FEEDS if effective(PROVIDERS[pid], conf.get(pid))[0]]
live = [pid for pid in serving if bbox_intersects(PROVIDERS[pid].meta.coverage, bbox)]
await asyncio.gather(*(FEEDS[pid].get() for pid in live))
areas: List[OutageArea] = []
providers = []
for pid in serving:
feed = FEEDS[pid]
hits = [a for a in feed.items if area_in_bbox(a, bbox)] if pid in live else []
room = max(0, MAX_AREAS - len(areas))
areas.extend(hits[:room])
providers.append({"id": pid, "name": PROVIDERS[pid].meta.name, **credit(pid),
"granularity": PROVIDERS[pid].meta.granularity,
"coverage_note": PROVIDERS[pid].coverage_note(),
"covers_bbox": pid in live,
"fetched_at": feed.fetched_at, "error": feed.error,
"total_in_bbox": len(hits), "truncated": len(hits) > room})
if not serving:
status, message = "none_enabled", "No outage source is enabled. An admin can enable one under Admin -> Outage providers."
elif not live:
status, message = "no_source", NO_SOURCE_MESSAGE
elif all(FEEDS[pid].error and FEEDS[pid].fetched_at is None for pid in live):
status, message = "error", "Every outage source covering this area failed to load; see each provider's error."
else:
status, message = "ok", None
return {"areas": areas, "providers": providers, "serving_count": len(serving),
"status": status, "message": message}
+61
View File
@@ -121,6 +121,67 @@ async def download_audio(gcs_uri: str) -> Optional[bytes]:
return None return None
# ── Ranged reads for /media playback (server-26#233) ────────────────────────
# A 600 s FLAC call is ~13 MB. download_audio() above pulls the whole object
# into memory, so /media used to make the browser wait for all of it before
# the first sample played. These two read only what a request asks for: the
# object size (one metadata GET) and then an inclusive byte span, which the
# router streams out in chunks. One cached client, because the router issues
# several span reads per playback and a fresh Client() per read re-resolves
# credentials every time.
_gcs_client_cache = None
def _gcs_client():
global _gcs_client_cache
if _gcs_client_cache is None:
from google.cloud import storage
if settings.gcp_credentials_path:
_gcs_client_cache = storage.Client.from_service_account_json(settings.gcp_credentials_path)
else:
_gcs_client_cache = storage.Client()
return _gcs_client_cache
async def audio_object_size(gcs_uri: str) -> Optional[int]:
"""Size in bytes of a stored audio object, or None if it is missing/unreadable."""
bucket_name, blob_path = split_gcs_uri(gcs_uri)
if not bucket_name:
return None
def _size() -> Optional[int]:
blob = _gcs_client().bucket(bucket_name).blob(blob_path)
blob.reload()
return blob.size
try:
size = await asyncio.to_thread(_size)
except Exception as e:
logger.warning(f"GCS metadata read failed for {gcs_uri}: {e}")
return None
return int(size) if size is not None else None
async def download_audio_range(gcs_uri: str, start: int, end: int) -> Optional[bytes]:
"""Bytes ``start``..``end`` INCLUSIVE of a stored object (GCS's own convention).
Reads only that span from GCS — never the whole object.
"""
bucket_name, blob_path = split_gcs_uri(gcs_uri)
if not bucket_name:
return None
def _read() -> bytes:
blob = _gcs_client().bucket(bucket_name).blob(blob_path)
return blob.download_as_bytes(start=start, end=end)
try:
return await asyncio.to_thread(_read)
except Exception as e:
logger.warning(f"GCS ranged read failed for {gcs_uri} [{start}-{end}]: {e}")
return None
def split_gcs_uri(gcs_uri: str) -> Tuple[Optional[str], Optional[str]]: def split_gcs_uri(gcs_uri: str) -> Tuple[Optional[str], Optional[str]]:
"""``gs://bucket/path/to.mp3`` → ``("bucket", "path/to.mp3")``.""" """``gs://bucket/path/to.mp3`` → ``("bucket", "path/to.mp3")``."""
if not gcs_uri or not gcs_uri.startswith("gs://"): if not gcs_uri or not gcs_uri.startswith("gs://"):
+5
View File
@@ -19,12 +19,14 @@ from app.internal.auth import (
) )
from app.routers import nodes, systems, calls, upload, tokens, incidents, alerts, admin, trips, places, links, users from app.routers import nodes, systems, calls, upload, tokens, incidents, alerts, admin, trips, places, links, users
from app.routers import enrollment, media, org, waitlist, telemetry, replay, traffic, cameras from app.routers import enrollment, media, org, waitlist, telemetry, replay, traffic, cameras
from app.routers import outages
from app.routers import discord as discord_router from app.routers import discord as discord_router
from app.routers import discord_link as discord_link_router from app.routers import discord_link as discord_link_router
from app.internal import dynsec from app.internal import dynsec
from app.internal import firestore as fstore from app.internal import firestore as fstore
from app.routers import discord_web from app.routers import discord_web
from app.routers import discord_checks from app.routers import discord_checks
from app.routers import discord_oauth
async def _clear_legacy_token_flags(): async def _clear_legacy_token_flags():
@@ -147,6 +149,8 @@ app.include_router(places.router, dependencies=[Depends(require_service_or_fi
app.include_router(traffic.router, dependencies=[Depends(require_service_or_firebase_token)]) app.include_router(traffic.router, dependencies=[Depends(require_service_or_firebase_token)])
app.include_router(cameras.router, dependencies=[Depends(require_service_or_firebase_token)]) app.include_router(cameras.router, dependencies=[Depends(require_service_or_firebase_token)])
app.include_router(cameras.admin_router) # auth: admin only, per endpoint (needs the principal for audit) app.include_router(cameras.admin_router) # auth: admin only, per endpoint (needs the principal for audit)
app.include_router(outages.router, dependencies=[Depends(require_service_or_firebase_token)])
app.include_router(outages.admin_router) # auth: admin only, per endpoint (needs the principal for audit)
app.include_router(upload.router) # auth is per-node, handled inline app.include_router(upload.router) # auth is per-node, handled inline
app.include_router(admin.router) # auth is per-endpoint (read: firebase, write: admin) app.include_router(admin.router) # auth is per-endpoint (read: firebase, write: admin)
app.include_router(replay.router) # auth: admin only (every route spends or reads a replay run) app.include_router(replay.router) # auth: admin only (every route spends or reads a replay run)
@@ -166,6 +170,7 @@ app.include_router(discord_web.router)
app.include_router(discord_router.router) app.include_router(discord_router.router)
app.include_router(discord_link_router.router) # auth per-endpoint (org owner / bot service key) — server-26#213 app.include_router(discord_link_router.router) # auth per-endpoint (org owner / bot service key) — server-26#213
app.include_router(discord_checks.router) # auth per-endpoint (org member / service key or admin) — server-26#225 app.include_router(discord_checks.router) # auth per-endpoint (org member / service key or admin) — server-26#225
app.include_router(discord_oauth.router) # auth per-endpoint (org owner / signed-in starter); 404 while OAuth is off — #225 S6
# NOTE: there used to be an app.routers.mqtt_auth router here (an HTTP # NOTE: there used to be an app.routers.mqtt_auth router here (an HTTP
# backend for the mosquitto-go-auth plugin). That plugin's upstream project # backend for the mosquitto-go-auth plugin). That plugin's upstream project
# is archived (no CVE patches) and was rejected for an internet-facing # is archived (no CVE patches) and was rejected for an internet-facing
+14 -18
View File
@@ -17,7 +17,6 @@ Auth:
from __future__ import annotations from __future__ import annotations
import secrets import secrets
from datetime import datetime, timezone
from typing import Optional from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Path, Security from fastapi import APIRouter, Depends, HTTPException, Path, Security
@@ -26,6 +25,7 @@ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from app.config import settings from app.config import settings
from app.internal import discord_guild_link as glink
from app.internal import discord_leases as leases from app.internal import discord_leases as leases
from app.internal import discord_rest from app.internal import discord_rest
from app.internal import discord_streams as streams from app.internal import discord_streams as streams
@@ -138,6 +138,7 @@ async def start_stream(body: StreamStart, _: dict = Depends(require_service_key_
mode=body.mode, channel_name=body.channel_name, guild_name=body.guild_name, mode=body.mode, channel_name=body.channel_name, guild_name=body.guild_name,
created_by=body.created_by, preferred_token_id=body.preferred_token_id, created_by=body.created_by, preferred_token_id=body.preferred_token_id,
max_streams=settings.discord_guild_max_streams, stream_id=body.stream_id, max_streams=settings.discord_guild_max_streams, stream_id=body.stream_id,
interactive=True, # /join, the admin page: re-read a stale "denied" (server-26#251)
) )
except streams.StreamError as e: except streams.StreamError as e:
return _error(e) return _error(e)
@@ -181,23 +182,18 @@ async def link_guild(guild_id: str, body: GuildLink, decoded: dict = Depends(req
org = await fstore.doc_get("organizations", body.org_id) org = await fstore.doc_get("organizations", body.org_id)
if not org: if not org:
raise HTTPException(404, "Organization not found.") raise HTTPException(404, "Organization not found.")
previous_org = await streams.resolve_guild_org(guild_id) # Support override: may move a server between orgs. Re-linking a server
doc = { # that was unlinked or removed brings it back. Live channels placed on the
"guild_id": guild_id, "org_id": body.org_id, # previous org's nodes are stopped (glink._settle) — left running, the
"linked_by": decoded.get("uid"), "linked_at": datetime.now(timezone.utc).isoformat(), # reconciler would keep re-placing them there.
# Re-linking a server that was unlinked or removed brings it back. out = await glink.link_and_settle(body.org_id, guild_id, body.guild_name, decoded.get("uid") or "admin",
"unlinked_at": None, "unlinked_by": None, "removed_at": None, "removed_by": None, "admin", allow_move=True, stop_reason=glink.ADMIN_RELINK_STOP_REASON)
} uid, email = describe_actor(decoded)
if body.guild_name and body.guild_name.strip(): await write_audit(uid, email, "discord.guild.link", details={
doc["guild_name"] = body.guild_name.strip()[:100] "guild_id": guild_id, "org_id": body.org_id, "previous_org_id": out["previous_org_id"],
await fstore.doc_set(streams.GUILDS, guild_id, doc) "already_linked": out["already_linked"], "streams_stopped": len(out["streams_stopped"]), "via": "admin",
# Live channels in this server were placed on the previous org's nodes; })
# left running, the reconciler would keep re-placing them there, so one return {"ok": True, "guild_id": guild_id, "org_id": body.org_id, "stopped": out["streams_stopped"]}
# org's equipment would keep serving a server that now belongs to another.
stopped: list[str] = []
if previous_org and previous_org != body.org_id:
stopped = await streams.stop_guild(guild_id, "server linked to another organization")
return {"ok": True, "guild_id": guild_id, "org_id": body.org_id, "stopped": stopped}
@router.delete("/streams/{stream_id}") @router.delete("/streams/{stream_id}")
+5 -7
View File
@@ -101,23 +101,21 @@ async def redeem_link_code(guild_id: str, body: RedeemBody, principal: dict = De
redeem_guild_limiter.check(f"guild:{guild_id}") redeem_guild_limiter.check(f"guild:{guild_id}")
redeem_user_limiter.check(f"user:{body.discord_user_id}") redeem_user_limiter.check(f"user:{body.discord_user_id}")
actor = f"discord:{body.discord_user_id}" actor = f"discord:{body.discord_user_id}"
before = await streams.resolve_guild_org(guild_id)
try: try:
result = await glink.redeem_code(body.code, guild_id, body.guild_name, body.discord_user_id) # Links, then stops anything the server had playing for the org it
# acted for before (the DISCORD_UNLINKED_GUILD_ORG_ID fallback).
result = await glink.redeem_and_settle(body.code, guild_id, body.guild_name, body.discord_user_id)
except glink.LinkError as e: except glink.LinkError as e:
await write_audit(actor, "", "discord.guild.link_refused", await write_audit(actor, "", "discord.guild.link_refused",
details={"guild_id": guild_id, "reason": e.code}) details={"guild_id": guild_id, "reason": e.code})
return JSONResponse(status_code=e.status, content={"detail": e.message, "code": e.code}) return JSONResponse(status_code=e.status, content={"detail": e.message, "code": e.code})
org_id = result["org_id"] org_id = result["org_id"]
stopped: list = [] stopped = result["streams_stopped"]
if before and before != org_id:
# The server was acting for DISCORD_UNLINKED_GUILD_ORG_ID; anything it
# had playing belongs to that org, not the one it just linked to.
stopped = await streams.stop_guild(guild_id, "relinked")
await write_audit(actor, "", "discord.guild.link", details={ await write_audit(actor, "", "discord.guild.link", details={
"guild_id": guild_id, "org_id": org_id, "already_linked": result["already_linked"], "guild_id": guild_id, "org_id": org_id, "already_linked": result["already_linked"],
"code_created_by_uid": result.get("created_by_uid"), "streams_stopped": len(stopped), "code_created_by_uid": result.get("created_by_uid"), "streams_stopped": len(stopped),
"via": "code",
}) })
logger.info(f"Discord guild {guild_id} linked to org={org_id!r} by {actor}") logger.info(f"Discord guild {guild_id} linked to org={org_id!r} by {actor}")
org = await fstore.doc_get("organizations", org_id) org = await fstore.doc_get("organizations", org_id)
+289
View File
@@ -0,0 +1,289 @@
"""
"Connect a Discord server" through a radio bot's own OAuth2 invite
(server-26#225 S6, owner decision Q1 = per-bot OAuth).
GET /discord/oauth/options org owner / platform admin — which bot Connect
would invite (no state minted). 404 = off.
POST /discord/oauth/start org owner / platform admin — mint a state,
return Discord's authorize URL
POST /discord/oauth/complete signed-in user who started it — exchange the
code, link the server, check permissions
Platform admins may pass ?org_id= to act for another org (resolve_org_scope).
Every route is a 404 while ``discord_oauth_redirect_uri`` is empty or no
eligible bot has an OAuth secret (PUT /tokens/{id}/oauth).
Which bot (``_pick_bot``): an explicit ``token_id`` the org may use; else the
radio bot preferred for the node's system (``node_id``); else the org's own
bring-your-own bot; else the pool bot with the fewest servers that is under
``discord_pool_bot_guild_cap`` (owner decision Q3). Only bots with an OAuth
secret set count. A bot the caller's org can't use is a 404, never a 403.
The org a server links to is the one the STATE names, re-checked against a
fresh read of the caller's membership; the server is the one Discord's code
exchange names. The redirect's ``guild_id`` query parameter is ignored.
This reverses ``discord_web``'s old "C2 never calls Discord on the browser's
behalf" on purpose (plan finding 5): the call is made with the bot
application's credentials, after the org-scope checks and rate limits here.
"""
from __future__ import annotations
import asyncio
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field
from app.config import settings
from app.internal import discord_guild_link as glink
from app.internal import discord_leases as leases
from app.internal import discord_oauth as oauth
from app.internal import discord_rest
from app.internal import discord_streams as streams
from app.internal import firestore as fstore
from app.internal.audit import write_audit
from app.internal.auth import (
_RateLimiter, describe_actor, get_role, require_firebase_token, require_org_owner_token, resolve_org_scope,
)
from app.internal.logger import logger
from app.routers.discord_checks import voice_check
router = APIRouter(prefix="/discord/oauth", tags=["discord"])
start_limiter = _RateLimiter(max_calls=5, window_seconds=600)
complete_limiter = _RateLimiter(max_calls=10, window_seconds=600)
_ID = r"^[A-Za-z0-9_-]{1,64}$"
_OFF = "Connecting a Discord server through DRB isn't available."
def _require_enabled() -> None:
if not oauth.enabled():
raise HTTPException(404, _OFF)
def _err(status: int, code: str, message: str, **extra) -> JSONResponse:
return JSONResponse(status_code=status, content={"detail": message, "code": code, **extra})
def _server_count(tok: dict) -> Optional[int]:
members = tok.get("guild_ids")
return len(members) if isinstance(members, list) else None
def _usable(tok: dict, org_id: str, oauth_ids: set) -> bool:
return (tok.get("token_id") in oauth_ids
and tok.get("valid") is not False
and bool(tok.get("application_id"))
and tok.get("org_id") in (None, org_id)
and leases.invitable(tok))
async def _pick_bot(org_id: str, *, node_id: Optional[str] = None,
token_id: Optional[str] = None) -> tuple[dict, str]:
"""(token doc, why) — why is ``chosen`` | ``preferred`` | ``org_bot`` |
``pool``. Raises HTTPException 404 when nothing fits."""
tokens = {t["token_id"]: t for t in await fstore.collection_list(leases.TOKENS) if t.get("token_id")}
oauth_ids = await oauth.enabled_token_ids()
if token_id:
tok = tokens.get(token_id)
if not tok or tok.get("org_id") not in (None, org_id):
raise HTTPException(404, "Radio bot not found.")
if not _usable(tok, org_id, oauth_ids):
raise HTTPException(404, f"{tok.get('name') or 'That radio bot'} can't be used to connect a server "
"right now.")
return tok, "chosen"
if node_id:
node = await fstore.doc_get("nodes", node_id)
if not node or node.get("org_id") != org_id:
raise HTTPException(404, "Node not found.")
system_id = streams._effective_system(node)
system = await fstore.doc_get("systems", system_id) if system_id else None
pref = tokens.get((system or {}).get("preferred_token_id") or "")
if pref and _usable(pref, org_id, oauth_ids):
return pref, "preferred"
own = sorted((t for t in tokens.values() if t.get("org_id") == org_id and _usable(t, org_id, oauth_ids)),
key=lambda t: (str(t.get("name") or "").lower(), t["token_id"]))
if own:
return own[0], "org_bot"
cap = settings.discord_pool_bot_guild_cap
pool = sorted((t for t in tokens.values() if t.get("org_id") is None and _usable(t, org_id, oauth_ids)),
key=lambda t: (_server_count(t) if _server_count(t) is not None else cap,
str(t.get("name") or "").lower(), t["token_id"]))
if pool:
return pool[0], "pool"
raise HTTPException(404, _OFF)
def _bot_summary(tok: dict, why: str) -> dict:
return {"name": tok.get("name") or "DRB radio bot", "reason": why,
"own_bot": tok.get("org_id") is not None}
# ---------------------------------------------------------------------------
# Routes
# ---------------------------------------------------------------------------
@router.get("/options")
async def oauth_options(node_id: Optional[str] = Query(None, pattern=_ID),
token_id: Optional[str] = Query(None, pattern=_ID),
org_id: Optional[str] = None,
decoded: dict = Depends(require_org_owner_token)):
"""Whether Connect is offered here, and which bot it would invite."""
_require_enabled()
scope = resolve_org_scope(decoded, org_id)
tok, why = await _pick_bot(scope, node_id=node_id, token_id=token_id)
return {"available": True, "bot": _bot_summary(tok, why)}
class StartBody(BaseModel):
node_id: Optional[str] = Field(None, pattern=_ID)
token_id: Optional[str] = Field(None, pattern=_ID)
return_to: Optional[str] = None
@router.post("/start")
async def oauth_start(body: StartBody, org_id: Optional[str] = None,
decoded: dict = Depends(require_org_owner_token)):
_require_enabled()
scope = resolve_org_scope(decoded, org_id)
uid, email = describe_actor(decoded)
start_limiter.check(f"{uid}:discord_oauth_start")
tok, why = await _pick_bot(scope, node_id=body.node_id, token_id=body.token_id)
return_to = oauth.safe_return_to(body.return_to)
nonce, expires_at = await oauth.create_state(org_id=scope, uid=uid, token_id=tok["token_id"],
node_id=body.node_id, return_to=return_to)
await write_audit(uid, email, "discord.oauth.start", details={
"org_id": scope, "token_id": tok["token_id"], "reason": why, "node_id": body.node_id})
return {"authorize_url": oauth.authorize_url(str(tok["application_id"]), nonce),
"expires_at": expires_at.isoformat(), "bot": _bot_summary(tok, why)}
class CompleteBody(BaseModel):
# Lengths checked in the handler: a pydantic 422 would echo the code back.
code: str
state: str
async def _still_owner(decoded: dict, uid: str, org_id: str) -> bool:
"""Fresh read: the claims in an ID token can be up to an hour old."""
if get_role(decoded) == "admin":
return True
member = await fstore.doc_get("org_members", uid)
return bool(member and member.get("org_id") == org_id and member.get("org_role") == "owner")
async def _channel_summary(guild_id: str, org_id: str, node_id: Optional[str]) -> Optional[dict]:
"""Permissions right after the link, within discord_oauth_link_check_seconds.
None = not answered in time (the picker's badges will say)."""
link = await fstore.doc_get(streams.GUILDS, guild_id) or {}
try:
out = await asyncio.wait_for(
voice_check(guild_id=guild_id, org_id=org_id, node_id=node_id,
channels=link.get("voice_channels") or [], channel_id=None, refresh=True,
with_token_ids=False),
timeout=settings.discord_oauth_link_check_seconds)
except Exception as e: # timeout or anything else: the link already stands
logger.info(f"Discord OAuth link {guild_id}: permission check skipped ({type(e).__name__})")
return None
channels = [{"channel_id": c["channel_id"], "name": c.get("name"), "state": c["state"],
"fix": next((b.get("fix") for b in c.get("bots") or [] if b.get("fix")), None)}
for c in out.get("channels") or []]
return {
"enabled": out.get("enabled"),
"channels_ready": sum(1 for c in channels if c["state"] == "ready"),
"channels_blocked": sum(1 for c in channels if c["state"] == "needs_fix"),
"channels": channels,
}
@router.post("/complete")
async def oauth_complete(body: CompleteBody, decoded: dict = Depends(require_firebase_token)):
_require_enabled()
uid, email = describe_actor(decoded)
complete_limiter.check(f"{uid}:discord_oauth_complete")
if not (0 < len(body.code) <= 200):
return _err(400, "code_rejected", "Discord didn't accept that sign-in. Start again from DRB.")
# 1. Consume the state (single use; must be this caller's).
try:
state = await oauth.consume_state(body.state, uid)
except oauth.StateError as e:
await write_audit(uid, email, "discord.oauth.refused", details={"reason": e.code})
return _err(e.status, e.code, e.message)
org_id, token_id = state["org_id"], state["token_id"]
return_to = state.get("return_to")
# 2. Still the owner of that org (fresh read, not the token's claims).
if not await _still_owner(decoded, uid, org_id):
await write_audit(uid, email, "discord.oauth.refused", details={"org_id": org_id, "reason": "not_owner"})
return _err(403, "not_owner", "Only the organization owner can connect a Discord server.")
# 3. Exchange the code with that bot application's own secret.
tok = await fstore.doc_get(leases.TOKENS, token_id)
secret = await oauth.load_secret(token_id)
if (not tok or not tok.get("application_id") or not secret
or tok.get("org_id") not in (None, org_id) or tok.get("valid") is False):
return _err(409, "bot_unavailable", "That radio bot can no longer connect servers. Start again from DRB.")
client_id = str(tok["application_id"])
r = await oauth.exchange_code(client_id, secret, body.code)
if not r.ok or not isinstance(r.data, dict):
await write_audit(uid, email, "discord.oauth.refused", details={
"org_id": org_id, "token_id": token_id, "reason": "exchange_failed", "status": r.status})
if r.status is not None and 400 <= r.status < 500 and r.status != 429:
return _err(400, "code_rejected", "Discord didn't accept that sign-in. Start again from DRB.")
return _err(502, "discord_unavailable", "Couldn't reach Discord to finish connecting. Start again from DRB.")
await oauth.revoke(client_id, secret, r.data.get("access_token"))
# 4. The server is the one Discord's exchange names — never the query string.
guild = r.data.get("guild") if isinstance(r.data.get("guild"), dict) else None
guild_id = str((guild or {}).get("id") or "")
if not guild_id.isdigit():
await write_audit(uid, email, "discord.oauth.refused", details={
"org_id": org_id, "token_id": token_id, "reason": "no_guild"})
return _err(400, "no_guild", "Discord didn't say which server the bot was added to. Start again from DRB.")
guild_name = guild.get("name") if isinstance(guild.get("name"), str) else None
# 5. Link + settle (a server another org linked is never moved: 409).
try:
result = await glink.link_and_settle(org_id, guild_id, guild_name, uid, "oauth",
extra={"linked_with_token_id": token_id})
except glink.LinkError as e:
await write_audit(uid, email, "discord.guild.link_refused", details={
"guild_id": guild_id, "org_id": org_id, "reason": e.code, "via": "oauth", "token_id": token_id})
return _err(e.status, e.code, e.message, guild_name=guild_name, bot_name=tok.get("name"))
# 6. The bot is in the server now: record it, drop stale cached answers, audit.
members = tok.get("guild_ids")
if isinstance(members, list) and guild_id not in members:
await fstore.doc_update(leases.TOKENS, token_id, {"guild_ids": [*members, guild_id]})
discord_rest.invalidate(guild_id=guild_id)
await write_audit(uid, email, "discord.guild.link", details={
"guild_id": guild_id, "org_id": org_id, "already_linked": result["already_linked"],
"previous_org_id": result["previous_org_id"], "streams_stopped": len(result["streams_stopped"]),
"via": "oauth", "token_id": token_id,
})
logger.info(f"Discord guild {guild_id} linked to org={org_id!r} via OAuth (bot {token_id}) by uid={uid}")
# 7. Which voice channels are ready, in the same request (capped).
check = await _channel_summary(guild_id, org_id, state.get("node_id"))
return {
"ok": True,
"guild_id": guild_id,
"guild_name": guild_name,
"org_id": org_id,
"bot_name": tok.get("name"),
"already_linked": result["already_linked"],
"streams_stopped": len(result["streams_stopped"]),
"node_id": state.get("node_id"),
"return_to": return_to,
"checked": check is not None,
"channels_ready": check["channels_ready"] if check else None,
"channels_blocked": check["channels_blocked"] if check else None,
"channels": check["channels"] if check else [],
}
+206 -16
View File
@@ -6,15 +6,38 @@ The node page used to take raw guild/channel IDs. It now picks from:
* the Discord servers the caller's org owns — ``discord_guilds`` docs that * the Discord servers the caller's org owns — ``discord_guilds`` docs that
resolve to the node's org (an explicit link, or the unlinked-server resolve to the node's org (an explicit link, or the unlinked-server
fallback org, same rule the bot uses: streams.resolve_guild_org), and fallback org, same rule the bot uses: streams.resolve_guild_org), and
* their voice channels, from a directory the DRB server bot pushes * their voice channels, from a directory kept on ``discord_guilds/{id}``.
(``PUT /discord/guilds/{id}/directory``). The bot already holds its
gateway cache of every server it is in; C2 never calls Discord with a Where the directory comes from (server-26#225 S5 — the main bot is optional):
token on the browser's behalf and no token string ever reaches the browser.
* the DRB server bot, when it is in the server: it pushes its gateway view
(``PUT /discord/guilds/{id}/directory``) on start, on changes and every
15 minutes (``directory_source: "main_bot"``);
* otherwise a radio bot that is in the server: C2 reads the channel list
over Discord REST with that bot's token (discord_rest.guild_directory —
the same cached snapshot the permission check reads, so no extra
request) and stores it (``directory_source: "radio_bot"``,
``radio_bot_present``). That happens when the picker is opened and the
stored list is missing (inline, inside the permission-check time budget)
or older than ``discord_directory_refresh_seconds`` (in the background).
So C2 does call Discord when a signed-in user opens the picker — with a
radio bot's token, for a server already linked to the caller's org, through
the rate-limited, cached client. No token string ever reaches the browser.
Which servers an org sees (``_org_guilds``):
* linked to the org: shown unless the server bot said it left AND no
radio bot the org may use is in it (``bot_tokens.guild_ids``);
* acting for the org only through the unlinked-server fallback: shown only
while the server bot is there, exactly as before. A radio bot's
membership never discovers a server, and REST never creates a
``discord_guilds`` doc.
A pick becomes a ``discord_streams`` desired-state doc **pinned to this node** A pick becomes a ``discord_streams`` desired-state doc **pinned to this node**
(streams.start_stream(node_id=...)), exactly what the old raw-ID path did, with (streams.start_stream(node_id=...)), exactly what the old raw-ID path did, with
the checks that path skipped: the server must belong to the node's org, the the checks that path skipped: the server must belong to the node's org, the
channel must be a voice channel the bot reported in that server, and the channel must be a voice channel in that server's directory, and the
per-server and per-node stream caps apply. per-server and per-node stream caps apply.
Auth: Auth:
@@ -29,6 +52,7 @@ Auth:
""" """
from __future__ import annotations from __future__ import annotations
import asyncio
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Optional from typing import Optional
@@ -38,6 +62,7 @@ from pydantic import BaseModel, Field
from app.config import settings from app.config import settings
from app.internal import discord_leases as leases from app.internal import discord_leases as leases
from app.internal import discord_rest
from app.internal import discord_streams as streams from app.internal import discord_streams as streams
from app.internal import firestore as fstore from app.internal import firestore as fstore
from app.internal.audit import write_audit from app.internal.audit import write_audit
@@ -54,6 +79,14 @@ router = APIRouter(prefix="/discord", tags=["discord"])
MAX_CHANNELS = 200 MAX_CHANNELS = 200
_NAME_MAX = 100 _NAME_MAX = 100
MAIN_BOT = "main_bot"
RADIO_BOT = "radio_bot"
# The server bot re-pushes every 15 min. A list it pushed more than three
# pushes ago means it is down (it never said it left): a radio bot's view
# takes over until it is back.
MAIN_BOT_STALE_SECONDS = 45 * 60
# How many member radio bots are asked, one after another, for the list.
DIRECTORY_BOTS = 3
def _now_iso() -> str: def _now_iso() -> str:
@@ -80,16 +113,20 @@ class GuildDirectory(BaseModel):
@router.put("/guilds/{guild_id}/directory") @router.put("/guilds/{guild_id}/directory")
async def put_guild_directory(guild_id: str, body: GuildDirectory, _: dict = Depends(require_service_key)): async def put_guild_directory(guild_id: str, body: GuildDirectory, _: dict = Depends(require_service_key)):
"""The server bot's view of one Discord server: its name and voice channels. """The server bot's view of one Discord server: its name and voice channels.
Merged onto ``discord_guilds/{id}`` — never touches ``org_id`` (the link).""" Merged onto ``discord_guilds/{id}`` — never touches ``org_id`` (the link).
Cached Discord snapshots of the server are dropped (a channel was added,
removed or renamed), so the next permission check reads it fresh."""
if not guild_id.isdigit(): if not guild_id.isdigit():
raise HTTPException(400, "guild_id must be a Discord snowflake.") raise HTTPException(400, "guild_id must be a Discord snowflake.")
data: dict = {"guild_id": guild_id, "bot_present": body.present, "directory_at": _now_iso()} data: dict = {"guild_id": guild_id, "bot_present": body.present, "directory_at": _now_iso()}
if body.present: if body.present:
data["voice_channels"] = [c.model_dump() for c in body.voice_channels] data["voice_channels"] = [c.model_dump() for c in body.voice_channels]
data["directory_source"] = MAIN_BOT
if body.name: if body.name:
# Same field the /drb link flow writes (feat/213-guild-link, PR #218). # Same field the /drb link flow writes (feat/213-guild-link, PR #218).
data["guild_name"] = body.name data["guild_name"] = body.name
await fstore.doc_set(streams.GUILDS, guild_id, data) await fstore.doc_set(streams.GUILDS, guild_id, data)
discord_rest.invalidate(guild_id=guild_id)
return {"ok": True, "guild_id": guild_id, "voice_channels": len(body.voice_channels)} return {"ok": True, "guild_id": guild_id, "voice_channels": len(body.voice_channels)}
@@ -130,24 +167,156 @@ def _require_control(can: bool) -> None:
raise HTTPException(403, "Only the organization owner, an admin, or this node's operator can do that.") raise HTTPException(403, "Only the organization owner, an admin, or this node's operator can do that.")
async def _org_guilds(org_id: str) -> list[dict]: async def _load_tokens() -> dict[str, dict]:
"""``discord_guilds`` docs this org may stream into, bot still present. return {t["token_id"]: t for t in await fstore.collection_list(leases.TOKENS) if t.get("token_id")}
def _member_radio_bots(tokens: dict[str, dict], guild_id: str, org_id: Optional[str]) -> list[str]:
"""Radio bots this org may use (pool, or the org's own) that Discord last
said are in the server, best first. Unknown membership (``guild_ids``
None) does not count: presence has to be positive."""
out = [tid for tid, t in tokens.items()
if t.get("token") and t.get("valid") is not False and t.get("org_id") in (None, org_id)
and isinstance(t.get("guild_ids"), list) and guild_id in t["guild_ids"]]
out.sort(key=lambda tid: leases._candidate_order(tid, tokens[tid], "", org_id, None))
return out
async def _org_guilds(org_id: str, tokens: Optional[dict] = None) -> list[dict]:
"""``discord_guilds`` docs this org may stream into, with a bot there.
Mirrors streams.resolve_guild_org: explicit link, else the unlinked-server Mirrors streams.resolve_guild_org: explicit link, else the unlinked-server
fallback org — except a server an org explicitly unlinked (``unlinked_at``, fallback org — except a server an org explicitly unlinked (``unlinked_at``,
PR #218), which gets no fallback.""" PR #218), which gets no fallback.
A linked server is shown while the server bot is there or a radio bot the
org may use is (server-26#225 S5: the main bot is optional). A server
that acts for the org only through the fallback still needs the server
bot: a radio bot's membership never discovers one."""
fallback = settings.discord_unlinked_guild_org_id or None fallback = settings.discord_unlinked_guild_org_id or None
if tokens is None:
tokens = await _load_tokens()
out = [] out = []
for g in await fstore.collection_list(streams.GUILDS): for g in await fstore.collection_list(streams.GUILDS):
gid = str(g.get("guild_id") or "") gid = str(g.get("guild_id") or "")
if not gid or g.get("bot_present") is False: if not gid:
continue
if g.get("org_id"):
if g["org_id"] != org_id:
continue
if g.get("bot_present") is False and not _member_radio_bots(tokens, gid, org_id):
continue
else:
if g.get("unlinked_at") or not fallback or fallback != org_id or g.get("bot_present") is False:
continue continue
owner = g.get("org_id") or (None if g.get("unlinked_at") else fallback)
if owner and owner == org_id:
out.append(g) out.append(g)
out.sort(key=lambda g: ((_guild_name(g) or "").lower(), g["guild_id"])) out.sort(key=lambda g: ((_guild_name(g) or "").lower(), g["guild_id"]))
return out return out
# ---------------------------------------------------------------------------
# Directory from a radio bot (server-26#225 S5)
# ---------------------------------------------------------------------------
def _age_seconds(ts) -> Optional[float]:
when = streams._parse_ts(ts)
return (datetime.now(timezone.utc) - when).total_seconds() if when else None
def _directory_due(g: dict) -> Optional[str]:
"""None when the stored list is fine; "missing" when there is none;
"stale" when it is old. Only for a server linked to an org — a fallback
server's list is the server bot's alone."""
if not g.get("org_id"):
return None
if g.get("voice_channels") is None:
return "missing"
from_main = g.get("bot_present") is True and (g.get("directory_source") or MAIN_BOT) == MAIN_BOT
limit = MAIN_BOT_STALE_SECONDS if from_main else settings.discord_directory_refresh_seconds
age = _age_seconds(g.get("directory_at"))
return "stale" if age is None or age > limit else None
def _main_bot_list_fresh(g: dict) -> bool:
"""The server bot is there and its own list is current: it wins."""
return (g.get("bot_present") is True and (g.get("directory_source") or MAIN_BOT) == MAIN_BOT
and _directory_due(g) is None)
async def _refresh_directory(g: dict, org_id: str, tokens: dict[str, dict]) -> Optional[dict]:
"""Read one linked server's voice channels from a member radio bot and
store them. Returns the fields written (also merged into ``g``), or None
when nothing was learned. Writes only onto the existing doc, and only
while it is still linked to ``org_id``: never creates a doc, never
touches ``org_id`` or the server bot's ``bot_present``."""
gid = g["guild_id"]
members = _member_radio_bots(tokens, gid, org_id)
if not members:
return None
docs = [{**tokens[t], "token_id": t} for t in members]
res = await discord_rest.guild_directory(docs, gid, max_bots=DIRECTORY_BOTS)
now = _now_iso()
if res.status == discord_rest.OK:
updates: dict = {"voice_channels": res.voice_channels, "directory_at": now,
"directory_source": RADIO_BOT, "radio_bot_present": True, "radio_bot_checked_at": now}
if res.name:
updates["guild_name"] = res.name
elif res.status == discord_rest.NOT_MEMBER:
updates = {"radio_bot_present": False, "radio_bot_checked_at": now}
else:
return None
cur = await fstore.doc_get(streams.GUILDS, gid)
if not cur or cur.get("org_id") != org_id:
return None # unlinked / moved / removed meanwhile
if _main_bot_list_fresh(cur):
return None # the server bot pushed a fresh list meanwhile; it wins
await fstore.doc_update(streams.GUILDS, gid, updates)
g.update(updates)
logger.info(f"Discord directory for {gid} from radio bot {res.token_id or '-'}: "
f"{res.status}, {len(res.voice_channels or [])} voice channel(s)")
return updates
_directory_tasks: dict[str, asyncio.Task] = {}
async def _refresh_quietly(g: dict, org_id: str, tokens: dict[str, dict]) -> None:
try:
await _refresh_directory(g, org_id, tokens)
except Exception as e: # a background refresh must never surface anywhere
logger.warning(f"Discord directory refresh for {g.get('guild_id')} failed: {type(e).__name__}")
def _refresh_in_background(g: dict, org_id: str, tokens: dict[str, dict]) -> None:
gid = g["guild_id"]
running = _directory_tasks.get(gid)
if running is not None and not running.done():
return
task = asyncio.create_task(_refresh_quietly(dict(g), org_id, tokens))
_directory_tasks[gid] = task
task.add_done_callback(lambda t, gid=gid: _directory_tasks.pop(gid, None)
if _directory_tasks.get(gid) is t else None)
async def _fill_directories(guilds: list[dict], org_id: str, tokens: dict[str, dict]) -> None:
"""Missing lists are read now (all servers at once, inside the
permission-check budget — an answer that comes later is cached and
stored by the next look); stale ones are refreshed in the background and
the stored list is shown meanwhile."""
now: list[dict] = []
for g in guilds:
due = _directory_due(g)
if not due or not _member_radio_bots(tokens, g["guild_id"], org_id):
continue
if due == "missing":
now.append(g)
else:
_refresh_in_background(g, org_id, tokens)
if not now:
return
tasks = [asyncio.create_task(_refresh_quietly(g, org_id, tokens)) for g in now]
await asyncio.wait(tasks, timeout=settings.discord_perm_check_budget_seconds)
def _bot_status(tokens: dict, *, node_id: str, guild_id: str, org_id: str) -> dict: def _bot_status(tokens: dict, *, node_id: str, guild_id: str, org_id: str) -> dict:
res = leases.pick_token(tokens, node_id=node_id, guild_id=guild_id, org_id=org_id) res = leases.pick_token(tokens, node_id=node_id, guild_id=guild_id, org_id=org_id)
if res.ok: if res.ok:
@@ -181,7 +350,10 @@ async def join_options(node_id: str, decoded: dict = Depends(require_firebase_to
system_id = streams._effective_system(node) system_id = streams._effective_system(node)
system = (await fstore.doc_get_cached("systems", system_id)) if system_id else None system = (await fstore.doc_get_cached("systems", system_id)) if system_id else None
guilds = await _org_guilds(org_id) if org_id else [] tokens = await _load_tokens()
guilds = await _org_guilds(org_id, tokens) if org_id else []
if org_id:
await _fill_directories(guilds, org_id, tokens)
live = [s for s in await fstore.collection_list(streams.STREAMS, desired="on") live = [s for s in await fstore.collection_list(streams.STREAMS, desired="on")
if s.get("org_id") == org_id] if s.get("org_id") == org_id]
nodes = {n.get("node_id"): n.get("name") for n in await fstore.collection_list("nodes", org_id=org_id) nodes = {n.get("node_id"): n.get("name") for n in await fstore.collection_list("nodes", org_id=org_id)
@@ -190,7 +362,6 @@ async def join_options(node_id: str, decoded: dict = Depends(require_firebase_to
by_channel: dict[tuple, list] = {} by_channel: dict[tuple, list] = {}
for s in sorted(live, key=lambda s: (s.get("created_at") or "", s.get("stream_id") or "")): for s in sorted(live, key=lambda s: (s.get("created_at") or "", s.get("stream_id") or "")):
by_channel.setdefault((str(s.get("guild_id")), str(s.get("channel_id"))), []).append(s) by_channel.setdefault((str(s.get("guild_id")), str(s.get("channel_id"))), []).append(s)
tokens = {t["token_id"]: t for t in await fstore.collection_list(leases.TOKENS) if t.get("token_id")}
token_names = {tid: t.get("name") or tid for tid, t in tokens.items()} token_names = {tid: t.get("name") or tid for tid, t in tokens.items()}
out_guilds = [] out_guilds = []
@@ -216,6 +387,10 @@ async def join_options(node_id: str, decoded: dict = Depends(require_firebase_to
"linked": bool(g.get("org_id")), "linked": bool(g.get("org_id")),
"synced": g.get("voice_channels") is not None, "synced": g.get("voice_channels") is not None,
"synced_at": g.get("directory_at"), "synced_at": g.get("directory_at"),
# Who listed the channels: "main_bot" (DRB server bot) or "radio_bot" (S5).
"directory_source": (g.get("directory_source") or (MAIN_BOT if g.get("voice_channels") is not None
else None)),
"main_bot_present": g.get("bot_present") is True,
"voice_channels": channels, "voice_channels": channels,
"streams_on": sum(1 for s in live if s.get("guild_id") == gid), "streams_on": sum(1 for s in live if s.get("guild_id") == gid),
"bot": _bot_status(tokens, node_id=node_id, guild_id=gid, org_id=org_id), "bot": _bot_status(tokens, node_id=node_id, guild_id=gid, org_id=org_id),
@@ -261,10 +436,24 @@ async def start_node_stream(node_id: str, body: NodeStreamStart, decoded: dict =
raise HTTPException(409, "This node is not listening to a system yet.") raise HTTPException(409, "This node is not listening to a system yet.")
guild_id, channel_id = str(body.guild_id), str(body.channel_id) guild_id, channel_id = str(body.guild_id), str(body.channel_id)
guild = next((g for g in await _org_guilds(org_id) if g["guild_id"] == guild_id), None) tokens = await _load_tokens()
guild = next((g for g in await _org_guilds(org_id, tokens) if g["guild_id"] == guild_id), None)
if not guild: if not guild:
raise HTTPException(404, "That Discord server is not linked to this organization.") raise HTTPException(404, "That Discord server is not linked to this organization.")
channel = next((c for c in guild.get("voice_channels") or [] if str(c.get("channel_id")) == channel_id), None)
def _find(g: dict) -> Optional[dict]:
return next((c for c in g.get("voice_channels") or [] if str(c.get("channel_id")) == channel_id), None)
channel = _find(guild)
if not channel and guild.get("org_id") and not _main_bot_list_fresh(guild):
# No list yet, or a radio bot's list that may predate this channel:
# read it once more (cached snapshot) before saying no.
try:
await asyncio.wait_for(_refresh_directory(guild, org_id, tokens),
timeout=settings.discord_perm_check_budget_seconds)
except asyncio.TimeoutError:
pass
channel = _find(guild)
if not channel: if not channel:
raise HTTPException(400, "That is not a voice channel DRB can see in this server. " raise HTTPException(400, "That is not a voice channel DRB can see in this server. "
"Refresh the list and pick again.") "Refresh the list and pick again.")
@@ -287,6 +476,7 @@ async def start_node_stream(node_id: str, body: NodeStreamStart, decoded: dict =
channel_name=channel.get("name"), guild_name=_guild_name(guild), channel_name=channel.get("name"), guild_name=_guild_name(guild),
created_by=f"web:{decoded.get('uid') or 'unknown'}", created_by=f"web:{decoded.get('uid') or 'unknown'}",
max_streams=settings.discord_guild_max_streams, max_streams=settings.discord_guild_max_streams,
interactive=True,
) )
except streams.StreamError as e: except streams.StreamError as e:
return JSONResponse(status_code=e.status, return JSONResponse(status_code=e.status,
+124 -21
View File
@@ -10,19 +10,100 @@ happens inline below, in the same spirit as routers/enrollment.py.
The bucket stays fully private and c2-core reads the object server-side with The bucket stays fully private and c2-core reads the object server-side with
Application Default Credentials, so no GCS signed URL — and therefore no Application Default Credentials, so no GCS signed URL — and therefore no
service-account private key on the VM — is involved anywhere in this path. service-account private key on the VM — is involved anywhere in this path.
HTTP Range (server-26#233): the body is streamed from GCS in fixed-size span
reads instead of downloaded whole first. A 600 s FLAC call is ~13 MB, and the
old full-body response (``Accept-Ranges: none``) made the browser wait for all
of it before playback started and made seeking re-download it. Now playback
starts after the first span and a seek is a ``206`` for just the bytes needed.
Only a single range is honoured; a multi-range request gets the full body,
which RFC 9110 §14.2 permits.
""" """
from fastapi import APIRouter, HTTPException, Query, Response import re
from typing import AsyncIterator, Optional, Tuple
from fastapi import APIRouter, Header, HTTPException, Query, Response
from fastapi.responses import StreamingResponse
from app.internal import firestore as fstore from app.internal import firestore as fstore
from app.internal.storage import verify_audio_link, gcs_uri_for_call, download_audio, content_type_for from app.internal.logger import logger
from app.internal.storage import (
audio_object_size,
content_type_for,
download_audio_range,
gcs_uri_for_call,
verify_audio_link,
)
router = APIRouter(prefix="/media", tags=["media"]) router = APIRouter(prefix="/media", tags=["media"])
# Bytes per GCS span read. ~25 s of FLAC at ~1.3 MB/min, so the first span —
# the only one the browser waits on — is one small GCS round-trip.
STREAM_CHUNK_BYTES = 512 * 1024
# Immutable content, but the URL expires — cache privately only.
_CACHE_CONTROL = "private, max-age=3600"
_RANGE_RE = re.compile(r"^\s*bytes\s*=\s*(\d*)\s*-\s*(\d*)\s*$", re.IGNORECASE)
class _Unsatisfiable(Exception):
pass
def parse_range(header: Optional[str], size: int) -> Optional[Tuple[int, int]]:
"""Resolve a ``Range`` header against an object of ``size`` bytes.
Returns an inclusive ``(start, end)``, or None when the header is absent or
should be ignored (not ``bytes``, multi-range, malformed — RFC 9110 lets a
server answer those with the full 200). Raises _Unsatisfiable for a
well-formed range that selects nothing (-> 416).
"""
if not header:
return None
m = _RANGE_RE.match(header)
if not m:
return None # other unit, multi-range ("a-b,c-d") or garbage
first, last = m.group(1), m.group(2)
if first == "" and last == "":
return None
if first == "":
# Suffix range: the final N bytes.
n = int(last)
if n == 0 or size == 0:
raise _Unsatisfiable()
return max(0, size - n), size - 1
start = int(first)
if last != "" and int(last) < start:
return None # invalid spec -> ignore the header
if start >= size:
raise _Unsatisfiable()
end = size - 1 if last == "" else min(int(last), size - 1)
return start, end
async def _stream(gcs_uri: str, first_chunk: bytes, start: int, end: int) -> AsyncIterator[bytes]:
yield first_chunk
pos = start + len(first_chunk)
while pos <= end:
chunk_end = min(pos + STREAM_CHUNK_BYTES - 1, end)
data = await download_audio_range(gcs_uri, pos, chunk_end)
if not data:
# Headers are already sent, so the status can't change; ending the
# body short makes the client see a truncated response rather than
# silently wrong audio.
logger.warning(f"/media stream cut short at byte {pos} of {gcs_uri}")
return
yield data
pos += len(data)
@router.get("/calls/{call_id}/audio") @router.get("/calls/{call_id}/audio")
async def get_call_audio( async def get_call_audio(
call_id: str, call_id: str,
exp: int = Query(..., description="Link expiry, unix seconds."), exp: int = Query(..., description="Link expiry, unix seconds."),
sig: str = Query(..., description="HMAC over call_id and expiry."), sig: str = Query(..., description="HMAC over call_id and expiry."),
range_header: Optional[str] = Header(None, alias="Range"),
): ):
# Verify before touching Firestore so an invalid link costs nothing. # Verify before touching Firestore so an invalid link costs nothing.
if not verify_audio_link(call_id, exp, sig): if not verify_audio_link(call_id, exp, sig):
@@ -36,26 +117,48 @@ async def get_call_audio(
if not gcs_uri: if not gcs_uri:
raise HTTPException(404, "No audio for this call.") raise HTTPException(404, "No audio for this call.")
data = await download_audio(gcs_uri) size = await audio_object_size(gcs_uri)
if not data: if size is None:
raise HTTPException(404, "Audio object missing from storage.") raise HTTPException(404, "Audio object missing from storage.")
return Response( # Was hardcoded audio/mpeg. The node now uploads FLAC, and a browser will
content=data, # not play a FLAC body labelled audio/mpeg. Derived from the stored
# Was hardcoded audio/mpeg. The node now uploads FLAC, and a browser
# will not play a FLAC body labelled audio/mpeg. Derived from the stored
# object's extension so old .mp3 recordings keep working unchanged. # object's extension so old .mp3 recordings keep working unchanged.
media_type=content_type_for(gcs_uri), media_type = content_type_for(gcs_uri)
headers={
"Content-Length": str(len(data)), try:
# Whole body at once, no Range support. This was comfortable at span = parse_range(range_header, size)
# 16 kbps mono (~60 KB for a 30 s call); FLAC is ~1.3 MB/min, so a except _Unsatisfiable:
# long call is now tens of MB and the browser must download all of return Response(
# it before playback starts. Acceptable for typical few-second status_code=416,
# transmissions, but this is the change that makes Range support headers={"Content-Range": f"bytes */{size}", "Accept-Ranges": "bytes"},
# actually matter — see DEFERRED.md. )
"Accept-Ranges": "none",
# Immutable content, but the URL expires — cache privately only. if size == 0:
"Cache-Control": "private, max-age=3600", return Response(
}, content=b"",
media_type=media_type,
headers={"Accept-Ranges": "bytes", "Cache-Control": _CACHE_CONTROL},
)
start, end = span if span else (0, size - 1)
# Fetched before the response starts so a read failure is still a clean
# 404 rather than a stream that dies after a 200/206 has gone out.
first = await download_audio_range(gcs_uri, start, min(start + STREAM_CHUNK_BYTES - 1, end))
if not first:
raise HTTPException(404, "Audio object missing from storage.")
headers = {
"Content-Length": str(end - start + 1),
"Accept-Ranges": "bytes",
"Cache-Control": _CACHE_CONTROL,
}
if span:
headers["Content-Range"] = f"bytes {start}-{end}/{size}"
return StreamingResponse(
_stream(gcs_uri, first, start, end),
status_code=206 if span else 200,
media_type=media_type,
headers=headers,
) )
+1 -1
View File
@@ -210,7 +210,7 @@ async def send_command(
doc = await discord_streams.start_stream( doc = await discord_streams.start_stream(
org_id=node.get("org_id"), guild_id=cmd.guild_id, channel_id=cmd.channel_id, org_id=node.get("org_id"), guild_id=cmd.guild_id, channel_id=cmd.channel_id,
system_id=system_id, node_id=node_id, preferred_token_id=cmd.preferred_token_id, system_id=system_id, node_id=node_id, preferred_token_id=cmd.preferred_token_id,
mode="adhoc", mode="adhoc", interactive=True,
) )
except discord_streams.StreamError as e: except discord_streams.StreamError as e:
raise HTTPException(e.status, e.message) raise HTTPException(e.status, e.message)
+52
View File
@@ -0,0 +1,52 @@
"""Power-outage endpoints (map layer, server-26#184).
``GET /outages`` -- any signed-in user (auth applied at include time in main.py):
outage areas inside a bbox from every provider an admin has enabled, each with
the provider's attribution, disclaimer, coverage note and feed freshness, plus
a ``status`` that says why an empty list is empty.
``/admin/outage-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/outages/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.outages import registry
router = APIRouter(prefix="/outages", tags=["outages"])
admin_router = APIRouter(prefix="/admin/outage-providers", tags=["admin"])
@router.get("")
async def get_outages(
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.outages_in_bbox((south, west, north, east))
class ProviderToggle(BaseModel):
enabled: bool
acknowledge_licence: bool = False
@admin_router.get("")
async def list_outage_providers(_=Depends(require_admin_token)):
return {"providers": await registry.list_providers()}
@admin_router.put("/{provider_id}")
async def set_outage_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})
+79 -4
View File
@@ -2,8 +2,10 @@ import uuid
from fastapi import APIRouter, HTTPException, Depends, Query from fastapi import APIRouter, HTTPException, Depends, Query
from pydantic import BaseModel from pydantic import BaseModel
from typing import Optional from typing import Optional
from app.config import settings
from app.internal import firestore as fstore from app.internal import firestore as fstore
from app.internal import discord_leases as leases from app.internal import discord_leases as leases
from app.internal import discord_oauth
from app.internal import discord_rest from app.internal import discord_rest
from app.internal.audit import write_audit from app.internal.audit import write_audit
from app.internal.auth import ( from app.internal.auth import (
@@ -27,9 +29,27 @@ class TokenCreate(BaseModel):
org_id: Optional[str] = None org_id: Optional[str] = None
def _masked(t: dict) -> dict: # What GET /tokens may return of a bot_tokens doc (server-26#225 S6: an
out = {**t, "token": "•••" + (t.get("token") or "")[-4:]} # allowlist, so a field added to the doc later — a secret, say — never
# leaks by default). The token string itself is masked; OAuth client secrets
# live in bot_oauth/{id}, never on this doc, and are never returned.
_PUBLIC_TOKEN_FIELDS = (
"token_id", "name", "org_id", "valid", "guild_ids", "check_error", "validated_at",
"application_id", "bot_user_id", "bot_name", "lease", "in_use", "assigned_node_id", "assigned_at",
"preferred_for_system_id",
)
def _masked(t: dict, oauth_ids: Optional[set] = None) -> dict:
out = {k: t[k] for k in _PUBLIC_TOKEN_FIELDS if k in t}
out["token"] = "•••" + (t.get("token") or "")[-4:]
out["invite_url"] = leases.invite_url(t.get("application_id")) out["invite_url"] = leases.invite_url(t.get("application_id"))
members = t.get("guild_ids")
out["server_count"] = len(members) if isinstance(members, list) else None
# Pool bots stop being offered at the cap (owner decision Q3); BYO bots have none.
out["server_cap"] = settings.discord_pool_bot_guild_cap if t.get("org_id") is None else None
if oauth_ids is not None:
out["oauth_enabled"] = t.get("token_id") in oauth_ids
return out return out
@@ -47,7 +67,8 @@ async def _republish_after_release(lease: Optional[dict]) -> None:
@router.get("") @router.get("")
async def list_tokens(_: dict = Depends(require_admin_token)): async def list_tokens(_: dict = Depends(require_admin_token)):
"""List all tokens. The actual token string is masked for safety.""" """List all tokens. The actual token string is masked for safety."""
return [_masked(t) for t in await fstore.collection_list("bot_tokens")] oauth_ids = await discord_oauth.enabled_token_ids()
return [_masked(t, oauth_ids) for t in await fstore.collection_list("bot_tokens")]
@router.post("", status_code=201) @router.post("", status_code=201)
@@ -83,7 +104,7 @@ async def refresh_token(token_id: str, _: dict = Depends(require_admin_token)):
if not await fstore.doc_get("bot_tokens", token_id): if not await fstore.doc_get("bot_tokens", token_id):
raise HTTPException(404, "Token not found.") raise HTTPException(404, "Token not found.")
await discord_rest.refresh_membership([token_id]) await discord_rest.refresh_membership([token_id])
return _masked(await fstore.doc_get("bot_tokens", token_id) or {}) return _masked(await fstore.doc_get("bot_tokens", token_id) or {}, await discord_oauth.enabled_token_ids())
@router.post("/flush", status_code=200) @router.post("/flush", status_code=200)
@@ -265,3 +286,57 @@ async def delete_token(token_id: str, _: dict = Depends(require_admin_token)):
if existing.get("lease"): if existing.get("lease"):
raise HTTPException(409, "Token is currently leased to a node. Stop its radio channels (or flush) first.") raise HTTPException(409, "Token is currently leased to a node. Stop its radio channels (or flush) first.")
await fstore.doc_delete("bot_tokens", token_id) await fstore.doc_delete("bot_tokens", token_id)
await discord_oauth.clear_secret(token_id)
class OAuthSecret(BaseModel):
# Length is checked in the handler, not here: a pydantic 422 echoes the
# rejected input back in its body.
client_secret: str
@router.put("/{token_id}/oauth", status_code=200)
async def set_oauth_secret(token_id: str, body: OAuthSecret, decoded: dict = Depends(require_admin_token)):
"""Set (or replace) this radio bot application's OAuth2 client secret, so
"Connect a Discord server" can invite it (server-26#225 S6). Checked with
Discord first (client-credentials grant) — a wrong secret is refused and
nothing is stored. The secret goes to bot_oauth/{id}, server-side only:
it is never echoed, never returned by any route and never logged."""
if "/" in token_id:
raise HTTPException(400, "Bad id.")
tok = await fstore.doc_get("bot_tokens", token_id)
if not tok:
raise HTTPException(404, "Token not found.")
if not tok.get("application_id"):
raise HTTPException(409, "DRB doesn't know this bot's application id yet. Re-check the bot, then try again.")
secret = (body.client_secret or "").strip()
if not (discord_oauth.SECRET_MIN <= len(secret) <= discord_oauth.SECRET_MAX):
raise HTTPException(400, "That doesn't look like a Discord client secret. Copy it from the Discord "
"Developer Portal → OAuth2.")
r = await discord_oauth.check_secret(str(tok["application_id"]), secret)
uid, email = describe_actor(decoded)
if not r.ok:
await write_audit(uid, email, "discord.token.oauth_refused", details={
"token_id": token_id, "status": r.status})
if r.status in (400, 401):
raise HTTPException(400, "Discord didn't accept that client secret for this bot's application. "
"Copy it again from the Discord Developer Portal → OAuth2.")
raise HTTPException(503, "Couldn't check the secret with Discord right now. Nothing was saved; "
"try again in a minute.")
await discord_oauth.store_secret(token_id, secret, uid)
await write_audit(uid, email, "discord.token.oauth_set", details={"token_id": token_id})
logger.info(f"Radio bot {token_id}: OAuth secret set by uid={uid}")
return {"ok": True, "token_id": token_id, "oauth_enabled": True,
"redirect_uri_configured": discord_oauth.enabled()}
@router.delete("/{token_id}/oauth", status_code=200)
async def clear_oauth_secret(token_id: str, decoded: dict = Depends(require_admin_token)):
"""Forget this bot's OAuth secret: Connect stops offering it."""
if "/" in token_id:
raise HTTPException(400, "Bad id.")
removed = await discord_oauth.clear_secret(token_id)
uid, email = describe_actor(decoded)
if removed:
await write_audit(uid, email, "discord.token.oauth_clear", details={"token_id": token_id})
return {"ok": True, "token_id": token_id, "oauth_enabled": False}
File diff suppressed because it is too large Load Diff
+392
View File
@@ -0,0 +1,392 @@
"""
Channel list and presence without the main bot (server-26#225 S5), plus the
interactive re-check of a cached "denied" answer (server-26#251).
* discord_rest.directory_from_snapshot / guild_directory: a radio bot's REST
snapshot becomes the same directory the DRB server bot pushes.
* _org_guilds: a linked server stays visible with only a radio bot in it; a
fallback-org server still needs the server bot; REST never discovers a
server or creates a ``discord_guilds`` doc.
* join_options / start_node_stream: a missing list is read inline from a
member radio bot, a stale one in the background; the server bot's fresh
list is never overwritten; nothing is written onto a server that was
unlinked meanwhile.
* start_stream(interactive=True) re-reads a "denied" snapshot older than
RECHECK_DENIED_AFTER_SECONDS once; the reconciler never does.
"""
import asyncio
import time
from datetime import datetime, timedelta, timezone
import pytest
from fastapi import HTTPException
from app.config import settings
from app.internal import discord_rest
from app.internal import discord_streams as streams
from app.routers import discord as discord_router
from app.models import CommandPayload
from app.routers import discord_web as web
from app.routers import nodes as nodes_router
from tests.test_discord_perm_check import ( # noqa: F401 (fs = fixture)
ADMIN, GUILD, ORG, OTHER, OWNER, SYS, _guild, _snap, _token, fs, run,
)
LINKED = "300" # linked to ORG, never seen by the server bot
def _ago(seconds: float) -> str:
return (datetime.now(timezone.utc) - timedelta(seconds=seconds)).isoformat()
def _rest_channels():
return [
{"id": "9", "name": "Radio", "type": 4, "position": 1},
{"id": "8", "name": "Admin", "type": 4, "position": 0},
{"id": "3001", "name": "dispatch", "type": 2, "position": 1, "parent_id": "9", "permission_overwrites": []},
{"id": "3002", "name": "fireground", "type": 2, "position": 0, "parent_id": "9", "permission_overwrites": []},
{"id": "3003", "name": "staff", "type": 2, "position": 0, "parent_id": "8", "permission_overwrites": []},
{"id": "3004", "name": "lobby", "type": 2, "position": 5, "permission_overwrites": []},
{"id": "3005", "name": "general", "type": 0, "position": 0, "permission_overwrites": []},
{"id": "3006", "name": "town hall", "type": 13, "position": 0, "permission_overwrites": []},
]
def _rest_snap(tok_id, guild_id=LINKED, status=discord_rest.OK):
if status != discord_rest.OK:
return discord_rest.GuildSnapshot(token_id=tok_id, guild_id=guild_id, status=status, error="x")
return discord_rest.GuildSnapshot(
token_id=tok_id, guild_id=guild_id, status=discord_rest.OK, bot_user_id=f"u-{tok_id}",
guild={**_guild(), "id": guild_id, "name": "Station 9"}, member={"roles": []},
channels=_rest_channels())
@pytest.fixture
def dfs(fs, monkeypatch):
"""The perm-check fixture plus a linked server only radio bots know."""
fs.put("discord_guilds", LINKED, {"guild_id": LINKED, "org_id": ORG, "linked_at": _ago(60)})
fs.dir_mode = {} # token_id -> "ok" | "not_member" | "unknown"
async def _snapshot(token_doc, guild_id, *, refresh=False):
tid = token_doc["token_id"]
fs.snap_calls.append((tid, guild_id, refresh))
if guild_id != LINKED:
mode = fs.snap_mode.get(tid, "ok")
return _snap(tid, muted=(mode == "muted"))
mode = fs.dir_mode.get(tid, "ok")
status = {"ok": discord_rest.OK, "not_member": discord_rest.NOT_MEMBER}.get(mode, discord_rest.UNKNOWN)
return _rest_snap(tid, status=status)
monkeypatch.setattr(discord_rest, "guild_snapshot", _snapshot)
web._directory_tasks.clear()
return fs
async def _drain():
while web._directory_tasks:
await asyncio.gather(*list(web._directory_tasks.values()), return_exceptions=True)
def _ids(opts):
return [g["guild_id"] for g in opts["guilds"]]
# ---------------------------------------------------------------------------
# discord_rest — directory from a snapshot
# ---------------------------------------------------------------------------
def test_directory_from_snapshot_matches_the_server_bots_shape_and_order():
d = discord_rest.directory_from_snapshot(_rest_snap("t1"))
assert d["name"] == "Station 9"
assert [(c["channel_id"], c["category"], c["position"]) for c in d["voice_channels"]] == [
("3004", None, 0), # no category first
("3003", "Admin", 1), # categories by position
("3002", "Radio", 2), # channels by position inside one
("3001", "Radio", 3),
] # text and stage channels are not listed
assert set(d["voice_channels"][0]) == {"channel_id", "name", "category", "position"}
def test_directory_from_snapshot_is_none_unless_ok():
assert discord_rest.directory_from_snapshot(None) is None
for status in (discord_rest.NOT_MEMBER, discord_rest.UNKNOWN, discord_rest.INVALID):
assert discord_rest.directory_from_snapshot(_rest_snap("t1", status=status)) is None
def test_directory_caps_channels_and_name_length():
snap = _rest_snap("t1")
snap.channels = [{"id": str(5000 + i), "name": "x" * 300, "type": 2, "position": i} for i in range(250)]
d = discord_rest.directory_from_snapshot(snap)
assert len(d["voice_channels"]) == discord_rest.DIRECTORY_MAX_CHANNELS
assert len(d["voice_channels"][0]["name"]) == 100
async def test_guild_directory_tries_the_next_bot_and_reports_not_member(dfs):
_token(dfs, "a", guilds=(LINKED,))
_token(dfs, "b", guilds=(LINKED,))
docs = [{**dfs.get("bot_tokens", t)} for t in ("a", "b")]
dfs.dir_mode["a"] = "not_member"
got = await discord_rest.guild_directory(docs, LINKED)
assert got.status == discord_rest.OK and got.token_id == "b" and len(got.voice_channels) == 4
dfs.dir_mode["b"] = "not_member"
assert (await discord_rest.guild_directory(docs, LINKED)).status == discord_rest.NOT_MEMBER
dfs.dir_mode["b"] = "unknown"
assert (await discord_rest.guild_directory(docs, LINKED)).status == discord_rest.UNKNOWN
assert (await discord_rest.guild_directory([], LINKED)).status == discord_rest.UNKNOWN
# ---------------------------------------------------------------------------
# Which servers an org sees
# ---------------------------------------------------------------------------
def test_linked_server_with_only_a_radio_bot_is_listed_with_its_channels(dfs):
"""Owner requirement 1: invite only the radio bot, no main bot."""
_token(dfs, "byo", org=ORG, guilds=(LINKED,))
opts = run(web.join_options("n1", OWNER))
g = next(g for g in opts["guilds"] if g["guild_id"] == LINKED)
assert g["synced"] is True and g["directory_source"] == "radio_bot" and g["main_bot_present"] is False
assert [c["name"] for c in g["voice_channels"]] == ["lobby", "staff", "fireground", "dispatch"]
assert g["name"] == "Station 9" and g["linked"] is True and g["bot"]["ok"] is True
doc = dfs.get("discord_guilds", LINKED)
assert doc["org_id"] == ORG and "bot_present" not in doc # never the server bot's field
assert doc["radio_bot_present"] is True and doc["directory_source"] == "radio_bot"
assert doc["guild_name"] == "Station 9" and len(doc["voice_channels"]) == 4
assert "secret" not in repr(opts)
def test_linked_server_without_any_radio_bot_keeps_todays_behaviour(dfs):
opts = run(web.join_options("n1", OWNER))
g = next(g for g in opts["guilds"] if g["guild_id"] == LINKED)
assert g["synced"] is False and g["bot"]["ok"] is False # invite links, as before
assert not [c for c in dfs.snap_calls if c[1] == LINKED]
@pytest.mark.parametrize("tok", [
{"org": OTHER}, # another org's branded bot
{"guilds": None}, # membership never checked
{"valid": False},
])
def test_only_a_usable_member_bot_keeps_a_server_without_the_main_bot(dfs, tok):
dfs.put("discord_guilds", LINKED, {**dfs.get("discord_guilds", LINKED), "bot_present": False})
kw = {"guilds": (LINKED,), **tok}
_token(dfs, "x", **kw)
assert LINKED not in _ids(run(web.join_options("n1", OWNER)))
assert not [c for c in dfs.snap_calls if c[1] == LINKED]
def test_server_bot_leaving_keeps_server_while_a_radio_bot_is_in_it(dfs):
_token(dfs, "t1", guilds=(GUILD,))
run(web.put_guild_directory(GUILD, web.GuildDirectory(present=False), {"service": True}))
opts = run(web.join_options("n1", OWNER))
assert GUILD in _ids(opts)
g = next(g for g in opts["guilds"] if g["guild_id"] == GUILD)
assert g["main_bot_present"] is False
def test_fallback_server_is_never_discovered_by_a_radio_bot(dfs, monkeypatch):
monkeypatch.setattr(settings, "discord_unlinked_guild_org_id", ORG)
dfs.put("discord_guilds", "400", {"guild_id": "400", "bot_present": False, "voice_channels": []})
_token(dfs, "t1", guilds=("400", "401", LINKED)) # also in a server DRB has no doc for
ids = _ids(run(web.join_options("n1", OWNER)))
assert "400" not in ids and "401" not in ids
assert dfs.get("discord_guilds", "401") is None # REST never creates a doc
assert not [c for c in dfs.snap_calls if c[1] in ("400", "401")]
def test_fallback_server_list_is_never_read_over_rest(dfs, monkeypatch):
monkeypatch.setattr(settings, "discord_unlinked_guild_org_id", ORG)
dfs.put("discord_guilds", "400", {"guild_id": "400", "bot_present": True, "voice_channels": None,
"directory_at": _ago(99999)})
_token(dfs, "t1", guilds=("400",))
assert "400" in _ids(run(web.join_options("n1", OWNER)))
assert not [c for c in dfs.snap_calls if c[1] == "400"]
# ---------------------------------------------------------------------------
# When the list is read, and who wins
# ---------------------------------------------------------------------------
def test_fresh_main_bot_list_is_never_read_over_rest(dfs):
_token(dfs, "t1", guilds=(LINKED,))
dfs.put("discord_guilds", LINKED, {**dfs.get("discord_guilds", LINKED), "bot_present": True,
"directory_source": "main_bot", "directory_at": _ago(600),
"voice_channels": [{"channel_id": "3001", "name": "dispatch"}]})
opts = run(web.join_options("n1", OWNER))
g = next(g for g in opts["guilds"] if g["guild_id"] == LINKED)
assert g["directory_source"] == "main_bot" and len(g["voice_channels"]) == 1
assert not [c for c in dfs.snap_calls if c[1] == LINKED]
def test_stale_list_is_refreshed_in_the_background(dfs):
_token(dfs, "t1", guilds=(LINKED,))
old = [{"channel_id": "3001", "name": "old name"}]
dfs.put("discord_guilds", LINKED, {**dfs.get("discord_guilds", LINKED), "voice_channels": old,
"directory_source": "radio_bot",
"directory_at": _ago(settings.discord_directory_refresh_seconds + 5)})
async def go():
opts = await web.join_options("n1", OWNER)
await _drain()
return opts
opts = run(go())
g = next(g for g in opts["guilds"] if g["guild_id"] == LINKED)
assert [c["name"] for c in g["voice_channels"]] == ["old name"] # stored list shown meanwhile
assert len(dfs.get("discord_guilds", LINKED)["voice_channels"]) == 4 # and replaced right after
def test_dead_main_bot_list_is_taken_over_by_a_radio_bot(dfs):
_token(dfs, "t1", guilds=(LINKED,))
dfs.put("discord_guilds", LINKED, {**dfs.get("discord_guilds", LINKED), "bot_present": True,
"directory_at": _ago(web.MAIN_BOT_STALE_SECONDS + 60),
"voice_channels": [{"channel_id": "3001", "name": "dispatch"}]})
async def go():
await web.join_options("n1", OWNER)
await _drain()
run(go())
doc = dfs.get("discord_guilds", LINKED)
assert doc["directory_source"] == "radio_bot" and len(doc["voice_channels"]) == 4
assert doc["bot_present"] is True # not ours to change
def test_not_member_marks_presence_and_keeps_the_list(dfs):
_token(dfs, "t1", guilds=(LINKED,))
keep = [{"channel_id": "3001", "name": "dispatch"}]
dfs.put("discord_guilds", LINKED, {**dfs.get("discord_guilds", LINKED), "voice_channels": keep,
"directory_source": "radio_bot", "directory_at": _ago(99999)})
dfs.dir_mode["t1"] = "not_member"
async def go():
await web.join_options("n1", OWNER)
await _drain()
run(go())
doc = dfs.get("discord_guilds", LINKED)
assert doc["radio_bot_present"] is False and doc["voice_channels"] == keep
def test_refresh_never_writes_onto_a_server_unlinked_meanwhile(dfs, monkeypatch):
_token(dfs, "t1", guilds=(LINKED,))
real = discord_rest.guild_directory
async def _unlink_then_answer(docs, guild_id, **kw):
dfs.put("discord_guilds", LINKED, {"guild_id": LINKED, "org_id": None, "unlinked_at": _ago(0)})
return await real(docs, guild_id, **kw)
monkeypatch.setattr(discord_rest, "guild_directory", _unlink_then_answer)
g = dict(dfs.get("discord_guilds", LINKED), org_id=ORG)
assert run(web._refresh_directory(g, ORG, {"t1": dfs.get("bot_tokens", "t1")})) is None
assert "voice_channels" not in dfs.get("discord_guilds", LINKED)
def test_slow_discord_never_holds_the_picker_past_the_budget(dfs, monkeypatch):
_token(dfs, "t1", guilds=(LINKED,))
monkeypatch.setattr(settings, "discord_perm_check_budget_seconds", 0.05)
async def _slow(docs, guild_id, **kw):
await asyncio.sleep(5)
monkeypatch.setattr(discord_rest, "guild_directory", _slow)
async def go():
t0 = time.monotonic()
opts = await web.join_options("n1", OWNER)
return opts, time.monotonic() - t0
opts, took = run(go())
assert took < 1.0
assert next(g for g in opts["guilds"] if g["guild_id"] == LINKED)["synced"] is False
def test_directory_push_marks_main_bot_and_drops_cached_snapshots(dfs, monkeypatch):
dropped = []
monkeypatch.setattr(discord_rest, "invalidate", lambda token_id=None, guild_id=None: dropped.append(
(token_id, guild_id)) or 0)
run(web.put_guild_directory(LINKED, web.GuildDirectory(name="S9", voice_channels=[
web.VoiceChannelIn(channel_id="3001", name="dispatch")]), {"service": True}))
doc = dfs.get("discord_guilds", LINKED)
assert doc["directory_source"] == "main_bot" and doc["bot_present"] is True and doc["org_id"] == ORG
assert dropped == [(None, LINKED)]
# ---------------------------------------------------------------------------
# Starting from the picker without the main bot
# ---------------------------------------------------------------------------
def test_start_checks_the_channel_against_the_radio_bots_list(dfs):
_token(dfs, "byo", org=ORG, guilds=(LINKED,))
doc = run(web.start_node_stream("n1", web.NodeStreamStart(guild_id=LINKED, channel_id="3001"), OWNER))
assert doc["ok"] is True and doc["token_id"] == "byo"
s = dfs.get("discord_streams", doc["stream_id"])
assert s["channel_name"] == "dispatch" and s["guild_name"] == "Station 9" and s["node_id"] == "n1"
def test_start_refuses_a_channel_the_radio_bot_does_not_list(dfs):
_token(dfs, "byo", org=ORG, guilds=(LINKED,))
for bad in ("3005", "3999"): # a text channel; no such channel
with pytest.raises(HTTPException) as e:
run(web.start_node_stream("n1", web.NodeStreamStart(guild_id=LINKED, channel_id=bad), OWNER))
assert e.value.status_code == 400
# ---------------------------------------------------------------------------
# server-26#251 — an interactive start re-reads a stale "denied"
# ---------------------------------------------------------------------------
@pytest.fixture
def aging(fs, monkeypatch):
"""Snapshots that are ``fs.age`` seconds old unless refreshed; ``muted``
until ``fs.fixed`` (the owner fixed the permission in Discord)."""
fs.age, fs.fixed = 60.0, False
async def _snapshot(token_doc, guild_id, *, refresh=False):
tid = token_doc["token_id"]
fs.snap_calls.append((tid, guild_id, refresh))
s = _snap(tid, muted=not (refresh and fs.fixed))
s.fetched_at = time.time() - (0 if refresh else fs.age)
return s
monkeypatch.setattr(discord_rest, "guild_snapshot", _snapshot)
_token(fs, "t1", name="DRB Radio 2")
return fs
def _join():
return discord_router.start_stream(discord_router.StreamStart(guild_id=GUILD, channel_id="1001",
system_id=SYS), {"service": True})
def test_join_after_fixing_the_permission_succeeds_at_once(aging):
aging.fixed = True
r = run(_join())
assert r["token_id"] == "t1"
assert ("t1", GUILD, True) in aging.snap_calls
def test_still_denied_after_the_recheck_is_refused(aging):
r = run(_join())
assert r.status_code == 409 and b"can't Speak in #dispatch" in r.body
assert [c for c in aging.snap_calls if c[2]] == [("t1", GUILD, True)] # once
def test_a_fresh_denied_answer_is_not_asked_again(aging):
aging.age = 1.0
aging.fixed = True
r = run(_join())
assert r.status_code == 409
assert not [c for c in aging.snap_calls if c[2]]
async def test_reconciler_never_rechecks(aging):
aging.fixed = True
with pytest.raises(streams.StreamError) as e:
await streams.start_stream(org_id=ORG, guild_id=GUILD, channel_id="1001", system_id=SYS)
assert e.value.code == "missing_permissions"
assert not [c for c in aging.snap_calls if c[2]]
def test_web_picker_start_is_interactive(aging):
aging.fixed = True
doc = run(web.start_node_stream("n1", web.NodeStreamStart(guild_id=GUILD, channel_id="1001"), OWNER))
assert doc["ok"] is True and doc["token_id"] == "t1"
def test_node_discord_join_command_is_interactive(aging):
aging.fixed = True
out = run(nodes_router.send_command("n1", CommandPayload(action="discord_join", guild_id=GUILD,
channel_id="1001"), ADMIN))
assert out["ok"] is True and out["token_id"] == "t1"
+412
View File
@@ -0,0 +1,412 @@
"""
Per-bot OAuth "Connect a Discord server" (server-26#225 S6).
Discord is an httpx.MockTransport; Firestore is tests/fake_firestore.py.
"""
import asyncio
import base64
import json
import logging
from datetime import datetime, timedelta, timezone
from urllib.parse import parse_qs, urlparse
import httpx
import pytest
from fastapi import HTTPException
from app.config import settings
from app.internal import discord_guild_link as glink
from app.internal import discord_oauth as oauth
from app.internal import discord_rest
from app.internal import discord_streams as streams
from app.routers import discord_oauth as router
from app.routers import tokens as tokens_router
from tests.fake_firestore import FakeFirestore
REDIRECT = "https://drb.example.test/discord/connected"
SECRET_A = "s3cr3t-app-a-0123456789abcdefXYZ"
SECRET_POOL = "s3cr3t-pool-0123456789abcdefXYZ"
GUILD = "100000000000000001"
OWNER_A = {"uid": "owner-a", "email": "a@example.test", "org_id": "org-a", "org_role": "owner"}
OWNER_B = {"uid": "owner-b", "email": "b@example.test", "org_id": "org-b", "org_role": "owner"}
ADMIN = {"uid": "admin-1", "email": "admin@example.test", "role": "admin", "org_id": "founding"}
class Discord:
"""Scripted OAuth endpoints. ``token_reply`` answers POST /oauth2/token."""
def __init__(self):
self.calls: list[httpx.Request] = []
self.token_reply = httpx.Response(200, json={
"access_token": "user-access-token", "token_type": "Bearer", "scope": "bot",
"guild": {"id": GUILD, "name": "Station 7"}})
def __call__(self, request: httpx.Request) -> httpx.Response:
self.calls.append(request)
path = request.url.path.removeprefix("/api/v10")
if path == "/oauth2/token":
r = self.token_reply
return r(request) if callable(r) else r
if path == "/oauth2/token/revoke":
return httpx.Response(200, json={})
return httpx.Response(404, json={"code": 0})
def form(self, i: int) -> dict:
return {k: v[0] for k, v in parse_qs(self.calls[i].content.decode()).items()}
def basic(self, i: int) -> str:
raw = self.calls[i].headers["authorization"].split(" ", 1)[1]
return base64.b64decode(raw).decode()
def paths(self):
return [r.url.path.removeprefix("/api/v10") for r in self.calls]
@pytest.fixture
def discord(monkeypatch):
d = Discord()
discord_rest.reset()
monkeypatch.setattr(discord_rest, "_transport", httpx.MockTransport(d))
return d
@pytest.fixture
def fs(monkeypatch, discord):
fake = FakeFirestore.install(monkeypatch)
monkeypatch.setattr(settings, "discord_oauth_redirect_uri", REDIRECT)
monkeypatch.setattr(settings, "discord_unlinked_guild_org_id", "")
monkeypatch.setattr(settings, "discord_pool_bot_guild_cap", 90)
for org in ("org-a", "org-b", "founding"):
fake.put("organizations", org, {"org_id": org, "name": org})
fake.put("org_members", "owner-a", {"uid": "owner-a", "org_id": "org-a", "org_role": "owner"})
fake.put("org_members", "owner-b", {"uid": "owner-b", "org_id": "org-b", "org_role": "owner"})
# org-a's own bot, two pool bots (one full), org-b's own bot.
fake.put("bot_tokens", "own-a", {"token_id": "own-a", "name": "Riverton Radio", "token": "tok-own-a",
"org_id": "org-a", "application_id": "111", "guild_ids": []})
fake.put("bot_tokens", "pool-1", {"token_id": "pool-1", "name": "DRB Radio 1", "token": "tok-pool-1",
"org_id": None, "application_id": "201", "guild_ids": ["9"] * 40})
fake.put("bot_tokens", "pool-2", {"token_id": "pool-2", "name": "DRB Radio 2", "token": "tok-pool-2",
"org_id": None, "application_id": "202", "guild_ids": ["9"] * 90})
fake.put("bot_tokens", "own-b", {"token_id": "own-b", "name": "Harbor Radio", "token": "tok-own-b",
"org_id": "org-b", "application_id": "333", "guild_ids": []})
for tid, secret in (("own-a", SECRET_A), ("pool-1", SECRET_POOL), ("pool-2", SECRET_POOL),
("own-b", SECRET_A)):
fake.put(oauth.OAUTH, tid, {"token_id": tid, "client_secret": secret, "enabled": True})
for lim in (router.start_limiter, router.complete_limiter):
lim._log.clear()
stopped: list = []
async def _stop_guild(guild_id, reason, channel_id=None, stream_id=None):
stopped.append((guild_id, reason))
return [f"{guild_id}:1"]
monkeypatch.setattr(streams, "stop_guild", _stop_guild)
fake.stopped = stopped
async def _voice_check(**kw):
return {"enabled": True, "channels": [
{"channel_id": "1", "name": "dispatch", "state": "ready", "bots": [{"fix": None}]},
{"channel_id": "2", "name": "fireground", "state": "needs_fix",
"bots": [{"fix": "In Discord: Edit Channel → Permissions → add Riverton Radio → allow Speak, "
"then try again."}]},
{"channel_id": "3", "name": "ops", "state": "unknown", "bots": []},
]}
monkeypatch.setattr(router, "voice_check", _voice_check)
return fake
def _audit(fake, action):
return [e for e in (fake.data.get("audit_log") or {}).values() if e["action"] == action]
def _status(resp):
return getattr(resp, "status_code", 200)
def _body(resp):
return json.loads(resp.body) if hasattr(resp, "body") else resp
async def _start(decoded=OWNER_A, **kw):
out = await router.oauth_start(router.StartBody(**kw), org_id=None, decoded=decoded)
q = parse_qs(urlparse(out["authorize_url"]).query)
return out, {k: v[0] for k, v in q.items()}
async def _complete(state, decoded=OWNER_A, code="the-code"):
return await router.oauth_complete(router.CompleteBody(code=code, state=state), decoded=decoded)
# ---------------------------------------------------------------------------
# Off switch
# ---------------------------------------------------------------------------
async def test_every_route_is_404_while_redirect_uri_is_empty(fs, monkeypatch):
monkeypatch.setattr(settings, "discord_oauth_redirect_uri", "")
for call in (router.oauth_options(node_id=None, token_id=None, org_id=None, decoded=OWNER_A),
router.oauth_start(router.StartBody(), org_id=None, decoded=OWNER_A),
_complete("anything")):
with pytest.raises(HTTPException) as e:
await call
assert e.value.status_code == 404
async def test_404_when_no_usable_bot_has_a_secret(fs):
for tid in ("own-a", "pool-1", "pool-2"):
fs.data[oauth.OAUTH].pop(tid)
with pytest.raises(HTTPException) as e:
await router.oauth_options(node_id=None, token_id=None, org_id=None, decoded=OWNER_A)
assert e.value.status_code == 404
# ---------------------------------------------------------------------------
# Which bot
# ---------------------------------------------------------------------------
async def test_org_bot_first_then_least_full_pool_bot_under_cap(fs):
out = await router.oauth_options(node_id=None, token_id=None, org_id=None, decoded=OWNER_A)
assert out["bot"] == {"name": "Riverton Radio", "reason": "org_bot", "own_bot": True}
fs.data[oauth.OAUTH].pop("own-a")
out = await router.oauth_options(node_id=None, token_id=None, org_id=None, decoded=OWNER_A)
assert out["bot"]["name"] == "DRB Radio 1" and out["bot"]["reason"] == "pool"
# pool-2 is at the cap (90): never offered, even when it's the only one left
fs.data[oauth.OAUTH].pop("pool-1")
with pytest.raises(HTTPException) as e:
await router.oauth_options(node_id=None, token_id=None, org_id=None, decoded=OWNER_A)
assert e.value.status_code == 404
async def test_preferred_bot_for_the_nodes_system_wins(fs):
fs.put("nodes", "n1", {"node_id": "n1", "org_id": "org-a", "assigned_system_id": "s1"})
fs.put("systems", "s1", {"system_id": "s1", "org_id": "org-a", "preferred_token_id": "pool-1"})
out = await router.oauth_options(node_id="n1", token_id=None, org_id=None, decoded=OWNER_A)
assert out["bot"]["name"] == "DRB Radio 1" and out["bot"]["reason"] == "preferred"
# another org's node is a 404
fs.put("nodes", "nb", {"node_id": "nb", "org_id": "org-b", "assigned_system_id": "s1"})
with pytest.raises(HTTPException) as e:
await router.oauth_options(node_id="nb", token_id=None, org_id=None, decoded=OWNER_A)
assert e.value.status_code == 404
async def test_explicit_bot_must_be_usable_by_the_org(fs):
out = await router.oauth_options(node_id=None, token_id="pool-1", org_id=None, decoded=OWNER_A)
assert out["bot"]["reason"] == "chosen"
for tid in ("own-b", "pool-2", "nope"): # other org's bot, full pool bot, unknown
with pytest.raises(HTTPException) as e:
await router.oauth_options(node_id=None, token_id=tid, org_id=None, decoded=OWNER_A)
assert e.value.status_code == 404
# ---------------------------------------------------------------------------
# Start
# ---------------------------------------------------------------------------
async def test_start_returns_the_bot_invite_and_stores_only_a_hash_of_the_state(fs):
out, q = await _start(return_to="/nodes/n1")
assert urlparse(out["authorize_url"]).netloc == "discord.com"
assert q["client_id"] == "111" and q["scope"] == "bot" and q["response_type"] == "code"
assert q["permissions"] == str((1 << 10) | (1 << 20) | (1 << 21))
assert q["redirect_uri"] == REDIRECT
nonce = q["state"]
assert len(nonce) >= 40
assert fs.get(oauth.STATES, nonce) is None # the nonce itself is never stored
st = fs.get(oauth.STATES, oauth.state_key(nonce))
assert st["org_id"] == "org-a" and st["uid"] == "owner-a" and st["token_id"] == "own-a"
assert st["return_to"] == "/nodes/n1" and st["used_at"] is None
assert len(_audit(fs, "discord.oauth.start")) == 1
@pytest.mark.parametrize("bad", ["https://evil.test/x", "//evil.test/x", "/\\evil.test", "nodes", "/a\nb"])
async def test_return_to_is_a_same_site_path_only(fs, bad):
_, q = await _start(return_to=bad)
assert fs.get(oauth.STATES, oauth.state_key(q["state"]))["return_to"] is None
async def test_start_rate_limited_per_user(fs):
for _ in range(5):
await _start()
with pytest.raises(HTTPException) as e:
await _start()
assert e.value.status_code == 429
# ---------------------------------------------------------------------------
# Complete
# ---------------------------------------------------------------------------
async def test_complete_links_the_server_from_the_exchange_and_reports_channels(fs, discord):
_, q = await _start(return_to="/settings/discord")
out = await _complete(q["state"])
assert out["ok"] and out["guild_id"] == GUILD and out["guild_name"] == "Station 7"
assert out["org_id"] == "org-a" and out["bot_name"] == "Riverton Radio"
assert (out["channels_ready"], out["channels_blocked"]) == (1, 1)
assert out["channels"][1]["fix"].startswith("In Discord")
assert out["return_to"] == "/settings/discord"
g = fs.get(streams.GUILDS, GUILD)
assert g["org_id"] == "org-a" and g["linked_via"] == "oauth" and g["linked_by"] == "owner-a"
assert g["linked_with_token_id"] == "own-a"
assert fs.get("bot_tokens", "own-a")["guild_ids"] == [GUILD]
# the exchange used that bot application's own credentials and the exact redirect URI
assert discord.paths() == ["/oauth2/token", "/oauth2/token/revoke"]
assert discord.basic(0) == f"111:{SECRET_A}"
assert discord.form(0) == {"grant_type": "authorization_code", "code": "the-code", "redirect_uri": REDIRECT}
assert discord.form(1)["token"] == "user-access-token" # revoked straight away
assert fs.get(oauth.STATES, oauth.state_key(q["state"]))["used_at"]
[entry] = _audit(fs, "discord.guild.link")
assert entry["details"]["via"] == "oauth" and entry["details"]["guild_id"] == GUILD
async def test_state_is_single_use(fs):
_, q = await _start()
assert _status(await _complete(q["state"])) == 200
again = await _complete(q["state"])
assert _status(again) == 409 and _body(again)["code"] == "state_used"
async def test_another_users_state_is_refused_and_burned(fs):
_, q = await _start(OWNER_A)
resp = await _complete(q["state"], decoded=OWNER_B) # login-CSRF shape
assert _status(resp) == 403 and _body(resp)["code"] == "wrong_user"
assert fs.get(streams.GUILDS, GUILD) is None
assert _status(await _complete(q["state"], decoded=OWNER_A)) == 409 # consumed
async def test_expired_and_unknown_states(fs):
_, q = await _start()
key = oauth.state_key(q["state"])
past = (datetime.now(timezone.utc) - timedelta(seconds=1)).isoformat()
fs.put(oauth.STATES, key, {**fs.get(oauth.STATES, key), "expires_at": past})
resp = await _complete(q["state"])
assert _status(resp) == 410 and fs.get(streams.GUILDS, GUILD) is None
assert _status(await _complete("not-a-state")) == 400
async def test_caller_who_is_no_longer_owner_is_refused(fs, discord):
_, q = await _start()
fs.put("org_members", "owner-a", {"uid": "owner-a", "org_id": "org-a", "org_role": "member"})
resp = await _complete(q["state"]) # token claims still say owner
assert _status(resp) == 403 and _body(resp)["code"] == "not_owner"
assert discord.calls == [] and fs.get(streams.GUILDS, GUILD) is None
async def test_exchange_without_a_guild_links_nothing(fs, discord):
discord.token_reply = httpx.Response(200, json={"access_token": "x", "scope": "bot"})
_, q = await _start()
resp = await _complete(q["state"])
assert _status(resp) == 400 and _body(resp)["code"] == "no_guild"
assert fs.get(streams.GUILDS, GUILD) is None
async def test_rejected_code_and_unreachable_discord(fs, discord):
discord.token_reply = httpx.Response(400, json={"error": "invalid_grant"})
_, q = await _start()
resp = await _complete(q["state"])
assert _status(resp) == 400 and _body(resp)["code"] == "code_rejected"
def _down(request):
raise httpx.ConnectError("down", request=request)
discord.token_reply = _down
_, q = await _start()
resp = await _complete(q["state"])
assert _status(resp) == 502 and fs.get(streams.GUILDS, GUILD) is None
async def test_server_linked_to_another_org_is_never_moved(fs):
fs.put(streams.GUILDS, GUILD, {"guild_id": GUILD, "org_id": "org-b", "guild_name": "Harbor"})
_, q = await _start()
resp = await _complete(q["state"])
assert _status(resp) == 409 and _body(resp)["code"] == "linked_elsewhere"
assert fs.get(streams.GUILDS, GUILD)["org_id"] == "org-b"
assert fs.stopped == []
assert [e["details"]["via"] for e in _audit(fs, "discord.guild.link_refused")] == ["oauth"]
async def test_linking_away_from_the_fallback_org_stops_its_streams(fs, monkeypatch):
monkeypatch.setattr(settings, "discord_unlinked_guild_org_id", "founding")
_, q = await _start()
out = await _complete(q["state"])
assert out["streams_stopped"] == 1 and fs.stopped == [(GUILD, glink.RELINK_STOP_REASON)]
async def test_permission_check_timeout_leaves_the_link_standing(fs, monkeypatch):
monkeypatch.setattr(settings, "discord_oauth_link_check_seconds", 0.05)
async def _slow(**kw):
await asyncio.sleep(1)
monkeypatch.setattr(router, "voice_check", _slow)
_, q = await _start()
out = await _complete(q["state"])
assert out["ok"] and out["checked"] is False and out["channels_ready"] is None
assert fs.get(streams.GUILDS, GUILD)["org_id"] == "org-a"
async def test_bot_handed_to_another_org_after_start_is_refused(fs):
_, q = await _start()
fs.put("bot_tokens", "own-a", {**fs.get("bot_tokens", "own-a"), "org_id": "org-b"})
resp = await _complete(q["state"])
assert _status(resp) == 409 and _body(resp)["code"] == "bot_unavailable"
# ---------------------------------------------------------------------------
# Secrets: PUT /tokens/{id}/oauth, never returned, never logged
# ---------------------------------------------------------------------------
async def test_set_secret_checks_it_with_discord_and_never_echoes_it(fs, discord, caplog):
caplog.set_level(logging.DEBUG)
fs.data[oauth.OAUTH].clear()
discord.token_reply = httpx.Response(401, json={"error": "invalid_client"})
with pytest.raises(HTTPException) as e:
await tokens_router.set_oauth_secret("pool-1", tokens_router.OAuthSecret(client_secret=SECRET_POOL),
decoded=ADMIN)
assert e.value.status_code == 400 and fs.get(oauth.OAUTH, "pool-1") is None
discord.token_reply = httpx.Response(200, json={"access_token": "cc-token", "scope": "identify"})
out = await tokens_router.set_oauth_secret("pool-1", tokens_router.OAuthSecret(client_secret=SECRET_POOL),
decoded=ADMIN)
assert out["oauth_enabled"] is True
assert discord.form(1) == {"grant_type": "client_credentials", "scope": "identify"}
assert discord.basic(1) == f"201:{SECRET_POOL}"
assert discord.form(2)["token"] == "cc-token" # the check's token is revoked
assert fs.get(oauth.OAUTH, "pool-1")["client_secret"] == SECRET_POOL
assert "client_secret" not in fs.get("bot_tokens", "pool-1")
listed = await tokens_router.list_tokens(_=ADMIN)
everything = json.dumps([listed, out, fs.data.get("audit_log")]) + caplog.text
assert SECRET_POOL not in everything and "tok-pool-1" not in everything
row = next(t for t in listed if t["token_id"] == "pool-1")
assert row["oauth_enabled"] is True and row["server_count"] == 40 and row["server_cap"] == 90
async def test_unreachable_discord_saves_nothing(fs, discord):
fs.data[oauth.OAUTH].clear()
def _down(request):
raise httpx.ConnectError("down", request=request)
discord.token_reply = _down
with pytest.raises(HTTPException) as e:
await tokens_router.set_oauth_secret("pool-1", tokens_router.OAuthSecret(client_secret=SECRET_POOL),
decoded=ADMIN)
assert e.value.status_code == 503 and fs.get(oauth.OAUTH, "pool-1") is None
async def test_token_list_is_an_allowlist(fs):
fs.put("bot_tokens", "pool-1", {**fs.get("bot_tokens", "pool-1"), "client_secret": "LEAK", "future": "x"})
row = next(t for t in await tokens_router.list_tokens(_=ADMIN) if t["token_id"] == "pool-1")
assert "client_secret" not in row and "future" not in row
assert row["token"] == "•••ol-1" and row["name"] == "DRB Radio 1"
async def test_clear_and_delete_remove_the_secret(fs):
await tokens_router.clear_oauth_secret("pool-1", decoded=ADMIN)
assert fs.get(oauth.OAUTH, "pool-1") is None
await tokens_router.delete_token("own-a", _=ADMIN)
assert fs.get(oauth.OAUTH, "own-a") is None
async def test_malformed_secret_is_refused_without_echo(fs, discord):
fs.data[oauth.OAUTH].clear()
with pytest.raises(HTTPException) as e:
await tokens_router.set_oauth_secret("pool-1", tokens_router.OAuthSecret(client_secret="short-secret"),
decoded=ADMIN)
assert e.value.status_code == 400 and "short-secret" not in str(e.value.detail)
assert discord.calls == [] and fs.get(oauth.OAUTH, "pool-1") is None
+4 -1
View File
@@ -80,7 +80,10 @@ def test_directory_push_keeps_org_link(fs):
assert [c["channel_id"] for c in g["voice_channels"]] == ["1003"] assert [c["channel_id"] for c in g["voice_channels"]] == ["1003"]
def test_directory_bot_removed_hides_guild(fs): def test_directory_bot_removed_hides_guild_without_a_radio_bot(fs):
# server-26#225 S5: the server bot leaving hides a linked server only when
# no radio bot the org may use is in it either.
fs.put("bot_tokens", "t1", {**fs.get("bot_tokens", "t1"), "guild_ids": ["200"]})
run(web.put_guild_directory("100", web.GuildDirectory(present=False), {"service": True})) run(web.put_guild_directory("100", web.GuildDirectory(present=False), {"service": True}))
opts = run(web.join_options("n1", OWNER)) opts = run(web.join_options("n1", OWNER))
assert opts["guilds"] == [] assert opts["guilds"] == []
+220
View File
@@ -0,0 +1,220 @@
"""
HTTP Range on /media/calls/{id}/audio (server-26#233).
The GCS reads are replaced by an in-memory object: ``audio_object_size`` and
``download_audio_range`` are patched where routers/media.py looks them up, and
every span read is recorded so the tests can assert the route never asks GCS
for more than the requested range (plus at most one STREAM_CHUNK_BYTES span).
"""
import time
from unittest.mock import AsyncMock, patch
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.internal import storage
from app.routers import media
app = FastAPI()
app.include_router(media.router)
client = TestClient(app)
CALL_ID = "call-abc"
GCS_URI = "gs://bucket/calls/call-abc.flac"
class FakeObject:
"""A stored object plus a log of every (start, end) span read from it."""
def __init__(self, data: bytes):
self.data = data
self.reads: list[tuple[int, int]] = []
self.size_calls = 0
async def size(self, gcs_uri):
self.size_calls += 1
return len(self.data)
async def read(self, gcs_uri, start, end):
self.reads.append((start, end))
return self.data[start:end + 1]
@pytest.fixture
def signed(monkeypatch):
monkeypatch.setattr(storage.settings, "service_key", "test-key", raising=False)
exp = int(time.time()) + 600
return {"exp": exp, "sig": storage.sign_audio_link(CALL_ID, exp)}
def _run(obj: FakeObject, params, headers=None, doc=None):
doc_get = AsyncMock(return_value=doc if doc is not None else {"call_id": CALL_ID, "audio_gcs_uri": GCS_URI})
with patch.object(media.fstore, "doc_get", doc_get), \
patch.object(media, "audio_object_size", obj.size), \
patch.object(media, "download_audio_range", obj.read):
resp = client.get(f"/media/calls/{CALL_ID}/audio", params=params, headers=headers or {})
return resp, doc_get
def test_bounded_range_returns_206_with_exact_bytes(signed):
obj = FakeObject(bytes(range(256)) * 40) # 10240 bytes
resp, _ = _run(obj, signed, {"Range": "bytes=100-199"})
assert resp.status_code == 206
assert resp.content == obj.data[100:200]
assert resp.headers["content-range"] == f"bytes 100-199/{len(obj.data)}"
assert resp.headers["content-length"] == "100"
assert resp.headers["accept-ranges"] == "bytes"
assert resp.headers["content-type"] == "audio/flac"
assert resp.headers["cache-control"] == "private, max-age=3600"
# Only the requested span was read from GCS — not the whole object.
assert obj.reads == [(100, 199)]
def test_open_ended_range(signed):
obj = FakeObject(b"x" * 5000 + b"tail")
resp, _ = _run(obj, signed, {"Range": "bytes=5000-"})
assert resp.status_code == 206
assert resp.content == b"tail"
assert resp.headers["content-range"] == "bytes 5000-5003/5004"
def test_suffix_range(signed):
obj = FakeObject(b"0123456789")
resp, _ = _run(obj, signed, {"Range": "bytes=-3"})
assert resp.status_code == 206
assert resp.content == b"789"
assert resp.headers["content-range"] == "bytes 7-9/10"
def test_suffix_longer_than_object_returns_whole_object(signed):
obj = FakeObject(b"0123456789")
resp, _ = _run(obj, signed, {"Range": "bytes=-500"})
assert resp.status_code == 206
assert resp.content == obj.data
assert resp.headers["content-range"] == "bytes 0-9/10"
def test_end_past_object_is_clamped(signed):
obj = FakeObject(b"0123456789")
resp, _ = _run(obj, signed, {"Range": "bytes=8-999"})
assert resp.status_code == 206
assert resp.content == b"89"
assert resp.headers["content-range"] == "bytes 8-9/10"
@pytest.mark.parametrize("rng", ["bytes=10-", "bytes=50-60", "bytes=-0"])
def test_unsatisfiable_range_is_416(signed, rng):
obj = FakeObject(b"0123456789")
resp, _ = _run(obj, signed, {"Range": rng})
assert resp.status_code == 416
assert resp.headers["content-range"] == "bytes */10"
assert obj.reads == []
def test_no_range_is_200_full_body_and_advertises_ranges(signed):
obj = FakeObject(b"abcdef" * 100)
resp, _ = _run(obj, signed)
assert resp.status_code == 200
assert resp.content == obj.data
assert resp.headers["accept-ranges"] == "bytes"
assert resp.headers["content-length"] == str(len(obj.data))
assert "content-range" not in resp.headers
@pytest.mark.parametrize("rng", ["bytes=0-1,4-5", "items=0-5", "bytes=5-2", "garbage"])
def test_ignored_range_headers_fall_back_to_200(signed, rng):
obj = FakeObject(b"0123456789")
resp, _ = _run(obj, signed, {"Range": rng})
assert resp.status_code == 200
assert resp.content == obj.data
def test_long_object_is_streamed_in_bounded_spans(signed):
# A ~13 MB "600 s FLAC": a seek near the end reads only from there on, in
# spans no larger than STREAM_CHUNK_BYTES, and the first span is all the
# route waits on before the response starts.
size = 13 * 1024 * 1024
obj = FakeObject(bytes(size))
start = size - 1_500_000
resp, _ = _run(obj, signed, {"Range": f"bytes={start}-"})
assert resp.status_code == 206
assert len(resp.content) == 1_500_000
assert obj.reads[0][0] == start
assert all(e - s + 1 <= media.STREAM_CHUNK_BYTES for s, e in obj.reads)
assert sum(e - s + 1 for s, e in obj.reads) == 1_500_000
def test_bad_signature_is_403_before_any_lookup(signed):
obj = FakeObject(b"0123456789")
resp, doc_get = _run(obj, {"exp": signed["exp"], "sig": "0" * 64}, {"Range": "bytes=0-3"})
assert resp.status_code == 403
doc_get.assert_not_called()
assert obj.size_calls == 0
assert obj.reads == []
def test_expired_link_is_403(signed, monkeypatch):
exp = int(time.time()) - 5
obj = FakeObject(b"0123456789")
resp, doc_get = _run(obj, {"exp": exp, "sig": storage.sign_audio_link(CALL_ID, exp)})
assert resp.status_code == 403
doc_get.assert_not_called()
def test_missing_object_is_404(signed):
obj = FakeObject(b"")
async def no_size(gcs_uri):
return None
with patch.object(media.fstore, "doc_get", AsyncMock(return_value={"call_id": CALL_ID, "audio_gcs_uri": GCS_URI})), \
patch.object(media, "audio_object_size", no_size), \
patch.object(media, "download_audio_range", obj.read):
resp = client.get(f"/media/calls/{CALL_ID}/audio", params=signed)
assert resp.status_code == 404
def test_legacy_mp3_keeps_its_content_type(signed):
obj = FakeObject(b"ID3" + b"\0" * 50)
resp, _ = _run(obj, signed, {"Range": "bytes=0-2"}, doc={"call_id": CALL_ID, "audio_gcs_uri": "gs://b/calls/x.mp3"})
assert resp.status_code == 206
assert resp.headers["content-type"] == "audio/mpeg"
assert resp.content == b"ID3"
# ── storage.py: the ranged reads never pull the whole object ────────────────
def _fake_client(monkeypatch, size=1234, payload=b"span"):
from unittest.mock import MagicMock
client = MagicMock()
blob = client.bucket.return_value.blob.return_value
blob.size = size
blob.download_as_bytes.return_value = payload
monkeypatch.setattr(storage, "_gcs_client_cache", client)
return client, blob
def test_storage_size_reloads_metadata_only(monkeypatch):
import asyncio
client, blob = _fake_client(monkeypatch, size=13_000_000)
assert asyncio.run(storage.audio_object_size(GCS_URI)) == 13_000_000
client.bucket.assert_called_with("bucket")
client.bucket.return_value.blob.assert_called_with("calls/call-abc.flac")
blob.reload.assert_called_once()
blob.download_as_bytes.assert_not_called()
def test_storage_range_read_passes_inclusive_bounds(monkeypatch):
import asyncio
_, blob = _fake_client(monkeypatch)
assert asyncio.run(storage.download_audio_range(GCS_URI, 100, 199)) == b"span"
blob.download_as_bytes.assert_called_once_with(start=100, end=199)
def test_storage_range_read_failure_returns_none(monkeypatch):
import asyncio
_, blob = _fake_client(monkeypatch)
blob.download_as_bytes.side_effect = RuntimeError("gcs down")
assert asyncio.run(storage.download_audio_range(GCS_URI, 0, 10)) is None
blob.reload.side_effect = RuntimeError("404")
assert asyncio.run(storage.audio_object_size(GCS_URI)) is None
+329
View File
@@ -0,0 +1,329 @@
"""Power-outage providers (server-26#184): registry, licence gate, admin routes, ODIN adapter.
What is held here (mirrors tests/test_camera_providers.py):
* every provider defaults OFF, and an off provider is never fetched;
* stubs can never be enabled (409);
* a gated licence needs ``acknowledge_licence``, which is stored with who/when
and audited -- and an audit failure refuses the enable (fail closed);
* an acknowledgement given for a different licence status no longer counts;
* the admin routes are admin-only;
* the ODIN parser reads the recorded public payloads, and an all-unparseable
payload is an error, not an empty layer;
* an area no serving provider covers says so instead of returning a bare [].
"""
import asyncio
import json
from datetime import datetime, timezone
from pathlib import Path
from unittest.mock import AsyncMock, patch
import pytest
from fastapi import HTTPException
from fastapi.testclient import TestClient
from app.internal.auth import require_admin_token, require_service_or_firebase_token
from app.internal.outages import base, providers, registry
from app.internal.outages.base import Licence
from app.main import app
client = TestClient(app)
ADMIN = {"uid": "admin-1", "email": "admin@example.com", "role": "admin"}
FIXTURE = json.loads((Path(__file__).parent / "fixtures" / "odin_public_2026-10-05.json").read_text())
STUBS = ("poweroutage_us", "coned_oru_stormcenter", "nyseg_rge_stormcenter", "central_hudson_stormcenter", "eagle_i_live")
NY_BBOX = (42.5, -78.5, 43.5, -75.5) # Rochester .. Syracuse
RECORDED_AT = datetime(2026, 10, 5, 6, 45, tzinfo=timezone.utc) # when the fixture was captured
class FakeStore:
def __init__(self):
self.docs = {}
self.audit = []
async def doc_get(self, col, doc):
return self.docs.get((col, doc))
async def doc_set(self, col, doc, data, merge=True):
cur = self.docs.setdefault((col, doc), {})
if col == "audit_log":
self.audit.append(data)
return
for k, v in data.items(): # one-level deep merge is all registry needs
if merge and isinstance(v, dict) and isinstance(cur.get(k), dict):
cur[k] = {**cur[k], **v}
else:
cur[k] = v
@pytest.fixture(autouse=True)
def store():
s = FakeStore()
registry.invalidate_cache()
for feed in registry.FEEDS.values():
feed.items, feed.fetched_at, feed.error, feed._next_attempt = [], None, None, 0.0
with patch.object(registry.fstore, "doc_get", s.doc_get), patch.object(registry.fstore, "doc_set", s.doc_set), \
patch("app.internal.audit.fstore.doc_set", s.doc_set):
yield s
registry.invalidate_cache()
app.dependency_overrides.clear()
def run(coro):
return asyncio.run(coro)
def _odin_areas():
return providers.parse_odin(FIXTURE["odi_format_json"], FIXTURE["odi_map_scale_county"], FIXTURE["odi_status"],
now=RECORDED_AT)[0]
# ── registry / gate ──────────────────────────────────────────────────────────
def test_every_catalogued_source_is_registered_and_defaults_off():
listed = run(registry.list_providers())
assert {p["id"] for p in listed} == {"odin", *STUBS}
assert all(p["enabled"] is False and p["serving"] is False for p in listed)
assert all(p["licence"]["label"] for p in listed)
odin = next(p for p in listed if p["id"] == "odin")
assert odin["licence"]["status"] == "unverified" and odin["licence"]["gated"] is True
assert odin["licence"]["terms_url"] == "https://odin.ornl.gov/v3/api-docs"
assert odin["not_serving_reason"] == "disabled (default)"
def test_catalogued_licence_statuses_match_the_source_terms():
lic = {pid: p.meta.licence.status for pid, p in registry.PROVIDERS.items()}
assert lic["poweroutage_us"] == Licence.PAID
for pid in ("coned_oru_stormcenter", "nyseg_rge_stormcenter", "central_hudson_stormcenter", "eagle_i_live"):
assert lic[pid] == Licence.AGREEMENT_REQUIRED, pid
assert all(not registry.PROVIDERS[pid].meta.implemented for pid in STUBS)
def test_warning_is_worded_for_outage_data():
w = registry.PROVIDERS["odin"].meta.licence.warning()
assert "outage data" in w and "imagery" not in w
def test_off_provider_is_never_fetched():
fetch = AsyncMock(return_value=[])
with patch.object(registry.PROVIDERS["odin"], "fetch", fetch):
out = run(registry.outages_in_bbox(NY_BBOX))
assert out["areas"] == [] and out["providers"] == [] and fetch.await_count == 0
assert out["serving_count"] == 0 and out["status"] == "none_enabled"
def test_stubs_cannot_be_enabled():
for pid in STUBS:
with pytest.raises(registry.ProviderError) as e:
run(registry.set_enabled(pid, True, ("u", "e"), acknowledge_licence=True))
assert e.value.code == 409, pid
def test_gated_licence_requires_acknowledgement(store):
with pytest.raises(registry.ProviderError) as e:
run(registry.set_enabled("odin", True, ("u1", "a@x")))
assert e.value.code == 409 and e.value.extra["requires_acknowledgement"] is True
assert "Licence unverified" in e.value.extra["warning"]
assert not store.audit and not run(registry.is_serving("odin"))
out = run(registry.set_enabled("odin", True, ("u1", "a@x"), acknowledge_licence=True))
assert out["serving"] is True
ack = out["acknowledgement"]
assert ack["by_uid"] == "u1" and ack["by_email"] == "a@x" and ack["at"]
assert ack["licence_status"] == Licence.UNVERIFIED.value
assert store.audit[-1]["action"] == "outage_provider.enable_acknowledged"
assert store.audit[-1]["details"]["provider_id"] == "odin"
assert store.docs[("config", "outage_providers")]["providers"]["odin"]["enabled"] is True
def test_gated_enable_refused_when_audit_cannot_be_written(store):
with patch.object(registry, "_audit", AsyncMock(side_effect=RuntimeError("firestore down"))):
with pytest.raises(registry.ProviderError) as e:
run(registry.set_enabled("odin", True, ("u", "e"), acknowledge_licence=True))
assert e.value.code == 503
assert not run(registry.is_serving("odin"))
assert ("config", "outage_providers") not in store.docs
def test_disable_clears_acknowledgement_and_is_audited(store):
run(registry.set_enabled("odin", True, ("u", "e"), acknowledge_licence=True))
out = run(registry.set_enabled("odin", False, ("u", "e")))
assert out["serving"] is False and out["acknowledgement"] is None
assert store.audit[-1]["action"] == "outage_provider.disable"
def test_stale_acknowledgement_stops_serving():
p = registry.PROVIDERS["odin"]
entry = {"enabled": True, "acknowledgement": {"licence_status": "paid"}}
assert registry.effective(p, entry) == (False, "licence acknowledgement missing or out of date")
entry["acknowledgement"]["licence_status"] = p.meta.licence.status.value
assert registry.effective(p, entry) == (True, None)
def test_unreadable_settings_fail_closed():
with patch.object(registry.fstore, "doc_get", AsyncMock(side_effect=RuntimeError("boom"))):
registry.invalidate_cache()
assert run(registry.get_settings()) == {}
assert not run(registry.is_serving("odin"))
# ── bbox query ───────────────────────────────────────────────────────────────
def test_query_returns_areas_in_bbox_with_credit(store):
run(registry.set_enabled("odin", True, ("u", "e"), acknowledge_licence=True))
with patch.object(registry.PROVIDERS["odin"], "fetch", AsyncMock(return_value=_odin_areas())):
out = run(registry.outages_in_bbox(NY_BBOX))
assert out["status"] == "ok" and out["serving_count"] == 1
assert {a["fips"] for a in out["areas"]} == {"36055", "36067"} # not Albany, not King County WA
prov = out["providers"][0]
assert prov["attribution"].startswith("Outage data: ODIN") and prov["disclaimer"]
assert prov["total_in_bbox"] == 2 and prov["fetched_at"] and prov["error"] is None
assert "Only utilities that report to ODIN appear" in prov["coverage_note"]
def test_area_no_serving_provider_covers_says_so(store):
run(registry.set_enabled("odin", True, ("u", "e"), acknowledge_licence=True))
fetch = AsyncMock(return_value=_odin_areas())
with patch.object(registry.PROVIDERS["odin"], "fetch", fetch):
out = run(registry.outages_in_bbox((51.4, -0.2, 51.6, 0.1))) # London
assert fetch.await_count == 0 # outside ODIN coverage -> never fetched
assert out["areas"] == [] and out["status"] == "no_source"
assert out["message"] == "No licensed outage source covers this area."
def test_dead_feed_is_an_error_not_an_empty_layer(store):
run(registry.set_enabled("odin", True, ("u", "e"), acknowledge_licence=True))
with patch.object(registry.PROVIDERS["odin"], "fetch", AsyncMock(side_effect=ValueError("schema change?"))):
out = run(registry.outages_in_bbox(NY_BBOX))
assert out["status"] == "error" and "schema change" in out["providers"][0]["error"]
def test_failed_refresh_keeps_last_good_feed():
feed = registry.FEEDS["odin"]
with patch.object(feed.provider, "fetch", AsyncMock(return_value=_odin_areas())):
run(feed.get())
feed._next_attempt = 0.0
with patch.object(feed.provider, "fetch", AsyncMock(side_effect=RuntimeError("down"))):
run(feed.get())
assert len(feed.items) == 4 and "down" in feed.error and feed.fetched_at
# ── admin routes ─────────────────────────────────────────────────────────────
def test_admin_routes_require_admin():
def deny():
raise HTTPException(403, "Admin access required")
app.dependency_overrides[require_admin_token] = deny
assert client.get("/admin/outage-providers").status_code == 403
assert client.put("/admin/outage-providers/odin", json={"enabled": True, "acknowledge_licence": True}).status_code == 403
def test_admin_route_toggle_and_gate(store):
app.dependency_overrides[require_admin_token] = lambda: ADMIN
r = client.put("/admin/outage-providers/odin", json={"enabled": True})
assert r.status_code == 409 and r.json()["requires_acknowledgement"] is True
assert r.json()["licence"]["status"] == "unverified"
r = client.put("/admin/outage-providers/odin", json={"enabled": True, "acknowledge_licence": True})
assert r.status_code == 200 and r.json()["acknowledgement"]["by_email"] == "admin@example.com"
assert client.put("/admin/outage-providers/poweroutage_us",
json={"enabled": True, "acknowledge_licence": True}).status_code == 409
assert client.put("/admin/outage-providers/nope", json={"enabled": True}).status_code == 404
listed = client.get("/admin/outage-providers").json()["providers"]
assert next(p for p in listed if p["id"] == "odin")["serving"] is True
def test_outages_route_rejects_inverted_bbox_and_reports_status():
app.dependency_overrides[require_service_or_firebase_token] = lambda: {"uid": "v"}
assert client.get("/outages", params={"south": 2, "west": 0, "north": 1, "east": 1}).status_code == 400
r = client.get("/outages", params={"south": 40.9, "west": -74.0, "north": 41.4, "east": -73.4})
assert r.status_code == 200 and r.json()["areas"] == [] and r.json()["status"] == "none_enabled"
def test_outages_route_requires_sign_in():
def deny():
raise HTTPException(401, "Not signed in")
app.dependency_overrides[require_service_or_firebase_token] = deny
assert client.get("/outages", params={"south": 40, "west": -74, "north": 41, "east": -73}).status_code == 401
# ── ODIN adapter (recorded public payloads, 2026-10-05) ──────────────────────
def test_odin_parser_reads_recorded_payload():
areas, info = providers.parse_odin(FIXTURE["odi_format_json"], FIXTURE["odi_map_scale_county"], FIXTURE["odi_status"],
now=RECORDED_AT)
by = {a["fips"]: a for a in areas}
assert set(by) == {"36001", "36067", "36055", "53033"}
onondaga = by["36067"]
assert onondaga["id"] == "odin:36067" and onondaga["provider"] == "odin"
assert onondaga["county"] == "Onondaga" and onondaga["state"] == "New York"
assert onondaga["customers_out"] == 11 and onondaga["customers_served"] is None
assert onondaga["etr"] == "2026-10-05T08:00:00Z" and onondaga["since"] == "2026-10-04T18:35:40Z"
assert onondaga["updated_at"].startswith("2026-10-05T06:20:47")
assert onondaga["utilities"] == [{"name": "NATIONAL GRID", "eia_id": "13573", "customers_out": 11,
"stale_customers_out": 0}]
assert onondaga["stale"] is False and onondaga["stale_customers_out"] == 0
s, w, n, e = onondaga["bbox"]
assert s < onondaga["lat"] < n and w < onondaga["lon"] < e
assert 42.7 < onondaga["lat"] < 43.3 and -76.6 < onondaga["lon"] < -75.8
# King County WA: several outage events from two utilities, summed per county
king = by["53033"]
assert king["customers_out"] == sum(r["metersOut"] for r in FIXTURE["odi_map_scale_county"] if r["id"] == "53033")
assert len(king["utilities"]) == 2
# Puget Sound Energy's "0001-01-01T00:00:00Z" means no ETR -- not a restoration time, not stale.
assert king["etr"] is None and king["stale_customers_out"] == 0
assert info["reporting_utilities"] == len(FIXTURE["odi_status"]) and info["utilities_with_error"] == 1
def test_odin_events_long_past_their_etr_are_stale_not_live():
# ODIN still listed Avangrid's Monroe County row from April 2026 (ETR
# 2026-04-10) on 2026-10-05. It must not read as 18 customers out right now.
by = {a["fips"]: a for a in _odin_areas()}
monroe = by["36055"]
assert monroe["customers_out"] == 0 and monroe["stale_customers_out"] == 18 and monroe["stale"] is True
assert monroe["since"].startswith("2026-04-10") # still shown, so the stale row is explainable
# Same payload, captured the day the outage was live: not stale.
live = {a["fips"]: a for a in providers.parse_odin(
FIXTURE["odi_format_json"], FIXTURE["odi_map_scale_county"], FIXTURE["odi_status"],
now=datetime(2026, 4, 10, 20, 0, tzinfo=timezone.utc))[0]}
assert live["36055"]["customers_out"] == 18 and live["36055"]["stale"] is False
# Live counties sort ahead of stale ones.
assert [a["stale"] for a in _odin_areas()] == sorted(a["stale"] for a in _odin_areas())
def test_odin_fetch_uses_the_three_public_endpoints():
calls = []
async def fake_get_json(url, params=None, headers=None):
calls.append((url, params))
return {"https://odin.ornl.gov/odi": FIXTURE["odi_format_json"],
"https://odin.ornl.gov/odi/map": FIXTURE["odi_map_scale_county"],
"https://odin.ornl.gov/odi/status": FIXTURE["odi_status"]}[url]
p = providers.OdinProvider()
with patch.object(providers, "get_json", fake_get_json):
areas = run(p.fetch())
assert len(areas) == 4
assert ("https://odin.ornl.gov/odi", {"format": "JSON"}) in calls
assert ("https://odin.ornl.gov/odi/map", {"scale": "COUNTY"}) in calls
assert p.reporting_utilities == len(FIXTURE["odi_status"])
assert "utilities reporting nationwide" in p.coverage_note()
def test_odin_schema_canary_trips_on_unparseable_payload():
odi, cmap, status = FIXTURE["odi_format_json"], FIXTURE["odi_map_scale_county"], FIXTURE["odi_status"]
renamed_odi = {"outage": [{"county": "36067", "count": 11}]} # fields renamed upstream
with pytest.raises(ValueError, match="schema"):
providers.parse_odin(renamed_odi, cmap, status)
with pytest.raises(ValueError, match="schema"):
providers.parse_odin(odi, [{"fips": "36067"}], status)
with pytest.raises(ValueError, match="schema"):
providers.parse_odin(odi, cmap, [{"utility": "x"}])
with pytest.raises(ValueError, match="schema"):
providers.parse_odin([], cmap, status) # wrapper changed
# A genuinely quiet day is NOT an error: nothing upstream, nothing parsed.
assert providers.parse_odin({"outage": []}, [], status)[0] == []
def test_area_in_bbox_uses_county_extent():
a = base.make_area("odin", "36067", county="Onondaga", state="New York", customers_out=1,
lat=43.0, lon=-76.2, bbox=(42.77, -76.5, 43.27, -75.9))
assert base.area_in_bbox(a, (43.2, -76.0, 43.5, -75.5)) # overlaps the county's corner only
assert not base.area_in_bbox(a, (44.0, -76.0, 44.5, -75.5))
+6 -3
View File
@@ -4,6 +4,7 @@ import { useAuth } from "@/components/AuthProvider";
import { c2api } from "@/lib/c2api"; import { c2api } from "@/lib/c2api";
import { ReplayTab } from "@/components/admin/ReplayTab"; import { ReplayTab } from "@/components/admin/ReplayTab";
import { CameraProvidersTab } from "@/components/admin/CameraProvidersTab"; import { CameraProvidersTab } from "@/components/admin/CameraProvidersTab";
import { OutageProvidersTab } from "@/components/admin/OutageProvidersTab";
import { useEffect, useState, useRef, useCallback } from "react"; import { useEffect, useState, useRef, useCallback } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import type { UserRecord, AuditEntry, UserRole, CallRecord } from "@/lib/types"; import type { UserRecord, AuditEntry, UserRole, CallRecord } from "@/lib/types";
@@ -1268,11 +1269,12 @@ function SttEvalTab() {
// Main admin page // Main admin page
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
type AdminTab = "features" | "cameras" | "correlation" | "replay" | "users" | "audit" | "calls" | "eval"; type AdminTab = "features" | "cameras" | "outages" | "correlation" | "replay" | "users" | "audit" | "calls" | "eval";
const TAB_LABELS: { key: AdminTab; label: string }[] = [ const TAB_LABELS: { key: AdminTab; label: string }[] = [
{ key: "features", label: "AI Features" }, { key: "features", label: "AI Features" },
{ key: "cameras", label: "Camera providers" }, { key: "cameras", label: "Camera providers" },
{ key: "outages", label: "Outage providers" },
{ key: "correlation", label: "Correlation Debug" }, { key: "correlation", label: "Correlation Debug" },
{ key: "replay", label: "Replay" }, { key: "replay", label: "Replay" },
{ key: "calls", label: "Calls" }, { key: "calls", label: "Calls" },
@@ -1285,7 +1287,7 @@ export default function AdminPage() {
const { user, isAdmin, loading: authLoading } = useAuth(); const { user, isAdmin, loading: authLoading } = useAuth();
const router = useRouter(); const router = useRouter();
const [tab, setTab] = useState<AdminTab>("features"); const [tab, setTab] = useState<AdminTab>("features");
// Deep link to a tab (/admin#cameras), e.g. from the live wall's empty state. // Deep link to a tab (/admin#cameras, /admin#outages), e.g. from a map layer's empty state.
useEffect(() => { useEffect(() => {
const h = window.location.hash.slice(1); const h = window.location.hash.slice(1);
if (TAB_LABELS.some((t) => t.key === h)) setTab(h as AdminTab); if (TAB_LABELS.some((t) => t.key === h)) setTab(h as AdminTab);
@@ -1304,7 +1306,7 @@ export default function AdminPage() {
if (!isAdmin) return null; if (!isAdmin) return null;
// Users/Audit tabs benefit from full width; everything else is narrow // Users/Audit tabs benefit from full width; everything else is narrow
const wide = tab === "users" || tab === "audit" || tab === "replay" || tab === "cameras"; const wide = tab === "users" || tab === "audit" || tab === "replay" || tab === "cameras" || tab === "outages";
return ( return (
<div className={`space-y-6 ${wide ? "" : "max-w-2xl"}`}> <div className={`space-y-6 ${wide ? "" : "max-w-2xl"}`}>
@@ -1326,6 +1328,7 @@ export default function AdminPage() {
{tab === "features" && <FeaturesTab />} {tab === "features" && <FeaturesTab />}
{tab === "cameras" && <CameraProvidersTab />} {tab === "cameras" && <CameraProvidersTab />}
{tab === "outages" && <OutageProvidersTab />}
{tab === "correlation" && <CorrelationDebugTab />} {tab === "correlation" && <CorrelationDebugTab />}
{tab === "replay" && <ReplayTab />} {tab === "replay" && <ReplayTab />}
{tab === "calls" && <StaleCallsTab />} {tab === "calls" && <StaleCallsTab />}
+4 -94
View File
@@ -8,105 +8,15 @@
* memorable thing per §2.4: "where was he when he said that" is answered * memorable thing per §2.4: "where was he when he said that" is answered
* by looking, not cross-referencing timestamps. * by looking, not cross-referencing timestamps.
*/ */
import { useEffect, useRef, useState } from "react"; import { useState } from "react";
import type { CallRecord } from "@/lib/types"; import type { CallRecord } from "@/lib/types";
import { c2api } from "@/lib/c2api"; import { InlinePlayer } from "@/components/InlinePlayer";
import { hasAudio as callHasAudio } from "@/lib/audioQueue";
import { isKnownSeverity, SEVERITY_COLORS } from "@/lib/severity"; import { isKnownSeverity, SEVERITY_COLORS } from "@/lib/severity";
function fmtTime(iso: string): string { function fmtTime(iso: string): string {
return new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }); return new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
} }
function fmtClock(s: number): string {
if (!Number.isFinite(s)) return "0:00";
const m = Math.floor(s / 60);
const r = Math.floor(s % 60);
return `${m}:${r.toString().padStart(2, "0")}`;
}
function InlinePlayer({ callId }: { callId: string }) {
const [url, setUrl] = useState<string | null>(null);
const [error, setError] = useState(false);
const [loading, setLoading] = useState(false);
const [playing, setPlaying] = useState(false);
const [current, setCurrent] = useState(0);
const [duration, setDuration] = useState(0);
const audioRef = useRef<HTMLAudioElement | null>(null);
async function ensureUrl() {
if (url || loading) return;
setLoading(true);
try {
const full = await c2api.getCall(callId);
setUrl(full.audio_url ?? null);
if (!full.audio_url) setError(true);
} catch {
setError(true);
} finally {
setLoading(false);
}
}
async function toggle() {
if (!url) {
await ensureUrl();
return;
}
const el = audioRef.current;
if (!el) return;
if (playing) el.pause();
else el.play();
}
// Once the URL lands, autoplay (the click that fetched it was the play intent).
useEffect(() => {
if (url && audioRef.current) audioRef.current.play().catch(() => {});
}, [url]);
if (error) {
return <span className="text-xs text-sev-major">Audio unavailable</span>;
}
return (
<div className="flex items-center gap-2 w-full max-w-xs" onClick={(e) => e.stopPropagation()}>
<button
onClick={toggle}
disabled={loading}
className="w-6 h-6 shrink-0 rounded-full bg-accent text-white flex items-center justify-center text-[10px] disabled:opacity-50"
title={playing ? "Pause" : "Play"}
>
{loading ? "…" : playing ? "❚❚" : "▶"}
</button>
<input
type="range"
min={0}
max={duration || 0}
value={current}
onChange={(e) => {
const t = Number(e.target.value);
if (audioRef.current) audioRef.current.currentTime = t;
setCurrent(t);
}}
className="flex-1 h-1 accent-accent"
disabled={!url}
/>
<span className="text-[10px] font-mono text-ink-muted tabular-nums shrink-0">
{fmtClock(current)}
</span>
{url && (
<audio
ref={audioRef}
src={url}
onPlay={() => setPlaying(true)}
onPause={() => setPlaying(false)}
onEnded={() => setPlaying(false)}
onTimeUpdate={(e) => setCurrent(e.currentTarget.currentTime)}
onLoadedMetadata={(e) => setDuration(e.currentTarget.duration)}
className="hidden"
/>
)}
</div>
);
}
function StopMarker({ stopNumber, color }: { stopNumber?: number; color: string }) { function StopMarker({ stopNumber, color }: { stopNumber?: number; color: string }) {
if (!stopNumber) { if (!stopNumber) {
@@ -132,7 +42,7 @@ export function CallSpineEntry({
isAdmin?: boolean; isAdmin?: boolean;
}) { }) {
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
const hasAudio = !!(call.audio_gcs_uri || call.audio_url); const hasAudio = callHasAudio(call);
const transcript = call.transcript_corrected || call.transcript; const transcript = call.transcript_corrected || call.transcript;
const color = isKnownSeverity(call.severity) ? SEVERITY_COLORS[call.severity] : "var(--ink-muted)"; const color = isKnownSeverity(call.severity) ? SEVERITY_COLORS[call.severity] : "var(--ink-muted)";
const substantive = !!transcript || (call.units && call.units.length > 0) || !!call.location_coords; const substantive = !!transcript || (call.units && call.units.length > 0) || !!call.location_coords;
+89
View File
@@ -0,0 +1,89 @@
"use client";
/**
* Click-to-listen control for one call — server-26#233. Moved out of
* CallSpineEntry.tsx so the live wall's spine, the map's path-stop popups and
* rail cards share it. It no longer owns an <audio> element: playback goes
* through lib/audioQueue.ts's single element, so starting a call anywhere
* stops whatever else was playing, and the map can ring the stop that's
* speaking.
*
* Every hook runs before any early return (the server-26#109 item 2 bug was a
* return between hooks). Nothing plays until the button is clicked.
*/
import { useAuth } from "@/components/AuthProvider";
import { canPlayAudio, seek, toggle, useAudio } from "@/lib/audioQueue";
function fmtClock(s: number): string {
if (!Number.isFinite(s)) return "0:00";
const m = Math.floor(s / 60);
const r = Math.floor(s % 60);
return `${m}:${r.toString().padStart(2, "0")}`;
}
/** Just the round play/pause button — for map popups, rail cards, spine rows. */
export function PlayButton({ callId, label, className = "" }: { callId: string; label?: string; className?: string }) {
const auth = useAuth();
const active = useAudio((s) => s.playingCallId === callId);
const status = useAudio((s) => (s.playingCallId === callId ? s.status : "idle"));
const failed = useAudio((s) => s.errorCallId === callId && s.playingCallId !== callId);
if (!canPlayAudio(auth)) return null;
const playing = active && status === "playing";
const loading = active && status === "loading";
return (
<button
type="button"
onClick={(e) => { e.stopPropagation(); toggle(callId); }}
disabled={loading}
title={failed ? "Audio unavailable — click to retry" : playing ? "Pause" : label ?? "Play this call"}
aria-label={playing ? "Pause call audio" : label ?? "Play call audio"}
aria-pressed={playing}
className={`w-6 h-6 shrink-0 rounded-full flex items-center justify-center text-[10px] disabled:opacity-50 ${failed ? "bg-raised text-sev-major border border-sev-major" : "bg-accent text-white"} ${className}`}
>
{loading ? "…" : playing ? "❚❚" : failed ? "!" : "▶"}
</button>
);
}
/**
* Button + seek bar + clock. `compact` shows only the button until this call
* is the one playing, then expands — for dense lists like the wall's spine.
*/
export function InlinePlayer({ callId, compact = false }: { callId: string; compact?: boolean }) {
const auth = useAuth();
const active = useAudio((s) => s.playingCallId === callId);
const current = useAudio((s) => (s.playingCallId === callId ? s.current : 0));
const duration = useAudio((s) => (s.playingCallId === callId ? s.duration : 0));
const failed = useAudio((s) => s.errorCallId === callId && s.playingCallId !== callId);
if (!canPlayAudio(auth)) return null;
if (failed && !compact) {
return (
<div className="flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
<PlayButton callId={callId} />
<span className="text-xs text-sev-major">Audio unavailable</span>
</div>
);
}
if (compact && !active) return <PlayButton callId={callId} />;
return (
<div className="flex items-center gap-2 w-full max-w-xs" onClick={(e) => e.stopPropagation()}>
<PlayButton callId={callId} />
<input
type="range"
min={0}
max={duration || 0}
step="any"
value={Math.min(current, duration || 0)}
onChange={(e) => seek(Number(e.target.value))}
className="flex-1 h-1 accent-accent"
disabled={!active || !duration}
aria-label="Seek"
/>
<span className="text-[10px] font-mono text-ink-muted tabular-nums shrink-0">
{fmtClock(current)}{duration ? ` / ${fmtClock(duration)}` : ""}
</span>
</div>
);
}
+26 -12
View File
@@ -27,6 +27,8 @@ import { TimeScrubber } from "@/components/TimeScrubber";
import { TranscriptTicker } from "@/components/live/TranscriptTicker"; import { TranscriptTicker } from "@/components/live/TranscriptTicker";
import { CameraDrill } from "@/components/live/CameraDrill"; import { CameraDrill } from "@/components/live/CameraDrill";
import { IncidentPanel, RegionPanel } from "@/components/live/WallPanels"; import { IncidentPanel, RegionPanel } from "@/components/live/WallPanels";
import { TruthStrip } from "@/components/live/TruthStrip";
import { usePipelineHealth } from "@/lib/usePipelineHealth";
const MapView = dynamic(() => import("@/components/MapView"), { ssr: false }); const MapView = dynamic(() => import("@/components/MapView"), { ssr: false });
@@ -93,12 +95,11 @@ export function LiveView() {
const { calls: recentCalls, loading: callsLoading } = useCalls(500); const { calls: recentCalls, loading: callsLoading } = useCalls(500);
const { aircraft } = useAircraft(); const { aircraft } = useAircraft();
const { vessels } = useVessels(); const { vessels } = useVessels();
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
const [clock, setClock] = useState<string>(""); const [clock, setClock] = useState<string>("");
useEffect(() => { // No heartbeat-driven "last updated" any more (server-26#232): a node
if (!nodesLoading) setLastUpdated(new Date()); // check-in kept '● Live' fresh while STT/correlation were dead (#196). The
}, [nodes, activeCalls, activeIncidents, nodesLoading]); // wall's liveness now comes from usePipelineHealth, keyed to pipeline output.
useEffect(() => { useEffect(() => {
const tick = () => setClock(new Date().toLocaleTimeString([], { hour12: false })); const tick = () => setClock(new Date().toLocaleTimeString([], { hour12: false }));
tick(); tick();
@@ -156,6 +157,13 @@ export function LiveView() {
}, [pointsById, cams.cameras]); }, [pointsById, cams.cameras]);
const camCountByIncident = useMemo(() => new Map(Array.from(nearById.entries()).map(([id, n]) => [id, n.length])), [nearById]); const camCountByIncident = useMemo(() => new Map(Array.from(nearById.entries()).map(([id, n]) => [id, n.length])), [nearById]);
const events = useRegionEvents(regionBox); const events = useRegionEvents(regionBox);
const health = usePipelineHealth({
calls: recentCalls,
incidents: activeIncidents,
nodes,
eventsStatus: events.events_status,
cameraProviders: cams.providers,
});
const onAir = useMemo(() => onAirIncidentIds(activeIncidents, activeCalls), [activeIncidents, activeCalls]); const onAir = useMemo(() => onAirIncidentIds(activeIncidents, activeCalls), [activeIncidents, activeCalls]);
const selected = link.inc ? activeIncidents.find((i) => i.incident_id === link.inc) ?? null : null; const selected = link.inc ? activeIncidents.find((i) => i.incident_id === link.inc) ?? null : null;
@@ -220,13 +228,14 @@ export function LiveView() {
// ── KPIs ────────────────────────────────────────────────────────────────── // ── KPIs ──────────────────────────────────────────────────────────────────
const count = (sev: string) => activeIncidents.filter((i) => i.severity === sev).length; const count = (sev: string) => activeIncidents.filter((i) => i.severity === sev).length;
const nodesUp = nodes.filter((n) => n.status !== "offline").length; // "Nodes hearing": a call in the last N min, not merely not-offline — a node
// that checks in but hears nothing is not contributing to the picture.
const nodesHearing = health.nodesHearing;
const eventsOff = !!events.events_status?.disabled; const eventsOff = !!events.events_status?.disabled;
const configuredButQuiet = !nodesLoading && nodes.length > 0 && activeIncidents.length === 0; // "Listening" only when every stage is healthy; otherwise the amber bar says why.
const mostRecentSeen = configuredButQuiet const configuredButQuiet = !nodesLoading && !callsLoading && nodes.length > 0
? nodes.map((n) => n.last_seen).filter((s): s is string => !!s).sort().at(-1) && activeIncidents.length === 0 && !health.degraded;
: null;
const crumb = (label: string, active: boolean, enabled: boolean, onClick: () => void) => ( const crumb = (label: string, active: boolean, enabled: boolean, onClick: () => void) => (
<button type="button" disabled={!enabled} onClick={onClick} aria-current={active ? "true" : undefined} <button type="button" disabled={!enabled} onClick={onClick} aria-current={active ? "true" : undefined}
@@ -251,13 +260,14 @@ export function LiveView() {
<Kpi value={count("major")} label="major" /> <Kpi value={count("major")} label="major" />
<Kpi value={count("moderate")} label="moderate" /> <Kpi value={count("moderate")} label="moderate" />
<Kpi value={activeIncidents.length} label="active" /> <Kpi value={activeIncidents.length} label="active" />
<Kpi value={`${nodesUp}/${nodes.length}`} label="nodes" /> <Kpi value={`${nodesHearing}/${nodes.length}`} label={`nodes hearing (${health.staleMin}m)`} />
<Kpi value={cams.cameras.length} label="cams" /> <Kpi value={cams.cameras.length} label="cams" />
<Kpi value={aircraft.length} label="aircraft" /> <Kpi value={aircraft.length} label="aircraft" />
<Kpi value={vessels.length} label="vessels" /> <Kpi value={vessels.length} label="vessels" />
<Kpi value={eventsOff ? "—" : events.events?.length ?? 0} label="road events" /> <Kpi value={eventsOff ? "—" : events.events?.length ?? 0} label="road events" />
</div> </div>
<div className="ml-auto flex items-center gap-3"> <div className="ml-auto flex items-center gap-3">
<TruthStrip health={health} placement="chips" />
<button type="button" aria-pressed={link.tour} onClick={() => setLink({ ...link, cam: null, tour: !link.tour })} <button type="button" aria-pressed={link.tour} onClick={() => setLink({ ...link, cam: null, tour: !link.tour })}
title="Wall mode: cycle through active incidents, worst first" title="Wall mode: cycle through active incidents, worst first"
className={`px-2 py-0.5 rounded border ${link.tour ? "bg-accent text-white border-accent" : "border-line text-ink-2 hover:text-ink"}`}> className={`px-2 py-0.5 rounded border ${link.tour ? "bg-accent text-white border-accent" : "border-line text-ink-2 hover:text-ink"}`}>
@@ -271,9 +281,13 @@ export function LiveView() {
<span className="font-mono text-ink-2 tabular-nums">{clock}</span> <span className="font-mono text-ink-2 tabular-nums">{clock}</span>
</div> </div>
</div> </div>
<TruthStrip health={health} placement="bar" />
<div className="flex-1 min-h-0 flex flex-col lg:flex-row"> <div className="flex-1 min-h-0 flex flex-col lg:flex-row">
<div className="relative h-[55vh] lg:h-auto lg:flex-1 min-w-0 shrink-0"> <div className="relative h-[55vh] lg:h-auto lg:flex-1 min-w-0 shrink-0">
{/* Degraded → grey the map and its incident rail (the rail lives inside
MapView, so it is wrapped here rather than edited). */}
<div className={`w-full h-full transition-[filter,opacity] ${health.degraded ? "grayscale opacity-60" : ""}`}>
{nodesLoading || callsLoading ? ( {nodesLoading || callsLoading ? (
<div className="w-full h-full flex items-center justify-center text-ink-muted text-sm">Loading map…</div> <div className="w-full h-full flex items-center justify-center text-ink-muted text-sm">Loading map…</div>
) : ( ) : (
@@ -282,7 +296,6 @@ export function LiveView() {
activeCalls={activeCalls} activeCalls={activeCalls}
incidents={activeIncidents} incidents={activeIncidents}
calls={recentCalls} calls={recentCalls}
lastUpdated={lastUpdated}
selectedIncidentId={selected?.incident_id ?? null} selectedIncidentId={selected?.incident_id ?? null}
onSelectIncident={selectIncident} onSelectIncident={selectIncident}
nearCameras={near} nearCameras={near}
@@ -291,13 +304,14 @@ export function LiveView() {
camCountByIncident={camCountByIncident} camCountByIncident={camCountByIncident}
/> />
)} )}
</div>
{/* Configured-and-quiet — distinct from "no nodes at all" (chunk 10's {/* Configured-and-quiet — distinct from "no nodes at all" (chunk 10's
Activation screen handles the zero-node case). */} Activation screen handles the zero-node case). */}
{configuredButQuiet && ( {configuredButQuiet && (
<div className="absolute top-12 left-1/2 -translate-x-1/2 z-[1001] pointer-events-none"> <div className="absolute top-12 left-1/2 -translate-x-1/2 z-[1001] pointer-events-none">
<span className="bg-surface/90 border border-line rounded-full px-3 py-1 text-xs text-ink-2 whitespace-nowrap"> <span className="bg-surface/90 border border-line rounded-full px-3 py-1 text-xs text-ink-2 whitespace-nowrap">
● Listening{mostRecentSeen ? ` — last check-in ${timeAgo(new Date(mostRecentSeen))}` : ""} ● Listening{health.newestCallAt ? ` — last call heard ${timeAgo(health.newestCallAt)}` : ""}
</span> </span>
</div> </div>
)} )}
+43 -12
View File
@@ -29,6 +29,8 @@ import type { Camera, CameraCredit, CameraProviderFeed, CamerasResponse, Ny511Ev
import { CameraFeed } from "@/components/CameraFeed"; import { CameraFeed } from "@/components/CameraFeed";
import { SourceCredit, fetchedLabel } from "@/components/SourceCredit"; import { SourceCredit, fetchedLabel } from "@/components/SourceCredit";
import { NEAR_CAMERA_KM, groupCallsByIncident, incidentPoints, viewshedWedge, worstFirst } from "@/lib/liveWall"; import { NEAR_CAMERA_KM, groupCallsByIncident, incidentPoints, viewshedWedge, worstFirst } from "@/lib/liveWall";
import { PlayButton } from "@/components/InlinePlayer";
import { hasAudio, useAudio } from "@/lib/audioQueue";
// ── Leaflet icon fix ────────────────────────────────────────────────────────── // ── Leaflet icon fix ──────────────────────────────────────────────────────────
delete (L.Icon.Default.prototype as unknown as Record<string, unknown>)._getIconUrl; delete (L.Icon.Default.prototype as unknown as Record<string, unknown>)._getIconUrl;
@@ -788,20 +790,26 @@ function incidentFanIcon(members: IncidentRecord[]): L.DivIcon {
} }
// ── Incident path stop marker — numbered, shared index with the call spine ─── // ── Incident path stop marker — numbered, shared index with the call spine ───
function pathStopIcon(index: number, isFirst: boolean, isLast: boolean, color: string): L.DivIcon { // `playing` (server-26#233): the stop whose call is on the speaker gets a
const size = 22; // thick accent ring outside the severity halo — "where was he when he said
// that", answered while it is being said.
function pathStopIcon(index: number, isFirst: boolean, isLast: boolean, color: string, playing = false): L.DivIcon {
const size = playing ? 30 : 22;
const c = size / 2;
const fill = isFirst ? "var(--surface)" : color; const fill = isFirst ? "var(--surface)" : color;
const textColor = isFirst ? color : "var(--page)"; const textColor = isFirst ? color : "var(--page)";
const halo = isLast ? `<circle cx="11" cy="11" r="10.5" fill="none" stroke="${color}" stroke-width="2" opacity="0.5"/>` : ""; const halo = isLast ? `<circle cx="${c}" cy="${c}" r="10.5" fill="none" stroke="${color}" stroke-width="2" opacity="0.5"/>` : "";
const ring = playing ? `<circle cx="${c}" cy="${c}" r="13.5" fill="none" stroke="var(--accent)" stroke-width="3"/>` : "";
return L.divIcon({ return L.divIcon({
className: "", className: "",
html: `<svg width="${size}" height="${size}" viewBox="0 0 22 22"> html: `<svg width="${size}" height="${size}" viewBox="0 0 ${size} ${size}">
${ring}
${halo} ${halo}
<circle cx="11" cy="11" r="8" fill="${fill}" stroke="${color}" stroke-width="2"/> <circle cx="${c}" cy="${c}" r="8" fill="${fill}" stroke="${color}" stroke-width="2"/>
<text x="11" y="14.5" text-anchor="middle" font-size="10" font-weight="600" fill="${textColor}" font-family="var(--font-sans)">${index}</text> <text x="${c}" y="${c + 3.5}" text-anchor="middle" font-size="10" font-weight="600" fill="${textColor}" font-family="var(--font-sans)">${index}</text>
</svg>`, </svg>`,
iconSize: [size, size], iconSize: [size, size],
iconAnchor: [size / 2, size / 2], iconAnchor: [c, c],
}); });
} }
@@ -1023,6 +1031,8 @@ function IncidentPathLayer({
incidents: IncidentRecord[]; incidents: IncidentRecord[];
callsByIncident: Map<string, CallRecord[]>; callsByIncident: Map<string, CallRecord[]>;
}) { }) {
// Only the playing id, so a timeupdate never re-renders the map.
const playingCallId = useAudio((s) => s.playingCallId);
return ( return (
<> <>
{incidents.map((inc) => { {incidents.map((inc) => {
@@ -1043,13 +1053,20 @@ function IncidentPathLayer({
<Marker <Marker
key={c.call_id} key={c.call_id}
position={[c.location_coords!.lat, c.location_coords!.lng]} position={[c.location_coords!.lat, c.location_coords!.lng]}
icon={pathStopIcon(i + 1, i === 0, i === calls.length - 1, color)} icon={pathStopIcon(i + 1, i === 0, i === calls.length - 1, color, c.call_id === playingCallId)}
zIndexOffset={c.call_id === playingCallId ? 1000 : 0}
> >
<Popup minWidth={160}> <Popup minWidth={160}>
<div className="text-gray-900"> <div className="text-gray-900">
<p className="text-xs text-gray-500">Stop {i + 1} of {calls.length}</p> <p className="text-xs text-gray-500">Stop {i + 1} of {calls.length}</p>
<p className="font-semibold text-sm mt-0.5">{inc.title ?? "Incident"}</p> <p className="font-semibold text-sm mt-0.5">{inc.title ?? "Incident"}</p>
{c.location && <p className="text-xs text-gray-600 mt-0.5">{c.location}</p>} {c.location && <p className="text-xs text-gray-600 mt-0.5">{c.location}</p>}
{hasAudio(c) && (
<div className="flex items-center gap-1.5 mt-1">
<PlayButton callId={c.call_id} label={`Play stop ${i + 1}`} />
<span className="text-xs text-gray-600">Play this call</span>
</div>
)}
<a href={`/incidents/${inc.incident_id}`} className="text-xs text-blue-600 hover:underline block mt-1"> <a href={`/incidents/${inc.incident_id}`} className="text-xs text-blue-600 hover:underline block mt-1">
View incident → View incident →
</a> </a>
@@ -1388,6 +1405,11 @@ export default function MapView({
const live = onAir?.has(inc.incident_id) ?? false; const live = onAir?.has(inc.incident_id) ?? false;
const cams = camCountByIncident?.get(inc.incident_id) ?? 0; const cams = camCountByIncident?.get(inc.incident_id) ?? 0;
const callCount = inc.call_ids?.length ?? 0; const callCount = inc.call_ids?.length ?? 0;
// "Play latest" (server-26#233): newest call of this incident in
// the loaded calls that has a recording.
const latest = (callsByIncident.get(inc.incident_id) ?? [])
.filter(hasAudio)
.reduce<CallRecord | null>((best, c) => (!best || c.started_at > best.started_at ? c : best), null);
const baseClass = `w-full text-left bg-surface/90 backdrop-blur-sm border rounded-lg px-3 py-2 text-xs hover:brightness-110 transition-all ${selected ? "ring-2 ring-accent" : ""}`; const baseClass = `w-full text-left bg-surface/90 backdrop-blur-sm border rounded-lg px-3 py-2 text-xs hover:brightness-110 transition-all ${selected ? "ring-2 ring-accent" : ""}`;
const cardBody = ( const cardBody = (
<> <>
@@ -1425,28 +1447,37 @@ export default function MapView({
)} )}
</> </>
); );
// The play control is a sibling of the card, not inside it — a
// <button> can't nest in the card's <button>/<a>.
const playLatest = latest && (
<PlayButton callId={latest.call_id} label="Play latest call" className="absolute bottom-2 right-2 shadow" />
);
if (inc.location_coords || onSelectIncident) { if (inc.location_coords || onSelectIncident) {
return ( return (
<div key={inc.incident_id} className="relative shrink-0">
<button <button
key={inc.incident_id}
onClick={() => handleIncidentSelect(inc)} onClick={() => handleIncidentSelect(inc)}
aria-pressed={selected} aria-pressed={selected}
className={baseClass} className={`${baseClass} ${latest ? "pr-10" : ""}`}
style={{ borderColor: color + "55" }} style={{ borderColor: color + "55" }}
> >
{cardBody} {cardBody}
</button> </button>
{playLatest}
</div>
); );
} }
return ( return (
<div key={inc.incident_id} className="relative shrink-0">
<a <a
key={inc.incident_id}
href={`/incidents/${inc.incident_id}`} href={`/incidents/${inc.incident_id}`}
className={`block ${baseClass}`} className={`block ${baseClass} ${latest ? "pr-10" : ""}`}
style={{ borderColor: color + "55" }} style={{ borderColor: color + "55" }}
> >
{cardBody} {cardBody}
</a> </a>
{playLatest}
</div>
); );
})} })}
</div> </div>
@@ -0,0 +1,156 @@
"use client";
/**
* Admin → Outage providers (server-26#184). Same licence gate as Camera
* providers: every source is listed with its licence status shown first, all
* default OFF. A licence-gated source (unverified / agreement required / paid)
* shows a blocking warning and needs an explicit confirmation, which c2-core
* records with who/when in config/outage_providers and audit_log. Stubs —
* utility storm-center maps, PowerOutage.us, EAGLE-I live — cannot be switched
* on at all: DRB holds no right to that data.
*/
import { useEffect, useState } from "react";
import { c2api } from "@/lib/c2api";
import type { LicenceStatus, OutageProviderAdmin } from "@/lib/types";
const LICENCE_STYLE: Record<LicenceStatus, { label: string; cls: string }> = {
commercial_ok: { label: "Commercial OK", cls: "bg-green-900 text-green-300 border-green-700" },
attribution_required: { label: "Attribution required", cls: "bg-sky-950 text-sky-300 border-sky-700" },
agreement_required: { label: "Requires agreement — not signed", cls: "bg-red-950 text-red-300 border-red-700" },
non_commercial: { label: "Non-commercial only", cls: "bg-red-950 text-red-300 border-red-700" },
unverified: { label: "Licence unverified", cls: "bg-amber-950 text-amber-300 border-amber-700" },
paid: { label: "Paid licence — no contract", cls: "bg-amber-950 text-amber-300 border-amber-700" },
prohibited: { label: "Terms forbid use", cls: "bg-red-950 text-red-300 border-red-700" },
};
function fmtEpoch(s: number | null | undefined): string {
return s ? new Date(s * 1000).toLocaleString([], { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" }) : "—";
}
export function OutageProvidersTab() {
const [providers, setProviders] = useState<OutageProviderAdmin[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState<string | null>(null);
const [confirming, setConfirming] = useState<OutageProviderAdmin | null>(null);
const [ackChecked, setAckChecked] = useState(false);
const load = () =>
c2api.listOutageProviders().then((r) => setProviders(r.providers)).catch((e) => setError(String(e)));
useEffect(() => { load(); }, []);
async function toggle(p: OutageProviderAdmin, enabled: boolean, acknowledge = false) {
if (enabled && p.licence.gated && !acknowledge) {
setError(null);
setAckChecked(false);
setConfirming(p);
return;
}
setBusy(p.id);
setError(null);
try {
const updated = await c2api.setOutageProvider(p.id, enabled, acknowledge);
setProviders((cur) => cur?.map((x) => (x.id === p.id ? updated : x)) ?? null);
setConfirming(null);
} catch (e) {
setError(String(e));
} finally {
setBusy(null);
}
}
return (
<section className="space-y-3">
<p className="text-gray-400 text-xs leading-snug">
Power-outage sources for the map. Every provider is <b>off</b> until enabled here. Licence status comes from the
source-terms research on server-26#184 (2026-10-05); a red or amber licence means DRB does not hold a confirmed right
to show that data. Scraped utility outage maps are never shipped — those sources stay locked until a data-sharing
agreement exists.
</p>
{error && <div className="bg-red-950 border border-red-800 rounded-lg p-3 text-red-400 text-sm font-mono break-words">{error}</div>}
{!providers ? (
<p className="text-gray-500 text-sm font-mono">Loading…</p>
) : (
<div className="bg-gray-900 border border-gray-800 rounded-xl divide-y divide-gray-800">
{providers.map((p) => {
const lic = LICENCE_STYLE[p.licence.status];
const lockReason = !p.implemented ? "No working adapter (stub)" : p.licence.status === "prohibited" ? "Source terms forbid this use" : null;
return (
<div key={p.id} className="px-5 py-4 space-y-2">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 space-y-1">
<span className={`inline-block text-xs font-semibold px-2 py-0.5 rounded border ${lic.cls}`}>{lic.label}</span>
<p className="text-white text-sm font-semibold">{p.name}</p>
<p className="text-gray-400 text-xs">{p.licence.label}</p>
<p className="text-gray-500 text-xs">{p.region} · {p.granularity}</p>
</div>
<div className="shrink-0 text-right space-y-1">
<button
type="button"
role="switch"
aria-checked={p.enabled}
aria-label={`${p.enabled ? "Disable" : "Enable"} ${p.name}`}
disabled={busy === p.id || (!!lockReason && !p.enabled)}
title={lockReason ?? undefined}
onClick={() => toggle(p, !p.enabled)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors disabled:opacity-40 ${p.enabled ? "bg-indigo-600" : "bg-gray-700"}`}
>
<span className={`inline-block h-4 w-4 rounded-full bg-white shadow transition-transform ${p.enabled ? "translate-x-6" : "translate-x-1"}`} />
</button>
<p className={`text-[11px] font-mono ${p.serving ? "text-green-400" : "text-gray-500"}`}>
{p.serving ? "serving" : lockReason ?? p.not_serving_reason ?? "off"}
</p>
</div>
</div>
{p.implemented && (
<div className="grid grid-cols-2 md:grid-cols-3 gap-2 text-[11px] font-mono text-gray-400">
<span>counties out: {p.health ? p.health.count : "—"}</span>
<span>last fetch: {fmtEpoch(p.health?.fetched_at)}</span>
<span className={p.health?.error ? "text-red-400" : ""}>health: {p.health ? (p.health.error ? "error" : p.health.fetched_at ? "ok" : "not fetched yet") : "n/a"}</span>
</div>
)}
{p.health?.error && <p className="text-[11px] font-mono text-red-400 break-words">{p.health.error}</p>}
{p.coverage_note && <p className="text-[11px] text-gray-400">{p.coverage_note}</p>}
{p.licence.attribution && <p className="text-[11px] text-gray-400">Attribution shown with the data: “{p.licence.attribution}”</p>}
{p.acknowledgement && (
<p className="text-[11px] text-amber-300">
Licence warning acknowledged by {p.acknowledgement.by_email || p.acknowledgement.by_uid} on {new Date(p.acknowledgement.at).toLocaleString()}
</p>
)}
<details className="text-[11px] text-gray-500">
<summary className="cursor-pointer select-none">Terms & adapter notes</summary>
<p className="mt-1">{p.licence.terms}</p>
<p className="mt-1">{p.notes}</p>
<p className="mt-1 space-x-2">
{p.licence.terms_url && <a className="underline" href={p.licence.terms_url} target="_blank" rel="noopener noreferrer">terms</a>}
{p.homepage && <a className="underline" href={p.homepage} target="_blank" rel="noopener noreferrer">source</a>}
{p.licence.issue && <span>tracking: {p.licence.issue}</span>}
</p>
</details>
</div>
);
})}
</div>
)}
{confirming && (
<div className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-4" role="alertdialog" aria-modal="true" aria-labelledby="outage-ack-title">
<div className="bg-gray-900 border border-red-700 rounded-xl max-w-lg w-full p-5 space-y-3">
<h2 id="outage-ack-title" className="text-white font-semibold">Enable {confirming.name}?</h2>
<p className="text-red-300 text-sm font-semibold">{LICENCE_STYLE[confirming.licence.status].label}</p>
<p className="text-gray-300 text-sm leading-snug">{confirming.licence.warning}</p>
{error && <p className="text-red-400 text-xs font-mono break-words">{error}</p>}
<label className="flex items-start gap-2 text-sm text-gray-200">
<input type="checkbox" checked={ackChecked} onChange={(e) => setAckChecked(e.target.checked)} className="mt-1" />
<span>I confirm DRB holds the right to show this source&apos;s outage data. This confirmation is recorded with my account and the time in the audit log.</span>
</label>
<div className="flex justify-end gap-2">
<button type="button" onClick={() => setConfirming(null)} className="text-sm px-3 py-1.5 rounded border border-gray-700 text-gray-300">Cancel</button>
<button type="button" disabled={!ackChecked || busy === confirming.id} onClick={() => toggle(confirming, true, true)}
className="text-sm px-3 py-1.5 rounded bg-red-700 text-white disabled:opacity-40">Enable and record</button>
</div>
</div>
</div>
)}
</section>
);
}
@@ -0,0 +1,53 @@
"use client";
/**
* Truth strip (server-26#232) — replaces the heartbeat-driven '● Live' pill.
*
* Healthy: compact per-stage chips (Radio · Transcripts · AI · Feeds), each
* with its last-output age. Any core stage dry past the threshold, or any
* /health/ai tier degraded: a full-width amber bar that says what broke and
* since when — "DEGRADED — radio heard, not transcribed since 12:15".
* Theme tokens only (sev-moderate is the amber), so both themes follow.
*/
import type { PipelineHealth, PipelineStage } from "@/lib/usePipelineHealth";
function dotClass(s: PipelineStage["state"]): string {
if (s === "degraded") return "bg-sev-moderate";
if (s === "unknown") return "bg-ink-muted";
return "bg-ink-2";
}
export function TruthChips({ health }: { health: PipelineHealth }) {
return (
<div className="flex items-center gap-1.5 flex-wrap" role="status" aria-label="Pipeline health">
{health.stages.map((s) => (
<span key={s.key} title={s.detail}
className={`inline-flex items-center gap-1 px-1.5 py-0.5 rounded border text-[11px] whitespace-nowrap ${
s.state === "degraded" ? "border-sev-moderate text-ink bg-sev-moderate/10" : "border-line text-ink-2"}`}>
<span className={`w-1.5 h-1.5 rounded-full ${dotClass(s.state)}`} aria-hidden="true" />
{s.label}
<span className="sr-only">: {s.state === "ok" ? "healthy" : s.state}, {s.detail}</span>
</span>
))}
</div>
);
}
export function TruthBar({ health }: { health: PipelineHealth }) {
if (!health.degraded || !health.message) return null;
return (
<div role="alert"
className="shrink-0 w-full px-3 py-1.5 border-b border-sev-moderate bg-sev-moderate/15 text-xs text-ink flex flex-wrap items-baseline gap-x-2">
<b className="font-bold tracking-wide">DEGRADED</b>
<span>— {health.message}</span>
<span className="ml-auto text-ink-muted whitespace-nowrap">
stale after {health.staleMin} min · map shows last-known state
</span>
</div>
);
}
/** Chips for the wall bar, plus the bar itself directly below it when degraded. */
export function TruthStrip({ health, placement }: { health: PipelineHealth; placement: "chips" | "bar" }) {
return placement === "chips" ? <TruthChips health={health} /> : <TruthBar health={health} />;
}
+56 -4
View File
@@ -14,6 +14,8 @@ import { useAuth } from "@/components/AuthProvider";
import { CameraFeed } from "@/components/CameraFeed"; import { CameraFeed } from "@/components/CameraFeed";
import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice"; import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice";
import { SourceCredit, fetchedLabel } from "@/components/SourceCredit"; import { SourceCredit, fetchedLabel } from "@/components/SourceCredit";
import { InlinePlayer } from "@/components/InlinePlayer";
import { canPlayAudio, hasAudio, playIncident, stop as stopAudio, useAudio } from "@/lib/audioQueue";
import { CONTEXT_KM, NEAR_CAMERA_KM, contextFor, sampleRadarDbz, type LatLon, type NearCamera } from "@/lib/liveWall"; import { CONTEXT_KM, NEAR_CAMERA_KM, contextFor, sampleRadarDbz, type LatLon, type NearCamera } from "@/lib/liveWall";
function sevColor(s: string | null | undefined) { function sevColor(s: string | null | undefined) {
@@ -31,6 +33,8 @@ function hhmm(iso: string): string {
} }
const H3 = "text-[11px] uppercase tracking-wide text-ink-muted font-medium mb-1.5"; const H3 = "text-[11px] uppercase tracking-wide text-ink-muted font-medium mb-1.5";
// Spine rows rendered before "show all loaded" (server-26#239).
const SPINE_CAP = 30;
const SEC = "px-4 py-3 border-b border-line"; const SEC = "px-4 py-3 border-b border-line";
export function CameraTile({ cam, feed, km, onOpen }: { cam: Camera; feed?: CameraProviderFeed; km?: number; onOpen: () => void }) { export function CameraTile({ cam, feed, km, onOpen }: { cam: Camera; feed?: CameraProviderFeed; km?: number; onOpen: () => void }) {
@@ -183,6 +187,20 @@ export function IncidentPanel({
return m; return m;
}, [calls]); }, [calls]);
const spine = useMemo(() => calls.slice().sort((a, b) => b.started_at.localeCompare(a.started_at)), [calls]); const spine = useMemo(() => calls.slice().sort((a, b) => b.started_at.localeCompare(a.started_at)), [calls]);
// "Play incident" (server-26#233): every loaded call with a recording, oldest first.
const playable = useMemo(() => spine.filter(hasAudio).map((c) => c.call_id).reverse(), [spine]);
const auth = useAuth();
const audioOk = canPlayAudio(auth);
const playingCallId = useAudio((s) => s.playingCallId);
const queueLen = useAudio((s) => s.queue.length);
const playingHere = !!playingCallId && spine.some((c) => c.call_id === playingCallId);
// Truncation is said out loud, not silent (server-26#239): the spine shows
// what this page has loaded, which can be less than the incident holds.
const [showAll, setShowAll] = useState(false);
useEffect(() => { setShowAll(false); }, [inc.incident_id]);
const totalCalls = Math.max(inc.call_ids?.length ?? 0, calls.length);
const outsideWindow = Math.max(0, (inc.call_ids?.length ?? 0) - calls.length);
const shown = showAll ? spine : spine.slice(0, SPINE_CAP);
const unitsOn = inc.units_active?.length ? inc.units_active : inc.units ?? []; const unitsOn = inc.units_active?.length ? inc.units_active : inc.units ?? [];
const cleared = inc.units_cleared ?? []; const cleared = inc.units_cleared ?? [];
@@ -270,23 +288,57 @@ export function IncidentPanel({
</div> </div>
<div className={SEC}> <div className={SEC}>
<h3 className={H3}>Call spine · newest first</h3> <div className="flex items-center gap-2 mb-1.5">
<h3 className={`${H3} mb-0`}>Call spine · newest first</h3>
{audioOk && playable.length > 0 && (
playingHere ? (
<button type="button" onClick={stopAudio}
className="ml-auto text-[10px] px-1.5 py-0.5 rounded border border-line text-ink-2 hover:bg-raised">
■ Stop{queueLen ? ` · ${queueLen} queued` : ""}
</button>
) : (
<button type="button" onClick={() => playIncident(playable)}
title="Play this incident's loaded calls back to back, oldest first"
className="ml-auto text-[10px] px-1.5 py-0.5 rounded bg-accent text-white hover:brightness-110">
▶ Play incident · {playable.length}
</button>
)
)}
</div>
{spine.length > SPINE_CAP || outsideWindow > 0 ? (
<p className="text-[10px] text-ink-muted mb-1.5">
{showAll || spine.length <= SPINE_CAP
? `Showing all ${spine.length} loaded of ${totalCalls}`
: `Showing latest ${SPINE_CAP} of ${totalCalls}`}
{outsideWindow > 0 && ` · ${outsideWindow} older call${outsideWindow !== 1 ? "s" : ""} outside the loaded window`}
{spine.length > SPINE_CAP && (
<button type="button" onClick={() => setShowAll((v) => !v)} className="ml-1.5 text-accent hover:underline">
{showAll ? "show latest 30" : "show all loaded"}
</button>
)}
</p>
) : null}
{spine.length ? ( {spine.length ? (
<ol className="space-y-2"> <ol className="space-y-2">
{spine.slice(0, 30).map((c) => { {shown.map((c) => {
const stop = stopOf.get(c.call_id); const stop = stopOf.get(c.call_id);
const text = c.transcript_corrected || c.transcript; const text = c.transcript_corrected || c.transcript;
const isPlaying = c.call_id === playingCallId;
return ( return (
<li key={c.call_id} className="flex gap-2 text-xs"> <li key={c.call_id}
className={`flex gap-2 text-xs rounded ${isPlaying ? "bg-raised ring-1 ring-accent -mx-1 px-1 py-0.5" : ""}`}
aria-current={isPlaying ? "true" : undefined}>
<span className="shrink-0 w-5 h-5 rounded-full flex items-center justify-center font-mono text-[10px]" <span className="shrink-0 w-5 h-5 rounded-full flex items-center justify-center font-mono text-[10px]"
style={stop ? { background: color, color: "var(--page)" } : { background: "var(--raised)", color: "var(--ink-muted)" }}>{stop ?? "·"}</span> style={stop ? { background: color, color: "var(--page)" } : { background: "var(--raised)", color: "var(--ink-muted)" }}>{stop ?? "·"}</span>
<div className="min-w-0"> <div className="min-w-0 flex-1">
<div className="flex gap-1.5 items-center"> <div className="flex gap-1.5 items-center">
<span className="font-mono text-ink-muted">{hhmm(c.started_at)}</span> <span className="font-mono text-ink-muted">{hhmm(c.started_at)}</span>
{c.talkgroup_name && <span className="font-mono text-[10px] px-1 rounded bg-raised text-ink-2 truncate">{c.talkgroup_name}</span>} {c.talkgroup_name && <span className="font-mono text-[10px] px-1 rounded bg-raised text-ink-2 truncate">{c.talkgroup_name}</span>}
</div> </div>
<p className="text-ink-2 leading-snug">{text || <span className="text-ink-muted">no transcript</span>}</p> <p className="text-ink-2 leading-snug">{text || <span className="text-ink-muted">no transcript</span>}</p>
{isPlaying && hasAudio(c) && <div className="mt-1"><InlinePlayer callId={c.call_id} /></div>}
</div> </div>
{hasAudio(c) && !isPlaying && <InlinePlayer callId={c.call_id} compact />}
</li> </li>
); );
})} })}
+176
View File
@@ -0,0 +1,176 @@
"use client";
/**
* Call-audio playback store — server-26#233.
*
* ONE module-level <audio> element for the whole app, so exactly one call
* plays at a time no matter how many players are on screen (spine rows, map
* path-stop popups, rail cards, the incident page). Components read it through
* useSyncExternalStore, which is what lets the wall panel and the map share
* "now playing" without LiveView.tsx threading state between them.
*
* Nothing here runs until play()/playIncident() is called from a click, so
* nothing autoplays on page load. The playback URL is minted per play by
* GET /calls/{id} (a short-lived HMAC link, see c2-core storage.py) rather
* than read off the Firestore doc, which only holds the gs:// location.
* c2-core serves it with HTTP Range, so a long call starts after the first
* span and seeking is a 206 for just the bytes needed.
*/
import { useSyncExternalStore } from "react";
import { c2api } from "@/lib/c2api";
import type { CallRecord } from "@/lib/types";
export type AudioStatus = "idle" | "loading" | "playing" | "paused";
export interface AudioState {
/** Calls still to play after the current one, oldest first. */
queue: string[];
playingCallId: string | null;
status: AudioStatus;
/** The last call whose audio could not be fetched or decoded. */
errorCallId: string | null;
current: number;
duration: number;
}
const IDLE: AudioState = {
queue: [], playingCallId: null, status: "idle", errorCallId: null, current: 0, duration: 0,
};
let state: AudioState = IDLE;
const listeners = new Set<() => void>();
let el: HTMLAudioElement | null = null;
// Bumped on every play/stop so a getCall() that resolves after the user has
// already moved on is dropped instead of hijacking the player.
let token = 0;
function set(patch: Partial<AudioState>) {
state = { ...state, ...patch };
listeners.forEach((l) => l());
}
function audio(): HTMLAudioElement {
if (el) return el;
el = new Audio();
el.preload = "auto";
el.addEventListener("play", () => set({ status: "playing" }));
el.addEventListener("pause", () => { if (state.status === "playing") set({ status: "paused" }); });
el.addEventListener("timeupdate", () => set({ current: el!.currentTime }));
el.addEventListener("loadedmetadata", () => set({ duration: Number.isFinite(el!.duration) ? el!.duration : 0 }));
el.addEventListener("ended", advance);
el.addEventListener("error", () => {
if (!state.playingCallId || !el!.getAttribute("src")) return;
set({ errorCallId: state.playingCallId });
advance();
});
return el;
}
function advance() {
const [next, ...rest] = state.queue;
if (next) void start(next, rest);
else stop();
}
async function start(callId: string, queue: string[]) {
const mine = ++token;
const a = audio();
a.pause();
set({ queue, playingCallId: callId, status: "loading", current: 0, duration: 0,
errorCallId: state.errorCallId === callId ? null : state.errorCallId });
let url: string | null = null;
try {
url = (await c2api.getCall(callId)).audio_url ?? null;
} catch {
url = null;
}
if (mine !== token) return;
if (!url) {
set({ errorCallId: callId });
advance();
return;
}
a.src = url;
// The click that got us here is the play intent; a rejection (autoplay
// policy after a slow fetch) leaves it paused for one more click.
a.play().catch(() => { if (mine === token) set({ status: "paused" }); });
}
/** Play one call now, replacing whatever is playing or queued. */
export function play(callId: string) {
void start(callId, []);
}
/** Play an incident's calls back to back. Pass them oldest first. */
export function playIncident(callIdsOldestFirst: string[]) {
const [first, ...rest] = callIdsOldestFirst;
if (first) void start(first, rest);
}
export function stop() {
token++;
if (el) {
el.pause();
el.removeAttribute("src");
el.load();
}
set({ ...IDLE, errorCallId: state.errorCallId });
}
export function pause() {
el?.pause();
}
export function resume() {
el?.play().catch(() => {});
}
export function seek(t: number) {
if (el && Number.isFinite(t)) {
el.currentTime = t;
set({ current: t });
}
}
/** Play/pause toggle for one call's button. */
export function toggle(callId: string) {
if (state.playingCallId !== callId) play(callId);
else if (state.status === "playing") pause();
else if (state.status === "paused") resume();
}
function subscribe(l: () => void): () => void {
listeners.add(l);
return () => {
listeners.delete(l);
// Last player unmounted (navigated away from every page that shows one):
// stop rather than keep playing audio nothing on screen can pause.
// Deferred so a same-tick remount (StrictMode, route swap) doesn't stop it.
if (!listeners.size) setTimeout(() => { if (!listeners.size && state.playingCallId) stop(); }, 0);
};
}
/**
* Subscribe to a slice of the store. Return a primitive from `select` (an id,
* a status, a number) so a component re-renders only when that slice changes
* — the map must not re-render on every timeupdate.
*/
export function useAudio<T>(select: (s: AudioState) => T): T {
return useSyncExternalStore(subscribe, () => select(state), () => select(IDLE));
}
/** True when a call has a stored recording c2-core can serve. */
export function hasAudio(c: Pick<CallRecord, "audio_gcs_uri" | "audio_url">): boolean {
return !!(c.audio_gcs_uri || c.audio_url);
}
/**
* The ONE gate for call-audio playback in the UI. BUSINESS_MODEL.md §3 puts
* audio playback in Scanner and up, with the Public tier getting none; no tier
* concept exists in the frontend yet (and Gate A keeps tier names off public
* surfaces), so today this is true for any signed-in org member. When tiers
* land, switch the Public tier off HERE rather than at each player.
*/
export function canPlayAudio(auth: { user: unknown; orgId: string | null; isAdmin?: boolean }): boolean {
return !!auth.user && (!!auth.orgId || !!auth.isAdmin);
}
+19 -1
View File
@@ -1,5 +1,7 @@
import { auth } from "@/lib/firebase"; import { auth } from "@/lib/firebase";
import type { AreaContext, CameraProviderAdmin, CamerasResponse, Ny511Response, TalkgroupPending } from "@/lib/types"; import type {
AreaContext, CameraProviderAdmin, CamerasResponse, Ny511Response, OutageProviderAdmin, OutagesResponse, TalkgroupPending,
} from "@/lib/types";
import type { DiscordAdminOverview, DiscordStream, RadioBot, RadioBotAddResult } from "@/lib/discord"; import type { DiscordAdminOverview, DiscordStream, RadioBot, RadioBotAddResult } from "@/lib/discord";
const BASE = process.env.NEXT_PUBLIC_C2_URL ?? "http://localhost:8000"; const BASE = process.env.NEXT_PUBLIC_C2_URL ?? "http://localhost:8000";
@@ -514,6 +516,22 @@ export const c2api = {
body: JSON.stringify({ enabled, acknowledge_licence: acknowledgeLicence }), body: JSON.stringify({ enabled, acknowledge_licence: acknowledgeLicence }),
}), }),
// Power outages from every admin-enabled provider inside a bbox (server-26#184).
// `status` explains an empty list: none_enabled / no_source / error / ok.
getOutages: (bbox: { south: number; west: number; north: number; east: number }) =>
request<OutagesResponse>(`/outages?${new URLSearchParams({
south: String(bbox.south), west: String(bbox.west), north: String(bbox.north), east: String(bbox.east),
})}`),
// Admin → Outage providers. Same licence gate as cameras: a gated provider
// answers 409 with `requires_acknowledgement` until `acknowledge_licence` is sent.
listOutageProviders: () => request<{ providers: OutageProviderAdmin[] }>("/admin/outage-providers"),
setOutageProvider: (id: string, enabled: boolean, acknowledgeLicence = false) =>
request<OutageProviderAdmin>(`/admin/outage-providers/${encodeURIComponent(id)}`, {
method: "PUT",
body: JSON.stringify({ enabled, acknowledge_licence: acknowledgeLicence }),
}),
// Public waitlist — no auth, see routers/waitlist.py // Public waitlist — no auth, see routers/waitlist.py
joinWaitlist: (body: { email: string; org_name?: string; note?: string }) => joinWaitlist: (body: { email: string; org_name?: string; note?: string }) =>
request<{ ok: boolean }>("/waitlist", { method: "POST", body: JSON.stringify(body) }), request<{ ok: boolean }>("/waitlist", { method: "POST", body: JSON.stringify(body) }),
+4
View File
@@ -50,6 +50,10 @@ export interface DiscordGuildOption {
linked: boolean; linked: boolean;
synced: boolean; synced: boolean;
synced_at: string | null; synced_at: string | null;
/** Who listed the channels (server-26#225 S5): the DRB server bot, or a radio bot read over REST. */
directory_source?: "main_bot" | "radio_bot" | null;
/** The DRB server bot is in this server. Not required: a radio bot alone is enough. */
main_bot_present?: boolean;
voice_channels: DiscordVoiceChannel[]; voice_channels: DiscordVoiceChannel[];
streams_on: number; streams_on: number;
bot: { ok: boolean; code: string | null; message: string | null; invite: DiscordInvite[] }; bot: { ok: boolean; code: string | null; message: string | null; invite: DiscordInvite[] };
+68
View File
@@ -217,6 +217,74 @@ export interface CameraProviderAdmin {
health: { fetched_at: number | null; last_attempt_at: number | null; error: string | null; count: number } | null; health: { fetched_at: number | null; last_attempt_at: number | null; error: string | null; count: number } | null;
} }
// Power outages (server-26#184) — GET /outages, cached server-side, not Firestore.
/**
* One county with active outages from an enabled provider. `id` is
* provider-prefixed ("odin:36067"). Counts are utility-reported meters, used
* as customers. `lat`/`lon` is the centre of the county's bounding box — a
* label point, not an outage location. Times are ISO-8601 UTC.
*/
export interface OutageArea {
id: string;
provider: string;
fips: string;
county: string | null;
state: string | null;
customers_out: number; // live events only
/** Meters on events the source still lists whose own ETR passed >24h ago (ODIN keeps months-old rows). */
stale_customers_out: number;
stale: boolean; // every event in the county is stale: dim or hide, never show as live
customers_served: number | null; // null: no county-level denominator from the source
etr: string | null; // latest estimated restoration across the county's outages
since: string | null; // earliest reported start
updated_at: string | null; // newest time a reporting utility here last sent data
lat: number | null;
lon: number | null;
bbox: [number, number, number, number] | null; // south, west, north, east
utilities: { name: string | null; eia_id: string | null; customers_out: number; stale_customers_out: number }[];
}
export interface OutageProviderFeed extends CameraCredit {
id: string;
name: string;
granularity: string;
coverage_note: string | null;
covers_bbox: boolean;
fetched_at: number | null; // epoch s of the provider's last successful fetch
error: string | null;
total_in_bbox: number;
truncated: boolean;
}
export interface OutagesResponse {
areas: OutageArea[];
providers: OutageProviderFeed[];
serving_count: number;
/** Why the list is what it is — an empty layer is never ambiguous. */
status: "ok" | "none_enabled" | "no_source" | "error";
message: string | null;
}
/** Admin → Outage providers (GET /admin/outage-providers). */
export interface OutageProviderAdmin {
id: string;
name: string;
region: string;
granularity: string;
implemented: boolean;
notes: string;
homepage: string | null;
licence: CameraProviderAdmin["licence"];
enabled: boolean;
serving: boolean;
not_serving_reason: string | null;
acknowledgement: CameraProviderAdmin["acknowledgement"];
updated_at: string | null;
updated_by: { uid: string; email: string } | null;
coverage_note: string | null;
health: CameraProviderAdmin["health"];
}
export interface Ny511Event { export interface Ny511Event {
id: string; id: string;
lat: number; lat: number;
+271
View File
@@ -0,0 +1,271 @@
"use client";
/**
* Pipeline truth for the live wall (server-26#232).
*
* The old '● Live · Ns ago' pill was reset by any node heartbeat, so the wall
* read live while transcription/correlation were dead (#196). This hook keys
* health to the PIPELINE instead: radio → STT → AI, plus the context feeds.
*
* Two sources, deliberately both:
* - Data timestamps on what the wall already subscribes to (recent calls,
* active incidents). These survive a c2-core restart.
* - GET /health/ai (main.py, auth-free) — per-tier provider state. It is
* in-memory per process and resets on restart, so it can only ADD a
* degraded verdict, never clear one the data shows.
*/
import { useEffect, useMemo, useState } from "react";
import type { CallRecord, CameraProviderFeed, IncidentRecord, NodeRecord, Ny511FeedStatus } from "@/lib/types";
const BASE = process.env.NEXT_PUBLIC_C2_URL ?? "http://localhost:8000";
const HEALTH_POLL_MS = 60_000;
const TICK_MS = 30_000;
function envMinutes(): number {
const n = Number(process.env.NEXT_PUBLIC_WALL_STALE_MIN);
return Number.isFinite(n) && n > 0 ? n : 10;
}
/** Minutes a stage may be dry before the wall calls itself degraded. */
export const WALL_STALE_MIN = envMinutes();
/**
* Radio silence and "no incident written" are weaker evidence than "radio
* heard but not transcribed" — a quiet county at 4am is not an outage, and the
* correlator orphans in doubt — so those two stages get a longer leash.
*/
const ABSENCE_FACTOR = 3;
/**
* Calls Whisper returned nothing for (silence, kerchunks, degenerate output)
* get no transcript by design, so one or two blank calls after the last
* transcript are normal. It takes this many to call STT dry.
*/
const MIN_UNSERVED = 3;
// ── /health/ai response (app/internal/ai_health.py snapshot()) ──────────────
export interface AiTierState {
degraded: boolean;
permanent: boolean;
provider: string | null;
model: string | null;
problem: string | null;
fix: string | null;
first_seen: string | null;
last_seen: string | null;
consecutive_failures: number;
alerted: boolean;
}
export interface AiHealthResponse {
tiers: Record<string, AiTierState>;
}
export type StageKey = "radio" | "stt" | "ai" | "feeds";
export type StageState = "ok" | "degraded" | "unknown";
export interface PipelineStage {
key: StageKey;
label: string;
state: StageState;
/** Newest evidence this stage produced output, or null if none in the window. */
lastAt: Date | null;
/** One-line human explanation, shown as the chip tooltip / bar text. */
detail: string;
}
export interface PipelineHealth {
stages: PipelineStage[];
/** True when a core stage (radio/STT/AI) is dry or an AI tier is degraded. */
degraded: boolean;
/** Bar text, e.g. "radio heard, not transcribed since 12:15 · transcription tier degraded (openai: …)". */
message: string | null;
degradedTiers: { tier: string; state: AiTierState }[];
/** null until the first /health/ai answer; false if it could not be reached. */
aiHealthReachable: boolean | null;
/** Distinct nodes with a call started in the last WALL_STALE_MIN minutes. */
nodesHearing: number;
newestCallAt: Date | null;
staleMin: number;
}
function useAiHealth(): { data: AiHealthResponse | null; reachable: boolean | null } {
const [data, setData] = useState<AiHealthResponse | null>(null);
const [reachable, setReachable] = useState<boolean | null>(null);
useEffect(() => {
let cancelled = false;
const load = () => {
if (document.hidden) return;
fetch(`${BASE}/health/ai`, { cache: "no-store" })
.then((r) => (r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`))))
.then((j: AiHealthResponse) => {
if (cancelled) return;
setData(j && typeof j.tiers === "object" ? j : { tiers: {} });
setReachable(true);
})
.catch((e) => {
if (cancelled) return;
console.error("health/ai:", e);
setReachable(false);
});
};
load();
const id = setInterval(load, HEALTH_POLL_MS);
const onVis = () => { if (!document.hidden) load(); };
document.addEventListener("visibilitychange", onVis);
return () => { cancelled = true; clearInterval(id); document.removeEventListener("visibilitychange", onVis); };
}, []);
return { data, reachable };
}
const ms = (iso: string | null | undefined): number => {
if (!iso) return NaN;
const t = Date.parse(iso);
return Number.isFinite(t) ? t : NaN;
};
const maxDate = (vals: number[]): Date | null => {
const ok = vals.filter((v) => Number.isFinite(v));
return ok.length ? new Date(Math.max(...ok)) : null;
};
export function clockTime(d: Date): string {
return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", hour12: false });
}
function agoText(d: Date | null, now: number): string {
if (!d) return "none in window";
const s = Math.max(0, Math.floor((now - d.getTime()) / 1000));
if (s < 60) return `${s}s ago`;
if (s < 3600) return `${Math.floor(s / 60)}m ago`;
return `${Math.floor(s / 3600)}h ago`;
}
const hasTranscript = (c: CallRecord) => !!(c.transcript || c.transcript_corrected);
const isAiTier = (t: string) => t !== "transcription";
export interface PipelineInput {
calls: CallRecord[];
incidents: IncidentRecord[];
nodes: NodeRecord[];
eventsStatus?: Ny511FeedStatus;
cameraProviders: CameraProviderFeed[];
}
export function usePipelineHealth(input: PipelineInput): PipelineHealth {
const { calls, incidents, nodes, eventsStatus, cameraProviders } = input;
const { data: ai, reachable } = useAiHealth();
// Staleness is a function of wall-clock time, not only of new data arriving.
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
const id = setInterval(() => setNow(Date.now()), TICK_MS);
return () => clearInterval(id);
}, []);
return useMemo(
() => derivePipelineHealth({ calls, incidents, nodes, eventsStatus, cameraProviders }, ai, reachable, now),
[calls, incidents, nodes, eventsStatus, cameraProviders, ai, reachable, now],
);
}
/** Pure derivation — separated from the hook so it can be exercised without React. */
export function derivePipelineHealth(
input: PipelineInput,
ai: AiHealthResponse | null,
reachable: boolean | null,
now: number,
): PipelineHealth {
const { calls, incidents, nodes, eventsStatus, cameraProviders } = input;
const staleMs = WALL_STALE_MIN * 60_000;
const absenceMs = staleMs * ABSENCE_FACTOR;
// Canonical, finished recordings only: a second node's copy of the same
// transmission (duplicate_of) is never transcribed, and an in-flight call
// has not been uploaded yet.
const real = calls.filter((c) => !c.duplicate_of && c.status !== "active" && Number.isFinite(ms(c.started_at)));
const tiers = Object.entries(ai?.tiers ?? {});
const degradedTiers = tiers.filter(([, s]) => s?.degraded).map(([tier, state]) => ({ tier, state }));
const tierText = (tier: string, s: AiTierState) =>
`${tier} tier degraded${s.provider ? ` (${s.provider}${s.model ? ` ${s.model}` : ""})` : ""}${s.problem ? `: ${s.problem}` : ""}`;
// ── Radio ────────────────────────────────────────────────────────────────
const newestCallAt = maxDate(calls.map((c) => ms(c.started_at)));
const anyNodeUp = nodes.some((n) => n.status !== "offline");
// Every configured node offline is not "Listening" — nothing can be heard.
const allNodesOffline = nodes.length > 0 && !anyNodeUp;
const radioDry = anyNodeUp && (!newestCallAt || now - newestCallAt.getTime() > absenceMs);
const radio: PipelineStage = {
key: "radio", label: "Radio", lastAt: newestCallAt,
state: nodes.length === 0 ? "unknown" : radioDry || allNodesOffline ? "degraded" : "ok",
detail: allNodesOffline
? `all ${nodes.length} node${nodes.length === 1 ? "" : "s"} offline`
: newestCallAt ? `last call heard ${agoText(newestCallAt, now)}` : "no calls in the recent window",
};
// Bounded to configured nodes so a call from a since-removed node can't push
// the KPI past N/N.
const knownNodes = new Set(nodes.map((n) => n.node_id));
const nodesHearing = new Set(
calls.filter((c) => now - ms(c.started_at) <= staleMs && knownNodes.has(c.node_id)).map((c) => c.node_id),
).size;
// ── STT: radio heard, nothing transcribed ────────────────────────────────
const newestTranscriptAt = maxDate(real.filter(hasTranscript).map((c) => ms(c.started_at)));
const unserved = real
.filter((c) => !hasTranscript(c) && (!newestTranscriptAt || ms(c.started_at) > newestTranscriptAt.getTime()))
.map((c) => ms(c.started_at));
const oldestUnserved = unserved.length ? Math.min(...unserved) : NaN;
const sttDry = unserved.length >= MIN_UNSERVED && now - oldestUnserved > staleMs;
const sttTier = degradedTiers.find((t) => t.tier === "transcription");
const sttSince = newestTranscriptAt ?? (Number.isFinite(oldestUnserved) ? new Date(oldestUnserved) : null);
const stt: PipelineStage = {
key: "stt", label: "Transcripts", lastAt: newestTranscriptAt,
state: sttDry || sttTier ? "degraded" : real.length ? "ok" : "unknown",
detail: sttDry
? `radio heard, not transcribed since ${sttSince ? clockTime(sttSince) : "—"} (${unserved.length} calls waiting)`
: newestTranscriptAt ? `last transcript ${agoText(newestTranscriptAt, now)}` : "no transcripts in the recent window",
};
// ── AI: transcribed, but nothing correlated ─────────────────────────────
const newestAiAt = maxDate([
...incidents.flatMap((i) => [ms(i.updated_at), ms(i.last_thin_at)]),
...real.filter((c) => (c.incident_ids?.length ?? 0) > 0 || !!c.incident_id).map((c) => ms(c.started_at)),
]);
const transcribedSinceAi = real
.filter((c) => hasTranscript(c) && (!newestAiAt || ms(c.started_at) > newestAiAt.getTime()))
.map((c) => ms(c.started_at));
const oldestUncorrelated = transcribedSinceAi.length ? Math.min(...transcribedSinceAi) : NaN;
const aiDry = transcribedSinceAi.length >= MIN_UNSERVED && now - oldestUncorrelated > absenceMs;
const aiTiers = degradedTiers.filter((t) => isAiTier(t.tier));
const ai_: PipelineStage = {
key: "ai", label: "AI", lastAt: newestAiAt,
state: aiDry || aiTiers.length ? "degraded" : reachable === false && !newestAiAt ? "unknown" : "ok",
detail: aiDry
? `transcribed, no incident written since ${newestAiAt ? clockTime(newestAiAt) : clockTime(new Date(oldestUncorrelated))}`
: `${newestAiAt ? `last incident write ${agoText(newestAiAt, now)}` : "no incident writes in the recent window"}${reachable === false ? " · /health/ai unreachable" : ""}`,
};
// ── Context feeds (chip only — a dead camera feed doesn't blind the pipeline) ──
const feedProblems: string[] = [];
if (eventsStatus && !eventsStatus.disabled) {
if (eventsStatus.error) feedProblems.push(`511: ${eventsStatus.error}`);
else if (eventsStatus.fetched_at && now / 1000 - eventsStatus.fetched_at > absenceMs / 1000)
feedProblems.push(`511 data ${Math.round((now / 1000 - eventsStatus.fetched_at) / 60)}m old`);
}
for (const p of cameraProviders) if (p.error) feedProblems.push(`${p.name}: ${p.error}`);
const feeds: PipelineStage = {
key: "feeds", label: "Feeds", lastAt: null,
state: feedProblems.length ? "degraded" : "ok",
detail: feedProblems.length ? feedProblems.join(" · ") : "511 / camera providers OK",
};
// ── Verdict ─────────────────────────────────────────────────────────────
const parts: string[] = [];
if (allNodesOffline) parts.push(`${radio.detail}${newestCallAt ? `, last call heard ${clockTime(newestCallAt)}` : ""}`);
else if (radioDry) parts.push(`nodes checking in, no radio heard since ${newestCallAt ? clockTime(newestCallAt) : "—"}`);
if (sttDry) parts.push(stt.detail);
if (aiDry) parts.push(ai_.detail);
for (const t of degradedTiers) parts.push(tierText(t.tier, t.state));
const degraded = parts.length > 0;
return {
stages: [radio, stt, ai_, feeds],
degraded,
message: degraded ? parts.join(" · ") : null,
degradedTiers,
aiHealthReachable: reachable,
nodesHearing,
newestCallAt,
staleMin: WALL_STALE_MIN,
};
}
+10
View File
@@ -154,6 +154,16 @@ service cloud.firestore {
allow read, write: if false; allow read, write: if false;
} }
// Radio-bot OAuth2 client secrets and in-flight "Connect a Discord
// server" states (server-26#225 S6). Secrets: c2-core only, never any client.
match /bot_oauth/{tokenId} {
allow read, write: if false;
}
match /discord_oauth_states/{stateHash} {
allow read, write: if false;
}
match /waitlist/{entryId} { match /waitlist/{entryId} {
allow read, write: if false; // POST /waitlist writes via the admin SDK allow read, write: if false; // POST /waitlist writes via the admin SDK
} }