Compare commits

..
Author SHA1 Message Date
Logan CusanoandClaude Opus 5 427d2a9f37 Say so loudly when the OpenAI account can no longer be billed
Build & Deploy / Build & push images (push) Successful in 4m6s
Build & Deploy / Deploy to VM (push) Failing after 2m56s
Transcription is the top of the pipeline and it fails soft: any exception logs
a WARNING, returns None, and upload.py carries on. That is the right behaviour
for a network blip and exactly the wrong behaviour for an unpayable account,
because with no transcript there is no extraction, no correlation and no
incident -- the system keeps accepting calls and quietly stores empty ones,
which looks like quiet radio traffic rather than an outage.

This is the third instance of the same failure mode today. The Gemini
correlator was down first on a retired model ID and then on a depleted
balance, and in both cases the only signal was a per-call WARNING that read as
noise. The OpenAI balance is low enough that this one is a matter of when.

Billing-shaped errors (insufficient_quota, billing, credit, quota exceeded)
now log once at ERROR, name what is dead downstream, and link the top-up page.
Everything else keeps the existing per-call WARNING.

No new environment variables, so CI deploys this without an ansible run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 20:41:45 -04:00
Logan CusanoandClaude Opus 5 83416fe169 Split platform-admin from org-owner, hide Trips from non-founding orgs
SAAS_PLAN.md B7. "admin" meant two different things before this: platform
operator (SAAS_PLAN.md's own framing) and, by accident of how
app/settings/layout.tsx was gated, the only role that could ever reach an
org's own billing/members/node-ownership settings. A paying customer who is
their own org's owner couldn't reach their own Settings page - the gate
checked isAdmin, which only platform admins ever have.

settings/layout.tsx now admits org_role === "owner" as well as platform
admins (isAdmin stays valid too, for support access to any org's
settings). Nav.tsx shows the Settings link on the same condition, and moves
Admin (the platform-operator screens: feature flags, users, audit,
correlation debug) out of the customer-facing link group entirely - it was
already gated server-side, this is just the nav no longer implying it's
part of the product.

Trips - an internal utility feature riding along on this stack, not a
tenant-scoped product surface (see [[trips-feature-intentional]]) - drops
out of the customer-facing viewer link group and only shows for the
founding org (new lib/tenancy.ts mirrors app/internal/tenancy.py's
FOUNDING_ORG_ID) or a platform admin, matching the mutation-route gating
routers/trips.py already got in the backend tenancy commit. Reads stay
open to any signed-in user, same as before - trips' own visibility model
(public/private per trip) predates and is unrelated to org tenancy, and
restricting it further wasn't asked for.

Also closes two DEFERRED.md items now that they have somewhere to write to:
app/settings/organization's "Save changes" button now actually calls
c2api.getOrg()/updateOrg() (routers/org.py, shipped in the backend tenancy
commit) instead of being permanently disabled. app/settings/nodes gained an
EnrollmentTokensPanel (mint/list/revoke against the same commit's
/org/enrollment-tokens routes) - without this, B2b's whole point (a
customer enrolls their own node with their own token instead of an
admin-issued key) had no way to actually be used outside a raw API call.

Left alone, and written up as new DEFERRED.md entries instead of guessed
at: node/system *write* routes (approve, create, delete) stay
platform-admin-only rather than being loosened to org owner/operator - a
real gap per SAAS_PLAN.md 2.4, but a separate authorization design that the
plan's 12-item build order doesn't enumerate. And settings/members +
settings/nodes' ownership table both still call GET /admin/users
(platform-admin-only) - a pure org owner who reaches the page via this
commit's gate will get 403s from it. Today's only real user is also a
platform admin, so this is invisible until a second, non-admin org owner
exists.

Typecheck: clean (tsc --noEmit via the WSL-native ~/drb-frontend copy).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 20:39:16 -04:00
Logan CusanoandClaude Opus 5 1b4ed0d09c Ship /terms, /privacy, and /waitlist as structure, not finished pages
SAAS_PLAN.md B5/B6, narrowed: no Stripe/pricing/tier work of any kind this
pass (a mid-build correction from the business side landed while this was
in progress - the commercial model, SAAS_PLAN.md section 6.1, is still
undecided), so app/pricing and lib/billing.ts's PLANS are untouched here.
What's left of B5/B6 without that - real legal pages and a working
waitlist - still ships.

app/terms/page.tsx and app/privacy/page.tsx are section scaffolding, not
legal text. Every section is a TODO(legal) note describing what that
section needs to cover, and the page leads with a "Draft - not yet in
force" banner. This isn't caution for its own sake: DRB records, stores,
and transcribes public-safety radio traffic, and recording/rebroadcast
legality varies by state (SAAS_PLAN.md section 6.3) - an agent-generated
draft here would be actively wrong to publish, not just unpolished. Both
were pre-added to middleware.ts's PUBLIC_PATHS and ChromeSwitcher's
MARKETING_PATHS two commits ago; MarketingFooter now links both.

app/waitlist/page.tsx is a real, working form against the already-shipped
POST /waitlist - email + optional org name/note, no plan or price
mentioned anywhere on it, matching the backend route's own scope (rate
limited by source IP, not coupled to any tier). Linked from
MarketingFooter as "Request access," not from the pricing page - pricing
CTAs stay exactly as they were.

Typecheck: clean (tsc --noEmit via the WSL-native ~/drb-frontend copy).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 20:38:49 -04:00
Logan CusanoandClaude Opus 5 4842725a03 Escalate a depleted Gemini balance the same way as a dead model ID
Build & Deploy / Build & push images (push) Successful in 4m8s
Build & Deploy / Deploy to VM (push) Successful in 2m2s
Correcting the model IDs got past the 404s and straight into 429 "Your
prepayment credits are depleted" on every call, so the LLM correlation tier is
still down -- same symptom, different cause, and the previous commit would have
logged it as an ordinary per-call WARNING and buried it exactly like the last
one.

An empty balance shares a status code with an ordinary rate limit but is the
opposite kind of problem: a rate limit clears on its own, a dead account never
does. The match is on the billing wording ("credits are depleted",
"prepayment", "billing") rather than on 429, so a burst of rate limiting still
reads as WARNING while an unpayable account escalates to the once-per-model
ERROR that names the fix.

The two escalation paths now share _log_tier_down, which is also where the
once-per-model suppression lives -- this code runs on every call at radio
traffic volume, so an ERROR per call would be its own kind of noise.

38 correlator tests still pass. No new environment variables, so CI deploys
this without an ansible run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 20:37:27 -04:00
Logan CusanoandClaude Opus 5 2a1d52b7af Add a real signup path instead of the accidental one
SAAS_PLAN.md 2.2: there was no /signup page. The only self-serve path was
Google sign-in on /login, which auto-provisions a Firebase account with no
role or org claim at all - previously that meant "viewer role, full read
access" the moment the AuthProvider cookie logic (previous commit) let it
through. That's closed now regardless; this commit is the other side of it
- giving people an actual way in.

app/signup/page.tsx: email/password (createUserWithEmailAndPassword) or
Google, same visual language as /login. It only creates the Firebase
account - org naming is deliberately not on this page, so every path that
produces an account with no org (this one, and Google-via-/login) converges
on the same next screen.

app/onboarding/page.tsx: that screen. Shown to any signed-in user with no
orgId (ChromeSwitcher's redirect, previous commit), collects an org name,
calls the new c2api.signup() -> POST /auth/signup (routers/links.py,
already shipped), then refreshClaims() to force-refetch the ID token so
orgId picks up immediately and the same redirect effect sends them on to
/dashboard - no manual reload needed.

lib/c2api.ts also gained getOrg/updateOrg and the enrollment-token
mint/list/revoke calls (routers/org.py, already shipped on the backend)
and joinWaitlist (routers/waitlist.py) - none consumed yet, wired in ahead
of the settings/legal commits that use them so this stays one add per
concept rather than scattering client additions across later commits.

/login gained a "Don't have an account? Sign up" link to /signup. This is
signup plumbing, not marketing copy - pricing/plan copy (app/pricing,
lib/billing.ts) is untouched in this pass, that's a separate, still-open
decision (SAAS_PLAN.md section 6).

Typecheck: clean (tsc --noEmit via the WSL-native ~/drb-frontend copy).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 20:34:04 -04:00
Logan CusanoandClaude Opus 5 c7f985df42 Scope every Firestore hook to org_id and stop granting sessions to nobody's org
Frontend half of SAAS_PLAN.md B2/B3. The backend commits so far (org_id
stamping, Firestore rules) don't protect anything by themselves - every
hook in lib/use*.ts reads Firestore directly from the browser
(onSnapshot(collection(db, ...))), which is why B1's rules commit called
this out as the actual read path in the first place. Until these hooks
filter by org_id, the rules just turn "any signed-in user sees everything"
into "any signed-in user sees nothing" the moment they're deployed, because
nothing supplies the org_id the rules now require.

useCalls (all three exports), useIncidents (useIncidents +
useActiveIncidents), useNodes, useSystems, and useAlerts (both exports) now
pull orgId from AuthProvider and add where("org_id","==",orgId) to their
query. If orgId is falsy - not yet resolved, or the account genuinely has
no org - each hook returns empty rather than falling back to an unfiltered
query, which would silently reopen the exact leak this closes for anyone
whose claim hasn't loaded yet. useIncident/useNodes single-doc-by-id reads
and useTrips are intentionally untouched: single-doc reads are already
covered by the rules directly, and trips has no org_id at all (see the
previous commit's trips.py gating - it's staying founding-org-only via B7,
not becoming tenant-scoped).

AuthProvider grew orgId/orgRole state (read from the org_id/org_role custom
claims POST /auth/signup sets) and a refreshClaims() escape hatch for the
signup flow to force a claims refetch after provisioning. The load-bearing
change is in when it sets the drb_session cookie: only when a claim carries
org_id. A signed-in user with no org - the accidental-signup hole
SAAS_PLAN.md 2.2/2.3 flagged, where Google sign-in on /login auto-creates a
Firebase account with no role or org claim at all - now gets no cookie,
which starts them at "no data, by construction" rather than "viewer role,
full read access" once combined with the rules deployed earlier.

ChromeSwitcher carries the other half of that guard: a signed-in user with
no orgId, anywhere outside the marketing pages, gets redirected to
/onboarding (added to the frontend in the next commit) instead of letting
every page's data hooks just quietly return empty forever. middleware.ts
adds /signup and /onboarding to a new no-cookie-gate list, since
AuthProvider's cookie logic means an unprovisioned user by definition has
no drb_session cookie - gating those two routes on it would bounce exactly
the users who need them back to /login before the client-side redirect
above ever runs. /terms and /privacy (next-next commit) are pre-added to
both PUBLIC_PATHS and ChromeSwitcher's MARKETING_PATHS here so that commit
doesn't need to touch routing files.

Typecheck: clean (tsc --noEmit via the WSL-native ~/drb-frontend copy).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 20:33:46 -04:00
Logan CusanoandClaude Opus 5 a3681ea698 Stamp org_id everywhere and gate every route that leaked across tenants
Build & Deploy / Build & push images (push) Successful in 4m5s
Build & Deploy / Deploy to VM (push) Successful in 1m59s
The previous commit shipped Firestore rules that reference an org_id claim
nothing issues yet, and an org_id filter nothing writes yet - this is the
commit that makes both real. Backend half of SAAS_PLAN.md B2/B2b/B2c.

Data model: organizations/{org_id} and org_members/{uid} are new
collections (models.py OrganizationRecord/OrgMember). org_id is now an
Optional field on NodeRecord, SystemRecord, CallRecord, IncidentRecord,
AlertRule, and AlertEvent - optional because every existing document
predates it; scripts/backfill_org_id.py (written, not run - it touches
production Firestore and Firebase Auth claims) is what closes that gap
later. plan_id/subscription_status/stripe_* on OrganizationRecord are
deliberately None: no billing or pricing model has been decided, so this is
a seam, not a promise. app/internal/tenancy.py holds FOUNDING_ORG_ID, the
org every pre-tenancy document and every legacy enrollment path resolves
into.

Where org_id comes from, end to end: a customer's node enrolls with a
per-org token (new enrollment_tokens/{token_hash} collection, minted via
POST /org/enrollment-tokens - new routers/org.py) instead of the old
fleet-wide ENROLLMENT_TOKEN, which still works as a fallback that resolves
to FOUNDING_ORG_ID so an already-deployed node's .env doesn't start failing
today. The node's org_id then flows onto every call it produces
(mqtt_handler.py's call_start/call_end, upload.py's /upload handler all
resolve it from the node doc), and onto every incident correlated from
those calls (incident_correlator.py's _create_incident/_create_master_incident).

That last one is the part that isn't just a read filter: _build_context's
`all_active = collection_list("incidents", status="active")` fed every
correlation candidate - fast-path talkgroup match, unit-continuity,
disambiguation - from the entire incidents collection, unscoped. Without
scoping it to the call's own org_id, a call from org A could link into an
incident org B already owns, which is a cross-tenant data merge at
correlation time, not just an over-broad read. Same shape of bug in
alerter.py: rule matching pulled every enabled alert_rule regardless of
org, so org A's keyword rule could fire (and POST org A's Discord webhook)
on org B's radio traffic. Both now resolve org_id from the call doc itself
rather than threading a new parameter through every caller.

Every list/get route gained org scoping via a new resolve_caller_org_id()
helper in internal/auth.py, which handles the three credential shapes those
routes accept (service key, node api_key, Firebase user) uniformly and
returns None (unrestricted) for the service key and platform admins -
preserving today's single-org behaviour exactly while closing the leak for
everyone else: GET /nodes, /systems, /calls, /incidents, /alerts,
/alert-rules. Write routes for nodes/systems (approve, create, delete, etc.)
deliberately stay platform-admin-only for now rather than being loosened to
org-owner/operator - that's a real gap called out in SAAS_PLAN.md 2.4's
"should be" column, but it's a separate authorization redesign the 12-item
build order doesn't actually enumerate, and doing it half-considered here
risked being exactly the "half-applied filter is worse than none" failure
mode the plan warns about. Today's founding org keeps working unchanged;
loosening node/system management to org owners is follow-up work, flagged
rather than guessed at.

Also closed the four spend/access-attack routes SAAS_PLAN.md B2c called out
by file and line: POST /calls/{id}/reprocess is now admin-only (was any
signed-in viewer looping the Whisper+Gemini pipeline for free - DEFERRED.md
had this as a live, independent-of-SaaS exploit) plus a per-call rate
limiter as a second guard; POST /alerts/{id}/acknowledge now checks the
alert's org_id; GET /admin/features moved from require_firebase_token to
require_admin_token; and trips.py's four unauthenticated mutation routes
(create_trip, update_trip_tags, create_event, update_event) are now
restricted to the founding org (or the bot's service key, or a platform
admin) - trips has no org_id of its own and isn't getting one, since
[[trips-feature-intentional]] says it's an internal utility riding along on
this stack, not a tenant-scoped product surface.

New public-but-scoped seam: POST /auth/signup (routers/links.py, alongside
the existing /auth/link* routes) provisions an organizations doc and an
owner org_members doc for a just-created Firebase user, then sets their
org_id/org_role claims - idempotent, so a double-submit doesn't create two
orgs. This is the only route that turns "has a Firebase account" into "can
read anything," which is what the frontend AuthProvider no-claim guard
(next commit) is built around.

Also new: GET/PATCH /org for the organization profile (closes the disabled
"Save changes" button noted in DEFERRED.md - there was no organizations
concept to save into before this), and POST /waitlist (public, source-IP
rate-limited, not coupled to any plan or tier - the commercial model is
still an open decision per SAAS_PLAN.md section 6).

Verified: all touched files py_compile clean; c2-core pytest is 69
passed / 10 failed, matching the documented pre-existing baseline exactly
(DEFERRED.md - mqtt_handler/node_sweeper test-vs-code drift, unrelated to
this change) - no new failures. flake8 --max-line-length=120 shows no new
violations in any touched file (checked each new E501/E221/E30x against
`git diff` to confirm it predates this commit); c2-core has no CI lint gate
regardless (CLAUDE.md - flake8 only runs in Client CI).

No new environment variables. Firestore composite indexes for the queries
this introduces were already shipped in the previous commit
(infra/firestore/firestore.indexes.json).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 20:28:37 -04:00
Logan CusanoandClaude Opus 5 74faa55396 Point correlation at models that still exist, and make a dead one loud
Both Gemini model IDs had been retired by Google. Production logs show every
correlation call 404ing -- "models/gemini-2.0-flash is no longer available" --
and gemini-1.5-pro is gone from the model list as well. Because a failed LLM
call falls back to the rules decision by design, nothing surfaced: the pipeline
kept producing incidents, so the LLM tier and the consensus tiebreak were dead
in production for an unknown number of days while correlation was being tuned.
Some of what recent tuning was reacting to was rules-only behaviour that was
never meant to run alone.

Cheap model becomes gemini-3.6-flash, which is the migration target named in
Google's own 404. Smart model becomes gemini-2.5-pro, the only stable Pro-tier
text model left; the tiebreak fires rarely and its value comes from being a
different, stronger model than the first pass, so a second Flash was not worth
the consensus it would give up. Model list checked against
https://ai.google.dev/gemini-api/docs/models on 2026-08-18.

The more important half is the logging. A per-call WARNING was the only signal,
and it is indistinguishable from an ordinary API hiccup, so a permanent
misconfiguration read as noise. Failures that look like a missing model (404,
"not found", "no longer available") now log once per model at ERROR, name the
config keys to change, and say plainly that correlation is running rules-only.
Transient errors keep the old per-call WARNING. Once per model, not once per
call, so the alert stays readable at radio traffic volume.

Gemini is used nowhere else in c2-core -- extraction, embeddings and summaries
all run on OpenAI -- so the blast radius was exactly the correlation LLM tier.

38 correlator tests still pass. No new environment variables: both model IDs
are config.py defaults and are not templated into any .env, so CI deploys this
without an ansible run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 20:28:15 -04:00
Logan CusanoandClaude Opus 5 c09cb72f66 Compare unit IDs by normalised key, not exact string
Build & Deploy / Build & push images (push) Successful in 4m6s
Build & Deploy / Deploy to VM (push) Successful in 2m32s
Dispatch audio names the same unit several ways within one conversation, and
every comparison in the correlator used exact string equality, so a follow-up
transmission from a unit already on an incident simply failed to find it. With
the creation gate no longer letting routine traffic open its own incident,
these stopped becoming junk incidents and started becoming orphans instead --
which is how they became visible. In the 01:05Z dump, five of eighteen orphans
were calls belonging to an incident that was open at that moment:

    "K-9A2"     vs "K-9-A-2"     punctuation
    "5-1-6"     vs "516"         digits read out individually
    "37"        vs "37th Post"   ordinal plus role word
    "11-Victor" vs "11 Victor"   hyphen vs space

_normalize_unit lowercases, drops punctuation and role words (post/unit/car),
strips ordinal suffixes, and joins the remaining tokens, so each pair above
collapses to one key. All six comparison sites now go through it: the two
fast-path debug reporters, unit-continuity candidate selection and its
reassignment check, the cross-talkgroup 2+ shared-unit test, and the
disambiguation scorer.

What it deliberately does NOT do is match a bare district letter -- "Adam" is
not treated as "6-Adam". Every district has an Adam, and collapsing them would
merge unrelated incidents across districts. That leaves a couple of the
observed orphans unlinked, which is the right trade: a missed link leaves an
orphan the re-correlation sweep retries three times, while a false link
corrupts an incident permanently and nothing walks it back.

Two smaller things fall out of the shared helper. Matches are reported as the
original spoken strings rather than the normalised keys, so corr_matched_units
stays readable in the debug view. And a unit made only of role words ("Post")
would normalise to the empty string and then compare equal to every other such
unit, so it falls back to the raw text -- tested, because that failure would be
silent and would merge aggressively.

Adds 13 cases: each observed pair, five pairs that must stay distinct, the
empty-key guard, match reporting, and an end-to-end check that the K-9A2 call
now links where it previously orphaned. 38 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 21:35:06 -04:00
Logan CusanoandClaude Opus 5 94ce9d48e2 Commit the Firestore security rules that were never in source control
SAAS_PLAN.md's review found the actual finding underneath "no multi-tenancy":
drb-frontend reads Firestore directly from the browser (every hook in lib/
does onSnapshot(collection(db, ...))), so drb-c2-core/app/internal/auth.py
is never in that read path at all. Whatever rules were protecting calls,
incidents, and nodes had been hand-set in the Firebase console -
unversioned, unreviewed, and invisible to anyone reading this repo.

Added infra/firestore/firestore.rules: deny-by-default, with every
tenant-scoped collection (nodes, systems, calls, incidents, alert_events,
alert_rules) gated on resource.data.org_id == request.auth.token.org_id, an
org_id claim that doesn't exist yet - the next commits add it. All client
writes stay denied; c2-core's admin SDK bypasses rules and remains the sole
writer, which was already the architecture. Secret-bearing collections
(node_keys, the new enrollment_tokens) are denied to clients outright rather
than org-scoped, since nothing should ever hand a raw credential to the
browser. trips/trip_events keep their current "signed-in users can read"
shape rather than being pulled into org scoping - that feature isn't
tenant-scoped in this pass (see B7), just hidden from non-founding-org users
in the UI.

Added infra/firestore/firestore.indexes.json for the composite indexes the
org_id-scoped queries will need once the frontend hooks add the equality
filter alongside their existing orderBy/range/array-contains clauses -
without these, those queries fail at runtime with a FAILED_PRECONDITION
"index required" error rather than at review time.

Also extended internal/firestore.py's collection_where() with optional
order_by/limit_to/start_after params (SAAS_PLAN.md item 1, a stated
prerequisite for B2: scoped queries need to stay ordered and bounded, and
the existing helper could only do unordered full-collection scans).
array_contains needed no new code - it was already a pass-through op string
to FieldFilter.

None of this is live yet. Deploying rules/indexes is a manual step
(firebase deploy --only firestore:rules,firestore:indexes --project
<project-id>, from infra/firestore/) - nothing in CI does this. Until it
runs, the console-configured rules are still what's actually enforced, and
these rules reference an org_id claim no token carries yet. Deploy this
alongside (not before) the org_id-stamping commits that follow, or every
read breaks for the current single-org deployment.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 21:31:09 -04:00
Logan CusanoandClaude Opus 5 7b5258cfdf Halve the tier-2 thin-call window, from 10 minutes to 5
Build & Deploy / Build & push images (push) Successful in 4m1s
Build & Deploy / Deploy to VM (push) Successful in 2m14s
With over-creation fixed, the incidents that remain are readable enough to
judge, and the ones that still do not make sense all fail the same way. A
content-free call attaches to the single active incident on its talkgroup if
that incident has been idle under tg_dispatch_thin_idle_minutes, and at 10
minutes that is long enough for the channel to have moved on to something
else. In the 00:30Z dump a "72 at Holland Station" incident absorbed a Grand
Central train-crew meet 9.6 minutes later, and a status check absorbed a
records lookup at 9.7.

Being the only candidate is not evidence. It means the channel was quiet,
which is exactly when guessing is weakest -- the single-candidate rule was
meant to avoid picking wrongly among several, not to license a match no other
signal supports.

Every correct thin attach in that dump was <= 3.4 minutes idle and every wrong
one was >= 8.2, so 5 separates them with room on both sides. Real
back-and-forth is unaffected: it runs through the 30-second tier-1 path, and
the observed conversational replies sit near zero. Tests pin both sides of the
new boundary at 4.9 and 5.1 minutes so a later change to this number has to be
deliberate. 23 pass.

Also corrects a DEFERRED.md entry written earlier today. It claimed nothing
ever closes an incident that goes quiet; summarizer.py has run a stale sweep
at incident_auto_resolve_minutes (90) the whole time. The 37 open incidents
were caused by over-creation, not by a missing sweeper, and 90 minutes may be
fine now -- worth rechecking on a fully post-fix dump before changing it.

No new environment variables: tg_dispatch_thin_idle_minutes is a config.py
default and is not templated into any .env, so CI deploys this without an
ansible run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 20:56:58 -04:00
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
112 changed files with 7117 additions and 544 deletions
+14 -5
View File
@@ -7,11 +7,20 @@
# password file. Use different values in production — do NOT reuse defaults. # 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_USER=drb-c2-core
MQTT_C2_PASS=change-me-c2 MQTT_C2_PASS=change-me-c2
# Shared credential for all edge nodes (ACL scopes each node to its own # Seeds mosquitto's built-in dynamic-security plugin's one-time "admin"
# nodes/<NODE_ID>/# namespace via the MQTT client ID) # bootstrap client on first boot (read directly by mosquitto, no entrypoint
MQTT_NODE_USER=drb-node # scripting involved). Must be >=12 chars. c2-core needs this SAME value as
MQTT_NODE_PASS=change-me-node # 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: | tags: |
${{ env.REGISTRY }}/frontend:latest ${{ env.REGISTRY }}/frontend:latest
${{ env.REGISTRY }}/frontend:${{ gitea.sha }} ${{ 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: deploy:
name: Deploy to VM name: Deploy to VM
@@ -59,15 +68,21 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Check runner outbound IP
run: curl -s ifconfig.me
- name: Write SSH key - name: Write SSH key
run: | run: |
echo "${{ secrets.SSH_PRIVATE_KEY }}" > /tmp/deploy_key printf '%s\n' "${{ secrets.SSH_PRIVATE_KEY }}" > /tmp/deploy_key
chmod 600 /tmp/deploy_key chmod 600 /tmp/deploy_key
ssh-keygen -l -f /tmp/deploy_key
- name: Deploy - name: Deploy
run: | run: |
ssh -o StrictHostKeyChecking=no \ ssh -o StrictHostKeyChecking=no \
-o HostKeyAlgorithms=ssh-ed25519,rsa-sha2-256,rsa-sha2-512 \ -o HostKeyAlgorithms=ssh-ed25519,rsa-sha2-256,rsa-sha2-512 \
-o ConnectTimeout=15 \
-v \
-i /tmp/deploy_key \ -i /tmp/deploy_key \
drb@${{ secrets.SERVER_IP }} << 'ENDSSH' drb@${{ secrets.SERVER_IP }} << 'ENDSSH'
set -e set -e
+4 -1
View File
@@ -15,7 +15,10 @@ infra/terraform.tfvars
infra/tf.log infra/tf.log
infra/ansible/inventory.ini infra/ansible/inventory.ini
infra/ansible/group_vars/all.yml 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 # Python
__pycache__/ __pycache__/
+26 -3
View File
@@ -8,13 +8,36 @@
# - restart: always (instead of unless-stopped) for hard reboots. # - restart: always (instead of unless-stopped) for hard reboots.
services: 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: mosquitto:
restart: always 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: c2-core:
restart: always restart: always
ports: ports: !override
- "127.0.0.1:8888:8000" # Caddy proxies, not exposed publicly - "127.0.0.1:8888:8000" # Caddy proxies, not exposed publicly
discord-bot: discord-bot:
@@ -22,5 +45,5 @@ services:
frontend: frontend:
restart: always restart: always
ports: ports: !override
- "127.0.0.1:3000:3000" # Caddy proxies, not exposed publicly - "127.0.0.1:3000:3000" # Caddy proxies, not exposed publicly
+18 -8
View File
@@ -1,20 +1,23 @@
services: 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: mosquitto:
image: eclipse-mosquitto:2 image: eclipse-mosquitto:2.1.2-alpine
restart: unless-stopped restart: unless-stopped
ports: ports:
- "1883:1883" - "1883:1883"
entrypoint: ["/bin/sh", "/mosquitto/config/entrypoint.sh"] - "8883:8883"
environment: environment:
- MQTT_C2_USER=${MQTT_C2_USER} - MOSQUITTO_DYNSEC_PASSWORD=${MOSQUITTO_DYNSEC_PASSWORD}
- MQTT_C2_PASS=${MQTT_C2_PASS}
- MQTT_NODE_USER=${MQTT_NODE_USER}
- MQTT_NODE_PASS=${MQTT_NODE_PASS}
volumes: volumes:
- ./drb-c2-core/mosquitto/mosquitto.conf:/mosquitto/config/mosquitto.conf:ro - ./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_data:/mosquitto/data
- mosquitto_certs:/mosquitto/certs
c2-core: c2-core:
image: ${REGISTRY}/c2-core:${TAG:-latest} image: ${REGISTRY}/c2-core:${TAG:-latest}
@@ -45,4 +48,11 @@ services:
- c2-core - c2-core
volumes: 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_data:
mosquitto_certs:
+9 -3
View File
@@ -2,10 +2,15 @@
MQTT_BROKER=mosquitto MQTT_BROKER=mosquitto
MQTT_PORT=1883 MQTT_PORT=1883
# Use the c2-core credential — must match MQTT_C2_USER/MQTT_C2_PASS in the # 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_USER=drb-c2-core
MQTT_PASS=change-me-c2 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 — path to service account JSON inside the container
GCP_CREDENTIALS_PATH=/app/gcp-key.json GCP_CREDENTIALS_PATH=/app/gcp-key.json
@@ -28,6 +33,7 @@ SUMMARY_INTERVAL_MINUTES=15
CORRELATION_WINDOW_HOURS=4 CORRELATION_WINDOW_HOURS=4
EMBEDDING_SIMILARITY_THRESHOLD=0.82 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 # Generate with: openssl rand -hex 32
NODE_API_KEY= ENROLLMENT_TOKEN=
+46 -3
View File
@@ -9,6 +9,15 @@ class Settings(BaseSettings):
mqtt_user: Optional[str] = None mqtt_user: Optional[str] = None
mqtt_pass: 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
gcp_credentials_path: Optional[str] = None # None → uses ADC gcp_credentials_path: Optional[str] = None # None → uses ADC
gcs_bucket: Optional[str] = None # None → audio upload disabled gcs_bucket: Optional[str] = None # None → audio upload disabled
@@ -29,8 +38,14 @@ class Settings(BaseSettings):
# Correlation consensus models # Correlation consensus models
# corr_cheap_model — first-pass LLM correlator (runs on every call) # corr_cheap_model — first-pass LLM correlator (runs on every call)
# corr_smart_model — tiebreaker (only fires when rules and cheap LLM disagree) # corr_smart_model — tiebreaker (only fires when rules and cheap LLM disagree)
corr_cheap_model: str = "gemini-2.0-flash" # Both IDs below were retired by Google and returned 404 on every call from
corr_smart_model: str = "gemini-1.5-pro" # some point before 2026-08-18 until they were corrected. Because a failed
# LLM call falls back to the rules decision, nothing broke loudly -- the
# entire LLM tier and the consensus tiebreak were simply dead in production
# while correlation behaviour was being tuned against rules-only output.
# Verify against https://ai.google.dev/gemini-api/docs/models before changing.
corr_cheap_model: str = "gemini-3.6-flash" # was gemini-2.0-flash (shut down)
corr_smart_model: str = "gemini-2.5-pro" # was gemini-1.5-pro (shut down)
summary_interval_minutes: int = 2 # how often the summary loop runs summary_interval_minutes: int = 2 # how often the summary loop runs
correlation_window_hours: int = 2 # slow/location path: max hours since last call correlation_window_hours: int = 2 # slow/location path: max hours since last call
embedding_similarity_threshold: float = 0.93 # slow-path: requires location corroboration embedding_similarity_threshold: float = 0.93 # slow-path: requires location corroboration
@@ -42,7 +57,14 @@ class Settings(BaseSettings):
unit_continuity_max_idle_minutes: int = 20 # unit-continuity path: skip if incident idle > this unit_continuity_max_idle_minutes: int = 20 # unit-continuity path: skip if incident idle > this
recorrelation_scan_minutes: int = 60 # re-examine orphaned calls ended within this window recorrelation_scan_minutes: int = 60 # re-examine orphaned calls ended within this window
tg_fast_path_idle_minutes: int = 90 # fast path: max minutes since incident last updated tg_fast_path_idle_minutes: int = 90 # fast path: max minutes since incident last updated
tg_dispatch_thin_idle_minutes: int = 10 # dispatch channels only: thin calls only attach to incidents idle < this many minutes # Dispatch channels only: tier-2 thin calls attach to a lone candidate idle < this.
# Was 10, which is long enough for the channel to have moved on to something else:
# on 2026-08-16 a "72 at Holland Station" incident absorbed a Grand Central train
# meet 9.6 min later, and a status check absorbed a records lookup at 9.7 min.
# Across that dump every correct thin attach was <= 3.4 min idle and every wrong
# one was >= 8.2, so 5 separates them with room on both sides. Genuine
# back-and-forth is handled by the 30-second tier-1 path above this.
tg_dispatch_thin_idle_minutes: int = 5
# Vocabulary learning # Vocabulary learning
vocabulary_induction_interval_hours: int = 24 # how often the induction loop runs vocabulary_induction_interval_hours: int = 24 # how often the induction loop runs
@@ -51,9 +73,30 @@ class Settings(BaseSettings):
# Internal service key — allows server-side services (discord bot) to call C2 without Firebase # Internal service key — allows server-side services (discord bot) to call C2 without Firebase
service_key: Optional[str] = None 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 size limit — reject audio files larger than this (bytes). Default 100 MB.
upload_max_bytes: int = 100 * 1024 * 1024 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"] # CORS — set to your frontend origin(s) in production, e.g. ["https://app.example.com"]
# Defaults to "*" for local development only. # Defaults to "*" for local development only.
cors_origins: list[str] = ["*"] cors_origins: list[str] = ["*"]
+16
View File
@@ -27,6 +27,21 @@ async def check_and_dispatch(
Check all enabled alert rules and fire events for any that match this call. Check all enabled alert rules and fire events for any that match this call.
""" """
try: try:
# Scoped to the call's own org — an unscoped query here would let an
# alert rule created by one org fire (and POST its Discord webhook)
# on another org's radio traffic. org_id is resolved from the call
# doc rather than threaded through as a new parameter, since every
# caller of check_and_dispatch already has call_id and the call doc
# is the single source of truth for a call's org once mqtt_handler.py
# / upload.py have stamped it. None only for a call from a node that
# predates tenancy and hasn't been through the backfill script yet —
# such calls fall back to the pre-tenancy behaviour of checking
# every rule regardless of org.
call_doc = await fstore.doc_get("calls", call_id)
org_id = (call_doc or {}).get("org_id")
if org_id is not None:
rules = await fstore.collection_list("alert_rules", enabled=True, org_id=org_id)
else:
rules = await fstore.collection_list("alert_rules", enabled=True) rules = await fstore.collection_list("alert_rules", enabled=True)
except Exception as e: except Exception as e:
logger.warning(f"Alerter: could not load rules: {e}") logger.warning(f"Alerter: could not load rules: {e}")
@@ -42,6 +57,7 @@ async def check_and_dispatch(
now = datetime.now(timezone.utc).isoformat() now = datetime.now(timezone.utc).isoformat()
event = { event = {
"alert_id": alert_id, "alert_id": alert_id,
"org_id": org_id,
"rule_id": rule.get("rule_id", ""), "rule_id": rule.get("rule_id", ""),
"rule_name": rule.get("name", ""), "rule_name": rule.get("name", ""),
"call_id": call_id, "call_id": call_id,
+132
View File
@@ -37,6 +37,41 @@ async def require_service_or_firebase_token(
raise HTTPException(status_code=401, detail="Invalid or expired 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: def get_role(decoded: dict) -> str:
"""Extract the effective role from a decoded Firebase token. """Extract the effective role from a decoded Firebase token.
@@ -49,6 +84,93 @@ def get_role(decoded: dict) -> str:
return role if role in ("admin", "operator", "viewer") else "viewer" return role if role in ("admin", "operator", "viewer") else "viewer"
# ---------------------------------------------------------------------------
# Tenancy — org_id / org_role claims, set by POST /auth/signup (routers/links.py)
# ---------------------------------------------------------------------------
# `role` above is platform-level (admin/operator/viewer — unrelated to which
# org a user belongs to). `org_role` is the customer-facing one: "owner" or
# "member" of the org named by the `org_id` claim. See SAAS_PLAN.md B2/B4.
def get_org_role(decoded: dict) -> Optional[str]:
org_role = decoded.get("org_role")
return org_role if org_role in ("owner", "member") else None
def require_org(decoded: dict) -> str:
"""Return the caller's org_id claim, or 403 if they don't have one.
A Firebase token with no org_id claim is a real, valid session (the user
signed in) that is nonetheless provisioned into nothing — see
AuthProvider's no-claim guard (SAAS_PLAN.md B3). Every org-scoped route
depends on this rather than trusting a client-supplied org_id, so a
caller can never read/write outside the org their own token names.
"""
org_id = decoded.get("org_id")
if not org_id:
raise HTTPException(403, "This account is not associated with an organization.")
return org_id
def resolve_org_scope(decoded: dict, org_id_override: Optional[str] = None) -> str:
"""Return the org_id a request should be scoped to.
Platform admins (role == "admin") may pass ?org_id=<id> to cross into
another org's data for support/debugging — the one exception to "you can
only ever see your own org's data" called out in SAAS_PLAN.md B2. Every
other caller is locked to their own token's org_id claim regardless of
what (if anything) they pass.
"""
if org_id_override and get_role(decoded) == "admin":
return org_id_override
return require_org(decoded)
async def resolve_caller_org_id(decoded: dict) -> Optional[str]:
"""
Resolve the org_id a caller should be scoped to, across every credential
shape this file's dependencies can produce (service key, node api_key,
Firebase user) — a single helper so read routes gated by
require_service_or_firebase_token / require_node_service_or_firebase_token
don't each need their own caller-shape switch.
Returns None for callers that should see across every org: the internal
service key (the Discord bot — a single fleet-wide principal, see
CLAUDE.md's auth section) and platform admins, matching
require_admin_token's existing "admin sees everything" behaviour. A
route that wants admins scoped too should check get_role() itself rather
than relying on this function to do it.
"""
if decoded.get("service"):
return None
if decoded.get("node"):
# Deferred import — same reasoning as require_node_service_or_firebase_token
# above: 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
node = await fstore.doc_get_cached("nodes", decoded.get("node_id") or "")
return (node or {}).get("org_id")
if get_role(decoded) == "admin":
return None
return require_org(decoded)
async def require_org_owner_token(
credentials: Optional[HTTPAuthorizationCredentials] = Security(_bearer),
) -> dict:
"""Verify a Firebase ID token AND require org_role == "owner" (or platform admin).
Used for org-administrative actions a regular member shouldn't be able to
do on their own org — minting/revoking enrollment tokens, renaming the
org. Platform admins pass through regardless of org_role so support can
act on an org that has no reachable owner.
"""
decoded = await require_firebase_token(credentials)
require_org(decoded)
if get_org_role(decoded) != "owner" and get_role(decoded) != "admin":
raise HTTPException(status_code=403, detail="Organization owner access required.")
return decoded
async def require_admin_token( async def require_admin_token(
credentials: Optional[HTTPAuthorizationCredentials] = Security(_bearer), credentials: Optional[HTTPAuthorizationCredentials] = Security(_bearer),
) -> dict: ) -> dict:
@@ -130,3 +252,13 @@ trip_chat_limiter = _RateLimiter(max_calls=20, window_seconds=300)
summarize_limiter = _RateLimiter(max_calls=5, window_seconds=600) summarize_limiter = _RateLimiter(max_calls=5, window_seconds=600)
# vocabulary bootstrap: 2 per system per hour # vocabulary bootstrap: 2 per system per hour
bootstrap_limiter = _RateLimiter(max_calls=2, window_seconds=3600) bootstrap_limiter = _RateLimiter(max_calls=2, window_seconds=3600)
# per-call reprocess: 3 per call per 10 minutes — reprocess re-runs the full
# Whisper + Gemini pipeline, which is real spend per call; this is now also
# admin-only (see routers/calls.py) but the limiter stays as a second guard
# against a compromised/careless admin session looping it. Keyed by call_id,
# same pattern as summarize_limiter.
reprocess_limiter = _RateLimiter(max_calls=3, window_seconds=600)
# public waitlist submissions: 5 per source IP per hour — POST /waitlist has
# no auth at all by design (SAAS_PLAN.md B6), so this is the only thing
# standing between it and being spammed.
waitlist_limiter = _RateLimiter(max_calls=5, window_seconds=3600)
+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")
+26 -1
View File
@@ -68,16 +68,41 @@ async def collection_list(collection: str, **filters) -> list[dict]:
async def collection_where( async def collection_where(
collection: str, collection: str,
conditions: list[tuple[str, str, Any]], conditions: list[tuple[str, str, Any]],
order_by: Optional[list[tuple[str, str]]] = None,
limit_to: Optional[int] = None,
start_after: Optional[dict] = None,
) -> list[dict]: ) -> list[dict]:
""" """
Query a collection with arbitrary where-clauses. Query a collection with arbitrary where-clauses.
conditions: list of (field, op, value) — e.g. [("ended_at", ">=", cutoff_dt)] conditions: list of (field, op, value) — e.g. [("ended_at", ">=", cutoff_dt)]
Supports any Firestore operator: "==", "!=", "<", "<=", ">", ">=". Supports any Firestore operator, including "array_contains" — it's just
forwarded straight to FieldFilter, so a condition like
("incident_ids", "array_contains", incident_id) already worked before this
function grew explicit order_by/limit/cursor params below.
order_by: list of (field, direction) — direction is "ASCENDING" or
"DESCENDING" (Firestore's own constants; passed straight through as
strings so this module doesn't need a google.cloud.firestore_v1.Query
import). Applied in list order, so multi-field sorts work.
limit_to: cap the number of documents returned.
start_after: cursor — a dict of the same field values as the *last*
document from a previous page's order_by fields (Firestore's
`Query.start_after()` takes a field-value mapping, not a document
snapshot, when you're not holding one).
Added for org_id-scoped queries that also need to be ordered/paginated —
unscoped equality-only lookups can keep using collection_list().
""" """
def _query(): def _query():
ref = db.collection(collection) ref = db.collection(collection)
for field, op, value in conditions: for field, op, value in conditions:
ref = ref.where(filter=FieldFilter(field, op, value)) ref = ref.where(filter=FieldFilter(field, op, value))
for field, direction in (order_by or []):
ref = ref.order_by(field, direction=direction)
if start_after is not None:
ref = ref.start_after(start_after)
if limit_to is not None:
ref = ref.limit(limit_to)
return [doc.to_dict() for doc in ref.stream()] return [doc.to_dict() for doc in ref.stream()]
return await asyncio.to_thread(_query) return await asyncio.to_thread(_query)
+137 -21
View File
@@ -135,6 +135,62 @@ _TAG_TYPE_HINTS: dict[str, str] = {
} }
# Words that describe a unit's role rather than identifying it. Whisper hears
# the same officer as "Post 5", "5", and "5 post" within one conversation, so
# these carry no distinguishing information and are dropped before comparison.
_UNIT_NOISE_TOKENS = frozenset({"post", "unit", "units", "car"})
_ORDINAL_RE = re.compile(r"^(\d+)(?:st|nd|rd|th)$")
def _normalize_unit(unit: str) -> str:
"""
Reduce a spoken unit ID to a comparison key.
Dispatch audio names the same unit inconsistently and exact string equality
silently drops the follow-ups. Observed on 2026-08-17 in a single hour, each
pair being one unit that failed to match itself:
"K-9A2" vs "K-9-A-2" punctuation
"5-1-6" vs "516" digits read out individually
"37" vs "37th Post" ordinal + role word
"11-Victor" vs "11 Victor"
Case, punctuation and role words go; digit groups join up. What is
deliberately NOT done is matching a bare district letter — "Adam" is not
treated as "6-Adam", because every district has an Adam and collapsing them
would merge unrelated incidents. That costs a few links and is the right
trade: a missed link leaves an orphan the sweep can retry, a false link
corrupts an incident permanently.
"""
tokens = [t for t in re.split(r"[^a-z0-9]+", unit.strip().lower()) if t]
cleaned: list[str] = []
for tok in tokens:
if tok in _UNIT_NOISE_TOKENS:
continue
ordinal = _ORDINAL_RE.match(tok)
cleaned.append(ordinal.group(1) if ordinal else tok)
key = "".join(cleaned)
# A unit made only of noise words ("Post") would normalise to "" and then
# collide with every other such unit, so fall back to the raw text.
return key or unit.strip().lower()
def _unit_keys(units: Optional[list[str]]) -> set[str]:
"""Comparison keys for a unit list, empties dropped."""
return {k for k in (_normalize_unit(u) for u in (units or [])) if k}
def _matching_units(call_units: Optional[list[str]], inc_units: Optional[list[str]]) -> list[str]:
"""
Call-side units that also appear on the incident, compared by normalised key
but returned as the original spoken strings so debug output stays readable.
"""
inc_keys = _unit_keys(inc_units)
if not inc_keys:
return []
return [u for u in (call_units or []) if _normalize_unit(u) in inc_keys]
def _infer_type_from_tags(tags: list[str]) -> Optional[str]: def _infer_type_from_tags(tags: list[str]) -> Optional[str]:
"""Return an incident type inferred from tags, or None if ambiguous.""" """Return an incident type inferred from tags, or None if ambiguous."""
for tag in tags: for tag in tags:
@@ -279,20 +335,33 @@ async def _build_context(
now = reference_time or datetime.now(timezone.utc) now = reference_time or datetime.now(timezone.utc)
window = timedelta(hours=settings.correlation_window_hours) window = timedelta(hours=settings.correlation_window_hours)
call_doc = await fstore.doc_get("calls", call_id) or {}
org_id = call_doc.get("org_id")
# Candidate incidents MUST be scoped to the call's own org — without this,
# a call from org A could match/link into an incident belonging to org B
# (fast-path talkgroup match, unit-continuity, disambiguation all pull
# from all_active/recent below), which is a cross-tenant data merge, not
# just an over-broad read. org_id is None for a call from a node that
# predates tenancy and hasn't been through scripts/backfill_org_id.py yet
# — such calls fall back to the pre-tenancy behaviour of matching across
# the whole collection rather than being unable to correlate at all.
if org_id is not None:
all_active = await fstore.collection_list("incidents", status="active", org_id=org_id)
else:
all_active = await fstore.collection_list("incidents", status="active") all_active = await fstore.collection_list("incidents", status="active")
recent = [inc for inc in all_active if _within_window_of(inc, now, window)] recent = [inc for inc in all_active if _within_window_of(inc, now, window)]
call_doc = await fstore.doc_get("calls", call_id) or {}
call_embedding = call_doc.get("embedding") call_embedding = call_doc.get("embedding")
call_units = units if units is not None else (call_doc.get("units") or []) 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_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_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") coords = location_coords or call_doc.get("location_coords")
is_thin_call = not call_units and not call_vehicles and not coords is_thin_call = not call_units and not call_vehicles and not coords
return { return {
"call_id": call_id, "all_active": all_active, "recent": recent, "call_id": call_id, "org_id": org_id, "all_active": all_active, "recent": recent,
"call_doc": call_doc, "call_embedding": call_embedding, "call_doc": call_doc, "call_embedding": call_embedding,
"call_units": call_units, "call_vehicles": call_vehicles, "call_units": call_units, "call_vehicles": call_vehicles,
"call_cleared": call_cleared, "call_severity": call_severity, "call_cleared": call_cleared, "call_severity": call_severity,
@@ -326,6 +395,7 @@ def _run_decision(ctx: dict) -> dict:
call_embedding = ctx["call_embedding"] call_embedding = ctx["call_embedding"]
call_units = ctx["call_units"] call_units = ctx["call_units"]
call_vehicles = ctx["call_vehicles"] call_vehicles = ctx["call_vehicles"]
call_severity = ctx["call_severity"]
coords = ctx["coords"] coords = ctx["coords"]
is_thin_call = ctx["is_thin_call"] is_thin_call = ctx["is_thin_call"]
now = ctx["now"] now = ctx["now"]
@@ -460,8 +530,7 @@ def _run_decision(ctx: dict) -> dict:
"corr_is_dispatch": is_dispatch, "corr_is_dispatch": is_dispatch,
} }
if fit_signal == "unit_overlap" and call_units: if fit_signal == "unit_overlap" and call_units:
inc_unit_set = set(candidate.get("units") or []) corr_debug["corr_matched_units"] = _matching_units(call_units, candidate.get("units"))
corr_debug["corr_matched_units"] = [u for u in call_units if u in inc_unit_set]
logger.info( logger.info(
f"Correlator fast-path: call {call_id} → {candidate['incident_id']} " f"Correlator fast-path: call {call_id} → {candidate['incident_id']} "
f"(signal={fit_signal}, is_dispatch={is_dispatch})" f"(signal={fit_signal}, is_dispatch={is_dispatch})"
@@ -501,8 +570,7 @@ def _run_decision(ctx: dict) -> dict:
"corr_is_dispatch": is_dispatch, "corr_is_dispatch": is_dispatch,
} }
if fit_signal == "unit_overlap" and call_units: if fit_signal == "unit_overlap" and call_units:
inc_unit_set = set(candidate.get("units") or []) corr_debug["corr_matched_units"] = _matching_units(call_units, candidate.get("units"))
corr_debug["corr_matched_units"] = [u for u in call_units if u in inc_unit_set]
logger.info( logger.info(
f"Correlator fast-path (disambig {len(tg_recent)} candidates): " f"Correlator fast-path (disambig {len(tg_recent)} candidates): "
f"call {call_id} → {candidate['incident_id']} (signal={fit_signal})" f"call {call_id} → {candidate['incident_id']} (signal={fit_signal})"
@@ -523,11 +591,11 @@ def _run_decision(ctx: dict) -> dict:
# incident, the officer has moved on and we don't link back to the old one. # incident, the officer has moved on and we don't link back to the old one.
# This correctly handles officers dispatched to a second call mid-shift. # This correctly handles officers dispatched to a second call mid-shift.
if not matched_incident and call_units and system_id and not reassignment: if not matched_incident and call_units and system_id and not reassignment:
call_unit_set = set(call_units) call_unit_set = _unit_keys(call_units)
unit_candidates = [ unit_candidates = [
inc for inc in all_active inc for inc in all_active
if system_id in (inc.get("system_ids") or []) if system_id in (inc.get("system_ids") or [])
and call_unit_set & set(inc.get("units") or []) and call_unit_set & _unit_keys(inc.get("units"))
] ]
# Apply idle cap: units get reassigned; a 20+ min gap means the officer # Apply idle cap: units get reassigned; a 20+ min gap means the officer
# has almost certainly moved on or the incident closed. # has almost certainly moved on or the incident closed.
@@ -539,7 +607,7 @@ def _run_decision(ctx: dict) -> dict:
best_unit_inc = max(unit_candidates, key=lambda i: i.get("updated_at", "")) best_unit_inc = max(unit_candidates, key=lambda i: i.get("updated_at", ""))
reassigned_away = any( reassigned_away = any(
inc["incident_id"] != best_unit_inc["incident_id"] inc["incident_id"] != best_unit_inc["incident_id"]
and call_unit_set & set(inc.get("units") or []) and call_unit_set & _unit_keys(inc.get("units"))
and inc.get("updated_at", "") > best_unit_inc.get("updated_at", "") and inc.get("updated_at", "") > best_unit_inc.get("updated_at", "")
for inc in all_active for inc in all_active
) )
@@ -614,7 +682,7 @@ def _run_decision(ctx: dict) -> dict:
# • embedding similarity >= cross-TG threshold (same subject matter) # • embedding similarity >= cross-TG threshold (same subject matter)
# Requiring 2+ shared units prevents single-officer false positives. # Requiring 2+ shared units prevents single-officer false positives.
if not matched_incident and call_embedding and incident_type and call_units and system_id: if not matched_incident and call_embedding and incident_type and call_units and system_id:
call_unit_set = set(call_units) call_unit_set = _unit_keys(call_units)
best_cross_score = 0.0 best_cross_score = 0.0
best_cross_inc: Optional[dict] = None best_cross_inc: Optional[dict] = None
for inc in recent: for inc in recent:
@@ -622,7 +690,7 @@ def _run_decision(ctx: dict) -> dict:
continue continue
if system_id not in (inc.get("system_ids") or []): if system_id not in (inc.get("system_ids") or []):
continue continue
inc_units_set = set(inc.get("units") or []) inc_units_set = _unit_keys(inc.get("units"))
if len(call_unit_set & inc_units_set) < 2: if len(call_unit_set & inc_units_set) < 2:
continue continue
inc_embedding = inc.get("embedding") inc_embedding = inc.get("embedding")
@@ -634,7 +702,7 @@ def _run_decision(ctx: dict) -> dict:
best_cross_inc = inc best_cross_inc = inc
if best_cross_inc and best_cross_score >= settings.embedding_cross_tg_threshold: if best_cross_inc and best_cross_score >= settings.embedding_cross_tg_threshold:
matched_incident = best_cross_inc matched_incident = best_cross_inc
shared = len(call_unit_set & set(best_cross_inc.get("units") or [])) shared = len(call_unit_set & _unit_keys(best_cross_inc.get("units")))
corr_debug = { corr_debug = {
"corr_path": "cross-tg", "corr_path": "cross-tg",
"corr_score": round(best_cross_score, 4), "corr_score": round(best_cross_score, 4),
@@ -718,6 +786,35 @@ def _run_decision(ctx: dict) -> dict:
f"Correlator: inferred incident_type={resolved_type!r} from tags {tags} for call {call_id}" 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: if not resolved_type:
return {"action": "orphan", "matched_incident": None, "incident_type": None, "corr_debug": corr_debug} return {"action": "orphan", "matched_incident": None, "incident_type": None, "corr_debug": corr_debug}
@@ -755,6 +852,7 @@ async def _apply_decision(decision: dict, ctx: dict) -> Optional[str]:
return None return None
call_id = ctx["call_id"] call_id = ctx["call_id"]
org_id = ctx["org_id"]
talkgroup_id = ctx["talkgroup_id"] talkgroup_id = ctx["talkgroup_id"]
talkgroup_name = ctx["talkgroup_name"] talkgroup_name = ctx["talkgroup_name"]
system_id = ctx["system_id"] system_id = ctx["system_id"]
@@ -772,11 +870,13 @@ async def _apply_decision(decision: dict, ctx: dict) -> Optional[str]:
if action == "link": if action == "link":
matched_incident = decision["matched_incident"] 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( await _update_incident(
matched_incident, call_id, talkgroup_id, system_id, tags, matched_incident, call_id, talkgroup_id, system_id, tags,
location, location_coords, call_units, call_vehicles, call_embedding, now, location, location_coords, call_units, call_vehicles, call_embedding, now,
talkgroup_name=talkgroup_name, incident_type=incident_type, 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"] return matched_incident["incident_id"]
@@ -805,7 +905,7 @@ async def _apply_decision(decision: dict, ctx: dict) -> Optional[str]:
# Create the new agency's child incident first # Create the new agency's child incident first
incident_id = await _create_incident( incident_id = await _create_incident(
call_id, incident_type, talkgroup_id, talkgroup_name, system_id, call_id, org_id, incident_type, talkgroup_id, talkgroup_name, system_id,
tags, location, location_coords, tags, location, location_coords,
call_units, call_vehicles, call_embedding, call_severity, now, call_units, call_vehicles, call_embedding, call_severity, now,
) )
@@ -824,6 +924,7 @@ async def _apply_decision(decision: dict, ctx: dict) -> Optional[str]:
master_id = await _create_master_incident( master_id = await _create_master_incident(
first_child_id=existing_child_id, first_child_id=existing_child_id,
second_child_id=incident_id, second_child_id=incident_id,
org_id=org_id,
operational_type=incident_type, operational_type=incident_type,
location=cross_parent.get("location") or location, location=cross_parent.get("location") or location,
location_coords=cross_parent.get("location_coords") or coords, location_coords=cross_parent.get("location_coords") or coords,
@@ -839,7 +940,7 @@ async def _apply_decision(decision: dict, ctx: dict) -> Optional[str]:
else: else:
# Normal single-agency incident creation # Normal single-agency incident creation
incident_id = await _create_incident( incident_id = await _create_incident(
call_id, incident_type, talkgroup_id, talkgroup_name, system_id, call_id, org_id, incident_type, talkgroup_id, talkgroup_name, system_id,
tags, location, location_coords, tags, location, location_coords,
call_units, call_vehicles, call_embedding, call_severity, now, call_units, call_vehicles, call_embedding, call_severity, now,
) )
@@ -919,8 +1020,8 @@ def _disambiguate(
elif idle_min < 15: score += 1.0 elif idle_min < 15: score += 1.0
# > 15 min: no bonus — older incidents compete on content/units only # > 15 min: no bonus — older incidents compete on content/units only
inc_units = set(inc.get("units") or []) inc_units = _unit_keys(inc.get("units"))
if inc_units and call_units and any(u in inc_units for u in call_units): if inc_units and call_units and (inc_units & _unit_keys(call_units)):
score += 10.0 score += 10.0
inc_vehicles = set(inc.get("vehicles") or []) inc_vehicles = set(inc.get("vehicles") or [])
@@ -1015,8 +1116,8 @@ def _call_fits_incident(
inc_id = inc.get("incident_id", "?") inc_id = inc.get("incident_id", "?")
# ── 1. Unit overlap ─────────────────────────────────────────────────────── # ── 1. Unit overlap ───────────────────────────────────────────────────────
inc_units = set(inc.get("units") or []) inc_units = _unit_keys(inc.get("units"))
matched_units = [u for u in call_units if u in inc_units] if (inc_units and call_units) else [] matched_units = _matching_units(call_units, inc.get("units"))
if matched_units: if matched_units:
if is_dispatch: if is_dispatch:
if call_coords: if call_coords:
@@ -1149,6 +1250,7 @@ async def _update_incident(
talkgroup_name: Optional[str] = None, talkgroup_name: Optional[str] = None,
incident_type: Optional[str] = None, incident_type: Optional[str] = None,
cleared_units: Optional[list[str]] = None, cleared_units: Optional[list[str]] = None,
refresh_activity: bool = True,
) -> None: ) -> None:
incident_id = inc["incident_id"] incident_id = inc["incident_id"]
@@ -1200,10 +1302,20 @@ async def _update_incident(
"units_active": units_active, "units_active": units_active,
"units_cleared": units_cleared, "units_cleared": units_cleared,
"location_mentions": location_mentions, "location_mentions": location_mentions,
"updated_at": now.isoformat(),
"summary_stale": True, "summary_stale": True,
**embedding_updates, **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: if best_location:
updates["location"] = best_location updates["location"] = best_location
if best_coords: if best_coords:
@@ -1249,6 +1361,7 @@ async def _update_incident(
async def _create_incident( async def _create_incident(
call_id: str, call_id: str,
org_id: Optional[str],
incident_type: str, incident_type: str,
talkgroup_id: Optional[int], talkgroup_id: Optional[int],
talkgroup_name: Optional[str], talkgroup_name: Optional[str],
@@ -1280,6 +1393,7 @@ async def _create_incident(
doc = { doc = {
"incident_id": incident_id, "incident_id": incident_id,
"org_id": org_id,
"title": title, "title": title,
"incident_type": "master", # structural role; "child" set on demotion "incident_type": "master", # structural role; "child" set on demotion
"type": incident_type, "type": incident_type,
@@ -1327,6 +1441,7 @@ def _merge_embedding_vecs(inc: dict, call_embedding: list[float]) -> dict:
async def _create_master_incident( async def _create_master_incident(
first_child_id: str, first_child_id: str,
second_child_id: str, second_child_id: str,
org_id: Optional[str],
operational_type: str, operational_type: str,
location: Optional[str], location: Optional[str],
location_coords: Optional[dict], location_coords: Optional[dict],
@@ -1340,6 +1455,7 @@ async def _create_master_incident(
master_id = str(uuid.uuid4()) master_id = str(uuid.uuid4())
doc = { doc = {
"incident_id": master_id, "incident_id": master_id,
"org_id": org_id,
"title": f"Multi-agency {operational_type} incident", "title": f"Multi-agency {operational_type} incident",
"incident_type": "master", "incident_type": "master",
"type": operational_type, "type": operational_type,
+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 vehicles: list of vehicle descriptions mentioned
units: list of unit IDs or officer numbers explicitly 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 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 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. 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 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. - 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. - 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. - 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. - 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". - 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. - 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} Talkgroup: {talkgroup_name}
{ten_codes_block}{vocabulary_block}{transcript_block}""" {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) # Geographic bias radius for geocoding — half-width in degrees (~55 km)
_GEO_DELTA = 0.5 _GEO_DELTA = 0.5
@@ -183,7 +195,13 @@ async def extract_scenes(
f"({len(transcript.split())} words), skipping" f"({len(transcript.split())} words), skipping"
) )
try: 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: except Exception:
pass pass
return [] return []
@@ -213,13 +231,35 @@ async def extract_scenes(
vehicles: list[str] = scene.get("vehicles") or [] vehicles: list[str] = scene.get("vehicles") or []
units: list[str] = scene.get("units") or [] units: list[str] = scene.get("units") or []
cleared_units: list[str] = scene.get("cleared_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)) resolved: bool = bool(scene.get("resolved", False))
reassignment: bool = bool(scene.get("reassignment", False)) reassignment: bool = bool(scene.get("reassignment", False))
transcript_corrected: Optional[str]= scene.get("transcript_corrected") or None transcript_corrected: Optional[str]= scene.get("transcript_corrected") or None
segment_indices: Optional[list] = scene.get("segment_indices") 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 incident_type = None
# Geocode this scene's location. # Geocode this scene's location.
@@ -399,11 +439,17 @@ async def _geocode_location(
return None return None
result = data["results"][0] result = data["results"][0]
location_type = result.get("geometry", {}).get("location_type", "") location_type = result.get("geometry", {}).get("location_type", "")
# Only accept address-level precision. GEOMETRIC_CENTER (city/neighborhood # Reject only APPROXIMATE — a region/city boundary centroid, which is
# centroid) and APPROXIMATE (region boundary) produce coordinates that look # what an ungeocodable string degrades to and is genuinely useless.
# 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. # ROOFTOP-only was too strict and emptied the map: dispatch names
if location_type not in ("ROOFTOP", "RANGE_INTERPOLATED"): # 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( logger.info(
f"Geocoding rejected '{location_str}' — imprecise result " f"Geocoding rejected '{location_str}' — imprecise result "
f"(location_type={location_type!r}), returning None" f"(location_type={location_type!r}), returning None"
+47 -2
View File
@@ -241,10 +241,55 @@ async def decide(call_id: str, ctx: dict) -> Optional[dict]:
) )
return decision return decision
except Exception as e: except Exception as e:
logger.warning(f"LLM correlator failed for call {call_id}: {e}") _log_llm_failure("LLM correlator", call_id, settings.corr_cheap_model, e)
return None return None
_dead_models: set[str] = set()
def _log_llm_failure(where: str, call_id: str, model: str, exc: Exception) -> None:
"""
Log an LLM failure, escalating a dead model ID to ERROR once per model.
A per-call WARNING was the only signal that gemini-2.0-flash had been shut
down, and since every failure falls back to the rules decision the pipeline
kept running normally -- the LLM tier was dead for an unknown number of days
while correlation was being tuned against rules-only output. A transient API
error is genuinely a warning; a model that does not exist is a config bug
that will never fix itself, so it gets ERROR and says what to do.
"""
text = str(exc)
low = text.lower()
if "404" in text or "not found" in low or "no longer available" in low:
_log_tier_down(where, model, "model is unavailable",
"Update CORR_CHEAP_MODEL/CORR_SMART_MODEL in config.py", text)
return
# A depleted balance reads as 429, the same status as an ordinary rate limit,
# but it is the opposite kind of problem: a rate limit clears on its own and a
# dead account never does. Matching on the billing wording keeps a burst of
# rate limits at WARNING while an empty account escalates like a bad model ID.
if "credits are depleted" in low or "prepayment" in low or "billing" in low:
_log_tier_down(where, model, "the Gemini account is out of credit",
"Top up billing at https://ai.studio/projects", text)
return
logger.warning(f"{where} failed for call {call_id}: {text}")
def _log_tier_down(where: str, model: str, problem: str, fix: str, text: str) -> None:
"""ERROR once per model, not once per call — this runs at radio-traffic volume."""
if model in _dead_models:
return
_dead_models.add(model)
logger.error(
f"{where}: {problem} ({model!r}) -- the LLM correlation tier is DISABLED "
f"and every call is falling back to rules-only. {fix}. API said: {text}"
)
async def tiebreak(rules_decision: dict, llm_decision: dict, ctx: dict) -> dict: async def tiebreak(rules_decision: dict, llm_decision: dict, ctx: dict) -> dict:
""" """
Run the smart tiebreaker (corr_smart_model) when rules and LLM disagree. Run the smart tiebreaker (corr_smart_model) when rules and LLM disagree.
@@ -263,7 +308,7 @@ async def tiebreak(rules_decision: dict, llm_decision: dict, ctx: dict) -> dict:
) )
return decision return decision
except Exception as e: except Exception as e:
logger.warning(f"LLM tiebreak failed for call {call_id}: {e} — using rules decision") _log_llm_failure("LLM tiebreak", call_id, settings.corr_smart_model, e)
return rules_decision return rules_decision
+8
View File
@@ -7,4 +7,12 @@ logging.basicConfig(
handlers=[logging.StreamHandler(sys.stdout)], 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") logger = logging.getLogger("drb-c2-core")
+75 -3
View File
@@ -1,11 +1,12 @@
import asyncio import asyncio
import json import json
from datetime import datetime, timezone from datetime import datetime, timezone, timedelta
from typing import Optional from typing import Optional
import paho.mqtt.client as mqtt import paho.mqtt.client as mqtt
from app.config import settings from app.config import settings
from app.internal.logger import logger from app.internal.logger import logger
from app.internal import firestore as fstore from app.internal import firestore as fstore
from app.internal.tenancy import FOUNDING_ORG_ID
class MQTTHandler: class MQTTHandler:
@@ -33,6 +34,10 @@ class MQTTHandler:
client.subscribe("nodes/+/checkin", qos=1) client.subscribe("nodes/+/checkin", qos=1)
client.subscribe("nodes/+/status", qos=1) client.subscribe("nodes/+/status", qos=1)
client.subscribe("nodes/+/metadata", 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) client.subscribe("nodes/+/key_request", qos=1)
logger.info("MQTT connected — subscribed to node topics.") logger.info("MQTT connected — subscribed to node topics.")
else: else:
@@ -83,9 +88,19 @@ class MQTTHandler:
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
if not existing: if not existing:
# First time we've seen this node — create it as unconfigured, pending approval # First time we've seen this node — create it as unconfigured, pending approval.
# This branch only fires for a node_id that has never gone through
# POST /nodes/enroll (routers/enrollment.py) — a properly-enrolled
# node already has a Firestore doc, with its real org_id, by the time
# its first checkin arrives, so `existing` would be truthy and this
# branch wouldn't run. What's left is the legacy shared-MQTT-password
# path (node-26 — see the TODO(mqtt-cutover) notes in this file),
# which has no enrollment token to resolve org_id from at all.
# Default it to FOUNDING_ORG_ID, same as enrollment.py's own
# legacy-token fallback.
doc = { doc = {
"node_id": node_id, "node_id": node_id,
"org_id": FOUNDING_ORG_ID,
"name": payload.get("name", node_id), "name": payload.get("name", node_id),
"lat": payload.get("lat", 0.0), "lat": payload.get("lat", 0.0),
"lon": payload.get("lon", 0.0), "lon": payload.get("lon", 0.0),
@@ -94,6 +109,11 @@ class MQTTHandler:
"last_seen": now.isoformat(), "last_seen": now.isoformat(),
"assigned_system_id": None, "assigned_system_id": None,
"approval_status": "pending", "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) await fstore.doc_set("nodes", node_id, doc, merge=False)
logger.info(f"New node registered: {node_id} — pending admin approval.") logger.info(f"New node registered: {node_id} — pending admin approval.")
@@ -111,6 +131,34 @@ class MQTTHandler:
elif existing.get("approval_status") == "approved": elif existing.get("approval_status") == "approved":
# Approved but not yet configured — restore reachable status after reboot # Approved but not yet configured — restore reachable status after reboot
updates["status"] = "unconfigured" 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) await fstore.doc_update("nodes", node_id, updates)
# NOTE: discord_connected in checkins is informational only — do NOT release the # NOTE: discord_connected in checkins is informational only — do NOT release the
@@ -157,6 +205,12 @@ class MQTTHandler:
# Look up assigned system for this node (cached — assignment rarely changes) # Look up assigned system for this node (cached — assignment rarely changes)
node = await fstore.doc_get_cached("nodes", node_id) node = await fstore.doc_get_cached("nodes", node_id)
system_id = node.get("assigned_system_id") if node else None system_id = node.get("assigned_system_id") if node else None
# org_id is inherited from the node, not carried in the MQTT payload —
# this is the load-bearing tenancy stamp (SAAS_PLAN.md B2b): every call
# and, downstream, every incident correlated from it, traces back to
# this. None only for a call from a node that predates tenancy and
# hasn't been through scripts/backfill_org_id.py yet.
org_id = node.get("org_id") if node else None
started_at_raw = payload.get("started_at") started_at_raw = payload.get("started_at")
started_at = ( started_at = (
@@ -179,6 +233,7 @@ class MQTTHandler:
doc = { doc = {
"call_id": call_id, "call_id": call_id,
"node_id": node_id, "node_id": node_id,
"org_id": org_id,
"system_id": system_id, "system_id": system_id,
"talkgroup_id": payload.get("tgid"), "talkgroup_id": payload.get("tgid"),
"talkgroup_name": tgid_name, "talkgroup_name": tgid_name,
@@ -212,6 +267,15 @@ class MQTTHandler:
"ended_at": ended_at, "ended_at": ended_at,
"status": "ended", "status": "ended",
} }
# doc_set below is a merge, so if call_start already wrote org_id this
# is a no-op write of the same value. But DEFERRED.md notes call_end
# can in principle arrive before call_start (ordering relies on MQTT
# preserving per-topic order, which holds in practice but isn't
# guaranteed) — in that case doc_set would CREATE the calls doc here
# with no org_id at all unless it's resolved independently.
node = await fstore.doc_get_cached("nodes", node_id)
if node and node.get("org_id"):
updates["org_id"] = node["org_id"]
if payload.get("audio_url"): if payload.get("audio_url"):
updates["audio_url"] = payload["audio_url"] updates["audio_url"] = payload["audio_url"]
@@ -221,6 +285,11 @@ class MQTTHandler:
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Key request — re-deliver an existing approved key to a node that # Key request — re-deliver an existing approved key to a node that
# lost its credentials (e.g. after a directory move / fresh volume) # 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): async def _handle_key_request(self, node_id: str):
@@ -253,7 +322,10 @@ class MQTTHandler:
logger.warning(f"MQTT not connected — could not push config to {node_id}") logger.warning(f"MQTT not connected — could not push config to {node_id}")
def publish_node_key(self, node_id: str, api_key: str): 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" topic = f"nodes/{node_id}/api_key"
if self._client and self._connected: if self._client and self._connected:
self._client.publish(topic, json.dumps({"api_key": api_key}), qos=2, retain=True) 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()})") logger.info(f"Node {node_id} marked offline (last seen: {last_seen.isoformat()})")
from app.routers.tokens import release_token from app.routers.tokens import release_token
await release_token(node_id) 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 c for c in recent_ended
if not c.get("incident_ids") and not c.get("incident_id") 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("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 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 asyncio
import datetime import hashlib
from typing import Optional import hmac
import os
import time
from typing import Optional, Tuple
from app.config import settings from app.config import settings
from app.internal.logger import logger 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: def _safe_audio_filename(filename: str, call_id: str) -> str:
"""Return a safe GCS object name derived from the call_id. """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. 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. 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 "" ext = os.path.splitext(filename)[-1].lower() if filename else ""
if ext not in (".mp3", ".wav", ".ogg", ".m4a", ".aac", ".flac"): if ext not in (".mp3", ".wav", ".ogg", ".m4a", ".aac", ".flac"):
ext = ".mp3" 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]: 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: if not settings.gcs_bucket:
logger.info("GCS_BUCKET not configured — skipping audio upload.") logger.info("GCS_BUCKET not configured — skipping audio upload.")
return None 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.cloud import storage
from google.oauth2 import service_account as sa
if settings.gcp_credentials_path: if settings.gcp_credentials_path:
client = storage.Client.from_service_account_json(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: else:
client = storage.Client() client = storage.Client()
signing_creds = None blob = client.bucket(settings.gcs_bucket).blob(blob_path)
bucket = client.bucket(settings.gcs_bucket)
safe_name = _safe_audio_filename(filename, call_id)
blob = bucket.blob(f"calls/{safe_name}")
blob.upload_from_string(data, content_type="audio/mpeg") 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: try:
url = await asyncio.to_thread(_upload) await asyncio.to_thread(_upload)
logger.info(f"Audio uploaded: {url}")
return url
except Exception as e: except Exception as e:
logger.error(f"GCS upload failed: {e}") logger.error(f"GCS upload failed: {e}")
return None 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)}
+22
View File
@@ -0,0 +1,22 @@
"""
Shared tenancy constants used across auth, enrollment, org provisioning,
trip gating, and scripts/backfill_org_id.py.
FOUNDING_ORG_ID is the org every pre-tenancy document (nodes, systems,
calls, incidents, alert_rules created before this pass) gets stamped with by
scripts/backfill_org_id.py, and the org the legacy fleet-wide
settings.enrollment_token still resolves to in routers/enrollment.py so an
already-deployed field node doesn't break the day these rules deploy — see
that file's enroll_node() for the fallback path.
NOTE ON MODEL: per the owner's correction mid-build, DRB's access model is
participation-based (you run a node feeding the network, you get access to
the network's data), not per-seat SaaS — org_role is "owner"/"member" with
no tier axis, and organizations.plan_id/seat_limit/node_limit are inert
placeholders (see models.py OrganizationRecord) until a business-strategy
pass defines what, if anything, gates on them. FOUNDING_ORG_ID plays no
special role in that model beyond being the backfill target and the legacy
token's org — it is not a "free tier" or a privileged org in code.
"""
FOUNDING_ORG_ID = "founding"
+122 -8
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. returning None on any failure so the intelligence pipeline can still run.
""" """
import asyncio import asyncio
import re
import tempfile import tempfile
import os import os
from typing import Optional from typing import Optional
@@ -14,15 +15,119 @@ from app.internal import firestore as fstore
# Whisper treats `prompt` as preceding transcript text, not instructions. # Whisper treats `prompt` as preceding transcript text, not instructions.
# Writing it as actual radio speech primes the vocabulary toward P25 codes # Writing it as actual radio speech primes the vocabulary toward P25 codes
# and phrasing before the model hears the audio. # 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 = ( _WHISPER_PROMPT = (
"10-4. 10-23. 10-20. 10-97. 10-8. 10-7. 10-34. 10-50. 10-52. " "Dispatch, go ahead. Copy that, en route. Show me on scene. "
"Post 4, I'm out. Post 3. En route. On scene. In route. " "Be advised, units responding. Negative, stand by. "
"Copy. Negative. Stand by. Be advised. Go ahead. " "Post 4, I'm out. Received, thank you. "
"Units responding. Dispatch. Talkgroup. " "Engine and ladder responding to a structure fire. "
"Engine. Ladder. Medic. Rescue. Car. Unit. " "Medic on scene with one patient. "
"MVA. MVC. Structure fire. Working fire." "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
_billing_reported = False
def _log_transcribe_failure(call_id: str, exc: Exception) -> None:
"""
Log a transcription failure, escalating an unpayable account to ERROR once.
Transcription failing returns None and the pipeline carries on by design, so
a per-call WARNING is invisible: no transcript means no extraction, which
means no incident, and the only symptom is calls quietly arriving empty. A
network blip is genuinely a warning. An exhausted balance is not -- it will
not fix itself and it takes the whole pipeline down with it, so it says so
once, loudly, and names the fix.
The same failure mode already bit the Gemini correlator twice (a retired
model ID, then a depleted balance), which is why this is worth the code.
"""
global _billing_reported
text = str(exc)
low = text.lower()
if ("insufficient_quota" in low or "billing" in low
or "credit" in low or "exceeded your current quota" in low):
if not _billing_reported:
_billing_reported = True
logger.error(
"Transcription: the OpenAI account cannot be billed -- EVERY call is "
"now stored with no transcript, so extraction, correlation and "
"incidents are all dead downstream. Top up at "
f"https://platform.openai.com/settings/organization/billing. API said: {text}"
)
return
logger.warning(f"Transcription failed for call {call_id}: {text}")
async def transcribe_call( async def transcribe_call(
call_id: str, call_id: str,
@@ -45,7 +150,7 @@ async def transcribe_call(
_sync_transcribe, gcs_uri, talkgroup_name _sync_transcribe, gcs_uri, talkgroup_name
) )
except Exception as e: except Exception as e:
logger.warning(f"Transcription failed for call {call_id}: {e}") _log_transcribe_failure(call_id, e)
return None, [] return None, []
if transcript: if transcript:
@@ -76,7 +181,10 @@ def _sync_transcribe(
if not settings.openai_api_key: if not settings.openai_api_key:
logger.warning("OPENAI_API_KEY not set — transcription disabled.") 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://"):] without_scheme = gcs_uri[len("gs://"):]
bucket_name, blob_path = without_scheme.split("/", 1) bucket_name, blob_path = without_scheme.split("/", 1)
@@ -145,11 +253,17 @@ def _sync_transcribe(
# in sync. If every segment was filtered, text becomes None which prevents # in sync. If every segment was filtered, text becomes None which prevents
# the intelligence pipeline from running on hallucinated content. # the intelligence pipeline from running on hallucinated content.
text = " ".join(s["text"] for s in segments) or None 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 return text, segments
else: else:
# json format returns just {"text": "..."} — no segments or timestamps. # json format returns just {"text": "..."} — no segments or timestamps.
# Intelligence extraction falls back to treating the whole transcript as one block. # Intelligence extraction falls back to treating the whole transcript as one block.
text = (response.text or "").strip() or None 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, [] return text, []
finally: finally:
try: try:
+43 -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.vocabulary_learner import vocabulary_induction_loop
from app.internal.recorrelation_sweep import recorrelation_loop from app.internal.recorrelation_sweep import recorrelation_loop
from app.config import settings 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 nodes, systems, calls, upload, tokens, incidents, alerts, admin, trips, places, links, users
from app.routers import enrollment, media, org, waitlist
from app.internal import dynsec
from app.internal import firestore as fstore from app.internal import firestore as fstore
@@ -36,6 +42,22 @@ async def lifespan(app: FastAPI):
logger.info("DRB C2 Core starting.") logger.info("DRB C2 Core starting.")
await _release_orphaned_tokens() 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() await mqtt_handler.connect()
sweeper_task = asyncio.create_task(sweeper_loop()) sweeper_task = asyncio.create_task(sweeper_loop())
summarizer_task = asyncio.create_task(summarizer_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(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(calls.router, dependencies=[Depends(require_service_or_firebase_token)])
app.include_router(tokens.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)]) app.include_router(incidents.router, dependencies=[Depends(require_service_or_firebase_token)])
@@ -74,6 +100,21 @@ 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(admin.router) # auth is per-endpoint (read: firebase, write: admin)
app.include_router(users.router) # auth: admin only 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(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
app.include_router(org.router) # auth is per-endpoint (read: firebase, write: org owner)
app.include_router(waitlist.router) # public — no auth, source-IP rate limited 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") @app.get("/health")
+58 -1
View File
@@ -3,6 +3,50 @@ from typing import Optional, List, Dict, Any
from datetime import datetime from datetime import datetime
# ---------------------------------------------------------------------------
# Organizations — the tenant boundary. See SAAS_PLAN.md B2 and
# app/internal/auth.py's require_org(). plan_id/subscription_status and the
# stripe_* fields are deliberately inert (None) here: no billing model has
# been decided yet (participation-based / reciprocal access, not per-seat
# SaaS — see the note on FOUNDING_ORG_ID in app/internal/tenancy.py), so this
# is just the seam a future billing pass would write into, not a promise
# about what that pass looks like.
# ---------------------------------------------------------------------------
class OrganizationRecord(BaseModel):
org_id: str
name: str
created_at: datetime
created_by_uid: str
plan_id: Optional[str] = None
subscription_status: Optional[str] = None
stripe_customer_id: Optional[str] = None
stripe_subscription_id: Optional[str] = None
current_period_end: Optional[datetime] = None
seat_limit: Optional[int] = None
node_limit: Optional[int] = None
retention_days: Optional[int] = None
class OrgMember(BaseModel):
uid: str
org_id: str
org_role: str # "owner" | "member"
email: Optional[str] = None
added_at: datetime
class EnrollmentTokenRecord(BaseModel):
"""Firestore doc id is the SHA-256 hash of the raw token — see
routers/enrollment.py's _hash_secret pattern (pickup_secret_hash)."""
org_id: str
label: str
created_at: datetime
created_by_uid: str
revoked: bool = False
uses: int = 0
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Nodes # Nodes
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -10,12 +54,18 @@ from datetime import datetime
class NodeRecord(BaseModel): class NodeRecord(BaseModel):
node_id: str node_id: str
name: str name: str
org_id: Optional[str] = None # stamped at enrollment; None only on pre-tenancy docs awaiting backfill
lat: float = 0.0 lat: float = 0.0
lon: float = 0.0 lon: float = 0.0
status: str = "offline" # online / offline / recording / unconfigured status: str = "offline" # online / offline / recording / unconfigured
configured: bool = False configured: bool = False
last_seen: Optional[datetime] = None last_seen: Optional[datetime] = None
assigned_system_id: Optional[str] = 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): class CommandPayload(BaseModel):
@@ -30,6 +80,7 @@ class CommandPayload(BaseModel):
class SystemRecord(BaseModel): class SystemRecord(BaseModel):
system_id: str system_id: str
org_id: Optional[str] = None
name: str name: str
type: str # P25 / DMR / NBFM type: str # P25 / DMR / NBFM
config: Dict[str, Any] = {} # OP25-compatible config blob config: Dict[str, Any] = {} # OP25-compatible config blob
@@ -50,6 +101,7 @@ class SystemCreate(BaseModel):
class CallRecord(BaseModel): class CallRecord(BaseModel):
call_id: str call_id: str
node_id: str node_id: str
org_id: Optional[str] = None # inherited from the node at call_start/upload — see internal/mqtt_handler.py
system_id: Optional[str] = None system_id: Optional[str] = None
talkgroup_id: Optional[int] = None talkgroup_id: Optional[int] = None
talkgroup_name: Optional[str] = None talkgroup_name: Optional[str] = None
@@ -57,7 +109,9 @@ class CallRecord(BaseModel):
srcaddr: Optional[str] = None srcaddr: Optional[str] = None
started_at: datetime started_at: datetime
ended_at: Optional[datetime] = None 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 transcript: Optional[str] = None # populated later by STT
incident_ids: List[str] = [] # one per scene detected in the recording incident_ids: List[str] = [] # one per scene detected in the recording
location: Optional[Dict[str, float]] = None # {lat, lng} location: Optional[Dict[str, float]] = None # {lat, lng}
@@ -71,6 +125,7 @@ class CallRecord(BaseModel):
class IncidentRecord(BaseModel): class IncidentRecord(BaseModel):
incident_id: str incident_id: str
org_id: Optional[str] = None # inherited from the calls that created it — see internal/incident_correlator.py
title: Optional[str] = None title: Optional[str] = None
type: Optional[str] = None # fire / police / ems / etc. type: Optional[str] = None # fire / police / ems / etc.
status: str = "active" # active / resolved status: str = "active" # active / resolved
@@ -107,6 +162,7 @@ class IncidentUpdate(BaseModel):
class AlertRule(BaseModel): class AlertRule(BaseModel):
rule_id: Optional[str] = None rule_id: Optional[str] = None
org_id: Optional[str] = None
name: str name: str
keywords: List[str] = [] keywords: List[str] = []
talkgroup_ids: List[int] = [] talkgroup_ids: List[int] = []
@@ -124,6 +180,7 @@ class AlertRuleUpdate(BaseModel):
class AlertEvent(BaseModel): class AlertEvent(BaseModel):
alert_id: Optional[str] = None alert_id: Optional[str] = None
org_id: Optional[str] = None
rule_id: str rule_id: str
rule_name: str rule_name: str
call_id: str call_id: str
+9 -3
View File
@@ -1,7 +1,7 @@
import asyncio import asyncio
from datetime import datetime, timezone, timedelta from datetime import datetime, timezone, timedelta
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, Depends, Query
from app.internal.auth import require_admin_token, require_firebase_token from app.internal.auth import require_admin_token
from app.internal.feature_flags import get_flags, set_flags from app.internal.feature_flags import get_flags, set_flags
from app.internal import firestore as fstore from app.internal import firestore as fstore
@@ -24,8 +24,13 @@ router = APIRouter(prefix="/admin", tags=["admin"])
@router.get("/features") @router.get("/features")
async def get_feature_flags(_=Depends(require_firebase_token)): async def get_feature_flags(_=Depends(require_admin_token)):
"""Return the current AI feature flag state. Any authenticated user can read.""" """
Return the current AI feature flag state. Admin-only (SAAS_PLAN.md B2c) —
was previously any authenticated user via require_firebase_token, which
handed platform-wide AI configuration state to every signed-in viewer
regardless of org.
"""
return await get_flags() return await get_flags()
@@ -132,6 +137,7 @@ async def debug_correlation(
_call_summary(c) for c in recent_calls _call_summary(c) for c in recent_calls
if c.get("status") == "ended" if c.get("status") == "ended"
and not c.get("incident_ids") and not c.get("incident_id") 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 and c.get("system_id") in ai_systems
] ]
orphans.sort(key=lambda c: c.get("started_at", ""), reverse=True) orphans.sort(key=lambda c: c.get("started_at", ""), reverse=True)
+28 -6
View File
@@ -1,10 +1,11 @@
import uuid import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Optional from typing import Optional
from fastapi import APIRouter, HTTPException, Depends from fastapi import APIRouter, HTTPException, Depends, Query
from app.models import AlertRule, AlertRuleUpdate from app.models import AlertRule, AlertRuleUpdate
from app.internal import firestore as fstore from app.internal import firestore as fstore
from app.internal.auth import require_admin_token from app.internal.auth import require_admin_token, require_service_or_firebase_token, resolve_caller_org_id
from app.internal.tenancy import FOUNDING_ORG_ID
router = APIRouter(tags=["alerts"]) router = APIRouter(tags=["alerts"])
@@ -14,18 +15,31 @@ router = APIRouter(tags=["alerts"])
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@router.get("/alerts") @router.get("/alerts")
async def list_alerts(acknowledged: Optional[bool] = None): async def list_alerts(
acknowledged: Optional[bool] = None,
decoded: dict = Depends(require_service_or_firebase_token),
):
filters = {} filters = {}
if acknowledged is not None: if acknowledged is not None:
filters["acknowledged"] = acknowledged filters["acknowledged"] = acknowledged
org_id = await resolve_caller_org_id(decoded)
if org_id is not None:
filters["org_id"] = org_id
return await fstore.collection_list("alert_events", **filters) return await fstore.collection_list("alert_events", **filters)
@router.post("/alerts/{alert_id}/acknowledge") @router.post("/alerts/{alert_id}/acknowledge")
async def acknowledge_alert(alert_id: str): async def acknowledge_alert(alert_id: str, decoded: dict = Depends(require_service_or_firebase_token)):
doc = await fstore.doc_get("alert_events", alert_id) doc = await fstore.doc_get("alert_events", alert_id)
if not doc: if not doc:
raise HTTPException(404, f"Alert '{alert_id}' not found.") raise HTTPException(404, f"Alert '{alert_id}' not found.")
# SAAS_PLAN.md B2c: previously any authenticated viewer could acknowledge
# any org's alerts. org_id may be absent on a pre-tenancy alert_event
# that hasn't been through scripts/backfill_org_id.py yet — allow those
# through rather than making them permanently unacknowledgeable.
org_id = await resolve_caller_org_id(decoded)
if org_id is not None and doc.get("org_id") and doc.get("org_id") != org_id:
raise HTTPException(404, f"Alert '{alert_id}' not found.")
await fstore.doc_update("alert_events", alert_id, {"acknowledged": True}) await fstore.doc_update("alert_events", alert_id, {"acknowledged": True})
return {"ok": True} return {"ok": True}
@@ -35,15 +49,23 @@ async def acknowledge_alert(alert_id: str):
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@router.get("/alert-rules") @router.get("/alert-rules")
async def list_alert_rules(): async def list_alert_rules(decoded: dict = Depends(require_service_or_firebase_token)):
org_id = await resolve_caller_org_id(decoded)
if org_id is not None:
return await fstore.collection_list("alert_rules", org_id=org_id)
return await fstore.collection_list("alert_rules") return await fstore.collection_list("alert_rules")
@router.post("/alert-rules") @router.post("/alert-rules")
async def create_alert_rule(body: AlertRule, _: dict = Depends(require_admin_token)): async def create_alert_rule(
body: AlertRule,
org_id: Optional[str] = Query(None, description="Platform-admin only — defaults to the founding org."),
_: dict = Depends(require_admin_token),
):
rule_id = str(uuid.uuid4()) rule_id = str(uuid.uuid4())
doc = { doc = {
"rule_id": rule_id, "rule_id": rule_id,
"org_id": org_id or FOUNDING_ORG_ID,
"name": body.name, "name": body.name,
"keywords": body.keywords, "keywords": body.keywords,
"talkgroup_ids": body.talkgroup_ids, "talkgroup_ids": body.talkgroup_ids,
+36 -9
View File
@@ -3,7 +3,13 @@ from fastapi import APIRouter, BackgroundTasks, HTTPException, Query, Depends
from pydantic import BaseModel from pydantic import BaseModel
from typing import Optional from typing import Optional
from app.internal import firestore as fstore from app.internal import firestore as fstore
from app.internal.auth import require_admin_token from app.internal.auth import (
require_admin_token,
require_service_or_firebase_token,
resolve_caller_org_id,
reprocess_limiter,
)
from app.internal.storage import gcs_uri_for_call, with_playback_url
class TranscriptUpdate(BaseModel): class TranscriptUpdate(BaseModel):
@@ -17,6 +23,7 @@ async def list_calls(
node_id: Optional[str] = Query(None), node_id: Optional[str] = Query(None),
status: Optional[str] = Query(None), status: Optional[str] = Query(None),
system_id: Optional[str] = Query(None), system_id: Optional[str] = Query(None),
decoded: dict = Depends(require_service_or_firebase_token),
): ):
filters = {} filters = {}
if node_id: if node_id:
@@ -25,28 +32,48 @@ async def list_calls(
filters["status"] = status filters["status"] = status
if system_id: if system_id:
filters["system_id"] = system_id filters["system_id"] = system_id
return await fstore.collection_list("calls", **filters) org_id = await resolve_caller_org_id(decoded)
if org_id is not None: # service key / platform admin stay unrestricted
filters["org_id"] = org_id
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}") @router.get("/{call_id}")
async def get_call(call_id: str): async def get_call(call_id: str, decoded: dict = Depends(require_service_or_firebase_token)):
call = await fstore.doc_get("calls", call_id) call = await fstore.doc_get("calls", call_id)
if not call: if not call:
raise HTTPException(404, f"Call '{call_id}' not found.") raise HTTPException(404, f"Call '{call_id}' not found.")
return call org_id = await resolve_caller_org_id(decoded)
if org_id is not None and call.get("org_id") != org_id:
raise HTTPException(404, f"Call '{call_id}' not found.")
return with_playback_url(call)
@router.post("/{call_id}/reprocess") @router.post("/{call_id}/reprocess")
async def reprocess_call(call_id: str, background_tasks: BackgroundTasks): async def reprocess_call(
"""Re-run the full intelligence pipeline (transcription → extraction → correlation) for a call.""" call_id: str,
background_tasks: BackgroundTasks,
_: dict = Depends(require_admin_token),
):
"""
Re-run the full intelligence pipeline (transcription -> extraction ->
correlation) for a call. Admin-only (SAAS_PLAN.md B2c) — this was
previously gated only by "any valid Firebase token", which meant any
signed-in viewer could loop it and burn the owner's OpenAI/Gemini
credits (DEFERRED.md, calls.py:42). The rate limiter below is a second
guard against the same thing happening from a compromised/careless
admin session, not the primary fix.
"""
call = await fstore.doc_get("calls", call_id) call = await fstore.doc_get("calls", call_id)
if not call: if not call:
raise HTTPException(404, f"Call '{call_id}' not found.") raise HTTPException(404, f"Call '{call_id}' not found.")
reprocess_limiter.check(call_id)
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 = gcs_uri_for_call(call)
gcs_uri = _public_url_to_gcs_uri(audio_url) if audio_url else None
background_tasks.add_task( background_tasks.add_task(
_run_intelligence_pipeline, _run_intelligence_pipeline,
+218
View File
@@ -0,0 +1,218 @@
"""
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
from app.internal.tenancy import FOUNDING_ORG_ID
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()
async def _resolve_org_for_token(token: str) -> Optional[str]:
"""
Resolve an X-Enrollment-Token to the org_id it enrolls a node into.
Tries the per-org enrollment_tokens collection first (SAAS_PLAN.md B2b —
minted/listed/revoked via routers/org.py), then falls back to the legacy
fleet-wide settings.enrollment_token so an already-deployed field node's
.env doesn't start failing the day per-org tokens ship. The fallback
always resolves to FOUNDING_ORG_ID — see app/internal/tenancy.py.
"""
token_hash = _hash_secret(token)
doc = await fstore.doc_get("enrollment_tokens", token_hash)
if doc and not doc.get("revoked"):
try:
await fstore.doc_update("enrollment_tokens", token_hash, {"uses": (doc.get("uses") or 0) + 1})
except Exception:
pass # use-counter is informational only — never block enrollment on it
return doc.get("org_id")
if settings.enrollment_token and secrets.compare_digest(token, settings.enrollment_token):
return FOUNDING_ORG_ID
return None
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 x_enrollment_token:
logger.warning(f"Enroll 401: missing X-Enrollment-Token from {client_ip} for node_id={body.node_id!r}")
raise HTTPException(401, "Invalid or missing X-Enrollment-Token")
org_id = await _resolve_org_for_token(x_enrollment_token)
if not org_id:
logger.warning(f"Enroll 401: bad 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,
# A node re-enrolling keeps whatever org_id it already has rather
# than letting a differently-scoped token silently reassign its
# tenancy — only a brand-new node_id (or one that predates tenancy
# entirely) picks up org_id from the token used here.
"org_id": (existing or {}).get("org_id") or org_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"])
+18 -3
View File
@@ -4,18 +4,30 @@ from typing import Optional
from fastapi import APIRouter, BackgroundTasks, HTTPException, Depends from fastapi import APIRouter, BackgroundTasks, HTTPException, Depends
from app.models import IncidentCreate, IncidentUpdate from app.models import IncidentCreate, IncidentUpdate
from app.internal import firestore as fstore from app.internal import firestore as fstore
from app.internal.auth import require_admin_token, require_service_or_firebase_token, summarize_limiter from app.internal.auth import (
require_admin_token,
require_service_or_firebase_token,
resolve_caller_org_id,
summarize_limiter,
)
router = APIRouter(prefix="/incidents", tags=["incidents"]) router = APIRouter(prefix="/incidents", tags=["incidents"])
@router.get("") @router.get("")
async def list_incidents(status: Optional[str] = None, type: Optional[str] = None): async def list_incidents(
status: Optional[str] = None,
type: Optional[str] = None,
decoded: dict = Depends(require_service_or_firebase_token),
):
filters = {} filters = {}
if status: if status:
filters["status"] = status filters["status"] = status
if type: if type:
filters["type"] = type filters["type"] = type
org_id = await resolve_caller_org_id(decoded)
if org_id is not None:
filters["org_id"] = org_id
return await fstore.collection_list("incidents", **filters) return await fstore.collection_list("incidents", **filters)
@@ -31,10 +43,13 @@ async def summarize_all_stale(
@router.get("/{incident_id}") @router.get("/{incident_id}")
async def get_incident(incident_id: str): async def get_incident(incident_id: str, decoded: dict = Depends(require_service_or_firebase_token)):
doc = await fstore.doc_get("incidents", incident_id) doc = await fstore.doc_get("incidents", incident_id)
if not doc: if not doc:
raise HTTPException(404, f"Incident '{incident_id}' not found.") raise HTTPException(404, f"Incident '{incident_id}' not found.")
org_id = await resolve_caller_org_id(decoded)
if org_id is not None and doc.get("org_id") != org_id:
raise HTTPException(404, f"Incident '{incident_id}' not found.")
return doc return doc
+88 -1
View File
@@ -1,11 +1,13 @@
import asyncio
import random import random
import string import string
from datetime import datetime, timezone, timedelta from datetime import datetime, timezone, timedelta
from uuid import uuid4 from uuid import uuid4
from fastapi import APIRouter, HTTPException, Depends, Request from fastapi import APIRouter, HTTPException, Depends, Request
from firebase_admin import auth as firebase_auth
from pydantic import BaseModel from pydantic import BaseModel
from app.internal import firestore as fstore from app.internal import firestore as fstore
from app.internal.auth import require_firebase_token, require_service_key from app.internal.auth import require_firebase_token, require_service_key, get_role
from app.internal.logger import logger from app.internal.logger import logger
router = APIRouter(prefix="/auth", tags=["auth"]) router = APIRouter(prefix="/auth", tags=["auth"])
@@ -129,6 +131,91 @@ async def unlink(decoded: dict = Depends(require_firebase_token)):
return {"ok": True} return {"ok": True}
# ---------------------------------------------------------------------------
# Org provisioning — SAAS_PLAN.md B4. The client creates the Firebase user
# first (email/password or Google) and calls this with that user's fresh ID
# token, which carries no org_id/org_role claim yet. This is the only route
# that turns "has a Firebase account" into "can read anything" — see
# infra/firestore/firestore.rules and AuthProvider's no-claim guard.
# ---------------------------------------------------------------------------
class SignupBody(BaseModel):
org_name: str
@router.post("/signup")
async def signup(body: SignupBody, decoded: dict = Depends(require_firebase_token)):
"""
Provision a new organization owned by the calling user, or return their
existing one. Idempotent by design: the frontend calls this right after
account creation, and a user who double-submits (or re-runs it after a
refresh) must not end up with two orgs.
"""
uid = decoded["uid"]
existing_org_id = decoded.get("org_id")
if existing_org_id:
org = await fstore.doc_get("organizations", existing_org_id)
if org:
return {"org_id": existing_org_id, "org_name": org.get("name"), "already_provisioned": True}
# Claim points at a deleted/missing org doc — fall through and
# provision a fresh one rather than leaving the account stranded.
org_name = body.org_name.strip()
if not org_name:
raise HTTPException(400, "org_name is required.")
if len(org_name) > 200:
raise HTTPException(400, "org_name is too long.")
org_id = str(uuid4())
now = datetime.now(timezone.utc).isoformat()
# plan_id/subscription_status/stripe_*/seat_limit/node_limit/retention_days
# are all deliberately None — no billing model exists yet (see
# app/internal/tenancy.py). This is the seam a future billing pass writes
# into; nothing today reads or enforces these fields.
await fstore.doc_set("organizations", org_id, {
"org_id": org_id,
"name": org_name,
"created_at": now,
"created_by_uid": uid,
"plan_id": None,
"subscription_status": None,
"stripe_customer_id": None,
"stripe_subscription_id": None,
"current_period_end": None,
"seat_limit": None,
"node_limit": None,
"retention_days": None,
}, merge=False)
await fstore.doc_set("org_members", uid, {
"uid": uid,
"org_id": org_id,
"org_role": "owner",
"email": decoded.get("email"),
"added_at": now,
}, merge=False)
# set_custom_user_claims() replaces the whole claim set, so preserve any
# existing custom claims (owned_node_ids, a platform `role` if this
# account was created via the admin-only POST /admin/users flow, etc.)
# rather than clobbering them. Firebase's own reserved JWT fields are
# stripped out — they aren't settable as custom claims and would raise.
_RESERVED = {
"iss", "aud", "auth_time", "user_id", "sub", "iat", "exp", "uid",
"email", "email_verified", "firebase", "name", "picture",
}
existing_claims = {k: v for k, v in decoded.items() if k not in _RESERVED}
# role: platform-level, orthogonal to org ownership. get_role() falls
# back to "viewer" for a brand-new self-serve signup with no claims yet.
claims = {**existing_claims, "org_id": org_id, "org_role": "owner", "role": get_role(decoded)}
await asyncio.to_thread(firebase_auth.set_custom_user_claims, uid, claims)
logger.info(f"Org provisioned: org_id={org_id} name={org_name!r} owner_uid={uid}")
return {"org_id": org_id, "org_name": org_name, "already_provisioned": False}
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Session recording — called by the frontend on each successful sign-in # Session recording — called by the frontend on each successful sign-in
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+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",
},
)
+138 -7
View File
@@ -1,25 +1,39 @@
import secrets import secrets
from typing import Optional from typing import Optional
from fastapi import APIRouter, HTTPException, Depends, Query from fastapi import APIRouter, HTTPException, Depends, Query
from pydantic import BaseModel
from app.models import CommandPayload from app.models import CommandPayload
from app.internal import firestore as fstore from app.internal import firestore as fstore
from app.internal.mqtt_handler import mqtt_handler from app.internal.mqtt_handler import mqtt_handler
from app.internal.auth import require_admin_token, require_service_key_or_admin from app.internal import dynsec
from app.internal.logger import logger
from app.internal.auth import (
require_admin_token,
require_service_key_or_admin,
require_service_or_firebase_token,
resolve_caller_org_id,
)
from app.routers.tokens import assign_token, release_token from app.routers.tokens import assign_token, release_token
router = APIRouter(prefix="/nodes", tags=["nodes"]) router = APIRouter(prefix="/nodes", tags=["nodes"])
@router.get("") @router.get("")
async def list_nodes(): async def list_nodes(decoded: dict = Depends(require_service_or_firebase_token)):
org_id = await resolve_caller_org_id(decoded)
if org_id is None: # service key or platform admin — unrestricted, matches prior behaviour
return await fstore.collection_list("nodes") return await fstore.collection_list("nodes")
return await fstore.collection_list("nodes", org_id=org_id)
@router.get("/{node_id}") @router.get("/{node_id}")
async def get_node(node_id: str): async def get_node(node_id: str, decoded: dict = Depends(require_service_or_firebase_token)):
node = await fstore.doc_get("nodes", node_id) node = await fstore.doc_get("nodes", node_id)
if not node: if not node:
raise HTTPException(404, f"Node '{node_id}' not found.") raise HTTPException(404, f"Node '{node_id}' not found.")
org_id = await resolve_caller_org_id(decoded)
if org_id is not None and node.get("org_id") != org_id:
raise HTTPException(404, f"Node '{node_id}' not found.")
return node return node
@@ -30,8 +44,23 @@ async def approve_node(node_id: str, _: dict = Depends(require_admin_token)):
raise HTTPException(404, f"Node '{node_id}' not found.") raise HTTPException(404, f"Node '{node_id}' not found.")
api_key = secrets.token_hex(32) 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_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"}) 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) mqtt_handler.publish_node_key(node_id, api_key)
return {"ok": True} return {"ok": True}
@@ -41,6 +70,11 @@ async def delete_node(node_id: str, _: dict = Depends(require_admin_token)):
node = await fstore.doc_get("nodes", node_id) node = await fstore.doc_get("nodes", node_id)
if not node: if not node:
raise HTTPException(404, f"Node '{node_id}' not found.") 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("node_keys", node_id)
await fstore.doc_delete("nodes", node_id) await fstore.doc_delete("nodes", node_id)
@@ -101,7 +135,15 @@ async def reissue_node_key(node_id: str, _: dict = Depends(require_admin_token))
if not node: if not node:
raise HTTPException(404, f"Node '{node_id}' not found.") raise HTTPException(404, f"Node '{node_id}' not found.")
api_key = secrets.token_hex(32) 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) 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) mqtt_handler.publish_node_key(node_id, api_key)
return {"ok": True} return {"ok": True}
@@ -126,10 +168,13 @@ async def assign_system(
if not system: if not system:
raise HTTPException(404, f"System '{system_id}' not found.") raise HTTPException(404, f"System '{system_id}' not found.")
# Include hardware preset in the push so the edge node applies it when # Include hardware preset, node type, and enforce timeout in the push
# generating the OP25 config. Strip it from the system doc first so it push_payload = {
# doesn't collide with SystemConfig field validation on the node side. **system,
push_payload = {**system, "hardware_preset": hardware_preset} "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: if ppm_override is not None:
push_payload["ppm_override"] = ppm_override push_payload["ppm_override"] = ppm_override
mqtt_handler.push_config(node_id, push_payload) mqtt_handler.push_config(node_id, push_payload)
@@ -145,3 +190,89 @@ async def assign_system(
await fstore.doc_update("nodes", node_id, node_updates) await fstore.doc_update("nodes", node_id, node_updates)
return {"ok": True} 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}
+121
View File
@@ -0,0 +1,121 @@
"""
Organization-scoped routes.
Two things live here:
1. Org profile (name) — closes the "Save changes" button that's been
disabled in app/settings/organization since there was no organizations
concept server-side to save into (see DEFERRED.md, now resolved).
2. Per-org enrollment tokens (SAAS_PLAN.md B2b) — the credential that lets
a customer's own node join THEIR org specifically. Before this, every
node enrolled with the same fleet-wide ENROLLMENT_TOKEN
(routers/enrollment.py), which had no way to say which org a newly
enrolled node belonged to — every node landed in the same pool.
"""
import hashlib
import secrets
from datetime import datetime, timezone
from fastapi import APIRouter, HTTPException, Depends
from pydantic import BaseModel
from app.internal import firestore as fstore
from app.internal.auth import require_firebase_token, require_org, require_org_owner_token
from app.internal.logger import logger
router = APIRouter(prefix="/org", tags=["org"])
def _hash_token(token: str) -> str:
return hashlib.sha256(token.encode()).hexdigest()
# ---------------------------------------------------------------------------
# Org profile
# ---------------------------------------------------------------------------
@router.get("")
async def get_org(decoded: dict = Depends(require_firebase_token)):
"""Any member of the org (owner or member) can read the org profile."""
org_id = require_org(decoded)
org = await fstore.doc_get("organizations", org_id)
if not org:
raise HTTPException(404, "Organization not found.")
return org
class OrgUpdateBody(BaseModel):
name: str
@router.patch("")
async def update_org(body: OrgUpdateBody, decoded: dict = Depends(require_org_owner_token)):
org_id = require_org(decoded)
name = body.name.strip()
if not name:
raise HTTPException(400, "name must not be empty.")
await fstore.doc_update("organizations", org_id, {"name": name})
return {"ok": True, "name": name}
# ---------------------------------------------------------------------------
# Enrollment tokens — mint/list/revoke. Minting and revoking are owner-only
# (this is fleet-security-sensitive, same tier as node approval); any org
# member can list them (metadata only, never the raw value) since anyone on
# the team might be the one physically standing up the next node.
# ---------------------------------------------------------------------------
class MintTokenBody(BaseModel):
label: str
class MintTokenResponse(BaseModel):
token_id: str
token: str # raw value — returned exactly once, never again, never stored
label: str
@router.post("/enrollment-tokens", response_model=MintTokenResponse)
async def mint_enrollment_token(body: MintTokenBody, decoded: dict = Depends(require_org_owner_token)):
org_id = require_org(decoded)
label = body.label.strip() or "Unnamed token"
raw = secrets.token_hex(24)
token_hash = _hash_token(raw)
now = datetime.now(timezone.utc).isoformat()
# Doc id IS the hash (matches enrollment.py's pickup_secret_hash pattern) —
# also stored as a field so list/delete below don't need a second lookup.
await fstore.doc_set("enrollment_tokens", token_hash, {
"token_hash": token_hash,
"org_id": org_id,
"label": label,
"created_at": now,
"created_by_uid": decoded.get("uid"),
"revoked": False,
"uses": 0,
}, merge=False)
logger.info(f"Enrollment token minted for org={org_id!r} label={label!r} by uid={decoded.get('uid')}")
return MintTokenResponse(token_id=token_hash, token=raw, label=label)
@router.get("/enrollment-tokens")
async def list_enrollment_tokens(decoded: dict = Depends(require_firebase_token)):
org_id = require_org(decoded)
tokens = await fstore.collection_list("enrollment_tokens", org_id=org_id)
return [
{
"token_id": t.get("token_hash"),
"label": t.get("label"),
"created_at": t.get("created_at"),
"revoked": t.get("revoked", False),
"uses": t.get("uses", 0),
}
for t in tokens
]
@router.delete("/enrollment-tokens/{token_id}")
async def revoke_enrollment_token(token_id: str, decoded: dict = Depends(require_org_owner_token)):
org_id = require_org(decoded)
doc = await fstore.doc_get("enrollment_tokens", token_id)
if not doc or doc.get("org_id") != org_id:
raise HTTPException(404, "Enrollment token not found.")
await fstore.doc_update("enrollment_tokens", token_id, {"revoked": True})
logger.info(f"Enrollment token revoked: org={org_id!r} token_id={token_id}")
return {"ok": True}
+22 -6
View File
@@ -1,10 +1,16 @@
import uuid import uuid
from fastapi import APIRouter, HTTPException, Depends from fastapi import APIRouter, HTTPException, Depends, Query
from pydantic import BaseModel from pydantic import BaseModel
from typing import Dict, Optional from typing import Dict, Optional
from app.models import SystemCreate, SystemRecord from app.models import SystemCreate, SystemRecord
from app.internal import firestore as fstore from app.internal import firestore as fstore
from app.internal.auth import require_admin_token, bootstrap_limiter from app.internal.auth import (
require_admin_token,
require_node_service_or_firebase_token,
resolve_caller_org_id,
bootstrap_limiter,
)
from app.internal.tenancy import FOUNDING_ORG_ID
router = APIRouter(prefix="/systems", tags=["systems"]) router = APIRouter(prefix="/systems", tags=["systems"])
@@ -23,22 +29,32 @@ class AiFlagsBody(BaseModel):
@router.get("") @router.get("")
async def list_systems(): async def list_systems(decoded: dict = Depends(require_node_service_or_firebase_token)):
org_id = await resolve_caller_org_id(decoded)
if org_id is None: # service key or platform admin — unrestricted, matches prior behaviour
return await fstore.collection_list("systems") return await fstore.collection_list("systems")
return await fstore.collection_list("systems", org_id=org_id)
@router.get("/{system_id}") @router.get("/{system_id}")
async def get_system(system_id: str): async def get_system(system_id: str, decoded: dict = Depends(require_node_service_or_firebase_token)):
system = await fstore.doc_get("systems", system_id) system = await fstore.doc_get("systems", system_id)
if not system: if not system:
raise HTTPException(404, f"System '{system_id}' not found.") raise HTTPException(404, f"System '{system_id}' not found.")
org_id = await resolve_caller_org_id(decoded)
if org_id is not None and system.get("org_id") != org_id:
raise HTTPException(404, f"System '{system_id}' not found.")
return system return system
@router.post("", status_code=201) @router.post("", status_code=201)
async def create_system(body: SystemCreate, _: dict = Depends(require_admin_token)): async def create_system(
body: SystemCreate,
org_id: Optional[str] = Query(None, description="Platform-admin only — defaults to the founding org."),
_: dict = Depends(require_admin_token),
):
system_id = str(uuid.uuid4()) system_id = str(uuid.uuid4())
doc = SystemRecord(system_id=system_id, **body.model_dump()) doc = SystemRecord(system_id=system_id, org_id=org_id or FOUNDING_ORG_ID, **body.model_dump())
await fstore.doc_set("systems", system_id, doc.model_dump(), merge=False) await fstore.doc_set("systems", system_id, doc.model_dump(), merge=False)
return doc return doc
+26 -4
View File
@@ -13,8 +13,10 @@ from app.internal.auth import (
require_service_or_firebase_token, require_service_or_firebase_token,
require_service_key, require_service_key,
require_service_key_or_admin, require_service_key_or_admin,
get_role,
trip_chat_limiter, trip_chat_limiter,
) )
from app.internal.tenancy import FOUNDING_ORG_ID
router = APIRouter(prefix="/trips", tags=["trips"]) router = APIRouter(prefix="/trips", tags=["trips"])
@@ -23,6 +25,22 @@ router = APIRouter(prefix="/trips", tags=["trips"])
# Access control helpers # Access control helpers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _require_founding_org(decoded: dict) -> None:
"""
Trips is an internal utility feature riding along on this stack, not a
tenant-scoped product surface (see [[trips-feature-intentional]] and
SAAS_PLAN.md B7/B2c) — it has no org_id on its documents and isn't
getting one in this pass. Restricting mutations to the founding org (plus
the bot's service key, and platform admins for support) is how it stays
usable for its original purpose without becoming a write surface every
new customer org can reach into.
"""
if decoded.get("service") or get_role(decoded) == "admin":
return
if decoded.get("org_id") != FOUNDING_ORG_ID:
raise HTTPException(403, "Trip planning is available to the founding org only.")
async def _discord_id_for_firebase(firebase_uid: str) -> Optional[str]: async def _discord_id_for_firebase(firebase_uid: str) -> Optional[str]:
link = await fstore.doc_get("firebase_discord_links", firebase_uid) link = await fstore.doc_get("firebase_discord_links", firebase_uid)
return (link or {}).get("discord_user_id") return (link or {}).get("discord_user_id")
@@ -224,7 +242,8 @@ async def list_trips(decoded: dict = Depends(require_service_or_firebase_token))
@router.post("") @router.post("")
async def create_trip(body: TripCreate): async def create_trip(body: TripCreate, decoded: dict = Depends(require_service_or_firebase_token)):
_require_founding_org(decoded)
if body.end_date < body.start_date: if body.end_date < body.start_date:
raise HTTPException(400, "end_date must be on or after start_date.") raise HTTPException(400, "end_date must be on or after start_date.")
trip_id = str(uuid.uuid4()) trip_id = str(uuid.uuid4())
@@ -263,8 +282,9 @@ async def get_trip(trip_id: str, decoded: dict = Depends(require_service_or_fire
@router.put("/{trip_id}/tags") @router.put("/{trip_id}/tags")
async def update_trip_tags(trip_id: str, body: dict): async def update_trip_tags(trip_id: str, body: dict, decoded: dict = Depends(require_service_or_firebase_token)):
"""Replace the trip's available tag list and overlap-allowed tag list.""" """Replace the trip's available tag list and overlap-allowed tag list."""
_require_founding_org(decoded)
trip = await fstore.doc_get("trips", trip_id) trip = await fstore.doc_get("trips", trip_id)
if not trip: if not trip:
raise HTTPException(404, f"Trip '{trip_id}' not found.") raise HTTPException(404, f"Trip '{trip_id}' not found.")
@@ -363,7 +383,8 @@ async def leave_trip(
@router.post("/{trip_id}/events") @router.post("/{trip_id}/events")
async def create_event(trip_id: str, body: TripEventCreate): async def create_event(trip_id: str, body: TripEventCreate, decoded: dict = Depends(require_service_or_firebase_token)):
_require_founding_org(decoded)
trip = await fstore.doc_get("trips", trip_id) trip = await fstore.doc_get("trips", trip_id)
if not trip: if not trip:
raise HTTPException(404, f"Trip '{trip_id}' not found.") raise HTTPException(404, f"Trip '{trip_id}' not found.")
@@ -396,7 +417,8 @@ async def create_event(trip_id: str, body: TripEventCreate):
@router.patch("/{trip_id}/events/{event_id}") @router.patch("/{trip_id}/events/{event_id}")
async def update_event(trip_id: str, event_id: str, body: TripEventUpdate): async def update_event(trip_id: str, event_id: str, body: TripEventUpdate, decoded: dict = Depends(require_service_or_firebase_token)):
_require_founding_org(decoded)
event = await fstore.doc_get("trip_events", event_id) event = await fstore.doc_get("trip_events", event_id)
if not event or event.get("trip_id") != trip_id: if not event or event.get("trip_id") != trip_id:
raise HTTPException(404, f"Event '{event_id}' not found in trip '{trip_id}'.") raise HTTPException(404, f"Event '{event_id}' not found in trip '{trip_id}'.")
+30 -21
View File
@@ -2,6 +2,7 @@ from typing import Optional
from fastapi import APIRouter, BackgroundTasks, UploadFile, File, Form, HTTPException, Security from fastapi import APIRouter, BackgroundTasks, UploadFile, File, Form, HTTPException, Security
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from app.internal.storage import upload_audio from app.internal.storage import upload_audio
from app.internal import dedup
from app.internal import firestore as fstore from app.internal import firestore as fstore
from app.internal.logger import logger from app.internal.logger import logger
from app.config import settings from app.config import settings
@@ -47,16 +48,38 @@ async def upload_call_audio(
if len(data) > settings.upload_max_bytes: if len(data) > settings.upload_max_bytes:
raise HTTPException(413, f"File too large (max {settings.upload_max_bytes // (1024*1024)} MB).") 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: 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.
# org_id is stamped defensively here too (not just in
# mqtt_handler.py's call_start/call_end): key_doc above proves this
# node_id is real and authenticated, so resolving org_id from the
# node doc here covers a call whose Firestore doc was somehow
# never written by call_start (the upload is otherwise the
# authoritative record of which node this audio came from).
node = await fstore.doc_get_cached("nodes", node_id)
updates = {"audio_gcs_uri": gcs_uri}
if node and node.get("org_id"):
updates["org_id"] = node["org_id"]
await fstore.doc_set("calls", call_id, updates)
except Exception as e: 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 # Another node in range recorded the same transmission. Keep the audio
gcs_uri = _public_url_to_gcs_uri(audio_url) # (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( background_tasks.add_task(
_run_intelligence_pipeline, _run_intelligence_pipeline,
@@ -68,21 +91,7 @@ async def upload_call_audio(
gcs_uri=gcs_uri, gcs_uri=gcs_uri,
) )
return {"url": audio_url} return {"url": gcs_uri}
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
async def _correlate_with_consensus( async def _correlate_with_consensus(
+57
View File
@@ -0,0 +1,57 @@
"""
Public waitlist submission — no self-serve org creation is promised here,
just "we'll get back to you". Not coupled to any plan/tier: the commercial
model (participation-based access, not per-seat SaaS — see
app/internal/tenancy.py) is still being defined separately, so this route
only ever writes {email, org_name, note} and never a plan_id.
Unauthenticated by design (a prospect has no account yet), so the only
protection against abuse is source-IP rate limiting via the shared
_RateLimiter (app/internal/auth.py).
"""
from datetime import datetime, timezone
from typing import Optional
from uuid import uuid4
from fastapi import APIRouter, Request
from pydantic import BaseModel, field_validator
from app.internal import firestore as fstore
from app.internal.auth import waitlist_limiter
from app.internal.logger import logger
router = APIRouter(tags=["waitlist"])
class WaitlistBody(BaseModel):
# Plain str, not pydantic.EmailStr — EmailStr needs the email-validator
# package, which isn't in requirements.txt, and adding a dependency for
# one light check wasn't worth it. Good-enough sanity check only; this
# is a marketing capture form, not an auth path.
email: str
org_name: Optional[str] = None
note: Optional[str] = None
@field_validator("email")
@classmethod
def _basic_email_shape(cls, v: str) -> str:
v = v.strip()
if "@" not in v or " " in v or len(v) > 254:
raise ValueError("Enter a valid email address.")
return v
@router.post("/waitlist", status_code=201)
async def join_waitlist(body: WaitlistBody, request: Request):
client_ip = request.client.host if request.client else "unknown"
waitlist_limiter.check(client_ip)
entry_id = str(uuid4())
await fstore.doc_set("waitlist", entry_id, {
"entry_id": entry_id,
"email": body.email.lower(),
"org_name": (body.org_name or "").strip() or None,
"note": (body.note or "").strip()[:2000] or None,
"created_at": datetime.now(timezone.utc).isoformat(),
"source_ip": client_ip,
}, merge=False)
logger.info(f"Waitlist signup: {body.email!r} (org_name={body.org_name!r})")
return {"ok": True}
-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 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 # Internal, plaintext — c2-core's own connection only (its dynsec-admin
password_file /tmp/passwd # control-plane calls AND its regular data-plane pub/sub both use this).
acl_file /mosquitto/config/acl.conf # 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 true
persistence_location /mosquitto/data/ persistence_location /mosquitto/data/
+186
View File
@@ -0,0 +1,186 @@
#!/usr/bin/env python3
"""
Backfill org_id onto every pre-tenancy document and create the founding org.
*** WRITE-ONLY REFERENCE — NOT RUN AS PART OF THIS CHANGE. ***
SAAS_PLAN.md B2 explicitly says "write it; do not run it" — this script
touches production Firestore (organizations, org_members, nodes, systems,
calls, incidents, alert_rules, alert_events) and Firebase Auth custom
claims. Read this whole docstring before ever running it anywhere.
WHY IT'S NEEDED: as of this pass, every doc in the six tenant collections
below predates the org_id field entirely (org_id is Optional[...] = None on
every model in app/models.py specifically to allow this). c2-core's read
routes and infra/firestore/firestore.rules now filter/require org_id, so
until this runs, pre-existing docs are invisible through the org-scoped
paths — they still exist, they're just unreachable by a caller whose token
carries an org_id claim. New docs created going forward (enrollment.py,
mqtt_handler.py, upload.py, incident_correlator.py) already stamp org_id
themselves; this script only needs to run ONCE, retroactively, and is safe
to re-run after that (idempotent — see below).
WHAT IT DOES:
1. Creates organizations/{FOUNDING_ORG_ID} if it doesn't already exist.
FOUNDING_ORG_ID ("founding") is the same id app/internal/tenancy.py
defines and the same id enrollment.py's legacy fleet-wide
ENROLLMENT_TOKEN fallback and mqtt_handler.py's legacy MQTT-checkin
path both already resolve brand-new nodes into — so a node that
enrolled the old way and a doc backfilled by this script end up in the
same org.
2. Sets --owner-email's org_id/org_role Firebase custom claims and writes
their org_members doc — the same shape POST /auth/signup writes for a
self-serve org, so this person becomes the founding org's owner in the
UI exactly as if they'd signed up normally. Their platform `role`
claim is left alone if already set, else defaults to "admin" (the
backfill owner is presumed to be today's single-tenant deployment's
admin).
3. Walks nodes / systems / calls / incidents / alert_rules / alert_events
and stamps org_id = FOUNDING_ORG_ID onto every document that doesn't
already have one. A document that already has org_id (from the
post-tenancy code paths that shipped alongside this script) is left
untouched — this is what makes a second run a no-op rather than a
re-stamp, so running it twice by accident is harmless.
USAGE (run from the drb-c2-core directory, with GCP_CREDENTIALS_PATH set or
Application Default Credentials available — same auth as set_admin.py):
python scripts/backfill_org_id.py --owner-email you@example.com --dry-run
python scripts/backfill_org_id.py --owner-email you@example.com
ALWAYS run with --dry-run first and read every line of its output — it
prints exactly what would be created/changed, with no writes, before you
run it for real. --dry-run performs full collection scans (read-only) to
produce accurate counts; on a large calls/incidents collection this is not
free, but it is the only way to know the real backfill count in advance.
NOT HANDLED: org_api_keys (collection doesn't exist server-side yet — see
DEFERRED.md) and node_keys (deliberately never gets an org_id column; it's
looked up by node_id / api_key value, not read as an org-scoped list).
"""
import argparse
import os
import sys
from datetime import datetime, timezone
import firebase_admin
from firebase_admin import auth, credentials, firestore
# Mirrors app/internal/tenancy.py — duplicated rather than imported so this
# script has no dependency on the app package (or its settings/env) being
# importable from wherever it's actually run.
FOUNDING_ORG_ID = "founding"
TENANT_COLLECTIONS = ["nodes", "systems", "calls", "incidents", "alert_rules", "alert_events"]
# Firestore batched writes cap at 500 operations; stay comfortably under it.
_BATCH_SIZE = 400
def main() -> None:
parser = argparse.ArgumentParser(
description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument("--owner-email", required=True, help="Firebase user who becomes the founding org's owner")
parser.add_argument("--org-name", default="Founding Org", help="Display name for the founding org")
parser.add_argument("--dry-run", action="store_true", help="Print what would change; write nothing")
args = parser.parse_args()
creds_path = os.getenv("GCP_CREDENTIALS_PATH", "gcp-key.json")
cred = credentials.Certificate(creds_path)
firebase_admin.initialize_app(cred)
db = firestore.client()
try:
owner = auth.get_user_by_email(args.owner_email)
except auth.UserNotFoundError:
print(f"No Firebase user found for {args.owner_email!r}")
sys.exit(1)
now = datetime.now(timezone.utc).isoformat()
existing_claims = owner.custom_claims or {}
org_ref = db.collection("organizations").document(FOUNDING_ORG_ID)
org_exists = org_ref.get().exists
print(f"organizations/{FOUNDING_ORG_ID}: {'exists — left alone' if org_exists else 'WILL CREATE'}")
print(f"org_members/{owner.uid}: WILL SET org_role='owner' (email={args.owner_email})")
print(
f"Firebase custom claims for {args.owner_email}: WILL SET org_id={FOUNDING_ORG_ID!r} org_role='owner', "
f"role={existing_claims.get('role', 'admin (default)')!r}"
)
counts: dict[str, tuple[int, int]] = {}
total_missing = 0
for collection in TENANT_COLLECTIONS:
docs = list(db.collection(collection).stream())
missing = [d for d in docs if not (d.to_dict() or {}).get("org_id")]
counts[collection] = (len(docs), len(missing))
total_missing += len(missing)
print(f"{collection}: {len(docs)} docs total, {len(missing)} missing org_id")
print(f"\nTotal documents to backfill: {total_missing}")
if args.dry_run:
print("\n--dry-run: no writes performed.")
return
if not org_exists:
org_ref.set({
"org_id": FOUNDING_ORG_ID,
"name": args.org_name,
"created_at": now,
"created_by_uid": owner.uid,
# Inert placeholders — no billing model exists yet, see
# app/internal/tenancy.py and models.py's OrganizationRecord.
"plan_id": None,
"subscription_status": None,
"stripe_customer_id": None,
"stripe_subscription_id": None,
"current_period_end": None,
"seat_limit": None,
"node_limit": None,
"retention_days": None,
})
print(f"Created organizations/{FOUNDING_ORG_ID}.")
db.collection("org_members").document(owner.uid).set({
"uid": owner.uid,
"org_id": FOUNDING_ORG_ID,
"org_role": "owner",
"email": args.owner_email,
"added_at": now,
}, merge=True)
print(f"Set org_members/{owner.uid}.")
new_claims = {**existing_claims, "org_id": FOUNDING_ORG_ID, "org_role": "owner"}
new_claims.setdefault("role", "admin")
auth.set_custom_user_claims(owner.uid, new_claims)
print(f"Set custom claims for {args.owner_email}.")
for collection in TENANT_COLLECTIONS:
_, missing_count = counts[collection]
if not missing_count:
print(f"{collection}: nothing to backfill.")
continue
batch = db.batch()
batch_count = 0
written = 0
for doc in db.collection(collection).stream():
if (doc.to_dict() or {}).get("org_id"):
continue
batch.update(doc.reference, {"org_id": FOUNDING_ORG_ID})
batch_count += 1
written += 1
if batch_count >= _BATCH_SIZE:
batch.commit()
batch = db.batch()
batch_count = 0
if batch_count:
batch.commit()
print(f"{collection}: backfilled {written} document(s).")
print("\nDone. The owner must sign out and back in (or wait up to 1 hour) for the new claims to take effect.")
if __name__ == "__main__":
main()
+60 -1
View File
@@ -1,2 +1,61 @@
# All C2 core settings have defaults — no env setup needed. # 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()
+249
View File
@@ -0,0 +1,249 @@
"""
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, _normalize_unit, _matching_units,
)
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.parametrize("idle_min", [1.0, 3.4, 4.9])
def test_thin_call_still_attaches_inside_the_tier2_window(idle_min):
inc = _incident(idle_min)
assert _run_decision(_ctx(all_active=[inc], recent=[inc]))["action"] == "link"
@pytest.mark.parametrize("idle_min", [5.1, 8.2, 9.7])
def test_thin_call_does_not_attach_after_the_channel_has_moved_on(idle_min):
"""
A '10-4' arriving many minutes into silence is new traffic, not a reply. The
old 10-minute window let one incident swallow an unrelated event 9.6 min
later; being the *only* candidate is not evidence, it just means the channel
was quiet, which is when the guess is weakest.
"""
inc = _incident(idle_min)
assert _run_decision(_ctx(all_active=[inc], recent=[inc]))["action"] == "orphan"
@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
# ---------------------------------------------------------------------------
# Unit-ID normalisation — dispatch names the same unit several ways
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("spoken,other", [
("K-9A2", "K-9-A-2"), # punctuation only
("5-1-6", "516"), # digits read out individually
("37", "37th Post"), # ordinal + role word
("11-Victor", "11 Victor"), # hyphen vs space
("Post 5", "5"), # bare role word
("post 1-2", "Post 1-2"), # case
])
def test_same_unit_spoken_differently_normalises_alike(spoken, other):
"""Every pair here was observed as one real unit failing to match itself."""
assert _normalize_unit(spoken) == _normalize_unit(other)
@pytest.mark.parametrize("a,b", [
("6-Adam", "Adam"), # every district has an Adam — must stay distinct
("6-Adam", "7-Adam"),
("11-Victor", "11-Xray"),
("516", "517"),
("3", "39"),
])
def test_genuinely_different_units_stay_distinct(a, b):
assert _normalize_unit(a) != _normalize_unit(b)
def test_role_only_unit_does_not_collapse_to_empty():
"""
"Post" is all noise words. Normalising it to "" would make every such unit
equal to every other, so it falls back to the raw text instead.
"""
assert _normalize_unit("Post") != ""
assert _normalize_unit("Post") != _normalize_unit("Unit")
def test_matching_units_reports_the_original_spoken_strings():
"""Debug output has to stay readable, so matches come back un-normalised."""
assert _matching_units(["K-9A2", "6-Adam"], ["K-9-A-2"]) == ["K-9A2"]
def test_matching_units_empty_when_nothing_overlaps():
assert _matching_units(["6-Adam"], ["7-Adam", "516"]) == []
def test_normalised_units_link_a_call_that_exact_match_would_orphan():
"""End-to-end: the K-9A2 case that orphaned in the 2026-08-17 01:05Z dump."""
inc = _incident(2.0)
inc["units"] = ["K-9-A-2"]
decision = _run_decision(_ctx(
all_active=[inc], recent=[inc],
call_units=["K-9A2"], is_thin_call=False, call_severity="routine",
))
assert decision["action"] == "link"
+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 ./ COPY package.json ./
RUN npm install RUN npm install
COPY . . 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 RUN npm run build
FROM node:20-slim AS runner FROM node:20-slim AS runner
+77 -27
View File
@@ -1,21 +1,52 @@
"use client"; "use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useNodes, useUnconfiguredNodes } from "@/lib/useNodes"; import { useNodes, useUnconfiguredNodes } from "@/lib/useNodes";
import { useCalls, useActiveCalls } from "@/lib/useCalls"; import { useCalls, useActiveCalls } from "@/lib/useCalls";
import { useSystems } from "@/lib/useSystems"; import { useSystems } from "@/lib/useSystems";
import { useActiveIncidents } from "@/lib/useIncidents";
import { NodeCard } from "@/components/NodeCard"; import { NodeCard } from "@/components/NodeCard";
import { CallRow } from "@/components/CallRow"; import { CallRow } from "@/components/CallRow";
import { NodeConfigModal } from "@/components/NodeConfigModal"; import { NodeConfigModal } from "@/components/NodeConfigModal";
import { TypeBadge } from "@/components/IncidentBadges";
import { severityBadge, severityRank } from "@/lib/severity";
import { useState } from "react"; import { useState } from "react";
import type { NodeRecord } from "@/lib/types"; import type { NodeRecord, IncidentRecord } from "@/lib/types";
import { useAuth } from "@/components/AuthProvider"; 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 }) { function StatCard({ label, value, accent }: { label: string; value: string | number; accent?: string }) {
return ( 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-xs text-gray-500 uppercase tracking-wider mb-1">{label}</p>
<p className={`text-3xl font-bold font-mono ${accent ?? "text-white"}`}>{value}</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> </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 { calls, error: callsError } = useCalls(20);
const activeCalls = useActiveCalls(); const activeCalls = useActiveCalls();
const { systems, error: systemsError } = useSystems(); const { systems, error: systemsError } = useSystems();
const activeIncidents = useActiveIncidents();
const [configNode, setConfigNode] = useState<NodeRecord | null>(null); const [configNode, setConfigNode] = useState<NodeRecord | null>(null);
const { isAdmin } = useAuth(); const { isAdmin } = useAuth();
@@ -33,44 +65,66 @@ export default function DashboardPage() {
const fsError = nodesError ?? callsError ?? systemsError; const fsError = nodesError ?? callsError ?? systemsError;
return ( // Worst-first: the incident that most needs a human's attention leads the panel.
<div className="space-y-6"> const sortedIncidents = [...activeIncidents].sort(
<h1 className="text-xl font-bold text-white font-mono">Dashboard</h1> (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 && ( return (
<div className="bg-red-950 border border-red-800 rounded-lg p-4"> <div className="space-y-8">
<p className="text-red-400 text-sm font-mono">Firestore error: {fsError}</p> <PageHeader
</div> title="Dashboard"
)} badge={notableIncidentCount > 0 && <Badge tone="danger">{notableIncidentCount} moderate+ active</Badge>}
/>
{fsError && <ErrorBanner message={`Firestore error: ${fsError}`} />}
{/* Pending config banner */} {/* Pending config banner */}
{pending.length > 0 && ( {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"> <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. {pending.length} new node{pending.length > 1 ? "s" : ""} connected and need{pending.length === 1 ? "s" : ""} configuration.
</p> </p>
<button <Button size="sm" onClick={() => setConfigNode(pending[0])}>Configure now</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>
</div> </div>
)} )}
{/* Stats */} {/* Stats */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4"> <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="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="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} /> <StatCard label="Systems" value={systems.length} />
</div> </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 */} {/* Nodes */}
<section> <section>
<h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-3">Nodes</h2> <h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-3">Nodes</h2>
{nodes.length === 0 ? ( {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"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{nodes.map((n) => ( {nodes.map((n) => (
@@ -84,9 +138,9 @@ export default function DashboardPage() {
<section> <section>
<h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-3">Recent Calls</h2> <h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-3">Recent Calls</h2>
{calls.length === 0 ? ( {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"> <table className="w-full text-sm">
<thead> <thead>
<tr className="text-xs text-gray-500 uppercase tracking-wider border-b border-gray-800"> <tr className="text-xs text-gray-500 uppercase tracking-wider border-b border-gray-800">
@@ -104,16 +158,12 @@ export default function DashboardPage() {
))} ))}
</tbody> </tbody>
</table> </table>
</div> </Card>
)} )}
</section> </section>
{configNode && ( {configNode && (
<NodeConfigModal <NodeConfigModal node={configNode} systems={systems} onClose={() => setConfigNode(null)} />
node={configNode}
systems={systems}
onClose={() => setConfigNode(null)}
/>
)} )}
</div> </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 { html:not(.dark) textarea::placeholder {
color: #94a3b8; 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 { CallRow } from "@/components/CallRow";
import { c2api } from "@/lib/c2api"; import { c2api } from "@/lib/c2api";
import type { IncidentRecord } from "@/lib/types"; 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 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"] }) { function StatusBadge({ status }: { status: IncidentRecord["status"] }) {
return ( return (
<span className={`text-xs px-2 py-0.5 rounded-full font-mono ${ <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"> <div className="flex items-center gap-2 flex-wrap">
<TypeBadge type={incident.type} /> <TypeBadge type={incident.type} />
<StatusBadge status={incident.status} /> <StatusBadge status={incident.status} />
{severityBadge(incident.severity)}
</div> </div>
<h1 className="text-lg sm:text-xl font-bold text-white font-mono leading-snug"> <h1 className="text-lg sm:text-xl font-bold text-white font-mono leading-snug">
{incident.title ?? "Incident"} {incident.title ?? "Incident"}
+179 -165
View File
@@ -1,49 +1,42 @@
"use client"; "use client";
import { useMemo, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useAuth } from "@/components/AuthProvider"; import { useAuth } from "@/components/AuthProvider";
import { useIncidents } from "@/lib/useIncidents"; import { useIncidents } from "@/lib/useIncidents";
import { c2api } from "@/lib/c2api"; import { c2api } from "@/lib/c2api";
import type { IncidentRecord } from "@/lib/types"; 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> = { // Severity badge/ordering now lives in lib/severity.ts (shared with CallRow).
fire: "bg-red-900 text-red-300", // `severityBadge()` already returns null for the legacy "unknown" value.
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",
};
const SEVERITY_COLORS: Record<string, string> = { type SeverityFilter = "all" | "minor" | "moderate" | "major";
major: "bg-red-950 text-red-400", const SEVERITY_FILTERS: { key: SeverityFilter; label: string }[] = [
moderate: "bg-orange-950 text-orange-400", { key: "all", label: "All" },
minor: "bg-gray-800 text-gray-400", { 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) { type SortMode = "recent" | "severity";
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>
);
}
function fmtTime(iso: string) { function fmtTime(iso: string) {
try { return new Date(iso).toLocaleString(); } catch { return iso; } try { return new Date(iso).toLocaleString(); } catch { return iso; }
} }
// ---------------------------------------------------------------------------
// Rows / cards
// ---------------------------------------------------------------------------
function IncidentRow({ incident, isAdmin, onResolve }: { function IncidentRow({ incident, isAdmin, onResolve }: {
incident: IncidentRecord; incident: IncidentRecord;
isAdmin: boolean; isAdmin: boolean;
@@ -53,19 +46,13 @@ function IncidentRow({ incident, isAdmin, onResolve }: {
return ( return (
<tr <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}`)} 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 text-white text-sm">{incident.title ?? "—"}</td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<span className={`text-xs px-2 py-0.5 rounded-full ${ <Badge tone={incident.status === "active" ? "success" : "neutral"}>{incident.status}</Badge>
incident.status === "active"
? "bg-green-900 text-green-300"
: "bg-gray-800 text-gray-400"
}`}>
{incident.status}
</span>
</td> </td>
<td className="px-4 py-3">{severityBadge(incident.severity)}</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> <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 text-gray-400 text-xs font-mono">{fmtTime(incident.updated_at)}</td>
<td className="px-4 py-3"> <td className="px-4 py-3">
{isAdmin && incident.status === "active" && ( {isAdmin && incident.status === "active" && (
<button <Button
size="sm" variant="secondary"
onClick={(e) => { e.stopPropagation(); onResolve(incident.incident_id); }} 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 Resolve
</button> </Button>
)} )}
</td> </td>
</tr> </tr>
); );
} }
function CreateModal({ onClose, onCreate }: { function IncidentCards({ incidents, isAdmin, onResolve }: {
onClose: () => void; incidents: IncidentRecord[];
onCreate: (body: object) => Promise<void>; 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 [title, setTitle] = useState("");
const [type, setType] = useState("other"); const [type, setType] = useState("other");
const [summary, setSummary] = useState(""); const [summary, setSummary] = useState("");
@@ -106,11 +165,8 @@ function CreateModal({ onClose, onCreate }: {
} }
return ( return (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50"> <div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4">
<form <form onSubmit={handleSubmit} className="bg-gray-900 border border-gray-700 rounded-xl p-6 w-full max-w-md space-y-4">
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> <h2 className="text-white font-bold">Create Incident</h2>
<div> <div>
<label className="text-xs text-gray-400 block mb-1">Title</label> <label className="text-xs text-gray-400 block mb-1">Title</label>
@@ -138,114 +194,37 @@ function CreateModal({ onClose, onCreate }: {
/> />
</div> </div>
<div className="flex gap-3 justify-end"> <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"> <Button type="button" variant="ghost" onClick={onClose}>Cancel</Button>
Cancel <Button type="submit" disabled={saving}>{saving ? "Creating…" : "Create"}</Button>
</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>
</div> </div>
</form> </form>
</div> </div>
); );
} }
function IncidentCards({ incidents, isAdmin, onResolve }: { // ---------------------------------------------------------------------------
incidents: IncidentRecord[]; // Page
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>
</>
);
}
export default function IncidentsPage() { export default function IncidentsPage() {
const { isAdmin } = useAuth(); const { isAdmin } = useAuth();
const { incidents, loading } = useIncidents(); const { incidents, loading } = useIncidents();
const [showCreate, setShowCreate] = useState(false); 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 filtered = useMemo(() => {
const resolved = incidents.filter((i) => i.status === "resolved"); 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) { async function handleResolve(id: string) {
try { await c2api.updateIncident(id, { status: "resolved" }); } try { await c2api.updateIncident(id, { status: "resolved" }); }
@@ -253,30 +232,53 @@ export default function IncidentsPage() {
} }
return ( return (
<div className="space-y-8"> <div className="space-y-6">
<div className="flex items-center justify-between"> <PageHeader
<div className="flex items-center gap-3"> title="Incidents"
<h1 className="text-white text-xl font-bold font-mono">Incidents</h1> badge={active.length > 0 && <Badge tone="danger">{active.length} active</Badge>}
{active.length > 0 && ( action={isAdmin && <Button onClick={() => setShowCreate(true)}>+ Create Incident</Button>}
<span className="text-xs bg-red-900 text-red-300 px-2 py-0.5 rounded-full font-mono"> />
{active.length} active
</span> {/* Severity filter + sort — severity is a filter dimension, not decoration */}
)} <div className="flex flex-wrap items-center justify-between gap-3">
</div> <div className="flex flex-wrap gap-1 bg-gray-900 border border-gray-800 rounded-lg p-1 w-fit">
{isAdmin && ( {SEVERITY_FILTERS.map(({ key, label }) => (
<button <button
onClick={() => setShowCreate(true)} key={key}
className="bg-indigo-600 hover:bg-indigo-500 text-white text-sm rounded-lg px-4 py-2 transition-colors" 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> </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> </div>
{loading ? ( {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 && ( {active.length > 0 && (
<section> <section>
<h2 className="text-sm font-mono text-gray-400 uppercase tracking-wider mb-3">Active</h2> <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> </section>
)} )}
{incidents.length === 0 && ( {filtered.length === 0 && (
<p className="text-gray-600 text-sm font-mono">No incidents recorded yet.</p> <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 type { Metadata } from "next";
import { Nav } from "@/components/Nav";
import { AuthProvider } from "@/components/AuthProvider"; import { AuthProvider } from "@/components/AuthProvider";
import { ThemeProvider } from "@/components/ThemeProvider"; import { ThemeProvider } from "@/components/ThemeProvider";
import { ChromeSwitcher } from "@/components/ChromeSwitcher";
import "./globals.css"; import "./globals.css";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "DRB Portal", title: "DRB — Public-Safety Radio Intelligence",
description: "Distributed Radio Bot — Control & Monitoring", description: "Live incident awareness from field SDR nodes — transcribed, correlated, and mapped in real time.",
}; };
export default function RootLayout({ children }: { children: React.ReactNode }) { 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"> <body className="min-h-screen bg-gray-950">
<ThemeProvider> <ThemeProvider>
<AuthProvider> <AuthProvider>
<Nav /> <ChromeSwitcher>{children}</ChromeSwitcher>
<main className="max-w-screen-2xl mx-auto px-4 md:px-6 py-6">{children}</main>
</AuthProvider> </AuthProvider>
</ThemeProvider> </ThemeProvider>
</body> </body>
+11 -1
View File
@@ -1,6 +1,7 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import Link from "next/link";
import { signInWithEmailAndPassword, GoogleAuthProvider, signInWithPopup } from "firebase/auth"; import { signInWithEmailAndPassword, GoogleAuthProvider, signInWithPopup } from "firebase/auth";
import { auth } from "@/lib/firebase"; import { auth } from "@/lib/firebase";
import { c2api } from "@/lib/c2api"; import { c2api } from "@/lib/c2api";
@@ -44,8 +45,12 @@ export default function LoginPage() {
return ( return (
<div className="max-w-sm mx-auto pt-16"> <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"> <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"> <form onSubmit={handleSubmit} className="space-y-4">
<div> <div>
@@ -100,6 +105,11 @@ export default function LoginPage() {
</svg> </svg>
Continue with Google Continue with Google
</button> </button>
<p className="text-center text-xs text-gray-500">
Don&apos;t have an account?{" "}
<Link href="/signup" className="text-indigo-400 hover:text-indigo-300 transition-colors">Sign up</Link>
</p>
</div> </div>
</div> </div>
); );
+50
View File
@@ -192,6 +192,54 @@ export default function NodeDetailPage() {
<StatusBadge status={node.status} /> <StatusBadge status={node.status} />
</div> </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 */} {/* Info */}
<div className="bg-gray-900 border border-gray-800 rounded-lg divide-y divide-gray-800 font-mono text-sm"> <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}`], ["Location", `${node.lat}, ${node.lon}`],
["Last Seen", node.last_seen ? new Date(node.last_seen).toLocaleString() : "never"], ["Last Seen", node.last_seen ? new Date(node.last_seen).toLocaleString() : "never"],
["Configured", node.configured ? "Yes" : "No"], ["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]) => ( ].map(([label, value]) => (
<div key={label} className="flex justify-between px-4 py-2.5"> <div key={label} className="flex justify-between px-4 py-2.5">
<span className="text-gray-500">{label}</span> <span className="text-gray-500">{label}</span>
+87
View File
@@ -0,0 +1,87 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/components/AuthProvider";
import { c2api } from "@/lib/c2api";
import { Button } from "@/components/ui/Button";
/**
* Shown to any signed-in user with no org_id claim — see ChromeSwitcher's
* no-claim guard (SAAS_PLAN.md B3). Two ways to land here:
* 1. Just created an account via /signup, org name not collected yet.
* 2. Signed in via Google on /login (which auto-creates a Firebase account
* on first use) and was never provisioned into anything.
* Either way, this is the one screen an unprovisioned account can reach,
* and completing it is what POST /auth/signup uses to grant org_id/org_role.
*/
export default function OnboardingPage() {
const { user, loading, orgId, refreshClaims } = useAuth();
const router = useRouter();
const [orgName, setOrgName] = useState("");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (loading) return;
if (!user) {
router.replace("/login");
return;
}
if (orgId) {
router.replace("/dashboard");
}
}, [loading, user, orgId, router]);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!orgName.trim()) return;
setSubmitting(true);
setError(null);
try {
await c2api.signup(orgName.trim());
// Firebase custom claims only show up in a *freshly fetched* ID token —
// getIdTokenResult(true) inside refreshClaims forces that fetch, then
// AuthProvider's own state (orgId) updates and the effect above
// redirects to /dashboard.
await refreshClaims();
} catch (err) {
setError(err instanceof Error ? err.message : "Could not set up your organization. Try again.");
setSubmitting(false);
}
}
if (loading || !user || orgId) return null;
return (
<div className="max-w-sm mx-auto pt-16">
<div className="bg-gray-900 border border-gray-700 rounded-xl p-8 space-y-5 font-mono">
<div>
<h1 className="text-white text-lg font-bold">Set up your organization</h1>
<p className="text-gray-400 text-xs mt-2 leading-relaxed">
One more step — name the organization your nodes, calls, and incidents will belong to. You can change this later.
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="text-xs text-gray-400 block mb-1">Organization name</label>
<input
type="text"
value={orgName}
onChange={(e) => setOrgName(e.target.value)}
required
autoFocus
placeholder="e.g. Riverside County Scanner"
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>
{error && <p className="text-red-400 text-xs">{error}</p>}
<Button type="submit" disabled={submitting || !orgName.trim()} fullWidth>
{submitting ? "Setting up…" : "Continue"}
</Button>
</form>
</div>
</div>
);
}
+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() { const CAPABILITIES = [
redirect("/dashboard"); {
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>
);
}
+83
View File
@@ -0,0 +1,83 @@
import Link from "next/link";
/**
* SAAS_PLAN.md B5: page structure only — see app/terms/page.tsx for why the
* agent building this did not write real legal text. Privacy Policy needs
* the same jurisdiction-aware legal review as Terms, plus specifics this
* agent cannot respond for on the owner's behalf: what a real DPA/CCPA/GDPR
* posture looks like, and what third-party processors (OpenAI, Gemini,
* Google Maps, Firebase/GCP, Stripe once chosen) actually receive and why.
*/
const SECTIONS: { heading: string; note: string }[] = [
{
heading: "1. What data this collects",
note: "TODO(legal): account data (email, org membership), field node telemetry (location, status), radio call audio and AI-generated transcripts/entities/incident data, and usage/session logs (drb-c2-core's audit_log and user_sessions collections already exist and hold some of this today).",
},
{
heading: "2. Third parties this data is sent to, and why",
note: "TODO(legal): OpenAI (Whisper transcription), Google Gemini (incident extraction/summarization/embeddings), Google Maps (geocoding location strings extracted from transcripts), Google Cloud (Firestore + GCS storage, Firebase Auth), and — once a payment processor is chosen (SAAS_PLAN.md section 6.5, not yet decided) — that processor. Each of these is a real, already-integrated dependency, not a hypothetical one; this section needs to name them accurately, not generically.",
},
{
heading: "3. Recorded radio traffic specifically",
note: "TODO(legal): this product's core function is recording, transcribing, and storing monitored radio audio — including public-safety traffic that may name individuals, locations, and in-progress incidents. This needs explicit treatment distinct from generic 'we collect usage data' privacy boilerplate, and needs to be read alongside the same legal review flagged in Terms section 3.",
},
{
heading: "4. How long data is kept",
note: "TODO(legal): no retention enforcement exists in the product yet (no TTL, no sweep, no deletion job — see DEFERRED.md) — this section cannot promise a retention/deletion window the system doesn't actually implement.",
},
{
heading: "5. Customer and end-user rights",
note: "TODO(legal): access/export/deletion requests, and who they're directed to — org owner vs. platform operator.",
},
{
heading: "6. Cookies and session data",
note: "TODO(legal): drb_session is a client-set, non-httpOnly cookie used only for UI redirect logic (not an auth boundary — see CLAUDE.md); Firebase Auth sets its own session storage. No analytics/tracking cookies are set today.",
},
{
heading: "7. Security practices",
note: "TODO(legal): at a level appropriate for public disclosure — Firestore security rules, per-node credentials, encrypted transport. Should be reviewed against SAAS_PLAN.md's actual findings before publishing any specific claim.",
},
{
heading: "8. Changes to this policy",
note: "TODO(legal): how customers are notified.",
},
{
heading: "9. Contact",
note: "TODO(legal): real company legal identity and contact address — not yet decided (SAAS_PLAN.md section 6.6).",
},
];
export default function PrivacyPage() {
return (
<div className="max-w-screen-md mx-auto px-4 md:px-6 py-16 md:py-20">
<div className="bg-yellow-900/30 border border-yellow-700/50 rounded-lg px-4 py-3 mb-10">
<p className="text-yellow-200 text-sm font-mono font-semibold">
Draft — not yet in force
</p>
<p className="text-yellow-200/80 text-xs mt-1 leading-relaxed">
This page is a structural placeholder, not a real Privacy Policy. Every section below is a{" "}
<code className="text-yellow-100">TODO(legal)</code> marker, not actual legal text. Nothing on this page
describes a binding commitment about how data is handled.
</p>
</div>
<h1 className="text-display-sm text-white">Privacy Policy</h1>
<p className="text-gray-500 text-sm mt-2 font-mono">Draft — last structured {new Date().getFullYear()}</p>
<div className="mt-10 space-y-8">
{SECTIONS.map((s) => (
<section key={s.heading}>
<h2 className="text-white font-semibold">{s.heading}</h2>
<p className="text-gray-500 text-sm mt-2 leading-relaxed italic">{s.note}</p>
</section>
))}
</div>
<p className="text-gray-600 text-xs font-mono mt-16">
Questions in the meantime? <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 reach us directly</Link>.
</p>
</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>
);
}
+61
View File
@@ -0,0 +1,61 @@
"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 }) {
// SAAS_PLAN.md B7: this used to gate on isAdmin (platform admin) alone,
// which meant a paying customer who is their own org's owner couldn't
// reach their own billing/members/node-ownership settings — "admin" here
// conflated "platform operator" with "org owner". isAdmin still passes
// (support/debugging access to any org's settings), but org_role ===
// "owner" is now sufficient on its own.
const { isAdmin, isOrgOwner, loading } = useAuth();
const canAccess = isAdmin || isOrgOwner;
const pathname = usePathname();
const router = useRouter();
useEffect(() => {
if (!loading && !canAccess) router.replace("/dashboard");
}, [loading, canAccess, router]);
if (loading || !canAccess) 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>
);
}
+284
View File
@@ -0,0 +1,284 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { useNodes } from "@/lib/useNodes";
import { useAuth } from "@/components/AuthProvider";
import { c2api } from "@/lib/c2api";
import type { UserRecord } from "@/lib/types";
import { StatusBadge } from "@/components/StatusBadge";
import { Card, CardHeader } from "@/components/ui/Card";
import { Button } from "@/components/ui/Button";
import { ErrorBanner } from "@/components/ui/EmptyState";
import { SkeletonRow } from "@/components/ui/Skeleton";
const UNASSIGNED = "__unassigned__";
interface EnrollmentToken {
token_id: string;
label: string;
created_at: string;
revoked: boolean;
uses: number;
}
/**
* SAAS_PLAN.md B2b — per-org enrollment tokens (routers/org.py). This is the
* credential a customer's field node presents to POST /nodes/enroll
* (X-Enrollment-Token) so it lands in THIS org instead of the legacy
* fleet-wide pool. Minting/revoking is owner-only server-side; any org
* member can list (metadata only, the raw token is shown exactly once at
* mint time and never again).
*/
function EnrollmentTokensPanel() {
const { isOrgOwner, isAdmin } = useAuth();
const canManage = isOrgOwner || isAdmin;
const [tokens, setTokens] = useState<EnrollmentToken[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [label, setLabel] = useState("");
const [minting, setMinting] = useState(false);
const [justMinted, setJustMinted] = useState<string | null>(null);
const load = useCallback(() => {
c2api.listEnrollmentTokens()
.then(setTokens)
.catch((e) => setError(e instanceof Error ? e.message : String(e)))
.finally(() => setLoading(false));
}, []);
useEffect(() => { load(); }, [load]);
async function handleMint(e: React.FormEvent) {
e.preventDefault();
if (!label.trim()) return;
setMinting(true);
setError(null);
try {
const result = await c2api.mintEnrollmentToken(label.trim());
setJustMinted(result.token);
setLabel("");
load();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setMinting(false);
}
}
async function handleRevoke(tokenId: string) {
try {
await c2api.revokeEnrollmentToken(tokenId);
load();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
}
}
return (
<Card>
<CardHeader
title="Enrollment tokens"
subtitle="Give a new field node one of these instead of an admin-issued key — it enrolls straight into this org."
/>
{justMinted && (
<div className="bg-indigo-900/30 border border-indigo-700/50 rounded-lg p-3 mb-4">
<p className="text-xs text-indigo-200 font-mono mb-1">
New token — copy it now, it won&apos;t be shown again:
</p>
<p className="text-xs text-indigo-100 font-mono break-all bg-gray-900 rounded px-2 py-1.5">{justMinted}</p>
<button
type="button"
onClick={() => setJustMinted(null)}
className="text-xs text-indigo-300 hover:text-indigo-200 mt-2 transition-colors"
>
Dismiss
</button>
</div>
)}
{error && <ErrorBanner message={error} />}
{canManage && (
<form onSubmit={handleMint} className="flex flex-wrap gap-2 mb-4">
<input
value={label}
onChange={(e) => setLabel(e.target.value)}
placeholder="Label, e.g. 'node-003 field kit'"
className="flex-1 min-w-[12rem] bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-white text-sm focus:outline-none focus:border-indigo-500"
/>
<Button type="submit" size="sm" disabled={minting || !label.trim()}>
{minting ? "Minting…" : "New token"}
</Button>
</form>
)}
{loading ? (
<div className="space-y-2">
<SkeletonRow cols={1} />
</div>
) : tokens.length === 0 ? (
<p className="text-gray-600 text-xs">No enrollment tokens yet.</p>
) : (
<table className="w-full text-sm">
<thead>
<tr className="text-xs text-gray-500 uppercase tracking-wider border-b border-gray-800">
<th className="py-2 text-left">Label</th>
<th className="py-2 text-left hidden sm:table-cell">Created</th>
<th className="py-2 text-left">Status</th>
{canManage && <th className="py-2 text-right">Actions</th>}
</tr>
</thead>
<tbody>
{tokens.map((t) => (
<tr key={t.token_id} className="border-b border-gray-800 last:border-0">
<td className="py-2 text-white">{t.label}</td>
<td className="py-2 text-gray-500 text-xs hidden sm:table-cell">
{new Date(t.created_at).toLocaleDateString()}
</td>
<td className="py-2 text-xs">
{t.revoked ? (
<span className="text-gray-600">Revoked</span>
) : (
<span className="text-green-400">Active · {t.uses} use{t.uses !== 1 ? "s" : ""}</span>
)}
</td>
{canManage && (
<td className="py-2 text-right">
{!t.revoked && (
<button
onClick={() => handleRevoke(t.token_id)}
className="text-xs text-red-400 hover:text-red-300 transition-colors"
>
Revoke
</button>
)}
</td>
)}
</tr>
))}
</tbody>
</table>
)}
</Card>
);
}
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-6">
<EnrollmentTokensPanel />
<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>
</div>
);
}
@@ -0,0 +1,134 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { c2api } from "@/lib/c2api";
import { useAuth } from "@/components/AuthProvider";
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 { isOrgOwner, isAdmin } = useAuth();
const [memberCount, setMemberCount] = useState<number | null>(null);
const [sub, setSub] = useState<Subscription | null>(null);
const [orgName, setOrgName] = useState("");
const [savedName, setSavedName] = useState("");
const [orgLoading, setOrgLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const canEdit = isOrgOwner || isAdmin;
useEffect(() => {
c2api.listUsers().then((u) => setMemberCount(u.length)).catch(() => setMemberCount(null));
getCurrentSubscription().then(setSub);
c2api.getOrg()
.then((org) => { setOrgName(org.name); setSavedName(org.name); })
.catch(() => {})
.finally(() => setOrgLoading(false));
}, []);
async function handleSave() {
setSaving(true);
setSaveError(null);
try {
const res = await c2api.updateOrg(orgName.trim());
setSavedName(res.name);
setOrgName(res.name);
} catch (err) {
setSaveError(err instanceof Error ? err.message : "Could not save.");
} finally {
setSaving(false);
}
}
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}
disabled={orgLoading || !canEdit}
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 disabled:opacity-50"
/>
</div>
{saveError && <p className="text-red-400 text-xs">{saveError}</p>}
<div className="flex items-center gap-3">
<Button
size="sm"
disabled={orgLoading || !canEdit || saving || !orgName.trim() || orgName.trim() === savedName}
onClick={handleSave}
title={!canEdit ? "Only the organization owner can change this" : undefined}
>
{saving ? "Saving…" : "Save changes"}
</Button>
</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");
}
+128
View File
@@ -0,0 +1,128 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { createUserWithEmailAndPassword, GoogleAuthProvider, signInWithPopup } from "firebase/auth";
import { auth } from "@/lib/firebase";
import { useRouter } from "next/navigation";
/**
* Self-serve account creation (SAAS_PLAN.md B4). Only creates the Firebase
* user — org naming happens on the next screen, /onboarding, which is also
* where every other no-org-yet path (Google sign-in via /login, etc.) ends
* up. Keeping that step in one shared place means there's exactly one route
* that calls POST /auth/signup.
*/
export default function SignupPage() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const router = useRouter();
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
setError(null);
try {
await createUserWithEmailAndPassword(auth, email, password);
router.push("/onboarding");
} catch (err: unknown) {
const code = (err as { code?: string })?.code;
if (code === "auth/email-already-in-use") {
setError("An account with this email already exists. Try signing in instead.");
} else if (code === "auth/weak-password") {
setError("Password is too weak — use at least 6 characters.");
} else {
setError("Could not create your account. Check your details and try again.");
}
} finally {
setLoading(false);
}
}
async function handleGoogle() {
setLoading(true);
setError(null);
try {
await signInWithPopup(auth, new GoogleAuthProvider());
router.push("/onboarding");
} catch {
setError("Google sign-up failed. Try again.");
} finally {
setLoading(false);
}
}
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">Create your account</h1>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="text-xs text-gray-400 block mb-1">Email</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
autoComplete="email"
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>
<label className="text-xs text-gray-400 block mb-1">Password</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={6}
autoComplete="new-password"
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>
{error && <p className="text-red-400 text-xs">{error}</p>}
<button
type="submit"
disabled={loading}
className="w-full bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white rounded-lg py-2 text-sm font-semibold transition-colors"
>
{loading ? "Creating account…" : "Create account"}
</button>
</form>
<div className="flex items-center gap-3">
<div className="flex-1 h-px bg-gray-700" />
<span className="text-xs text-gray-500">or</span>
<div className="flex-1 h-px bg-gray-700" />
</div>
<button
type="button"
onClick={handleGoogle}
disabled={loading}
className="w-full flex items-center justify-center gap-3 bg-white hover:bg-gray-100 disabled:opacity-50 text-gray-900 rounded-lg py-2 text-sm font-semibold transition-colors"
>
<svg width="18" height="18" viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
<path d="M17.64 9.2c0-.637-.057-1.251-.164-1.84H9v3.481h4.844c-.209 1.125-.843 2.078-1.796 2.717v2.258h2.908c1.702-1.567 2.684-3.875 2.684-6.615z" fill="#4285F4"/>
<path d="M9 18c2.43 0 4.467-.806 5.956-2.184l-2.908-2.258c-.806.54-1.837.859-3.048.859-2.344 0-4.328-1.584-5.036-3.711H.957v2.332C2.438 15.983 5.482 18 9 18z" fill="#34A853"/>
<path d="M3.964 10.706A5.41 5.41 0 0 1 3.682 9c0-.593.102-1.17.282-1.706V4.962H.957A8.996 8.996 0 0 0 0 9c0 1.452.348 2.827.957 4.038l3.007-2.332z" fill="#FBBC05"/>
<path d="M9 3.58c1.321 0 2.508.454 3.44 1.345l2.582-2.58C13.463.891 11.426 0 9 0 5.482 0 2.438 2.017.957 4.962L3.964 6.294C4.672 4.169 6.656 3.58 9 3.58z" fill="#EA4335"/>
</svg>
Continue with Google
</button>
<p className="text-center text-xs text-gray-500">
Already have an account?{" "}
<Link href="/login" className="text-indigo-400 hover:text-indigo-300 transition-colors">Sign in</Link>
</p>
</div>
</div>
);
}
+89
View File
@@ -0,0 +1,89 @@
import Link from "next/link";
/**
* SAAS_PLAN.md B5: page structure only. The agent building this is
* explicitly instructed not to invent legal text — DRB records, stores, and
* transcribes public-safety radio traffic, and recording/rebroadcast
* legality varies by state (see SAAS_PLAN.md section 6.3), so this needs a
* human, and probably a lawyer, not a generated draft. Every section below
* is a placeholder marking what a real Terms of Service needs to cover, not
* actual terms — see the banner and every TODO(legal) marker.
*/
const SECTIONS: { heading: string; note: string }[] = [
{
heading: "1. Acceptance of terms",
note: "TODO(legal): standard acceptance clause — using the service means agreeing to these terms.",
},
{
heading: "2. What the service does and does not do",
note: "TODO(legal): describe the product (SDR ingestion, transcription, AI correlation, Discord relay) and, importantly, disclaim accuracy — AI-generated transcripts and incident summaries are not guaranteed accurate and must not be relied on as the sole source for dispatch or safety decisions.",
},
{
heading: "3. Radio recording and rebroadcast — the part that needs a lawyer",
note: "TODO(legal): this is the section that actually matters. Recording, storing, and rebroadcasting monitored radio traffic (including public-safety frequencies) has different legal treatment by state and by traffic type (encrypted vs. clear, dispatch vs. tactical). Needs jurisdiction-aware legal review before this product can be sold across state lines — do not ship this page live without it.",
},
{
heading: "4. Customer responsibilities and acceptable use",
note: "TODO(legal): who owns the hardware, who's responsible for lawful operation of the field node, prohibited uses.",
},
{
heading: "5. Data ownership and retention",
note: "TODO(legal): who owns the recorded audio/transcripts/incidents, how long they're kept, what happens on cancellation or account deletion. Note: no retention enforcement exists in the product yet either (see DEFERRED.md) — this section can't promise a retention window the system doesn't yet enforce.",
},
{
heading: "6. Payment, billing, and cancellation",
note: "TODO(legal): once a billing model and pricing exist (SAAS_PLAN.md section 6, still undecided) — refunds, proration, what happens to data on non-payment.",
},
{
heading: "7. Service availability and support",
note: "TODO(legal): whether any uptime/SLA commitment is made (today: none).",
},
{
heading: "8. Limitation of liability",
note: "TODO(legal): standard limitation-of-liability language, reviewed against the fact that this product touches public-safety-adjacent data.",
},
{
heading: "9. Changes to these terms",
note: "TODO(legal): how customers are notified of material changes.",
},
{
heading: "10. Governing law and contact",
note: "TODO(legal): governing jurisdiction, and a real company legal identity and contact address (SAAS_PLAN.md section 6.6 — not yet decided).",
},
];
export default function TermsPage() {
return (
<div className="max-w-screen-md mx-auto px-4 md:px-6 py-16 md:py-20">
<div className="bg-yellow-900/30 border border-yellow-700/50 rounded-lg px-4 py-3 mb-10">
<p className="text-yellow-200 text-sm font-mono font-semibold">
Draft — not yet in force
</p>
<p className="text-yellow-200/80 text-xs mt-1 leading-relaxed">
This page is a structural placeholder, not a real Terms of Service. Every section below is a{" "}
<code className="text-yellow-100">TODO(legal)</code> marker, not actual legal text. Nothing on this page is
binding, and no self-serve signup should be considered subject to it until an actual attorney-reviewed
version replaces this content.
</p>
</div>
<h1 className="text-display-sm text-white">Terms of Service</h1>
<p className="text-gray-500 text-sm mt-2 font-mono">Draft — last structured {new Date().getFullYear()}</p>
<div className="mt-10 space-y-8">
{SECTIONS.map((s) => (
<section key={s.heading}>
<h2 className="text-white font-semibold">{s.heading}</h2>
<p className="text-gray-500 text-sm mt-2 leading-relaxed italic">{s.note}</p>
</section>
))}
</div>
<p className="text-gray-600 text-xs font-mono mt-16">
Questions in the meantime? <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 reach us directly</Link>.
</p>
</div>
);
}
+105
View File
@@ -0,0 +1,105 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { c2api } from "@/lib/c2api";
/**
* SAAS_PLAN.md B6's waitlist form — POST /waitlist (routers/waitlist.py) is
* public, source-IP rate-limited, and deliberately not coupled to any plan
* or tier: the commercial model (who runs the node, what a customer
* actually buys) is still an open decision (SAAS_PLAN.md section 6.1), so
* this collects only {email, org_name, note} and makes no promise about
* price or plan.
*/
export default function WaitlistPage() {
const [email, setEmail] = useState("");
const [orgName, setOrgName] = useState("");
const [note, setNote] = useState("");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [done, setDone] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setSubmitting(true);
setError(null);
try {
await c2api.joinWaitlist({ email, org_name: orgName || undefined, note: note || undefined });
setDone(true);
} catch (err) {
setError(err instanceof Error ? err.message : "Could not submit — try again in a moment.");
} finally {
setSubmitting(false);
}
}
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">
{done ? (
<>
<h1 className="text-white text-lg font-bold">You&apos;re on the list</h1>
<p className="text-gray-400 text-sm leading-relaxed">
Thanks — we&apos;ll reach out at the email you gave us. In the meantime, feel free to{" "}
<Link href="/faq" className="text-indigo-400 hover:text-indigo-300 transition-colors">read the FAQ</Link>.
</p>
</>
) : (
<>
<div>
<h1 className="text-white text-lg font-bold">Request access</h1>
<p className="text-gray-400 text-xs mt-2 leading-relaxed">
Self-serve signup isn&apos;t open yet. Leave your details and we&apos;ll follow up to get your organization set up.
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="text-xs text-gray-400 block mb-1">Email</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
autoComplete="email"
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>
<label className="text-xs text-gray-400 block mb-1">Organization (optional)</label>
<input
type="text"
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>
<label className="text-xs text-gray-400 block mb-1">Anything else? (optional)</label>
<textarea
value={note}
onChange={(e) => setNote(e.target.value)}
rows={3}
maxLength={2000}
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 resize-none"
/>
</div>
{error && <p className="text-red-400 text-xs">{error}</p>}
<button
type="submit"
disabled={submitting}
className="w-full bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white rounded-lg py-2 text-sm font-semibold transition-colors"
>
{submitting ? "Submitting…" : "Request access"}
</button>
</form>
</>
)}
</div>
</div>
);
}
+54 -9
View File
@@ -5,6 +5,8 @@ import { onAuthStateChanged, signOut as firebaseSignOut, User } from "firebase/a
import { auth } from "@/lib/firebase"; import { auth } from "@/lib/firebase";
import type { UserRole } from "@/lib/types"; import type { UserRole } from "@/lib/types";
export type OrgRole = "owner" | "member";
interface AuthContextType { interface AuthContextType {
user: User | null; user: User | null;
loading: boolean; loading: boolean;
@@ -12,7 +14,13 @@ interface AuthContextType {
isAdmin: boolean; isAdmin: boolean;
isOperator: boolean; isOperator: boolean;
ownedNodeIds: string[]; ownedNodeIds: string[];
/** Tenant claim — null means this account isn't provisioned into an org yet. */
orgId: string | null;
orgRole: OrgRole | null;
isOrgOwner: boolean;
signOut: () => Promise<void>; signOut: () => Promise<void>;
/** Force-refetch the ID token's claims — call after POST /auth/signup so orgId picks up immediately. */
refreshClaims: () => Promise<void>;
} }
const AuthContext = createContext<AuthContextType>({ const AuthContext = createContext<AuthContextType>({
@@ -22,7 +30,11 @@ const AuthContext = createContext<AuthContextType>({
isAdmin: false, isAdmin: false,
isOperator: false, isOperator: false,
ownedNodeIds: [], ownedNodeIds: [],
orgId: null,
orgRole: null,
isOrgOwner: false,
signOut: async () => {}, signOut: async () => {},
refreshClaims: async () => {},
}); });
export function AuthProvider({ children }: { children: React.ReactNode }) { export function AuthProvider({ children }: { children: React.ReactNode }) {
@@ -30,15 +42,11 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [role, setRole] = useState<UserRole | null>(null); const [role, setRole] = useState<UserRole | null>(null);
const [ownedNodeIds, setOwnedNodeIds] = useState<string[]>([]); const [ownedNodeIds, setOwnedNodeIds] = useState<string[]>([]);
const [orgId, setOrgId] = useState<string | null>(null);
const [orgRole, setOrgRole] = useState<OrgRole | null>(null);
useEffect(() => { async function applyClaims(u: User, forceRefresh: boolean) {
return onAuthStateChanged(auth, async (u) => { const result = await u.getIdTokenResult(forceRefresh);
setUser(u);
setLoading(false);
if (u) {
document.cookie = "drb_session=1; path=/; SameSite=Strict";
const result = await u.getIdTokenResult(true);
const claims = result.claims; const claims = result.claims;
// Derive role: prefer granular "role" claim, fall back to legacy "admin" boolean // Derive role: prefer granular "role" claim, fall back to legacy "admin" boolean
@@ -53,11 +61,41 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
setRole(effectiveRole); setRole(effectiveRole);
setOwnedNodeIds((claims.owned_node_ids as string[]) ?? []); setOwnedNodeIds((claims.owned_node_ids as string[]) ?? []);
// org_id/org_role are set by POST /auth/signup. No org_id claim means
// this account was created (e.g. via Google sign-in's implicit account
// creation) but never provisioned — see the no-claim guard below, which
// is what stops that from being a live data exposure.
const claimOrgId = typeof claims.org_id === "string" ? claims.org_id : null;
const claimOrgRole = claims.org_role === "owner" || claims.org_role === "member" ? claims.org_role : null;
setOrgId(claimOrgId);
setOrgRole(claimOrgRole);
// drb_session is only a UX redirect signal (middleware.ts), not a
// security boundary (see CLAUDE.md) — but it must not be set for an
// unprovisioned account, or the middleware will wave them straight into
// /dashboard instead of /onboarding.
if (claimOrgId) {
document.cookie = "drb_session=1; path=/; SameSite=Strict";
} else {
document.cookie = "drb_session=; path=/; max-age=0";
}
}
useEffect(() => {
return onAuthStateChanged(auth, async (u) => {
setUser(u);
if (u) {
await applyClaims(u, true);
} else { } else {
document.cookie = "drb_session=; path=/; max-age=0"; document.cookie = "drb_session=; path=/; max-age=0";
setRole(null); setRole(null);
setOwnedNodeIds([]); setOwnedNodeIds([]);
setOrgId(null);
setOrgRole(null);
} }
setLoading(false);
}); });
}, []); }, []);
@@ -66,11 +104,18 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
document.cookie = "drb_session=; path=/; max-age=0"; document.cookie = "drb_session=; path=/; max-age=0";
} }
async function refreshClaims() {
if (auth.currentUser) await applyClaims(auth.currentUser, true);
}
const isAdmin = role === "admin"; const isAdmin = role === "admin";
const isOperator = role === "operator"; const isOperator = role === "operator";
const isOrgOwner = orgRole === "owner";
return ( return (
<AuthContext.Provider value={{ user, loading, role, isAdmin, isOperator, ownedNodeIds, signOut }}> <AuthContext.Provider
value={{ user, loading, role, isAdmin, isOperator, ownedNodeIds, orgId, orgRole, isOrgOwner, signOut, refreshClaims }}
>
{children} {children}
</AuthContext.Provider> </AuthContext.Provider>
); );
+36 -5
View File
@@ -1,8 +1,9 @@
"use client"; "use client";
import { useState } from "react"; import { useEffect, useState } from "react";
import type { CallRecord } from "@/lib/types"; import type { CallRecord } from "@/lib/types";
import { c2api } from "@/lib/c2api"; import { c2api } from "@/lib/c2api";
import { severityBadge } from "@/lib/severity";
interface Props { interface Props {
call: CallRecord; call: CallRecord;
@@ -37,11 +38,31 @@ export function CallRow({ call, systemName, isAdmin }: Props) {
: call.incident_id ? [call.incident_id] : []; : call.incident_id ? [call.incident_id] : [];
const isActive = call.status === "active"; 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 displayTranscript = (!showOriginal && call.transcript_corrected) ? call.transcript_corrected : call.transcript;
const hasBoth = !!(call.transcript && call.transcript_corrected); const hasBoth = !!(call.transcript && call.transcript_corrected);
const hasSegments = call.segments && call.segments.length > 1; 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() { function startEdit() {
setEditText(call.transcript_corrected ?? call.transcript ?? ""); setEditText(call.transcript_corrected ?? call.transcript ?? "");
setEditing(true); setEditing(true);
@@ -78,6 +99,10 @@ export function CallRow({ call, systemName, isAdmin }: Props) {
{call.tags[0]} {call.tags[0]}
</span> </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>
<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">{systemName ?? call.system_id ?? "—"}</td>
<td className="px-4 py-2 text-gray-400 hidden sm:table-cell">{call.node_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>
<td className="px-4 py-2 text-xs"> <td className="px-4 py-2 text-xs">
{call.audio_url ? ( {hasAudio ? (
<span className="text-blue-400">▶</span> <span className="text-blue-400">▶</span>
) : ( ) : (
<span className="text-gray-700">—</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"> <tr className="bg-gray-900/60 border-b border-gray-800">
<td colSpan={7} className="px-6 py-3 space-y-2"> <td colSpan={7} className="px-6 py-3 space-y-2">
{/* Audio player */} {/* Audio player */}
{call.audio_url && ( {hasAudio && (
audioError ? (
<p className="text-xs text-red-400 font-mono">Could not load audio.</p>
) : audioUrl ? (
<audio <audio
controls controls
src={call.audio_url} src={audioUrl}
className="w-full max-w-sm h-8" className="w-full max-w-sm h-8"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
/> />
) : (
<p className="text-xs text-gray-600 font-mono">Loading audio…</p>
)
)} )}
{/* Tags */} {/* Tags */}
@@ -0,0 +1,64 @@
"use client";
import { useEffect } from "react";
import { usePathname, useRouter } from "next/navigation";
import { useAuth } from "@/components/AuthProvider";
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.
// Keep in sync with PUBLIC_PATHS in middleware.ts (that one decides whether
// to redirect at all; this one just picks page chrome).
const MARKETING_PATHS = new Set(["/", "/features", "/pricing", "/faq", "/terms", "/privacy"]);
// Pages a signed-in user with no org_id claim must still be able to reach —
// otherwise the redirect below would loop against itself, or lock someone
// out of the one screen (/onboarding) that fixes their account.
const NO_ORG_ALLOWED_PATHS = new Set(["/onboarding", "/login", "/signup", "/profile"]);
/**
* 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.
*
* Also carries AuthProvider's no-claim guard (SAAS_PLAN.md B3): a signed-in
* Firebase user with no org_id claim is a real session that is nonetheless
* provisioned into nothing — AuthProvider already refuses to set the
* drb_session cookie for them, so middleware.ts's redirect only covers
* "not signed in at all". This effect covers the other case: signed in, no
* org, anywhere in the app — send them to /onboarding rather than letting
* every page's data hooks fail open or silently return nothing.
*/
export function ChromeSwitcher({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const { user, loading, orgId } = useAuth();
const router = useRouter();
useEffect(() => {
if (loading) return;
if (!user) return; // not signed in — middleware.ts already routes this to /login
if (orgId) return;
if (MARKETING_PATHS.has(pathname) || NO_ORG_ALLOWED_PATHS.has(pathname)) return;
router.replace("/onboarding");
}, [loading, user, orgId, pathname, router]);
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>
);
}
+11 -3
View File
@@ -7,6 +7,7 @@ import { useUnconfiguredNodes } from "@/lib/useNodes";
import { useUnacknowledgedAlerts } from "@/lib/useAlerts"; import { useUnacknowledgedAlerts } from "@/lib/useAlerts";
import { useAuth } from "@/components/AuthProvider"; import { useAuth } from "@/components/AuthProvider";
import { useTheme } from "@/components/ThemeProvider"; import { useTheme } from "@/components/ThemeProvider";
import { FOUNDING_ORG_ID } from "@/lib/tenancy";
// Links visible to all authenticated roles (viewer+) // Links visible to all authenticated roles (viewer+)
const viewerLinks = [ const viewerLinks = [
@@ -15,9 +16,13 @@ const viewerLinks = [
{ href: "/incidents", label: "Incidents" }, { href: "/incidents", label: "Incidents" },
{ href: "/map", label: "Map" }, { href: "/map", label: "Map" },
{ href: "/alerts", label: "Alerts" }, { href: "/alerts", label: "Alerts" },
{ href: "/trips", label: "Trips" },
]; ];
// Trips is an internal utility feature, not a tenant-scoped product surface
// (see [[trips-feature-intentional]] and SAAS_PLAN.md B7) — shown only to
// the founding org, matching routers/trips.py's own gating.
const tripsLink = { href: "/trips", label: "Trips" };
// Additional links for operators and admins // Additional links for operators and admins
const operatorLinks = [ const operatorLinks = [
{ href: "/nodes", label: "Nodes" }, { href: "/nodes", label: "Nodes" },
@@ -25,7 +30,8 @@ const operatorLinks = [
{ href: "/tokens", label: "Tokens" }, { href: "/tokens", label: "Tokens" },
]; ];
// Admin-only links // Platform-admin-only link. Settings is handled separately below — it's
// customer-facing for org owners too, not admin-only (SAAS_PLAN.md B7).
const adminLinks = [ const adminLinks = [
{ href: "/admin", label: "Admin" }, { href: "/admin", label: "Admin" },
]; ];
@@ -55,7 +61,7 @@ function MoonIcon() {
} }
export function Nav() { export function Nav() {
const { user, isAdmin, isOperator } = useAuth(); const { user, isAdmin, isOperator, isOrgOwner, orgId } = useAuth();
const pathname = usePathname(); const pathname = usePathname();
const router = useRouter(); const router = useRouter();
const { nodes: pending } = useUnconfiguredNodes(); const { nodes: pending } = useUnconfiguredNodes();
@@ -67,8 +73,10 @@ export function Nav() {
const allLinks = [ const allLinks = [
...viewerLinks, ...viewerLinks,
...(orgId === FOUNDING_ORG_ID || isAdmin ? [tripsLink] : []),
...(isAdmin || isOperator ? operatorLinks : []), ...(isAdmin || isOperator ? operatorLinks : []),
...(isAdmin ? adminLinks : []), ...(isAdmin ? adminLinks : []),
...(isAdmin || isOrgOwner ? [{ href: "/settings", label: "Settings" }] : []),
]; ];
function navLinkClass(href: string) { function navLinkClass(href: string) {
+12
View File
@@ -45,6 +45,18 @@ export function NodeCard({ node, system }: Props) {
⚠ Needs configuration ⚠ Needs configuration
</div> </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> </div>
</Link> </Link>
); );
+39 -4
View File
@@ -17,9 +17,11 @@ const PRESETS = [
]; ];
export function NodeConfigModal({ node, systems, onClose }: Props) { export function NodeConfigModal({ node, systems, onClose }: Props) {
const [systemId, setSystemId] = useState(""); const [systemId, setSystemId] = useState(node.assigned_system_id ?? "");
const [preset, setPreset] = useState("rtl-sdr-v3"); const [preset, setPreset] = useState(node.hardware_preset ?? "rtl-sdr-v3");
const [ppm, setPpm] = useState("0"); 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 [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -32,6 +34,10 @@ export function NodeConfigModal({ node, systems, onClose }: Props) {
setSaving(true); setSaving(true);
setError(null); setError(null);
try { try {
await c2api.updateNode(node.node_id, {
node_type: nodeType,
enforce_override_timeout: enforceTimeout,
});
await c2api.assignSystem(node.node_id, systemId, preset, ppmOverride); await c2api.assignSystem(node.node_id, systemId, preset, ppmOverride);
onClose(); onClose();
} catch (err) { } catch (err) {
@@ -100,12 +106,41 @@ export function NodeConfigModal({ node, systems, onClose }: Props) {
/> />
</div> </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>} {error && <p className="text-red-400 text-xs">{error}</p>}
<div className="flex gap-3 pt-1"> <div className="flex gap-3 pt-1">
<button <button
type="submit" 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" 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"} {saving ? "Saving…" : "Assign & Configure"}
@@ -0,0 +1,26 @@
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="/waitlist" className="hover:text-gray-300 transition-colors">Request access</Link>
<Link href="/terms" className="hover:text-gray-300 transition-colors">Terms</Link>
<Link href="/privacy" className="hover:text-gray-300 transition-colors">Privacy</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 };
}
+37
View File
@@ -30,6 +30,12 @@ export const c2api = {
if (ppmOverride !== undefined) params.set("ppm_override", String(ppmOverride)); if (ppmOverride !== undefined) params.set("ppm_override", String(ppmOverride));
return request(`/nodes/${nodeId}/config/${systemId}?${params}`, { method: "POST" }); 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 // Systems
getSystems: () => request<unknown[]>("/systems"), getSystems: () => request<unknown[]>("/systems"),
@@ -216,4 +222,35 @@ export const c2api = {
// Session recording — called on each explicit sign-in // Session recording — called on each explicit sign-in
recordSession: () => recordSession: () =>
request<{ ok: boolean }>("/auth/session", { method: "POST" }), request<{ ok: boolean }>("/auth/session", { method: "POST" }),
// Org provisioning (SAAS_PLAN.md B4) — called once from /onboarding right
// after a Firebase account exists but before it has an org_id claim.
signup: (orgName: string) =>
request<{ org_id: string; org_name: string; already_provisioned: boolean }>("/auth/signup", {
method: "POST",
body: JSON.stringify({ org_name: orgName }),
}),
// Organization profile
getOrg: () =>
request<{ org_id: string; name: string; created_at: string }>("/org"),
updateOrg: (name: string) =>
request<{ ok: boolean; name: string }>("/org", { method: "PATCH", body: JSON.stringify({ name }) }),
// Per-org enrollment tokens (SAAS_PLAN.md B2b)
listEnrollmentTokens: () =>
request<{ token_id: string; label: string; created_at: string; revoked: boolean; uses: number }[]>(
"/org/enrollment-tokens"
),
mintEnrollmentToken: (label: string) =>
request<{ token_id: string; token: string; label: string }>("/org/enrollment-tokens", {
method: "POST",
body: JSON.stringify({ label }),
}),
revokeEnrollmentToken: (tokenId: string) =>
request(`/org/enrollment-tokens/${tokenId}`, { method: "DELETE" }),
// Public waitlist — no auth, see routers/waitlist.py
joinWaitlist: (body: { email: string; org_name?: string; note?: string }) =>
request<{ ok: boolean }>("/waitlist", { method: "POST", body: JSON.stringify(body) }),
}; };
+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>
);
}
+10
View File
@@ -0,0 +1,10 @@
/**
* Mirrors drb-c2-core/app/internal/tenancy.py's FOUNDING_ORG_ID — the org
* every pre-tenancy document and every legacy enrollment path resolves
* into. Frontend-side, it's used only to gate the /trips feature (an
* internal utility riding along on this stack, not a tenant-scoped product
* surface — see [[trips-feature-intentional]] and SAAS_PLAN.md B7) to the
* founding org, matching the same restriction the backend already enforces
* in routers/trips.py.
*/
export const FOUNDING_ORG_ID = "founding";
+14
View File
@@ -52,6 +52,11 @@ export interface NodeRecord {
approval_status: ApprovalStatus | null; approval_status: ApprovalStatus | null;
hardware_preset?: string; hardware_preset?: string;
ppm_override?: number | null; 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 { export interface VocabularyPendingTerm {
@@ -88,6 +93,13 @@ export interface CallRecord {
freq: number | null; freq: number | null;
started_at: string; started_at: string;
ended_at: string | null; 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; audio_url: string | null;
transcript: string | null; transcript: string | null;
transcript_corrected: string | null; transcript_corrected: string | null;
@@ -99,6 +111,8 @@ export interface CallRecord {
location: string | null; location: string | null;
tags: string[]; tags: string[];
status: "active" | "ended"; 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 // Correlation debug — written by the correlator, present after a call is linked
corr_path?: string | null; corr_path?: string | null;
corr_score?: number | null; corr_score?: number | null;
+16 -2
View File
@@ -4,6 +4,7 @@ import { useEffect, useState } from "react";
import { collection, onSnapshot, query, orderBy, limit, where, FirestoreError } from "firebase/firestore"; import { collection, onSnapshot, query, orderBy, limit, where, FirestoreError } from "firebase/firestore";
import { onAuthStateChanged } from "firebase/auth"; import { onAuthStateChanged } from "firebase/auth";
import { db, auth } from "@/lib/firebase"; import { db, auth } from "@/lib/firebase";
import { useAuth } from "@/components/AuthProvider";
import type { AlertEvent } from "@/lib/types"; import type { AlertEvent } from "@/lib/types";
const toISO = (v: unknown): string => const toISO = (v: unknown): string =>
@@ -14,6 +15,7 @@ export function useAlerts(limitCount = 50) {
const [alerts, setAlerts] = useState<AlertEvent[]>([]); const [alerts, setAlerts] = useState<AlertEvent[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const { orgId } = useAuth();
useEffect(() => { useEffect(() => {
let unsubFirestore: (() => void) | undefined; let unsubFirestore: (() => void) | undefined;
@@ -26,9 +28,15 @@ export function useAlerts(limitCount = 50) {
setLoading(false); setLoading(false);
return; return;
} }
if (!orgId) {
setAlerts([]);
setLoading(false);
return;
}
const q = query( const q = query(
collection(db, "alert_events"), collection(db, "alert_events"),
where("org_id", "==", orgId),
orderBy("triggered_at", "desc"), orderBy("triggered_at", "desc"),
limit(limitCount) limit(limitCount)
); );
@@ -52,13 +60,14 @@ export function useAlerts(limitCount = 50) {
unsubAuth(); unsubAuth();
if (unsubFirestore) unsubFirestore(); if (unsubFirestore) unsubFirestore();
}; };
}, [limitCount]); }, [limitCount, orgId]);
return { alerts, loading, error }; return { alerts, loading, error };
} }
export function useUnacknowledgedAlerts() { export function useUnacknowledgedAlerts() {
const [alerts, setAlerts] = useState<AlertEvent[]>([]); const [alerts, setAlerts] = useState<AlertEvent[]>([]);
const { orgId } = useAuth();
useEffect(() => { useEffect(() => {
let unsubFirestore: (() => void) | undefined; let unsubFirestore: (() => void) | undefined;
@@ -70,9 +79,14 @@ export function useUnacknowledgedAlerts() {
setAlerts([]); setAlerts([]);
return; return;
} }
if (!orgId) {
setAlerts([]);
return;
}
const q = query( const q = query(
collection(db, "alert_events"), collection(db, "alert_events"),
where("org_id", "==", orgId),
where("acknowledged", "==", false), where("acknowledged", "==", false),
orderBy("triggered_at", "desc"), orderBy("triggered_at", "desc"),
limit(100) limit(100)
@@ -89,7 +103,7 @@ export function useUnacknowledgedAlerts() {
unsubAuth(); unsubAuth();
if (unsubFirestore) unsubFirestore(); if (unsubFirestore) unsubFirestore();
}; };
}, []); }, [orgId]);
return alerts; return alerts;
} }
+28 -5
View File
@@ -4,12 +4,14 @@ import { useEffect, useState } from "react";
import { collection, onSnapshot, query, orderBy, limit, where, FirestoreError } from "firebase/firestore"; import { collection, onSnapshot, query, orderBy, limit, where, FirestoreError } from "firebase/firestore";
import { onAuthStateChanged } from "firebase/auth"; import { onAuthStateChanged } from "firebase/auth";
import { db, auth } from "@/lib/firebase"; import { db, auth } from "@/lib/firebase";
import { useAuth } from "@/components/AuthProvider";
import type { CallRecord } from "@/lib/types"; import type { CallRecord } from "@/lib/types";
export function useCalls(limitCount = 50, dateFrom?: Date, dateTo?: Date) { export function useCalls(limitCount = 50, dateFrom?: Date, dateTo?: Date) {
const [calls, setCalls] = useState<CallRecord[]>([]); const [calls, setCalls] = useState<CallRecord[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const { orgId } = useAuth();
// Stable ms values so the effect dependency doesn't fire on every render // Stable ms values so the effect dependency doesn't fire on every render
const dateFromMs = dateFrom?.getTime(); const dateFromMs = dateFrom?.getTime();
@@ -26,11 +28,21 @@ export function useCalls(limitCount = 50, dateFrom?: Date, dateTo?: Date) {
setLoading(false); setLoading(false);
return; return;
} }
// No org_id claim yet (still resolving, or genuinely unprovisioned —
// see ChromeSwitcher's no-claim guard) — an unfiltered query here
// would be exactly the cross-tenant read this scoping exists to
// close, so wait rather than fall back to "query everything".
if (!orgId) {
setCalls([]);
setLoading(false);
return;
}
const from = dateFromMs != null ? new Date(dateFromMs) : undefined; const from = dateFromMs != null ? new Date(dateFromMs) : undefined;
const to = dateToMs != null ? new Date(dateToMs) : undefined; const to = dateToMs != null ? new Date(dateToMs) : undefined;
const constraints = [ const constraints = [
where("org_id", "==", orgId),
...(from ? [where("started_at", ">=", from)] : []), ...(from ? [where("started_at", ">=", from)] : []),
...(to ? [where("started_at", "<=", to)] : []), ...(to ? [where("started_at", "<=", to)] : []),
orderBy("started_at", "desc"), orderBy("started_at", "desc"),
@@ -52,7 +64,7 @@ export function useCalls(limitCount = 50, dateFrom?: Date, dateTo?: Date) {
unsubAuth(); unsubAuth();
if (unsubFirestore) unsubFirestore(); if (unsubFirestore) unsubFirestore();
}; };
}, [limitCount, dateFromMs, dateToMs]); }, [limitCount, dateFromMs, dateToMs, orgId]);
return { calls, loading, error }; return { calls, loading, error };
} }
@@ -60,6 +72,7 @@ export function useCalls(limitCount = 50, dateFrom?: Date, dateTo?: Date) {
export function useCallsByIncident(incidentId: string | null) { export function useCallsByIncident(incidentId: string | null) {
const [calls, setCalls] = useState<CallRecord[]>([]); const [calls, setCalls] = useState<CallRecord[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const { orgId } = useAuth();
useEffect(() => { useEffect(() => {
if (!incidentId) { setLoading(false); return; } if (!incidentId) { setLoading(false); return; }
@@ -68,11 +81,16 @@ export function useCallsByIncident(incidentId: string | null) {
const unsubAuth = onAuthStateChanged(auth, (user) => { const unsubAuth = onAuthStateChanged(auth, (user) => {
if (unsubFirestore) { unsubFirestore(); unsubFirestore = undefined; } if (unsubFirestore) { unsubFirestore(); unsubFirestore = undefined; }
if (!user) { setLoading(false); return; } if (!user) { setLoading(false); return; }
if (!orgId) { setCalls([]); setLoading(false); return; }
const toISO = (v: any): string | null => const toISO = (v: any): string | null =>
v?.toDate?.()?.toISOString?.() ?? (typeof v === "string" ? v : null); v?.toDate?.()?.toISOString?.() ?? (typeof v === "string" ? v : null);
const q = query(collection(db, "calls"), where("incident_ids", "array-contains", incidentId)); const q = query(
collection(db, "calls"),
where("org_id", "==", orgId),
where("incident_ids", "array-contains", incidentId)
);
unsubFirestore = onSnapshot(q, (snap) => { unsubFirestore = onSnapshot(q, (snap) => {
const docs = snap.docs.map((d) => { const docs = snap.docs.map((d) => {
const data = d.data(); const data = d.data();
@@ -85,13 +103,14 @@ export function useCallsByIncident(incidentId: string | null) {
}); });
return () => { unsubAuth(); if (unsubFirestore) unsubFirestore(); }; return () => { unsubAuth(); if (unsubFirestore) unsubFirestore(); };
}, [incidentId]); }, [incidentId, orgId]);
return { calls, loading }; return { calls, loading };
} }
export function useActiveCalls() { export function useActiveCalls() {
const [calls, setCalls] = useState<CallRecord[]>([]); const [calls, setCalls] = useState<CallRecord[]>([]);
const { orgId } = useAuth();
useEffect(() => { useEffect(() => {
let unsubFirestore: (() => void) | undefined; let unsubFirestore: (() => void) | undefined;
@@ -103,8 +122,12 @@ export function useActiveCalls() {
setCalls([]); setCalls([]);
return; return;
} }
if (!orgId) {
setCalls([]);
return;
}
const q = query(collection(db, "calls"), where("status", "==", "active")); const q = query(collection(db, "calls"), where("org_id", "==", orgId), where("status", "==", "active"));
const toISO = (v: any): string | null => const toISO = (v: any): string | null =>
v?.toDate?.()?.toISOString?.() ?? (typeof v === "string" ? v : null); v?.toDate?.()?.toISOString?.() ?? (typeof v === "string" ? v : null);
unsubFirestore = onSnapshot(q, (snap) => { unsubFirestore = onSnapshot(q, (snap) => {
@@ -119,7 +142,7 @@ export function useActiveCalls() {
unsubAuth(); unsubAuth();
if (unsubFirestore) unsubFirestore(); if (unsubFirestore) unsubFirestore();
}; };
}, []); }, [orgId]);
return calls; return calls;
} }
+16 -3
View File
@@ -4,6 +4,7 @@ import { useEffect, useState } from "react";
import { collection, doc, onSnapshot, query, orderBy, limit, where, FirestoreError } from "firebase/firestore"; import { collection, doc, onSnapshot, query, orderBy, limit, where, FirestoreError } from "firebase/firestore";
import { onAuthStateChanged } from "firebase/auth"; import { onAuthStateChanged } from "firebase/auth";
import { db, auth } from "@/lib/firebase"; import { db, auth } from "@/lib/firebase";
import { useAuth } from "@/components/AuthProvider";
import type { IncidentRecord } from "@/lib/types"; import type { IncidentRecord } from "@/lib/types";
const toISO = (v: unknown): string => const toISO = (v: unknown): string =>
@@ -14,6 +15,7 @@ export function useIncidents(limitCount = 100) {
const [incidents, setIncidents] = useState<IncidentRecord[]>([]); const [incidents, setIncidents] = useState<IncidentRecord[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const { orgId } = useAuth();
useEffect(() => { useEffect(() => {
let unsubFirestore: (() => void) | undefined; let unsubFirestore: (() => void) | undefined;
@@ -26,9 +28,15 @@ export function useIncidents(limitCount = 100) {
setLoading(false); setLoading(false);
return; return;
} }
if (!orgId) {
setIncidents([]);
setLoading(false);
return;
}
const q = query( const q = query(
collection(db, "incidents"), collection(db, "incidents"),
where("org_id", "==", orgId),
orderBy("started_at", "desc"), orderBy("started_at", "desc"),
limit(limitCount) limit(limitCount)
); );
@@ -53,7 +61,7 @@ export function useIncidents(limitCount = 100) {
unsubAuth(); unsubAuth();
if (unsubFirestore) unsubFirestore(); if (unsubFirestore) unsubFirestore();
}; };
}, [limitCount]); }, [limitCount, orgId]);
return { incidents, loading, error }; return { incidents, loading, error };
} }
@@ -97,6 +105,7 @@ export function useIncident(incidentId: string | null) {
export function useActiveIncidents() { export function useActiveIncidents() {
const [incidents, setIncidents] = useState<IncidentRecord[]>([]); const [incidents, setIncidents] = useState<IncidentRecord[]>([]);
const { orgId } = useAuth();
useEffect(() => { useEffect(() => {
let unsubFirestore: (() => void) | undefined; let unsubFirestore: (() => void) | undefined;
@@ -108,8 +117,12 @@ export function useActiveIncidents() {
setIncidents([]); setIncidents([]);
return; return;
} }
if (!orgId) {
setIncidents([]);
return;
}
const q = query(collection(db, "incidents"), where("status", "==", "active")); const q = query(collection(db, "incidents"), where("org_id", "==", orgId), where("status", "==", "active"));
unsubFirestore = onSnapshot(q, (snap) => { unsubFirestore = onSnapshot(q, (snap) => {
setIncidents(snap.docs.map((d) => { setIncidents(snap.docs.map((d) => {
const data = d.data(); const data = d.data();
@@ -126,7 +139,7 @@ export function useActiveIncidents() {
unsubAuth(); unsubAuth();
if (unsubFirestore) unsubFirestore(); if (unsubFirestore) unsubFirestore();
}; };
}, []); }, [orgId]);
return incidents; return incidents;
} }
+10 -3
View File
@@ -1,15 +1,17 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { collection, onSnapshot, query, FirestoreError } from "firebase/firestore"; import { collection, onSnapshot, query, where, FirestoreError } from "firebase/firestore";
import { onAuthStateChanged } from "firebase/auth"; import { onAuthStateChanged } from "firebase/auth";
import { db, auth } from "@/lib/firebase"; import { db, auth } from "@/lib/firebase";
import { useAuth } from "@/components/AuthProvider";
import type { NodeRecord } from "@/lib/types"; import type { NodeRecord } from "@/lib/types";
export function useNodes() { export function useNodes() {
const [nodes, setNodes] = useState<NodeRecord[]>([]); const [nodes, setNodes] = useState<NodeRecord[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const { orgId } = useAuth();
useEffect(() => { useEffect(() => {
let unsubFirestore: (() => void) | undefined; let unsubFirestore: (() => void) | undefined;
@@ -22,8 +24,13 @@ export function useNodes() {
setLoading(false); setLoading(false);
return; return;
} }
if (!orgId) {
setNodes([]);
setLoading(false);
return;
}
const q = query(collection(db, "nodes")); const q = query(collection(db, "nodes"), where("org_id", "==", orgId));
unsubFirestore = onSnapshot(q, (snap) => { unsubFirestore = onSnapshot(q, (snap) => {
setNodes(snap.docs.map((d) => d.data() as NodeRecord)); setNodes(snap.docs.map((d) => d.data() as NodeRecord));
setLoading(false); setLoading(false);
@@ -34,7 +41,7 @@ export function useNodes() {
unsubAuth(); unsubAuth();
if (unsubFirestore) unsubFirestore(); if (unsubFirestore) unsubFirestore();
}; };
}, []); }, [orgId]);
return { nodes, loading, error }; return { nodes, loading, error };
} }
+11 -3
View File
@@ -1,15 +1,17 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { collection, onSnapshot, FirestoreError } from "firebase/firestore"; import { collection, onSnapshot, query, where, FirestoreError } from "firebase/firestore";
import { onAuthStateChanged } from "firebase/auth"; import { onAuthStateChanged } from "firebase/auth";
import { db, auth } from "@/lib/firebase"; import { db, auth } from "@/lib/firebase";
import { useAuth } from "@/components/AuthProvider";
import type { SystemRecord } from "@/lib/types"; import type { SystemRecord } from "@/lib/types";
export function useSystems() { export function useSystems() {
const [systems, setSystems] = useState<SystemRecord[]>([]); const [systems, setSystems] = useState<SystemRecord[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const { orgId } = useAuth();
useEffect(() => { useEffect(() => {
let unsubFirestore: (() => void) | undefined; let unsubFirestore: (() => void) | undefined;
@@ -22,8 +24,14 @@ export function useSystems() {
setLoading(false); setLoading(false);
return; return;
} }
if (!orgId) {
setSystems([]);
setLoading(false);
return;
}
unsubFirestore = onSnapshot(collection(db, "systems"), (snap) => { const q = query(collection(db, "systems"), where("org_id", "==", orgId));
unsubFirestore = onSnapshot(q, (snap) => {
setSystems(snap.docs.map((d) => d.data() as SystemRecord)); setSystems(snap.docs.map((d) => d.data() as SystemRecord));
setLoading(false); setLoading(false);
}, (err: FirestoreError) => { console.error("useSystems:", err); setError(err.message); setLoading(false); }); }, (err: FirestoreError) => { console.error("useSystems:", err); setError(err.message); setLoading(false); });
@@ -33,7 +41,7 @@ export function useSystems() {
unsubAuth(); unsubAuth();
if (unsubFirestore) unsubFirestore(); if (unsubFirestore) unsubFirestore();
}; };
}, []); }, [orgId]);
return { systems, loading, error }; return { systems, loading, error };
} }
+23
View File
@@ -1,9 +1,32 @@
import { NextRequest, NextResponse } from "next/server"; 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", "/terms", "/privacy", "/waitlist"]);
// /signup and /onboarding are deliberately NOT gated by the drb_session
// cookie here, even though they aren't "public" in the sense of not needing
// an account — AuthProvider only sets that cookie once a user has an org_id
// claim (SAAS_PLAN.md B3's no-claim guard), and /onboarding exists
// specifically for a signed-in user who doesn't have one yet. Gating it on
// the same cookie would bounce the exact users who need it back to /login
// before ChromeSwitcher's client-side redirect ever runs. Both pages do
// their own client-side auth check (redirect to /login if genuinely signed
// out) instead.
const NO_SESSION_COOKIE_GATE = new Set(["/signup", "/onboarding"]);
// 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) { export function middleware(request: NextRequest) {
const session = request.cookies.get("drb_session"); const session = request.cookies.get("drb_session");
const { pathname } = request.nextUrl; const { pathname } = request.nextUrl;
if (PUBLIC_PATHS.has(pathname) || NO_SESSION_COOKIE_GATE.has(pathname)) {
return NextResponse.next();
}
if (pathname === "/login") { if (pathname === "/login") {
if (session) return NextResponse.redirect(new URL("/dashboard", request.url)); if (session) return NextResponse.redirect(new URL("/dashboard", request.url));
return NextResponse.next(); return NextResponse.next();
+22
View File
@@ -10,6 +10,28 @@ const config: Config = {
extend: { extend: {
fontFamily: { fontFamily: {
mono: ["ui-monospace", "Cascadia Code", "Source Code Pro", "monospace"], 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. # 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. # 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: git:
repo: "{{ repo_url }}" repo: "{{ repo_url }}"
dest: "{{ app_dir }}" dest: "{{ app_dir }}"
version: main version: main
update: false update: true
force: true
become: false become: false
- name: Set ownership of app directory - name: Set ownership of app directory
@@ -59,6 +66,93 @@
mode: "0644" mode: "0644"
notify: Reload Caddy 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 - name: Log in to container registry
command: > command: >
docker login {{ vault_registry_host }} 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 }} { 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 { reverse_proxy localhost:8888 {
header_up X-Forwarded-For {remote_host} 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 { reverse_proxy localhost:3000 {
header_up X-Forwarded-For {remote_host} header_up X-Forwarded-For {remote_host}
} }

Some files were not shown because too many files have changed in this diff Show More