Add NASA FIRMS active-fire ingest + /api/fires; API keys management page
Coherent merge of two coordinated features on the shared working tree:
FIRMS fire heatmap (backend, t_6e404c14):
- app/fire_sources.py: fetch FIRMS VIIRS area CSV (free MAP_KEY) -> NATS events.fire
- fires hypertable (TimescaleDB, 1-day chunks) with natural-key PK
(latitude, longitude, acq_time, satellite); idempotent ON CONFLICT DO NOTHING
- alembic/versions/002_fires.py; GET /api/fires?bbox=&since= (JSON only)
- POST /api/ingest/fires; ~15 min poll loop (FIRMS_INTERVAL=900) in ingester
- env-driven config (FIRMS_MAP_KEY/DATASET/BBOX/INTERVAL); docs/firms.md covers
the zero-cost GIBS VIIRS_SNPP_Thermal_Anomalies_375m_All tile alternative
- 18 tests (parser, mapping, idempotency, API contract) verified vs real
TimescaleDB+PostGIS (localhost/osint-dashboard-pg image)
API keys page (frontend, t_4433cff2):
- app/keystore.py: api_keys table (self-creating), FIRMS/GEMINI/TELEGRAM
registry with format validation, ****last4 masking, get_api_key()
- GET/POST/DELETE /api/keys (never returns full values); Keys tab in index.html
DB_NULL_POOL env switch in app/database.py enables a NullPool for tests /
short-lived processes that open a fresh event loop per unit.
2026-08-24 15:37:42 -04:00
|
|
|
"""OSINT Dashboard — API key store (keyv-style Postgres table).
|
|
|
|
|
|
|
|
|
|
Keys live in the ``api_keys`` table, shared between the dashboard app and the
|
|
|
|
|
ingest services (both connect to the same Postgres). Only the app WRITES keys
|
|
|
|
|
via the management API; ingest services read them with :func:`get_api_key`.
|
|
|
|
|
|
|
|
|
|
Storage: the table is created lazily with ``CREATE TABLE IF NOT EXISTS`` on
|
|
|
|
|
first use in each process (no alembic migration, so it can never fork the
|
|
|
|
|
migration chain or block container startup). DDL is idempotent and safe when
|
|
|
|
|
the app and ingester containers race on first boot.
|
|
|
|
|
|
|
|
|
|
Security contract:
|
|
|
|
|
* Values are stored in Postgres, never in the container image or frontend.
|
|
|
|
|
* ``GET /api/keys`` returns only set/missing status plus a masked
|
|
|
|
|
"****last4" hint. Full values are NEVER returned by the API.
|
|
|
|
|
* Registered keys get cheap format validation on save (regex), e.g. the
|
|
|
|
|
FIRMS map key must be a 32-char hex string.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import asyncio
|
|
|
|
|
import re
|
|
|
|
|
from datetime import datetime, timezone
|
|
|
|
|
|
|
|
|
|
from sqlalchemy import Column, DateTime, String, Table, Text, func, select, text
|
|
|
|
|
|
|
|
|
|
from database import async_session, engine, metadata
|
|
|
|
|
|
|
|
|
|
# ── Table definition (bound to the shared metadata; created lazily) ───────
|
|
|
|
|
api_keys = Table(
|
|
|
|
|
"api_keys",
|
|
|
|
|
metadata,
|
|
|
|
|
Column("name", String(128), primary_key=True),
|
|
|
|
|
Column("value", Text, nullable=False),
|
|
|
|
|
Column("created_at", DateTime(timezone=True), server_default=func.now(), nullable=False),
|
|
|
|
|
Column("updated_at", DateTime(timezone=True), server_default=func.now(), nullable=False),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
_CREATE_TABLE_SQL = text(
|
|
|
|
|
"""
|
|
|
|
|
CREATE TABLE IF NOT EXISTS api_keys (
|
|
|
|
|
name VARCHAR(128) PRIMARY KEY,
|
|
|
|
|
value TEXT NOT NULL,
|
|
|
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
|
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
|
|
|
)
|
|
|
|
|
"""
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# ── Registry of keys the ingest services understand ───────────────────────
|
|
|
|
|
# Each entry: human description (shown in the UI) + optional ``pattern`` for
|
|
|
|
|
# cheap format validation and an ``example`` for error/placeholder text.
|
|
|
|
|
KEY_REGISTRY: dict[str, dict] = {
|
|
|
|
|
"FIRMS_MAP_KEY": {
|
|
|
|
|
"description": "NASA FIRMS API key — active fire / satellite ingest.",
|
|
|
|
|
"pattern": r"^[0-9a-fA-F]{32}$",
|
|
|
|
|
"example": "32-char hex string (e.g. 5f3c…9a02)",
|
|
|
|
|
},
|
2026-08-27 22:55:51 -04:00
|
|
|
"NOUS_API_KEY": {
|
|
|
|
|
"description": "Nous Portal API key — hourly news summarizer (inference-api.nousresearch.com).",
|
|
|
|
|
"pattern": r"^.{16,}$",
|
|
|
|
|
"example": "key from https://portal.nousresearch.com (API keys page)",
|
Add NASA FIRMS active-fire ingest + /api/fires; API keys management page
Coherent merge of two coordinated features on the shared working tree:
FIRMS fire heatmap (backend, t_6e404c14):
- app/fire_sources.py: fetch FIRMS VIIRS area CSV (free MAP_KEY) -> NATS events.fire
- fires hypertable (TimescaleDB, 1-day chunks) with natural-key PK
(latitude, longitude, acq_time, satellite); idempotent ON CONFLICT DO NOTHING
- alembic/versions/002_fires.py; GET /api/fires?bbox=&since= (JSON only)
- POST /api/ingest/fires; ~15 min poll loop (FIRMS_INTERVAL=900) in ingester
- env-driven config (FIRMS_MAP_KEY/DATASET/BBOX/INTERVAL); docs/firms.md covers
the zero-cost GIBS VIIRS_SNPP_Thermal_Anomalies_375m_All tile alternative
- 18 tests (parser, mapping, idempotency, API contract) verified vs real
TimescaleDB+PostGIS (localhost/osint-dashboard-pg image)
API keys page (frontend, t_4433cff2):
- app/keystore.py: api_keys table (self-creating), FIRMS/GEMINI/TELEGRAM
registry with format validation, ****last4 masking, get_api_key()
- GET/POST/DELETE /api/keys (never returns full values); Keys tab in index.html
DB_NULL_POOL env switch in app/database.py enables a NullPool for tests /
short-lived processes that open a fresh event loop per unit.
2026-08-24 15:37:42 -04:00
|
|
|
},
|
|
|
|
|
"TELEGRAM_TOKEN": {
|
|
|
|
|
"description": "Telegram bot token — push alert notifications to a channel.",
|
|
|
|
|
"pattern": r"^\d{8,10}:[0-9A-Za-z_-]{35}$",
|
|
|
|
|
"example": "123456789:AA… (bot token from @BotFather)",
|
|
|
|
|
},
|
feat: toggleable live map feeds (ADS-B, trains, AIS, radar, WFIGS, NWS)
Wire the free data streams from docs/free-data-streams.md into the
dashboard as layer-panel toggles. Third-party APIs are proxied/cached
in FastAPI; raster tiles (IEM, RainViewer, GIBS) stay in the browser.
- Aircraft via ADSB.lol viewport poll (bbox required, radius ≤ 150 nm)
- Amtraker trains, NHC storms, WFIGS incidents/perimeters
- NWS + IEM SBW as /api/weather-alerts (does not collide with /api/alerts)
- AISStream worker is server-side only and idles without AISSTREAM_API_KEY
- Caltrans CWWP2 D1–D12 camera parser; FIRMS dual-write NOAA-20/21
2026-08-27 19:08:30 -04:00
|
|
|
"AISSTREAM_API_KEY": {
|
|
|
|
|
"description": "AISStream WebSocket key — live vessel positions (server-side only).",
|
|
|
|
|
"pattern": r"^.{8,}$",
|
|
|
|
|
"example": "key from https://aisstream.io/account (GitHub login)",
|
|
|
|
|
},
|
|
|
|
|
"OPENSKY_CLIENT_ID": {
|
|
|
|
|
"description": "OpenSky OAuth client id — optional ADS-B fallback (unused until enabled).",
|
|
|
|
|
"example": "client id from opensky-network.org account",
|
|
|
|
|
},
|
|
|
|
|
"OPENSKY_CLIENT_SECRET": {
|
|
|
|
|
"description": "OpenSky OAuth client secret — optional ADS-B fallback.",
|
|
|
|
|
"example": "client secret from the OpenSky account page",
|
|
|
|
|
},
|
Add NASA FIRMS active-fire ingest + /api/fires; API keys management page
Coherent merge of two coordinated features on the shared working tree:
FIRMS fire heatmap (backend, t_6e404c14):
- app/fire_sources.py: fetch FIRMS VIIRS area CSV (free MAP_KEY) -> NATS events.fire
- fires hypertable (TimescaleDB, 1-day chunks) with natural-key PK
(latitude, longitude, acq_time, satellite); idempotent ON CONFLICT DO NOTHING
- alembic/versions/002_fires.py; GET /api/fires?bbox=&since= (JSON only)
- POST /api/ingest/fires; ~15 min poll loop (FIRMS_INTERVAL=900) in ingester
- env-driven config (FIRMS_MAP_KEY/DATASET/BBOX/INTERVAL); docs/firms.md covers
the zero-cost GIBS VIIRS_SNPP_Thermal_Anomalies_375m_All tile alternative
- 18 tests (parser, mapping, idempotency, API contract) verified vs real
TimescaleDB+PostGIS (localhost/osint-dashboard-pg image)
API keys page (frontend, t_4433cff2):
- app/keystore.py: api_keys table (self-creating), FIRMS/GEMINI/TELEGRAM
registry with format validation, ****last4 masking, get_api_key()
- GET/POST/DELETE /api/keys (never returns full values); Keys tab in index.html
DB_NULL_POOL env switch in app/database.py enables a NullPool for tests /
short-lived processes that open a fresh event loop per unit.
2026-08-24 15:37:42 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# Any stored key must at least be a sane UPPER_SNAKE name.
|
|
|
|
|
_NAME_RE = re.compile(r"^[A-Z][A-Z0-9_]{1,63}$")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class KeyFormatError(ValueError):
|
|
|
|
|
"""Raised when a key name or value fails format validation."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── Lazy table bootstrap ──────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
_ensure_lock = asyncio.Lock()
|
|
|
|
|
_ensured = False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def ensure_api_keys_table() -> None:
|
|
|
|
|
"""Create the api_keys table if it doesn't exist (idempotent, per process)."""
|
|
|
|
|
global _ensured
|
|
|
|
|
if _ensured:
|
|
|
|
|
return
|
|
|
|
|
async with _ensure_lock:
|
|
|
|
|
if _ensured:
|
|
|
|
|
return
|
|
|
|
|
async with engine.begin() as conn:
|
|
|
|
|
await conn.execute(_CREATE_TABLE_SQL)
|
|
|
|
|
_ensured = True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── Validation ────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
def is_registered(name: str) -> bool:
|
|
|
|
|
"""True if ``name`` has an entry in the registry (gets format validation)."""
|
|
|
|
|
return name in KEY_REGISTRY
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def validate_name(name: str) -> None:
|
|
|
|
|
"""Reject malformed key names (must be UPPER_SNAKE)."""
|
|
|
|
|
if not _NAME_RE.match(name or ""):
|
|
|
|
|
raise KeyFormatError(
|
|
|
|
|
"Invalid key name — use UPPER_SNAKE_CASE (e.g. FIRMS_MAP_KEY)."
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def validate_value(name: str, value: str) -> None:
|
|
|
|
|
"""Cheap format validation for registered keys; rejects empty values."""
|
|
|
|
|
if not value or not value.strip():
|
|
|
|
|
raise KeyFormatError("Value must not be empty.")
|
|
|
|
|
info = KEY_REGISTRY.get(name)
|
|
|
|
|
pattern = (info or {}).get("pattern")
|
|
|
|
|
if pattern and not re.match(pattern, value.strip()):
|
|
|
|
|
example = (info or {}).get("example", "a matching value")
|
|
|
|
|
raise KeyFormatError(f"'{name}' has an invalid format — expected {example}.")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def mask_value(value: str | None) -> str:
|
|
|
|
|
"""Mask a stored value as '****last4' (or '****' when shorter than 4)."""
|
|
|
|
|
if not value:
|
|
|
|
|
return ""
|
|
|
|
|
v = value.strip()
|
|
|
|
|
tail = v[-4:] if len(v) >= 4 else ""
|
|
|
|
|
return f"****{tail}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── Store operations ──────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
async def list_keys() -> list[dict]:
|
|
|
|
|
"""Registry keys + any extra stored keys, with masked set-status only."""
|
|
|
|
|
await ensure_api_keys_table()
|
|
|
|
|
async with async_session() as session:
|
|
|
|
|
rows = (await session.execute(select(api_keys))).mappings().all()
|
|
|
|
|
stored = {r["name"]: r["value"] for r in rows}
|
|
|
|
|
|
|
|
|
|
names = list(KEY_REGISTRY) + [n for n in stored if n not in KEY_REGISTRY]
|
|
|
|
|
out = []
|
|
|
|
|
for name in names:
|
|
|
|
|
value = stored.get(name)
|
|
|
|
|
info = KEY_REGISTRY.get(name, {})
|
|
|
|
|
out.append(
|
|
|
|
|
{
|
|
|
|
|
"name": name,
|
|
|
|
|
"set": value is not None,
|
|
|
|
|
"masked": mask_value(value) if value is not None else None,
|
|
|
|
|
"description": info.get("description", ""),
|
|
|
|
|
"example": info.get("example", ""),
|
|
|
|
|
"validated": name in KEY_REGISTRY,
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
out.sort(key=lambda k: k["name"].lower())
|
|
|
|
|
return out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def set_key(name: str, value: str) -> dict:
|
|
|
|
|
"""Upsert a key value. Validates registered formats; rejects bad names.
|
|
|
|
|
|
|
|
|
|
Returns the masked status (never the raw value).
|
|
|
|
|
"""
|
|
|
|
|
validate_name(name)
|
|
|
|
|
validate_value(name, value)
|
|
|
|
|
value = value.strip()
|
|
|
|
|
now = datetime.now(timezone.utc)
|
|
|
|
|
|
|
|
|
|
await ensure_api_keys_table()
|
|
|
|
|
async with async_session() as session:
|
|
|
|
|
existing = (
|
|
|
|
|
await session.execute(select(api_keys).where(api_keys.c.name == name))
|
|
|
|
|
).mappings().one_or_none()
|
|
|
|
|
if existing:
|
|
|
|
|
await session.execute(
|
|
|
|
|
api_keys.update()
|
|
|
|
|
.where(api_keys.c.name == name)
|
|
|
|
|
.values(value=value, updated_at=now)
|
|
|
|
|
)
|
|
|
|
|
else:
|
|
|
|
|
await session.execute(
|
|
|
|
|
api_keys.insert().values(name=name, value=value, updated_at=now)
|
|
|
|
|
)
|
|
|
|
|
await session.commit()
|
|
|
|
|
return {"name": name, "set": True, "masked": mask_value(value)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def delete_key(name: str) -> bool:
|
|
|
|
|
"""Remove a stored key. Returns True if something was deleted."""
|
|
|
|
|
await ensure_api_keys_table()
|
|
|
|
|
async with async_session() as session:
|
|
|
|
|
result = await session.execute(api_keys.delete().where(api_keys.c.name == name))
|
|
|
|
|
await session.commit()
|
|
|
|
|
return bool(result.rowcount)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def get_api_key(name: str) -> str | None:
|
|
|
|
|
"""Read a stored key value — used by ingest services, never by the API.
|
|
|
|
|
|
|
|
|
|
Returns the raw value (or None when unset) so producers can pass it to
|
2026-08-28 00:02:51 -04:00
|
|
|
external APIs (FIRMS, Nous, Telegram, …). Reads live from Postgres, so a
|
Add NASA FIRMS active-fire ingest + /api/fires; API keys management page
Coherent merge of two coordinated features on the shared working tree:
FIRMS fire heatmap (backend, t_6e404c14):
- app/fire_sources.py: fetch FIRMS VIIRS area CSV (free MAP_KEY) -> NATS events.fire
- fires hypertable (TimescaleDB, 1-day chunks) with natural-key PK
(latitude, longitude, acq_time, satellite); idempotent ON CONFLICT DO NOTHING
- alembic/versions/002_fires.py; GET /api/fires?bbox=&since= (JSON only)
- POST /api/ingest/fires; ~15 min poll loop (FIRMS_INTERVAL=900) in ingester
- env-driven config (FIRMS_MAP_KEY/DATASET/BBOX/INTERVAL); docs/firms.md covers
the zero-cost GIBS VIIRS_SNPP_Thermal_Anomalies_375m_All tile alternative
- 18 tests (parser, mapping, idempotency, API contract) verified vs real
TimescaleDB+PostGIS (localhost/osint-dashboard-pg image)
API keys page (frontend, t_4433cff2):
- app/keystore.py: api_keys table (self-creating), FIRMS/GEMINI/TELEGRAM
registry with format validation, ****last4 masking, get_api_key()
- GET/POST/DELETE /api/keys (never returns full values); Keys tab in index.html
DB_NULL_POOL env switch in app/database.py enables a NullPool for tests /
short-lived processes that open a fresh event loop per unit.
2026-08-24 15:37:42 -04:00
|
|
|
key set via the dashboard is picked up on the next poll — no restart.
|
|
|
|
|
"""
|
|
|
|
|
await ensure_api_keys_table()
|
|
|
|
|
async with async_session() as session:
|
|
|
|
|
row = (
|
|
|
|
|
await session.execute(select(api_keys).where(api_keys.c.name == name))
|
|
|
|
|
).mappings().one_or_none()
|
|
|
|
|
return row["value"] if row else None
|