diff --git a/drb-c2-core/app/internal/discord_guild_link.py b/drb-c2-core/app/internal/discord_guild_link.py index af9da2d..8bb3c62 100644 --- a/drb-c2-core/app/internal/discord_guild_link.py +++ b/drb-c2-core/app/internal/discord_guild_link.py @@ -27,6 +27,16 @@ Firestore layout: unlinked_at|None, unlinked_by|None 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 @@ -35,6 +45,7 @@ import secrets from datetime import datetime, timedelta, timezone from typing import Optional +from app.internal import discord_streams as streams from app.internal import firestore as fstore CODES = "discord_link_codes" @@ -91,6 +102,63 @@ async def mint_code(org_id: str, uid: str) -> dict: "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): """Build the transaction body. Every read happens before any write.""" 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", "That link code has expired. Generate a new one in DRB → " "Settings → Organization.")} - org_id = doc["org_id"] - current = (guild or {}).get("org_id") - if current and current != org_id: - return {"error": LinkError(409, "linked_elsewhere", - "This server is already linked to a different DRB organization. " - "That organization has to unlink it first.")} - - iso = now.isoformat() - txn.update(code_ref, {"redeemed_at": iso, "redeemed_guild_id": guild_id, + out = _apply_link(txn, guild_ref, guild, org_id=doc["org_id"], guild_id=guild_id, + guild_name=guild_name, linked_by=f"discord:{discord_user_id}", via="code", now=now) + if isinstance(out, LinkError): + return {"error": out} # the code is NOT consumed: its org can use it elsewhere + txn.update(code_ref, {"redeemed_at": now.isoformat(), "redeemed_guild_id": guild_id, "redeemed_by_discord_id": discord_user_id}) - txn.set(guild_ref, { - "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 {**out, "created_by_uid": doc.get("created_by_uid")} return _txn 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) if len(code) != _CODE_LEN: 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 +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]: return [ {"guild_id": g.get("guild_id"), "guild_name": g.get("guild_name"), diff --git a/drb-c2-core/app/internal/discord_oauth.py b/drb-c2-core/app/internal/discord_oauth.py new file mode 100644 index 0000000..ed8f111 --- /dev/null +++ b/drb-c2-core/app/internal/discord_oauth.py @@ -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"] diff --git a/drb-c2-core/app/internal/discord_rest.py b/drb-c2-core/app/internal/discord_rest.py index 5129fe7..515d264 100644 --- a/drb-c2-core/app/internal/discord_rest.py +++ b/drb-c2-core/app/internal/discord_rest.py @@ -655,3 +655,100 @@ async def guild_snapshot(token_doc: dict, guild_id: str, *, refresh: bool = Fals error=f"check failed ({type(e).__name__})") 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) diff --git a/drb-c2-core/app/internal/discord_streams.py b/drb-c2-core/app/internal/discord_streams.py index 4950d3c..7f27239 100644 --- a/drb-c2-core/app/internal/discord_streams.py +++ b/drb-c2-core/app/internal/discord_streams.py @@ -53,6 +53,7 @@ A retained MQTT message therefore never stores a bot credential on the broker. from __future__ import annotations import asyncio +import time from datetime import datetime, timezone, timedelta from typing import Optional @@ -93,6 +94,17 @@ def _parse_ts(value) -> Optional[datetime]: # 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]: """The org a Discord server acts for: its ``discord_guilds`` link, else ``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) gets no fallback: otherwise unlinking org B's server would quietly hand it to the founding org.""" - link = 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 + return acting_org_of(await fstore.doc_get(GUILDS, str(guild_id))) 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} +# 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, - 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: return None 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, 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: @@ -484,6 +516,7 @@ async def start_stream( preferred_token_id: Optional[str] = None, max_streams: Optional[int] = None, stream_id: Optional[str] = None, + interactive: bool = False, ) -> dict: """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 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. + + ``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"): 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, 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, - existing=existing, + existing=existing, interactive=interactive, ) 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], 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 if stream_id: 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} 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()): 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.", @@ -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. 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) + node_ids=node_ids, recheck_denied=interactive) 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, soft_preferred_token_id=soft_pref, perms=perms) diff --git a/drb-c2-core/app/internal/outages/__init__.py b/drb-c2-core/app/internal/outages/__init__.py new file mode 100644 index 0000000..948fe44 --- /dev/null +++ b/drb-c2-core/app/internal/outages/__init__.py @@ -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. +""" diff --git a/drb-c2-core/app/internal/outages/base.py b/drb-c2-core/app/internal/outages/base.py new file mode 100644 index 0000000..072fa57 --- /dev/null +++ b/drb-c2-core/app/internal/outages/base.py @@ -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)) diff --git a/drb-c2-core/app/internal/outages/providers.py b/drb-c2-core/app/internal/outages/providers.py new file mode 100644 index 0000000..520a2c9 --- /dev/null +++ b/drb-c2-core/app/internal/outages/providers.py @@ -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"), +] diff --git a/drb-c2-core/app/internal/outages/registry.py b/drb-c2-core/app/internal/outages/registry.py new file mode 100644 index 0000000..0d3d8ce --- /dev/null +++ b/drb-c2-core/app/internal/outages/registry.py @@ -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": {"": {"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} diff --git a/drb-c2-core/app/internal/storage.py b/drb-c2-core/app/internal/storage.py index 18af633..deea51f 100644 --- a/drb-c2-core/app/internal/storage.py +++ b/drb-c2-core/app/internal/storage.py @@ -121,6 +121,67 @@ async def download_audio(gcs_uri: str) -> Optional[bytes]: 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]]: """``gs://bucket/path/to.mp3`` → ``("bucket", "path/to.mp3")``.""" if not gcs_uri or not gcs_uri.startswith("gs://"): diff --git a/drb-c2-core/app/main.py b/drb-c2-core/app/main.py index 8ebe83a..d08ae53 100644 --- a/drb-c2-core/app/main.py +++ b/drb-c2-core/app/main.py @@ -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 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_link as discord_link_router from app.internal import dynsec from app.internal import firestore as fstore from app.routers import discord_web from app.routers import discord_checks +from app.routers import discord_oauth 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(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(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(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) @@ -166,6 +170,7 @@ app.include_router(discord_web.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_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 # backend for the mosquitto-go-auth plugin). That plugin's upstream project # is archived (no CVE patches) and was rejected for an internet-facing diff --git a/drb-c2-core/app/routers/discord.py b/drb-c2-core/app/routers/discord.py index 70129e2..4851249 100644 --- a/drb-c2-core/app/routers/discord.py +++ b/drb-c2-core/app/routers/discord.py @@ -17,7 +17,6 @@ Auth: from __future__ import annotations import secrets -from datetime import datetime, timezone from typing import Optional from fastapi import APIRouter, Depends, HTTPException, Path, Security @@ -26,6 +25,7 @@ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer 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_rest 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, created_by=body.created_by, preferred_token_id=body.preferred_token_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: 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) if not org: raise HTTPException(404, "Organization not found.") - previous_org = await streams.resolve_guild_org(guild_id) - doc = { - "guild_id": guild_id, "org_id": body.org_id, - "linked_by": decoded.get("uid"), "linked_at": datetime.now(timezone.utc).isoformat(), - # Re-linking a server that was unlinked or removed brings it back. - "unlinked_at": None, "unlinked_by": None, "removed_at": None, "removed_by": None, - } - if body.guild_name and body.guild_name.strip(): - doc["guild_name"] = body.guild_name.strip()[:100] - await fstore.doc_set(streams.GUILDS, guild_id, doc) - # 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 - # 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} + # Support override: may move a server between orgs. Re-linking a server + # that was unlinked or removed brings it back. Live channels placed on the + # previous org's nodes are stopped (glink._settle) — left running, the + # reconciler would keep re-placing them there. + out = await glink.link_and_settle(body.org_id, guild_id, body.guild_name, decoded.get("uid") or "admin", + "admin", allow_move=True, stop_reason=glink.ADMIN_RELINK_STOP_REASON) + uid, email = describe_actor(decoded) + await write_audit(uid, email, "discord.guild.link", details={ + "guild_id": guild_id, "org_id": body.org_id, "previous_org_id": out["previous_org_id"], + "already_linked": out["already_linked"], "streams_stopped": len(out["streams_stopped"]), "via": "admin", + }) + return {"ok": True, "guild_id": guild_id, "org_id": body.org_id, "stopped": out["streams_stopped"]} @router.delete("/streams/{stream_id}") diff --git a/drb-c2-core/app/routers/discord_link.py b/drb-c2-core/app/routers/discord_link.py index 5a85500..2813103 100644 --- a/drb-c2-core/app/routers/discord_link.py +++ b/drb-c2-core/app/routers/discord_link.py @@ -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_user_limiter.check(f"user:{body.discord_user_id}") actor = f"discord:{body.discord_user_id}" - before = await streams.resolve_guild_org(guild_id) 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: await write_audit(actor, "", "discord.guild.link_refused", details={"guild_id": guild_id, "reason": e.code}) return JSONResponse(status_code=e.status, content={"detail": e.message, "code": e.code}) org_id = result["org_id"] - stopped: list = [] - 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") + stopped = result["streams_stopped"] await write_audit(actor, "", "discord.guild.link", details={ "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), + "via": "code", }) logger.info(f"Discord guild {guild_id} linked to org={org_id!r} by {actor}") org = await fstore.doc_get("organizations", org_id) diff --git a/drb-c2-core/app/routers/discord_oauth.py b/drb-c2-core/app/routers/discord_oauth.py new file mode 100644 index 0000000..4a793da --- /dev/null +++ b/drb-c2-core/app/routers/discord_oauth.py @@ -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 [], + } diff --git a/drb-c2-core/app/routers/discord_web.py b/drb-c2-core/app/routers/discord_web.py index 8dd8df4..6d09b76 100644 --- a/drb-c2-core/app/routers/discord_web.py +++ b/drb-c2-core/app/routers/discord_web.py @@ -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 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 - * their voice channels, from a directory the DRB server bot pushes - (``PUT /discord/guilds/{id}/directory``). The bot already holds its - gateway cache of every server it is in; C2 never calls Discord with a - token on the browser's behalf and no token string ever reaches the browser. + * their voice channels, from a directory kept on ``discord_guilds/{id}``. + +Where the directory comes from (server-26#225 S5 — the main bot is optional): + + * 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** (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 -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. Auth: @@ -29,6 +52,7 @@ Auth: """ from __future__ import annotations +import asyncio from datetime import datetime, timezone from typing import Optional @@ -38,6 +62,7 @@ from pydantic import BaseModel, Field from app.config import settings 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 firestore as fstore from app.internal.audit import write_audit @@ -54,6 +79,14 @@ router = APIRouter(prefix="/discord", tags=["discord"]) MAX_CHANNELS = 200 _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: @@ -80,16 +113,20 @@ class GuildDirectory(BaseModel): @router.put("/guilds/{guild_id}/directory") 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. - 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(): raise HTTPException(400, "guild_id must be a Discord snowflake.") data: dict = {"guild_id": guild_id, "bot_present": body.present, "directory_at": _now_iso()} if body.present: data["voice_channels"] = [c.model_dump() for c in body.voice_channels] + data["directory_source"] = MAIN_BOT if body.name: # Same field the /drb link flow writes (feat/213-guild-link, PR #218). data["guild_name"] = body.name 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)} @@ -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.") -async def _org_guilds(org_id: str) -> list[dict]: - """``discord_guilds`` docs this org may stream into, bot still present. +async def _load_tokens() -> dict[str, dict]: + 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 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 + if tokens is None: + tokens = await _load_tokens() out = [] for g in await fstore.collection_list(streams.GUILDS): gid = str(g.get("guild_id") or "") - if not gid or g.get("bot_present") is False: + if not gid: continue - owner = g.get("org_id") or (None if g.get("unlinked_at") else fallback) - if owner and owner == org_id: - out.append(g) + 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 + out.append(g) out.sort(key=lambda g: ((_guild_name(g) or "").lower(), g["guild_id"])) 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: res = leases.pick_token(tokens, node_id=node_id, guild_id=guild_id, org_id=org_id) 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 = (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") 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) @@ -190,7 +362,6 @@ async def join_options(node_id: str, decoded: dict = Depends(require_firebase_to by_channel: dict[tuple, list] = {} 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) - 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()} 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")), "synced": g.get("voice_channels") is not None, "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, "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), @@ -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.") 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: 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: raise HTTPException(400, "That is not a voice channel DRB can see in this server. " "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), created_by=f"web:{decoded.get('uid') or 'unknown'}", max_streams=settings.discord_guild_max_streams, + interactive=True, ) except streams.StreamError as e: return JSONResponse(status_code=e.status, diff --git a/drb-c2-core/app/routers/media.py b/drb-c2-core/app/routers/media.py index 67a35ad..e01b1ae 100644 --- a/drb-c2-core/app/routers/media.py +++ b/drb-c2-core/app/routers/media.py @@ -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 Application Default Credentials, so no GCS signed URL — and therefore no 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.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"]) +# 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") async def get_call_audio( call_id: str, exp: int = Query(..., description="Link expiry, unix seconds."), 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. if not verify_audio_link(call_id, exp, sig): @@ -36,26 +117,48 @@ async def get_call_audio( if not gcs_uri: raise HTTPException(404, "No audio for this call.") - data = await download_audio(gcs_uri) - if not data: + size = await audio_object_size(gcs_uri) + if size is None: raise HTTPException(404, "Audio object missing from storage.") - return Response( - content=data, - # 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. - media_type=content_type_for(gcs_uri), - headers={ - "Content-Length": str(len(data)), - # Whole body at once, no Range support. This was comfortable at - # 16 kbps mono (~60 KB for a 30 s call); FLAC is ~1.3 MB/min, so a - # long call is now tens of MB and the browser must download all of - # it before playback starts. Acceptable for typical few-second - # transmissions, but this is the change that makes Range support - # actually matter — see DEFERRED.md. - "Accept-Ranges": "none", - # Immutable content, but the URL expires — cache privately only. - "Cache-Control": "private, max-age=3600", - }, + # 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. + media_type = content_type_for(gcs_uri) + + try: + span = parse_range(range_header, size) + except _Unsatisfiable: + return Response( + status_code=416, + headers={"Content-Range": f"bytes */{size}", "Accept-Ranges": "bytes"}, + ) + + if size == 0: + 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, ) diff --git a/drb-c2-core/app/routers/nodes.py b/drb-c2-core/app/routers/nodes.py index 3707366..85da6b2 100644 --- a/drb-c2-core/app/routers/nodes.py +++ b/drb-c2-core/app/routers/nodes.py @@ -210,7 +210,7 @@ async def send_command( doc = await discord_streams.start_stream( 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, - mode="adhoc", + mode="adhoc", interactive=True, ) except discord_streams.StreamError as e: raise HTTPException(e.status, e.message) diff --git a/drb-c2-core/app/routers/outages.py b/drb-c2-core/app/routers/outages.py new file mode 100644 index 0000000..73b07fa --- /dev/null +++ b/drb-c2-core/app/routers/outages.py @@ -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}) diff --git a/drb-c2-core/app/routers/tokens.py b/drb-c2-core/app/routers/tokens.py index 9c23005..5db3ed0 100644 --- a/drb-c2-core/app/routers/tokens.py +++ b/drb-c2-core/app/routers/tokens.py @@ -2,8 +2,10 @@ import uuid from fastapi import APIRouter, HTTPException, Depends, Query from pydantic import BaseModel from typing import Optional +from app.config import settings from app.internal import firestore as fstore from app.internal import discord_leases as leases +from app.internal import discord_oauth from app.internal import discord_rest from app.internal.audit import write_audit from app.internal.auth import ( @@ -27,9 +29,27 @@ class TokenCreate(BaseModel): org_id: Optional[str] = None -def _masked(t: dict) -> dict: - out = {**t, "token": "•••" + (t.get("token") or "")[-4:]} +# What GET /tokens may return of a bot_tokens doc (server-26#225 S6: an +# 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")) + 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 @@ -47,7 +67,8 @@ async def _republish_after_release(lease: Optional[dict]) -> None: @router.get("") async def list_tokens(_: dict = Depends(require_admin_token)): """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) @@ -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): raise HTTPException(404, "Token not found.") 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) @@ -265,3 +286,57 @@ async def delete_token(token_id: str, _: dict = Depends(require_admin_token)): if existing.get("lease"): 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 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} diff --git a/drb-c2-core/tests/fixtures/odin_public_2026-10-05.json b/drb-c2-core/tests/fixtures/odin_public_2026-10-05.json new file mode 100644 index 0000000..4f91304 --- /dev/null +++ b/drb-c2-core/tests/fixtures/odin_public_2026-10-05.json @@ -0,0 +1,3384 @@ +{ + "_recorded": "2026-10-05 from https://odin.ornl.gov public endpoints (OpenAPI: https://odin.ornl.gov/v3/api-docs). Rows filtered to four counties; values verbatim.", + "odi_map_scale_county": [ + { + "countyName": "King", + "feature": { + "coordinates": [ + [ + [ + -122.475914, + 47.525282 + ], + [ + -122.458321, + 47.532735 + ], + [ + -122.447017, + 47.540044 + ], + [ + -122.444854, + 47.543333 + ], + [ + -122.444297, + 47.555565 + ], + [ + -122.454766, + 47.573685 + ], + [ + -122.453582, + 47.581237 + ], + [ + -122.447569, + 47.593313 + ], + [ + -122.446063, + 47.607998 + ], + [ + -122.44867, + 47.61651 + ], + [ + -122.454908, + 47.626561 + ], + [ + -122.452812, + 47.643798 + ], + [ + -122.459375, + 47.669161 + ], + [ + -122.4597, + 47.675788 + ], + [ + -122.457614, + 47.685171 + ], + [ + -122.446925, + 47.704478 + ], + [ + -122.43746, + 47.713048 + ], + [ + -122.433464, + 47.728908 + ], + [ + -122.42992, + 47.73528 + ], + [ + -122.427149, + 47.745364 + ], + [ + -122.42943, + 47.762425 + ], + [ + -122.438093, + 47.777814 + ], + [ + -121.991775, + 47.775349 + ], + [ + -121.884438, + 47.777217 + ], + [ + -121.497024, + 47.777832 + ], + [ + -121.455545, + 47.778426 + ], + [ + -121.455535, + 47.780576 + ], + [ + -121.119179, + 47.779933 + ], + [ + -121.119483, + 47.775839 + ], + [ + -121.107703, + 47.766683 + ], + [ + -121.110505, + 47.761597 + ], + [ + -121.110506, + 47.757827 + ], + [ + -121.10646, + 47.756806 + ], + [ + -121.101278, + 47.75714 + ], + [ + -121.0988, + 47.759105 + ], + [ + -121.094018, + 47.758184 + ], + [ + -121.089914, + 47.746321 + ], + [ + -121.082126, + 47.743025 + ], + [ + -121.078995, + 47.73919 + ], + [ + -121.081524, + 47.734468 + ], + [ + -121.084945, + 47.732297 + ], + [ + -121.08478, + 47.72991 + ], + [ + -121.087347, + 47.728629 + ], + [ + -121.083949, + 47.723068 + ], + [ + -121.069965, + 47.717829 + ], + [ + -121.066005, + 47.713603 + ], + [ + -121.067117, + 47.706272 + ], + [ + -121.079371, + 47.701964 + ], + [ + -121.090288, + 47.702834 + ], + [ + -121.090887, + 47.699067 + ], + [ + -121.094609, + 47.695034 + ], + [ + -121.105513, + 47.688815 + ], + [ + -121.121584, + 47.685213 + ], + [ + -121.123679, + 47.680208 + ], + [ + -121.128824, + 47.676181 + ], + [ + -121.128914, + 47.672155 + ], + [ + -121.123179, + 47.663326 + ], + [ + -121.125635, + 47.657159 + ], + [ + -121.131884, + 47.655664 + ], + [ + -121.131009, + 47.653122 + ], + [ + -121.133697, + 47.645605 + ], + [ + -121.125971, + 47.637403 + ], + [ + -121.127868, + 47.632101 + ], + [ + -121.125266, + 47.628128 + ], + [ + -121.118762, + 47.626971 + ], + [ + -121.113902, + 47.621945 + ], + [ + -121.113099, + 47.618986 + ], + [ + -121.120066, + 47.615675 + ], + [ + -121.120284, + 47.612823 + ], + [ + -121.117909, + 47.607208 + ], + [ + -121.111887, + 47.602515 + ], + [ + -121.111115, + 47.599715 + ], + [ + -121.113569, + 47.597288 + ], + [ + -121.11792, + 47.596758 + ], + [ + -121.11946, + 47.591507 + ], + [ + -121.137806, + 47.595749 + ], + [ + -121.142693, + 47.591993 + ], + [ + -121.152149, + 47.595578 + ], + [ + -121.172192, + 47.590233 + ], + [ + -121.170755, + 47.586006 + ], + [ + -121.172985, + 47.583384 + ], + [ + -121.169846, + 47.578494 + ], + [ + -121.171781, + 47.574323 + ], + [ + -121.177019, + 47.569621 + ], + [ + -121.176718, + 47.566687 + ], + [ + -121.179941, + 47.564956 + ], + [ + -121.188306, + 47.568731 + ], + [ + -121.194701, + 47.566125 + ], + [ + -121.208538, + 47.565957 + ], + [ + -121.216852, + 47.569773 + ], + [ + -121.2289, + 47.565603 + ], + [ + -121.233025, + 47.560616 + ], + [ + -121.232033, + 47.557954 + ], + [ + -121.234056, + 47.555944 + ], + [ + -121.233362, + 47.550567 + ], + [ + -121.235695, + 47.548474 + ], + [ + -121.234583, + 47.545637 + ], + [ + -121.243467, + 47.542963 + ], + [ + -121.241475, + 47.53894 + ], + [ + -121.243053, + 47.53831 + ], + [ + -121.242636, + 47.535995 + ], + [ + -121.2516, + 47.532045 + ], + [ + -121.255616, + 47.533476 + ], + [ + -121.256961, + 47.529199 + ], + [ + -121.262284, + 47.523602 + ], + [ + -121.278999, + 47.522941 + ], + [ + -121.285534, + 47.517034 + ], + [ + -121.283391, + 47.514869 + ], + [ + -121.28651, + 47.513201 + ], + [ + -121.29195, + 47.513551 + ], + [ + -121.288473, + 47.50933 + ], + [ + -121.29222, + 47.50209 + ], + [ + -121.300347, + 47.495842 + ], + [ + -121.302698, + 47.486311 + ], + [ + -121.311166, + 47.482935 + ], + [ + -121.312037, + 47.479167 + ], + [ + -121.314676, + 47.477879 + ], + [ + -121.316223, + 47.479838 + ], + [ + -121.324552, + 47.479422 + ], + [ + -121.331991, + 47.473689 + ], + [ + -121.338673, + 47.476388 + ], + [ + -121.341599, + 47.474609 + ], + [ + -121.348117, + 47.465239 + ], + [ + -121.363923, + 47.464833 + ], + [ + -121.379259, + 47.454305 + ], + [ + -121.379105, + 47.450464 + ], + [ + -121.385275, + 47.444982 + ], + [ + -121.381455, + 47.438448 + ], + [ + -121.385247, + 47.434389 + ], + [ + -121.403087, + 47.431246 + ], + [ + -121.414065, + 47.422017 + ], + [ + -121.422616, + 47.422315 + ], + [ + -121.427252, + 47.420846 + ], + [ + -121.428378, + 47.416226 + ], + [ + -121.424199, + 47.415762 + ], + [ + -121.422305, + 47.412974 + ], + [ + -121.42282, + 47.405995 + ], + [ + -121.427476, + 47.402566 + ], + [ + -121.430356, + 47.403171 + ], + [ + -121.433816, + 47.399107 + ], + [ + -121.427357, + 47.395742 + ], + [ + -121.426104, + 47.389854 + ], + [ + -121.430013, + 47.386978 + ], + [ + -121.440509, + 47.385771 + ], + [ + -121.441484, + 47.380585 + ], + [ + -121.43777, + 47.371847 + ], + [ + -121.443939, + 47.373815 + ], + [ + -121.453003, + 47.371799 + ], + [ + -121.458978, + 47.373116 + ], + [ + -121.465895, + 47.368106 + ], + [ + -121.461257, + 47.36134 + ], + [ + -121.464064, + 47.353818 + ], + [ + -121.449748, + 47.347925 + ], + [ + -121.443834, + 47.343392 + ], + [ + -121.438423, + 47.343726 + ], + [ + -121.430511, + 47.334487 + ], + [ + -121.431574, + 47.329828 + ], + [ + -121.43608, + 47.328594 + ], + [ + -121.439333, + 47.323548 + ], + [ + -121.438455, + 47.317538 + ], + [ + -121.445331, + 47.309816 + ], + [ + -121.443606, + 47.307898 + ], + [ + -121.432682, + 47.304955 + ], + [ + -121.430667, + 47.296441 + ], + [ + -121.432846, + 47.293829 + ], + [ + -121.427492, + 47.289075 + ], + [ + -121.419419, + 47.286726 + ], + [ + -121.419133, + 47.290837 + ], + [ + -121.4163, + 47.293499 + ], + [ + -121.409781, + 47.289217 + ], + [ + -121.394195, + 47.288767 + ], + [ + -121.390562, + 47.286038 + ], + [ + -121.372603, + 47.284972 + ], + [ + -121.369903, + 47.285589 + ], + [ + -121.370774, + 47.288325 + ], + [ + -121.366989, + 47.290932 + ], + [ + -121.361778, + 47.291935 + ], + [ + -121.359676, + 47.285526 + ], + [ + -121.341568, + 47.281658 + ], + [ + -121.341316, + 47.277982 + ], + [ + -121.33835, + 47.275969 + ], + [ + -121.338746, + 47.268322 + ], + [ + -121.335417, + 47.263539 + ], + [ + -121.335885, + 47.260629 + ], + [ + -121.331941, + 47.258774 + ], + [ + -121.331979, + 47.257418 + ], + [ + -121.334029, + 47.25421 + ], + [ + -121.33755, + 47.2529 + ], + [ + -121.339833, + 47.247146 + ], + [ + -121.346545, + 47.246426 + ], + [ + -121.352504, + 47.24229 + ], + [ + -121.360747, + 47.240526 + ], + [ + -121.358489, + 47.237573 + ], + [ + -121.358368, + 47.234419 + ], + [ + -121.365569, + 47.226984 + ], + [ + -121.365109, + 47.224431 + ], + [ + -121.346241, + 47.218686 + ], + [ + -121.335226, + 47.219179 + ], + [ + -121.325183, + 47.216086 + ], + [ + -121.311197, + 47.207658 + ], + [ + -121.311631, + 47.205571 + ], + [ + -121.309207, + 47.20339 + ], + [ + -121.311763, + 47.199675 + ], + [ + -121.306466, + 47.192758 + ], + [ + -121.306138, + 47.187954 + ], + [ + -121.309955, + 47.185255 + ], + [ + -121.311846, + 47.179541 + ], + [ + -121.317823, + 47.173387 + ], + [ + -121.311901, + 47.164037 + ], + [ + -121.306013, + 47.162 + ], + [ + -121.29755, + 47.148673 + ], + [ + -121.301243, + 47.145187 + ], + [ + -121.303413, + 47.13889 + ], + [ + -121.315534, + 47.133844 + ], + [ + -121.320757, + 47.135292 + ], + [ + -121.335095, + 47.132683 + ], + [ + -121.339903, + 47.135697 + ], + [ + -121.349742, + 47.1376 + ], + [ + -121.353898, + 47.14054 + ], + [ + -121.364677, + 47.141759 + ], + [ + -121.368215, + 47.13959 + ], + [ + -121.381317, + 47.137255 + ], + [ + -121.388581, + 47.133538 + ], + [ + -121.393968, + 47.132762 + ], + [ + -121.394872, + 47.128672 + ], + [ + -121.399148, + 47.126574 + ], + [ + -121.401344, + 47.122939 + ], + [ + -121.406237, + 47.120721 + ], + [ + -121.408081, + 47.1181 + ], + [ + -121.404323, + 47.108639 + ], + [ + -121.399058, + 47.104598 + ], + [ + -121.402526, + 47.098013 + ], + [ + -121.392999, + 47.092023 + ], + [ + -121.382766, + 47.094769 + ], + [ + -121.38046, + 47.090996 + ], + [ + -121.376893, + 47.089875 + ], + [ + -121.379961, + 47.087248 + ], + [ + -121.394004, + 47.088981 + ], + [ + -121.406655, + 47.088588 + ], + [ + -121.411971, + 47.090699 + ], + [ + -121.418027, + 47.088223 + ], + [ + -121.441756, + 47.084402 + ], + [ + -121.454736, + 47.089353 + ], + [ + -121.471879, + 47.099739 + ], + [ + -121.480682, + 47.110636 + ], + [ + -121.485711, + 47.111966 + ], + [ + -121.489776, + 47.116367 + ], + [ + -121.505257, + 47.117975 + ], + [ + -121.50975, + 47.122503 + ], + [ + -121.51372, + 47.122973 + ], + [ + -121.51593, + 47.121557 + ], + [ + -121.531511, + 47.124579 + ], + [ + -121.535883, + 47.122546 + ], + [ + -121.551232, + 47.122925 + ], + [ + -121.560361, + 47.119537 + ], + [ + -121.573636, + 47.120267 + ], + [ + -121.581964, + 47.118658 + ], + [ + -121.594461, + 47.122228 + ], + [ + -121.595198, + 47.125255 + ], + [ + -121.605793, + 47.134902 + ], + [ + -121.606243, + 47.137416 + ], + [ + -121.60965, + 47.138382 + ], + [ + -121.611469, + 47.142991 + ], + [ + -121.617609, + 47.14468 + ], + [ + -121.617233, + 47.146485 + ], + [ + -121.621046, + 47.147812 + ], + [ + -121.620794, + 47.149399 + ], + [ + -121.629533, + 47.153428 + ], + [ + -121.637553, + 47.153308 + ], + [ + -121.643824, + 47.155594 + ], + [ + -121.648522, + 47.154934 + ], + [ + -121.662198, + 47.160044 + ], + [ + -121.668653, + 47.15587 + ], + [ + -121.680793, + 47.15257 + ], + [ + -121.684173, + 47.149785 + ], + [ + -121.705071, + 47.151997 + ], + [ + -121.719136, + 47.151116 + ], + [ + -121.728016, + 47.157632 + ], + [ + -121.738016, + 47.160371 + ], + [ + -121.743086, + 47.164538 + ], + [ + -121.749486, + 47.162072 + ], + [ + -121.762463, + 47.167509 + ], + [ + -121.769297, + 47.165417 + ], + [ + -121.773347, + 47.166492 + ], + [ + -121.777687, + 47.170456 + ], + [ + -121.777564, + 47.173383 + ], + [ + -121.783363, + 47.172222 + ], + [ + -121.796464, + 47.173059 + ], + [ + -121.799426, + 47.169827 + ], + [ + -121.803328, + 47.169248 + ], + [ + -121.805296, + 47.165831 + ], + [ + -121.810399, + 47.163867 + ], + [ + -121.813312, + 47.159501 + ], + [ + -121.819297, + 47.158802 + ], + [ + -121.827069, + 47.152678 + ], + [ + -121.831296, + 47.151931 + ], + [ + -121.831689, + 47.148483 + ], + [ + -121.839617, + 47.143362 + ], + [ + -121.846125, + 47.14439 + ], + [ + -121.849453, + 47.147261 + ], + [ + -121.860413, + 47.146597 + ], + [ + -121.860849, + 47.150651 + ], + [ + -121.865477, + 47.152603 + ], + [ + -121.872936, + 47.150534 + ], + [ + -121.878466, + 47.15394 + ], + [ + -121.886187, + 47.152947 + ], + [ + -121.901538, + 47.155742 + ], + [ + -121.909422, + 47.150544 + ], + [ + -121.920888, + 47.149956 + ], + [ + -121.927892, + 47.147705 + ], + [ + -121.927858, + 47.144816 + ], + [ + -121.930913, + 47.142702 + ], + [ + -121.931907, + 47.139282 + ], + [ + -121.937864, + 47.138721 + ], + [ + -121.943816, + 47.14005 + ], + [ + -121.950487, + 47.14497 + ], + [ + -121.949937, + 47.150953 + ], + [ + -121.954188, + 47.155668 + ], + [ + -121.958674, + 47.157237 + ], + [ + -121.969515, + 47.156281 + ], + [ + -121.973171, + 47.160709 + ], + [ + -121.980581, + 47.164563 + ], + [ + -121.991446, + 47.163237 + ], + [ + -121.993932, + 47.17257 + ], + [ + -122.004533, + 47.170195 + ], + [ + -122.006774, + 47.170964 + ], + [ + -122.006695, + 47.172877 + ], + [ + -122.009758, + 47.175403 + ], + [ + -122.012724, + 47.17605 + ], + [ + -122.022306, + 47.173974 + ], + [ + -122.031934, + 47.175007 + ], + [ + -122.036433, + 47.172794 + ], + [ + -122.047485, + 47.17123 + ], + [ + -122.053683, + 47.17572 + ], + [ + -122.054297, + 47.178601 + ], + [ + -122.058429, + 47.179912 + ], + [ + -122.063385, + 47.185879 + ], + [ + -122.081336, + 47.190224 + ], + [ + -122.086339, + 47.193729 + ], + [ + -122.094764, + 47.194977 + ], + [ + -122.094831, + 47.198199 + ], + [ + -122.100713, + 47.200071 + ], + [ + -122.110303, + 47.209943 + ], + [ + -122.110916, + 47.212528 + ], + [ + -122.115764, + 47.215977 + ], + [ + -122.113242, + 47.217785 + ], + [ + -122.115686, + 47.220689 + ], + [ + -122.113036, + 47.223631 + ], + [ + -122.117514, + 47.225865 + ], + [ + -122.114864, + 47.22897 + ], + [ + -122.115747, + 47.231344 + ], + [ + -122.125459, + 47.232316 + ], + [ + -122.125873, + 47.234026 + ], + [ + -122.122511, + 47.237241 + ], + [ + -122.122968, + 47.239134 + ], + [ + -122.137585, + 47.246821 + ], + [ + -122.138334, + 47.250645 + ], + [ + -122.136755, + 47.254373 + ], + [ + -122.143976, + 47.257526 + ], + [ + -122.334914, + 47.257366 + ], + [ + -122.33483, + 47.26311 + ], + [ + -122.437252, + 47.333717 + ], + [ + -122.454485, + 47.324179 + ], + [ + -122.471551, + 47.31862 + ], + [ + -122.500513, + 47.317765 + ], + [ + -122.520417, + 47.322775 + ], + [ + -122.528059, + 47.327014 + ], + [ + -122.534781, + 47.33344 + ], + [ + -122.540942, + 47.344818 + ], + [ + -122.541661, + 47.349193 + ], + [ + -122.540063, + 47.355742 + ], + [ + -122.529072, + 47.372959 + ], + [ + -122.528362, + 47.377036 + ], + [ + -122.531115, + 47.383161 + ], + [ + -122.539269, + 47.392461 + ], + [ + -122.539761, + 47.396552 + ], + [ + -122.5364, + 47.404653 + ], + [ + -122.527917, + 47.416136 + ], + [ + -122.524499, + 47.423871 + ], + [ + -122.522743, + 47.449725 + ], + [ + -122.517672, + 47.464867 + ], + [ + -122.487908, + 47.50191 + ], + [ + -122.482815, + 47.518322 + ], + [ + -122.475914, + 47.525282 + ] + ] + ], + "type": "Polygon", + "properties": { + "outage_type": "COUNTY", + "metersOut": "3", + "county": "King", + "utility_1": "CITY OF SEATTLE - (WA)", + "source_type": "MIXED", + "state": "Washington", + "utility_0": "Puget Sound Energy Inc", + "precision_label": "County-level outage summary" + } + }, + "generalized": null, + "h3Resolution": null, + "id": "53033", + "metersOut": 3, + "outageType": "COUNTY", + "outages": [ + { + "areaId": null, + "cellIds": null, + "county": "53033", + "dataGeometry": null, + "envelope": { + "maxX": -122.034387741, + "maxY": 47.69667735, + "minX": -122.222013044, + "minY": 47.478707168 + }, + "metersOut": 2, + "metersServed": null, + "outageBbox": "{\"type\":\"Polygon\",\"coordinates\":[[[-122.222013044,47.478707168],[-122.222013044,47.478797097],[-122.221880394,47.478797097],[-122.221880394,47.478707168],[-122.222013044,47.478707168]]]}", + "outageIndex": 2, + "outageKind": "POLYGON", + "reported": 2, + "seq": null, + "state": "53", + "stateName": "Washington", + "tract": "53033026003,53033032333", + "utilityId": "4002", + "utilityName": "Puget Sound Energy Inc", + "zip": null + }, + { + "areaId": null, + "cellIds": null, + "county": "53033", + "dataGeometry": null, + "envelope": { + "maxX": -122.35167, + "maxY": 47.74612, + "minX": -122.35167, + "minY": 47.74612 + }, + "metersOut": 1, + "metersServed": 470380, + "outageBbox": "{\"type\":\"Point\",\"coordinates\":[-122.35167,47.74612]}", + "outageIndex": 0, + "outageKind": "POINT", + "reported": 1, + "seq": null, + "state": "53", + "stateName": "Washington", + "tract": "53033020900", + "utilityId": "2375", + "utilityName": "CITY OF SEATTLE - (WA)", + "zip": null + } + ], + "precisionLabel": "County-level outage summary", + "sourceType": "MIXED", + "stateName": "Washington", + "tract": null, + "zipCode": null + }, + { + "countyName": "Albany", + "feature": { + "coordinates": [ + [ + [ + -74.180274, + 42.729979 + ], + [ + -73.809603, + 42.775016 + ], + [ + -73.809369, + 42.778869 + ], + [ + -73.797965, + 42.779477 + ], + [ + -73.787065, + 42.789677 + ], + [ + -73.782464, + 42.791777 + ], + [ + -73.777364, + 42.791777 + ], + [ + -73.769564, + 42.786277 + ], + [ + -73.766664, + 42.786077 + ], + [ + -73.758466, + 42.795276 + ], + [ + -73.750165, + 42.801376 + ], + [ + -73.737663, + 42.818877 + ], + [ + -73.726663, + 42.822577 + ], + [ + -73.722663, + 42.820677 + ], + [ + -73.721363, + 42.813077 + ], + [ + -73.716663, + 42.808177 + ], + [ + -73.720363, + 42.804177 + ], + [ + -73.719863, + 42.801277 + ], + [ + -73.711363, + 42.795177 + ], + [ + -73.708963, + 42.787677 + ], + [ + -73.703163, + 42.782477 + ], + [ + -73.689162, + 42.775377 + ], + [ + -73.683457, + 42.779112 + ], + [ + -73.681162, + 42.782977 + ], + [ + -73.676762, + 42.783277 + ], + [ + -73.683762, + 42.766677 + ], + [ + -73.688962, + 42.738278 + ], + [ + -73.690162, + 42.735277 + ], + [ + -73.697244, + 42.728818 + ], + [ + -73.703563, + 42.709478 + ], + [ + -73.702463, + 42.699978 + ], + [ + -73.729127, + 42.665052 + ], + [ + -73.741764, + 42.654379 + ], + [ + -73.751165, + 42.63608 + ], + [ + -73.752965, + 42.628679 + ], + [ + -73.761265, + 42.610379 + ], + [ + -73.761265, + 42.600779 + ], + [ + -73.752365, + 42.57668 + ], + [ + -73.752165, + 42.56658 + ], + [ + -73.758313, + 42.534215 + ], + [ + -73.761332, + 42.525832 + ], + [ + -73.777085, + 42.503999 + ], + [ + -73.785548, + 42.487209 + ], + [ + -73.786078, + 42.475249 + ], + [ + -73.783721, + 42.464231 + ], + [ + -73.836757, + 42.458592 + ], + [ + -73.93326, + 42.444814 + ], + [ + -74.263469, + 42.407127 + ], + [ + -74.264863, + 42.419863 + ], + [ + -74.252424, + 42.445359 + ], + [ + -74.244457, + 42.470265 + ], + [ + -74.241629, + 42.472036 + ], + [ + -74.236447, + 42.485459 + ], + [ + -74.228501, + 42.494381 + ], + [ + -74.226447, + 42.499233 + ], + [ + -74.224985, + 42.50855 + ], + [ + -74.225639, + 42.526403 + ], + [ + -74.229065, + 42.52903 + ], + [ + -74.241572, + 42.550802 + ], + [ + -74.236959, + 42.56012 + ], + [ + -74.237621, + 42.563124 + ], + [ + -74.233995, + 42.575688 + ], + [ + -74.220274, + 42.585412 + ], + [ + -74.219639, + 42.591386 + ], + [ + -74.215166, + 42.597435 + ], + [ + -74.193495, + 42.618999 + ], + [ + -74.192569, + 42.627069 + ], + [ + -74.179324, + 42.646384 + ], + [ + -74.169725, + 42.667426 + ], + [ + -74.168525, + 42.67431 + ], + [ + -74.169525, + 42.691365 + ], + [ + -74.164678, + 42.717207 + ], + [ + -74.180274, + 42.729979 + ] + ] + ], + "type": "Polygon", + "properties": { + "outage_type": "COUNTY", + "metersOut": "3", + "county": "Albany", + "source_type": "COUNTY", + "state": "New York", + "utility_0": "CENTRAL HUDSON GAS & ELEC CORP", + "precision_label": "County-level outage summary" + } + }, + "generalized": null, + "h3Resolution": null, + "id": "36001", + "metersOut": 3, + "outageType": "COUNTY", + "outages": [ + { + "areaId": null, + "cellIds": null, + "county": "36001", + "dataGeometry": null, + "envelope": { + "maxX": -73.676762, + "maxY": 42.822577, + "minX": -74.264863, + "minY": 42.407127 + }, + "metersOut": 3, + "metersServed": 309263, + "outageBbox": "{\"type\":\"Polygon\",\"coordinates\":[[[-74.264863,42.407127],[-74.264863,42.822577],[-73.676762,42.822577],[-73.676762,42.407127],[-74.264863,42.407127]]]}", + "outageIndex": 0, + "outageKind": "COUNTY", + "reported": 1, + "seq": null, + "state": "36", + "stateName": "New York", + "tract": null, + "utilityId": "554", + "utilityName": "CENTRAL HUDSON GAS & ELEC CORP", + "zip": null + } + ], + "precisionLabel": "County-level outage summary", + "sourceType": "COUNTY", + "stateName": "New York", + "tract": null, + "zipCode": null + }, + { + "countyName": "Onondaga", + "feature": { + "coordinates": [ + [ + [ + -76.471774, + 43.104983 + ], + [ + -76.476125, + 43.125066 + ], + [ + -76.486303, + 43.143223 + ], + [ + -76.481227, + 43.145886 + ], + [ + -76.473066, + 43.146017 + ], + [ + -76.479224, + 43.227519 + ], + [ + -76.344632, + 43.23267 + ], + [ + -76.344582, + 43.231525 + ], + [ + -76.324213, + 43.232308 + ], + [ + -76.316117, + 43.229428 + ], + [ + -76.307541, + 43.230788 + ], + [ + -76.289012, + 43.2185 + ], + [ + -76.287572, + 43.211652 + ], + [ + -76.282915, + 43.206756 + ], + [ + -76.281027, + 43.201091 + ], + [ + -76.271351, + 43.203968 + ], + [ + -76.262051, + 43.218468 + ], + [ + -76.262151, + 43.223568 + ], + [ + -76.263551, + 43.226168 + ], + [ + -76.266851, + 43.227468 + ], + [ + -76.267751, + 43.232768 + ], + [ + -76.272151, + 43.234868 + ], + [ + -76.275551, + 43.238768 + ], + [ + -76.272851, + 43.240168 + ], + [ + -76.267551, + 43.239668 + ], + [ + -76.263751, + 43.236368 + ], + [ + -76.25745, + 43.236868 + ], + [ + -76.25775, + 43.234068 + ], + [ + -76.250001, + 43.225191 + ], + [ + -76.24465, + 43.214568 + ], + [ + -76.233849, + 43.212368 + ], + [ + -76.230049, + 43.207868 + ], + [ + -76.224449, + 43.204968 + ], + [ + -76.217873, + 43.204772 + ], + [ + -76.208336, + 43.208036 + ], + [ + -76.210649, + 43.210568 + ], + [ + -76.206176, + 43.216612 + ], + [ + -76.198748, + 43.212568 + ], + [ + -76.194248, + 43.217068 + ], + [ + -76.202548, + 43.222268 + ], + [ + -76.20472, + 43.22898 + ], + [ + -76.205008, + 43.237412 + ], + [ + -76.213393, + 43.24002 + ], + [ + -76.210609, + 43.244004 + ], + [ + -76.212977, + 43.256672 + ], + [ + -76.208825, + 43.263648 + ], + [ + -76.2119, + 43.267132 + ], + [ + -76.202944, + 43.271605 + ], + [ + -76.182104, + 43.257621 + ], + [ + -76.179626, + 43.253593 + ], + [ + -76.173533, + 43.251 + ], + [ + -76.169047, + 43.246668 + ], + [ + -76.148526, + 43.239636 + ], + [ + -76.139838, + 43.240356 + ], + [ + -76.130494, + 43.234527 + ], + [ + -76.115967, + 43.233561 + ], + [ + -76.106968, + 43.227476 + ], + [ + -76.10638, + 43.224312 + ], + [ + -76.099254, + 43.224937 + ], + [ + -76.084514, + 43.216769 + ], + [ + -76.080303, + 43.215808 + ], + [ + -76.079984, + 43.218052 + ], + [ + -76.079064, + 43.218593 + ], + [ + -76.078737, + 43.215461 + ], + [ + -76.069361, + 43.214667 + ], + [ + -76.068071, + 43.212558 + ], + [ + -76.081266, + 43.214319 + ], + [ + -76.085182, + 43.213081 + ], + [ + -76.084899, + 43.211083 + ], + [ + -76.08023, + 43.20997 + ], + [ + -76.079673, + 43.209132 + ], + [ + -76.0784, + 43.209364 + ], + [ + -76.076897, + 43.208449 + ], + [ + -76.08076, + 43.208616 + ], + [ + -76.082457, + 43.20685 + ], + [ + -76.081865, + 43.202257 + ], + [ + -76.078902, + 43.19948 + ], + [ + -76.057905, + 43.200724 + ], + [ + -76.055386, + 43.199168 + ], + [ + -76.053414, + 43.201426 + ], + [ + -76.051991, + 43.200737 + ], + [ + -76.053414, + 43.201826 + ], + [ + -76.052619, + 43.202857 + ], + [ + -76.047668, + 43.202612 + ], + [ + -76.034954, + 43.195502 + ], + [ + -76.032031, + 43.190919 + ], + [ + -76.023653, + 43.189947 + ], + [ + -76.011817, + 43.18558 + ], + [ + -76.007646, + 43.180467 + ], + [ + -76.002787, + 43.179469 + ], + [ + -76.003633, + 43.177295 + ], + [ + -76.005744, + 43.177774 + ], + [ + -76.003941, + 43.176757 + ], + [ + -76.002285, + 43.178139 + ], + [ + -76.002541, + 43.179621 + ], + [ + -75.991437, + 43.18325 + ], + [ + -75.989609, + 43.179225 + ], + [ + -75.992635, + 43.173849 + ], + [ + -75.990028, + 43.171527 + ], + [ + -75.988447, + 43.172616 + ], + [ + -75.986032, + 43.171554 + ], + [ + -75.983084, + 43.173706 + ], + [ + -75.98151, + 43.172779 + ], + [ + -75.979478, + 43.170698 + ], + [ + -75.981008, + 43.167704 + ], + [ + -75.978099, + 43.167606 + ], + [ + -75.978661, + 43.165095 + ], + [ + -75.97438, + 43.163175 + ], + [ + -75.977439, + 43.1594 + ], + [ + -75.977068, + 43.157646 + ], + [ + -75.971235, + 43.154639 + ], + [ + -75.973374, + 43.148996 + ], + [ + -75.971783, + 43.143847 + ], + [ + -75.977267, + 43.14047 + ], + [ + -75.977284, + 43.136195 + ], + [ + -75.973837, + 43.137342 + ], + [ + -75.975299, + 43.13912 + ], + [ + -75.972191, + 43.140469 + ], + [ + -75.96752, + 43.134947 + ], + [ + -75.964495, + 43.135168 + ], + [ + -75.963814, + 43.133632 + ], + [ + -75.957405, + 43.133672 + ], + [ + -75.95758, + 43.13629 + ], + [ + -75.955015, + 43.133927 + ], + [ + -75.953405, + 43.135459 + ], + [ + -75.950057, + 43.134853 + ], + [ + -75.953277, + 43.133363 + ], + [ + -75.950149, + 43.132061 + ], + [ + -75.954592, + 43.127986 + ], + [ + -75.956763, + 43.126804 + ], + [ + -75.958677, + 43.128328 + ], + [ + -75.958628, + 43.125396 + ], + [ + -75.960567, + 43.12327 + ], + [ + -75.958339, + 43.123728 + ], + [ + -75.95701, + 43.121973 + ], + [ + -75.95932, + 43.119597 + ], + [ + -75.956135, + 43.120266 + ], + [ + -75.957585, + 43.117695 + ], + [ + -75.955843, + 43.118314 + ], + [ + -75.9544, + 43.116857 + ], + [ + -75.956023, + 43.115148 + ], + [ + -75.960135, + 43.115395 + ], + [ + -75.963517, + 43.117563 + ], + [ + -75.963719, + 43.119571 + ], + [ + -75.965463, + 43.119651 + ], + [ + -75.965928, + 43.1154 + ], + [ + -75.971793, + 43.109029 + ], + [ + -75.970349, + 43.107567 + ], + [ + -75.97297, + 43.106634 + ], + [ + -75.971524, + 43.104914 + ], + [ + -75.976396, + 43.103038 + ], + [ + -75.976344, + 43.098694 + ], + [ + -75.974569, + 43.096509 + ], + [ + -75.975404, + 43.090921 + ], + [ + -75.971121, + 43.092468 + ], + [ + -75.971584, + 43.088903 + ], + [ + -75.969547, + 43.089549 + ], + [ + -75.96753, + 43.087625 + ], + [ + -75.968792, + 43.089529 + ], + [ + -75.967382, + 43.08992 + ], + [ + -75.965272, + 43.088863 + ], + [ + -75.966673, + 43.087531 + ], + [ + -75.963973, + 43.086697 + ], + [ + -75.960814, + 43.089011 + ], + [ + -75.961825, + 43.085505 + ], + [ + -75.954475, + 43.087464 + ], + [ + -75.954115, + 43.088823 + ], + [ + -75.95126, + 43.087315 + ], + [ + -75.950431, + 43.089629 + ], + [ + -75.948699, + 43.086817 + ], + [ + -75.947087, + 43.08895 + ], + [ + -75.939488, + 43.086663 + ], + [ + -75.938057, + 43.089109 + ], + [ + -75.93445, + 43.087102 + ], + [ + -75.931135, + 43.090221 + ], + [ + -75.928666, + 43.087214 + ], + [ + -75.92892, + 43.088986 + ], + [ + -75.926757, + 43.090565 + ], + [ + -75.919894, + 43.089876 + ], + [ + -75.918818, + 43.086115 + ], + [ + -75.917215, + 43.08591 + ], + [ + -75.896079, + 42.790964 + ], + [ + -76.08017, + 42.781599 + ], + [ + -76.080213, + 42.780014 + ], + [ + -76.274673, + 42.771257 + ], + [ + -76.275348, + 42.78047 + ], + [ + -76.29877, + 42.80746 + ], + [ + -76.356974, + 42.84945 + ], + [ + -76.450738, + 42.84576 + ], + [ + -76.462999, + 43.006316 + ], + [ + -76.491941, + 43.004964 + ], + [ + -76.499219, + 43.099837 + ], + [ + -76.497829, + 43.102657 + ], + [ + -76.491232, + 43.106295 + ], + [ + -76.473594, + 43.104029 + ], + [ + -76.471774, + 43.104983 + ] + ] + ], + "type": "Polygon", + "properties": { + "outage_type": "COUNTY", + "metersOut": "11", + "county": "Onondaga", + "source_type": "COUNTY", + "state": "New York", + "utility_0": "NATIONAL GRID", + "precision_label": "County-level outage summary" + } + }, + "generalized": null, + "h3Resolution": null, + "id": "36067", + "metersOut": 11, + "outageType": "COUNTY", + "outages": [ + { + "areaId": null, + "cellIds": null, + "county": "36067", + "dataGeometry": null, + "envelope": { + "maxX": -75.896079, + "maxY": 43.271605, + "minX": -76.499324, + "minY": 42.771257 + }, + "metersOut": 11, + "metersServed": 1686884, + "outageBbox": "{\"type\":\"Polygon\",\"coordinates\":[[[-76.499324,42.771257],[-76.499324,43.271605],[-75.896079,43.271605],[-75.896079,42.771257],[-76.499324,42.771257]]]}", + "outageIndex": 4, + "outageKind": "COUNTY", + "reported": 1, + "seq": null, + "state": "36", + "stateName": "New York", + "tract": null, + "utilityId": "1932", + "utilityName": "NATIONAL GRID", + "zip": null + } + ], + "precisionLabel": "County-level outage summary", + "sourceType": "COUNTY", + "stateName": "New York", + "tract": null, + "zipCode": null + }, + { + "countyName": "Monroe", + "feature": { + "coordinates": [ + [ + [ + -77.99729, + 43.132981 + ], + [ + -77.994777, + 43.375058 + ], + [ + -77.993982, + 43.631234 + ], + [ + -77.387995, + 43.631543 + ], + [ + -77.376215, + 43.345163 + ], + [ + -77.374582, + 43.160451 + ], + [ + -77.371478, + 43.034696 + ], + [ + -77.485418, + 43.034564 + ], + [ + -77.482517, + 42.943164 + ], + [ + -77.635022, + 42.944464 + ], + [ + -77.637522, + 42.939964 + ], + [ + -77.759052, + 42.940462 + ], + [ + -77.760736, + 42.942998 + ], + [ + -77.759418, + 42.944491 + ], + [ + -77.752545, + 42.94564 + ], + [ + -77.746371, + 42.944072 + ], + [ + -77.737554, + 42.947869 + ], + [ + -77.733124, + 42.945186 + ], + [ + -77.731181, + 42.946901 + ], + [ + -77.734469, + 42.954771 + ], + [ + -77.732568, + 42.955931 + ], + [ + -77.728407, + 42.953033 + ], + [ + -77.725032, + 42.959223 + ], + [ + -77.726199, + 42.965968 + ], + [ + -77.724586, + 42.971293 + ], + [ + -77.718837, + 42.971441 + ], + [ + -77.717828, + 42.973365 + ], + [ + -77.720748, + 42.979193 + ], + [ + -77.732457, + 42.981341 + ], + [ + -77.730957, + 42.988372 + ], + [ + -77.909832, + 42.987762 + ], + [ + -77.909414, + 43.00252 + ], + [ + -77.90754, + 43.002859 + ], + [ + -77.904121, + 43.037056 + ], + [ + -77.951044, + 43.039544 + ], + [ + -77.905934, + 43.133561 + ], + [ + -77.99729, + 43.132981 + ] + ] + ], + "type": "Polygon", + "properties": { + "outage_type": "COUNTY", + "metersOut": "18", + "county": "Monroe", + "source_type": "ZIP", + "state": "New York", + "utility_0": "AVANGRID", + "precision_label": "County-level outage summary" + } + }, + "generalized": null, + "h3Resolution": null, + "id": "36055", + "metersOut": 18, + "outageType": "COUNTY", + "outages": [ + { + "areaId": null, + "cellIds": null, + "county": "36055", + "dataGeometry": null, + "envelope": { + "maxX": -77.671293, + "maxY": 43.25294, + "minX": -77.759955, + "minY": 43.173908 + }, + "metersOut": 18, + "metersServed": null, + "outageBbox": "{\"type\":\"Polygon\",\"coordinates\":[[[-77.759955,43.173908],[-77.759955,43.25294],[-77.671293,43.25294],[-77.671293,43.173908],[-77.759955,43.173908]]]}", + "outageIndex": 6, + "outageKind": "ZIP", + "reported": 1, + "seq": null, + "state": "36", + "stateName": "New York", + "tract": null, + "utilityId": "10000", + "utilityName": "AVANGRID", + "zip": "14626" + } + ], + "precisionLabel": "County-level outage summary", + "sourceType": "ZIP", + "stateName": "New York", + "tract": null, + "zipCode": null + } + ], + "odi_format_json": { + "outage": [ + { + "MRID": "1165478", + "actualPeriod": null, + "cause": null, + "causeKind": null, + "communityDescriptor": "36001", + "customersRestored": null, + "estimatedPeriod": null, + "estimatedRestorationTime": { + "confidenceKind": null, + "ert": "2026-10-05T08:30:00Z", + "ertSource": null + }, + "incident": { + "cause": null, + "location": { + "coordinateSystem": { + "crsUrn": "EPSG:4326" + }, + "direction": null, + "geoInfoReference": null, + "kind": "ELECTRICAL_NETWORK", + "positionPoints": [], + "type": null + } + }, + "metersAffected": 3, + "mrid": "1165478", + "names": [ + { + "name": "CENTRAL HUDSON GAS & ELEC CORP", + "nameType": "UtilityName", + "nameTypeAuthority": "EIA" + }, + { + "name": "3249", + "nameType": "UtilityId", + "nameTypeAuthority": "EIA" + } + ], + "originalCustomersServed": null, + "originalMetersAffected": null, + "outageArea": { + "earliestReportedTime": "2026-10-04T22:14:30Z", + "metersServed": null, + "outageAreaKind": "COUNTY" + }, + "outageKind": null, + "reportedStartTime": "2026-10-04T22:14:30Z", + "statusKind": "assigned", + "utilityDisclaimer": "Based on reports from customers and field personnel, we predict the outages listed" + }, + { + "MRID": "951411", + "actualPeriod": null, + "cause": "Unknown", + "causeKind": "", + "communityDescriptor": "36067", + "customersRestored": null, + "estimatedPeriod": null, + "estimatedRestorationTime": { + "confidenceKind": null, + "ert": "2026-10-05T08:00:00Z", + "ertSource": null + }, + "incident": { + "cause": "Unknown", + "location": { + "coordinateSystem": { + "crsUrn": "EPSG:4326" + }, + "direction": null, + "geoInfoReference": null, + "kind": "ELECTRICAL_NETWORK", + "positionPoints": [], + "type": null + } + }, + "metersAffected": 11, + "mrid": "951411", + "names": [ + { + "name": "NATIONAL GRID", + "nameType": "UtilityName", + "nameTypeAuthority": "EIA" + }, + { + "name": "13573", + "nameType": "UtilityId", + "nameTypeAuthority": "EIA" + } + ], + "originalCustomersServed": null, + "originalMetersAffected": null, + "outageArea": { + "earliestReportedTime": "2026-10-04T18:35:40Z", + "metersServed": null, + "outageAreaKind": "COUNTY" + }, + "outageKind": null, + "reportedStartTime": "2026-10-04T18:35:40Z", + "statusKind": "", + "utilityDisclaimer": "" + }, + { + "MRID": null, + "actualPeriod": null, + "cause": "Investigating", + "causeKind": null, + "communityDescriptor": "53033", + "customersRestored": null, + "estimatedPeriod": null, + "estimatedRestorationTime": null, + "incident": { + "cause": "Investigating", + "location": { + "coordinateSystem": { + "crsUrn": "EPSG:4326" + }, + "direction": null, + "geoInfoReference": null, + "kind": "ELECTRICAL_NETWORK", + "positionPoints": [], + "type": null + } + }, + "metersAffected": 1, + "mrid": null, + "names": [ + { + "name": "CITY OF SEATTLE - (WA)", + "nameType": "UtilityName", + "nameTypeAuthority": "EIA" + }, + { + "name": "16868", + "nameType": "UtilityId", + "nameTypeAuthority": "EIA" + } + ], + "originalCustomersServed": null, + "originalMetersAffected": null, + "outageArea": { + "earliestReportedTime": "2026-10-05T06:04:00Z", + "metersServed": null, + "outageAreaKind": "COUNTY" + }, + "outageKind": null, + "reportedStartTime": "2026-10-05T06:04:00Z", + "statusKind": "Dispatched", + "utilityDisclaimer": null + }, + { + "MRID": "62bf6b17-1acc-485b-bc53-42dfca9fcfc8", + "actualPeriod": null, + "cause": "Scheduled Outage", + "causeKind": "lineDown", + "communityDescriptor": "53033", + "customersRestored": null, + "estimatedPeriod": null, + "estimatedRestorationTime": { + "confidenceKind": null, + "ert": "0001-01-01T00:00:00Z", + "ertSource": null + }, + "incident": { + "cause": "Scheduled Outage", + "location": { + "coordinateSystem": { + "crsUrn": "EPSG:4326" + }, + "direction": null, + "geoInfoReference": null, + "kind": "ELECTRICAL_NETWORK", + "positionPoints": [], + "type": null + } + }, + "metersAffected": 1, + "mrid": "62bf6b17-1acc-485b-bc53-42dfca9fcfc8", + "names": [ + { + "name": "Puget Sound Energy Inc", + "nameType": "UtilityName", + "nameTypeAuthority": "EIA" + }, + { + "name": "pugetsoundenergy", + "nameType": "UtilityId", + "nameTypeAuthority": "EIA" + } + ], + "originalCustomersServed": null, + "originalMetersAffected": null, + "outageArea": { + "earliestReportedTime": "2026-10-03T15:29:43Z", + "metersServed": null, + "outageAreaKind": "COUNTY" + }, + "outageKind": null, + "reportedStartTime": "2026-10-03T15:29:43Z", + "statusKind": "awaitingCrewAssignment", + "utilityDisclaimer": "None" + }, + { + "MRID": "a6fd7d77-f084-4d6f-9dac-08d274e0099f", + "actualPeriod": null, + "cause": "Under investigation", + "causeKind": "lineDown", + "communityDescriptor": "53033", + "customersRestored": null, + "estimatedPeriod": null, + "estimatedRestorationTime": { + "confidenceKind": null, + "ert": "0001-01-01T00:00:00Z", + "ertSource": null + }, + "incident": { + "cause": "Under investigation", + "location": { + "coordinateSystem": { + "crsUrn": "EPSG:4326" + }, + "direction": null, + "geoInfoReference": null, + "kind": "ELECTRICAL_NETWORK", + "positionPoints": [], + "type": null + } + }, + "metersAffected": 1, + "mrid": "a6fd7d77-f084-4d6f-9dac-08d274e0099f", + "names": [ + { + "name": "Puget Sound Energy Inc", + "nameType": "UtilityName", + "nameTypeAuthority": "EIA" + }, + { + "name": "pugetsoundenergy", + "nameType": "UtilityId", + "nameTypeAuthority": "EIA" + } + ], + "originalCustomersServed": null, + "originalMetersAffected": null, + "outageArea": { + "earliestReportedTime": "2026-10-05T03:23:27Z", + "metersServed": null, + "outageAreaKind": "COUNTY" + }, + "outageKind": null, + "reportedStartTime": "2026-10-05T03:23:27Z", + "statusKind": "awaitingCrewAssignment", + "utilityDisclaimer": "None" + }, + { + "MRID": "1650672", + "actualPeriod": null, + "cause": "N/A", + "causeKind": null, + "communityDescriptor": "36055", + "customersRestored": null, + "estimatedPeriod": null, + "estimatedRestorationTime": { + "confidenceKind": null, + "ert": "2026-04-10T22:15:00Z", + "ertSource": null + }, + "incident": { + "cause": "N/A", + "location": { + "coordinateSystem": { + "crsUrn": "EPSG:4326" + }, + "direction": null, + "geoInfoReference": null, + "kind": "ELECTRICAL_NETWORK", + "positionPoints": [], + "type": null + } + }, + "metersAffected": 18, + "mrid": "1650672", + "names": [ + { + "name": "AVANGRID", + "nameType": "UtilityName", + "nameTypeAuthority": "EIA" + }, + { + "name": "14349", + "nameType": "UtilityId", + "nameTypeAuthority": "EIA" + } + ], + "originalCustomersServed": null, + "originalMetersAffected": null, + "outageArea": { + "earliestReportedTime": "2026-04-10T19:13:29Z", + "metersServed": null, + "outageAreaKind": "COUNTY" + }, + "outageKind": null, + "reportedStartTime": "2026-04-10T19:13:29Z", + "statusKind": "assigned", + "utilityDisclaimer": null + } + ] + }, + "odi_status": [ + { + "dataId": "45ea5762-cf5c-46b3-b0fa-bb34c03925e4", + "dataResolution": "COUNTY", + "eiaId": "3249", + "hasError": false, + "name": "CENTRAL HUDSON GAS & ELEC CORP", + "outageFeed": null, + "receivedDate": "2026-10-05T06:21:16.964Z", + "totalOutages": 3, + "vendor": null + }, + { + "dataId": "d18693f9-59f9-4c45-9dfb-07fcc6046758", + "dataResolution": "COUNTY", + "eiaId": "13573", + "hasError": false, + "name": "NATIONAL GRID", + "outageFeed": null, + "receivedDate": "2026-10-05T06:20:47.233Z", + "totalOutages": 15, + "vendor": null + }, + { + "dataId": "7bc8b495-4fea-45ff-aae5-bce2b8ab48a5", + "dataResolution": "ZIP", + "eiaId": "14349", + "hasError": false, + "name": "AVANGRID", + "outageFeed": null, + "receivedDate": "2026-10-05T06:20:12.410Z", + "totalOutages": 21, + "vendor": null + }, + { + "dataId": "81288660-5502-469f-96b9-c2a1a1cf482f", + "dataResolution": "POINT", + "eiaId": "16868", + "hasError": false, + "name": "CITY OF SEATTLE - (WA)", + "outageFeed": null, + "receivedDate": "2026-10-05T06:20:00.171Z", + "totalOutages": 1, + "vendor": "datacapable" + }, + { + "dataId": "579e922c-44e2-4540-8ef3-f338bc15c7c9", + "dataResolution": "COUNTY", + "eiaId": "17609", + "hasError": true, + "name": "SOUTHERN CALIFORNIA EDISON CO", + "outageFeed": null, + "receivedDate": "2026-10-05T06:15:02.910Z", + "totalOutages": 6332, + "vendor": null + } + ] +} \ No newline at end of file diff --git a/drb-c2-core/tests/test_discord_directory.py b/drb-c2-core/tests/test_discord_directory.py new file mode 100644 index 0000000..37b74b7 --- /dev/null +++ b/drb-c2-core/tests/test_discord_directory.py @@ -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" diff --git a/drb-c2-core/tests/test_discord_oauth.py b/drb-c2-core/tests/test_discord_oauth.py new file mode 100644 index 0000000..c44489e --- /dev/null +++ b/drb-c2-core/tests/test_discord_oauth.py @@ -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 diff --git a/drb-c2-core/tests/test_discord_web_picker.py b/drb-c2-core/tests/test_discord_web_picker.py index 708c0a9..48da518 100644 --- a/drb-c2-core/tests/test_discord_web_picker.py +++ b/drb-c2-core/tests/test_discord_web_picker.py @@ -80,7 +80,10 @@ def test_directory_push_keeps_org_link(fs): 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})) opts = run(web.join_options("n1", OWNER)) assert opts["guilds"] == [] diff --git a/drb-c2-core/tests/test_media_range.py b/drb-c2-core/tests/test_media_range.py new file mode 100644 index 0000000..609399f --- /dev/null +++ b/drb-c2-core/tests/test_media_range.py @@ -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 diff --git a/drb-c2-core/tests/test_outage_providers.py b/drb-c2-core/tests/test_outage_providers.py new file mode 100644 index 0000000..13d8c8a --- /dev/null +++ b/drb-c2-core/tests/test_outage_providers.py @@ -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)) diff --git a/drb-frontend/app/admin/page.tsx b/drb-frontend/app/admin/page.tsx index 6a744c9..6b83723 100644 --- a/drb-frontend/app/admin/page.tsx +++ b/drb-frontend/app/admin/page.tsx @@ -4,6 +4,7 @@ import { useAuth } from "@/components/AuthProvider"; import { c2api } from "@/lib/c2api"; import { ReplayTab } from "@/components/admin/ReplayTab"; import { CameraProvidersTab } from "@/components/admin/CameraProvidersTab"; +import { OutageProvidersTab } from "@/components/admin/OutageProvidersTab"; import { useEffect, useState, useRef, useCallback } from "react"; import { useRouter } from "next/navigation"; import type { UserRecord, AuditEntry, UserRole, CallRecord } from "@/lib/types"; @@ -1268,11 +1269,12 @@ function SttEvalTab() { // 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 }[] = [ { key: "features", label: "AI Features" }, { key: "cameras", label: "Camera providers" }, + { key: "outages", label: "Outage providers" }, { key: "correlation", label: "Correlation Debug" }, { key: "replay", label: "Replay" }, { key: "calls", label: "Calls" }, @@ -1285,7 +1287,7 @@ export default function AdminPage() { const { user, isAdmin, loading: authLoading } = useAuth(); const router = useRouter(); const [tab, setTab] = useState("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(() => { const h = window.location.hash.slice(1); if (TAB_LABELS.some((t) => t.key === h)) setTab(h as AdminTab); @@ -1304,7 +1306,7 @@ export default function AdminPage() { if (!isAdmin) return null; // 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 (
@@ -1326,6 +1328,7 @@ export default function AdminPage() { {tab === "features" && } {tab === "cameras" && } + {tab === "outages" && } {tab === "correlation" && } {tab === "replay" && } {tab === "calls" && } diff --git a/drb-frontend/components/CallSpineEntry.tsx b/drb-frontend/components/CallSpineEntry.tsx index d939c1b..b2635af 100644 --- a/drb-frontend/components/CallSpineEntry.tsx +++ b/drb-frontend/components/CallSpineEntry.tsx @@ -8,105 +8,15 @@ * memorable thing per §2.4: "where was he when he said that" is answered * by looking, not cross-referencing timestamps. */ -import { useEffect, useRef, useState } from "react"; +import { useState } from "react"; 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"; function fmtTime(iso: string): string { 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(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(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 Audio unavailable; - } - - return ( -
e.stopPropagation()}> - - { - const t = Number(e.target.value); - if (audioRef.current) audioRef.current.currentTime = t; - setCurrent(t); - }} - className="flex-1 h-1 accent-accent" - disabled={!url} - /> - - {fmtClock(current)} - - {url && ( -
- ); -} function StopMarker({ stopNumber, color }: { stopNumber?: number; color: string }) { if (!stopNumber) { @@ -132,7 +42,7 @@ export function CallSpineEntry({ isAdmin?: boolean; }) { 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 color = isKnownSeverity(call.severity) ? SEVERITY_COLORS[call.severity] : "var(--ink-muted)"; const substantive = !!transcript || (call.units && call.units.length > 0) || !!call.location_coords; diff --git a/drb-frontend/components/InlinePlayer.tsx b/drb-frontend/components/InlinePlayer.tsx new file mode 100644 index 0000000..2a3b88d --- /dev/null +++ b/drb-frontend/components/InlinePlayer.tsx @@ -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
+
+
+ {/* Degraded → grey the map and its incident rail (the rail lives inside + MapView, so it is wrapped here rather than edited). */} +
{nodesLoading || callsLoading ? (
Loading map…
) : ( @@ -282,7 +296,6 @@ export function LiveView() { activeCalls={activeCalls} incidents={activeIncidents} calls={recentCalls} - lastUpdated={lastUpdated} selectedIncidentId={selected?.incident_id ?? null} onSelectIncident={selectIncident} nearCameras={near} @@ -291,13 +304,14 @@ export function LiveView() { camCountByIncident={camCountByIncident} /> )} +
{/* Configured-and-quiet — distinct from "no nodes at all" (chunk 10's Activation screen handles the zero-node case). */} {configuredButQuiet && (
- ● Listening{mostRecentSeen ? ` — last check-in ${timeAgo(new Date(mostRecentSeen))}` : ""} + ● Listening{health.newestCallAt ? ` — last call heard ${timeAgo(health.newestCallAt)}` : ""}
)} diff --git a/drb-frontend/components/MapView.tsx b/drb-frontend/components/MapView.tsx index 6efa562..a5f173b 100644 --- a/drb-frontend/components/MapView.tsx +++ b/drb-frontend/components/MapView.tsx @@ -29,6 +29,8 @@ import type { Camera, CameraCredit, CameraProviderFeed, CamerasResponse, Ny511Ev import { CameraFeed } from "@/components/CameraFeed"; import { SourceCredit, fetchedLabel } from "@/components/SourceCredit"; 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 ────────────────────────────────────────────────────────── delete (L.Icon.Default.prototype as unknown as Record)._getIconUrl; @@ -788,20 +790,26 @@ function incidentFanIcon(members: IncidentRecord[]): L.DivIcon { } // ── Incident path stop marker — numbered, shared index with the call spine ─── -function pathStopIcon(index: number, isFirst: boolean, isLast: boolean, color: string): L.DivIcon { - const size = 22; +// `playing` (server-26#233): the stop whose call is on the speaker gets a +// 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 textColor = isFirst ? color : "var(--page)"; - const halo = isLast ? `` : ""; + const halo = isLast ? `` : ""; + const ring = playing ? `` : ""; return L.divIcon({ className: "", - html: ` + html: ` + ${ring} ${halo} - - ${index} + + ${index} `, iconSize: [size, size], - iconAnchor: [size / 2, size / 2], + iconAnchor: [c, c], }); } @@ -1023,6 +1031,8 @@ function IncidentPathLayer({ incidents: IncidentRecord[]; callsByIncident: Map; }) { + // Only the playing id, so a timeupdate never re-renders the map. + const playingCallId = useAudio((s) => s.playingCallId); return ( <> {incidents.map((inc) => { @@ -1043,13 +1053,20 @@ function IncidentPathLayer({

Stop {i + 1} of {calls.length}

{inc.title ?? "Incident"}

{c.location &&

{c.location}

} + {hasAudio(c) && ( +
+ + Play this call +
+ )} View incident → @@ -1388,6 +1405,11 @@ export default function MapView({ const live = onAir?.has(inc.incident_id) ?? false; const cams = camCountByIncident?.get(inc.incident_id) ?? 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((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 cardBody = ( <> @@ -1425,28 +1447,37 @@ export default function MapView({ )} ); + // The play control is a sibling of the card, not inside it — a + // +
+ + {playLatest} +
); } return ( -
- {cardBody} - +
+ + {cardBody} + + {playLatest} +
); })}
diff --git a/drb-frontend/components/admin/OutageProvidersTab.tsx b/drb-frontend/components/admin/OutageProvidersTab.tsx new file mode 100644 index 0000000..492a7e6 --- /dev/null +++ b/drb-frontend/components/admin/OutageProvidersTab.tsx @@ -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 = { + 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(null); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(null); + const [confirming, setConfirming] = useState(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 ( +
+

+ Power-outage sources for the map. Every provider is off 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. +

+ {error &&
{error}
} + {!providers ? ( +

Loading…

+ ) : ( +
+ {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 ( +
+
+
+ {lic.label} +

{p.name}

+

{p.licence.label}

+

{p.region} · {p.granularity}

+
+
+ +

+ {p.serving ? "serving" : lockReason ?? p.not_serving_reason ?? "off"} +

+
+
+ {p.implemented && ( +
+ counties out: {p.health ? p.health.count : "—"} + last fetch: {fmtEpoch(p.health?.fetched_at)} + health: {p.health ? (p.health.error ? "error" : p.health.fetched_at ? "ok" : "not fetched yet") : "n/a"} +
+ )} + {p.health?.error &&

{p.health.error}

} + {p.coverage_note &&

{p.coverage_note}

} + {p.licence.attribution &&

Attribution shown with the data: “{p.licence.attribution}”

} + {p.acknowledgement && ( +

+ Licence warning acknowledged by {p.acknowledgement.by_email || p.acknowledgement.by_uid} on {new Date(p.acknowledgement.at).toLocaleString()} +

+ )} +
+ Terms & adapter notes +

{p.licence.terms}

+

{p.notes}

+

+ {p.licence.terms_url && terms} + {p.homepage && source} + {p.licence.issue && tracking: {p.licence.issue}} +

+
+
+ ); + })} +
+ )} + + {confirming && ( +
+
+

Enable {confirming.name}?

+

{LICENCE_STYLE[confirming.licence.status].label}

+

{confirming.licence.warning}

+ {error &&

{error}

} + +
+ + +
+
+
+ )} +
+ ); +} diff --git a/drb-frontend/components/live/TruthStrip.tsx b/drb-frontend/components/live/TruthStrip.tsx new file mode 100644 index 0000000..6fcda25 --- /dev/null +++ b/drb-frontend/components/live/TruthStrip.tsx @@ -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 ( +
+ {health.stages.map((s) => ( + + + ))} +
+ ); +} + +export function TruthBar({ health }: { health: PipelineHealth }) { + if (!health.degraded || !health.message) return null; + return ( +
+ DEGRADED + — {health.message} + + stale after {health.staleMin} min · map shows last-known state + +
+ ); +} + +/** 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" ? : ; +} diff --git a/drb-frontend/components/live/WallPanels.tsx b/drb-frontend/components/live/WallPanels.tsx index 584a5af..5715e01 100644 --- a/drb-frontend/components/live/WallPanels.tsx +++ b/drb-frontend/components/live/WallPanels.tsx @@ -14,6 +14,8 @@ import { useAuth } from "@/components/AuthProvider"; import { CameraFeed } from "@/components/CameraFeed"; import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice"; 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"; 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"; +// Spine rows rendered before "show all loaded" (server-26#239). +const SPINE_CAP = 30; 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 }) { @@ -183,6 +187,20 @@ export function IncidentPanel({ return m; }, [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 cleared = inc.units_cleared ?? []; @@ -270,23 +288,57 @@ export function IncidentPanel({
-

Call spine · newest first

+
+

Call spine · newest first

+ {audioOk && playable.length > 0 && ( + playingHere ? ( + + ) : ( + + ) + )} +
+ {spine.length > SPINE_CAP || outsideWindow > 0 ? ( +

+ {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 && ( + + )} +

+ ) : null} {spine.length ? (
    - {spine.slice(0, 30).map((c) => { + {shown.map((c) => { const stop = stopOf.get(c.call_id); const text = c.transcript_corrected || c.transcript; + const isPlaying = c.call_id === playingCallId; return ( -
  1. +
  2. {stop ?? "·"} -
    +
    {hhmm(c.started_at)} {c.talkgroup_name && {c.talkgroup_name}}

    {text || no transcript}

    + {isPlaying && hasAudio(c) &&
    }
    + {hasAudio(c) && !isPlaying && }
  3. ); })} diff --git a/drb-frontend/lib/audioQueue.ts b/drb-frontend/lib/audioQueue.ts new file mode 100644 index 0000000..06bbe58 --- /dev/null +++ b/drb-frontend/lib/audioQueue.ts @@ -0,0 +1,176 @@ +"use client"; + +/** + * Call-audio playback store — server-26#233. + * + * ONE module-level