- POST /discord/nodes/{id}/streams/stop: stream_id must be guild:channel
snowflakes, so a malformed id is a 422 instead of a Firestore path error.
- Offline footer said channels resume on reconnect; ad-hoc ones stop once
the node is gone past the lease grace (reconcile_once). Say so.
- Picker: say why Start is disabled when the node has no system.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PR #218 (server-26#213 item 1) unlinks a server by clearing org_id and
stamping unlinked_at, and resolve_guild_org then refuses the fallback org.
The overview showed such a server as "Unlinked · fallback org" and offered it
in Add radio channel, where start_stream would 403. Mirror the rule here
(forward-compatible: unlinked_at does not exist on main yet). Also key each
stream row by the resolved guild id, and drop panel copy that pointed at a
"Needed" figure the page doesn't show / misstated the invite permissions.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The card only loads for owners/admins (the list endpoint is owner-only), so a
member saw "Loading…" forever. Show an owner-only note instead. Also assert the
one-server/two-org race really retried, as the PR body already claimed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Frontend, built from design-canvas/discord-system-mockup.html ("DRB admin"):
- Settings > Discord (/settings/discord, platform admin only; the tab is
hidden from org owners because every route behind it is admin-only).
KPIs + tabs:
* Radio channels: every discord_streams doc, desired -> actual, the node's
real error, red after 2 min desired-on without connecting. Stop
(POST /discord/guilds/{id}/stop), Start for a stopped channel and
"+ Add radio channel" (POST /discord/streams), with the invite links a
"no bot in this server" refusal carries.
* Servers: linked servers plus servers seen running radio while unlinked
(acting for DISCORD_UNLINKED_GUILD_ORG_ID); Link / Change org
(PUT /discord/guilds/{id}/link).
* Radio bots: valid / check_error, guild membership with a per-server
invite link for each server the bot is missing from, lease (node,
since, releasing), fan-out, Refresh (POST /tokens/{id}/refresh),
Remove, Release all leases (POST /tokens/flush), add with optional BYO org.
* How it works.
- /tokens now renders the same radio-bot table (new token fields + refresh).
- Left out, no backend yet: alert routes, incident forum/roles/plan columns,
per-channel talkgroup chips, "Generate link code" (item 1).
Backend (routers/discord.py):
- GET /discord/admin/overview (platform admin): guilds, streams with node
and bot names, orgs, systems, limits. No token strings, no Discord user
ids, no linking-admin uid.
- PUT /discord/guilds/{id}/link: digits-only id, optional guild_name label,
and re-linking a server to a different org stops its live channels
(the reconciler would otherwise keep re-placing them on the old org's
nodes, using its stored org_id).
- POST /discord/streams: guild_id/channel_id must be digits (the web form
types them in; the bot always sent snowflakes).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A server an org explicitly unlinked (unlinked_at) must not fall back to the
default org in the picker either, or the founding org could stream into it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- c2-core routers/discord_web.py:
PUT /discord/guilds/{id}/directory (service key): the server bot pushes each
server's name + voice channels; merged onto discord_guilds, never touches org_id.
GET /discord/nodes/{id}/join-options: the node org's servers (same
resolve rule as the bot), their voice channels with any live stream,
radio-bot readiness + invite links, and this node's streams. No tokens.
POST /discord/nodes/{id}/streams: stream pinned to the node; server must be
the node org's, channel must be in the bot's directory, guild + node caps.
POST /discord/nodes/{id}/streams/stop: one stream on this node, or all.
Control = platform admin, org owner, or operator owning the node.
- nodes.py: legacy raw-ID discord_join now refuses a server outside the node's org.
- discord bot: DirectorySync cog pushes on ready (15-min loop), join/leave,
rename, voice/category channel changes (debounced).
- frontend: NodeDiscordRadio panel + picker replaces DiscordJoinModal and the
admin-only Join/Leave buttons.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- c2-core: POST /discord/link-codes (org owner/admin) mints a single-use,
10-minute code; minting revokes the org's older codes. The bot redeems it
via POST /discord/guilds/{id}/link/redeem (service key) inside one
Firestore transaction, so two servers racing for one code cannot both win
and a server linked to org A refuses org B's code. GET/DELETE
/discord/linked-guilds lists/unlinks the caller's org's servers; unlink
stops that server's streams. Rate-limited (mint per user, redeem per
server and per Discord user); every mint/link/refusal/unlink audited.
- resolve_guild_org: an explicitly unlinked server no longer falls back to
DISCORD_UNLINKED_GUILD_ORG_ID. Linking away from the fallback org stops
the streams it had running for that org.
- Discord bot: /drb link code: (Manage Server, enforced at runtime too);
GatedTree answers every other command in an unlinked server with how to
link (autocomplete returns nothing). With the default fallback org set,
nothing is gated — unchanged behaviour.
- Frontend: "Linked Discord servers" card on Settings -> Organization —
generate code with countdown + copy, polls for the new link, unlink.
- 16 tests: single-use, two-server race, one-server/two-org race, expiry,
revoked/malformed codes, wrong-org takeover, idempotent relink, rate
limits, fallback stop, scoped list, unlink without fallback.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
GET /tokens and PUT /tokens/{id}/prefer became platform-admin only in #216
(server-26#204 item 6). Three frontend surfaces still offered them to
operators:
- systems page "Preferred Bot Token" panel: now rendered for admins only.
No non-admin listing endpoint was added: the write behind the picker is
admin-only too, so a list would only feed a control that 403s on save.
The panel also no longer turns a fetch error into "No tokens in pool."
- /tokens page: admin-only (operators are sent to /network).
- /network hub: the Bot Tokens card is shown to admins only.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The remote 'exit 0' came back as ssh exit 1 on the VM, and under bash -e
the step died before the SUPERSEDED check ran (runs 1139, 1144). Capture
the ssh status, check SUPERSEDED first, then fail on a non-zero status.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- reconcile_once only walked desired=on streams, so a token left "releasing"
by /leave while its node was offline (or a deleted node) never returned to
the pool until that node came back (server-26#204 leak shape). Sweep leased
tokens whose node is offline/deleted past the lease grace.
- publish_desired stamped release_seq AFTER building the list, so a stop
landing between the two got a seq whose list still held the stream; an ack
for it could free a token the node was still told to use. Stamp first.
Three regression tests; each fails on the previous commit.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The tip check's git fetch ran before the disk-relief prune that #129 says
must be the first thing a deploy does; on a full disk the fetch could fail
and skip the prune. Prune only removes unreferenced images, so running it
on a run that then turns out superseded is harmless.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Feed/catalog error text (shown to every signed-in user via GET /cameras and
/traffic/511 status fields) carried httpx's full request URL, including
?key= for Ontario 511 and the 511NY DAA key. Query strings are now dropped.
- TfL and Calgary licences are per GEV only (TfL's terms page 403'd), the same
footing as Ontario 511, so they are UNVERIFIED (acknowledgement required)
instead of ungated attribution_required. Attribution still renders.
- The map camera layer clamps its bbox to +/-180/+/-90; zoomed-out bounds
produced a 422 ("could not reach server").
Refs server-26#198
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
c2-core
- discord_streams/discord_leases wired in: POST /discord/streams,
POST /discord/guilds/{id}/stop, GET /discord/guilds/{id}/overview,
GET /discord/streams/{id}, PUT /discord/guilds/{id}/link (admin),
GET /discord/node/{node}/tokens/{id} (node key, lease holder only).
- Retained nodes/{id}/discord/desired (token ids, never token strings),
nodes/+/discord/actual subscribed and handled; per-node publish lock keeps
the retained list the newest; failed publishes retried by the reconciler.
- Tokens freed only after the node acks logout (or offline past
discord_lease_offline_release_seconds) - sweeper and C2 startup no longer
release leases.
- /nodes/{id}/command discord_join/leave map onto streams; CommandPayload
keeps preferred_token_id. /tokens: admin-only list, validate on add against
Discord, refresh, flush via transactional force release.
- Guild -> org via discord_guilds, unlinked guilds fall back to
DISCORD_UNLINKED_GUILD_ORG_ID (default "founding").
- tests/fake_firestore.py: optimistic-transaction fake; 27 lease/race tests.
discord bot
- /join, /leave, /status, autocomplete scoped to the calling guild; /leave
never touches another server; Manage Channels default permission;
/join edits its reply with the node's real result; /help copy fixed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Two merges minutes apart ran two "Deploy to VM" jobs on the VM at once
(616/617, 652/656): racing compose pull/up, one run's prune breaking the
other's retry, health checks judging the wrong containers.
- deploy job: concurrency group drb-prod-deploy, cancel-in-progress false
(Gitea >= 1.26 honours it, go-gitea/gitea#32751; server is 1.27.3).
- Deploy step exits clean, before touching the VM, when its SHA is no
longer origin/main's tip; Health check is skipped for a superseded run.
Stops an older run landing after a newer one and rolling prod back.
- deploy-firestore-rules: its own group plus the same tip check.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
No AI client set a timeout: OpenAI's SDK waits 600s per attempt and retries
twice (timeouts included); google-genai waits forever. A hung provider call
had no upper bound, so recorrelation_sweep's 15-minute pipeline hold-back was
a guess against an unbounded tail.
- config: stt_timeout_seconds (120), openai_timeout_seconds (60),
openai_max_retries (1), gemini_timeout_seconds (60).
- app/internal/ai_clients.py builds every OpenAI/AsyncOpenAI client with them
(transcription, extraction, embeddings, summarizer, vocabulary learner,
trip chat). gemini.py's shared client gets HttpOptions(timeout=ms).
- A timeout raises like any other provider error, so existing fallbacks are
unchanged: rules-only correlation, rules decision on tiebreak, raw
transcript on correction, ai_health "transient".
- MIN_MINUTES_SINCE_PIPELINE_START is now derived from those timeouts
(pipeline_worst_case_seconds, 3-scene budget + 2 min non-AI slack), floor
15: 21 min with the defaults. Loop warns if it reaches the scan window.
- tests/test_ai_timeouts.py: client limits, a real hung-socket request that
gives up in <5s, timeout fallback per tier, the derived bound, and a guard
against any new OpenAI( outside ai_clients.py.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
reject_node only set approval_status, so an approved-then-rejected node kept
its dynsec login, its /upload key and /systems read access.
- reject_node: status -> rejected first (fail-closed), then
dynsec.delete_node_client, then delete node_keys. dynsec failure -> 502,
key kept, retry is idempotent. Re-approve mints a fresh key.
- upload.py: valid key alone is not enough; node must be approved (403),
uncached read so revocation bites immediately.
- dynsec.reconcile_all: deletes rejected nodes' logins on startup, healing
nodes rejected before this change or whose revoke failed.
- Reject confirm text says it revokes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- LiveView: localStorage pins carry image URLs, so they rendered (without
credit) after an admin disabled the provider. Pins now show only while
their provider is serving (server-26#198 licence gating).
- MapView: pinned/docked map players drop out once their provider leaves
the serving list; credits are kept from the last answer so docked players
keep attribution while the overlay is hidden.
- WallPanels: the "no camera provider enabled" link to /admin was a dead CTA
for non-admins (bounced to /). Link shown to admins only, deep-linked to
/admin#cameras; the admin page now honours a tab hash.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Backend (c2-core):
- app/internal/cameras/: provider interface, licence model, per-provider
catalog cache; one adapter per source in design-canvas/camera-sources.md.
Implemented: 511NY (legacy shape + tolerant XML inventory parser, 511x
endpoints UNVERIFIED), Caltrans, DelDOT, Ontario 511, DriveBC, Calgary,
TfL, NSW, Fintraffic. Stubs: NYC DOT, Westchester DPW, TRANSCOM, CTroads,
511NJ, Austin, TxDOT, MD CHART, WSDOT, Tallinn, Tarktee, Warendorf,
TrafficLand, Windy, EarthCam.
- Every provider defaults OFF (config/camera_providers). Gated licences
(agreement required / unverified / non-commercial / paid) need an explicit
acknowledgement stored with who/when and written to audit_log; the audit
write is a precondition. Stubs and prohibited sources cannot be enabled.
Ack is bound to the licence status it was given for.
- GET /cameras (signed-in), GET/PUT /admin/camera-providers (admin only).
- /traffic/511 follows the ny511 provider switch and returns the DAA credit
+ NYSDOT as-is disclaimer; 511NY event descriptions no longer truncated
("may not alter the substance of a report").
Frontend:
- LiveView becomes the ops wall: Region > Incident > Camera drill-down,
worst-first rail (MapView), KPIs, transcript ticker, scrubber incident
bands, auto-tour, deep links (#inc=&cam=&tour), Esc steps up.
- Incident panel: nearest cameras (path-weighted), fused situational context
(ADS-B, AIS, 511NY events, NEXRAD tile sample), units, call spine.
- MapView keeps every layer; adds Camera Viewsheds (approx.) overlay and a
nearest-camera layer for the selected incident.
- CameraFeed hygiene: hidden-tab/off-screen gating, 2 concurrent HLS cap,
last good frame + stale badge, frozen-frame signature, HLS stall watchdog,
provider attribution rendered on the imagery.
- Camera/event popups show source credit, update time and the disclaimer.
- Admin > Camera providers tab with licence status, health, gated confirm.
- Camera detection is not built (blocked on #199/#200); seam only.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- system_config.normalize(): a P25 system with config.mode "conventional"
carries config.channels [{freq MHz, name, nac?}] (1-2, within 1.5 MHz) and
can no longer carry control channels (422). A missing mode is trunked and
passes through untouched, so there is no migration. An analog channel is
mirrored into control_channels for older nodes.
- talkgroups.name_from_system resolves the edge node's synthetic per-channel
id (freq in 100 Hz units) to the channel name.
- The checkin persists radio_channels (conventional channels + encryption).
- Systems editor: Type = P25 trunked / P25 conventional / Analog (NBFM) /
DMR, plus a channel-row editor for conventional and a one-frequency form for
analog. The node page shows conventional channels and an encryption notice.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- approve_node: reuses node_keys/{id}.api_key when one exists (still
re-syncs dynsec with it, still never overwrites node_keys). Re-clicking
Approve can no longer lock a live node out. Reissue stays explicit.
- POST /nodes/{id}/request-update (admin): sets update_requested_at on the
node doc AND sends MQTT node_update best-effort, so offline/locked-out
nodes still get it.
- POST /nodes/{id}/update-check (node, inline auth: Bearer api_key for THAT
node or X-Pickup-Secret): host updater's HTTPS check-in. Returns only
update_requested_at; records updater_last_check_at / sha / applied result.
Unknown node -> 401 (no node_id oracle). C2 cannot name a ref, image or
command.
- Frontend: "Update Node Software" (admin only) calls request-update, honest
confirm text; node page shows updater check-in and last update state.
Tests: tests/test_node_provisioning.py (12).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Any fatal hls.js error set videoFailed permanently, so a brief 511NY feed
drop left the tile on a 60s snapshot until it was reopened. Now fatal
media/network errors get up to 3 in-place recoveries, and a tile that did
fall back retries video every 30s.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The tiebreaker forced ~all of the 09-27 rollover sink's bad links: idle resets
on every link so a 2.5h, 40-call incident read as fresh, and the rules treated
unit overlap as enough on a county channel where units work jobs back to back.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- DOT cameras can be pinned at the camera or docked in a strip over the
map; up to 6 open at once, switchable between modes.
- Below zoom 14, 511 cameras/events bucket into a 40px world-pixel grid,
one icon per cell with a count badge; click zooms in.
- Weather radar refreshed by remounting via `key`, and a remounted child of
an unchecked LayersControl.Overlay isn't re-added to the map — radar went
off every 5 min. Now refreshes in place with setUrl + cache-buster.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A viewer whose first login ran self-serve signup got an empty org of
their own (org_role owner), so they saw no incidents or calls, and the
earlier fix deliberately never moved a user who already had an org.
PATCH /admin/users/{uid} now moves a user when org_id is passed
explicitly (claims + org_members, audited with left_org_id; the old org
is not deleted). The user list returns org_id/org_role, and the admin
user panel shows the org and a 'Move to my organization' button when it
isn't yours.
Verified: c2-core pytest 496 passed; frontend tsc --noEmit clean.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
POST /admin/users set a role claim but never an org_id/org_role claim or
an org_members doc. firestore.rules gates every read on the org_id claim,
so a viewer created from the Users page saw no incidents or calls.
New users now join the requested org (default: the acting admin's own,
else 'founding') as org_role 'member', with the same org_members doc
POST /auth/signup writes. PATCH /admin/users/{uid} attaches an org-less
user the same way, which heals accounts created before this fix; it never
silently moves a user who already has an org.
Verified: c2-core pytest 495 passed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Token colors wrapped in color-mix so opacity modifiers (bg-surface/90 etc.)
generate rules; map overlay panels were rendering see-through.
- Light mode: keep white text on saturated fills (primary/danger buttons were
remapped to navy); map text-gray-200, bg-gray-800/60, border-gray-600.
- Dark mode: lift text-gray-600/700 to gray-500 for contrast.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Clicking a camera's snapshot pins a 192x108 tile anchored at the camera --
the live HLS stream when the camera has one (hls.js; Safari native), else the
snapshot refreshed every 60s, falling back to the snapshot if the stream dies.
Up to 6 pins, oldest dropped. Players exist only while the DOT Cameras overlay
is on, so hiding it stops every stream.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>