The previous commit shipped Firestore rules that reference an org_id claim
nothing issues yet, and an org_id filter nothing writes yet - this is the
commit that makes both real. Backend half of SAAS_PLAN.md B2/B2b/B2c.
Data model: organizations/{org_id} and org_members/{uid} are new
collections (models.py OrganizationRecord/OrgMember). org_id is now an
Optional field on NodeRecord, SystemRecord, CallRecord, IncidentRecord,
AlertRule, and AlertEvent - optional because every existing document
predates it; scripts/backfill_org_id.py (written, not run - it touches
production Firestore and Firebase Auth claims) is what closes that gap
later. plan_id/subscription_status/stripe_* on OrganizationRecord are
deliberately None: no billing or pricing model has been decided, so this is
a seam, not a promise. app/internal/tenancy.py holds FOUNDING_ORG_ID, the
org every pre-tenancy document and every legacy enrollment path resolves
into.
Where org_id comes from, end to end: a customer's node enrolls with a
per-org token (new enrollment_tokens/{token_hash} collection, minted via
POST /org/enrollment-tokens - new routers/org.py) instead of the old
fleet-wide ENROLLMENT_TOKEN, which still works as a fallback that resolves
to FOUNDING_ORG_ID so an already-deployed node's .env doesn't start failing
today. The node's org_id then flows onto every call it produces
(mqtt_handler.py's call_start/call_end, upload.py's /upload handler all
resolve it from the node doc), and onto every incident correlated from
those calls (incident_correlator.py's _create_incident/_create_master_incident).
That last one is the part that isn't just a read filter: _build_context's
`all_active = collection_list("incidents", status="active")` fed every
correlation candidate - fast-path talkgroup match, unit-continuity,
disambiguation - from the entire incidents collection, unscoped. Without
scoping it to the call's own org_id, a call from org A could link into an
incident org B already owns, which is a cross-tenant data merge at
correlation time, not just an over-broad read. Same shape of bug in
alerter.py: rule matching pulled every enabled alert_rule regardless of
org, so org A's keyword rule could fire (and POST org A's Discord webhook)
on org B's radio traffic. Both now resolve org_id from the call doc itself
rather than threading a new parameter through every caller.
Every list/get route gained org scoping via a new resolve_caller_org_id()
helper in internal/auth.py, which handles the three credential shapes those
routes accept (service key, node api_key, Firebase user) uniformly and
returns None (unrestricted) for the service key and platform admins -
preserving today's single-org behaviour exactly while closing the leak for
everyone else: GET /nodes, /systems, /calls, /incidents, /alerts,
/alert-rules. Write routes for nodes/systems (approve, create, delete, etc.)
deliberately stay platform-admin-only for now rather than being loosened to
org-owner/operator - that's a real gap called out in SAAS_PLAN.md 2.4's
"should be" column, but it's a separate authorization redesign the 12-item
build order doesn't actually enumerate, and doing it half-considered here
risked being exactly the "half-applied filter is worse than none" failure
mode the plan warns about. Today's founding org keeps working unchanged;
loosening node/system management to org owners is follow-up work, flagged
rather than guessed at.
Also closed the four spend/access-attack routes SAAS_PLAN.md B2c called out
by file and line: POST /calls/{id}/reprocess is now admin-only (was any
signed-in viewer looping the Whisper+Gemini pipeline for free - DEFERRED.md
had this as a live, independent-of-SaaS exploit) plus a per-call rate
limiter as a second guard; POST /alerts/{id}/acknowledge now checks the
alert's org_id; GET /admin/features moved from require_firebase_token to
require_admin_token; and trips.py's four unauthenticated mutation routes
(create_trip, update_trip_tags, create_event, update_event) are now
restricted to the founding org (or the bot's service key, or a platform
admin) - trips has no org_id of its own and isn't getting one, since
[[trips-feature-intentional]] says it's an internal utility riding along on
this stack, not a tenant-scoped product surface.
New public-but-scoped seam: POST /auth/signup (routers/links.py, alongside
the existing /auth/link* routes) provisions an organizations doc and an
owner org_members doc for a just-created Firebase user, then sets their
org_id/org_role claims - idempotent, so a double-submit doesn't create two
orgs. This is the only route that turns "has a Firebase account" into "can
read anything," which is what the frontend AuthProvider no-claim guard
(next commit) is built around.
Also new: GET/PATCH /org for the organization profile (closes the disabled
"Save changes" button noted in DEFERRED.md - there was no organizations
concept to save into before this), and POST /waitlist (public, source-IP
rate-limited, not coupled to any plan or tier - the commercial model is
still an open decision per SAAS_PLAN.md section 6).
Verified: all touched files py_compile clean; c2-core pytest is 69
passed / 10 failed, matching the documented pre-existing baseline exactly
(DEFERRED.md - mqtt_handler/node_sweeper test-vs-code drift, unrelated to
this change) - no new failures. flake8 --max-line-length=120 shows no new
violations in any touched file (checked each new E501/E221/E30x against
`git diff` to confirm it predates this commit); c2-core has no CI lint gate
regardless (CLAUDE.md - flake8 only runs in Client CI).
No new environment variables. Firestore composite indexes for the queries
this introduces were already shipped in the previous commit
(infra/firestore/firestore.indexes.json).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
DRB Server
Full-stack backend for the DRB (Distributed Radio Bot) platform — a community-powered, real-time situational awareness system for public safety radio monitoring. Receives telemetry and audio from SDR edge nodes, runs an AI intelligence pipeline on every call, correlates calls into incidents, dispatches alerts, and serves a live operational dashboard.
What DRB Is
DRB turns radio scanners into a shared geographic intelligence picture. Anyone can contribute coverage by running an edge node (an RTL-SDR dongle on a small Linux machine). All nodes feed into a single platform where operators can:
- Monitor in real time — see active incidents on a map as they unfold, with calls grouped by incident as they come in
- Listen live — stream audio to Discord voice channels or directly in the browser
- Get alerted — receive push notifications when significant events are detected
- Investigate after the fact — search transcribed call history, replay audio, and review incident timelines
The intended deployment is a Tactical Operations Center (TOC) display: a map showing active incidents across all covered jurisdictions, with live audio and alert feeds for operators monitoring an area during an event (severe weather, major incident, etc.).
System Overview
DRB is a distributed SDR (Software Defined Radio) monitoring platform. Edge nodes (small Linux machines with RTL-SDR dongles) decode radio systems and stream audio. The server coordinates those nodes, runs the intelligence pipeline on each call, manages Discord voice bot assignments, and serves the operational dashboard.
Edge Node (client machine)
│
├── MQTT checkin/status/metadata ──► mosquitto ──► c2-core ──► Firestore
│
└── MQTT commands ◄─────────────────────────────── c2-core
│
└── discord_join ──► edge node joins Discord voice + streams PulseAudio
Discord User
└── /join /leave /status /help ──► discord-bot ──► c2-core ──► MQTT ──► edge node
Browser (admin)
└── frontend ──► Firestore (real-time reads, Firebase Auth)
└── c2-core REST API (writes, commands, token pool)
Services
| Service | Container | Description | Port |
|---|---|---|---|
mosquitto |
eclipse-mosquitto |
MQTT broker — receives telemetry from edge nodes, dispatches commands | 1883 |
mosquitto |
eclipse-mosquitto |
MQTT broker — receives telemetry from edge nodes, dispatches commands | 1883 |
c2-core |
Python 3.11 / FastAPI | Command & control API — MQTT handler, intelligence pipeline, incident correlator, alert dispatch, node/system/token management | 8000 |
discord-bot |
Python 3.11 / discord.py | Server-side Discord slash command bot — /join, /leave, /status, /help |
— |
frontend |
Node.js / Next.js 14 | Operational web dashboard — live map, incident feed, call history, node and system management | 3000 |
Directory Structure
Server/
├── docker-compose.yml # Orchestrates all four services
├── Makefile # Convenience targets (setup/build/up/down/logs)
│
├── drb-c2-core/ # FastAPI command & control backend
│ ├── app/
│ │ ├── main.py # App entry point — router registration, MQTT startup, node sweeper
│ │ ├── config.py # Pydantic settings (reads from .env)
│ │ ├── models.py # Shared Pydantic models (CommandPayload, etc.)
│ │ ├── routers/
│ │ │ ├── nodes.py # Node CRUD, approve/reject, command dispatch, system assignment
│ │ │ ├── systems.py # Radio system CRUD (P25/DMR/NBFM configs)
│ │ │ ├── calls.py # Call log retrieval (read-only — calls created by MQTT handler)
│ │ │ ├── tokens.py # Discord bot token pool — add/delete/list; assign_token/release_token helpers
│ │ │ ├── upload.py # Audio file upload endpoint (called by edge nodes); triggers intelligence pipeline
│ │ │ ├── incidents.py # Incident CRUD, manual link/unlink calls, resolve
│ │ │ └── alerts.py # Alert rule CRUD
│ │ └── internal/
│ │ ├── auth.py # Auth — Firebase ID token OR service key (Bearer)
│ │ ├── firestore.py # Async Firestore wrappers (doc_get, doc_set, doc_update, collection_list)
│ │ ├── mqtt_handler.py # MQTT subscriber — call_start/call_end handlers, node checkin/status
│ │ ├── node_sweeper.py # Background task — marks nodes offline after 90s without heartbeat
│ │ ├── storage.py # GCS audio file uploads
│ │ ├── transcription.py # STT pipeline — OpenAI Whisper / GPT-4o transcribe
│ │ ├── intelligence.py # GPT scene extraction: tags, incident type, location, units, vehicles; geocoding
│ │ ├── incident_correlator.py # Hybrid correlator — matches calls to incidents or creates new ones
│ │ └── alerter.py # Alert dispatch — evaluates rules, sends notifications
│ ├── scripts/
│ │ └── set_admin.py # CLI to grant/revoke Firebase admin custom claim
│ ├── gcp-key.json # GCP service account key — place here, NOT committed to git
│ └── .env.example
│
├── drb-server-discord-bot/ # Discord slash command bot (server-side)
│ ├── app/
│ │ ├── main.py # Bot startup, cog loading, guild sync
│ │ ├── config.py # Settings (Discord token, C2 URL, service key)
│ │ ├── commands/
│ │ │ └── radio.py # /join (with token autocomplete), /leave, /status, /help
│ │ └── internal/
│ │ └── c2_client.py # Async HTTP client to c2-core — includes service key auth header
│ └── .env.example
│
└── drb-frontend/ # Next.js 14 admin dashboard
├── app/
│ ├── dashboard/ # Overview: active node grid + live call feed
│ ├── map/ # Leaflet map — node locations, active incident pins
│ ├── calls/ # Call history — talkgroup, duration, audio playback, transcript
│ ├── nodes/ # Node list + per-node detail (approve, reject, assign system)
│ ├── systems/ # Radio system CRUD (P25 form with talkgroup editor, DMR/NBFM JSON)
│ ├── tokens/ # Discord bot token pool management
│ ├── incidents/ # Incident list/detail — linked calls, location, summary, resolve
│ ├── alerts/ # Alert rule configuration
│ └── login/ # Firebase Auth login page
├── components/
│ ├── MapView.tsx # react-leaflet map with status-colored markers
│ ├── NodeCard.tsx # Node status card with action buttons
│ ├── NodeConfigModal.tsx # Modal for assigning a radio system to a node
│ ├── CallRow.tsx # Call history row — talkgroup, duration, audio player
│ ├── AuthProvider.tsx # Firebase Auth context + middleware guard
│ ├── Nav.tsx # Sidebar navigation
│ └── StatusBadge.tsx # Color-coded status pill (online/recording/offline/unconfigured)
├── lib/
│ ├── firebase.ts # Firebase JS SDK initialization
│ ├── c2api.ts # Fetch wrapper for c2-core REST API (attaches Firebase ID token)
│ ├── types.ts # Shared TypeScript types (NodeRecord, CallRecord, SystemRecord, etc.)
│ ├── useNodes.ts # Firestore real-time subscription to nodes collection
│ ├── useCalls.ts # Firestore real-time subscription to calls (active + history)
│ └── useSystems.ts # Firestore real-time subscription to systems collection
├── next.config.mjs # Next.js config — standalone output, transpilePackages for Leaflet
└── .env.example
Prerequisites
- Docker + Docker Compose
- A Firebase project with:
- Firestore enabled (Native mode)
- Firebase Authentication enabled (Email/Password provider)
- A GCP service account key with Firestore + Firebase Auth Admin SDK permissions
- A Discord application with:
- One bot token for the server command bot (handles
/join,/leave, etc.) - One or more additional bot tokens for the pool (edge nodes use these to join voice channels)
- One bot token for the server command bot (handles
Setup
# 1. Copy all env files
make setup
# 2. Place your GCP service account key
cp /path/to/your-service-account-key.json drb-c2-core/gcp-key.json
# 3. Fill in secrets — see tables below
nano drb-c2-core/.env
nano drb-server-discord-bot/.env
nano drb-frontend/.env
# 4. Build and start
make build
make up
# 5. Grant admin access to your Firebase user
cd drb-c2-core
python scripts/set_admin.py grant your@email.com
# Sign out and back in to the frontend for the claim to take effect
Environment Variables
drb-c2-core/.env
| Variable | Required | Description |
|---|---|---|
MQTT_BROKER |
Yes | Hostname of the MQTT broker (default: mosquitto) |
MQTT_PORT |
No | MQTT broker port (default: 1883) |
FIRESTORE_DATABASE |
No | Firestore database name (default: (default)) |
GCP_CREDENTIALS_PATH |
Yes | Path to the service account key inside the container (e.g. /secrets/gcp-key.json) |
GCS_BUCKET |
No | GCS bucket name for storing call audio recordings |
SERVICE_KEY |
Yes* | Shared secret for internal service-to-service auth. Must match C2_SERVICE_KEY in the discord-bot env |
*Required for the server Discord bot to authenticate against the C2 API.
drb-server-discord-bot/.env
| Variable | Required | Description |
|---|---|---|
DISCORD_TOKEN |
Yes | Bot token for the server-side slash command bot |
C2_URL |
Yes | Internal URL of c2-core (e.g. http://c2-core:8000) |
C2_SERVICE_KEY |
Yes* | Must match SERVICE_KEY in drb-c2-core — sent as a Bearer token on all C2 API calls |
DEV_GUILD_ID |
No | Discord guild ID to sync slash commands instantly during development |
drb-frontend/.env
| Variable | Required | Description |
|---|---|---|
NEXT_PUBLIC_FIREBASE_API_KEY |
Yes | Firebase web API key |
NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN |
Yes | Firebase auth domain |
NEXT_PUBLIC_FIREBASE_PROJECT_ID |
Yes | GCP/Firebase project ID |
NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET |
Yes | Firebase storage bucket |
NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID |
Yes | Firebase messaging sender ID |
NEXT_PUBLIC_FIREBASE_APP_ID |
Yes | Firebase app ID |
NEXT_PUBLIC_FIRESTORE_DATABASE |
No | Firestore database name — must match c2-core (default: (default)) |
NEXT_PUBLIC_C2_URL |
Yes | C2 API URL reachable from the user's browser |
Authentication
Two auth mechanisms are supported on all C2 routes:
- Firebase ID tokens — issued by Firebase Auth to browser users. The frontend attaches these automatically via
c2api.ts. Required for the dashboard to call the C2 API. - Service key — a static shared secret for internal service calls. The server Discord bot sends this as a
Bearertoken. SetSERVICE_KEYin c2-core andC2_SERVICE_KEYin the discord-bot to the same value.
Admin-only routes (node approve/reject) additionally require the Firebase user to have an admin: true custom claim:
python drb-c2-core/scripts/set_admin.py grant your@email.com
python drb-c2-core/scripts/set_admin.py revoke your@email.com
Discord Bot Token Pool
Edge nodes join Discord voice channels using bot tokens managed by the server. Add tokens in the Tokens page of the frontend (or POST /tokens). Each token has:
- A friendly name
- The actual Discord bot token string (masked in list views)
in_useflag and the node it's currently assigned to
Flow when a user runs /join:
- Discord bot sends a
discord_joincommand to c2-core with the target guild and channel IDs. - c2-core picks a free token (optionally the one requested via autocomplete), marks it
in_use, and forwards the token + channel info to the target edge node over MQTT. - The edge node starts a
discord.pybot with that token and joins the voice channel. - The bot streams live audio continuously from PulseAudio (
drb_sink.monitoron the edge node). Icecast is for browser listening only. - On
/leave, c2-core releases the token back to the pool.
Node Lifecycle
- Edge node starts → sends MQTT
checkin→ appears as pending in the dashboard - Admin clicks Approve → c2-core generates a random API key, stores it in
node_keys, publishes it to the node over MQTT, updates node toapproved - Admin assigns a radio system via Config → c2-core pushes the full system config (frequencies, talkgroups, Icecast settings) over MQTT
- Edge node writes the config and (re)starts OP25
- Node sends
online/recording/unconfiguredstatus via 30s MQTT heartbeats node_sweeperbackground task marks any node offline after 90s without a heartbeat
Call & Intelligence Pipeline
Every radio call flows through a four-stage pipeline triggered when the edge node uploads its recording:
Edge node ──► MQTT call_start ──► CallRecord created (active)
│
Edge node ──► audio upload ──► GCS storage
│
▼
[1] TRANSCRIPTION
OpenAI Whisper / GPT-4o transcribe
→ CallRecord.transcript
│
▼
[2] INTELLIGENCE EXTRACTION (GPT-4o-mini)
Scene detection — splits multi-incident recordings
Speaker role inference — dispatch vs. unit patterns
used to correctly attribute locations (dispatch-
provided address vs. unit position report) and
units (being dispatched vs. acknowledging)
Entity extraction: tags, incident_type, location,
units, vehicles, severity, resolved flag
+ geocoding (Google Maps)
+ embedding (text-embedding-3-small)
→ CallRecord.tags, .location, .units, etc.
│
▼
[3] INCIDENT CORRELATION (hybrid engine)
Fast path — talkgroup + recency
Unit path — same officer continuity
Location — proximity match
Cross-TG — multi-agency / channel hop
Slow path — embedding similarity
→ IncidentRecord created or updated
│
▼
[4] ALERT DISPATCH
Evaluate alert rules (keywords, talkgroups)
→ notifications sent
Frontend Pages
| Page | URL | Description |
|---|---|---|
| Dashboard | /dashboard |
Live node grid + active call stream |
| Map | /map |
Leaflet map — nodes color-coded by status, active call popups |
| Calls | /calls |
Full call history — talkgroup, duration, audio playback, transcript |
| Nodes | /nodes |
Node list; per-node detail for approve/reject/assign system |
| Systems | /systems |
Create and manage P25/DMR/NBFM radio system configurations |
| Tokens | /tokens |
Discord bot token pool management |
| Incidents | /incidents |
Incident list/detail — linked calls, location, summary, tags, resolve |
| Alerts | /alerts |
Not built |
Makefile Targets
make setup — copy .env.example files to .env
make build — docker compose build
make up — docker compose up -d
make down — docker compose down
make logs — follow all service logs
make logs-c2 — c2-core logs only
make logs-bot — discord-bot logs only
make logs-frontend — frontend logs only