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 " : {
2026-08-28 20:53:32 -04:00
" description " : " Nous Portal API key — 15-min news summarizer (inference-api.nousresearch.com). " ,
2026-08-27 22:55:51 -04:00
" 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 " : {
2026-08-29 00:40:41 -04:00
" description " : " AISStream (open/shared) — live US-coast AIS. Server-side WebSocket only. " ,
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
" pattern " : r " ^. { 8,}$ " ,
" example " : " key from https://aisstream.io/account (GitHub login) " ,
} ,
2026-08-29 00:18:49 -04:00
" VESSELAPI_API_KEY " : {
2026-08-29 00:40:41 -04:00
" description " : " VesselAPI (commercial) — Strait of Hormuz AIS, 5× /day cache. Paste the Bearer token from dashboard.vesselapi.com. Not a US-coast feed. " ,
2026-08-29 00:18:49 -04:00
" pattern " : r " ^. { 8,}$ " ,
" example " : " Bearer token from https://dashboard.vesselapi.com/ " ,
} ,
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
" 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