Compare commits

...
Author SHA1 Message Date
Logan CusanoandClaude Opus 5 0bd92269d2 Stop httpx logging API keys in plaintext
Build & Deploy / Build & push images (push) Successful in 4m0s
Build & Deploy / Deploy to VM (push) Successful in 2m54s
httpx logs every request at INFO as a full URL including the query string, so
the Google Maps key appeared in c2-core's container logs on every geocode call
-- `?address=Holland+Station&...&key=AIza...`. Anyone who can read the logs, or
who is pasted a few lines of them, has the key. It was found exactly that way
while checking why the map was empty.

Nothing in this service needs per-request client logging; callers already log
their own failures with context. httpx and httpcore drop to WARNING, so real
transport errors still surface and the URLs stop being printed.

This does not un-leak the existing key -- it is in the container's log history
and has to be rotated in GCP separately.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 20:05:29 -04:00
Logan CusanoandClaude Opus 5 96625fabd0 Stop ambient radio chatter from opening incidents, and refill the map
The 23:46Z correlation dump confirmed the severity gate fixed the problem it
was written for -- orphans fell from 69 to 16, and only three of those are
after the deploy boundary, two of them deliberate skips. Nothing on TG 9048
absorbs the channel any more; the largest post-deploy incident is four calls
over nine minutes and is genuinely one event.

It overcorrected. 37 of 50 incidents were open, most a single routine call.
The cause was the gate's own substance test, which counted `units` and
`location`. Radio protocol puts a unit ID in essentially every transmission
and a place name in most of them, so has_substance was true almost always and
the severity check never actually ran -- "11-Victor, 72 at Holland Station"
became its own permanent incident. Substance is now a vehicle, a geocode or a
tag: things the extractor found beyond who was speaking and where they stood.
Severity still opens an incident on its own, so nothing real is lost.

incident_type is now validated against the enum the prompt offers rather than
trusted. It is written straight through to incident.type and rendered as the
title, so a model that answered the severity question in the type field
produced an incident titled "Routine -- TGID 9563". Unrecognised values become
None and fall to the tag/severity path, which is what "unknown" already did.

The map was empty for a separate reason: geocoding accepted only ROOFTOP and
RANGE_INTERPOLATED. Dispatch names places the way people speak, and Google
returns GEOMETRIC_CENTER for exactly those forms -- intersections ("Lake
Street and Veterans Memorial Drive") and named POIs ("Brewster Station").
Requiring a street address discarded nearly every real dispatch location and
left only numbered addresses plotted, which is why the July incidents have
coordinates and none since do. GEOMETRIC_CENTER is now accepted; APPROXIMATE
is still rejected, since a region centroid is what an ungeocodable string
degrades to. Note this is necessary but may not be sufficient -- if
GOOGLE_MAPS_API_KEY is unset on the host the map stays empty regardless, and
that has not been checked from here.

Two things found and deliberately not fixed, both in DEFERRED.md. One call can
still land in two incidents, because upload.py correlates each extracted scene
independently and the model over-split one conversation; multi-scene is
intentional, so that is prompt tuning rather than a code change. And nothing
closes an incident that merely goes quiet -- signal-resolution and master
auto-resolve both exist, but a one-call incident nobody clears stays active
forever. That wanted the over-creation fixed first so a time-based sweeper
would not just paper over it.

Gate tests updated: units and location alone must now orphan, and the case
that matters most is kept explicit -- units with a real severity still open an
incident. 17 pass. No new environment variables, so CI deploys this without an
ansible run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 19:58:02 -04:00
Logan Cusano 53965e1a19 Rebuild the frontend as a product rather than an internal tool
Build & Deploy / Build & push images (push) Successful in 4m3s
Build & Deploy / Deploy to VM (push) Successful in 2m35s
The UI worked but read as an operator console: no public face, no way to
describe or sell the thing, and no account surface beyond the node list. This
adds the missing halves and reorganises what was already there around the
incident, which is the unit of value the rest of the pipeline is built to
produce.

A shared design system replaces per-page styling: components/ui (Button, Card,
Badge, EmptyState, Skeleton, PageHeader), a type scale and shadow set in the
Tailwind config, and light-mode tokens in globals.css. The existing
html:not(.dark) remap mechanism is extended rather than replaced -- a parallel
theming system would have been two sources of truth for the same colours.

Public marketing pages (/, /features, /pricing, /faq) load without a session.
middleware.ts gained a PUBLIC_PATHS allowlist to permit that; it remains a UX
redirect and is still NOT an authorisation boundary, which the comment there
says explicitly. Real enforcement is unchanged and still lives server-side in
c2-core's auth.py. Chrome switching is done by pathname in ChromeSwitcher
instead of by route group, because a route group would have collided on / and
forced most of app/ to move for no behavioural gain.

Billing and API keys ship as typed stubs, not integrations. lib/billing.ts and
lib/apiKeys.ts define the data model and the screens consume it, but every
mutating call throws with a message naming the backend route that has to exist
first, and the sample data is labelled as sample. Nothing here can charge
anyone or mint a real credential -- picking a payment processor and holding its
keys is a decision for a human, and a half-wired checkout is worse than an
obviously absent one.

The severity work from the c2-core change lands here too. severity is now a
filter and sort dimension on the incident list rather than decoration, since
a busy dispatch channel is only readable if you can collapse it to moderate and
above. routine gets a muted treatment because it is the majority of traffic,
legacy "unknown" still renders nothing, and TypeBadge handles the new "other"
incident type. Severity rendering moved into lib/severity.tsx so the incident
list, incident detail and call rows cannot drift apart.

Deliberately not touched: calls, map, alerts, nodes, systems, tokens, trips and
admin. They already share the palette and stay coherent, and rewriting them
would have buried the parts that actually needed to change. No colour tokens
were renamed, so nothing regressed there.

Verified with tsc --noEmit (npm run typecheck), clean. No runtime verification
was possible and none was done. No new environment variables.
2026-08-16 19:34:47 -04:00
Logan Cusano 6d5eb4c5f2 Let severity, not incident_type, decide what becomes an incident
Build & Deploy / Build & push images (push) Successful in 4m3s
Build & Deploy / Deploy to VM (push) Successful in 2m29s
The 2026-08-16 correlation dump showed two failures that looked unrelated and
were the same bug. TG 9048 held one incident of 28 calls spanning 49 minutes --
a prisoner transport, a drone retrieval, a records lookup and a canvass, glued
together -- while 32 other calls on that same channel stayed permanently
orphaned.

Creating an incident required a concrete incident_type. Nothing on a transit
police channel produced one: the extraction prompt said to prefer "other" when
uncertain, extraction then collapsed "other" to None, and the tag-based fallback
had no tags to work with because administrative traffic carries none. So the
channel could never open a SECOND incident. Every later call funnelled into
whichever incident happened to exist first, and every call too substantial for
the thin path had nowhere to go at all. The two symptoms were the same missing
value seen from opposite ends.

Severity now decides incident-worthiness. It is a better fit for the question
being asked -- "is this a real event?" -- than a service label ever was, and
unlike incident_type it is always present. The prompt defines four levels with
no escape hatch (routine/minor/moderate/major, "unknown" is gone) and calls
skipped for a too-short transcript are still recorded as routine, because
downstream code reads a missing severity as "not processed yet" rather than
"nothing happened". Anything above routine, or carrying any extracted content,
opens an incident under the neutral "other" type. "other" is also kept as a real
classification now -- rail operations and public works genuinely are not police,
fire or EMS.

Separately, thin calls no longer refresh updated_at; they write last_thin_at.
updated_at drives every recency gate in the fast path, so each "10-4" was
resetting the idle clock on whatever it attached to, keeping that incident
inside the gate for as long as anyone kept acknowledging. An incident now ages
from its last substantive call. This is what made the 49-minute incident
possible even once buckets existed, so it is fixed independently rather than
being left to the gate change.

The re-correlation sweep also now honours skip_reason. /upload has always
refused to correlate garbage and too-short transcripts, but the sweep did not
apply the same filter, so those fragments came back minutes later through the
thin path and attached to whatever was most recent -- a second, quieter route
into the same over-merge.

Adds tests/test_correlator_gate.py (15 cases), the first tests against
incident_correlator.py in its 1,517-line history. tests/conftest.py stubs
firebase-admin only when it is genuinely absent, so the container's real SDK is
never shadowed; this is what makes the correlator importable in the dev venv.
That stub also made test_mqtt_handler and test_node_sweeper collectable for the
first time, revealing 10 pre-existing failures in them -- test-vs-code drift,
untouched here and catalogued in DEFERRED.md.

No new environment variables, so CI deploys this without an ansible run.
2026-08-16 18:25:25 -04:00
Logan Cusano 97013e1505 Stop Whisper hallucinations and dedupe recordings across nodes
Build & Deploy / Build & push images (push) Successful in 4m0s
Build & Deploy / Deploy to VM (push) Successful in 2m29s
Two independent sources of garbage in the AI pipeline, both visible in the
2026-08-16 correlation dump.

1. Hallucinated transcripts. The Whisper prompt opened with an enumerated run
   of ten-codes: 10-4, 10-23, 10-20, 10-97 and so on. Whisper treats prompt
   text as preceding transcript, so on noisy or silent audio it continued the
   series, emitting transcripts that count upward from 10-4 to 10-99. The
   existing no_speech_prob filter could not catch these: the model is highly
   confident in text it invented by continuing a pattern.

   The prompt no longer contains a series to extend, and _is_degenerate()
   rejects the three shapes this failure takes: ascending ten-code runs, one
   phrase looping, and near-identical segments across a whole recording.
   Verified against 13 transcripts from production: all four known
   hallucinations rejected, all nine real ones kept, including terse traffic
   containing legitimate codes.

2. Duplicate recordings. node-002 and node-PI-2 both cover TG 9048 and both
   uploaded the same transmissions, ~1.1s apart. Nine pairs appeared in one
   dump. Each was transcribed, billed and correlated twice, and the resulting
   incident listed two units where there was one.

   Canonical selection is by earliest started_at, tie-broken on call_id, NOT
   by upload order: upload order varies with encode time and network latency,
   so it would make the authoritative recording non-deterministic. Call
   documents are created from MQTT call_start before uploads arrive, so both
   nodes independently reach the same verdict. The loser keeps its audio (it
   may be the cleaner capture) but is excluded from STT, correlation, the
   re-correlation sweep and the orphan debug view.

Also fixes _sync_transcribe returning a bare None when OPENAI_API_KEY is
missing, where the caller unpacks two values. A missing key surfaced as a
misleading "Transcription failed" instead of the real warning.

Adds tests/test_dedup.py (15 cases). dedup.py reaches Firestore through an
injected callable so it stays importable without firebase-admin present.
2026-08-16 17:28:27 -04:00
Logan Cusano a2cd2c57ca Serve call audio through c2-core instead of GCS signed URLs
Build & Deploy / Build & push images (push) Successful in 4m0s
Build & Deploy / Deploy to VM (push) Failing after 2m34s
upload_audio() could only sign a URL when GCP_CREDENTIALS_PATH pointed at a
service-account key file. The deployed VM runs on Application Default
Credentials with no key file, so every upload silently took the fallback
branch and returned a bare gs:// URI. That broke two things at once:

  * Browsers cannot fetch a gs:// URI, so no recording was ever playable.
  * _public_url_to_gcs_uri() only matched https://storage.googleapis.com/ and
    returned None for it, so `if gcs_uri:` in the upload path was always false
    and transcription never ran. Nothing was logged, which is why this looked
    like an OpenAI credits problem rather than a storage one.

The fallback also interpolated the client-supplied filename instead of the
call_id-derived safe name, so the URI did not even name the object written.

Calls now store only the canonical gs:// location. A short-lived playback link
is minted per read as an HMAC over (call_id, expiry) keyed by SERVICE_KEY, and
audio is served from the private bucket by the new /media route. An <audio src>
cannot carry an Authorization header, so the link has to be the credential;
that router is therefore public with the check done inline, as enrollment.py
already does. Signing GCS URLs from the VM would have needed a
serviceAccountTokenCreator grant on its own service account — this avoids the
IAM change entirely and keeps the bucket private.

gcs_uri_for_call() reconstructs the object name from call_id, so recordings
made before this fix are reachable again without a data migration.

Frontend rows come straight from Firestore via onSnapshot and never see a
server-minted field, so CallRow fetches the link lazily on expand.

Also removes the last long-lived (1 year) signed URL and the log line that
printed it.
2026-08-16 16:26:41 -04:00
Logan CusanoandClaude Opus 5 a195563da6 Let edge nodes read /systems with their own api_key
Build & Deploy / Build & push images (push) Successful in 4m26s
Build & Deploy / Deploy to VM (push) Successful in 1m55s
The node builds its OP25 config from GET /systems, but that router only
accepted a Firebase token or the shared service key — a node holds neither.
Every fetch returned 401 and the node fell back to its stale offline cache,
so a system edited in the UI never reached the field. Confirmed on node-002
against the live server: "Failed to fetch systems from C2: 401 Unauthorized
... Offline cache will be used."

The node sends no node_id with the request, only the bearer token, so the
key is matched by querying node_keys for the value instead of fetching a
known document the way /upload does.

Read access only: the mutating routes in this router each carry their own
require_admin_token, so widening the router-level gate doesn't let a node
create, edit or delete a system.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 14:19:04 -04:00
Logan CusanoandClaude Opus 5 55cd1110df Give mosquitto's bind-mounted dirs to uid 1883, not root
The broker crash-looped on every deploy: "Unable to load server certificate
/mosquitto/certs/mqtt.crt ... Permission denied". The cert-sync script wrote
600 root:root into a 0700 root:root directory, on the assumption that
mosquitto runs as root inside its container. It does not — the stock
eclipse-mosquitto entrypoint drops privileges to the in-image mosquitto
user, confirmed on the server as uid=1883(mosquitto) gid=1883(mosquitto),
and the broker's own log says so on every start.

Certs dir is now root:1883 0750 with the cert 0644 and the key 0640, and
the data dir is 1883:1883 recursively — recursively because mosquitto
WRITES dynamic-security.json there, and a root-owned file left by an
earlier deploy would still be unwritable after a directory-only chown.

Also drops the "unverified Caddy cert path" note: a real issuance confirmed
the path, producing CN=mqtt.drb.cusano.net signed by Let's Encrypt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 13:29:26 -04:00
Logan CusanoandClaude Opus 5 a0a414ad21 Revert the CI full-fetch workaround and drop the dead Caddyfile
Build & Deploy / Build & push images (push) Successful in 7m34s
Build & Deploy / Deploy to VM (push) Successful in 29s
Shallow clones were never a Gitea packing bug. An intruder had set
uploadpack.packObjectsHook in Gitea's HOME gitconfig, pointing at a
non-executable dropper, so every upload-pack died mid-pack. That hook is
gone and --depth=1 clones are verified working, so fetch-depth: 0 buys
nothing but slower CI. See INCIDENT-2026-08-11.md.

infra/Caddyfile was dead: ansible templates Caddyfile.j2 to
/etc/caddy/Caddyfile, and nothing ever deployed the static copy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 12:59:52 -04:00
Logan CusanoandClaude Opus 5 518ac46929 Use a full fetch in CI: Gitea fails to pack a shallow clone
Build & Deploy / Build & push images (push) Failing after 50s
Build & Deploy / Deploy to VM (push) Has been skipped
actions/checkout defaults to depth=1, and Gitea aborted generating that pack
with a bad pack header protocol error on all three retries, failing the build
before any image was pushed. A full fetch avoids the shallow-pack path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 10:09:06 -04:00
Logan CusanoandClaude Opus 5 052dda0b1f Point app_url at the bare domain and publish the broker host
Build & Deploy / Build & push images (push) Failing after 42s
Build & Deploy / Deploy to VM (push) Has been skipped
app_url advertised https://app.<domain>, which has never had a DNS record —
the frontend is served on the bare domain by Caddy. Adds mqtt_host so the
broker endpoint nodes connect to is discoverable from terraform output.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 10:05:12 -04:00
Logan CusanoandClaude Opus 5 ee633cbe46 Secure the broker for public exposure: TLS and per-node credentials
Build & Deploy / Build & push images (push) Failing after 42s
Build & Deploy / Deploy to VM (push) Has been skipped
Edge nodes are deployed to arbitrary locations by arbitrary people, so the
broker has to be reachable from the internet and secured on its own merits
rather than by a VPN.

Three defects made that impossible. The broker only had a plaintext 1883
listener; every node shared one drb-node password; and the ACL pattern used
%c, the client-supplied client id, so any holder of that shared password
could set client_id to another node and take over its namespace. The comment
claiming this cryptographically prevented cross-node access was wrong and is
gone.

Authentication now uses mosquitto 2.x's built-in dynamic-security plugin on
the stock eclipse-mosquitto image. c2-core administers it over the control
topic, creating each node's client on approval with username=<node_id> and
password=<its node_keys api_key>, attached to a role whose ACL is nodes/%u/#
against the authenticated username. One credential, one revocation point.
An HTTP-callback plugin was implemented first and rejected: that project is
archived upstream, which is not an acceptable dependency on an
internet-facing broker.

Because dynsec state is a second source of truth alongside Firestore,
approve/reissue/delete now write to the broker first and surface a 502
rather than drifting, and c2-core reconciles every approved node into dynsec
on startup.

Adds node self-enrollment (POST /nodes/enroll, GET /nodes/{id}/credentials)
so a new node can obtain its key over HTTPS without an operator handling
secrets by hand. Enrolling an already-approved node_id is refused on the
fleet token alone — otherwise a leaked token plus a guessable id would let
an attacker steal a live node's key before the real node asked for it.
Pickup secrets are stored hashed and returned once, and the endpoint is rate
limited per source IP.

Infrastructure: an 8883 TLS listener fed by Caddy's certificate via a
systemd path unit, a firewall rule for it, and Caddy now 404s /internal/*
so the api vhost cannot proxy internal routes.

Also fixes CORS, which allowed https://app.<domain> while the frontend is
served on the bare domain — every call from the portal would have failed —
and widens the vault gitignore to a glob, since ansible-vault leaves
backup siblings that the exact-name rule left committable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 09:34:44 -04:00
Logan CusanoandClaude Opus 5 1f5f1fede8 Serve the frontend on the bare domain instead of app.<domain>
Build & Deploy / Build & push images (push) Successful in 4m4s
Build & Deploy / Deploy to VM (push) Failing after 2m11s
Only drb.cusano.net and api.drb.cusano.net have public A records, so the
app.<domain> vhost had no cert to present and the bare domain — the record
that actually exists — matched no site at all, producing
ERR_SSL_PROTOCOL_ERROR in the browser.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 22:35:47 -04:00
Logan CusanoandClaude Opus 5 12c9ad73bb Document the no-$-in-vault-values rule that caused the MQTT auth failure
Build & Deploy / Build & push images (push) Successful in 4m4s
Build & Deploy / Deploy to VM (push) Failing after 2m12s
A password containing "$fP" was interpolated away by compose, giving
mosquitto and c2-core two different passwords and producing
"MQTT connect refused: Not authorized" with nothing in the logs pointing at
the cause. Recorded next to the values so the next person generating
credentials sees it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 22:28:38 -04:00
Logan CusanoandClaude Opus 5 971ab74d44 Escape $ in the compose-interpolated .env so MQTT passwords survive
Build & Deploy / Build & push images (push) Successful in 4m2s
Build & Deploy / Deploy to VM (push) Failing after 2m12s
Compose interpolates the top-level .env, so a password containing "$fP" was
read as the variable $fP and replaced with an empty string — hence the
repeated "The \"fP\" variable is not set" warnings on every compose command.

The env_file templates are not interpolated, so c2-core kept the literal
password while mosquitto's entrypoint received the mangled one. The two sides
disagreed and c2-core could not authenticate to the broker. Escaping $ as $$
here (and only here) makes compose collapse it back to the real value.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 21:28:38 -04:00
Logan CusanoandClaude Opus 5 6140dd7b9c Fix prod compose port collision and make ansible deploy re-runnable
Build & Deploy / Build & push images (push) Successful in 4m24s
Build & Deploy / Deploy to VM (push) Failing after 2m11s
docker-compose.prod.yml: compose merges `ports` by appending, so the prod
override left the base file's 8888:8000 and 3000:3000 in place next to the
127.0.0.1-scoped ones. Each container tried to bind its port twice and the
second bind failed with "address already in use", so c2-core and frontend
could never start. It also meant the localhost-only binding never applied —
both ports were published on every interface. Marked both `!override`, the
same way mosquitto already used `!reset`.

infra/ansible:
- add the missing "Reload Caddy" handler; the Deploy Caddyfile task notified
  a handler that did not exist, which aborts the play
- guard mkswap/swapon on whether /swapfile is already active, so a second run
  does not fail on "mounted" / "Device or resource busy"
- git task now updates instead of clone-once, otherwise a re-run redeploys
  whatever code was on the VM at first clone
- vault.yml.example: correct the registry token comment to read-only scope

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 21:19:03 -04:00
Logan Cusano 2e3fde2448 refactor: Clean checkin override parsing and require node type in frontend configuration modal
Build & Deploy / Build & push images (push) Successful in 4m0s
Build & Deploy / Deploy to VM (push) Failing after 2m12s
2026-07-12 23:20:39 -04:00
Logan Cusano c42bd1902c feat: Add local system override with 24h timeout support 2026-07-12 23:05:53 -04:00
Logan c6684ea61b Update deploy with next vars
Build & Deploy / Build & push images (push) Successful in 4m9s
Build & Deploy / Deploy to VM (push) Failing after 2m12s
2026-06-22 02:45:49 -04:00
logan fa5f91c0fa Merge pull request 'Infrastructure builds' (#1) from build-infrastructure into main
Build & Deploy / Build & push images (push) Failing after 6m3s
Build & Deploy / Deploy to VM (push) Has been skipped
Reviewed-on: #1
2026-06-22 02:34:58 -04:00
85 changed files with 4816 additions and 447 deletions
+14 -5
View File
@@ -7,11 +7,20 @@
# password file. Use different values in production — do NOT reuse defaults.
# -----------------------------------------------------------------------
# C2-core service account (full broker access)
# C2-core service account (full broker access via the "c2core" dynsec role)
MQTT_C2_USER=drb-c2-core
MQTT_C2_PASS=change-me-c2
# Shared credential for all edge nodes (ACL scopes each node to its own
# nodes/<NODE_ID>/# namespace via the MQTT client ID)
MQTT_NODE_USER=drb-node
MQTT_NODE_PASS=change-me-node
# Seeds mosquitto's built-in dynamic-security plugin's one-time "admin"
# bootstrap client on first boot (read directly by mosquitto, no entrypoint
# scripting involved). Must be >=12 chars. c2-core needs this SAME value as
# MQTT_DYNSEC_ADMIN_PASS in drb-c2-core/.env to log in as "admin" and
# administer node credentials — see app/internal/dynsec.py.
MOSQUITTO_DYNSEC_PASSWORD=change-me-dynsec-admin-min-12-chars
# There is no shared node credential anymore. Each node authenticates as
# username=<node_id>, password=<its node_keys.api_key> — checked by
# mosquitto's dynamic-security plugin (not an HTTP backend — that was an
# earlier, since-rejected design using the now-archived mosquitto-go-auth).
# Nodes obtain that key via the enrollment flow — see ENROLLMENT_TOKEN in
# drb-c2-core/.env.example.
+16 -1
View File
@@ -52,6 +52,15 @@ jobs:
tags: |
${{ env.REGISTRY }}/frontend:latest
${{ env.REGISTRY }}/frontend:${{ gitea.sha }}
build-args: |
NEXT_PUBLIC_C2_URL=https://api.${{ secrets.DRB_DOMAIN }}
NEXT_PUBLIC_FIREBASE_API_KEY=${{ secrets.FIREBASE_API_KEY }}
NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN=${{ secrets.FIREBASE_AUTH_DOMAIN }}
NEXT_PUBLIC_FIREBASE_PROJECT_ID=${{ secrets.FIREBASE_PROJECT_ID }}
NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET=${{ secrets.FIREBASE_STORAGE_BUCKET }}
NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID=${{ secrets.FIREBASE_MESSAGING_SENDER_ID }}
NEXT_PUBLIC_FIREBASE_APP_ID=${{ secrets.FIREBASE_APP_ID }}
NEXT_PUBLIC_FIRESTORE_DATABASE=${{ secrets.FIRESTORE_DATABASE }}
deploy:
name: Deploy to VM
@@ -59,15 +68,21 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check runner outbound IP
run: curl -s ifconfig.me
- name: Write SSH key
run: |
echo "${{ secrets.SSH_PRIVATE_KEY }}" > /tmp/deploy_key
printf '%s\n' "${{ secrets.SSH_PRIVATE_KEY }}" > /tmp/deploy_key
chmod 600 /tmp/deploy_key
ssh-keygen -l -f /tmp/deploy_key
- name: Deploy
run: |
ssh -o StrictHostKeyChecking=no \
-o HostKeyAlgorithms=ssh-ed25519,rsa-sha2-256,rsa-sha2-512 \
-o ConnectTimeout=15 \
-v \
-i /tmp/deploy_key \
drb@${{ secrets.SERVER_IP }} << 'ENDSSH'
set -e
+4 -1
View File
@@ -15,7 +15,10 @@ infra/terraform.tfvars
infra/tf.log
infra/ansible/inventory.ini
infra/ansible/group_vars/all.yml
infra/ansible/vault.yml
# Glob, not the bare filename: ansible-vault edit and manual backups leave
# siblings like vault.yml.locked.bak, which the exact-name rule left untracked
# but committable.
infra/ansible/vault.yml*
# Python
__pycache__/
+26 -3
View File
@@ -8,13 +8,36 @@
# - restart: always (instead of unless-stopped) for hard reboots.
services:
# ports AND volumes both need !override here, not !reset/a plain list —
# compose merges list-type fields by APPENDING across -f files. A plain
# list (or !reset on volumes) would leave dev's mosquitto_certs named
# volume mounted at /mosquitto/certs alongside this bind mount, and two
# mounts targeting the same path is exactly the "address already in use"-
# style footgun the c2-core override below already hit once with ports.
# mosquitto-data is now a host bind mount too (not just certs) — it holds
# dynamic-security.json, the broker's only record of node credentials
# (see app/internal/dynsec.py "TWO-SOURCES-OF-TRUTH"). A named Docker
# volume already survives normal redeploys (git pull && compose pull &&
# up -d never passes -v), but the bind mount makes it inspectable/
# backupable the same way the cert directory already is. NOT read-only —
# mosquitto writes dynamic-security.json here.
mosquitto:
restart: always
ports: !reset [] # Remove the dev 1883:1883 mapping — internal only
ports: !override
- "8883:8883" # TLS only, published. 1883 stays internal (docker bridge, c2-core's own login).
volumes: !override
- ./drb-c2-core/mosquitto/mosquitto.conf:/mosquitto/config/mosquitto.conf:ro
- /opt/drb/mosquitto-data:/mosquitto/data
- /opt/drb/mosquitto-certs:/mosquitto/certs:ro # fed by the cert-sync systemd unit, see infra/ansible
# !override, not a plain list: compose MERGES `ports` by appending, so a plain
# list leaves the base file's "8888:8000" in place alongside this one. The
# container then tries to bind 8888 twice — 0.0.0.0 and 127.0.0.1 — and the
# second bind fails with "address already in use". It also silently defeated
# the whole point of this override, publishing the port on every interface.
c2-core:
restart: always
ports:
ports: !override
- "127.0.0.1:8888:8000" # Caddy proxies, not exposed publicly
discord-bot:
@@ -22,5 +45,5 @@ services:
frontend:
restart: always
ports:
ports: !override
- "127.0.0.1:3000:3000" # Caddy proxies, not exposed publicly
+18 -8
View File
@@ -1,20 +1,23 @@
services:
# Auth is mosquitto's own built-in dynamic-security plugin (see
# mosquitto.conf + app/internal/dynsec.py) — NOT mosquitto-go-auth, that
# project is archived upstream (no CVE patches), rejected for a
# public-internet broker. Stock official image, pinned to an exact patch
# (not the floating `:2` tag). MOSQUITTO_DYNSEC_PASSWORD seeds the
# plugin's own one-time "admin" bootstrap client on first boot — read
# directly by the plugin's C code, no entrypoint scripting needed for it.
mosquitto:
image: eclipse-mosquitto:2
image: eclipse-mosquitto:2.1.2-alpine
restart: unless-stopped
ports:
- "1883:1883"
entrypoint: ["/bin/sh", "/mosquitto/config/entrypoint.sh"]
- "8883:8883"
environment:
- MQTT_C2_USER=${MQTT_C2_USER}
- MQTT_C2_PASS=${MQTT_C2_PASS}
- MQTT_NODE_USER=${MQTT_NODE_USER}
- MQTT_NODE_PASS=${MQTT_NODE_PASS}
- MOSQUITTO_DYNSEC_PASSWORD=${MOSQUITTO_DYNSEC_PASSWORD}
volumes:
- ./drb-c2-core/mosquitto/mosquitto.conf:/mosquitto/config/mosquitto.conf:ro
- ./drb-c2-core/mosquitto/acl.conf:/mosquitto/config/acl.conf:ro
- ./drb-c2-core/mosquitto/entrypoint.sh:/mosquitto/config/entrypoint.sh:ro
- mosquitto_data:/mosquitto/data
- mosquitto_certs:/mosquitto/certs
c2-core:
image: ${REGISTRY}/c2-core:${TAG:-latest}
@@ -45,4 +48,11 @@ services:
- c2-core
volumes:
# Dev only for both. Prod overrides these to host bind mounts
# (/opt/drb/mosquitto-data, /opt/drb/mosquitto-certs — the latter fed by
# the Caddy cert-sync systemd unit) — see docker-compose.prod.yml and
# infra/ansible/roles/deploy/templates/. mosquitto_data holds
# dynamic-security.json (node MQTT credentials, see app/internal/dynsec.py)
# as well as the usual broker persistence state.
mosquitto_data:
mosquitto_certs:
+9 -3
View File
@@ -2,10 +2,15 @@
MQTT_BROKER=mosquitto
MQTT_PORT=1883
# Use the c2-core credential — must match MQTT_C2_USER/MQTT_C2_PASS in the
# top-level .env (which is passed to the mosquitto entrypoint)
# top-level .env
MQTT_USER=drb-c2-core
MQTT_PASS=change-me-c2
# Same value as the top-level .env's MOSQUITTO_DYNSEC_PASSWORD — lets
# c2-core log in as mosquitto's built-in dynsec "admin" client to
# administer node MQTT credentials. See app/internal/dynsec.py.
MQTT_DYNSEC_ADMIN_PASS=change-me-dynsec-admin-min-12-chars
# GCP — path to service account JSON inside the container
GCP_CREDENTIALS_PATH=/app/gcp-key.json
@@ -28,6 +33,7 @@ SUMMARY_INTERVAL_MINUTES=15
CORRELATION_WINDOW_HOURS=4
EMBEDDING_SIMILARITY_THRESHOLD=0.82
# Auth — static key that edge nodes send as Bearer token on /upload
# Fleet-wide token edge nodes present as X-Enrollment-Token on first boot
# (POST /nodes/enroll). Shared across every node — NOT a per-node secret.
# Generate with: openssl rand -hex 32
NODE_API_KEY=
ENROLLMENT_TOKEN=
+30
View File
@@ -9,6 +9,15 @@ class Settings(BaseSettings):
mqtt_user: Optional[str] = None
mqtt_pass: Optional[str] = None
# mosquitto's built-in dynamic-security plugin (see app/internal/dynsec.py).
# "admin" is hardcoded by the plugin itself on first boot — not actually
# configurable — kept as a named setting rather than a literal for
# readability. mqtt_dynsec_admin_pass must equal the mosquitto
# container's own MOSQUITTO_DYNSEC_PASSWORD env var (root .env /
# root.env.j2) or c2-core can't administer node credentials at all.
mqtt_dynsec_admin_user: str = "admin"
mqtt_dynsec_admin_pass: Optional[str] = None
# GCP
gcp_credentials_path: Optional[str] = None # None → uses ADC
gcs_bucket: Optional[str] = None # None → audio upload disabled
@@ -51,9 +60,30 @@ class Settings(BaseSettings):
# Internal service key — allows server-side services (discord bot) to call C2 without Firebase
service_key: Optional[str] = None
# Fleet-wide token edge nodes present to POST /nodes/enroll on first boot.
# Not a per-node secret — see routers/enrollment.py for why a leaked copy
# of this alone can't steal an already-approved node's key.
enrollment_token: Optional[str] = None
# Upload size limit — reject audio files larger than this (bytes). Default 100 MB.
upload_max_bytes: int = 100 * 1024 * 1024
# Public origin this API is reachable on, e.g. "https://api.drb.example.com".
# Only used to build absolute call-audio playback links: an <audio src> is
# fetched by the browser directly, so a relative path would resolve against
# the frontend origin, not this one.
public_api_url: Optional[str] = None
# How long a minted call-audio playback link stays valid. Long enough for a
# browsing session, short enough that a copied link isn't durable access.
audio_link_ttl_seconds: int = 6 * 60 * 60
# Two nodes hearing the same transmission start recording within about a
# second of each other (measured across node-002/node-PI-2 on TG 9048).
# 10s is generous against clock skew while staying well under the gap
# between genuinely separate transmissions on a busy dispatch channel.
duplicate_window_seconds: int = 10
# CORS — set to your frontend origin(s) in production, e.g. ["https://app.example.com"]
# Defaults to "*" for local development only.
cors_origins: list[str] = ["*"]
+35
View File
@@ -37,6 +37,41 @@ async def require_service_or_firebase_token(
raise HTTPException(status_code=401, detail="Invalid or expired token")
async def require_node_service_or_firebase_token(
credentials: Optional[HTTPAuthorizationCredentials] = Security(_bearer),
) -> dict:
"""Accept a node's own API key in addition to a service key / Firebase token.
Edge nodes need to read ``/systems`` to build their OP25 config, but they
hold neither a Firebase token nor the shared service key — only the
per-node api_key that ``/upload`` already trusts. Without this they got a
flat 401 and silently fell back to their stale offline cache, so a system
edited in the UI never reached the node.
Unlike ``/upload``, the node sends no node_id alongside the bearer token,
so the key is matched by querying ``node_keys`` for the value rather than
fetching a known document. Mutating routes are unaffected: they carry
their own ``require_admin_token`` dependency, so widening the router-level
gate grants nodes read access only.
"""
if not credentials:
raise HTTPException(status_code=401, detail="Missing authorization token")
token = credentials.credentials
if settings.service_key and secrets.compare_digest(token, settings.service_key):
return {"service": True}
try:
return firebase_auth.verify_id_token(token)
except Exception:
pass
# Deferred import: app.internal.firestore initialises firebase-admin at
# import time, and auth.py is imported from module scope in the routers.
from app.internal import firestore as fstore
matches = await fstore.collection_list("node_keys", api_key=token)
if matches:
return {"node": True, "node_id": matches[0].get("node_id")}
raise HTTPException(status_code=401, detail="Invalid or expired token")
def get_role(decoded: dict) -> str:
"""Extract the effective role from a decoded Firebase token.
+112
View File
@@ -0,0 +1,112 @@
"""
Cross-node duplicate detection for call recordings.
Two edge nodes within range of the same trunked system both decode and upload
the same transmission. That is the normal case for a distributed network, not
an error — but without this, one transmission is transcribed twice, billed
twice, and correlated twice, and the resulting incident shows two "units"
where there was one.
CANONICAL SELECTION IS DELIBERATELY NOT "FIRST UPLOAD WINS". Upload order
depends on encode time and network latency, so it varies run to run; picking
by it would make which recording is authoritative non-deterministic. The call
document is created from the MQTT call_start event *before* the upload
arrives, so by upload time every node's document for the transmission already
exists and can be ranked. Canonical is the earliest ``started_at``, breaking
ties on ``call_id`` so both nodes independently reach the same verdict.
The loser keeps its audio — it is ~60 KB and may be the cleaner capture if the
winner's node had a weak signal — but is excluded from the AI pipeline.
"""
from datetime import datetime, timedelta, timezone
from typing import Awaitable, Callable, Optional
from app.config import settings
from app.internal.logger import logger
# Firestore is reached through an injected callable rather than a module-level
# import. app.internal.firestore initialises firebase-admin at import time,
# which needs credentials and the SDK present — so importing it here would make
# this module unimportable in a unit test. Same reasoning as the deferred
# import in app/internal/auth.py.
QueryFn = Callable[[str, list], Awaitable[list[dict]]]
def _parse_dt(value) -> Optional[datetime]:
"""Firestore hands back Timestamp, datetime, or ISO string depending on writer."""
if not value:
return None
if isinstance(value, datetime):
return value if value.tzinfo else value.replace(tzinfo=timezone.utc)
try:
parsed = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
except ValueError:
return None
return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc)
def _is_canonical(call: dict, others: list[dict]) -> bool:
"""True if `call` is the one recording of this transmission that should be processed."""
started = _parse_dt(call.get("started_at"))
call_id = call.get("call_id") or ""
for other in others:
other_started = _parse_dt(other.get("started_at"))
if not other_started or not started:
continue
if other_started < started:
return False
if other_started == started and (other.get("call_id") or "") < call_id:
return False
return True
async def find_duplicate_of(call: dict, query: Optional[QueryFn] = None) -> Optional[str]:
"""Return the canonical call_id if `call` duplicates another node's recording.
Returns None when this call is the canonical one, or when there is nothing
to compare against (single node in range, or the call lacks the talkgroup
and system identifiers the match is keyed on).
"""
system_id = call.get("system_id")
talkgroup_id = call.get("talkgroup_id")
call_id = call.get("call_id")
started = _parse_dt(call.get("started_at"))
if not (system_id and talkgroup_id is not None and call_id and started):
return None
if query is None:
from app.internal import firestore as fstore
query = fstore.collection_where
window = timedelta(seconds=settings.duplicate_window_seconds)
try:
# Range-scan on started_at, then filter the rest in Python — Firestore
# allows a range on only one field per query.
nearby = await query("calls", [
("system_id", "==", system_id),
("started_at", ">=", started - window),
("started_at", "<=", started + window),
])
except Exception as e:
# Never block an upload on dedup — worst case is the pre-existing
# behaviour of processing both copies.
logger.warning(f"Duplicate check failed for call {call_id}: {e}")
return None
matches = [
c for c in nearby
if c.get("call_id") != call_id
and c.get("talkgroup_id") == talkgroup_id
and c.get("node_id") != call.get("node_id") # same node twice is a real repeat
and not c.get("duplicate_of") # never point at another duplicate
]
if not matches:
return None
if _is_canonical(call, matches):
return None
canonical = min(
matches,
key=lambda c: (_parse_dt(c.get("started_at")) or started, c.get("call_id") or ""),
)
return canonical.get("call_id")
+341
View File
@@ -0,0 +1,341 @@
"""
Client for mosquitto's built-in dynamic-security plugin.
WHY THIS EXISTS: MQTT-PUBLIC-AUTH-PLAN.md originally specced the
mosquitto-go-auth plugin (HTTP backend). That project was archived by its
maintainer 2025-08-06 ("no more changes") — unacceptable for a broker that's
about to be reachable from the public internet, no way to get a CVE fix.
Replaced with mosquitto 2.x's own `dynamic-security` plugin, which ships in
and is maintained alongside the official eclipse-mosquitto image itself.
HOW DYNSEC WORKS (verified against plugin source on
github.com/eclipse-mosquitto/mosquitto, 2026-08-16 — see citations inline;
NOT verified by running anything, per instruction not to execute/deploy
anything from this machine):
- The broker persists clients/roles/ACLs in a JSON file at
`plugin_opt_config_file` (we point this at /mosquitto/data/, the same
volume `persistence_location` already uses — one durable volume for all
broker state, see docker-compose.yml).
- Admin commands are plain MQTT publishes: JSON `{"commands": [...]}` to
`$CONTROL/dynamic-security/v1` (source: plugin.c,
`mosquitto_callback_register(plg_id, MOSQ_EVT_CONTROL,
dynsec_control_callback, "$CONTROL/dynamic-security/v1", ...)`).
Replies come back on `$CONTROL/dynamic-security/v1/response`
(source: control.c, `#define RESPONSE_TOPIC
"$CONTROL/dynamic-security/v1/response"`).
- Per-command JSON fields (verified against clients.c / roles.c handlers
and the plugin README):
createClient: username, password, clientid, textname, textdescription,
roles: [{rolename, priority}], groups: [...]
modifyClient: same fields, username identifies the existing client
deleteClient: username
createRole: rolename, textname, textdescription,
acls: [{acltype, topic, priority, allow}]
acltype values: publishClientSend, publishClientReceive,
subscribeLiteral, subscribePattern, unsubscribeLiteral,
unsubscribePattern. %u (username) and %c (clientid) are valid
substitutions in `topic` for every type except the two *Literal ones.
- On first boot, if `plugin_opt_config_file` doesn't exist, the plugin
bootstraps itself (config_init.c): reads env var
`MOSQUITTO_DYNSEC_PASSWORD` (or `plugin_opt_password_init_file`) and
creates a client literally named "admin" (hardcoded string, NOT
configurable — verified in config_init.c's `client_add_admin()`) with
three roles: `super-admin` (full pub/sub on `$CONTROL/#` — i.e. this is
what makes a client capable of issuing further dynsec commands, and
it's an ordinary role, nothing hardcoded beyond the initial grant),
`sys-observe` ($SYS/# read-only), `topic-observe` (# read-only, NOT
read-write). If MOSQUITTO_DYNSEC_PASSWORD is set (we always set it),
no `democlient` demo account gets created — that only happens in the
"no password provided, generate one randomly" path.
- This is a genuine backend swap, not just config: `allow_anonymous
false` plus the *absence* of `password_file`/`acl_file` directives
means dynsec is the only auth backend registered — nothing else is
there to conflict with it. (Inferred from plugin architecture — every
mosquitto auth backend, built-in or plugin, registers the same
basic-auth/ACL callback hooks; there's no "layering" mechanism, so
without password_file/acl_file directives there is nothing else to
check credentials or topics.)
WHAT COULD NOT BE VERIFIED (see also the plan doc + final report):
- The exact JSON envelope of a *response* message (only individual
command outcomes were confirmed: `mosquitto_control_command_reply(cmd,
NULL)` for success, `mosquitto_control_command_reply(cmd, "error
string")` for failure — the wrapping object shape, e.g. whether it's
`{"responses": [{"command": ..., "error": ...}]}`, was not directly
read from source). This client parses defensively: it treats ANY
dict containing a non-null "error"/"Error" key anywhere in the
top-level response payload as failure, presence of "already exists" in
that string as an idempotent success, and a response with no such key
within the timeout as success. A response timeout is always a hard
failure (never assumed to mean success).
- "Client already exists" was confirmed verbatim as createClient's
error string; "already exists" for createRole is assumed analogous,
not directly confirmed.
TWO-SOURCES-OF-TRUTH: Firestore's `node_keys` collection is the source of
truth for node credentials (nothing changes there); dynamic-security.json
is a derived cache the broker uses to authenticate. `reconcile_all()`
rebuilds every approved node's dynsec client from Firestore and is called
on every c2-core startup — so a lost/corrupted dynamic-security.json (e.g.
volume wiped) self-heals on the next restart instead of silently locking
out every node. `upsert_node_client()`/`delete_node_client()` are also
called synchronously from routers/nodes.py's approve/reissue/delete
handlers and raise on failure — those endpoints now fail loudly (502)
instead of updating Firestore while dynsec silently didn't get the memo.
"""
import asyncio
import json
import time
import uuid
import paho.mqtt.client as mqtt
from app.config import settings
from app.internal.logger import logger
from app.internal import firestore as fstore
CONTROL_TOPIC = "$CONTROL/dynamic-security/v1"
RESPONSE_TOPIC = "$CONTROL/dynamic-security/v1/response"
_RESPONSE_TIMEOUT_SECONDS = 10
# Role every approved node's dynsec client is attached to. %u = the
# authenticated username (the node_id) — this is the fixed version of the
# old `pattern readwrite nodes/%c/#`, where %c was the client-supplied,
# spoofable client ID.
NODE_ROLE = "node"
# Role c2-core's own login gets, in addition to being handed the plugin's
# built-in `super-admin` role (see grant_c2core_admin()). Mirrors the old
# `topic readwrite #` superuser line.
C2CORE_ROLE = "c2core"
class DynsecError(Exception):
"""A dynsec command was rejected, or no response arrived in time."""
def _run_commands_sync(commands: list[dict], username: str, password: str) -> list[dict]:
"""
Blocking: open a short-lived MQTT connection, publish one or more dynsec
commands, wait for the matching replies, disconnect. Always called via
asyncio.to_thread — see the async wrappers below. A fresh connection per
call (rather than reusing mqtt_handler's long-lived client) keeps this
request/response exchange simple and isolated from that client's
async-callback-driven subscribe state.
"""
responses: list[dict] = []
done = {"got": False, "error": None}
def _on_connect(client, userdata, flags, reason_code, properties):
if reason_code != 0:
done["error"] = f"connect refused: {reason_code}"
done["got"] = True
return
client.subscribe(RESPONSE_TOPIC, qos=1)
client.publish(CONTROL_TOPIC, json.dumps({"commands": commands}), qos=1)
def _on_message(client, userdata, msg):
try:
payload = json.loads(msg.payload.decode())
except Exception:
return
responses.append(payload)
done["got"] = True
client = mqtt.Client(
callback_api_version=mqtt.CallbackAPIVersion.VERSION2,
client_id=f"drb-c2-core-dynsec-{uuid.uuid4().hex[:8]}",
)
client.username_pw_set(username, password)
client.on_connect = _on_connect
client.on_message = _on_message
try:
client.connect(settings.mqtt_broker, settings.mqtt_port, keepalive=30)
except Exception as e:
raise DynsecError(f"could not connect to mosquitto for dynsec command: {e}")
client.loop_start()
deadline = time.monotonic() + _RESPONSE_TIMEOUT_SECONDS
try:
while not done["got"] and time.monotonic() < deadline:
time.sleep(0.05)
finally:
client.loop_stop()
client.disconnect()
if done["error"]:
raise DynsecError(str(done["error"]))
if not responses:
raise DynsecError(
f"no response on {RESPONSE_TOPIC} within {_RESPONSE_TIMEOUT_SECONDS}s for commands: "
f"{[c.get('command') for c in commands]}"
)
return responses
def _check_responses_ok(responses: list[dict], tolerate_already_exists: bool = False) -> None:
"""Raise DynsecError unless every response payload is error-free (or,
when tolerate_already_exists, only contains an 'already exists'-style
error — see the module docstring's "could not verify" note on why this
is a substring match rather than a structured error code check)."""
for payload in responses:
# Defensive: walk the payload looking for any *-cased "error" key
# with a non-empty value, since the exact envelope shape wasn't
# confirmed from source. Covers both a flat {"error": "..."} and a
# {"responses": [{"error": "..."}]}-style wrapper.
errors = _find_error_strings(payload)
for err in errors:
if tolerate_already_exists and "already exist" in err.lower():
continue
raise DynsecError(f"dynsec command failed: {err}")
def _find_error_strings(obj) -> list[str]:
found = []
if isinstance(obj, dict):
for k, v in obj.items():
if k.lower() == "error" and v:
found.append(str(v))
else:
found.extend(_find_error_strings(v))
elif isinstance(obj, list):
for item in obj:
found.extend(_find_error_strings(item))
return found
# ---------------------------------------------------------------------------
# Async wrappers (all real work happens in the thread pool)
# ---------------------------------------------------------------------------
async def _admin_publish(commands: list[dict], tolerate_already_exists: bool = False) -> list[dict]:
if not settings.mqtt_dynsec_admin_pass:
raise DynsecError("MQTT_DYNSEC_ADMIN_PASS / mqtt_dynsec_admin_pass is not configured")
responses = await asyncio.to_thread(
_run_commands_sync, commands, settings.mqtt_dynsec_admin_user, settings.mqtt_dynsec_admin_pass
)
_check_responses_ok(responses, tolerate_already_exists=tolerate_already_exists)
return responses
async def ensure_roles_and_c2core_grant() -> None:
"""
Idempotent, safe to run on every startup:
1. createRole "node" — nodes/%u/# publish+subscribe (both directions)
2. createRole "c2core" — full "#" publish+subscribe, same reach the
old `topic readwrite #` superuser line gave c2-core
3. createClient/modifyClient drb-c2-core (settings.mqtt_user) with
BOTH roles above AND the plugin's built-in "super-admin" role —
i.e. c2-core's existing login is handed the actual dynsec admin
role, not a separate identity, per the design decision.
Runs over the dedicated "admin" bootstrap login (step 3 assigns
super-admin to c2-core's own login for the record / future use, but
THIS module still authenticates its own ongoing calls as "admin" — see
the module docstring for why: it's the one identity guaranteed by
mosquitto's own source to hold super-admin, so control-plane calls
don't depend on step 3's grant having actually landed).
"""
node_acl_types = ["publishClientSend", "publishClientReceive", "subscribePattern", "unsubscribePattern"]
await _admin_publish([{
"command": "createRole",
"rolename": NODE_ROLE,
"textname": "DRB edge node — own namespace only",
"acls": [{"acltype": t, "topic": "nodes/%u/#", "priority": 0, "allow": True} for t in node_acl_types],
}], tolerate_already_exists=True)
c2core_acl_types = ["publishClientSend", "publishClientReceive", "subscribePattern", "unsubscribePattern"]
await _admin_publish([{
"command": "createRole",
"rolename": C2CORE_ROLE,
"textname": "DRB c2-core — full broker access",
"acls": [{"acltype": t, "topic": "#", "priority": 0, "allow": True} for t in c2core_acl_types],
}], tolerate_already_exists=True)
if not settings.mqtt_user or not settings.mqtt_pass:
logger.warning("dynsec: MQTT_USER/MQTT_PASS not configured — skipping c2-core client grant")
return
roles = [{"rolename": C2CORE_ROLE, "priority": 1}, {"rolename": "super-admin", "priority": 2}]
try:
await _admin_publish([{
"command": "createClient",
"username": settings.mqtt_user,
"password": settings.mqtt_pass,
"roles": roles,
}])
logger.info(f"dynsec: created client {settings.mqtt_user!r} with roles {C2CORE_ROLE}, super-admin")
except DynsecError as e:
if "already exist" in str(e).lower():
await _admin_publish([{
"command": "modifyClient",
"username": settings.mqtt_user,
"password": settings.mqtt_pass,
"roles": roles,
}])
logger.info(f"dynsec: updated existing client {settings.mqtt_user!r} with roles {C2CORE_ROLE}, super-admin")
else:
raise
async def upsert_node_client(node_id: str, api_key: str) -> None:
"""Create or update a node's dynsec client — called from
routers/nodes.py approve_node()/reissue_node_key(), and from
reconcile_all() on startup. Raises DynsecError on failure; callers
must not write Firestore as if this succeeded when it didn't."""
try:
await _admin_publish([{
"command": "createClient",
"username": node_id,
"password": api_key,
"roles": [{"rolename": NODE_ROLE, "priority": 1}],
}])
except DynsecError as e:
if "already exist" not in str(e).lower():
raise
await _admin_publish([{
"command": "modifyClient",
"username": node_id,
"password": api_key,
"roles": [{"rolename": NODE_ROLE, "priority": 1}],
}])
async def delete_node_client(node_id: str) -> None:
"""Best-effort: a node that was never enrolled in dynsec (or already
removed) is treated as already-deleted, not an error."""
try:
await _admin_publish([{"command": "deleteClient", "username": node_id}])
except DynsecError as e:
if "not found" not in str(e).lower():
raise
async def reconcile_all() -> None:
"""
Rebuild dynsec state for every approved node from Firestore
(node_keys is the source of truth). Called once at c2-core startup,
after ensure_roles_and_c2core_grant(). Self-heals a lost/corrupted
dynamic-security.json (e.g. volume wiped, or a prior approve/reissue's
dynsec publish silently failed to persist for some other reason) —
without this, a broker restart with an intact Firestore but an empty
dynsec store would lock out every previously-approved node until
someone noticed and manually re-approved each one.
"""
nodes = await fstore.collection_list("nodes", approval_status="approved")
if not nodes:
return
ok, failed = 0, 0
for node in nodes:
node_id = node.get("node_id")
if not node_id:
continue
key_doc = await fstore.doc_get("node_keys", node_id)
if not key_doc or not key_doc.get("api_key"):
logger.warning(f"dynsec reconcile: node {node_id!r} is approved but has no node_keys entry — skipping")
continue
try:
await upsert_node_client(node_id, key_doc["api_key"])
ok += 1
except DynsecError as e:
failed += 1
logger.error(f"dynsec reconcile: failed to sync node {node_id!r}: {e}")
logger.info(f"dynsec reconcile: {ok} node(s) synced, {failed} failed")
@@ -287,7 +287,7 @@ async def _build_context(
call_units = units if units is not None else (call_doc.get("units") or [])
call_vehicles = vehicles if vehicles is not None else (call_doc.get("vehicles") or [])
call_cleared = cleared_units if cleared_units is not None else (call_doc.get("cleared_units") or [])
call_severity = call_doc.get("severity") or "unknown"
call_severity = call_doc.get("severity") or "routine"
coords = location_coords or call_doc.get("location_coords")
is_thin_call = not call_units and not call_vehicles and not coords
@@ -326,6 +326,7 @@ def _run_decision(ctx: dict) -> dict:
call_embedding = ctx["call_embedding"]
call_units = ctx["call_units"]
call_vehicles = ctx["call_vehicles"]
call_severity = ctx["call_severity"]
coords = ctx["coords"]
is_thin_call = ctx["is_thin_call"]
now = ctx["now"]
@@ -718,6 +719,35 @@ def _run_decision(ctx: dict) -> dict:
f"Correlator: inferred incident_type={resolved_type!r} from tags {tags} for call {call_id}"
)
# Severity, not type, decides whether a call is incident-worthy.
#
# Requiring a concrete incident_type here meant a channel whose traffic never
# classifies — transit/rail administration, records lookups, prisoner
# transports — could never open a SECOND incident. Every later call on that
# talkgroup then funnelled into whichever incident happened to be created
# first, producing hour-long incidents made of unrelated transmissions
# (2026-08-16: TG 9048, 28 calls / 49 min) alongside 30+ permanent orphans.
#
# Anything the extractor judged a real event, or that carries any concrete
# content, now opens an incident under the neutral "other" type. Only
# content-free routine traffic is still left for the thin path to attach.
#
# `call_units` and `location` are NOT substance. Radio protocol puts a unit
# ID in essentially every transmission and a place name in most of them, so
# including them made has_substance true almost always and the severity check
# dead code — the first version of this gate turned "11-Victor, 72 at Holland
# Station" into its own incident and left 37 of 50 incidents open, one call
# each. A vehicle, a geocode, or a tag means the extractor found something
# beyond who was speaking and where they stood.
if not resolved_type:
has_substance = bool(call_vehicles or coords or tags)
if call_severity in ("minor", "moderate", "major") or has_substance:
resolved_type = "other"
logger.info(
f"Correlator: call {call_id} has no incident_type — opening an 'other' "
f"incident (severity={call_severity}, substance={has_substance})"
)
if not resolved_type:
return {"action": "orphan", "matched_incident": None, "incident_type": None, "corr_debug": corr_debug}
@@ -772,11 +802,13 @@ async def _apply_decision(decision: dict, ctx: dict) -> Optional[str]:
if action == "link":
matched_incident = decision["matched_incident"]
# A thin call attaches for context but does not count as incident activity.
thin_link = (decision.get("corr_debug") or {}).get("corr_path") == "fast/thin"
await _update_incident(
matched_incident, call_id, talkgroup_id, system_id, tags,
location, location_coords, call_units, call_vehicles, call_embedding, now,
talkgroup_name=talkgroup_name, incident_type=incident_type,
cleared_units=call_cleared,
cleared_units=call_cleared, refresh_activity=not thin_link,
)
return matched_incident["incident_id"]
@@ -1149,6 +1181,7 @@ async def _update_incident(
talkgroup_name: Optional[str] = None,
incident_type: Optional[str] = None,
cleared_units: Optional[list[str]] = None,
refresh_activity: bool = True,
) -> None:
incident_id = inc["incident_id"]
@@ -1200,10 +1233,20 @@ async def _update_incident(
"units_active": units_active,
"units_cleared": units_cleared,
"location_mentions": location_mentions,
"updated_at": now.isoformat(),
"summary_stale": True,
**embedding_updates,
}
# `updated_at` drives every recency gate in the fast path, so a content-free
# status call must NOT refresh it. When it did, each "10-4" reset the idle
# clock on the incident it attached to, which kept that incident permanently
# "recent" and made it absorb the entire channel for as long as anyone kept
# acknowledging. The incident now ages from its last SUBSTANTIVE call, and
# thin traffic rides along without extending its life.
if refresh_activity:
updates["updated_at"] = now.isoformat()
else:
updates["last_thin_at"] = now.isoformat()
if best_location:
updates["location"] = best_location
if best_coords:
+56 -10
View File
@@ -42,7 +42,7 @@ Response format — a JSON object with a "scenes" array. Each scene:
vehicles: list of vehicle descriptions mentioned
units: list of unit IDs or officer numbers explicitly mentioned
cleared_units: list of unit IDs that explicitly signal back-in-service or available in this recording
severity: one of "minor" | "moderate" | "major" | "unknown"
severity: one of "routine" | "minor" | "moderate" | "major"
resolved: true if this scene explicitly signals incident closure, false otherwise
reassignment: true if a unit is breaking from their current scene to respond to a completely different call — whether dispatch-initiated ("Baker, can you clear and respond to...", "Adam, break from that and go to...") OR unit-initiated ("Show me headed to the vehicle complaint", "Can you show me to that call", a unit going 10-8 and self-requesting a new assignment). False if the unit is reporting in on their current scene, giving a status update, or requesting information about their existing call.
transcript_corrected: corrected text for this scene's transmissions only, or null
@@ -52,7 +52,12 @@ Rules:
- tags: describe WHAT happened, not WHERE. Specific, lowercase, hyphenated. Do not use location names, road names, talkgroup names, or place names as tags (wrong: "lower-macy's", "canvas-route-6", "route-202"; right: "suspect-search", "shoplifting", "vehicle-pursuit"). Do not repeat incident_type as a tag.
- units: ONLY identifiers that appear verbatim in the transcript. Use speaker role inference to distinguish units being dispatched from units acknowledging — both should be included. Never infer or guess unit IDs not present in the text.
- Do not invent details not present in the transcript.
- incident_type: let the talkgroup channel be your primary signal. Use "fire" ONLY if the talkgroup is clearly a fire/rescue channel OR the transcript explicitly describes active fire, smoke, flames, or structure fire activation. Police or EMS referencing a fire scene → use "police" or "ems". When uncertain, prefer "other" over "fire".
- incident_type: let the talkgroup channel be your primary signal. Use "fire" ONLY if the talkgroup is clearly a fire/rescue channel OR the transcript explicitly describes active fire, smoke, flames, or structure fire activation. Police or EMS referencing a fire scene → use "police" or "ems". When the channel is a police channel and nothing in the transcript contradicts it, return "police" — do NOT fall back to "other" merely because the transmission is administrative. Reserve "other" for traffic that genuinely belongs to no emergency service (rail operations, public works, utility coordination). Reserve "unknown" for transcripts too garbled to place at all.
- severity: ALWAYS return one of the four values. Judge the underlying event, not how dramatic the words sound.
"routine" — administrative/status traffic with no incident behind it: mileage and transport logging, radio checks, acknowledgements, shift changes, track block/power requests, records lookups.
"minor" — a real but low-stakes call: lift assist, parking complaint, past-tense larceny report, noise complaint, welfare check.
"moderate" — an active call needing a response now: MVA, alarm activation, disturbance in progress, medical call, suspicious person, road closure.
"major" — life safety or major property loss: structure fire, vehicle pursuit, shots fired, entrapment, cardiac arrest, officer needing assistance.
- ten_codes: interpret radio codes using the department reference provided below. Do not guess codes not listed.
- resolved: true only when the scene explicitly signals "Code 4", "all clear", "10-42", "in custody", "patient transported", "fire out", "GOA", "negative contact", "scene clear".
- cleared_units: only include units that explicitly stated their own back-in-service status in this recording (e.g. "Unit 7, 10-8", "Baker-1 available", "E-14 back in service", or the department ten-code for available/back-in-service listed above). Silence or absence of a unit is NOT clearance. A scene-wide Code 4 belongs in resolved=true, not here — cleared_units is for individual unit availability signals only.
@@ -63,6 +68,13 @@ System: {system_id}
Talkgroup: {talkgroup_name}
{ten_codes_block}{vocabulary_block}{transcript_block}"""
# The incident_type enum offered to the model in EXTRACTION_PROMPT. Kept here
# rather than only in the prompt so a model that invents a value cannot write it
# into incident.type. "unknown" is deliberately absent — it is a real answer
# from the model but not a usable type, and is normalised to None alongside
# anything unrecognised.
_VALID_INCIDENT_TYPES = frozenset({"fire", "ems", "police", "accident", "other"})
# Geographic bias radius for geocoding — half-width in degrees (~55 km)
_GEO_DELTA = 0.5
@@ -183,7 +195,13 @@ async def extract_scenes(
f"({len(transcript.split())} words), skipping"
)
try:
await fstore.doc_set("calls", call_id, {"skip_reason": "transcript_too_short"})
# Severity is still recorded: a five-word acknowledgement is genuinely
# routine traffic, and downstream code treats a missing severity as
# "not yet processed" rather than "nothing happened".
await fstore.doc_set("calls", call_id, {
"skip_reason": "transcript_too_short",
"severity": "routine",
})
except Exception:
pass
return []
@@ -213,13 +231,35 @@ async def extract_scenes(
vehicles: list[str] = scene.get("vehicles") or []
units: list[str] = scene.get("units") or []
cleared_units: list[str] = scene.get("cleared_units") or []
severity: str = scene.get("severity") or "unknown"
# Every call carries a severity — it is the signal the correlator uses to
# decide whether a call is incident-worthy at all, so it must never be
# absent. "unknown" is a legacy value from before the prompt guaranteed
# one of the four levels; normalise it to the bottom rung.
severity: str = scene.get("severity") or "routine"
if severity == "unknown":
severity = "routine"
resolved: bool = bool(scene.get("resolved", False))
reassignment: bool = bool(scene.get("reassignment", False))
transcript_corrected: Optional[str]= scene.get("transcript_corrected") or None
segment_indices: Optional[list] = scene.get("segment_indices")
if incident_type in ("unknown", "other", ""):
# "other" is a real classification (rail ops, public works, utility work)
# and is kept. Collapsing it to None used to make the call untypeable,
# and an untypeable call could never open an incident — see the creation
# gate in incident_correlator._run_decision().
#
# Anything outside the enum is a model error, not a new category. The
# value is written straight through to incident.type and rendered as the
# incident title, so on 2026-08-16 a model that answered the severity
# question in the type field produced an incident literally titled
# "Routine — TGID 9563". Unrecognised values become None and fall to the
# tag/severity path, which is the same treatment "unknown" already got.
if incident_type not in _VALID_INCIDENT_TYPES:
if incident_type and incident_type != "unknown":
logger.warning(
f"Intelligence: discarding invalid incident_type {incident_type!r} "
f"(not in {sorted(_VALID_INCIDENT_TYPES)})"
)
incident_type = None
# Geocode this scene's location.
@@ -399,11 +439,17 @@ async def _geocode_location(
return None
result = data["results"][0]
location_type = result.get("geometry", {}).get("location_type", "")
# Only accept address-level precision. GEOMETRIC_CENTER (city/neighborhood
# centroid) and APPROXIMATE (region boundary) produce coordinates that look
# valid but are too vague for 0.5km proximity matching — they often resolve
# to the same point as the node's position and create false proximity matches.
if location_type not in ("ROOFTOP", "RANGE_INTERPOLATED"):
# Reject only APPROXIMATE — a region/city boundary centroid, which is
# what an ungeocodable string degrades to and is genuinely useless.
#
# ROOFTOP-only was too strict and emptied the map: dispatch names
# places the way people speak, and Google returns GEOMETRIC_CENTER for
# exactly those forms — intersections ("Lake Street and Veterans
# Memorial Drive") and named POIs ("Brewster Station"). Both are
# precise enough to plot and to proximity-match; requiring a street
# address threw away nearly every real dispatch location, leaving only
# numbered addresses geocoded.
if location_type not in ("ROOFTOP", "RANGE_INTERPOLATED", "GEOMETRIC_CENTER"):
logger.info(
f"Geocoding rejected '{location_str}' — imprecise result "
f"(location_type={location_type!r}), returning None"
+8
View File
@@ -7,4 +7,12 @@ logging.basicConfig(
handlers=[logging.StreamHandler(sys.stdout)],
)
# httpx logs every request at INFO as a full URL *including the query string*,
# which puts API keys in plaintext in container logs — the Google Maps key was
# leaking on every geocode call (`?address=...&key=AIza...`). Nothing here needs
# per-request client logging, so drop httpx to WARNING; failures still surface
# because the callers log their own errors.
logging.getLogger("httpx").setLevel(logging.WARNING)
logging.getLogger("httpcore").setLevel(logging.WARNING)
logger = logging.getLogger("drb-c2-core")
+47 -2
View File
@@ -1,6 +1,6 @@
import asyncio
import json
from datetime import datetime, timezone
from datetime import datetime, timezone, timedelta
from typing import Optional
import paho.mqtt.client as mqtt
from app.config import settings
@@ -33,6 +33,10 @@ class MQTTHandler:
client.subscribe("nodes/+/checkin", qos=1)
client.subscribe("nodes/+/status", qos=1)
client.subscribe("nodes/+/metadata", qos=1)
# TODO(mqtt-cutover): drop this subscribe once the enrollment/HTTP
# credentials flow (routers/enrollment.py) is stable in prod and
# node-26 (the one live node) has been migrated. See
# MQTT-PUBLIC-AUTH-PLAN.md "Rollout order" step 6.
client.subscribe("nodes/+/key_request", qos=1)
logger.info("MQTT connected — subscribed to node topics.")
else:
@@ -94,6 +98,11 @@ class MQTTHandler:
"last_seen": now.isoformat(),
"assigned_system_id": None,
"approval_status": "pending",
"node_type": payload.get("node_type", "fixed"),
"enforce_override_timeout": payload.get("enforce_override_timeout", True),
"is_overridden": False,
"override_system_id": None,
"override_timeout_at": None,
}
await fstore.doc_set("nodes", node_id, doc, merge=False)
logger.info(f"New node registered: {node_id} — pending admin approval.")
@@ -111,6 +120,34 @@ class MQTTHandler:
elif existing.get("approval_status") == "approved":
# Approved but not yet configured — restore reachable status after reboot
updates["status"] = "unconfigured"
node_type = payload.get("node_type", existing.get("node_type", "fixed"))
enforce_timeout = payload.get("enforce_override_timeout", existing.get("enforce_override_timeout", True))
is_overridden = payload.get("is_overridden", False)
override_system_id = payload.get("override_system_id")
updates["node_type"] = node_type
updates["enforce_override_timeout"] = enforce_timeout
if node_type == "portable":
updates["is_overridden"] = False
updates["override_system_id"] = None
updates["override_timeout_at"] = None
else:
updates["is_overridden"] = is_overridden
updates["override_system_id"] = override_system_id
if is_overridden:
existing_timeout = existing.get("override_timeout_at")
existing_override_id = existing.get("override_system_id")
if enforce_timeout:
if not existing_timeout or existing_override_id != override_system_id:
updates["override_timeout_at"] = (now + timedelta(hours=24)).isoformat()
else:
updates["override_timeout_at"] = None
else:
updates["override_timeout_at"] = None
await fstore.doc_update("nodes", node_id, updates)
# NOTE: discord_connected in checkins is informational only — do NOT release the
@@ -221,6 +258,11 @@ class MQTTHandler:
# ------------------------------------------------------------------
# Key request — re-deliver an existing approved key to a node that
# lost its credentials (e.g. after a directory move / fresh volume)
# TODO(mqtt-cutover): remove this handler + publish_node_key() below,
# and the key_request subscribe above, in the separate post-cutover
# pass called out in MQTT-PUBLIC-AUTH-PLAN.md. Left in place for now so
# node-26 (currently live, using the shared-password MQTT path) keeps
# working until the enrollment flow has replaced it in prod.
# ------------------------------------------------------------------
async def _handle_key_request(self, node_id: str):
@@ -253,7 +295,10 @@ class MQTTHandler:
logger.warning(f"MQTT not connected — could not push config to {node_id}")
def publish_node_key(self, node_id: str, api_key: str):
"""Publish the provisioned API key to the node (retained so it survives reconnects)."""
"""Publish the provisioned API key to the node (retained so it survives reconnects).
TODO(mqtt-cutover): dead once nodes.py's callers switch to the HTTP
credentials poll (routers/enrollment.py) exclusively. See note above
_handle_key_request."""
topic = f"nodes/{node_id}/api_key"
if self._client and self._connected:
self._client.publish(topic, json.dumps({"api_key": api_key}), qos=2, retain=True)
+32
View File
@@ -55,3 +55,35 @@ async def _sweep():
logger.info(f"Node {node_id} marked offline (last seen: {last_seen.isoformat()})")
from app.routers.tokens import release_token
await release_token(node_id)
continue
# Check for expired system overrides (only for fixed nodes with timeout enforced)
override_timeout_raw = node.get("override_timeout_at")
enforce_timeout = node.get("enforce_override_timeout", True)
node_type = node.get("node_type", "fixed")
if override_timeout_raw and enforce_timeout and node_type != "portable":
if isinstance(override_timeout_raw, str):
override_timeout = datetime.fromisoformat(override_timeout_raw)
else:
override_timeout = override_timeout_raw
if override_timeout.tzinfo is None:
override_timeout = override_timeout.replace(tzinfo=timezone.utc)
if datetime.now(timezone.utc) > override_timeout:
node_id = node.get("node_id")
assigned_system_id = node.get("assigned_system_id")
logger.info(f"Node {node_id} override has expired. Reverting to system {assigned_system_id}.")
# Push the original assigned config if it exists
if assigned_system_id:
system_doc = await fstore.doc_get("systems", assigned_system_id)
if system_doc:
from app.internal.mqtt_handler import mqtt_handler
mqtt_handler.push_config(node_id, system_doc)
await fstore.doc_update("nodes", node_id, {
"is_overridden": False,
"override_system_id": None,
"override_timeout_at": None,
})
@@ -54,6 +54,13 @@ async def _run_sweep_pass() -> None:
c for c in recent_ended
if not c.get("incident_ids") and not c.get("incident_id")
and not c.get("corr_path") # skip calls already exhausted
and not c.get("duplicate_of") # another node's copy — never processed by design
# /upload deliberately skips correlation for garbage and too-short
# transcripts (routers/upload.py) because they carry no signal. The sweep
# was not applying the same guard, so those fragments came back in through
# the thin path minutes later and attached to whatever was most recent —
# a second route into the over-merge the thin fix above addresses.
and not c.get("skip_reason")
and c.get("corr_sweep_count", 0) < MAX_SWEEP_ATTEMPTS
]
+155 -23
View File
@@ -1,9 +1,40 @@
"""
Call-audio storage and playback links.
TWO THINGS THIS MODULE DELIBERATELY DOES NOT DO ANY MORE:
1. It does not return a GCS *signed* URL from the upload path. Signing needs a
service-account private key, and the deployed VM runs on Application Default
Credentials with no key file (see ansible c2-core.env.j2). The old code
silently fell back to returning a bare ``gs://`` URI, which broke two things
at once: browsers can't fetch a gs:// URI, so no recording was ever
playable, and ``_public_url_to_gcs_uri`` in upload.py returned None for it,
so the transcription step was skipped without logging anything at all.
2. It does not store a long-lived URL on the call document. What gets persisted
is the canonical ``gs://`` object location; a short-lived playback link is
minted on read instead. Nothing durable and nothing loggable is a credential.
Playback goes through c2-core's own /media route rather than GCS directly,
because an <audio src> cannot carry an Authorization header — so the link
itself has to be the credential. It is a plain HMAC over (call_id, expiry)
keyed by SERVICE_KEY, which costs no network round-trip, keeps the bucket
fully private, and needs no IAM change on the VM's service account.
"""
import asyncio
import datetime
from typing import Optional
import hashlib
import hmac
import os
import time
from typing import Optional, Tuple
from app.config import settings
from app.internal.logger import logger
# Domain separation: the audio-link key is derived from SERVICE_KEY rather than
# being SERVICE_KEY itself, so a leaked playback link can never be replayed as
# a service-key bearer token against the rest of the API.
_KEY_CONTEXT = b"drb-audio-link-v1"
def _safe_audio_filename(filename: str, call_id: str) -> str:
"""Return a safe GCS object name derived from the call_id.
@@ -12,7 +43,6 @@ def _safe_audio_filename(filename: str, call_id: str) -> str:
call_id (which we control) to prevent path traversal via crafted filenames.
The original extension is preserved only if it's a known audio type.
"""
import os
ext = os.path.splitext(filename)[-1].lower() if filename else ""
if ext not in (".mp3", ".wav", ".ogg", ".m4a", ".aac", ".flac"):
ext = ".mp3"
@@ -20,38 +50,140 @@ def _safe_audio_filename(filename: str, call_id: str) -> str:
async def upload_audio(data: bytes, filename: str, call_id: str = "") -> Optional[str]:
"""Upload audio bytes to GCS and return a signed URL, or None if disabled."""
"""Upload audio bytes to GCS and return the canonical gs:// URI, or None if disabled."""
if not settings.gcs_bucket:
logger.info("GCS_BUCKET not configured — skipping audio upload.")
return None
def _upload() -> str:
safe_name = _safe_audio_filename(filename, call_id)
blob_path = f"calls/{safe_name}"
def _upload() -> None:
from google.cloud import storage
from google.oauth2 import service_account as sa
if settings.gcp_credentials_path:
client = storage.Client.from_service_account_json(settings.gcp_credentials_path)
signing_creds = sa.Credentials.from_service_account_file(settings.gcp_credentials_path)
else:
client = storage.Client()
signing_creds = None
bucket = client.bucket(settings.gcs_bucket)
safe_name = _safe_audio_filename(filename, call_id)
blob = bucket.blob(f"calls/{safe_name}")
blob = client.bucket(settings.gcs_bucket).blob(blob_path)
blob.upload_from_string(data, content_type="audio/mpeg")
if signing_creds:
return blob.generate_signed_url(
version="v2",
expiration=datetime.timedelta(days=365),
method="GET",
credentials=signing_creds,
)
# Fallback: return the gs:// URI (no public access)
return f"gs://{settings.gcs_bucket}/calls/{filename}"
try:
url = await asyncio.to_thread(_upload)
logger.info(f"Audio uploaded: {url}")
return url
await asyncio.to_thread(_upload)
except Exception as e:
logger.error(f"GCS upload failed: {e}")
return None
gcs_uri = f"gs://{settings.gcs_bucket}/{blob_path}"
logger.info(f"Audio uploaded: {gcs_uri}")
return gcs_uri
async def download_audio(gcs_uri: str) -> Optional[bytes]:
"""Fetch an object back out of GCS. Server-side read — no signing involved."""
bucket_name, blob_path = split_gcs_uri(gcs_uri)
if not bucket_name:
return None
def _download() -> bytes:
from google.cloud import storage
if settings.gcp_credentials_path:
client = storage.Client.from_service_account_json(settings.gcp_credentials_path)
else:
client = storage.Client()
return client.bucket(bucket_name).blob(blob_path).download_as_bytes()
try:
return await asyncio.to_thread(_download)
except Exception as e:
logger.warning(f"GCS download failed for {gcs_uri}: {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://"):
return None, None
without_scheme = gcs_uri[len("gs://"):]
if "/" not in without_scheme:
return None, None
bucket_name, blob_path = without_scheme.split("/", 1)
return bucket_name, blob_path
def gcs_uri_for_call(call: dict) -> Optional[str]:
"""Resolve the audio object for a call document.
Prefers the canonical ``audio_gcs_uri`` written by /upload. Falls back to
reconstructing the object name from the call_id for documents written
before this module was fixed: those stored a gs:// URI built from the
*client-supplied* filename, which never matched the object actually
written (always ``calls/{call_id}.mp3``). Reconstructing rather than
trusting the stored value is what makes every pre-existing recording
playable again without a data migration.
"""
uri = call.get("audio_gcs_uri")
if uri:
return uri
call_id = call.get("call_id")
if call.get("audio_url") and call_id and settings.gcs_bucket:
return f"gs://{settings.gcs_bucket}/calls/{call_id}.mp3"
return None
def _link_key() -> Optional[bytes]:
if not settings.service_key:
return None
return hmac.new(settings.service_key.encode("utf-8"), _KEY_CONTEXT, hashlib.sha256).digest()
def sign_audio_link(call_id: str, expires_at: int) -> Optional[str]:
key = _link_key()
if not key:
return None
msg = f"{call_id}:{expires_at}".encode("utf-8")
return hmac.new(key, msg, hashlib.sha256).hexdigest()
def verify_audio_link(call_id: str, expires_at: int, signature: str) -> bool:
if expires_at < int(time.time()):
return False
expected = sign_audio_link(call_id, expires_at)
if not expected:
return False
return hmac.compare_digest(expected, signature)
_warned_no_service_key = False
_warned_no_public_url = False
def playback_url(call: dict) -> Optional[str]:
"""Mint a short-lived playback URL for a call, or None if it has no audio."""
global _warned_no_service_key, _warned_no_public_url
call_id = call.get("call_id")
if not call_id or not gcs_uri_for_call(call):
return None
expires_at = int(time.time()) + settings.audio_link_ttl_seconds
signature = sign_audio_link(call_id, expires_at)
if not signature:
if not _warned_no_service_key:
logger.error("SERVICE_KEY not set — call audio cannot be served.")
_warned_no_service_key = True
return None
# Loud rather than silent: a relative link here would 404 against the
# frontend origin, which is the exact failure mode this module exists to
# stop repeating. Deploy via ansible so c2-core.env.j2 sets PUBLIC_API_URL.
if not settings.public_api_url and not _warned_no_public_url:
logger.error("PUBLIC_API_URL not set — call audio links will be relative and will not resolve.")
_warned_no_public_url = True
base = (settings.public_api_url or "").rstrip("/")
return f"{base}/media/calls/{call_id}/audio?exp={expires_at}&sig={signature}"
def with_playback_url(call: dict) -> dict:
"""Return the call dict with a freshly minted ``audio_url``."""
return {**call, "audio_url": playback_url(call)}
+85 -7
View File
@@ -5,6 +5,7 @@ Audio is downloaded from GCS then sent to the Whisper API. Falls back to
returning None on any failure so the intelligence pipeline can still run.
"""
import asyncio
import re
import tempfile
import os
from typing import Optional
@@ -14,15 +15,83 @@ from app.internal import firestore as fstore
# Whisper treats `prompt` as preceding transcript text, not instructions.
# Writing it as actual radio speech primes the vocabulary toward P25 codes
# and phrasing before the model hears the audio.
#
# DO NOT put an enumerated run of ten-codes in here. The original version of
# this prompt opened with "10-4. 10-23. 10-20. 10-97. 10-8. ..." and Whisper,
# treating that as text it should continue, filled noisy or silent audio with
# sequences like "10-4. 10-5. 10-6. ... 10-99." Those hallucinations sailed
# straight past the no_speech_prob filter below, because the model is highly
# confident the continuation it invented is speech. Codes appear here only
# singly and inside a sentence, where there is no series to extend.
_WHISPER_PROMPT = (
"10-4. 10-23. 10-20. 10-97. 10-8. 10-7. 10-34. 10-50. 10-52. "
"Post 4, I'm out. Post 3. En route. On scene. In route. "
"Copy. Negative. Stand by. Be advised. Go ahead. "
"Units responding. Dispatch. Talkgroup. "
"Engine. Ladder. Medic. Rescue. Car. Unit. "
"MVA. MVC. Structure fire. Working fire."
"Dispatch, go ahead. Copy that, en route. Show me on scene. "
"Be advised, units responding. Negative, stand by. "
"Post 4, I'm out. Received, thank you. "
"Engine and ladder responding to a structure fire. "
"Medic on scene with one patient. "
"Vehicle accident with injuries, MVA. "
"Show me 10-8 and clear."
)
# Degenerate-output detection (see _is_degenerate). Tuned to catch Whisper's
# repetition failure mode without discarding terse but real radio traffic.
_MIN_CODES_FOR_RUN = 6 # ten-codes needed before a run is even considered
_RUN_RATIO = 0.7 # share of consecutive pairs that must step by +1
_MIN_SEGMENTS_FOR_REPEAT = 6 # segments needed before repetition is considered
_UNIQUE_RATIO = 0.25 # unique/total segment texts at or below this is degenerate
_MAX_PHRASE_REPEATS = 8 # identical consecutive phrase repeats allowed in one blob
def _ten_code_run(text: str) -> bool:
"""True if the text is mostly a counting run of ten-codes.
Real traffic uses ten-codes constantly, but never in ascending order — a
dispatcher does not say "10-4, 10-5, 10-6". An arithmetic series is the
signature of Whisper continuing a pattern rather than hearing one.
"""
numbers = [int(n) for n in re.findall(r"\b10-(\d{1,2})\b", text)]
if len(numbers) < _MIN_CODES_FOR_RUN:
return False
steps = [b - a for a, b in zip(numbers, numbers[1:])]
ascending = sum(1 for s in steps if s == 1)
return steps and (ascending / len(steps)) >= _RUN_RATIO
def _phrase_loop(text: str) -> bool:
"""True if one short phrase repeats far more than speech plausibly would.
Catches the other repetition mode, e.g. "Dispatch, do you copy?" emitted
a dozen times over static.
"""
parts = [p.strip().lower() for p in re.split(r"[.!?]", text) if p.strip()]
if len(parts) <= _MAX_PHRASE_REPEATS:
return False
repeats = 1
for prev, cur in zip(parts, parts[1:]):
repeats = repeats + 1 if cur == prev else 1
if repeats > _MAX_PHRASE_REPEATS:
return True
return False
def _is_degenerate(text: str, segments: list[dict]) -> bool:
"""True if a transcript looks like Whisper output rather than radio traffic.
Applied AFTER the per-segment no_speech_prob filter, which does not catch
these: the model reports high confidence in text it invented by continuing
a pattern, so the only tell is the shape of the output itself.
"""
if not text:
return False
if _ten_code_run(text) or _phrase_loop(text):
return True
# Near-identical segments repeated across the whole recording.
if len(segments) >= _MIN_SEGMENTS_FOR_REPEAT:
normalised = {s["text"].strip().lower() for s in segments}
if len(normalised) / len(segments) <= _UNIQUE_RATIO:
return True
return False
async def transcribe_call(
call_id: str,
@@ -76,7 +145,10 @@ def _sync_transcribe(
if not settings.openai_api_key:
logger.warning("OPENAI_API_KEY not set — transcription disabled.")
return None
# Tuple, not a bare None: the caller unpacks two values, so returning
# None here raised a TypeError that surfaced as a misleading
# "Transcription failed" instead of the real missing-key warning.
return None, []
without_scheme = gcs_uri[len("gs://"):]
bucket_name, blob_path = without_scheme.split("/", 1)
@@ -145,11 +217,17 @@ def _sync_transcribe(
# in sync. If every segment was filtered, text becomes None which prevents
# the intelligence pipeline from running on hallucinated content.
text = " ".join(s["text"] for s in segments) or None
if _is_degenerate(text or "", segments):
logger.info(f"Discarded hallucinated transcript for {gcs_uri}: {(text or '')[:80]!r}")
return None, []
return text, segments
else:
# json format returns just {"text": "..."} — no segments or timestamps.
# Intelligence extraction falls back to treating the whole transcript as one block.
text = (response.text or "").strip() or None
if _is_degenerate(text or "", []):
logger.info(f"Discarded hallucinated transcript for {gcs_uri}: {(text or '')[:80]!r}")
return None, []
return text, []
finally:
try:
+41 -2
View File
@@ -9,8 +9,14 @@ from app.internal.summarizer import summarizer_loop
from app.internal.vocabulary_learner import vocabulary_induction_loop
from app.internal.recorrelation_sweep import recorrelation_loop
from app.config import settings
from app.internal.auth import require_firebase_token, require_service_or_firebase_token
from app.internal.auth import (
require_firebase_token,
require_service_or_firebase_token,
require_node_service_or_firebase_token,
)
from app.routers import nodes, systems, calls, upload, tokens, incidents, alerts, admin, trips, places, links, users
from app.routers import enrollment, media
from app.internal import dynsec
from app.internal import firestore as fstore
@@ -36,6 +42,22 @@ async def lifespan(app: FastAPI):
logger.info("DRB C2 Core starting.")
await _release_orphaned_tokens()
# dynsec bootstrap + reconcile — must happen before mqtt_handler.connect()
# so that by the time the app is serving requests, c2-core's own dynsec
# client/roles exist and every already-approved node's dynsec client
# matches Firestore (see app/internal/dynsec.py "TWO-SOURCES-OF-TRUTH").
# Non-fatal by design: if the broker or MQTT_DYNSEC_ADMIN_PASS isn't
# reachable/configured yet (e.g. first-ever deploy, mosquitto still
# starting), log loudly and keep booting rather than crash-looping
# c2-core itself — mqtt_handler.connect() below has its own retry loop
# and node approval/reissue endpoints fail loudly on their own if dynsec
# calls fail later, so nothing here is silently swallowed forever.
try:
await dynsec.ensure_roles_and_c2core_grant()
await dynsec.reconcile_all()
except dynsec.DynsecError as e:
logger.error(f"dynsec bootstrap/reconcile failed — node approval/reissue will fail until this is resolved: {e}")
await mqtt_handler.connect()
sweeper_task = asyncio.create_task(sweeper_loop())
summarizer_task = asyncio.create_task(summarizer_loop())
@@ -63,7 +85,11 @@ app.add_middleware(
)
app.include_router(nodes.router, dependencies=[Depends(require_service_or_firebase_token)])
app.include_router(systems.router, dependencies=[Depends(require_service_or_firebase_token)])
# systems is the one router edge nodes read directly (system_cacher.py builds
# the OP25 config from it), so its gate also accepts a per-node api_key. The
# write routes inside carry their own require_admin_token, so nodes get read
# access only.
app.include_router(systems.router, dependencies=[Depends(require_node_service_or_firebase_token)])
app.include_router(calls.router, dependencies=[Depends(require_service_or_firebase_token)])
app.include_router(tokens.router, dependencies=[Depends(require_service_or_firebase_token)])
app.include_router(incidents.router, dependencies=[Depends(require_service_or_firebase_token)])
@@ -74,6 +100,19 @@ 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(users.router) # auth: admin only
app.include_router(links.router) # auth is per-endpoint (generate: firebase, resolve: service key)
app.include_router(enrollment.router) # public; auth is the enrollment/pickup-secret tokens, checked inline
# public by necessity — an <audio src> can't send a bearer token, so the
# short-lived HMAC in the URL is the credential. Checked inline in media.py.
app.include_router(media.router)
# 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
# broker — see MQTT-PUBLIC-AUTH-PLAN.md. MQTT auth is now mosquitto's own
# built-in dynamic-security plugin (app/internal/dynsec.py talks to it over
# MQTT control topics, not HTTP), so there is nothing at /internal/mqtt/*
# anymore. Caddy's Caddyfile.j2 still 404s /internal/* on api.<domain> as
# defence in depth even though nothing calls it today — cheap insurance
# against a future /internal/* route being added and forgotten there.
@app.get("/health")
+8 -1
View File
@@ -16,6 +16,11 @@ class NodeRecord(BaseModel):
configured: bool = False
last_seen: Optional[datetime] = None
assigned_system_id: Optional[str] = None
node_type: str = "fixed" # fixed or portable
enforce_override_timeout: bool = True
is_overridden: bool = False
override_system_id: Optional[str] = None
override_timeout_at: Optional[datetime] = None
class CommandPayload(BaseModel):
@@ -57,7 +62,9 @@ class CallRecord(BaseModel):
srcaddr: Optional[str] = None
started_at: datetime
ended_at: Optional[datetime] = None
audio_url: Optional[str] = None
audio_gcs_uri: Optional[str] = None # canonical gs:// object location
audio_url: Optional[str] = None # NOT stored — minted per read, see internal/storage.py
duplicate_of: Optional[str] = None # another node recorded this same transmission first
transcript: Optional[str] = None # populated later by STT
incident_ids: List[str] = [] # one per scene detected in the recording
location: Optional[Dict[str, float]] = None # {lat, lng}
+1
View File
@@ -132,6 +132,7 @@ async def debug_correlation(
_call_summary(c) for c in recent_calls
if c.get("status") == "ended"
and not c.get("incident_ids") and not c.get("incident_id")
and not c.get("duplicate_of") # another node's copy — never meant to correlate
and c.get("system_id") in ai_systems
]
orphans.sort(key=lambda c: c.get("started_at", ""), reverse=True)
+7 -5
View File
@@ -4,6 +4,7 @@ from pydantic import BaseModel
from typing import Optional
from app.internal import firestore as fstore
from app.internal.auth import require_admin_token
from app.internal.storage import gcs_uri_for_call, with_playback_url
class TranscriptUpdate(BaseModel):
@@ -25,7 +26,9 @@ async def list_calls(
filters["status"] = status
if system_id:
filters["system_id"] = system_id
return await fstore.collection_list("calls", **filters)
calls = await fstore.collection_list("calls", **filters)
# audio_url is not stored — it's a short-lived signed link minted per read.
return [with_playback_url(c) for c in calls]
@router.get("/{call_id}")
@@ -33,7 +36,7 @@ async def get_call(call_id: str):
call = await fstore.doc_get("calls", call_id)
if not call:
raise HTTPException(404, f"Call '{call_id}' not found.")
return call
return with_playback_url(call)
@router.post("/{call_id}/reprocess")
@@ -43,10 +46,9 @@ async def reprocess_call(call_id: str, background_tasks: BackgroundTasks):
if not call:
raise HTTPException(404, f"Call '{call_id}' not found.")
from app.routers.upload import _run_intelligence_pipeline, _public_url_to_gcs_uri
from app.routers.upload import _run_intelligence_pipeline
audio_url = call.get("audio_url")
gcs_uri = _public_url_to_gcs_uri(audio_url) if audio_url else None
gcs_uri = gcs_uri_for_call(call)
background_tasks.add_task(
_run_intelligence_pipeline,
+184
View File
@@ -0,0 +1,184 @@
"""
Node self-enrollment — the public replacement for the old shared-MQTT-
password flow (see MQTT-PUBLIC-AUTH-PLAN.md "Enrollment flow").
1. POST /nodes/enroll (X-Enrollment-Token: <fleet-wide token>)
First-boot node upserts itself as `approval_status: pending` and gets
back a one-time pickup_secret. Only its hash is persisted.
2. GET /nodes/{id}/credentials (X-Pickup-Secret: <secret from step 1>)
Node polls this with backoff until an admin approves it in the
frontend (existing nodes.py approve_node() flow — unchanged, still
writes node_keys/{id}.api_key) and then reads its api_key back.
These two endpoints are meant to be public (unlike the dynsec control-plane
traffic in app/internal/dynsec.py, which never leaves the docker-internal
MQTT bridge) — that's the whole point of moving off WireGuard-per-node.
Auth is the token headers checked inline below, not the app-wide
Firebase/service-key dependency the rest of routers/nodes.py uses.
"""
import hashlib
import secrets
import time
from typing import Optional
from fastapi import APIRouter, HTTPException, Header, Request
from pydantic import BaseModel
from app.config import settings
from app.internal import firestore as fstore
from app.internal.logger import logger
router = APIRouter(prefix="/nodes", tags=["enrollment"])
# ---------------------------------------------------------------------------
# Per-source-IP token bucket for /nodes/enroll.
#
# c2-core has no rate-limiting dependency anywhere today (see
# MQTT-PUBLIC-AUTH-PLAN.md); this is a deliberately small (~30 line)
# in-memory limiter rather than a new library. Known limitations:
# - per-process: with more than one c2-core instance, each has its own
# bucket, so real throughput is (limit x instance count). Fine today —
# there is exactly one instance.
# - resets on every restart/redeploy — not persisted anywhere.
# Good enough to blunt casual guessing of node_ids against the fleet token;
# not a substitute for a real edge/WAF rate limiter if this endpoint is
# ever seriously targeted.
# ---------------------------------------------------------------------------
class _TokenBucket:
def __init__(self, capacity: int, refill_per_sec: float):
self.capacity = capacity
self.refill_per_sec = refill_per_sec
self._buckets: dict[str, tuple[float, float]] = {} # key -> (tokens, last_refill_ts)
def allow(self, key: str) -> bool:
now = time.monotonic()
tokens, last_ts = self._buckets.get(key, (float(self.capacity), now))
tokens = min(self.capacity, tokens + (now - last_ts) * self.refill_per_sec)
if tokens < 1:
self._buckets[key] = (tokens, now)
return False
self._buckets[key] = (tokens - 1, now)
return True
# Burst of 5, refilling 1/minute — enrollment is a first-boot, once-per-node
# event, so a legitimate node never needs more than a handful of attempts.
_enroll_limiter = _TokenBucket(capacity=5, refill_per_sec=1 / 60)
def _hash_secret(secret: str) -> str:
return hashlib.sha256(secret.encode()).hexdigest()
class EnrollRequest(BaseModel):
node_id: str
name: Optional[str] = None
lat: float = 0.0
lon: float = 0.0
class EnrollResponse(BaseModel):
node_id: str
pickup_secret: str
approval_status: str
@router.post("/enroll", response_model=EnrollResponse)
async def enroll_node(
body: EnrollRequest,
request: Request,
x_enrollment_token: Optional[str] = Header(None),
):
client_ip = request.client.host if request.client else "unknown"
if not _enroll_limiter.allow(client_ip):
raise HTTPException(429, "Too many enrollment attempts. Try again later.")
if not settings.enrollment_token:
raise HTTPException(503, "Enrollment is not configured on this server.")
if not x_enrollment_token or not secrets.compare_digest(x_enrollment_token, settings.enrollment_token):
logger.warning(f"Enroll 401: bad/missing enrollment token from {client_ip} for node_id={body.node_id!r}")
raise HTTPException(401, "Invalid or missing X-Enrollment-Token")
node_id = body.node_id.strip()
if not node_id:
raise HTTPException(400, "node_id is required")
existing = await fstore.doc_get("nodes", node_id)
# -------------------------------------------------------------------
# CRITICAL GUARD — do not remove or weaken this check.
#
# An already-approved node_id must NEVER get a fresh pickup_secret off
# the fleet-wide enrollment token alone. The fleet token is shared by
# every node (it ships in every node's .env / setup.sh prompt), so it
# is the credential most likely to leak. Without this guard, a leaked
# fleet token plus a guessable node_id (node-001, node-002, ...) would
# let an attacker "re-enroll" a live, already-approved node and race
# the real node to GET /nodes/{id}/credentials — stealing its actual
# api_key before the legitimate device ever asks.
#
# Recovery for an approved node goes through the existing admin-only
# POST /nodes/{id}/reissue-key instead (routers/nodes.py), which
# requires a Firebase admin token, not the fleet token.
# -------------------------------------------------------------------
if existing and existing.get("approval_status") == "approved":
logger.warning(
f"Enroll refused: node_id={node_id!r} is already approved — "
f"refusing to issue a new pickup_secret from the fleet token alone "
f"(source_ip={client_ip})"
)
raise HTTPException(
403,
"Node is already approved. This endpoint cannot re-issue credentials "
"for an approved node from the enrollment token alone — use admin "
"key reissue.",
)
pickup_secret = secrets.token_hex(24)
doc = {
"node_id": node_id,
"name": body.name or (existing or {}).get("name") or node_id,
"lat": body.lat or (existing or {}).get("lat", 0.0),
"lon": body.lon or (existing or {}).get("lon", 0.0),
"approval_status": (existing or {}).get("approval_status", "pending"),
"pickup_secret_hash": _hash_secret(pickup_secret),
}
# approval_status stays "pending" for a brand-new node; if it's an
# existing "pending" or "rejected" node re-enrolling (e.g. lost its
# pickup_secret before an admin ever approved it), leave whatever
# status it already has rather than silently flipping "rejected" back
# to "pending" — that decision belongs to an admin, not this endpoint.
await fstore.doc_set("nodes", node_id, doc, merge=True)
logger.info(f"Node enrolled: {node_id} (status={doc['approval_status']}, source_ip={client_ip})")
return EnrollResponse(node_id=node_id, pickup_secret=pickup_secret, approval_status=doc["approval_status"])
class CredentialsResponse(BaseModel):
approval_status: str
api_key: Optional[str] = None
@router.get("/{node_id}/credentials", response_model=CredentialsResponse)
async def get_node_credentials(node_id: str, x_pickup_secret: Optional[str] = Header(None)):
if not x_pickup_secret:
raise HTTPException(401, "Missing X-Pickup-Secret header")
node = await fstore.doc_get("nodes", node_id)
if not node or not node.get("pickup_secret_hash"):
raise HTTPException(404, "Unknown node, or node was never enrolled via POST /nodes/enroll")
if not secrets.compare_digest(_hash_secret(x_pickup_secret), node["pickup_secret_hash"]):
raise HTTPException(401, "Invalid pickup secret")
approval_status = node.get("approval_status", "pending")
if approval_status != "approved":
return CredentialsResponse(approval_status=approval_status)
key_doc = await fstore.doc_get("node_keys", node_id)
if not key_doc or not key_doc.get("api_key"):
# Approved but no key yet — shouldn't normally happen, approve_node()
# always writes node_keys in the same call that sets approved. Treat
# it as "keep polling" rather than erroring the node's retry loop.
return CredentialsResponse(approval_status=approval_status)
return CredentialsResponse(approval_status=approval_status, api_key=key_doc["api_key"])
+55
View File
@@ -0,0 +1,55 @@
"""
Call-audio playback.
Public router by necessity: a browser's <audio src="..."> cannot attach an
Authorization header, so the link itself carries the credential — a short-lived
HMAC over (call_id, expiry) minted by app/internal/storage.py. That is why this
router is included in main.py WITHOUT a router-level auth dependency; the check
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.
"""
from fastapi import APIRouter, HTTPException, Query, Response
from app.internal import firestore as fstore
from app.internal.storage import verify_audio_link, gcs_uri_for_call, download_audio
router = APIRouter(prefix="/media", tags=["media"])
@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."),
):
# Verify before touching Firestore so an invalid link costs nothing.
if not verify_audio_link(call_id, exp, sig):
raise HTTPException(403, "Invalid or expired audio link")
call = await fstore.doc_get("calls", call_id)
if not call:
raise HTTPException(404, f"Call '{call_id}' not found.")
gcs_uri = gcs_uri_for_call(call)
if not gcs_uri:
raise HTTPException(404, "No audio for this call.")
data = await download_audio(gcs_uri)
if not data:
raise HTTPException(404, "Audio object missing from storage.")
return Response(
content=data,
media_type="audio/mpeg",
headers={
"Content-Length": str(len(data)),
# Recordings are small (16 kbps mono — a 30s call is ~60 KB), so the
# whole body is sent at once and the browser seeks within its own
# buffer. Range support would only matter for long files.
"Accept-Ranges": "none",
# Immutable content, but the URL expires — cache privately only.
"Cache-Control": "private, max-age=3600",
},
)
+124 -4
View File
@@ -1,9 +1,12 @@
import secrets
from typing import Optional
from fastapi import APIRouter, HTTPException, Depends, Query
from pydantic import BaseModel
from app.models import CommandPayload
from app.internal import firestore as fstore
from app.internal.mqtt_handler import mqtt_handler
from app.internal import dynsec
from app.internal.logger import logger
from app.internal.auth import require_admin_token, require_service_key_or_admin
from app.routers.tokens import assign_token, release_token
@@ -30,8 +33,23 @@ async def approve_node(node_id: str, _: dict = Depends(require_admin_token)):
raise HTTPException(404, f"Node '{node_id}' not found.")
api_key = secrets.token_hex(32)
# dynsec FIRST, Firestore second: if the broker rejects/never confirms
# the new client, we must not tell Firestore (and the admin UI) the
# node is approved with a key mosquitto doesn't actually recognise —
# that's exactly the silent-drift the two-sources-of-truth problem
# warns about. See app/internal/dynsec.py.
try:
await dynsec.upsert_node_client(node_id, api_key)
except dynsec.DynsecError as e:
logger.error(f"Approve {node_id!r}: dynsec upsert failed, NOT writing Firestore: {e}")
raise HTTPException(502, f"Could not provision MQTT credentials for node: {e}")
await fstore.doc_set("node_keys", node_id, {"node_id": node_id, "api_key": api_key}, merge=False)
await fstore.doc_update("nodes", node_id, {"approval_status": "approved"})
# TODO(mqtt-cutover): drop this MQTT push once nodes pull their key via
# GET /nodes/{id}/credentials (routers/enrollment.py) exclusively — see
# MQTT-PUBLIC-AUTH-PLAN.md "Rollout order" step 6. Kept for node-26.
mqtt_handler.publish_node_key(node_id, api_key)
return {"ok": True}
@@ -41,6 +59,11 @@ async def delete_node(node_id: str, _: dict = Depends(require_admin_token)):
node = await fstore.doc_get("nodes", node_id)
if not node:
raise HTTPException(404, f"Node '{node_id}' not found.")
try:
await dynsec.delete_node_client(node_id)
except dynsec.DynsecError as e:
logger.error(f"Delete {node_id!r}: dynsec deleteClient failed, NOT deleting Firestore docs: {e}")
raise HTTPException(502, f"Could not revoke MQTT credentials for node: {e}")
await fstore.doc_delete("node_keys", node_id)
await fstore.doc_delete("nodes", node_id)
@@ -101,7 +124,15 @@ async def reissue_node_key(node_id: str, _: dict = Depends(require_admin_token))
if not node:
raise HTTPException(404, f"Node '{node_id}' not found.")
api_key = secrets.token_hex(32)
try:
await dynsec.upsert_node_client(node_id, api_key)
except dynsec.DynsecError as e:
logger.error(f"Reissue {node_id!r}: dynsec upsert failed, NOT writing Firestore: {e}")
raise HTTPException(502, f"Could not update MQTT credentials for node: {e}")
await fstore.doc_set("node_keys", node_id, {"node_id": node_id, "api_key": api_key}, merge=False)
# TODO(mqtt-cutover): drop this MQTT push once nodes pull their key via
# GET /nodes/{id}/credentials (routers/enrollment.py) exclusively — see
# MQTT-PUBLIC-AUTH-PLAN.md "Rollout order" step 6. Kept for node-26.
mqtt_handler.publish_node_key(node_id, api_key)
return {"ok": True}
@@ -126,10 +157,13 @@ async def assign_system(
if not system:
raise HTTPException(404, f"System '{system_id}' not found.")
# Include hardware preset in the push so the edge node applies it when
# generating the OP25 config. Strip it from the system doc first so it
# doesn't collide with SystemConfig field validation on the node side.
push_payload = {**system, "hardware_preset": hardware_preset}
# Include hardware preset, node type, and enforce timeout in the push
push_payload = {
**system,
"hardware_preset": hardware_preset,
"node_type": node.get("node_type", "fixed"),
"enforce_override_timeout": node.get("enforce_override_timeout", True),
}
if ppm_override is not None:
push_payload["ppm_override"] = ppm_override
mqtt_handler.push_config(node_id, push_payload)
@@ -145,3 +179,89 @@ async def assign_system(
await fstore.doc_update("nodes", node_id, node_updates)
return {"ok": True}
class NodeUpdateBody(BaseModel):
node_type: Optional[str] = None
enforce_override_timeout: Optional[bool] = None
@router.patch("/{node_id}")
async def update_node(
node_id: str,
body: NodeUpdateBody,
_: dict = Depends(require_admin_token),
):
node = await fstore.doc_get("nodes", node_id)
if not node:
raise HTTPException(404, f"Node '{node_id}' not found.")
updates = body.model_dump(exclude_unset=True)
if not updates:
return {"ok": True}
await fstore.doc_update("nodes", node_id, updates)
# Re-push config to apply new node settings locally
updated_node = await fstore.doc_get("nodes", node_id)
assigned_system_id = updated_node.get("assigned_system_id")
if assigned_system_id:
system = await fstore.doc_get("systems", assigned_system_id)
if system:
push_payload = {
**system,
"hardware_preset": updated_node.get("hardware_preset", "rtl-sdr-v3"),
"node_type": updated_node.get("node_type", "fixed"),
"enforce_override_timeout": updated_node.get("enforce_override_timeout", True),
}
if updated_node.get("ppm_override") is not None:
push_payload["ppm_override"] = updated_node["ppm_override"]
mqtt_handler.push_config(node_id, push_payload)
return {"ok": True}
class AckOverrideBody(BaseModel):
timeout_minutes: int = 1440
@router.post("/{node_id}/override/ack")
async def ack_override(
node_id: str,
body: AckOverrideBody,
_: dict = Depends(require_service_key_or_admin),
):
node = await fstore.doc_get("nodes", node_id)
if not node:
raise HTTPException(404, f"Node '{node_id}' not found.")
from datetime import datetime, timezone, timedelta
new_timeout = datetime.now(timezone.utc) + timedelta(minutes=body.timeout_minutes)
await fstore.doc_update("nodes", node_id, {
"override_timeout_at": new_timeout.isoformat()
})
return {"ok": True, "override_timeout_at": new_timeout.isoformat()}
@router.post("/{node_id}/override/reset")
async def reset_override(
node_id: str,
_: dict = Depends(require_service_key_or_admin),
):
node = await fstore.doc_get("nodes", node_id)
if not node:
raise HTTPException(404, f"Node '{node_id}' not found.")
assigned_system_id = node.get("assigned_system_id")
if assigned_system_id:
system = await fstore.doc_get("systems", assigned_system_id)
if system:
mqtt_handler.push_config(node_id, system)
await fstore.doc_update("nodes", node_id, {
"is_overridden": False,
"override_system_id": None,
"override_timeout_at": None,
})
return {"ok": True}
+20 -21
View File
@@ -2,6 +2,7 @@ from typing import Optional
from fastapi import APIRouter, BackgroundTasks, UploadFile, File, Form, HTTPException, Security
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from app.internal.storage import upload_audio
from app.internal import dedup
from app.internal import firestore as fstore
from app.internal.logger import logger
from app.config import settings
@@ -47,16 +48,28 @@ async def upload_call_audio(
if len(data) > settings.upload_max_bytes:
raise HTTPException(413, f"File too large (max {settings.upload_max_bytes // (1024*1024)} MB).")
audio_url = await upload_audio(data, file.filename or "", call_id=call_id)
gcs_uri = await upload_audio(data, file.filename or "", call_id=call_id)
if audio_url:
if gcs_uri:
try:
await fstore.doc_set("calls", call_id, {"audio_url": audio_url})
# Canonical object location only. The playback link is minted per
# read in storage.playback_url() — nothing durable is stored here.
await fstore.doc_set("calls", call_id, {"audio_gcs_uri": gcs_uri})
except Exception as e:
logger.warning(f"Could not update call {call_id} with audio_url: {e}")
logger.warning(f"Could not update call {call_id} with audio_gcs_uri: {e}")
# Convert public GCS URL to gs:// URI for Speech-to-Text
gcs_uri = _public_url_to_gcs_uri(audio_url)
# Another node in range recorded the same transmission. Keep the audio
# (it may be the cleaner capture) but don't transcribe or correlate it
# a second time — see app/internal/dedup.py.
call_doc = await fstore.doc_get("calls", call_id)
duplicate_of = await dedup.find_duplicate_of(call_doc) if call_doc else None
if duplicate_of:
await fstore.doc_set("calls", call_id, {"duplicate_of": duplicate_of})
logger.info(
f"Call {call_id} from {node_id} duplicates {duplicate_of} "
f"— audio kept, AI pipeline skipped."
)
return {"url": gcs_uri, "duplicate_of": duplicate_of}
background_tasks.add_task(
_run_intelligence_pipeline,
@@ -68,21 +81,7 @@ async def upload_call_audio(
gcs_uri=gcs_uri,
)
return {"url": audio_url}
def _public_url_to_gcs_uri(url: str) -> Optional[str]:
"""
Convert a public GCS URL (possibly signed) like
https://storage.googleapis.com/bucket/calls/file.mp3?Expires=...
to a gs:// URI usable by Speech-to-Text.
Returns None if the URL doesn't look like a GCS URL.
"""
prefix = "https://storage.googleapis.com/"
if url and url.startswith(prefix):
path = url[len(prefix):].split("?")[0] # strip signed-URL query params
return "gs://" + path
return None
return {"url": gcs_uri}
async def _correlate_with_consensus(
-19
View File
@@ -1,19 +0,0 @@
# -----------------------------------------------------------------------
# Mosquitto ACL — DRB C2 Server
# -----------------------------------------------------------------------
# Two principals:
# drb-c2-core — the backend service; needs full broker access
# drb-node — shared credential for all edge nodes; scoped to their
# own namespace via MQTT client ID (%c = NODE_ID)
# -----------------------------------------------------------------------
# C2-core service — full read/write on every topic
user drb-c2-core
topic readwrite #
# Edge nodes — each node may only read/write topics under nodes/<its-own-ID>/
# Mosquitto substitutes %c with the connecting client's MQTT client ID at
# runtime. Edge nodes set client_id = NODE_ID in mqtt_manager.py, so this
# cryptographically prevents node-A from publishing to nodes/node-B/api_key
# or any other node's namespace.
pattern readwrite nodes/%c/#
-37
View File
@@ -1,37 +0,0 @@
#!/bin/sh
# Mosquitto entrypoint — generates /mosquitto/config/passwd from env vars
# before handing off to the broker process.
#
# Required environment variables (set in docker-compose.yml):
# MQTT_C2_USER — username for the drb-c2-core service
# MQTT_C2_PASS — password for the drb-c2-core service
# MQTT_NODE_USER — shared username for all edge nodes
# MQTT_NODE_PASS — shared password for all edge nodes
set -e
PASSWD_FILE=/tmp/passwd
# Remove any stale file so we start clean on every container start
rm -f "$PASSWD_FILE"
if [ -z "$MQTT_C2_USER" ] || [ -z "$MQTT_C2_PASS" ]; then
echo "ERROR: MQTT_C2_USER and MQTT_C2_PASS must be set" >&2
exit 1
fi
if [ -z "$MQTT_NODE_USER" ] || [ -z "$MQTT_NODE_PASS" ]; then
echo "ERROR: MQTT_NODE_USER and MQTT_NODE_PASS must be set" >&2
exit 1
fi
# -c creates/overwrites the file; subsequent calls append without -c
mosquitto_passwd -c -b "$PASSWD_FILE" "$MQTT_C2_USER" "$MQTT_C2_PASS"
mosquitto_passwd -b "$PASSWD_FILE" "$MQTT_NODE_USER" "$MQTT_NODE_PASS"
# mosquitto_passwd creates the file 0600 (root-only); mosquitto drops to
# the mosquitto user before reading it, so make it world-readable.
chmod 644 "$PASSWD_FILE"
echo "Mosquitto: password file written for users: $MQTT_C2_USER, $MQTT_NODE_USER"
exec /usr/sbin/mosquitto -c /mosquitto/config/mosquitto.conf
+37 -5
View File
@@ -1,11 +1,43 @@
listener 1883
# Auth: mosquitto's own built-in dynamic-security plugin — NOT
# mosquitto-go-auth (that project is archived upstream, no CVE patches;
# rejected for an internet-facing broker). This plugin ships in and is
# maintained alongside the official eclipse-mosquitto image itself.
# See MQTT-PUBLIC-AUTH-PLAN.md and app/internal/dynsec.py for the full
# design (bootstrap, roles, the two-sources-of-truth reconcile).
#
# Plugin path is DERIVED FROM SOURCE (docker/2.1-alpine/Dockerfile in
# eclipse-mosquitto/mosquitto), not observed by running the image —
# nothing in this project executes/pulls images from this machine. Verify
# it on first real deploy: `docker compose logs mosquitto` will say
# "Error: Unable to load plugin" at the exact path below if it's wrong for
# whatever patch tag ends up pinned.
plugin /usr/lib/mosquitto_dynamic_security.so
# Lives on the same persistent volume as `persistence_location` below —
# one durable volume for all broker state, survives redeploys.
plugin_opt_config_file /mosquitto/data/dynamic-security.json
allow_anonymous false
# No password_file/acl_file directive anywhere in this file — the plugin
# above is the only registered auth backend. There is no "coexist" mode:
# nothing else is registered to conflict with it.
# Credentials and ACLs are generated/mounted at container startup
password_file /tmp/passwd
acl_file /mosquitto/config/acl.conf
# Internal, plaintext — c2-core's own connection only (its dynsec-admin
# control-plane calls AND its regular data-plane pub/sub both use this).
# Never published to the host in prod (docker-compose.prod.yml removes the
# port mapping); external nodes use the TLS listener below instead.
listener 1883
# Public, TLS — edge nodes connect here as username=node_id, password=api_key
# (the same credential /upload already trusts via node_keys), authorized by
# the "node" dynsec role (nodes/%u/# — %u is the dynsec-authenticated
# username, fixing the old %c-based ACL's client-ID-spoofing hole). Cert/key
# come from infra/ansible's Caddy cert-sync unit; see
# MQTT-PUBLIC-AUTH-PLAN.md "Infra" and the "Rollout order" cert-verification
# step for what happens before that cert exists.
listener 8883
certfile /mosquitto/certs/mqtt.crt
keyfile /mosquitto/certs/mqtt.key
# Persist retained messages (e.g. api_key, node status) across broker restarts
persistence true
persistence_location /mosquitto/data/
+60 -1
View File
@@ -1,2 +1,61 @@
# All C2 core settings have defaults — no env setup needed.
# Add any shared fixtures here if required in the future.
#
# firebase-admin and google-cloud-firestore are runtime-only dependencies: they
# are installed in the container but not in the local dev venv, and
# app/internal/firestore.py calls _init_firebase() at import time. Without the
# stubs below, importing ANY module that reaches Firestore fails at collection
# time, which is why test_mqtt_handler and test_node_sweeper could not be run
# outside the container.
#
# The stubs are installed only when the real packages are absent, so the
# container's real SDK is never shadowed.
import sys
from types import ModuleType
from unittest.mock import MagicMock
try: # pragma: no cover - exercised only by which packages are installed
import firebase_admin # noqa: F401
except ModuleNotFoundError:
_firebase = ModuleType("firebase_admin")
# Falsy so _init_firebase() takes the initialize_app() branch rather than the
# already-initialised branch, which is itself broken (see DEFERRED.md).
_firebase._apps = {}
_firebase.initialize_app = MagicMock()
_firebase.credentials = MagicMock()
_firebase.firestore = MagicMock()
_credentials = ModuleType("firebase_admin.credentials")
_credentials.Certificate = MagicMock()
_credentials.ApplicationDefault = MagicMock()
_fs = ModuleType("firebase_admin.firestore")
_fs.client = MagicMock()
# A distinct sentinel rather than a MagicMock: production code writes this
# into dicts that tests compare against, and a MagicMock compares unequal
# to itself across attribute accesses.
_fs.SERVER_TIMESTAMP = "__SERVER_TIMESTAMP__"
_auth = ModuleType("firebase_admin.auth")
_auth.verify_id_token = MagicMock()
_auth.set_custom_user_claims = MagicMock()
_auth.get_user_by_email = MagicMock()
_auth.get_user = MagicMock()
_firebase.auth = _auth
_firebase.credentials = _credentials
_firebase.firestore = _fs
sys.modules["firebase_admin"] = _firebase
sys.modules["firebase_admin.credentials"] = _credentials
sys.modules["firebase_admin.firestore"] = _fs
sys.modules["firebase_admin.auth"] = _auth
try: # pragma: no cover
from google.cloud.firestore_v1.base_query import FieldFilter # noqa: F401
except ModuleNotFoundError:
for _name in (
"google", "google.cloud", "google.cloud.firestore_v1",
"google.cloud.firestore_v1.base_query",
):
sys.modules.setdefault(_name, ModuleType(_name))
sys.modules["google.cloud.firestore_v1.base_query"].FieldFilter = MagicMock()
+172
View File
@@ -0,0 +1,172 @@
"""
Unit tests for the incident-creation gate and the thin-call activity rule.
Both behaviours come from the 2026-08-16 correlation dump, where TG 9048
produced one 28-call / 49-minute incident alongside 32 permanent orphans:
* Requiring a concrete incident_type to create an incident meant a channel
whose traffic never classifies could never open a second incident, so every
later call funnelled into whichever incident existed first.
* Thin ("10-4") calls refreshed updated_at, which kept that incident
permanently inside the fast-path recency gate.
_run_decision is pure — it reads only the context dict — so these cases need no
Firestore. _update_incident writes, so its test patches fstore.
"""
import pytest
from datetime import datetime, timedelta, timezone
from unittest.mock import AsyncMock, patch
from app.internal.incident_correlator import _run_decision, _update_incident
NOW = datetime(2026, 8, 16, 21, 0, 0, tzinfo=timezone.utc)
def _ctx(**overrides) -> dict:
"""Context with no active incidents, so the decision reaches the creation gate."""
base = {
"call_id": "call-1",
"all_active": [],
"recent": [],
"call_doc": {},
"call_embedding": None,
"call_units": [],
"call_vehicles": [],
"call_cleared": [],
"call_severity": "routine",
"coords": None,
"is_thin_call": True,
"now": NOW,
"system_id": "sys-1",
"talkgroup_id": 9048,
"talkgroup_name": "MTA PD Districts 6/7/11 - Police Dispatch",
"tags": [],
"incident_type": None,
"location": None,
"location_coords": None,
"reassignment": False,
"create_if_new": True,
}
base.update(overrides)
return base
# ---------------------------------------------------------------------------
# Creation gate — severity decides incident-worthiness, not incident_type
# ---------------------------------------------------------------------------
def test_routine_status_traffic_stays_orphaned():
"""A content-free acknowledgement must not open an incident of its own."""
assert _run_decision(_ctx())["action"] == "orphan"
@pytest.mark.parametrize("severity", ["minor", "moderate", "major"])
def test_any_real_severity_opens_an_untyped_incident(severity):
decision = _run_decision(_ctx(call_severity=severity))
assert decision["action"] == "new"
assert decision["incident_type"] == "other"
@pytest.mark.parametrize("field,value", [
("call_vehicles", ["RMP 22146"]),
("coords", {"lat": 41.0, "lng": -73.8}),
("tags", ["prisoner-transport"]),
])
def test_concrete_content_opens_an_untyped_incident(field, value):
"""Routine severity is overridden by anything the extractor actually found."""
decision = _run_decision(_ctx(**{field: value}))
assert decision["action"] == "new"
assert decision["incident_type"] == "other"
@pytest.mark.parametrize("field,value", [
("call_units", ["11-Victor"]),
("location", "Holland Station"),
])
def test_ambient_radio_fields_are_not_substance(field, value):
"""
A unit ID and a place name appear in nearly every transmission, so treating
them as substance made the severity check dead code: "11-Victor, 72 at
Holland Station" opened its own incident, and 37 of 50 incidents were single
routine calls left permanently active.
"""
assert _run_decision(_ctx(**{field: value}))["action"] == "orphan"
def test_units_and_location_together_still_orphan():
decision = _run_decision(_ctx(call_units=["11-Victor"], location="Holland Station"))
assert decision["action"] == "orphan"
def test_units_with_real_severity_still_open_an_incident():
"""Severity is the gate — ambient fields don't block it, they just can't open it alone."""
decision = _run_decision(_ctx(call_units=["11-Victor"], call_severity="moderate"))
assert decision["action"] == "new"
assert decision["incident_type"] == "other"
def test_explicit_type_is_never_downgraded_to_other():
decision = _run_decision(_ctx(incident_type="police", call_severity="moderate"))
assert decision["action"] == "new"
assert decision["incident_type"] == "police"
def test_other_survives_extraction_and_creates_an_incident():
""""other" is a real classification now, not a synonym for unclassifiable."""
decision = _run_decision(_ctx(incident_type="other"))
assert decision["action"] == "new"
assert decision["incident_type"] == "other"
def test_sweep_never_creates_incidents():
"""The re-correlation sweep passes create_if_new=False — it may only link."""
decision = _run_decision(_ctx(call_severity="major", create_if_new=False))
assert decision["action"] == "orphan"
# ---------------------------------------------------------------------------
# Thin calls attach for context but do not count as incident activity
# ---------------------------------------------------------------------------
def _incident(idle_minutes: float) -> dict:
updated = NOW - timedelta(minutes=idle_minutes)
return {
"incident_id": "inc-1",
"system_ids": ["sys-1"],
"talkgroup_ids": ["9048"],
"updated_at": updated.isoformat(),
"started_at": updated.isoformat(),
"status": "active",
}
def test_thin_call_links_to_the_active_incident_on_its_talkgroup():
inc = _incident(0.2)
decision = _run_decision(_ctx(all_active=[inc], recent=[inc]))
assert decision["action"] == "link"
assert decision["corr_debug"]["corr_path"] == "fast/thin"
@pytest.mark.asyncio
async def test_thin_link_does_not_refresh_updated_at():
with patch("app.internal.incident_correlator.fstore") as mock_fstore:
mock_fstore.doc_set = AsyncMock()
await _update_incident(
_incident(5), "call-1", 9048, "sys-1", [], None, None, [], [], None, NOW,
refresh_activity=False,
)
updates = mock_fstore.doc_set.await_args.args[2]
assert "updated_at" not in updates, "a '10-4' must not reset the incident idle clock"
assert updates["last_thin_at"] == NOW.isoformat()
assert updates["summary_stale"] is True, "the call still belongs in the summary"
@pytest.mark.asyncio
async def test_substantive_link_does_refresh_updated_at():
with patch("app.internal.incident_correlator.fstore") as mock_fstore:
mock_fstore.doc_set = AsyncMock()
await _update_incident(
_incident(5), "call-1", 9048, "sys-1", [], None, None, ["6 Adam"], [], None, NOW,
)
updates = mock_fstore.doc_set.await_args.args[2]
assert updates["updated_at"] == NOW.isoformat()
assert "last_thin_at" not in updates
+158
View File
@@ -0,0 +1,158 @@
"""
Unit tests for cross-node duplicate detection.
Fixture timings come from real production data: node-002 and node-PI-2 both
recorded TG 9048 on 2026-08-16, starting ~1.1s apart.
"""
import pytest
from datetime import datetime, timezone, timedelta
from app.internal.dedup import _parse_dt, _is_canonical, find_duplicate_of
BASE = datetime(2026, 8, 16, 19, 31, 46, tzinfo=timezone.utc)
def _query_returning(*calls):
"""Stand-in for fstore.collection_where."""
async def _q(_collection, _conditions):
return list(calls)
return _q
def _query_raising(exc):
async def _q(_collection, _conditions):
raise exc
return _q
def _call(call_id, node_id, offset_seconds=0.0, talkgroup_id=9048, **extra):
return {
"call_id": call_id,
"node_id": node_id,
"system_id": "sys-1",
"talkgroup_id": talkgroup_id,
"started_at": BASE + timedelta(seconds=offset_seconds),
**extra,
}
# ---------------------------------------------------------------------------
# Timestamp parsing — Firestore returns three different shapes
# ---------------------------------------------------------------------------
def test_parse_dt_accepts_aware_datetime():
assert _parse_dt(BASE) == BASE
def test_parse_dt_assumes_utc_for_naive_datetime():
naive = datetime(2026, 8, 16, 19, 31, 46)
assert _parse_dt(naive) == BASE
def test_parse_dt_accepts_iso_string_with_z():
assert _parse_dt("2026-08-16T19:31:46Z") == BASE
def test_parse_dt_returns_none_for_junk():
assert _parse_dt("not a date") is None
assert _parse_dt(None) is None
# ---------------------------------------------------------------------------
# Canonical selection
# ---------------------------------------------------------------------------
def test_earlier_start_wins():
early = _call("a", "node-002", 0.0)
late = _call("b", "node-PI-2", 1.1)
assert _is_canonical(early, [late]) is True
assert _is_canonical(late, [early]) is False
def test_identical_starts_break_tie_on_call_id():
first = _call("aaa", "node-002", 0.0)
second = _call("bbb", "node-PI-2", 0.0)
assert _is_canonical(first, [second]) is True
assert _is_canonical(second, [first]) is False
def test_both_nodes_reach_the_same_verdict():
"""The whole point: the decision must not depend on upload order."""
a = _call("a", "node-002", 0.0)
b = _call("b", "node-PI-2", 1.1)
verdicts = [_is_canonical(a, [b]), _is_canonical(b, [a])]
assert verdicts.count(True) == 1, "exactly one recording must be canonical"
# ---------------------------------------------------------------------------
# find_duplicate_of
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_returns_canonical_id_for_later_recording():
canonical = _call("canon", "node-002", 0.0)
later = _call("later", "node-PI-2", 1.1)
q = _query_returning(canonical, later)
assert await find_duplicate_of(later, query=q) == "canon"
@pytest.mark.asyncio
async def test_returns_none_for_the_canonical_recording():
canonical = _call("canon", "node-002", 0.0)
later = _call("later", "node-PI-2", 1.1)
q = _query_returning(canonical, later)
assert await find_duplicate_of(canonical, query=q) is None
@pytest.mark.asyncio
async def test_same_node_is_never_a_duplicate():
"""Back-to-back transmissions from one node are real, separate calls."""
first = _call("a", "node-002", 0.0)
second = _call("b", "node-002", 2.0)
q = _query_returning(first, second)
assert await find_duplicate_of(second, query=q) is None
@pytest.mark.asyncio
async def test_different_talkgroup_is_not_a_duplicate():
other_tg = _call("a", "node-002", 0.0, talkgroup_id=9600)
mine = _call("b", "node-PI-2", 1.0, talkgroup_id=9048)
q = _query_returning(other_tg, mine)
assert await find_duplicate_of(mine, query=q) is None
@pytest.mark.asyncio
async def test_never_chains_onto_another_duplicate():
"""A third node must point at the original, not at a duplicate of it."""
canonical = _call("canon", "node-002", 0.0)
already_dupe = _call("dupe", "node-PI-2", 0.5, duplicate_of="canon")
third = _call("third", "node-003", 1.0)
q = _query_returning(canonical, already_dupe, third)
assert await find_duplicate_of(third, query=q) == "canon"
@pytest.mark.asyncio
async def test_no_match_returns_none():
lonely = _call("only", "node-002", 0.0)
q = _query_returning(lonely)
assert await find_duplicate_of(lonely, query=q) is None
@pytest.mark.asyncio
async def test_missing_identifiers_skip_the_check():
called = False
async def _q(_collection, _conditions):
nonlocal called
called = True
return []
incomplete = {"call_id": "x", "node_id": "node-002", "started_at": BASE}
assert await find_duplicate_of(incomplete, query=_q) is None
assert called is False, "must bail out before querying"
@pytest.mark.asyncio
async def test_query_failure_never_blocks_the_upload():
call = _call("a", "node-002", 0.0)
q = _query_raising(RuntimeError("firestore down"))
assert await find_duplicate_of(call, query=q) is None
+11
View File
@@ -4,6 +4,17 @@ WORKDIR /app
COPY package.json ./
RUN npm install
COPY . .
# Build-time public vars — baked into the Next.js bundle by the CI workflow
ARG NEXT_PUBLIC_C2_URL
ARG NEXT_PUBLIC_FIREBASE_API_KEY
ARG NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN
ARG NEXT_PUBLIC_FIREBASE_PROJECT_ID
ARG NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET
ARG NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID
ARG NEXT_PUBLIC_FIREBASE_APP_ID
ARG NEXT_PUBLIC_FIRESTORE_DATABASE
RUN npm run build
FROM node:20-slim AS runner
+77 -27
View File
@@ -1,21 +1,52 @@
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useNodes, useUnconfiguredNodes } from "@/lib/useNodes";
import { useCalls, useActiveCalls } from "@/lib/useCalls";
import { useSystems } from "@/lib/useSystems";
import { useActiveIncidents } from "@/lib/useIncidents";
import { NodeCard } from "@/components/NodeCard";
import { CallRow } from "@/components/CallRow";
import { NodeConfigModal } from "@/components/NodeConfigModal";
import { TypeBadge } from "@/components/IncidentBadges";
import { severityBadge, severityRank } from "@/lib/severity";
import { useState } from "react";
import type { NodeRecord } from "@/lib/types";
import type { NodeRecord, IncidentRecord } from "@/lib/types";
import { useAuth } from "@/components/AuthProvider";
import { PageHeader } from "@/components/ui/PageHeader";
import { Card } from "@/components/ui/Card";
import { Badge } from "@/components/ui/Badge";
import { Button } from "@/components/ui/Button";
import { EmptyState, ErrorBanner } from "@/components/ui/EmptyState";
function StatCard({ label, value, accent }: { label: string; value: string | number; accent?: string }) {
return (
<div className="bg-gray-900 border border-gray-800 rounded-lg p-4">
<Card>
<p className="text-xs text-gray-500 uppercase tracking-wider mb-1">{label}</p>
<p className={`text-3xl font-bold font-mono ${accent ?? "text-white"}`}>{value}</p>
</Card>
);
}
function fmtTime(iso: string) {
try { return new Date(iso).toLocaleString([], { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }); }
catch { return iso; }
}
function IncidentSummaryCard({ incident }: { incident: IncidentRecord }) {
const router = useRouter();
return (
<Card hover className="cursor-pointer" onClick={() => router.push(`/incidents/${incident.incident_id}`)}>
<div className="flex items-center gap-2 mb-2 flex-wrap">
<TypeBadge type={incident.type} />
{severityBadge(incident.severity)}
</div>
<p className="text-white text-sm font-semibold leading-snug line-clamp-2">{incident.title ?? "Untitled incident"}</p>
<p className="text-gray-500 text-xs font-mono mt-2">
{fmtTime(incident.started_at)} · {incident.call_ids.length} call{incident.call_ids.length !== 1 ? "s" : ""}
</p>
</Card>
);
}
@@ -25,6 +56,7 @@ export default function DashboardPage() {
const { calls, error: callsError } = useCalls(20);
const activeCalls = useActiveCalls();
const { systems, error: systemsError } = useSystems();
const activeIncidents = useActiveIncidents();
const [configNode, setConfigNode] = useState<NodeRecord | null>(null);
const { isAdmin } = useAuth();
@@ -33,44 +65,66 @@ export default function DashboardPage() {
const fsError = nodesError ?? callsError ?? systemsError;
return (
<div className="space-y-6">
<h1 className="text-xl font-bold text-white font-mono">Dashboard</h1>
// Worst-first: the incident that most needs a human's attention leads the panel.
const sortedIncidents = [...activeIncidents].sort(
(a, b) => severityRank(b.severity) - severityRank(a.severity) || b.started_at.localeCompare(a.started_at)
);
const notableIncidentCount = activeIncidents.filter((i) => severityRank(i.severity) >= 2).length;
{fsError && (
<div className="bg-red-950 border border-red-800 rounded-lg p-4">
<p className="text-red-400 text-sm font-mono">Firestore error: {fsError}</p>
</div>
)}
return (
<div className="space-y-8">
<PageHeader
title="Dashboard"
badge={notableIncidentCount > 0 && <Badge tone="danger">{notableIncidentCount} moderate+ active</Badge>}
/>
{fsError && <ErrorBanner message={`Firestore error: ${fsError}`} />}
{/* Pending config banner */}
{pending.length > 0 && (
<div className="bg-indigo-950 border border-indigo-800 rounded-lg p-4 flex items-center justify-between">
<div className="bg-indigo-600/10 border border-indigo-600/40 rounded-lg p-4 flex items-center justify-between gap-3 flex-wrap">
<p className="text-indigo-300 text-sm font-mono">
{pending.length} new node{pending.length > 1 ? "s" : ""} connected and need{pending.length === 1 ? "s" : ""} configuration.
</p>
<button
onClick={() => setConfigNode(pending[0])}
className="text-xs bg-indigo-700 hover:bg-indigo-600 text-white px-3 py-1.5 rounded-lg transition-colors"
>
Configure now
</button>
<Button size="sm" onClick={() => setConfigNode(pending[0])}>Configure now</Button>
</div>
)}
{/* Stats */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<StatCard label="Active Incidents" value={activeIncidents.length} accent={activeIncidents.length > 0 ? "text-orange-400" : undefined} />
<StatCard label="Nodes Online" value={onlineCount} accent="text-green-400" />
<StatCard label="Active Calls" value={activeCalls.length} accent={activeCalls.length > 0 ? "text-orange-400" : undefined} />
<StatCard label="Total Nodes" value={nodes.length} />
<StatCard label="Systems" value={systems.length} />
</div>
{/* Active incidents — the primary "what's happening" view */}
<section>
<div className="flex items-center justify-between mb-3">
<h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wider">Active Incidents</h2>
<Link href="/incidents" className="text-xs text-indigo-400 hover:text-indigo-300 font-mono transition-colors">
View all →
</Link>
</div>
{sortedIncidents.length === 0 ? (
<EmptyState
title="No active incidents"
description="Incidents appear here automatically as calls correlate into events."
/>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{sortedIncidents.slice(0, 6).map((inc) => (
<IncidentSummaryCard key={inc.incident_id} incident={inc} />
))}
</div>
)}
</section>
{/* Nodes */}
<section>
<h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-3">Nodes</h2>
{nodes.length === 0 ? (
<p className="text-gray-600 text-sm font-mono">No nodes registered yet.</p>
<EmptyState title="No nodes registered yet" description="Deploy a field SDR node and it will show up here automatically." />
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{nodes.map((n) => (
@@ -84,9 +138,9 @@ export default function DashboardPage() {
<section>
<h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-3">Recent Calls</h2>
{calls.length === 0 ? (
<p className="text-gray-600 text-sm font-mono">No calls recorded yet.</p>
<EmptyState title="No calls recorded yet" />
) : (
<div className="bg-gray-900 border border-gray-800 rounded-xl overflow-hidden">
<Card padding="none" className="overflow-hidden overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-xs text-gray-500 uppercase tracking-wider border-b border-gray-800">
@@ -104,16 +158,12 @@ export default function DashboardPage() {
))}
</tbody>
</table>
</div>
</Card>
)}
</section>
{configNode && (
<NodeConfigModal
node={configNode}
systems={systems}
onClose={() => setConfigNode(null)}
/>
<NodeConfigModal node={configNode} systems={systems} onClose={() => setConfigNode(null)} />
)}
</div>
);
+91
View File
@@ -0,0 +1,91 @@
"use client";
import { useState } from "react";
import { Badge } from "@/components/ui/Badge";
import { LinkButton } from "@/components/ui/Button";
const FAQS: { q: string; a: string }[] = [
{
q: "What hardware do I need to run a node?",
a: "A node is a small field SDR device running our edge-node software — it needs an SDR dongle capable of receiving your local P25 or analog trunked system, and a network connection to reach your DRB account. Full setup instructions are provided once you add a node.",
},
{
q: "What's the difference between a 'call' and an 'incident'?",
a: "A call is a single radio transmission. An incident is the thing you actually care about — a pursuit, a fire, an accident — built by correlating related calls together, sometimes across multiple talkgroups or nodes. Incidents are the primary view; calls are the evidence behind them.",
},
{
q: "Does DRB do the transcription and AI work itself, or is that a separate cost?",
a: "Transcription and incident correlation are included in every paid plan and run automatically on every recorded call. The Community plan includes AI features on a limited call volume; Pro and Enterprise scale with your node count.",
},
{
q: "Can I listen to live radio traffic without opening the dashboard?",
a: "Yes — the Discord bot can join a voice channel and relay live audio from any of your nodes, so your team can listen without a separate scanner app.",
},
{
q: "How does node ownership and team access work?",
a: "Admins have full access. Operators are scoped to a specific list of nodes they own — they see and manage only those. Viewers get read-only access to everything the org exposes. You manage all of this from Settings → Members.",
},
{
q: "What happens if I go over my plan's node or seat limit?",
a: "You'll see a plan-limit notice in Settings → Billing before anything is blocked. In this demo build there's no live enforcement wired up yet — see the Billing settings page for what's stubbed vs. real.",
},
{
q: "How long is call and incident history kept?",
a: "Retention depends on plan — 7 days on Community, 90 days on Pro, and a year or more on Enterprise (negotiable). Historical calls remain searchable and linked to their incidents for the full retention window.",
},
{
q: "Is DMR supported?",
a: "Not yet — DMR is on the roadmap but the current release only decodes P25 and analog trunked systems.",
},
];
function ChevronIcon({ open }: { open: boolean }) {
return (
<svg
width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"
strokeLinecap="round" strokeLinejoin="round"
className={`text-gray-500 shrink-0 transition-transform ${open ? "rotate-180" : ""}`}
>
<polyline points="6 9 12 15 18 9" />
</svg>
);
}
export default function FaqPage() {
const [openIndex, setOpenIndex] = useState<number | null>(0);
return (
<div className="max-w-screen-md mx-auto px-4 md:px-6 py-16 md:py-20">
<div className="text-center">
<Badge tone="brand">FAQ</Badge>
<h1 className="text-display-sm md:text-display text-white mt-5">Frequently asked questions</h1>
<p className="text-gray-400 mt-4">Can&apos;t find what you&apos;re looking for? Sign in and reach out from your account.</p>
</div>
<div className="mt-12 divide-y divide-gray-800 border-t border-b border-gray-800">
{FAQS.map((item, i) => {
const open = openIndex === i;
return (
<div key={item.q}>
<button
onClick={() => setOpenIndex(open ? null : i)}
className="w-full flex items-center justify-between gap-4 py-5 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 rounded-lg"
aria-expanded={open}
>
<span className="text-white font-semibold text-sm md:text-base">{item.q}</span>
<ChevronIcon open={open} />
</button>
{open && (
<p className="text-gray-400 text-sm leading-relaxed pb-5 pr-8 animate-fade-in">{item.a}</p>
)}
</div>
);
})}
</div>
<div className="text-center mt-16">
<LinkButton href="/login" size="lg">Get started</LinkButton>
</div>
</div>
);
}
+105
View File
@@ -0,0 +1,105 @@
import { Card } from "@/components/ui/Card";
import { Badge } from "@/components/ui/Badge";
import { LinkButton } from "@/components/ui/Button";
const SECTIONS = [
{
eyebrow: "Correlation",
title: "Calls become incidents",
body:
"The correlation engine groups related transmissions — across talkgroups and even across nodes — into a single incident. A pursuit renders as a path through every checkin point heard while it moved; a structure fire or accident renders as a pin at the location dispatch gave.",
points: [
"Hybrid rule + LLM correlation with a cheap/smart consensus tiebreak",
"Distance, timing, shared units, and talkgroup signals all feed the match",
"Every call keeps its correlation debug trail for admins to audit",
],
},
{
eyebrow: "AI pipeline",
title: "Transcription and entity extraction",
body:
"Every recorded call is transcribed and scanned for the details that matter — units on scene, vehicles, and locations — so an incident reads like a dispatch briefing instead of a stack of raw audio.",
points: [
"Automatic speech-to-text on every call",
"Scene & entity extraction feeds the correlator and the incident summary",
"AI-generated incident summaries, regenerable on demand",
],
},
{
eyebrow: "Situational awareness",
title: "Live map, full history",
body:
"Glance at the map to see what's active right now, or scrub back through history to review how a specific incident unfolded — every linked call, in order, with playback.",
points: [
"Real-time node and incident map",
"Per-incident call timeline with audio playback",
"Configurable alert rules that post to Discord on keyword or talkgroup match",
],
},
{
eyebrow: "Field hardware",
title: "Field SDR nodes",
body:
"Lightweight edge nodes run OP25/GNU Radio against a P25 or analog trunked system and stream decoded audio to your account. Deploy one node to cover a town, or a whole network across a region.",
points: [
"P25 and analog trunked systems supported",
"Per-node hardware tuning (gain, PPM, antenna) persists independently of system assignment",
"Node health, call activity, and configuration all visible from the dashboard",
],
},
{
eyebrow: "Team",
title: "Discord voice relay & role-scoped access",
body:
"The Discord bot relays live radio audio into a voice channel so your team can listen along without a separate app, and doubles as a lightweight utility bot for team coordination.",
points: [
"Live audio relay per node, on demand",
"Admin / operator / viewer roles, with operators scoped to the nodes they own",
"Discord account linking for in-Discord commands",
],
},
];
export default function FeaturesPage() {
return (
<div className="max-w-screen-xl mx-auto px-4 md:px-6 py-16 md:py-20">
<div className="max-w-2xl">
<Badge tone="brand">Features</Badge>
<h1 className="text-display-sm md:text-display text-white mt-5">Everything between the radio and the map</h1>
<p className="text-gray-400 mt-4 leading-relaxed">
DRB is the pipeline from decoded radio traffic to a picture your team can act on: transcription,
correlation, mapping, and a live relay — end to end.
</p>
</div>
<div className="mt-16 space-y-16">
{SECTIONS.map((s) => (
<div key={s.title} className="grid grid-cols-1 lg:grid-cols-5 gap-8 items-start">
<div className="lg:col-span-2">
<p className="text-indigo-400 text-xs font-mono uppercase tracking-wider font-semibold">{s.eyebrow}</p>
<h2 className="text-white text-2xl font-bold mt-2">{s.title}</h2>
<p className="text-gray-400 mt-3 leading-relaxed">{s.body}</p>
</div>
<Card padding="lg" className="lg:col-span-3">
<ul className="space-y-3">
{s.points.map((p) => (
<li key={p} className="flex items-start gap-3 text-sm text-gray-300">
<span className="mt-1.5 w-1.5 h-1.5 rounded-full bg-indigo-500 shrink-0" />
{p}
</li>
))}
</ul>
</Card>
</div>
))}
</div>
<div className="text-center mt-20 pt-16 border-t border-gray-800">
<h2 className="text-display-sm text-white">See it running on your own traffic</h2>
<div className="mt-6">
<LinkButton href="/login" size="lg">Get started</LinkButton>
</div>
</div>
</div>
);
}
+33
View File
@@ -115,3 +115,36 @@ html:not(.dark) input::placeholder,
html:not(.dark) textarea::placeholder {
color: #94a3b8;
}
/* ── Marketing/product surface additions (2026-08 overhaul) ─────────────────
* Same pattern as above: components use hardcoded dark-palette Tailwind
* classes, remapped here for light mode instead of dark: prefixes.
* Only new classes introduced by the marketing pages / settings shell live
* below — everything else reuses the palette already mapped above.
*/
/* Tinted accent surfaces (plan highlight cards, "included" checks, danger zones) */
html:not(.dark) .bg-indigo-600\/10 { background-color: rgba(79,70,229,0.08) !important; }
html:not(.dark) .border-indigo-600\/40 { border-color: rgba(79,70,229,0.35) !important; }
html:not(.dark) .bg-green-600\/10 { background-color: rgba(22,163,74,0.08) !important; }
html:not(.dark) .bg-red-600\/10 { background-color: rgba(220,38,38,0.08) !important; }
html:not(.dark) .border-red-600\/40 { border-color: rgba(220,38,38,0.35) !important; }
html:not(.dark) .bg-yellow-600\/10 { background-color: rgba(202,138,4,0.08) !important; }
html:not(.dark) .border-yellow-600\/40 { border-color: rgba(202,138,4,0.35) !important; }
/* Marketing hero background — subtle radial glow, brand-neutral in both themes */
.marketing-hero-bg {
background-image: radial-gradient(ellipse 80% 50% at 50% -10%, rgba(99,102,241,0.25), transparent 60%);
}
html:not(.dark) .marketing-hero-bg {
background-image: radial-gradient(ellipse 80% 50% at 50% -10%, rgba(99,102,241,0.12), transparent 60%);
}
/* Skeleton loading shimmer */
@keyframes skeleton-pulse {
0%, 100% { opacity: 0.5; }
50% { opacity: 1; }
}
.skeleton {
animation: skeleton-pulse 1.6s ease-in-out infinite;
}
+3 -17
View File
@@ -10,26 +10,11 @@ import { useAuth } from "@/components/AuthProvider";
import { CallRow } from "@/components/CallRow";
import { c2api } from "@/lib/c2api";
import type { IncidentRecord } from "@/lib/types";
import { TypeBadge } from "@/components/IncidentBadges";
import { severityBadge } from "@/lib/severity";
const MapView = dynamic(() => import("@/components/MapView"), { ssr: false });
const TYPE_COLORS: Record<string, string> = {
fire: "bg-red-900 text-red-300",
police: "bg-blue-900 text-blue-300",
ems: "bg-yellow-900 text-yellow-300",
accident: "bg-orange-900 text-orange-300",
other: "bg-gray-800 text-gray-300",
};
function TypeBadge({ type }: { type: string | null }) {
const cls = TYPE_COLORS[type ?? "other"] ?? TYPE_COLORS.other;
return (
<span className={`text-xs font-mono px-2 py-0.5 rounded-full capitalize ${cls}`}>
{type ?? "other"}
</span>
);
}
function StatusBadge({ status }: { status: IncidentRecord["status"] }) {
return (
<span className={`text-xs px-2 py-0.5 rounded-full font-mono ${
@@ -93,6 +78,7 @@ export default function IncidentDetailPage() {
<div className="flex items-center gap-2 flex-wrap">
<TypeBadge type={incident.type} />
<StatusBadge status={incident.status} />
{severityBadge(incident.severity)}
</div>
<h1 className="text-lg sm:text-xl font-bold text-white font-mono leading-snug">
{incident.title ?? "Incident"}
+179 -165
View File
@@ -1,49 +1,42 @@
"use client";
import { useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/components/AuthProvider";
import { useIncidents } from "@/lib/useIncidents";
import { c2api } from "@/lib/c2api";
import type { IncidentRecord } from "@/lib/types";
import { useState } from "react";
import { PageHeader } from "@/components/ui/PageHeader";
import { Card } from "@/components/ui/Card";
import { Button } from "@/components/ui/Button";
import { Badge } from "@/components/ui/Badge";
import { EmptyState } from "@/components/ui/EmptyState";
import { SkeletonCard } from "@/components/ui/Skeleton";
import { severityBadge, severityRank } from "@/lib/severity";
import { TypeBadge } from "@/components/IncidentBadges";
const TYPE_COLORS: Record<string, string> = {
fire: "bg-red-900 text-red-300",
police: "bg-blue-900 text-blue-300",
ems: "bg-yellow-900 text-yellow-300",
accident: "bg-orange-900 text-orange-300",
other: "bg-gray-800 text-gray-300",
};
// Severity badge/ordering now lives in lib/severity.ts (shared with CallRow).
// `severityBadge()` already returns null for the legacy "unknown" value.
const SEVERITY_COLORS: Record<string, string> = {
major: "bg-red-950 text-red-400",
moderate: "bg-orange-950 text-orange-400",
minor: "bg-gray-800 text-gray-400",
};
type SeverityFilter = "all" | "minor" | "moderate" | "major";
const SEVERITY_FILTERS: { key: SeverityFilter; label: string }[] = [
{ key: "all", label: "All" },
{ key: "minor", label: "Minor+" },
{ key: "moderate", label: "Moderate+" },
{ key: "major", label: "Major only" },
];
const FILTER_THRESHOLD: Record<SeverityFilter, number> = { all: -1, minor: 1, moderate: 2, major: 3 };
function severityBadge(severity: string | null | undefined) {
if (!severity || severity === "unknown") return null;
const cls = SEVERITY_COLORS[severity] ?? "bg-gray-800 text-gray-400";
return (
<span className={`text-xs font-mono px-2 py-0.5 rounded-full capitalize ${cls}`}>
{severity}
</span>
);
}
function typeBadge(type: string | null) {
const cls = TYPE_COLORS[type ?? "other"] ?? TYPE_COLORS.other;
return (
<span className={`text-xs font-mono px-2 py-0.5 rounded-full capitalize ${cls}`}>
{type ?? "other"}
</span>
);
}
type SortMode = "recent" | "severity";
function fmtTime(iso: string) {
try { return new Date(iso).toLocaleString(); } catch { return iso; }
}
// ---------------------------------------------------------------------------
// Rows / cards
// ---------------------------------------------------------------------------
function IncidentRow({ incident, isAdmin, onResolve }: {
incident: IncidentRecord;
isAdmin: boolean;
@@ -53,19 +46,13 @@ function IncidentRow({ incident, isAdmin, onResolve }: {
return (
<tr
className="border-b border-gray-800 hover:bg-gray-900 cursor-pointer"
className="border-b border-gray-800 last:border-0 hover:bg-gray-900/60 cursor-pointer transition-colors"
onClick={() => router.push(`/incidents/${incident.incident_id}`)}
>
<td className="px-4 py-3">{typeBadge(incident.type)}</td>
<td className="px-4 py-3"><TypeBadge type={incident.type} /></td>
<td className="px-4 py-3 text-white text-sm">{incident.title ?? "—"}</td>
<td className="px-4 py-3">
<span className={`text-xs px-2 py-0.5 rounded-full ${
incident.status === "active"
? "bg-green-900 text-green-300"
: "bg-gray-800 text-gray-400"
}`}>
{incident.status}
</span>
<Badge tone={incident.status === "active" ? "success" : "neutral"}>{incident.status}</Badge>
</td>
<td className="px-4 py-3">{severityBadge(incident.severity)}</td>
<td className="px-4 py-3 text-gray-400 text-xs font-mono">{incident.call_ids.length}</td>
@@ -73,22 +60,94 @@ function IncidentRow({ incident, isAdmin, onResolve }: {
<td className="px-4 py-3 text-gray-400 text-xs font-mono">{fmtTime(incident.updated_at)}</td>
<td className="px-4 py-3">
{isAdmin && incident.status === "active" && (
<button
<Button
size="sm" variant="secondary"
onClick={(e) => { e.stopPropagation(); onResolve(incident.incident_id); }}
className="text-xs bg-gray-800 hover:bg-gray-700 text-gray-300 px-2 py-1 rounded transition-colors"
>
Resolve
</button>
</Button>
)}
</td>
</tr>
);
}
function CreateModal({ onClose, onCreate }: {
onClose: () => void;
onCreate: (body: object) => Promise<void>;
function IncidentCards({ incidents, isAdmin, onResolve }: {
incidents: IncidentRecord[];
isAdmin: boolean;
onResolve: (id: string) => void;
}) {
const router = useRouter();
return (
<div className="space-y-2">
{incidents.map((inc) => (
<Card
key={inc.incident_id}
padding="sm"
hover
className="cursor-pointer active:bg-gray-800"
onClick={() => router.push(`/incidents/${inc.incident_id}`)}
>
<div className="flex items-center justify-between gap-2 mb-1.5">
<div className="flex items-center gap-2">
<TypeBadge type={inc.type} />
<Badge tone={inc.status === "active" ? "success" : "neutral"}>{inc.status}</Badge>
</div>
{isAdmin && inc.status === "active" && (
<Button size="sm" variant="secondary" onClick={(e) => { e.stopPropagation(); onResolve(inc.incident_id); }}>
Resolve
</Button>
)}
</div>
<p className="text-white text-sm font-semibold leading-snug">{inc.title ?? "—"}</p>
<div className="flex items-center gap-2 mt-1">
{severityBadge(inc.severity)}
<p className="text-gray-500 text-xs font-mono">
{fmtTime(inc.started_at)} · {inc.call_ids.length} call{inc.call_ids.length !== 1 ? "s" : ""}
</p>
</div>
</Card>
))}
</div>
);
}
function IncidentTable({ incidents, isAdmin, onResolve }: {
incidents: IncidentRecord[];
isAdmin: boolean;
onResolve: (id: string) => void;
}) {
return (
<>
<div className="sm:hidden">
<IncidentCards incidents={incidents} isAdmin={isAdmin} onResolve={onResolve} />
</div>
<div className="hidden sm:block bg-gray-900 border border-gray-800 rounded-xl overflow-hidden overflow-x-auto">
<table className="w-full text-left">
<thead>
<tr className="border-b border-gray-800 text-xs text-gray-500 uppercase">
<th className="px-4 py-3">Type</th>
<th className="px-4 py-3">Title</th>
<th className="px-4 py-3">Status</th>
<th className="px-4 py-3">Severity</th>
<th className="px-4 py-3">Calls</th>
<th className="px-4 py-3">Started</th>
<th className="px-4 py-3">Updated</th>
<th className="px-4 py-3"></th>
</tr>
</thead>
<tbody>
{incidents.map((inc) => (
<IncidentRow key={inc.incident_id} incident={inc} isAdmin={isAdmin} onResolve={onResolve} />
))}
</tbody>
</table>
</div>
</>
);
}
function CreateModal({ onClose, onCreate }: { onClose: () => void; onCreate: (body: object) => Promise<void> }) {
const [title, setTitle] = useState("");
const [type, setType] = useState("other");
const [summary, setSummary] = useState("");
@@ -106,11 +165,8 @@ function CreateModal({ onClose, onCreate }: {
}
return (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50">
<form
onSubmit={handleSubmit}
className="bg-gray-900 border border-gray-700 rounded-xl p-6 w-full max-w-md space-y-4"
>
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4">
<form onSubmit={handleSubmit} className="bg-gray-900 border border-gray-700 rounded-xl p-6 w-full max-w-md space-y-4">
<h2 className="text-white font-bold">Create Incident</h2>
<div>
<label className="text-xs text-gray-400 block mb-1">Title</label>
@@ -138,114 +194,37 @@ function CreateModal({ onClose, onCreate }: {
/>
</div>
<div className="flex gap-3 justify-end">
<button type="button" onClick={onClose} className="text-sm text-gray-400 hover:text-gray-200 px-4 py-2">
Cancel
</button>
<button
type="submit" disabled={saving}
className="bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white text-sm rounded-lg px-4 py-2"
>
{saving ? "Creating…" : "Create"}
</button>
<Button type="button" variant="ghost" onClick={onClose}>Cancel</Button>
<Button type="submit" disabled={saving}>{saving ? "Creating…" : "Create"}</Button>
</div>
</form>
</div>
);
}
function IncidentCards({ incidents, isAdmin, onResolve }: {
incidents: IncidentRecord[];
isAdmin: boolean;
onResolve: (id: string) => void;
}) {
const router = useRouter();
return (
<div className="space-y-2">
{incidents.map((inc) => (
<div
key={inc.incident_id}
className="bg-gray-900 border border-gray-800 rounded-xl p-4 cursor-pointer active:bg-gray-800"
onClick={() => router.push(`/incidents/${inc.incident_id}`)}
>
<div className="flex items-center justify-between gap-2 mb-1.5">
<div className="flex items-center gap-2">
{typeBadge(inc.type)}
<span className={`text-xs px-2 py-0.5 rounded-full ${
inc.status === "active" ? "bg-green-900 text-green-300" : "bg-gray-800 text-gray-400"
}`}>{inc.status}</span>
</div>
{isAdmin && inc.status === "active" && (
<button
onClick={(e) => { e.stopPropagation(); onResolve(inc.incident_id); }}
className="text-xs bg-gray-800 hover:bg-gray-700 text-gray-300 px-2 py-1 rounded transition-colors"
>
Resolve
</button>
)}
</div>
<p className="text-white text-sm font-semibold leading-snug">{inc.title ?? "—"}</p>
<div className="flex items-center gap-2 mt-1">
{severityBadge(inc.severity)}
<p className="text-gray-500 text-xs font-mono">
{fmtTime(inc.started_at)} · {inc.call_ids.length} call{inc.call_ids.length !== 1 ? "s" : ""}
</p>
</div>
</div>
))}
</div>
);
}
function IncidentTable({ incidents, isAdmin, onResolve }: {
incidents: IncidentRecord[];
isAdmin: boolean;
onResolve: (id: string) => void;
}) {
return (
<>
{/* Mobile card view */}
<div className="sm:hidden">
<IncidentCards incidents={incidents} isAdmin={isAdmin} onResolve={onResolve} />
</div>
{/* Desktop table view */}
<div className="hidden sm:block bg-gray-900 border border-gray-800 rounded-xl overflow-hidden">
<table className="w-full text-left">
<thead>
<tr className="border-b border-gray-800 text-xs text-gray-500 uppercase">
<th className="px-4 py-3">Type</th>
<th className="px-4 py-3">Title</th>
<th className="px-4 py-3">Status</th>
<th className="px-4 py-3">Severity</th>
<th className="px-4 py-3">Calls</th>
<th className="px-4 py-3">Started</th>
<th className="px-4 py-3">Updated</th>
<th className="px-4 py-3"></th>
</tr>
</thead>
<tbody>
{incidents.map((inc) => (
<IncidentRow
key={inc.incident_id}
incident={inc}
isAdmin={isAdmin}
onResolve={onResolve}
/>
))}
</tbody>
</table>
</div>
</>
);
}
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
export default function IncidentsPage() {
const { isAdmin } = useAuth();
const { incidents, loading } = useIncidents();
const [showCreate, setShowCreate] = useState(false);
const [severityFilter, setSeverityFilter] = useState<SeverityFilter>("all");
const [sortMode, setSortMode] = useState<SortMode>("recent");
const active = incidents.filter((i) => i.status === "active");
const resolved = incidents.filter((i) => i.status === "resolved");
const filtered = useMemo(() => {
const threshold = FILTER_THRESHOLD[severityFilter];
const list = incidents.filter((i) => severityRank(i.severity) >= threshold);
if (sortMode === "severity") {
return [...list].sort((a, b) => severityRank(b.severity) - severityRank(a.severity) || b.started_at.localeCompare(a.started_at));
}
return list; // useIncidents() already orders by started_at desc
}, [incidents, severityFilter, sortMode]);
const active = filtered.filter((i) => i.status === "active");
const resolved = filtered.filter((i) => i.status === "resolved");
const hiddenCount = incidents.length - filtered.length;
async function handleResolve(id: string) {
try { await c2api.updateIncident(id, { status: "resolved" }); }
@@ -253,30 +232,53 @@ export default function IncidentsPage() {
}
return (
<div className="space-y-8">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<h1 className="text-white text-xl font-bold font-mono">Incidents</h1>
{active.length > 0 && (
<span className="text-xs bg-red-900 text-red-300 px-2 py-0.5 rounded-full font-mono">
{active.length} active
</span>
)}
</div>
{isAdmin && (
<div className="space-y-6">
<PageHeader
title="Incidents"
badge={active.length > 0 && <Badge tone="danger">{active.length} active</Badge>}
action={isAdmin && <Button onClick={() => setShowCreate(true)}>+ Create Incident</Button>}
/>
{/* Severity filter + sort — severity is a filter dimension, not decoration */}
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap gap-1 bg-gray-900 border border-gray-800 rounded-lg p-1 w-fit">
{SEVERITY_FILTERS.map(({ key, label }) => (
<button
onClick={() => setShowCreate(true)}
className="bg-indigo-600 hover:bg-indigo-500 text-white text-sm rounded-lg px-4 py-2 transition-colors"
key={key}
onClick={() => setSeverityFilter(key)}
className={`text-sm font-mono px-3.5 py-1.5 rounded-md transition-colors ${
severityFilter === key ? "bg-gray-800 text-white" : "text-gray-500 hover:text-gray-300"
}`}
>
+ Create Incident
{label}
</button>
)}
))}
</div>
<label className="flex items-center gap-2 text-xs font-mono text-gray-500">
Sort
<select
value={sortMode}
onChange={(e) => setSortMode(e.target.value as SortMode)}
className="bg-gray-900 border border-gray-800 rounded-lg px-2 py-1.5 text-gray-200 focus:outline-none focus:border-indigo-500"
>
<option value="recent">Most recent</option>
<option value="severity">Highest severity</option>
</select>
</label>
</div>
{loading ? (
<p className="text-gray-500 text-sm font-mono">Loading…</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<SkeletonCard /><SkeletonCard />
</div>
) : (
<>
{hiddenCount > 0 && (
<p className="text-xs text-gray-600 font-mono">
{hiddenCount} incident{hiddenCount !== 1 ? "s" : ""} hidden by the severity filter.
</p>
)}
{active.length > 0 && (
<section>
<h2 className="text-sm font-mono text-gray-400 uppercase tracking-wider mb-3">Active</h2>
@@ -291,8 +293,20 @@ export default function IncidentsPage() {
</section>
)}
{incidents.length === 0 && (
<p className="text-gray-600 text-sm font-mono">No incidents recorded yet.</p>
{filtered.length === 0 && (
<EmptyState
title={incidents.length === 0 ? "No incidents recorded yet" : "No incidents match this filter"}
description={
incidents.length === 0
? "Incidents appear automatically once calls start correlating."
: "Try a lower severity threshold."
}
action={
incidents.length > 0 && severityFilter !== "all" ? (
<Button variant="secondary" size="sm" onClick={() => setSeverityFilter("all")}>Clear filter</Button>
) : undefined
}
/>
)}
</>
)}
+4 -5
View File
@@ -1,12 +1,12 @@
import type { Metadata } from "next";
import { Nav } from "@/components/Nav";
import { AuthProvider } from "@/components/AuthProvider";
import { ThemeProvider } from "@/components/ThemeProvider";
import { ChromeSwitcher } from "@/components/ChromeSwitcher";
import "./globals.css";
export const metadata: Metadata = {
title: "DRB Portal",
description: "Distributed Radio Bot — Control & Monitoring",
title: "DRB — Public-Safety Radio Intelligence",
description: "Live incident awareness from field SDR nodes — transcribed, correlated, and mapped in real time.",
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
@@ -19,8 +19,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
<body className="min-h-screen bg-gray-950">
<ThemeProvider>
<AuthProvider>
<Nav />
<main className="max-w-screen-2xl mx-auto px-4 md:px-6 py-6">{children}</main>
<ChromeSwitcher>{children}</ChromeSwitcher>
</AuthProvider>
</ThemeProvider>
</body>
+6 -1
View File
@@ -1,6 +1,7 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { signInWithEmailAndPassword, GoogleAuthProvider, signInWithPopup } from "firebase/auth";
import { auth } from "@/lib/firebase";
import { c2api } from "@/lib/c2api";
@@ -44,8 +45,12 @@ export default function LoginPage() {
return (
<div className="max-w-sm mx-auto pt-16">
<Link href="/" className="flex items-center justify-center gap-2 mb-6 font-mono font-bold text-white">
<span className="inline-flex items-center justify-center w-8 h-8 rounded-lg bg-indigo-600 text-white">D</span>
DRB
</Link>
<div className="bg-gray-900 border border-gray-700 rounded-xl p-8 space-y-5 font-mono">
<h1 className="text-white text-lg font-bold">DRB Portal</h1>
<h1 className="text-white text-lg font-bold">Sign in</h1>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
+50
View File
@@ -192,6 +192,54 @@ export default function NodeDetailPage() {
<StatusBadge status={node.status} />
</div>
{/* Override Warning */}
{node.is_overridden && (
<div className="bg-yellow-950/40 border border-yellow-800/60 rounded-lg p-4 font-mono text-sm space-y-3">
<div className="flex items-center gap-2 text-yellow-400 font-semibold">
<span className="text-base">⚠</span>
<span>Local System Override Active</span>
</div>
<p className="text-gray-400 text-xs leading-relaxed">
This node is operating on a local system override.
{node.override_timeout_at ? (
<> Resets automatically on: <span className="text-white font-bold">{new Date(node.override_timeout_at).toLocaleString()}</span>.</>
) : (
<> No timeout is currently enforced (permanent override).</>
)}
</p>
<div className="flex gap-2">
{node.override_timeout_at && (
<button
onClick={async () => {
try {
await c2api.ackOverride(id, 1440);
} catch (e) {
alert("Failed to extend timer.");
}
}}
className="px-3 py-1 bg-yellow-800 hover:bg-yellow-700 text-white rounded text-xs transition-colors"
>
Ack (Reset 24h Timer)
</button>
)}
<button
onClick={async () => {
if (confirm("Force this node to revert back to its assigned system config?")) {
try {
await c2api.resetOverride(id);
} catch (e) {
alert("Failed to reset override.");
}
}
}}
className="px-3 py-1 bg-red-900 hover:bg-red-800 text-red-200 rounded text-xs transition-colors"
>
Force Revert Config
</button>
</div>
</div>
)}
{/* Info */}
<div className="bg-gray-900 border border-gray-800 rounded-lg divide-y divide-gray-800 font-mono text-sm">
{[
@@ -199,6 +247,8 @@ export default function NodeDetailPage() {
["Location", `${node.lat}, ${node.lon}`],
["Last Seen", node.last_seen ? new Date(node.last_seen).toLocaleString() : "never"],
["Configured", node.configured ? "Yes" : "No"],
["Node Type", node.node_type ?? "fixed"],
...(node.node_type !== "portable" ? [["Enforce Timeout", node.enforce_override_timeout ? "Yes" : "No"]] : []),
].map(([label, value]) => (
<div key={label} className="flex justify-between px-4 py-2.5">
<span className="text-gray-500">{label}</span>
+139 -3
View File
@@ -1,5 +1,141 @@
import { redirect } from "next/navigation";
import Link from "next/link";
import { LinkButton } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
import { Badge } from "@/components/ui/Badge";
import { PLANS } from "@/lib/billing";
export default function Home() {
redirect("/dashboard");
const CAPABILITIES = [
{
title: "Incidents, not raw calls",
body: "Individual transmissions are correlated into a single incident — a pursuit becomes a path through every checkin point, a structure fire becomes a pin at the dispatched address.",
},
{
title: "AI transcription & extraction",
body: "Every call is transcribed and scanned for units, vehicles, and locations, so an incident page reads like a briefing instead of a call log.",
},
{
title: "Live map, full history",
body: "Watch what's happening right now, or scrub back through history to see how an incident unfolded call by call.",
},
{
title: "Field SDR nodes",
body: "Lightweight edge nodes decode P25 and analog police/fire traffic and stream it to your account — deploy one node or a whole regional network.",
},
{
title: "Discord voice relay",
body: "Pipe live radio audio into a Discord channel so your team can listen along in real time, no separate scanner app required.",
},
{
title: "Role-scoped access",
body: "Admins, operators scoped to the nodes they own, and read-only viewers — invite your team with the access level that fits.",
},
];
const STEPS = [
{ n: "01", title: "Deploy a node", body: "Point a field SDR node at your local P25 or analog system. It streams decoded audio to your DRB account over the network." },
{ n: "02", title: "We transcribe & correlate", body: "Calls are transcribed, entities are extracted, and related calls are correlated into incidents automatically." },
{ n: "03", title: "Your team watches", body: "Incidents show up on the live map and dashboard with an AI summary, units on scene, and every related recording." },
];
export default function MarketingHomePage() {
return (
<div>
{/* Hero */}
<section className="marketing-hero-bg">
<div className="max-w-screen-xl mx-auto px-4 md:px-6 pt-20 pb-24 md:pt-28 md:pb-32">
<div className="max-w-3xl">
<Badge tone="brand">Public-safety radio intelligence</Badge>
<h1 className="text-display-sm md:text-display mt-5 text-white">
See what&apos;s happening on the radio, as an incident — not a wall of calls.
</h1>
<p className="text-gray-400 text-base md:text-lg mt-5 max-w-2xl leading-relaxed">
DRB turns field SDR nodes into a live public-safety picture: police/fire radio is decoded, transcribed,
and correlated into incidents you can watch on a map or scrub back through in history — with a Discord
bot to relay the audio live to your team.
</p>
<div className="flex flex-wrap items-center gap-3 mt-8">
<LinkButton href="/login" size="lg">Get started</LinkButton>
<LinkButton href="/pricing" variant="secondary" size="lg">View pricing</LinkButton>
</div>
</div>
</div>
</section>
{/* Capabilities */}
<section className="max-w-screen-xl mx-auto px-4 md:px-6 py-16 md:py-20">
<div className="max-w-2xl mb-10">
<h2 className="text-display-sm text-white">The unit of value is the incident</h2>
<p className="text-gray-400 mt-3">
A scanner feed is noise. DRB's job is to turn that noise into a small number of things you actually care
about — and let you click into any one of them for the full picture.
</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5">
{CAPABILITIES.map((c) => (
<Card key={c.title} padding="lg" hover>
<h3 className="text-white font-semibold">{c.title}</h3>
<p className="text-gray-400 text-sm mt-2 leading-relaxed">{c.body}</p>
</Card>
))}
</div>
</section>
{/* How it works */}
<section className="border-t border-gray-800">
<div className="max-w-screen-xl mx-auto px-4 md:px-6 py-16 md:py-20">
<h2 className="text-display-sm text-white mb-10">How it works</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
{STEPS.map((s) => (
<div key={s.n}>
<p className="text-indigo-400 font-mono text-sm font-bold">{s.n}</p>
<h3 className="text-white font-semibold mt-2">{s.title}</h3>
<p className="text-gray-400 text-sm mt-2 leading-relaxed">{s.body}</p>
</div>
))}
</div>
</div>
</section>
{/* Pricing teaser */}
<section className="border-t border-gray-800">
<div className="max-w-screen-xl mx-auto px-4 md:px-6 py-16 md:py-20">
<div className="flex flex-col md:flex-row md:items-end justify-between gap-4 mb-10">
<div>
<h2 className="text-display-sm text-white">Plans for one node or a whole region</h2>
<p className="text-gray-400 mt-2">Start free. Upgrade when you add nodes or need longer retention.</p>
</div>
<Link href="/pricing" className="text-indigo-400 hover:text-indigo-300 text-sm font-mono transition-colors shrink-0">
See full plan comparison →
</Link>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-5">
{PLANS.map((plan) => (
<Card key={plan.id} padding="lg" highlighted={plan.highlighted}>
{plan.highlighted && <Badge tone="brand" className="mb-3">Most popular</Badge>}
<h3 className="text-white font-semibold">{plan.name}</h3>
<p className="text-gray-500 text-xs mt-1">{plan.tagline}</p>
<p className="text-white text-2xl font-bold font-mono mt-4">
{plan.priceMonthlyUsd === null ? "Custom" : plan.priceMonthlyUsd === 0 ? "Free" : `$${plan.priceMonthlyUsd}`}
{plan.priceMonthlyUsd !== null && plan.priceMonthlyUsd > 0 && <span className="text-gray-500 text-sm font-normal">/mo</span>}
</p>
</Card>
))}
</div>
</div>
</section>
{/* Final CTA */}
<section className="border-t border-gray-800">
<div className="max-w-screen-xl mx-auto px-4 md:px-6 py-16 md:py-20 text-center">
<h2 className="text-display-sm text-white">Bring your first node online</h2>
<p className="text-gray-400 mt-3 max-w-xl mx-auto">
Sign in to create an account, add a node, and start seeing incidents within minutes of your first call.
</p>
<div className="mt-8">
<LinkButton href="/login" size="lg">Get started</LinkButton>
</div>
</div>
</section>
</div>
);
}
+101
View File
@@ -0,0 +1,101 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { PLANS, type BillingInterval } from "@/lib/billing";
import { Card } from "@/components/ui/Card";
import { Badge } from "@/components/ui/Badge";
import { LinkButton } from "@/components/ui/Button";
function CheckIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" className="text-green-400 shrink-0 mt-0.5">
<polyline points="20 6 9 17 4 12" />
</svg>
);
}
export default function PricingPage() {
const [interval, setInterval] = useState<BillingInterval>("monthly");
return (
<div className="max-w-screen-xl mx-auto px-4 md:px-6 py-16 md:py-20">
<div className="text-center max-w-2xl mx-auto">
<h1 className="text-display-sm md:text-display text-white">Simple, node-based pricing</h1>
<p className="text-gray-400 mt-4">
Every plan includes the full incident pipeline — transcription, correlation, mapping, and the Discord relay.
Plans differ in how many nodes and seats you get, and how far back your history goes.
</p>
</div>
{/* Interval toggle */}
<div className="flex items-center justify-center gap-1 mt-10 bg-gray-900 border border-gray-800 rounded-lg p-1 w-fit mx-auto">
{(["monthly", "annual"] as BillingInterval[]).map((i) => (
<button
key={i}
onClick={() => setInterval(i)}
className={`text-sm font-mono px-4 py-1.5 rounded-md transition-colors capitalize ${
interval === i ? "bg-gray-800 text-white" : "text-gray-500 hover:text-gray-300"
}`}
>
{i}
{i === "annual" && <span className="ml-1.5 text-green-400 text-xs">save ~17%</span>}
</button>
))}
</div>
{/* Plan cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mt-10 items-stretch">
{PLANS.map((plan) => {
const price = interval === "annual" ? plan.priceAnnualUsd : plan.priceMonthlyUsd;
const priceLabel =
price === null ? "Custom" : price === 0 ? "Free" : `$${interval === "annual" ? Math.round(price / 12) : price}`;
return (
<Card key={plan.id} padding="lg" highlighted={plan.highlighted} className="flex flex-col">
{plan.highlighted && <Badge tone="brand" className="mb-3 w-fit">Most popular</Badge>}
<h2 className="text-white text-lg font-bold">{plan.name}</h2>
<p className="text-gray-500 text-sm mt-1.5 leading-relaxed">{plan.tagline}</p>
<div className="mt-6">
<span className="text-white text-3xl font-bold font-mono">{priceLabel}</span>
{price !== null && price > 0 && <span className="text-gray-500 text-sm">/mo</span>}
{interval === "annual" && price !== null && price > 0 && (
<p className="text-gray-600 text-xs mt-1">billed ${plan.priceAnnualUsd}/year</p>
)}
</div>
<div className="mt-6">
<LinkButton href="/login" variant={plan.highlighted ? "primary" : "secondary"} fullWidth>
{plan.priceMonthlyUsd === null ? "Contact sales" : "Get started"}
</LinkButton>
</div>
<ul className="mt-6 space-y-2.5 flex-1">
{plan.features.map((f) => (
<li key={f} className="flex items-start gap-2 text-sm text-gray-300">
<CheckIcon />
{f}
</li>
))}
</ul>
</Card>
);
})}
</div>
<p className="text-center text-gray-600 text-xs font-mono mt-8">
Prices shown are sample figures for this demo build — nothing here is connected to a live payment processor.
</p>
<div className="text-center mt-16">
<p className="text-gray-400">
Questions about a plan?{" "}
<Link href="/faq" className="text-indigo-400 hover:text-indigo-300 transition-colors">Check the FAQ</Link>
{" "}or{" "}
<Link href="/login" className="text-indigo-400 hover:text-indigo-300 transition-colors">sign in to talk to us</Link>.
</p>
</div>
</div>
);
}
+155
View File
@@ -0,0 +1,155 @@
"use client";
import { useEffect, useState } from "react";
import { listApiKeys, createApiKey, revokeApiKey, type ApiKeyRecord } from "@/lib/apiKeys";
import { Card } from "@/components/ui/Card";
import { Button } from "@/components/ui/Button";
import { Badge } from "@/components/ui/Badge";
import { EmptyState } from "@/components/ui/EmptyState";
function fmtDate(iso: string) {
return new Date(iso).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
}
function CreateKeyModal({ onClose, onCreated }: { onClose: () => void; onCreated: (r: ApiKeyRecord) => void }) {
const [name, setName] = useState("");
const [saving, setSaving] = useState(false);
const [rawKey, setRawKey] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setSaving(true);
try {
const { record, rawKey } = await createApiKey(name);
onCreated(record);
setRawKey(rawKey);
} finally {
setSaving(false);
}
}
function copy() {
if (!rawKey) return;
navigator.clipboard?.writeText(rawKey).then(() => { setCopied(true); setTimeout(() => setCopied(false), 2000); });
}
if (rawKey) {
return (
<div className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-4">
<Card padding="lg" className="w-full max-w-lg space-y-4">
<h2 className="text-white font-semibold">Key created</h2>
<p className="text-xs text-gray-400">
Copy this key now — it won&apos;t be shown again. This is a sample key from the demo module in{" "}
<code className="text-gray-300">lib/apiKeys.ts</code>; it doesn&apos;t authenticate against anything.
</p>
<div className="bg-gray-800 border border-gray-700 rounded-lg p-3">
<p className="text-xs text-indigo-300 break-all font-mono">{rawKey}</p>
</div>
<div className="flex gap-3">
<Button variant="secondary" onClick={copy} fullWidth>{copied ? "Copied!" : "Copy key"}</Button>
<Button onClick={onClose} fullWidth>Done</Button>
</div>
</Card>
</div>
);
}
return (
<div className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-4">
<Card padding="lg" className="w-full max-w-md">
<h2 className="text-white font-semibold mb-4">New API key</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="text-xs text-gray-400 block mb-1">Label</label>
<input
required value={name} onChange={(e) => setName(e.target.value)}
placeholder="e.g. Ops dashboard integration"
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
/>
</div>
<div className="flex gap-3">
<Button type="submit" disabled={saving} fullWidth>{saving ? "Creating…" : "Create key"}</Button>
<Button type="button" variant="secondary" onClick={onClose} fullWidth>Cancel</Button>
</div>
</form>
</Card>
</div>
);
}
export default function ApiKeysSettingsPage() {
const [keys, setKeys] = useState<ApiKeyRecord[]>([]);
const [loading, setLoading] = useState(true);
const [showCreate, setShowCreate] = useState(false);
useEffect(() => { listApiKeys().then(setKeys).finally(() => setLoading(false)); }, []);
async function handleRevoke(id: string) {
await revokeApiKey(id);
setKeys((prev) => prev.map((k) => (k.key_id === id ? { ...k, revoked: true } : k)));
}
const active = keys.filter((k) => !k.revoked);
return (
<div className="space-y-4">
<div className="bg-indigo-600/10 border border-indigo-600/40 rounded-xl p-4">
<p className="text-indigo-300 text-sm font-semibold">Preview feature</p>
<p className="text-gray-400 text-xs mt-1 leading-relaxed">
Organization API keys aren&apos;t backed by a real endpoint yet — this screen runs against an in-memory
demo module (<code className="text-gray-300">lib/apiKeys.ts</code>) so the flow can be reviewed end to
end. See that file for the exact backend routes a real integration needs.
</p>
</div>
{showCreate && (
<CreateKeyModal onClose={() => setShowCreate(false)} onCreated={(r) => setKeys((prev) => [...prev, r])} />
)}
<div className="flex items-center justify-between">
<p className="text-sm text-gray-500">{loading ? "Loading…" : `${active.length} active key${active.length !== 1 ? "s" : ""}`}</p>
<Button size="sm" onClick={() => setShowCreate(true)}>+ Create key</Button>
</div>
{!loading && keys.length === 0 ? (
<EmptyState title="No API keys yet" description="Create one to authenticate external integrations against the DRB API." />
) : (
<Card padding="none" className="overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="text-xs text-gray-500 uppercase tracking-wider border-b border-gray-800 bg-gray-900">
<th className="px-4 py-3 text-left">Label</th>
<th className="px-4 py-3 text-left">Key</th>
<th className="px-4 py-3 text-left hidden sm:table-cell">Created</th>
<th className="px-4 py-3 text-left hidden sm:table-cell">Last used</th>
<th className="px-4 py-3 text-left">Status</th>
<th className="px-4 py-3 w-20"></th>
</tr>
</thead>
<tbody>
{keys.map((k) => (
<tr key={k.key_id} className="border-b border-gray-800 last:border-0">
<td className="px-4 py-3 text-white">{k.name}</td>
<td className="px-4 py-3 text-gray-500 font-mono text-xs">{k.key_prefix}…</td>
<td className="px-4 py-3 text-gray-400 text-xs hidden sm:table-cell">{fmtDate(k.created_at)}</td>
<td className="px-4 py-3 text-gray-400 text-xs hidden sm:table-cell">{k.last_used_at ? fmtDate(k.last_used_at) : "Never"}</td>
<td className="px-4 py-3">
{k.revoked ? <Badge tone="danger">Revoked</Badge> : <Badge tone="success">Active</Badge>}
</td>
<td className="px-4 py-3 text-right">
{!k.revoked && (
<button onClick={() => handleRevoke(k.key_id)} className="text-xs text-red-500 hover:text-red-400 transition-colors">
Revoke
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</Card>
)}
</div>
);
}
+214
View File
@@ -0,0 +1,214 @@
"use client";
import { useEffect, useState } from "react";
import {
PLANS, getPlan, getCurrentSubscription, getUsageSummary, getInvoices,
createCheckoutSession, createBillingPortalSession,
type Subscription, type UsageSummary, type Invoice, type PlanId,
} from "@/lib/billing";
import { Card, CardHeader } from "@/components/ui/Card";
import { Badge } from "@/components/ui/Badge";
import { Button } from "@/components/ui/Button";
import { SkeletonCard } from "@/components/ui/Skeleton";
function fmtDate(iso: string) {
return new Date(iso).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
}
function StatusBanner({ sub }: { sub: Subscription }) {
if (sub.status === "trialing" && sub.trialEndsAt) {
return (
<div className="bg-indigo-600/10 border border-indigo-600/40 rounded-xl p-4 flex items-center justify-between gap-4 flex-wrap">
<p className="text-sm text-indigo-300">
Trial active — ends {fmtDate(sub.trialEndsAt)}. Add a payment method to keep your plan after that.
</p>
<Badge tone="brand">Trial</Badge>
</div>
);
}
if (sub.status === "past_due") {
return (
<div className="bg-red-600/10 border border-red-600/40 rounded-xl p-4 flex items-center justify-between gap-4 flex-wrap">
<p className="text-sm text-red-400">
Payment failed on your last invoice. Update your payment method to avoid losing access.
</p>
<Badge tone="danger">Past due</Badge>
</div>
);
}
if (sub.status === "canceled") {
return (
<div className="bg-yellow-600/10 border border-yellow-600/40 rounded-xl p-4 flex items-center justify-between gap-4 flex-wrap">
<p className="text-sm text-yellow-400">Your subscription is canceled. Reactivate to restore full access.</p>
<Badge tone="warning">Canceled</Badge>
</div>
);
}
return null;
}
function UsageBar({ label, used, limit }: { label: string; used: number; limit: number | "unlimited" }) {
const pct = limit === "unlimited" ? 0 : Math.min(100, Math.round((used / Math.max(limit, 1)) * 100));
const nearLimit = limit !== "unlimited" && used / limit >= 0.9;
return (
<div>
<div className="flex items-baseline justify-between mb-1.5">
<span className="text-xs text-gray-400 font-mono">{label}</span>
<span className="text-xs font-mono text-gray-300">
{used} / {limit === "unlimited" ? "∞" : limit}
</span>
</div>
<div className="h-2 rounded-full bg-gray-800 overflow-hidden">
<div
className={`h-full rounded-full transition-all ${nearLimit ? "bg-orange-500" : "bg-indigo-500"}`}
style={{ width: limit === "unlimited" ? "8%" : `${pct}%` }}
/>
</div>
</div>
);
}
const INVOICE_TONE: Record<Invoice["status"], "success" | "warning" | "neutral" | "danger"> = {
paid: "success",
open: "warning",
void: "neutral",
uncollectible: "danger",
};
export default function BillingSettingsPage() {
const [sub, setSub] = useState<Subscription | null>(null);
const [usage, setUsage] = useState<UsageSummary | null>(null);
const [invoices, setInvoices] = useState<Invoice[]>([]);
const [loading, setLoading] = useState(true);
const [actionError, setActionError] = useState<string | null>(null);
const [busyPlan, setBusyPlan] = useState<PlanId | null>(null);
const [portalBusy, setPortalBusy] = useState(false);
useEffect(() => {
Promise.all([getCurrentSubscription(), getUsageSummary(), getInvoices()])
.then(([s, u, i]) => { setSub(s); setUsage(u); setInvoices(i); })
.finally(() => setLoading(false));
}, []);
async function handleChoosePlan(planId: PlanId) {
setBusyPlan(planId);
setActionError(null);
try {
const { url } = await createCheckoutSession(planId, sub?.interval ?? "monthly");
window.location.href = url;
} catch (e) {
setActionError(e instanceof Error ? e.message : String(e));
} finally {
setBusyPlan(null);
}
}
async function handleManageBilling() {
setPortalBusy(true);
setActionError(null);
try {
const { url } = await createBillingPortalSession();
window.location.href = url;
} catch (e) {
setActionError(e instanceof Error ? e.message : String(e));
} finally {
setPortalBusy(false);
}
}
if (loading || !sub || !usage) {
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<SkeletonCard /><SkeletonCard />
</div>
);
}
const plan = getPlan(sub.planId);
return (
<div className="space-y-6 max-w-4xl">
<p className="text-xs text-gray-600 font-mono">
Demo data — this page isn&apos;t connected to a live payment processor. See lib/billing.ts for the integration plan.
</p>
<StatusBanner sub={sub} />
{actionError && (
<div className="bg-red-950 border border-red-800 rounded-lg p-4">
<p className="text-red-400 text-sm">{actionError}</p>
</div>
)}
<Card>
<CardHeader
title="Current plan"
subtitle={sub.cancelAtPeriodEnd ? `Cancels ${sub.currentPeriodEnd ? fmtDate(sub.currentPeriodEnd) : "at period end"}` : sub.currentPeriodEnd ? `Renews ${fmtDate(sub.currentPeriodEnd)}` : undefined}
action={<Badge tone={plan.id === "free" ? "neutral" : "brand"}>{plan.name}</Badge>}
/>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<UsageBar label="Seats" used={usage.seatsUsed} limit={usage.seatsLimit} />
<UsageBar label="Nodes" used={usage.nodesUsed} limit={usage.nodesLimit} />
</div>
<div className="mt-5 pt-5 border-t border-gray-800">
<Button variant="secondary" size="sm" onClick={handleManageBilling} disabled={portalBusy}>
{portalBusy ? "Opening…" : "Manage payment method & invoices"}
</Button>
</div>
</Card>
<Card>
<CardHeader title="Change plan" subtitle="Upgrading takes effect immediately; downgrading takes effect at the end of the current period." />
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
{PLANS.map((p) => {
const isCurrent = p.id === sub.planId;
return (
<div
key={p.id}
className={`rounded-xl border p-4 flex flex-col ${p.highlighted ? "border-indigo-600/40" : "border-gray-800"}`}
>
<p className="text-white font-semibold text-sm">{p.name}</p>
<p className="text-gray-500 text-xs mt-1 flex-1">{p.tagline}</p>
<p className="text-white text-lg font-bold font-mono mt-3">
{p.priceMonthlyUsd === null ? "Custom" : p.priceMonthlyUsd === 0 ? "Free" : `$${p.priceMonthlyUsd}/mo`}
</p>
<Button
className="mt-3"
size="sm"
variant={isCurrent ? "secondary" : "primary"}
disabled={isCurrent || busyPlan === p.id}
onClick={() => handleChoosePlan(p.id)}
fullWidth
>
{isCurrent ? "Current plan" : busyPlan === p.id ? "Redirecting…" : p.priceMonthlyUsd === null ? "Contact sales" : "Switch"}
</Button>
</div>
);
})}
</div>
</Card>
<Card>
<CardHeader title="Invoice history" />
{invoices.length === 0 ? (
<p className="text-gray-600 text-sm">No invoices yet.</p>
) : (
<div className="divide-y divide-gray-800">
{invoices.map((inv) => (
<div key={inv.id} className="flex items-center justify-between gap-4 py-3">
<div className="min-w-0">
<p className="text-gray-200 text-sm">{inv.description}</p>
<p className="text-gray-600 text-xs font-mono">{fmtDate(inv.date)}</p>
</div>
<div className="flex items-center gap-3 shrink-0">
<span className="text-gray-300 text-sm font-mono">${inv.amountUsd.toFixed(2)}</span>
<Badge tone={INVOICE_TONE[inv.status]}>{inv.status}</Badge>
</div>
</div>
))}
</div>
)}
</Card>
</div>
);
}
+54
View File
@@ -0,0 +1,54 @@
"use client";
import { useEffect } from "react";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { useAuth } from "@/components/AuthProvider";
import { PageHeader } from "@/components/ui/PageHeader";
const TABS = [
{ href: "/settings/organization", label: "Organization" },
{ href: "/settings/members", label: "Members" },
{ href: "/settings/nodes", label: "Node Ownership" },
{ href: "/settings/api-keys", label: "API Keys" },
{ href: "/settings/billing", label: "Billing" },
];
export default function SettingsLayout({ children }: { children: React.ReactNode }) {
const { isAdmin, loading } = useAuth();
const pathname = usePathname();
const router = useRouter();
useEffect(() => {
if (!loading && !isAdmin) router.replace("/dashboard");
}, [loading, isAdmin, router]);
if (loading || !isAdmin) return null;
return (
<div className="space-y-6">
<PageHeader
title="Settings"
description="Organization profile, team access, node ownership, API keys, and billing."
/>
<div className="flex flex-wrap gap-1 bg-gray-900 border border-gray-800 rounded-lg p-1 w-fit max-w-full overflow-x-auto">
{TABS.map((t) => (
<Link
key={t.href}
href={t.href}
className={`text-sm font-mono px-4 py-1.5 rounded-md transition-colors whitespace-nowrap ${
pathname === t.href || pathname.startsWith(t.href + "/")
? "bg-gray-800 text-white"
: "text-gray-500 hover:text-gray-300"
}`}
>
{t.label}
</Link>
))}
</div>
{children}
</div>
);
}
+197
View File
@@ -0,0 +1,197 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { c2api } from "@/lib/c2api";
import { useAuth } from "@/components/AuthProvider";
import type { UserRecord, UserRole } from "@/lib/types";
import { Card } from "@/components/ui/Card";
import { Badge } from "@/components/ui/Badge";
import { Button } from "@/components/ui/Button";
import { EmptyState, ErrorBanner } from "@/components/ui/EmptyState";
import { SkeletonRow } from "@/components/ui/Skeleton";
const ROLE_TONE: Record<UserRole, "brand" | "success" | "neutral"> = {
admin: "brand",
operator: "success",
viewer: "neutral",
};
const ROLE_LABEL: Record<UserRole, string> = { admin: "Admin", operator: "Operator", viewer: "Viewer" };
function InviteModal({ onClose, onCreated }: { onClose: () => void; onCreated: (u: UserRecord) => void }) {
const [email, setEmail] = useState("");
const [role, setRole] = useState<UserRole>("viewer");
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [inviteLink, setInviteLink] = useState<string | null>(null);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setSaving(true);
setError(null);
try {
const created = await c2api.createUser({ email, role });
onCreated(created);
if (created.invite_link) setInviteLink(created.invite_link);
else onClose();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setSaving(false);
}
}
if (inviteLink) {
return (
<div className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-4">
<Card padding="lg" className="w-full max-w-md space-y-4">
<h2 className="text-white font-semibold">Member invited</h2>
<p className="text-xs text-gray-400">Share this one-time invite link so they can set their password. It expires after use.</p>
<div className="bg-gray-800 border border-gray-700 rounded-lg p-3">
<p className="text-xs text-indigo-300 break-all">{inviteLink}</p>
</div>
<Button onClick={onClose} fullWidth>Done</Button>
</Card>
</div>
);
}
return (
<div className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-4">
<Card padding="lg" className="w-full max-w-md">
<h2 className="text-white font-semibold mb-4">Invite a member</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="text-xs text-gray-400 block mb-1">Email</label>
<input
type="email" required value={email} onChange={(e) => setEmail(e.target.value)}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
placeholder="teammate@example.com"
/>
</div>
<div>
<label className="text-xs text-gray-400 block mb-1">Role</label>
<select
value={role} onChange={(e) => setRole(e.target.value as UserRole)}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
>
<option value="admin">Admin — full access</option>
<option value="operator">Operator — owns nodes</option>
<option value="viewer">Viewer — read-only</option>
</select>
</div>
{error && <p className="text-red-400 text-xs">{error}</p>}
<div className="flex gap-3 pt-1">
<Button type="submit" disabled={saving} fullWidth>{saving ? "Sending…" : "Send invite"}</Button>
<Button type="button" variant="secondary" onClick={onClose} fullWidth>Cancel</Button>
</div>
</form>
</Card>
</div>
);
}
export default function MembersSettingsPage() {
const { user } = useAuth();
const [users, setUsers] = useState<UserRecord[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [showInvite, setShowInvite] = useState(false);
const [savingUid, setSavingUid] = useState<string | null>(null);
const load = useCallback(async () => {
try {
setUsers(await c2api.listUsers());
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setLoading(false);
}
}, []);
useEffect(() => { load(); }, [load]);
async function handleRoleChange(u: UserRecord, role: UserRole) {
setSavingUid(u.uid);
try {
const updated = await c2api.updateUser(u.uid, { role, owned_node_ids: role === "operator" ? u.owned_node_ids : [] });
setUsers((prev) => prev.map((x) => (x.uid === u.uid ? { ...x, ...updated } : x)));
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setSavingUid(null);
}
}
return (
<div className="space-y-4">
{showInvite && (
<InviteModal onClose={() => setShowInvite(false)} onCreated={(u) => setUsers((prev) => [...prev, u])} />
)}
<div className="flex items-center justify-between">
<p className="text-sm text-gray-500">
{loading ? "Loading members…" : `${users.length} member${users.length !== 1 ? "s" : ""}`}
</p>
<Button size="sm" onClick={() => setShowInvite(true)}>+ Invite member</Button>
</div>
{error && <ErrorBanner message={error} />}
{!loading && users.length === 0 ? (
<EmptyState title="No members yet" description="Invite your team to give them dashboard access." />
) : (
<Card padding="none" className="overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="text-xs text-gray-500 uppercase tracking-wider border-b border-gray-800 bg-gray-900">
<th className="px-4 py-3 text-left">Member</th>
<th className="px-4 py-3 text-left">Role</th>
<th className="px-4 py-3 text-left hidden sm:table-cell">Owned nodes</th>
<th className="px-4 py-3 text-left hidden md:table-cell">Status</th>
</tr>
</thead>
<tbody>
{loading
? Array.from({ length: 3 }).map((_, i) => <SkeletonRow key={i} cols={4} />)
: users.map((u) => (
<tr key={u.uid} className="border-b border-gray-800 last:border-0">
<td className="px-4 py-3">
<p className="text-white">{u.display_name || u.email}</p>
{u.display_name && <p className="text-gray-600 text-xs">{u.email}</p>}
</td>
<td className="px-4 py-3">
{u.uid === user?.uid ? (
<Badge tone={ROLE_TONE[u.role]}>{ROLE_LABEL[u.role]}</Badge>
) : (
<select
value={u.role}
disabled={savingUid === u.uid}
onChange={(e) => handleRoleChange(u, e.target.value as UserRole)}
className="bg-gray-800 border border-gray-700 rounded-lg px-2 py-1 text-xs text-white focus:outline-none focus:border-indigo-500 disabled:opacity-50"
>
<option value="admin">Admin</option>
<option value="operator">Operator</option>
<option value="viewer">Viewer</option>
</select>
)}
</td>
<td className="px-4 py-3 text-gray-400 text-xs hidden sm:table-cell">
{u.role === "operator" ? u.owned_node_ids.length : "—"}
</td>
<td className="px-4 py-3 hidden md:table-cell">
{u.disabled ? <Badge tone="danger">Disabled</Badge> : <Badge tone="success">Active</Badge>}
</td>
</tr>
))}
</tbody>
</table>
</Card>
)}
<p className="text-xs text-gray-600 font-mono">
Need to disable or delete a member? Use the full user admin panel under Admin → Users.
</p>
</div>
);
}
+127
View File
@@ -0,0 +1,127 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { useNodes } from "@/lib/useNodes";
import { c2api } from "@/lib/c2api";
import type { UserRecord } from "@/lib/types";
import { StatusBadge } from "@/components/StatusBadge";
import { Card } from "@/components/ui/Card";
import { ErrorBanner } from "@/components/ui/EmptyState";
import { SkeletonRow } from "@/components/ui/Skeleton";
const UNASSIGNED = "__unassigned__";
export default function NodeOwnershipSettingsPage() {
const { nodes, loading: nodesLoading } = useNodes();
const [users, setUsers] = useState<UserRecord[]>([]);
const [loadingUsers, setLoadingUsers] = useState(true);
const [error, setError] = useState<string | null>(null);
const [savingNodeId, setSavingNodeId] = useState<string | null>(null);
useEffect(() => {
c2api.listUsers()
.then(setUsers)
.catch((e) => setError(e instanceof Error ? e.message : String(e)))
.finally(() => setLoadingUsers(false));
}, []);
// Ownership (owned_node_ids) is only meaningful for operators elsewhere in the
// app (see Admin → Users); admins already have full access regardless.
const assignable = useMemo(() => users.filter((u) => u.role === "operator"), [users]);
const ownerByNode = useMemo(() => {
const map = new Map<string, UserRecord>();
for (const u of users) {
if (u.role !== "operator") continue;
for (const nodeId of u.owned_node_ids) map.set(nodeId, u);
}
return map;
}, [users]);
const reassign = useCallback(async (nodeId: string, newUid: string) => {
setSavingNodeId(nodeId);
setError(null);
try {
const prevOwner = ownerByNode.get(nodeId);
// Remove from previous owner, if any and different from the new one.
if (prevOwner && prevOwner.uid !== newUid) {
const next = prevOwner.owned_node_ids.filter((id) => id !== nodeId);
await c2api.updateUser(prevOwner.uid, { owned_node_ids: next });
setUsers((all) => all.map((u) => (u.uid === prevOwner.uid ? { ...u, owned_node_ids: next } : u)));
}
// Add to new owner, if one was selected.
if (newUid !== UNASSIGNED) {
const newOwner = users.find((u) => u.uid === newUid);
if (newOwner && !newOwner.owned_node_ids.includes(nodeId)) {
const next = [...newOwner.owned_node_ids, nodeId];
await c2api.updateUser(newOwner.uid, { owned_node_ids: next });
setUsers((all) => all.map((u) => (u.uid === newOwner.uid ? { ...u, owned_node_ids: next } : u)));
}
}
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setSavingNodeId(null);
}
}, [ownerByNode, users]);
const loading = nodesLoading || loadingUsers;
return (
<div className="space-y-4">
<p className="text-sm text-gray-500">
Assign each node to the operator responsible for it. Operators only see and manage the nodes assigned to them here.
</p>
{error && <ErrorBanner message={error} />}
<Card padding="none" className="overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="text-xs text-gray-500 uppercase tracking-wider border-b border-gray-800 bg-gray-900">
<th className="px-4 py-3 text-left">Node</th>
<th className="px-4 py-3 text-left hidden sm:table-cell">Status</th>
<th className="px-4 py-3 text-left">Owner</th>
</tr>
</thead>
<tbody>
{loading ? (
Array.from({ length: 3 }).map((_, i) => <SkeletonRow key={i} cols={3} />)
) : nodes.length === 0 ? (
<tr><td colSpan={3} className="px-4 py-8 text-center text-gray-600 text-sm">No nodes registered yet.</td></tr>
) : (
nodes.map((n) => {
const owner = ownerByNode.get(n.node_id);
return (
<tr key={n.node_id} className="border-b border-gray-800 last:border-0">
<td className="px-4 py-3">
<Link href={`/nodes/${n.node_id}`} className="text-white hover:text-indigo-300 transition-colors">
{n.name}
</Link>
<p className="text-gray-600 text-xs font-mono">{n.node_id}</p>
</td>
<td className="px-4 py-3 hidden sm:table-cell"><StatusBadge status={n.status} /></td>
<td className="px-4 py-3">
<select
value={owner?.uid ?? UNASSIGNED}
disabled={savingNodeId === n.node_id}
onChange={(e) => reassign(n.node_id, e.target.value)}
className="bg-gray-800 border border-gray-700 rounded-lg px-2 py-1.5 text-xs text-white focus:outline-none focus:border-indigo-500 disabled:opacity-50 max-w-[14rem]"
>
<option value={UNASSIGNED}>Unassigned</option>
{assignable.map((u) => (
<option key={u.uid} value={u.uid}>{u.display_name || u.email}</option>
))}
</select>
</td>
</tr>
);
})
)}
</tbody>
</table>
</Card>
</div>
);
}
@@ -0,0 +1,104 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { c2api } from "@/lib/c2api";
import { useNodes } from "@/lib/useNodes";
import { getCurrentSubscription, getPlan, type Subscription } from "@/lib/billing";
import { Card, CardHeader } from "@/components/ui/Card";
import { Badge } from "@/components/ui/Badge";
import { Button } from "@/components/ui/Button";
import { Skeleton } from "@/components/ui/Skeleton";
function StatTile({ label, value }: { label: string; value: string | number }) {
return (
<div>
<p className="text-xs text-gray-500 uppercase tracking-wider font-mono">{label}</p>
<p className="text-2xl font-bold text-white font-mono mt-1">{value}</p>
</div>
);
}
export default function OrganizationSettingsPage() {
const { nodes } = useNodes();
const [memberCount, setMemberCount] = useState<number | null>(null);
const [sub, setSub] = useState<Subscription | null>(null);
const [orgName, setOrgName] = useState("My Organization");
useEffect(() => {
c2api.listUsers().then((u) => setMemberCount(u.length)).catch(() => setMemberCount(null));
getCurrentSubscription().then(setSub);
}, []);
const plan = sub ? getPlan(sub.planId) : null;
return (
<div className="space-y-6 max-w-3xl">
<Card>
<CardHeader
title="Organization profile"
subtitle="Basic identity for this DRB account."
/>
<div className="space-y-4">
<div>
<label className="text-xs text-gray-400 block mb-1">Organization name</label>
<input
value={orgName}
onChange={(e) => setOrgName(e.target.value)}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
/>
</div>
<div className="flex items-center gap-3">
<Button size="sm" disabled title="Organization profile isn't persisted server-side yet">
Save changes
</Button>
<span className="text-xs text-gray-600 font-mono">
Preview only — no backend endpoint stores this yet.
</span>
</div>
</div>
</Card>
<Card>
<CardHeader
title="Overview"
action={
plan ? (
<Badge tone={plan.id === "free" ? "neutral" : "brand"}>{plan.name} plan</Badge>
) : (
<Skeleton className="h-5 w-16" />
)
}
/>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-6">
<StatTile label="Nodes" value={nodes.length} />
<StatTile label="Members" value={memberCount ?? "—"} />
<StatTile
label="Status"
value={sub ? sub.status.replace("_", " ") : "—"}
/>
</div>
<div className="mt-5 pt-5 border-t border-gray-800 flex items-center gap-4 text-sm">
<Link href="/settings/billing" className="text-indigo-400 hover:text-indigo-300 transition-colors">
Manage plan & billing →
</Link>
<Link href="/settings/members" className="text-indigo-400 hover:text-indigo-300 transition-colors">
Manage members →
</Link>
</div>
</Card>
<div className="bg-gray-900 border border-red-800/60 rounded-xl p-5">
<CardHeader title="Danger zone" subtitle="Destructive organization-level actions." />
<div className="flex flex-wrap gap-3">
<Button variant="danger" size="sm" disabled title="Not available in this build — contact support">
Delete organization
</Button>
<Button variant="secondary" size="sm" disabled title="Not available in this build — contact support">
Transfer ownership
</Button>
</div>
</div>
</div>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default function SettingsIndexPage() {
redirect("/settings/organization");
}
+36 -5
View File
@@ -1,8 +1,9 @@
"use client";
import { useState } from "react";
import { useEffect, useState } from "react";
import type { CallRecord } from "@/lib/types";
import { c2api } from "@/lib/c2api";
import { severityBadge } from "@/lib/severity";
interface Props {
call: CallRecord;
@@ -37,11 +38,31 @@ export function CallRow({ call, systemName, isAdmin }: Props) {
: call.incident_id ? [call.incident_id] : [];
const isActive = call.status === "active";
const hasDetails = call.transcript || call.transcript_corrected || (call.tags && call.tags.length > 0) || incidentIds.length > 0 || call.audio_url;
// Rows come straight from Firestore (lib/useCalls.ts), and the doc only holds
// the private gs:// object location — never a playable URL. Presence of audio
// is known from the doc; the actual link is minted by the API on expand.
// audio_url is the legacy field: older docs stored an (unplayable) gs:// URI
// there, so it still signals "this call has a recording".
const hasAudio = !!(call.audio_gcs_uri || call.audio_url);
const hasDetails = call.transcript || call.transcript_corrected || (call.tags && call.tags.length > 0) || incidentIds.length > 0 || hasAudio;
const displayTranscript = (!showOriginal && call.transcript_corrected) ? call.transcript_corrected : call.transcript;
const hasBoth = !!(call.transcript && call.transcript_corrected);
const hasSegments = call.segments && call.segments.length > 1;
// Fetched lazily on expand: playback links are short-lived, so minting one
// for every row up front would waste most of them and expire the rest.
const [audioUrl, setAudioUrl] = useState<string | null>(null);
const [audioError, setAudioError] = useState(false);
useEffect(() => {
if (!expanded || !hasAudio || audioUrl || audioError) return;
let cancelled = false;
c2api.getCall(call.call_id)
.then((full) => { if (!cancelled) setAudioUrl(full.audio_url ?? null); })
.catch(() => { if (!cancelled) setAudioError(true); });
return () => { cancelled = true; };
}, [expanded, hasAudio, audioUrl, audioError, call.call_id]);
function startEdit() {
setEditText(call.transcript_corrected ?? call.transcript ?? "");
setEditing(true);
@@ -78,6 +99,10 @@ export function CallRow({ call, systemName, isAdmin }: Props) {
{call.tags[0]}
</span>
)}
{/* Routine/minor are the majority of traffic and stay unbadged; moderate+ is the triage signal worth a badge in a dense list. */}
{(call.severity === "moderate" || call.severity === "major") && (
<span className="ml-2">{severityBadge(call.severity)}</span>
)}
</td>
<td className="px-4 py-2 text-gray-400 hidden sm:table-cell">{systemName ?? call.system_id ?? "—"}</td>
<td className="px-4 py-2 text-gray-400 hidden sm:table-cell">{call.node_id}</td>
@@ -89,7 +114,7 @@ export function CallRow({ call, systemName, isAdmin }: Props) {
)}
</td>
<td className="px-4 py-2 text-xs">
{call.audio_url ? (
{hasAudio ? (
<span className="text-blue-400">▶</span>
) : (
<span className="text-gray-700">—</span>
@@ -104,13 +129,19 @@ export function CallRow({ call, systemName, isAdmin }: Props) {
<tr className="bg-gray-900/60 border-b border-gray-800">
<td colSpan={7} className="px-6 py-3 space-y-2">
{/* Audio player */}
{call.audio_url && (
{hasAudio && (
audioError ? (
<p className="text-xs text-red-400 font-mono">Could not load audio.</p>
) : audioUrl ? (
<audio
controls
src={call.audio_url}
src={audioUrl}
className="w-full max-w-sm h-8"
onClick={(e) => e.stopPropagation()}
/>
) : (
<p className="text-xs text-gray-600 font-mono">Loading audio…</p>
)
)}
{/* Tags */}
@@ -0,0 +1,37 @@
"use client";
import { usePathname } from "next/navigation";
import { Nav } from "@/components/Nav";
import { MarketingHeader } from "@/components/marketing/MarketingHeader";
import { MarketingFooter } from "@/components/marketing/MarketingFooter";
// Public marketing surface — exact paths, not prefixes, so e.g. /features/x
// (if it ever exists) doesn't accidentally get pulled into marketing chrome.
const MARKETING_PATHS = new Set(["/", "/features", "/pricing", "/faq"]);
/**
* Picks page chrome by route: the public marketing pages get a full-bleed
* layout with their own header/footer, everything else (the authenticated
* app, including /login and /settings) keeps the existing app Nav + padded
* main container.
*/
export function ChromeSwitcher({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
if (MARKETING_PATHS.has(pathname)) {
return (
<>
<MarketingHeader />
{children}
<MarketingFooter />
</>
);
}
return (
<>
<Nav />
<main className="max-w-screen-2xl mx-auto px-4 md:px-6 py-6">{children}</main>
</>
);
}
@@ -0,0 +1,21 @@
// Shared incident "type" badge — used on the incidents list, incident detail,
// and the dashboard's active-incidents panel. `other` covers anything outside
// the four radio-traffic archetypes (rail ops, public works, utility
// coordination, …) and must always resolve to a styled badge, never fall
// through unstyled.
const TYPE_COLORS: Record<string, string> = {
fire: "bg-red-900 text-red-300",
police: "bg-blue-900 text-blue-300",
ems: "bg-yellow-900 text-yellow-300",
accident: "bg-orange-900 text-orange-300",
other: "bg-gray-800 text-gray-300",
};
export function TypeBadge({ type }: { type: string | null }) {
const cls = TYPE_COLORS[type ?? "other"] ?? TYPE_COLORS.other;
return (
<span className={`text-xs font-mono px-2 py-0.5 rounded-full capitalize ${cls}`}>
{type ?? "other"}
</span>
);
}
+1
View File
@@ -28,6 +28,7 @@ const operatorLinks = [
// Admin-only links
const adminLinks = [
{ href: "/admin", label: "Admin" },
{ href: "/settings", label: "Settings" },
];
function SunIcon() {
+12
View File
@@ -45,6 +45,18 @@ export function NodeCard({ node, system }: Props) {
⚠ Needs configuration
</div>
)}
{node.is_overridden && (
<div className="mt-3 text-xs text-yellow-500 font-mono border-t border-gray-800 pt-2 flex justify-between">
<span>⚠ Local Override</span>
{node.override_timeout_at ? (
<span className="text-gray-500">
Resets: {new Date(node.override_timeout_at).toLocaleTimeString()}
</span>
) : (
<span className="text-gray-500">Permanent</span>
)}
</div>
)}
</div>
</Link>
);
+39 -4
View File
@@ -17,9 +17,11 @@ const PRESETS = [
];
export function NodeConfigModal({ node, systems, onClose }: Props) {
const [systemId, setSystemId] = useState("");
const [preset, setPreset] = useState("rtl-sdr-v3");
const [ppm, setPpm] = useState("0");
const [systemId, setSystemId] = useState(node.assigned_system_id ?? "");
const [preset, setPreset] = useState(node.hardware_preset ?? "rtl-sdr-v3");
const [ppm, setPpm] = useState(node.ppm_override ? String(node.ppm_override) : "0");
const [nodeType, setNodeType] = useState(node.node_type ?? "");
const [enforceTimeout, setEnforceTimeout] = useState(node.enforce_override_timeout ?? true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -32,6 +34,10 @@ export function NodeConfigModal({ node, systems, onClose }: Props) {
setSaving(true);
setError(null);
try {
await c2api.updateNode(node.node_id, {
node_type: nodeType,
enforce_override_timeout: enforceTimeout,
});
await c2api.assignSystem(node.node_id, systemId, preset, ppmOverride);
onClose();
} catch (err) {
@@ -100,12 +106,41 @@ export function NodeConfigModal({ node, systems, onClose }: Props) {
/>
</div>
<div>
<label className="block text-xs text-gray-400 mb-1">Node Type *</label>
<select
value={nodeType}
onChange={(e) => setNodeType(e.target.value)}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
required
>
<option value="">Select node type...</option>
<option value="fixed">Fixed Node (Standard)</option>
<option value="portable">Portable Node (Handheld)</option>
</select>
</div>
{nodeType === "fixed" && (
<div className="flex items-center gap-2 py-1">
<input
type="checkbox"
id="enforceTimeout"
checked={enforceTimeout}
onChange={(e) => setEnforceTimeout(e.target.checked)}
className="rounded bg-gray-800 border-gray-700 text-indigo-600 focus:ring-indigo-500 focus:ring-offset-gray-900"
/>
<label htmlFor="enforceTimeout" className="text-xs text-gray-400 cursor-pointer select-none">
Enforce Local Override Timeout (24 hours)
</label>
</div>
)}
{error && <p className="text-red-400 text-xs">{error}</p>}
<div className="flex gap-3 pt-1">
<button
type="submit"
disabled={saving || !systemId}
disabled={saving || !systemId || !nodeType}
className="flex-1 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white rounded-lg py-2 text-sm font-semibold transition-colors"
>
{saving ? "Saving…" : "Assign & Configure"}
@@ -0,0 +1,23 @@
import Link from "next/link";
export function MarketingFooter() {
return (
<footer className="border-t border-gray-800 mt-24">
<div className="max-w-screen-xl mx-auto px-4 md:px-6 py-10 flex flex-col md:flex-row items-start md:items-center justify-between gap-6">
<div className="flex items-center gap-2 font-mono font-bold text-white">
<span className="inline-flex items-center justify-center w-6 h-6 rounded-lg bg-indigo-600 text-white text-xs">D</span>
DRB
</div>
<nav className="flex flex-wrap items-center gap-x-6 gap-y-2 text-sm font-mono text-gray-500">
<Link href="/features" className="hover:text-gray-300 transition-colors">Features</Link>
<Link href="/pricing" className="hover:text-gray-300 transition-colors">Pricing</Link>
<Link href="/faq" className="hover:text-gray-300 transition-colors">FAQ</Link>
<Link href="/login" className="hover:text-gray-300 transition-colors">Sign in</Link>
</nav>
<p className="text-xs font-mono text-gray-600">© {new Date().getFullYear()} DRB. All rights reserved.</p>
</div>
</footer>
);
}
@@ -0,0 +1,96 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useAuth } from "@/components/AuthProvider";
import { LinkButton } from "@/components/ui/Button";
const LINKS = [
{ href: "/features", label: "Features" },
{ href: "/pricing", label: "Pricing" },
{ href: "/faq", label: "FAQ" },
];
export function MarketingHeader() {
const pathname = usePathname();
const { user, loading } = useAuth();
const [mobileOpen, setMobileOpen] = useState(false);
return (
<header className="sticky top-0 z-40 border-b border-gray-800 bg-gray-950/95 backdrop-blur">
<div className="max-w-screen-xl mx-auto px-4 md:px-6 py-4 flex items-center gap-6">
<Link href="/" className="flex items-center gap-2 shrink-0 font-mono font-bold text-white tracking-tight">
<span className="inline-flex items-center justify-center w-7 h-7 rounded-lg bg-indigo-600 text-white text-sm">D</span>
DRB
</Link>
<nav className="hidden md:flex items-center gap-6 ml-4">
{LINKS.map(({ href, label }) => (
<Link
key={href}
href={href}
className={`text-sm font-mono transition-colors ${
pathname === href ? "text-white" : "text-gray-400 hover:text-gray-200"
}`}
>
{label}
</Link>
))}
</nav>
<div className="ml-auto hidden md:flex items-center gap-3">
{!loading && user ? (
<LinkButton href="/dashboard" size="md">Go to dashboard</LinkButton>
) : (
<>
<LinkButton href="/login" variant="ghost" size="md">Sign in</LinkButton>
<LinkButton href="/login" size="md">Get started</LinkButton>
</>
)}
</div>
<button
onClick={() => setMobileOpen((v) => !v)}
className="md:hidden ml-auto text-gray-400 hover:text-gray-200 transition-colors p-1"
aria-label="Toggle menu"
>
{mobileOpen ? (
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
</svg>
) : (
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<line x1="3" y1="12" x2="21" y2="12" /><line x1="3" y1="6" x2="21" y2="6" /><line x1="3" y1="18" x2="21" y2="18" />
</svg>
)}
</button>
</div>
{mobileOpen && (
<div className="md:hidden border-t border-gray-800 bg-gray-950 px-4 py-3 flex flex-col gap-1">
{LINKS.map(({ href, label }) => (
<Link
key={href}
href={href}
onClick={() => setMobileOpen(false)}
className={`py-2 text-sm font-mono transition-colors ${pathname === href ? "text-white" : "text-gray-400"}`}
>
{label}
</Link>
))}
<div className="border-t border-gray-800 pt-3 mt-2 flex flex-col gap-2">
{!loading && user ? (
<LinkButton href="/dashboard" size="md" fullWidth>Go to dashboard</LinkButton>
) : (
<>
<LinkButton href="/login" variant="secondary" size="md" fullWidth>Sign in</LinkButton>
<LinkButton href="/login" size="md" fullWidth>Get started</LinkButton>
</>
)}
</div>
</div>
)}
</header>
);
}
+28
View File
@@ -0,0 +1,28 @@
import type { ReactNode } from "react";
type Tone = "neutral" | "brand" | "success" | "warning" | "danger" | "info";
const TONE_CLASSES: Record<Tone, string> = {
neutral: "bg-gray-800 text-gray-300",
brand: "bg-indigo-900 text-indigo-300",
success: "bg-green-900 text-green-300",
warning: "bg-yellow-900 text-yellow-300",
danger: "bg-red-900 text-red-300",
info: "bg-blue-900 text-blue-300",
};
export function Badge({ children, tone = "neutral", className }: { children: ReactNode; tone?: Tone; className?: string }) {
return (
<span
className={[
"inline-flex items-center gap-1 text-xs font-mono px-2 py-0.5 rounded-full whitespace-nowrap",
TONE_CLASSES[tone],
className ?? "",
]
.filter(Boolean)
.join(" ")}
>
{children}
</span>
);
}
+76
View File
@@ -0,0 +1,76 @@
import Link from "next/link";
import type { ButtonHTMLAttributes, ReactNode } from "react";
type Variant = "primary" | "secondary" | "ghost" | "danger";
type Size = "sm" | "md" | "lg";
const VARIANT_CLASSES: Record<Variant, string> = {
primary:
"bg-indigo-600 hover:bg-indigo-500 active:bg-indigo-700 text-white shadow-card disabled:hover:bg-indigo-600",
secondary:
"bg-gray-800 hover:bg-gray-700 active:bg-gray-700 text-gray-100 border border-gray-700 disabled:hover:bg-gray-800",
ghost:
"bg-transparent hover:bg-gray-800 active:bg-gray-800 text-gray-300 hover:text-white disabled:hover:bg-transparent",
danger:
"bg-red-700 hover:bg-red-600 active:bg-red-700 text-white disabled:hover:bg-red-700",
};
const SIZE_CLASSES: Record<Size, string> = {
sm: "text-xs px-3 py-1.5 rounded-lg gap-1.5",
md: "text-sm px-4 py-2 rounded-lg gap-2",
lg: "text-sm px-5 py-2.5 rounded-xl gap-2",
};
const BASE =
"inline-flex items-center justify-center font-semibold font-mono transition-colors " +
"disabled:opacity-50 disabled:cursor-not-allowed " +
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-gray-950";
interface CommonProps {
variant?: Variant;
size?: Size;
children: ReactNode;
className?: string;
fullWidth?: boolean;
}
type ButtonProps = CommonProps &
ButtonHTMLAttributes<HTMLButtonElement> & {
href?: undefined;
};
interface LinkButtonProps extends CommonProps {
href: string;
external?: boolean;
}
function classes(variant: Variant, size: Size, fullWidth: boolean | undefined, extra?: string) {
return [BASE, VARIANT_CLASSES[variant], SIZE_CLASSES[size], fullWidth ? "w-full" : "", extra ?? ""]
.filter(Boolean)
.join(" ");
}
/** Button — use for in-page actions. Pass `href` instead to render a Link (see LinkButton export). */
export function Button({ variant = "primary", size = "md", children, className, fullWidth, ...rest }: ButtonProps) {
return (
<button className={classes(variant, size, fullWidth, className)} {...rest}>
{children}
</button>
);
}
/** Same visual language as Button, but renders a Next.js Link — for navigation, not actions. */
export function LinkButton({ variant = "primary", size = "md", children, className, fullWidth, href, external }: LinkButtonProps) {
if (external) {
return (
<a href={href} target="_blank" rel="noopener noreferrer" className={classes(variant, size, fullWidth, className)}>
{children}
</a>
);
}
return (
<Link href={href} className={classes(variant, size, fullWidth, className)}>
{children}
</Link>
);
}
+47
View File
@@ -0,0 +1,47 @@
import type { HTMLAttributes, ReactNode } from "react";
interface CardProps extends HTMLAttributes<HTMLDivElement> {
children: ReactNode;
hover?: boolean;
padding?: "none" | "sm" | "md" | "lg";
highlighted?: boolean;
}
const PADDING: Record<NonNullable<CardProps["padding"]>, string> = {
none: "",
sm: "p-4",
md: "p-5",
lg: "p-8",
};
/** Standard surface card — the base container used across app + settings + marketing. */
export function Card({ children, hover, padding = "md", highlighted, className, ...rest }: CardProps) {
return (
<div
className={[
"bg-gray-900 border rounded-xl",
highlighted ? "border-indigo-600/40 shadow-glow" : "border-gray-800",
hover ? "transition-colors hover:border-gray-600" : "",
PADDING[padding],
className ?? "",
]
.filter(Boolean)
.join(" ")}
{...rest}
>
{children}
</div>
);
}
export function CardHeader({ title, subtitle, action }: { title: ReactNode; subtitle?: ReactNode; action?: ReactNode }) {
return (
<div className="flex items-start justify-between gap-4 mb-4">
<div className="min-w-0">
<h3 className="text-white font-semibold text-sm">{title}</h3>
{subtitle && <p className="text-gray-500 text-xs mt-0.5 leading-snug">{subtitle}</p>}
</div>
{action && <div className="shrink-0">{action}</div>}
</div>
);
}
+29
View File
@@ -0,0 +1,29 @@
import type { ReactNode } from "react";
interface EmptyStateProps {
icon?: ReactNode;
title: string;
description?: string;
action?: ReactNode;
}
/** Consistent "nothing here yet" panel — replaces the ad-hoc `<p className="text-gray-600">` scattered across pages. */
export function EmptyState({ icon, title, description, action }: EmptyStateProps) {
return (
<div className="flex flex-col items-center justify-center text-center py-12 px-6 border border-dashed border-gray-800 rounded-xl">
{icon && <div className="text-gray-700 mb-3">{icon}</div>}
<p className="text-gray-300 text-sm font-semibold font-mono">{title}</p>
{description && <p className="text-gray-600 text-xs font-mono mt-1 max-w-sm">{description}</p>}
{action && <div className="mt-4">{action}</div>}
</div>
);
}
/** Inline error banner — for API/Firestore errors surfaced within a page section. */
export function ErrorBanner({ message }: { message: string }) {
return (
<div className="bg-red-950 border border-red-800 rounded-lg p-4">
<p className="text-red-400 text-sm font-mono">{message}</p>
</div>
);
}
+24
View File
@@ -0,0 +1,24 @@
import type { ReactNode } from "react";
interface PageHeaderProps {
title: ReactNode;
description?: ReactNode;
badge?: ReactNode;
action?: ReactNode;
}
/** Standard page title row — title + optional badge on the left, primary action on the right. */
export function PageHeader({ title, description, badge, action }: PageHeaderProps) {
return (
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-3 flex-wrap">
<h1 className="text-xl font-bold text-white font-mono">{title}</h1>
{badge}
</div>
{description && <p className="text-gray-500 text-sm mt-1 max-w-2xl">{description}</p>}
</div>
{action && <div className="shrink-0">{action}</div>}
</div>
);
}
+27
View File
@@ -0,0 +1,27 @@
/** Loading placeholder block. Use instead of a bare "Loading…" string wherever the eventual
* content has a predictable shape (cards, table rows, stat tiles). */
export function Skeleton({ className }: { className?: string }) {
return <div className={`skeleton bg-gray-800 rounded-md ${className ?? "h-4 w-full"}`} />;
}
export function SkeletonCard() {
return (
<div className="bg-gray-900 border border-gray-800 rounded-xl p-4 space-y-3">
<Skeleton className="h-4 w-1/3" />
<Skeleton className="h-3 w-2/3" />
<Skeleton className="h-3 w-1/2" />
</div>
);
}
export function SkeletonRow({ cols = 5 }: { cols?: number }) {
return (
<tr className="border-b border-gray-800">
{Array.from({ length: cols }).map((_, i) => (
<td key={i} className="px-4 py-3">
<Skeleton className="h-3 w-full max-w-[8rem]" />
</td>
))}
</tr>
);
}
+73
View File
@@ -0,0 +1,73 @@
/**
* Organization API keys — STUB MODULE, no backend endpoint exists yet.
*
* This is a distinct concept from the two API-key-shaped things that already
* exist server-side:
* - `node_keys` (Firestore collection) — per-node upload credentials, issued
* via /nodes/{id}/reissue-key. Not this.
* - The Discord bot token pool (app/tokens) — Discord bot tokens, not this.
*
* This module models organization-level API keys for third-party
* integrations (a standard SaaS feature) that DRB does not yet expose.
* Everything below is in-memory demo state so the settings UI has something
* real to render; nothing here is persisted or capable of authenticating
* against the real API.
*
* TODO(api-keys): to make this real, add to drb-c2-core:
* - `org_api_keys` Firestore collection: {key_id, org_id, name, key_hash,
* key_prefix, created_at, last_used_at, created_by_uid, revoked}
* - POST /org/api-keys → generate, return the raw key ONCE
* - GET /org/api-keys → list (prefix + metadata only, never the raw key)
* - DELETE /org/api-keys/{id} → revoke
* - A new auth path in internal/auth.py that checks `Authorization: Bearer drb_live_…`
* against `key_hash` (constant-time compare), scoped like a viewer/operator token.
* Then replace the functions below with c2api calls hitting those routes.
*/
export interface ApiKeyRecord {
key_id: string;
name: string;
/** Only the prefix is ever shown after creation — mirrors how real key systems (Stripe, GitHub) do it. */
key_prefix: string;
created_at: string;
last_used_at: string | null;
revoked: boolean;
}
// Sample/demo fixture — obviously not real keys, never sent anywhere.
let DEMO_KEYS: ApiKeyRecord[] = [
{
key_id: "demo_key_1",
name: "Ops dashboard integration",
key_prefix: "drb_live_sample_4f2a",
created_at: "2026-07-02T14:00:00.000Z",
last_used_at: "2026-08-15T09:12:00.000Z",
revoked: false,
},
];
export async function listApiKeys(): Promise<ApiKeyRecord[]> {
return DEMO_KEYS;
}
/**
* Returns the full (fake) key exactly once, same UX contract a real key
* issuance flow would have — the raw secret is shown once and never again.
*/
export async function createApiKey(name: string): Promise<{ record: ApiKeyRecord; rawKey: string }> {
const suffix = Math.random().toString(36).slice(2, 10);
const record: ApiKeyRecord = {
key_id: `demo_key_${DEMO_KEYS.length + 1}`,
name,
key_prefix: `drb_live_sample_${suffix.slice(0, 4)}`,
created_at: new Date().toISOString(),
last_used_at: null,
revoked: false,
};
DEMO_KEYS = [...DEMO_KEYS, record];
return { record, rawKey: `drb_live_sample_${suffix}_DEMO_NOT_A_REAL_KEY` };
}
export async function revokeApiKey(keyId: string): Promise<void> {
DEMO_KEYS = DEMO_KEYS.map((k) => (k.key_id === keyId ? { ...k, revoked: true } : k));
}
+235
View File
@@ -0,0 +1,235 @@
/**
* Billing/licensing boundary — STUB MODULE.
*
* This file defines the typed shape the settings UI (app/settings/billing) talks to.
* Nothing here calls a real payment processor. Every exported function returns
* hardcoded sample data or throws, and is marked with a TODO describing exactly
* what a real integration would do.
*
* Do NOT wire a real Stripe (or other) publishable/secret key into this file.
* When a processor is chosen:
* 1. Add a c2-core router (e.g. `routers/billing.py`) that owns all server-side
* calls to the processor's API using a secret key from server env — never
* exposed to the frontend.
* 2. Add a Stripe (or similar) webhook endpoint on c2-core that keeps an
* `organizations/{orgId}` Firestore doc in sync with subscription state
* (plan, status, current_period_end, seats, node_limit).
* 3. Replace the bodies below with `c2api`-style `fetch` calls into that router.
* Checkout/portal functions should return a redirect URL from a real
* Checkout/Billing Portal session — the frontend's only job is
* `window.location.href = url`, it should never touch card data directly.
*/
export type PlanId = "free" | "pro" | "enterprise";
export type SubscriptionStatus = "trialing" | "active" | "past_due" | "canceled" | "none";
export type BillingInterval = "monthly" | "annual";
export interface PlanLimits {
seats: number | "unlimited";
nodes: number | "unlimited";
retentionDays: number;
}
export interface PlanDefinition {
id: PlanId;
name: string;
tagline: string;
priceMonthlyUsd: number | null; // null = "contact us"
priceAnnualUsd: number | null;
limits: PlanLimits;
features: string[];
highlighted?: boolean;
}
export interface Subscription {
planId: PlanId;
status: SubscriptionStatus;
interval: BillingInterval;
currentPeriodEnd: string | null; // ISO date
cancelAtPeriodEnd: boolean;
trialEndsAt: string | null; // ISO date
seatsUsed: number;
nodesUsed: number;
}
export interface Invoice {
id: string;
date: string; // ISO date
amountUsd: number;
status: "paid" | "open" | "void" | "uncollectible";
description: string;
/** In a real integration, a short-lived link to the processor-hosted PDF/receipt. */
hostedUrl: string | null;
}
export interface UsageSummary {
seatsUsed: number;
seatsLimit: number | "unlimited";
nodesUsed: number;
nodesLimit: number | "unlimited";
periodStart: string;
periodEnd: string;
}
// ---------------------------------------------------------------------------
// Plan catalog — this is real UI copy (safe to ship), just not wired to a
// live pricing table. In a real integration this would likely be fetched
// from the processor (Stripe Prices API) instead of hardcoded here so price
// changes don't require a frontend deploy.
// ---------------------------------------------------------------------------
export const PLANS: PlanDefinition[] = [
{
id: "free",
name: "Community",
tagline: "For a single node and a small crew keeping an eye on local traffic.",
priceMonthlyUsd: 0,
priceAnnualUsd: 0,
limits: { seats: 3, nodes: 1, retentionDays: 7 },
features: [
"1 field node",
"3 team seats",
"Live incident map",
"7-day call & incident history",
"Discord voice relay",
],
},
{
id: "pro",
name: "Pro",
tagline: "For agencies and serious hobbyist networks running multiple nodes.",
priceMonthlyUsd: 79,
priceAnnualUsd: 790,
limits: { seats: 15, nodes: 10, retentionDays: 90 },
features: [
"Up to 10 field nodes",
"15 team seats",
"AI incident correlation & summaries",
"90-day call & incident history",
"Alert rules with Discord webhooks",
"API key access",
],
highlighted: true,
},
{
id: "enterprise",
name: "Enterprise",
tagline: "For regional networks with custom retention, SSO, and support needs.",
priceMonthlyUsd: null,
priceAnnualUsd: null,
limits: { seats: "unlimited", nodes: "unlimited", retentionDays: 365 },
features: [
"Unlimited field nodes",
"Unlimited team seats",
"1-year+ retention (custom)",
"SSO / SAML",
"Dedicated support & uptime SLA",
"Custom data residency",
],
},
];
export function getPlan(id: PlanId): PlanDefinition {
return PLANS.find((p) => p.id === id) ?? PLANS[0];
}
// ---------------------------------------------------------------------------
// Sample account state — clearly a demo fixture, not a real customer record.
// TODO(billing): replace with `c2api.getSubscription()` once c2-core exposes
// GET /org/subscription backed by the processor + Firestore org doc.
// ---------------------------------------------------------------------------
const SAMPLE_SUBSCRIPTION: Subscription = {
planId: "pro",
status: "trialing",
interval: "monthly",
currentPeriodEnd: new Date(Date.now() + 1000 * 60 * 60 * 24 * 21).toISOString(),
cancelAtPeriodEnd: false,
trialEndsAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7).toISOString(),
seatsUsed: 4,
nodesUsed: 2,
};
const SAMPLE_INVOICES: Invoice[] = [
{ id: "sample_inv_1003", date: "2026-07-16", amountUsd: 79, status: "paid", description: "Pro plan — monthly", hostedUrl: null },
{ id: "sample_inv_1002", date: "2026-06-16", amountUsd: 79, status: "paid", description: "Pro plan — monthly", hostedUrl: null },
{ id: "sample_inv_1001", date: "2026-05-16", amountUsd: 0, status: "paid", description: "Community plan", hostedUrl: null },
];
/**
* TODO(billing): replace with `c2api.getSubscription()` → GET /org/subscription.
* Returns sample data so the settings UI has something real to render today.
*/
export async function getCurrentSubscription(): Promise<Subscription> {
return SAMPLE_SUBSCRIPTION;
}
/**
* TODO(billing): replace with `c2api.getUsageSummary()` → GET /org/usage,
* computed server-side from `nodes` count + org member count.
*/
export async function getUsageSummary(): Promise<UsageSummary> {
const sub = await getCurrentSubscription();
const plan = getPlan(sub.planId);
const now = new Date();
const periodStart = new Date(now.getFullYear(), now.getMonth(), 1);
const periodEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0);
return {
seatsUsed: sub.seatsUsed,
seatsLimit: plan.limits.seats,
nodesUsed: sub.nodesUsed,
nodesLimit: plan.limits.nodes,
periodStart: periodStart.toISOString(),
periodEnd: periodEnd.toISOString(),
};
}
/**
* TODO(billing): replace with `c2api.getInvoices()` → GET /org/invoices,
* which on the backend would list Stripe Invoices for the org's customer id
* and map them to this shape (hostedUrl = Stripe's `hosted_invoice_url`).
*/
export async function getInvoices(): Promise<Invoice[]> {
return SAMPLE_INVOICES;
}
/**
* TODO(billing): replace with `c2api.createCheckoutSession(planId, interval)`
* → POST /org/billing/checkout-session, which creates a Stripe Checkout
* Session server-side (secret key never leaves the server) and returns
* `{ url }`. Frontend then does `window.location.href = url`.
*
* Throws here — there is no live checkout to redirect to.
*/
export async function createCheckoutSession(_planId: PlanId, _interval: BillingInterval): Promise<{ url: string }> {
throw new Error(
"Checkout is not wired to a payment processor yet. This is a demo build — " +
"no card will be charged. See lib/billing.ts for the integration TODO."
);
}
/**
* TODO(billing): replace with `c2api.createBillingPortalSession()` →
* POST /org/billing/portal-session, which creates a Stripe Billing Portal
* session server-side and returns `{ url }` for redirect. The portal is
* where a real integration would let customers update payment methods,
* cancel, or download invoices — avoids building that UI ourselves.
*/
export async function createBillingPortalSession(): Promise<{ url: string }> {
throw new Error(
"Billing portal is not wired to a payment processor yet. See lib/billing.ts for the integration TODO."
);
}
/**
* TODO(billing): replace with `c2api.previewPlanChange(planId, interval)` →
* GET /org/billing/preview?plan=…, which on the backend would call the
* processor's upcoming-invoice/proration preview endpoint.
* Returns a rough client-side estimate so the upgrade/downgrade UI has
* something to show; not a real proration calculation.
*/
export async function previewPlanChange(planId: PlanId, interval: BillingInterval): Promise<{ dueTodayUsd: number; nextAmountUsd: number }> {
const plan = getPlan(planId);
const price = interval === "annual" ? plan.priceAnnualUsd : plan.priceMonthlyUsd;
return { dueTodayUsd: price ?? 0, nextAmountUsd: price ?? 0 };
}
+6
View File
@@ -30,6 +30,12 @@ export const c2api = {
if (ppmOverride !== undefined) params.set("ppm_override", String(ppmOverride));
return request(`/nodes/${nodeId}/config/${systemId}?${params}`, { method: "POST" });
},
ackOverride: (nodeId: string, timeoutMinutes: number = 1440) =>
request(`/nodes/${nodeId}/override/ack`, { method: "POST", body: JSON.stringify({ timeout_minutes: timeoutMinutes }) }),
resetOverride: (nodeId: string) =>
request(`/nodes/${nodeId}/override/reset`, { method: "POST" }),
updateNode: (id: string, body: { node_type?: string; enforce_override_timeout?: boolean }) =>
request(`/nodes/${id}`, { method: "PATCH", body: JSON.stringify(body) }),
// Systems
getSystems: () => request<unknown[]>("/systems"),
+36
View File
@@ -0,0 +1,36 @@
/**
* Shared severity ladder for calls and incidents: routine < minor < moderate < major.
* Every call/incident gets one of these four. `"unknown"` (and any other
* unrecognized value) is a legacy value still present on historical docs —
* treat it as "no severity", not as a fifth level.
*/
import type { ReactElement } from "react";
export type Severity = "routine" | "minor" | "moderate" | "major";
export const SEVERITY_ORDER: Record<Severity, number> = { routine: 0, minor: 1, moderate: 2, major: 3 };
export const SEVERITY_LABEL: Record<Severity, string> = { routine: "Routine", minor: "Minor", moderate: "Moderate", major: "Major" };
export const SEVERITY_COLORS: Record<Severity, string> = {
routine: "bg-gray-800/40 text-gray-600",
minor: "bg-gray-800 text-gray-400",
moderate: "bg-orange-950 text-orange-400",
major: "bg-red-950 text-red-400",
};
export function isKnownSeverity(s: string | null | undefined): s is Severity {
return s === "routine" || s === "minor" || s === "moderate" || s === "major";
}
/** Legacy/unset severities rank below `routine` so a recency-sorted list never confuses them with a real (low) severity. */
export function severityRank(s: string | null | undefined): number {
return isKnownSeverity(s) ? SEVERITY_ORDER[s] : -1;
}
export function severityBadge(severity: string | null | undefined): ReactElement | null {
if (!isKnownSeverity(severity)) return null;
return (
<span className={`text-xs font-mono px-2 py-0.5 rounded-full ${SEVERITY_COLORS[severity]}`}>
{SEVERITY_LABEL[severity]}
</span>
);
}
+14
View File
@@ -52,6 +52,11 @@ export interface NodeRecord {
approval_status: ApprovalStatus | null;
hardware_preset?: string;
ppm_override?: number | null;
node_type?: string;
enforce_override_timeout?: boolean;
is_overridden?: boolean;
override_system_id?: string | null;
override_timeout_at?: string | null;
}
export interface VocabularyPendingTerm {
@@ -88,6 +93,13 @@ export interface CallRecord {
freq: number | null;
started_at: string;
ended_at: string | null;
/** Private gs:// object location. Present on the Firestore doc; not playable. */
audio_gcs_uri?: string | null;
/**
* Short-lived playback link. Minted per read by the API — only populated on
* calls fetched via c2api, never on docs read straight from Firestore.
* On pre-fix docs this holds a legacy (unplayable) gs:// URI instead.
*/
audio_url: string | null;
transcript: string | null;
transcript_corrected: string | null;
@@ -99,6 +111,8 @@ export interface CallRecord {
location: string | null;
tags: string[];
status: "active" | "ended";
/** Four-level ladder: routine | minor | moderate | major. Legacy docs may still carry "unknown". */
severity?: string | null;
// Correlation debug — written by the correlator, present after a call is linked
corr_path?: string | null;
corr_score?: number | null;
+12
View File
@@ -1,9 +1,21 @@
import { NextRequest, NextResponse } from "next/server";
// Public marketing pages — no session required. Keep this in sync with
// MARKETING_PATHS in components/ChromeSwitcher.tsx (that one picks page
// chrome; this one decides whether to redirect at all).
const PUBLIC_PATHS = new Set(["/", "/features", "/pricing", "/faq"]);
// NOTE: this is a UX redirect only, not a security boundary — it just checks
// a client-set cookie's presence. Real enforcement is server-side, in
// drb-c2-core/app/internal/auth.py. See CLAUDE.md.
export function middleware(request: NextRequest) {
const session = request.cookies.get("drb_session");
const { pathname } = request.nextUrl;
if (PUBLIC_PATHS.has(pathname)) {
return NextResponse.next();
}
if (pathname === "/login") {
if (session) return NextResponse.redirect(new URL("/dashboard", request.url));
return NextResponse.next();
+22
View File
@@ -10,6 +10,28 @@ const config: Config = {
extend: {
fontFamily: {
mono: ["ui-monospace", "Cascadia Code", "Source Code Pro", "monospace"],
sans: ["ui-sans-serif", "system-ui", "-apple-system", "Segoe UI", "Roboto", "Helvetica Neue", "Arial", "sans-serif"],
},
// Marketing/product type scale — used by the (marketing) surface and
// settings shell so headings read as a deliberate hierarchy rather than
// ad-hoc text-xl/text-2xl bumps.
fontSize: {
"display-lg": ["3.5rem", { lineHeight: "1.05", letterSpacing: "-0.02em", fontWeight: "700" }],
"display": ["2.75rem", { lineHeight: "1.1", letterSpacing: "-0.02em", fontWeight: "700" }],
"display-sm": ["2.125rem", { lineHeight: "1.15", letterSpacing: "-0.01em", fontWeight: "700" }],
},
boxShadow: {
card: "0 1px 2px 0 rgb(0 0 0 / 0.4), 0 1px 3px 0 rgb(0 0 0 / 0.3)",
"card-hover": "0 4px 12px 0 rgb(0 0 0 / 0.45), 0 2px 4px 0 rgb(0 0 0 / 0.3)",
glow: "0 0 0 1px rgb(99 102 241 / 0.4), 0 0 24px 0 rgb(99 102 241 / 0.25)",
},
animation: {
"fade-in": "fade-in 0.4s ease-out",
"slide-up": "slide-up 0.4s ease-out",
},
keyframes: {
"fade-in": { from: { opacity: "0" }, to: { opacity: "1" } },
"slide-up": { from: { opacity: "0", transform: "translateY(8px)" }, to: { opacity: "1", transform: "translateY(0)" } },
},
},
},
-14
View File
@@ -1,14 +0,0 @@
# Managed by CI — deployed to /etc/caddy/Caddyfile on the server.
# Caddy handles TLS automatically via Let's Encrypt.
api.{$DRB_DOMAIN} {
reverse_proxy localhost:8888 {
header_up X-Forwarded-For {remote_host}
}
}
app.{$DRB_DOMAIN} {
reverse_proxy localhost:3000 {
header_up X-Forwarded-For {remote_host}
}
}
@@ -0,0 +1,19 @@
---
# The "Deploy Caddyfile" task notifies this. Without this file the play aborts
# with "The requested handler 'Reload Caddy' was not found" — notify does not
# tolerate a missing handler.
#
# reloaded, not restarted: caddy reload swaps config with zero downtime and
# keeps existing TLS certs/connections; a restart drops every in-flight request.
- name: Reload Caddy
ansible.builtin.systemd_service:
name: caddy
state: reloaded
enabled: true
# For the mqtt-cert-sync.service/.path unit files — systemd won't pick up a
# new/changed unit file until the manager config is reloaded.
- name: Reload systemd daemon
ansible.builtin.systemd_service:
daemon_reload: true
+96 -2
View File
@@ -2,12 +2,19 @@
# First-time setup: clone repo, write secrets, pull pre-built images and start stack.
# Images are built and pushed by Gitea CI — this role never builds on the VM.
- name: Clone repo (skipped if already present)
# update: true (was false) — with update disabled, every re-run of this playbook
# redeployed the code that happened to be on the VM at first clone, so any fix
# pushed to main was invisible here and the only way to ship one was CI or a
# manual pull. force: true discards local edits made on the VM; the templated
# .env files and Caddyfile live outside git tracking, so nothing generated by
# this role is at risk.
- name: Clone or update repo
git:
repo: "{{ repo_url }}"
dest: "{{ app_dir }}"
version: main
update: false
update: true
force: true
become: false
- name: Set ownership of app directory
@@ -59,6 +66,93 @@
mode: "0644"
notify: Reload Caddy
# --- MQTT TLS cert sync (Caddy -> mosquitto) --------------------------------
# See MQTT-PUBLIC-AUTH-PLAN.md "Infra". mosquitto reads its cert from this
# directory (docker-compose.prod.yml bind-mounts it in); nothing but root can
# read Caddy's own cert storage, so a systemd path unit + oneshot service
# copies a readable copy out and SIGHUPs the broker on every change.
# root:1883 0750, not root:root 0700. The stock eclipse-mosquitto entrypoint
# drops privileges to the in-image `mosquitto` user (uid/gid 1883), so a
# root-only directory makes the broker fail to read its own cert and
# crash-loop: "Unable to load server certificate ... Permission denied".
# The host has no `mosquitto` user, hence the numeric gid.
- name: Create mosquitto certs directory
file:
path: /opt/drb/mosquitto-certs
state: directory
owner: root
group: "1883"
mode: "0750"
# dynamic-security.json (node credentials — see app/internal/dynsec.py)
# lives here, and mosquitto WRITES it, so this must be owned by the uid the
# broker actually runs as (1883), not root. The earlier assumption that the
# container runs as root was wrong — the image's entrypoint drops privileges
# to the `mosquitto` user, which a real deploy proved by failing to read a
# root-owned cert. Same numeric-gid reasoning as the certs directory above.
- name: Create mosquitto data directory
file:
path: /opt/drb/mosquitto-data
state: directory
owner: "1883"
group: "1883"
mode: "0700"
# recurse so an existing root-owned dynamic-security.json / mosquitto.db
# left behind by the earlier root-owned deploy gets fixed too — chowning
# only the directory would leave the broker unable to rewrite them.
recurse: true
- name: Deploy MQTT cert-sync script
template:
src: sync-mqtt-cert.sh.j2
dest: /opt/drb/sync-mqtt-cert.sh
owner: root
group: root
mode: "0700"
- name: Deploy MQTT cert-sync systemd service unit
template:
src: mqtt-cert-sync.service.j2
dest: /etc/systemd/system/mqtt-cert-sync.service
owner: root
group: root
mode: "0644"
notify: Reload systemd daemon
- name: Deploy MQTT cert-sync systemd path unit
template:
src: mqtt-cert-sync.path.j2
dest: /etc/systemd/system/mqtt-cert-sync.path
owner: root
group: root
mode: "0644"
notify: Reload systemd daemon
# Flush the daemon-reload handler now (rather than at end-of-play) so the
# path unit is registered and actively watching BEFORE the "Reload Caddy"
# handler below fires and Caddy goes to obtain the mqtt.{{ domain }} cert —
# otherwise the unit could miss the very first PathChanged event.
- name: Apply pending handlers (systemd daemon-reload)
meta: flush_handlers
- name: Enable and start MQTT cert-sync path unit
ansible.builtin.systemd_service:
name: mqtt-cert-sync.path
state: started
enabled: true
# Best-effort initial sync in case Caddy already has a cert from a previous
# run (e.g. re-running this playbook after the first successful deploy) —
# the path unit only fires on a CHANGE, so it won't pick up a cert that was
# already sitting there unchanged before it started watching. Non-fatal if
# nothing exists yet (first-ever run, before Caddy has issued anything).
- name: Best-effort initial MQTT cert sync
command: /opt/drb/sync-mqtt-cert.sh
register: _initial_sync
changed_when: "'copied cert' in _initial_sync.stdout"
failed_when: false
- name: Log in to container registry
command: >
docker login {{ vault_registry_host }}
@@ -1,12 +1,40 @@
# Managed by Ansible — do not edit manually on the server.
# Managed by Ansible — do not edit manually.
api.{{ domain }} {
# MQTT auth is no longer an HTTP backend c2-core exposes (it moved to
# mosquitto's own built-in dynamic-security plugin, administered over MQTT
# control topics — see app/internal/dynsec.py) — there is currently no
# /internal/* route in c2-core at all. This block stays anyway as defence
# in depth: c2-core's app-wide reverse_proxy below forwards every path by
# default, so this guarantees any FUTURE /internal/* route (or a
# regression that reintroduces one) is still unreachable from the public
# internet unless someone also deliberately deletes this block. `route`
# forces top-to-bottom evaluation instead of Caddy's automatic directive
# sorting, so this is guaranteed to run before reverse_proxy.
route {
respond /internal/* 404
reverse_proxy localhost:8888 {
header_up X-Forwarded-For {remote_host}
}
}
}
app.{{ domain }} {
# mqtt.{{ domain }} has no application behind it — mosquitto's TLS listener
# (8883) is a raw MQTT socket, not HTTP, so Caddy can't reverse_proxy to it.
# This block's only job is to make Caddy request+manage a Let's Encrypt cert
# for the name via ACME HTTP-01, which infra/ansible's cert-sync unit then
# copies out to mosquitto. The DNS A record for mqtt.{{ domain }} must exist
# before this runs, or ACME issuance fails (see MQTT-PUBLIC-AUTH-PLAN.md).
mqtt.{{ domain }} {
respond 404
}
# Frontend is served on the bare domain, not app.{{ domain }}: only drb and api
# have public DNS records. A vhost for a name with no A record still starts,
# but Caddy retries ACME against it forever and logs a failure each time.
# To move it to app.{{ domain }}, create the A record first, then change this
# line — the reverse_proxy target stays the same either way.
{{ domain }} {
reverse_proxy localhost:3000 {
header_up X-Forwarded-For {remote_host}
}
@@ -5,16 +5,34 @@ MQTT_PORT=1883
MQTT_USER={{ vault_mqtt_c2_user }}
MQTT_PASS={{ vault_mqtt_c2_pass }}
# Same value as mosquitto's MOSQUITTO_DYNSEC_PASSWORD (root.env.j2) — lets
# c2-core log in as the dynsec plugin's built-in "admin" client to
# administer node credentials. See app/internal/dynsec.py.
MQTT_DYNSEC_ADMIN_PASS={{ vault_mqtt_dynsec_admin_pass }}
# No GCP_CREDENTIALS_PATH — the VM uses Application Default Credentials
# via the GCE metadata server. The Terraform IAM bindings grant the required roles.
# NOTE: because there is no service-account key file here, c2-core cannot mint
# GCS *signed* URLs. Call audio is therefore served through c2-core's own
# /media route (app/routers/media.py) rather than direct-from-bucket links.
FIRESTORE_DATABASE={{ vault_firestore_database }}
GCS_BUCKET={{ vault_gcs_bucket }}
# Absolute origin for call-audio playback links. The browser fetches <audio src>
# directly, so a relative path would resolve against the frontend origin
# (https://{{ domain }}) instead of the API's.
PUBLIC_API_URL=https://api.{{ domain }}
OPENAI_API_KEY={{ vault_openai_api_key }}
GOOGLE_MAPS_API_KEY={{ vault_google_maps_api_key }}
GEMINI_API_KEY={{ vault_gemini_api_key }}
SERVICE_KEY={{ vault_service_key }}
NODE_API_KEY={{ vault_node_api_key }}
ENROLLMENT_TOKEN={{ vault_enrollment_token }}
CORS_ORIGINS=["https://app.{{ domain }}"]
# Bare domain, not app.<domain>: the frontend is served on {{ domain }} itself
# (see Caddyfile.j2 — only api. and the bare name have DNS records). This said
# app.{{ domain }} while the browser origin was https://{{ domain }}, so every
# frontend call to the API would have failed CORS. If the frontend ever moves
# to app.{{ domain }}, change this at the same time.
CORS_ORIGINS=["https://{{ domain }}"]
@@ -0,0 +1,18 @@
# Managed by Ansible — do not edit manually.
#
# Watches Caddy's on-disk cert for mqtt.{{ domain }} and fires
# mqtt-cert-sync.service on every change (initial issuance + every renewal).
# See sync-mqtt-cert.sh.j2 for the "unverified path" caveat — if Caddy's
# storage layout doesn't match, this unit simply never fires and mosquitto
# keeps using its self-signed placeholder cert (see mosquitto/entrypoint.sh)
# rather than failing loudly, so check `systemctl status mqtt-cert-sync.path`
# after the first deploy.
[Unit]
Description=Watch for a renewed MQTT TLS cert from Caddy (mqtt.{{ domain }})
[Path]
PathChanged=/var/lib/caddy/.local/share/caddy/certificates/acme-v02.api.letsencrypt.org-directory/mqtt.{{ domain }}/mqtt.{{ domain }}.crt
Unit=mqtt-cert-sync.service
[Install]
WantedBy=multi-user.target
@@ -0,0 +1,11 @@
# Managed by Ansible — do not edit manually.
#
# Runs as root: it needs read access to Caddy's 0700 cert storage AND the
# ability to run `docker compose kill -s HUP` regardless of docker group
# membership. Triggered by mqtt-cert-sync.path, not run standalone.
[Unit]
Description=Sync Caddy-issued MQTT TLS cert to mosquitto and reload
[Service]
Type=oneshot
ExecStart=/opt/drb/sync-mqtt-cert.sh
@@ -1,10 +1,26 @@
# Top-level docker-compose environment — MQTT credentials and registry prefix.
# Managed by Ansible. Do not edit manually.
#
# The passwords are $-escaped ($ -> $$). Compose INTERPOLATES this file, so a
# raw "$fP" in a password is read as the variable $fP, warned about, and
# replaced with an empty string. The env_file templates (c2-core.env.j2 etc.)
# are NOT interpolated, so they keep the literal value — which means an
# unescaped $ here silently gives mosquitto and c2-core two different
# passwords and MQTT auth fails. Compose collapses $$ back to a single $, so
# both sides end up with the real password.
# Do not add the same escaping to the env_file templates; it would be literal.
MQTT_C2_USER={{ vault_mqtt_c2_user }}
MQTT_C2_PASS={{ vault_mqtt_c2_pass }}
MQTT_NODE_USER={{ vault_mqtt_node_user }}
MQTT_NODE_PASS={{ vault_mqtt_node_pass }}
MQTT_C2_PASS={{ vault_mqtt_c2_pass | replace('$', '$$') }}
# Seeds mosquitto's built-in dynamic-security plugin's one-time "admin"
# bootstrap client on first boot (read directly by the plugin's C code via
# getenv — see app/internal/dynsec.py). Must be >=12 chars (plugin-enforced
# minimum). c2-core needs this SAME value as MQTT_DYNSEC_ADMIN_PASS in its
# own env (c2-core.env.j2) to log in as "admin" and administer node
# credentials — kept as one vault var (vault_mqtt_dynsec_admin_pass) so the
# two can't drift.
MOSQUITTO_DYNSEC_PASSWORD={{ vault_mqtt_dynsec_admin_pass | replace('$', '$$') }}
# Container registry prefix — docker compose uses this for image: ${REGISTRY}/name:latest
REGISTRY={{ vault_registry }}
@@ -0,0 +1,56 @@
#!/bin/bash
# Managed by Ansible — do not edit manually.
#
# Copies Caddy's managed TLS cert for mqtt.{{ domain }} out of Caddy's
# storage (root:caddy, 0700 — nothing else can read it) into a location the
# mosquitto container can read, then SIGHUPs the broker so it picks up the
# new cert without a full restart.
#
# Triggered by mqtt-cert-sync.path.j2 (a systemd path unit) watching the
# source cert file for changes — a path unit rather than cron so this fires
# on the actual write instead of racing a polling interval.
#
# CONFIRMED 2026-08-16 against a real issuance on drb-server: this path is
# correct, and the copied cert came out as CN=mqtt.drb.cusano.net issued by
# Let's Encrypt. Was previously flagged unverified.
#
# UNVERIFIED: mosquitto 2.x reloading TLS certs on SIGHUP without dropping
# connections is documented upstream but untested here. If listener 8883
# doesn't pick up the new cert (check `docker compose logs mosquitto` after
# a sync), replace the `kill -s HUP` line below with a full
# `docker compose ... restart mosquitto` instead.
set -euo pipefail
DOMAIN="mqtt.{{ domain }}"
CADDY_CERT_DIR="/var/lib/caddy/.local/share/caddy/certificates/acme-v02.api.letsencrypt.org-directory/${DOMAIN}"
DEST_DIR="/opt/drb/mosquitto-certs"
APP_DIR="{{ app_dir }}"
SRC_CERT="${CADDY_CERT_DIR}/${DOMAIN}.crt"
SRC_KEY="${CADDY_CERT_DIR}/${DOMAIN}.key"
if [ ! -f "$SRC_CERT" ] || [ ! -f "$SRC_KEY" ]; then
echo "sync-mqtt-cert: source cert/key not found yet at $CADDY_CERT_DIR — Caddy may not have issued it yet." >&2
exit 0
fi
mkdir -p "$DEST_DIR"
# Copy, don't symlink — nothing outside the caddy user can read the
# originals (0700-owned), so mosquitto (running as a different container/
# user) needs its own readable copy, not a pointer to an unreadable file.
cp "$SRC_CERT" "$DEST_DIR/mqtt.crt"
cp "$SRC_KEY" "$DEST_DIR/mqtt.key"
# Ownership matters: the stock eclipse-mosquitto entrypoint drops privileges
# to the in-image `mosquitto` user (uid/gid 1883) — the broker does NOT run
# as root, despite what an earlier note in DEFERRED.md claimed. Proof from a
# real deploy: "running mosquitto as user: mosquitto", immediately followed
# by "Unable to load server certificate ... Permission denied" on a
# 600 root:root cert. The host has no such user, so use the numeric gid.
# The cert is public material (0644); the key is group-read only (0640).
chown root:1883 "$DEST_DIR/mqtt.crt" "$DEST_DIR/mqtt.key"
chmod 644 "$DEST_DIR/mqtt.crt"
chmod 640 "$DEST_DIR/mqtt.key"
cd "$APP_DIR"
docker compose -f docker-compose.yml -f docker-compose.prod.yml kill -s HUP mosquitto
echo "sync-mqtt-cert: copied cert for ${DOMAIN} and sent SIGHUP to mosquitto."
+19 -2
View File
@@ -36,16 +36,33 @@
path: /swapfile
mode: "0600"
# mkswap refuses to touch a file that is already active as swap, so a
# re-run would fail here without this guard. The swap file survives
# reboots via the fstab entry below, so on any second run it IS active.
- name: Check whether the swap file is already active
command: swapon --show=NAME --noheadings
register: _active_swaps
changed_when: false
failed_when: false
- name: Format swap file
command: mkswap /swapfile
when: "'/swapfile' not in _active_swaps.stdout"
register: _mkswap
changed_when: _mkswap.rc == 0
# Guarded by the same check as mkswap above. The stderr test alone was not
# enough: an already-active swap file reports "Device or resource busy",
# not "already", so the original failed_when never matched it.
- name: Enable swap
command: swapon /swapfile
when: "'/swapfile' not in _active_swaps.stdout"
register: _swapon
failed_when: _swapon.rc != 0 and 'already' not in _swapon.stderr
changed_when: _swapon.rc == 0
failed_when: >
_swapon.rc is defined and _swapon.rc != 0
and 'already' not in _swapon.stderr
and 'busy' not in _swapon.stderr
changed_when: _swapon.rc is defined and _swapon.rc == 0
- name: Persist swap in fstab
lineinfile:
+22 -4
View File
@@ -4,15 +4,26 @@
# Edit later with:
# ansible-vault edit vault.yml
# DO NOT put a literal "$" in any value here. Docker compose interpolates the
# top-level .env, and depending on version it also interpolates env_file, so a
# password like "aB$fPx" is read as the variable $fPx and silently replaced
# with an empty string — on one side of the connection but not the other.
# That produced "MQTT connect refused: Not authorized" with no obvious cause.
# Generate with: openssl rand -hex 32 (hex output has no shell metacharacters)
# ── MQTT ─────────────────────────────────────────────────────────────────────
# No more shared node credential (vault_mqtt_node_user/pass) — nodes now
# authenticate as username=<node_id>, password=<their node_keys.api_key>,
# checked by mosquitto's built-in dynamic-security plugin (c2-core
# administers it — see app/internal/dynsec.py). See vault_enrollment_token
# below for how a node gets that key in the first place.
vault_mqtt_c2_user: drb-c2-core
vault_mqtt_c2_pass: "CHANGE_ME"
vault_mqtt_node_user: drb-node
vault_mqtt_node_pass: "CHANGE_ME"
vault_mqtt_dynsec_admin_pass: "CHANGE_ME" # openssl rand -hex 32 — must be >=12 chars, plugin-enforced minimum
# ── C2 Core ───────────────────────────────────────────────────────────────────
vault_service_key: "" # openssl rand -hex 32
vault_node_api_key: "" # openssl rand -hex 32
vault_enrollment_token: "" # openssl rand -hex 32 — fleet-wide, shared by every node's POST /nodes/enroll
vault_openai_api_key: ""
vault_google_maps_api_key: ""
vault_gemini_api_key: ""
@@ -22,7 +33,14 @@ vault_firestore_database: "c2-server"
# ── Gitea Container Registry ──────────────────────────────────────────────────
vault_registry_host: "git.vpn.cusano.net"
vault_registry_user: "logan"
vault_registry_token: "" # Gitea access token with package:write scope
vault_registry_token: "" # Gitea access token, READ-ONLY package scope.
# The VM only pulls (roles/deploy/tasks/main.yml:62-72);
# nothing here pushes. Pushing is CI's job and uses a
# separate write-scoped token (BUILD_TOKEN in Gitea
# repo secrets). Keep them separate: this token sits on
# an internet-facing VM, and a write-scoped one there
# would let an attacker publish a poisoned image that
# every future deploy and edge node would install.
vault_registry: "git.vpn.cusano.net/logan" # full image prefix
# ── Discord Bot ───────────────────────────────────────────────────────────────
+23 -14
View File
@@ -64,20 +64,25 @@ resource "google_compute_firewall" "allow_ssh" {
target_tags = ["drb-server"]
}
# MQTT is NOT exposed externally — edge nodes connect via WireGuard (see below)
# If you need to temporarily allow direct MQTT access for testing, uncomment and
# restrict source_ranges to your node IPs.
#
# resource "google_compute_firewall" "allow_mqtt" {
# name = "drb-allow-mqtt"
# network = "default"
# allow {
# protocol = "tcp"
# ports = ["8883"] # TLS MQTT, not 1883
# }
# source_ranges = ["YOUR_NODE_CIDR"]
# target_tags = ["drb-server"]
# }
# MQTT is now publicly exposed on 8883 (TLS) — nodes get deployed to
# arbitrary locations by arbitrary people, so there is no fixed CIDR to
# restrict this to (WireGuard-per-node was evaluated and rejected; see
# MQTT-PUBLIC-AUTH-PLAN.md). Security is enforced by mosquitto-go-auth
# (per-node api_key over TLS), not by network ACL. 1883 (plaintext) is
# intentionally NOT opened here — it stays on the docker bridge for
# c2-core's own connection only.
resource "google_compute_firewall" "allow_mqtt" {
name = "drb-allow-mqtt"
network = "default"
allow {
protocol = "tcp"
ports = ["8883"] # TLS MQTT only, not 1883
}
source_ranges = ["0.0.0.0/0"]
target_tags = ["drb-server"]
}
# ---------------------------------------------------------------------------
# Compute Engine VM
@@ -185,5 +190,9 @@ resource "google_storage_bucket" "audio" {
# After terraform apply, add these A records in Route 53:
# app.drb.cusano.net → server_ip output
# api.drb.cusano.net → server_ip output
# mqtt.drb.cusano.net → server_ip output — MUST exist before the ansible
# deploy that adds the Caddy
# mqtt.<domain> block, or ACME
# issuance for it fails.
# Or use a single wildcard: *.drb.cusano.net → server_ip
# ---------------------------------------------------------------------------
+11 -1
View File
@@ -3,12 +3,22 @@ output "server_ip" {
description = "Static external IP of the DRB server VM"
}
# Bare domain, not app.<domain> — the frontend is served on the domain itself
# (see infra/ansible/roles/deploy/templates/Caddyfile.j2). Only the bare name,
# api. and mqtt. have DNS records; app. has never resolved.
output "app_url" {
value = "https://app.${var.domain}"
value = "https://${var.domain}"
description = "Frontend / portal URL"
}
output "api_url" {
value = "https://api.${var.domain}"
description = "c2-core REST API URL"
}
output "mqtt_host" {
value = "mqtt.${var.domain}"
description = "Broker hostname edge nodes connect to on TCP 8883 (TLS)"
}
output "project_number" {