Sentinel Command Center — cloud dashboard for managing and viewing security cameras under the Sentinel by SourceBox product brand. FastAPI backend + React 19 frontend with Clerk authentication. Live video is streamed through an in-memory segment cache — no Tigris, no S3, no presigned URLs in the live path.
Brand-history note for grep-discoverability: the product has carried three names —
OpenSentry(early),SourceBox Sentry(mid), andSentinel by SourceBox(current, from May 2026 onward). TheSentinel AIname is reserved specifically for the AI-agent feature. Both GitHub repos were renamed in May 2026: Command CenterOpenSentry-Command→Sentinel-Command, and CloudNodeopensentry-cloud-node→Sentinel-CameraNode(note the deliberate "CameraNode" — the repo name now describes the artifact more literally, while the binary, install paths, and product UI keep saying "CloudNode"). GitHub auto-redirects the old URLs, so any hardcoded reference in a release artifact / cached doc / external bookmark continues to resolve. Identifiers preserved verbatim across the entire rebrand (do not rename these without a migration plan): the binary namesourcebox-sentry-cloudnode, the env-var prefixSOURCEBOX_SENTRY_*, the Windows install pathC:\ProgramData\SourceBoxSentry\, the AES key-derivation domain stringopensentry-cloudnode-machine-id-v2(see CloudNodedatabase.rs::KEY_DOMAIN_V2), and the production hostnameopensentry-command.fly.dev(tied to the Fly app, decoupled from the repo rename).
Prerequisites: Python ≥ 3.12 (enforced by backend/pyproject.toml), Node 18+, uv for Python dependency management.
# Backend
cd backend
uv sync
uv run python start.py # http://localhost:8000
# Tests
cd backend
uv run pytest
# Frontend
cd frontend
npm install
npm run dev # http://localhost:5173
npm run build # Production build → backend/static/Backend config is loaded from environment variables (see backend/.env.example).
Required:
CLERK_SECRET_KEY/CLERK_PUBLISHABLE_KEY— Clerk auth
Optional:
CLERK_WEBHOOK_SECRET— Svix signature for Clerk subscription + organizationMembership webhooksDATABASE_URL— defaults tosqlite:///./opensentry.db. Production usessqlite:////data/opensentry.dbon a Fly volume — single-machine deploy, NullPool, WAL, busy_timeout=5000 (seeapp/core/database.py).FRONTEND_URL— extra CORS origin (must have scheme, no trailing slash)REDIS_URL— slowapi rate-limiter shared storage. Without it, per-process in-memory counters (single-VM safe; multi-VM round-robins around the limit). Currently in production via Upstash on Fly.SEGMENT_CACHE_MAX_PER_CAMERA— segments cached in memory per camera (default 60, ~60s — CloudNode emits 1-second segments)SEGMENT_CACHE_MAX_TOTAL_BYTES— global byte ceiling across all camera caches (default 2 GiB). When exceeded,hls.pyevicts oldest segments across ALL cameras until back under cap.SEGMENT_PUSH_MAX_BYTES— max bytes per pushed segment (default 2 MB)PLAYLIST_PUSH_MAX_BYTES— max bytes per pushed playlist (default 64 KB)CLEANUP_INTERVAL— run cache eviction every N playlist updates (default 20)INACTIVE_CAMERA_CLEANUP_HOURS— free caches for cameras offline this long (default 24)LOG_RETENTION_DAYS— stream + MCP + audit + motion + notification + email log retention (default 90; per-tier override via plan slug — Free 30 / Pro 90 / Pro Plus 365)OFFLINE_SWEEP_INTERVAL_SECONDS— how often to mark stale rows offline (default 30)SENTRY_DSN— error tracking. In production this is managed by the Fly Sentry extension (fly ext sentry create -a opensentry-command) which provisions a sponsored Team plan and auto-injects the secret; you rarely set this by hand.app/core/sentry.py::init_sentry()is a no-op when the DSN is absent, so local dev needs no extra config. Dashboard:fly ext sentry dashboard -a opensentry-command.
Email (Resend, optional):
EMAIL_ENABLED— global kill-switch (defaultfalse). Code can ship with it off; flip totrueonce DNS propagates and a smoke test passes. Worker still runs when off; transport short-circuits with a logged "would have sent" line.RESEND_API_KEY— Resend transactional API key (re_…)RESEND_WEBHOOK_SECRET— Svix signing secret for the/api/webhooks/resendbounce/complaint handlerEMAIL_FROM_ADDRESS— defaultnotifications@sourceboxsentry.com(must be on a verified Resend domain)EMAIL_FROM_NAME— defaultSentinel by SourceBoxEMAIL_WORKER_INTERVAL_SECONDS— outbox-drain tick interval (default 5)EMAIL_WORKER_BATCH_SIZE— max rows drained per tick (default 20)EMAIL_MAX_ATTEMPTS— retries before a row is permanently failed (default 3)
Frontend config: VITE_CLERK_PUBLISHABLE_KEY, VITE_API_URL, VITE_LOCAL_HLS.
backend/
├── app/
│ ├── main.py # FastAPI app, CORS, SPA middleware, rate limiting,
│ │ # lifespan startup (8 background loops: log-cleanup,
│ │ # offline-sweep, viewer-usage flush, release-cache
│ │ # refresh, email-worker, disk-check, motion-digest,
│ │ # sentinel-reaper), MCP mount, disk-check + motion-
│ │ # digest loop bodies
│ ├── templates/emails/ # 46 Jinja2 email templates — _layout.html.j2 +
│ │ # 15 kinds (camera offline/online, node offline/
│ │ # online, incident_created, mcp_key_created/revoked,
│ │ # cloudnode_disk_low, member_added/role_changed/
│ │ # removed/promotion_requested, motion, motion_digest,
│ │ # welcome) × 3 files (subject.txt + body.txt + body.html)
│ ├── api/
│ │ ├── cameras.py # Cameras, groups, settings, audit logs, danger zone
│ │ ├── nodes.py # CloudNode register/heartbeat/CRUD, plan info,
│ │ │ # cloudnode_disk_low alert helper (per-node debounce
│ │ │ # via Setting key colon-suffix pattern)
│ │ ├── hls.py # HLS playlist + segment memory cache + push-segment
│ │ │ # + HTTP motion fallback + global byte-cap eviction
│ │ ├── audit.py # Stream access logs + stats
│ │ ├── incidents.py # AI-generated incident reports (CRUD + evidence blobs)
│ │ ├── mcp_keys.py # MCP API key management + tool catalog + audit
│ │ │ # notifications (mcp_key_created/revoked)
│ │ ├── mcp_activity.py # MCP activity logs, stats, SSE stream
│ │ ├── integration.py # Home Assistant REST API (osi_ keys): key mgmt +
│ │ │ # camera discovery, snapshot, recording, status, motion SSE
│ │ ├── motion.py # Motion event queries, stats, SSE stream
│ │ ├── notifications.py # Notification inbox, unread count, SSE, broadcaster,
│ │ │ # email kind map, email cooldown gate (motion v1.1),
│ │ │ # email prefs endpoints, signed unsubscribe
│ │ ├── install.py # CloudNode + MCP setup script endpoints
│ │ ├── ws.py # CloudNode WebSocket channel
│ │ └── webhooks.py # Clerk subscription + organizationMembership +
│ │ # Resend bounce/complaint webhook handlers
│ ├── mcp/
│ │ └── server.py # FastMCP server + 23 tools + ScopeMiddleware
│ ├── core/
│ │ ├── audit.py # Audit-log writer (error-swallowing pattern)
│ │ ├── auth.py # Clerk JWT validation (V1 + V2 permissions), dependencies
│ │ ├── config.py # Environment loading (Config class)
│ │ ├── clerk.py # Clerk SDK init
│ │ ├── database.py # SQLAlchemy engine + session factory + Base
│ │ ├── email.py # Resend SDK touchpoint (single send entry; honors
│ │ │ # EMAIL_ENABLED kill-switch + suppression list)
│ │ ├── email_templates.py # Jinja2 renderer (per-template autoescape selection;
│ │ │ # .html.j2 escapes, .txt.j2 doesn't)
│ │ ├── email_unsubscribe.py # Signed JWT for one-click footer unsubscribe links
│ │ ├── email_worker.py # EmailOutbox drain loop + retry + reclaim-stuck-sending
│ │ ├── errors.py # ApiError class — structured 4xx/5xx envelope
│ │ ├── limiter.py # slowapi Limiter instance (tenant-aware key)
│ │ ├── migrations.py # sync_schema (column adder), drop_orphan_tables,
│ │ │ # sanitize_existing_codecs — stand-in for Alembic
│ │ ├── plans.py # PLAN_LIMITS, effective_plan_for_caps, grace period
│ │ ├── recipients.py # Clerk org member lookup + 5-min TTL cache + audience
│ │ │ # filter (admin / all) + suppression-list exclusion
│ │ ├── release_cache.py # GitHub /releases/latest cache for CloudNode
│ │ │ # update_available signal
│ │ └── sentry.py # Sentry SDK init (no-op when SENTRY_DSN unset)
│ ├── models/models.py # 18 ORM models (see Data Models below)
│ └── schemas/schemas.py # Pydantic request/response schemas incl. McpKeyCreate
├── scripts/
│ ├── install.sh # CloudNode installer for Linux/macOS (served by install.py).
│ │ # Windows installs via the MSI from the latest CloudNode
│ │ # GitHub release, not a script — see CloudNodeSetup docs.
│ └── mcp-setup.sh / .ps1 # MCP client config helpers (Claude Code / Desktop / Cursor / Windsurf)
├── tests/ # pytest — security, MCP scoping, motion, notifications, offline sweep,
│ # billing/grace, ApiError envelope, drop_orphan_tables migration
├── start.py # Uvicorn entrypoint (0.0.0.0:8000, reload=True)
├── pyproject.toml # Includes [tool.uv] constraint-dependencies pinning
│ # python-multipart and authlib past Dependabot moderates
└── .env.example
docs/ # Supplementary docs that don't belong in README/AGENTS
├── README.md # Index of runbooks + ADRs
└── adr/
├── 0001-sync-schema-vs-alembic.md
└── 0002-viewer-hour-billing.md
frontend/
├── tests/ # vitest + @testing-library/react + happy-dom
│ ├── setup.js # @testing-library/jest-dom matchers + cleanup
│ ├── sanity.test.js # runner + DOM + matcher wiring smoke
│ ├── services/api.test.js # fetchWithAuth shape contract (4 wire shapes)
│ ├── components/ # DocsDiagrams, UpgradeModal, EmptyState
│ └── pages/DocsPage.test.jsx # split structural smoke (every section id renders)
└── src/
├── pages/
│ ├── LandingPage.jsx # Public landing page
│ ├── DashboardPage.jsx # Camera grid with status cards + controls
│ ├── SettingsPage.jsx # Nodes, groups, recording, danger zone
│ ├── McpPage.jsx # MCP keys (scope picker) + activity (live SSE)
│ ├── IncidentsPage.jsx # AI- and human-filed incident reports + create flow
│ ├── AdminPage.jsx # Stream logs, MCP activity, audit trail
│ ├── PricingPage.jsx # Public pricing tiers
│ ├── SecurityPage.jsx # Public privacy + security claims page (/security)
│ ├── SentinelPage.jsx # Sentinel agent dashboard — config (triggers,
│ │ # schedule, cooldown, scope), run history,
│ │ # manual "Run now"
│ ├── LegalPage.jsx # /legal/:page — Terms, Privacy, etc.
│ ├── DocsPage.jsx # /docs — slim composition shell that renders 19 sections
│ ├── docs/ # one file per <section> on /docs (extracted from the
│ │ # 1,747-line monolith); shared state lives in
│ │ # docs/context.jsx (DocsProvider + OsTabs + useDocs)
│ ├── SignInPage.jsx / SignUpPage.jsx
│ └── TestHlsPage.jsx # Admin-only HLS debug view
├── components/
│ ├── HlsPlayer.jsx # HLS.js player with Clerk JWT xhrSetup
│ ├── CameraCard.jsx # Live thumbnail + status + actions
│ ├── IncidentReportModal.jsx # Markdown + evidence viewer
│ ├── NotificationBell.jsx # Unread badge + inbox popover (SSE-fed)
│ ├── AddNodeModal.jsx # Node creation flow (shows one-time API key)
│ ├── KeyRotationModal.jsx # Rotate node API key
│ ├── UpgradeModal.jsx # Paywall prompt (plan gating)
│ ├── HeartbeatBanner.jsx # "Waiting for first heartbeat" banner shown
│ │ # after node creation; polls /api/nodes/{id}
│ │ # until it sees a last_seen, persists its
│ │ # dismissed state in localStorage
│ ├── WelcomeHero.jsx # Dashboard empty-state hero — exports
│ │ # AdminWelcomeHero (3-step "set up your first
│ │ # camera" checklist) and MemberWelcomeHero
│ │ # (capability-focused welcome for non-admins)
│ ├── Layout.jsx / PublicLayout.jsx
│ ├── LandingNav.jsx / LandingFooter.jsx
│ ├── ToastContainer.jsx / LoadingSpinner.jsx
│ ├── DocsDiagrams.jsx # 8 inline-SVG diagrams embedded on /docs
│ │ # (System Architecture / HLS Pipeline / Motion FSM /
│ │ # Config Precedence / Incident Lifecycle / MCP Workflow /
│ │ # Security Model rings / Dashboard IA tree)
│ └── EmptyState.jsx
├── hooks/
│ ├── useNotifications.jsx # SSE inbox + unread count
│ ├── useMotionAlerts.jsx # Motion SSE + toast fan-out
│ ├── usePlanInfo.jsx # Plan info + node quotas
│ ├── useSharedToken.jsx # Shared Clerk token provider (HLS + fetch)
│ └── useToasts.jsx
└── services/api.js # Typed client for every backend endpoint
Browser ──Clerk JWT──→ FastAPI ──SQL──→ SQLite / PostgreSQL
↕
CloudNode ──X-Node-API-Key──→ FastAPI ──RAM──→ in-memory segment cache
──WebSocket──────→ + per-org motion/notification broadcasters
MCP Client ──Bearer osc_…──→ FastMCP → ScopeMiddleware → tools
- CloudNode generates HLS segments via FFmpeg (1-second
.tsfiles by default; seestreaming.hls.segment_durationin CloudNode'sconfig.yaml) - CloudNode calls
POST /api/cameras/{id}/push-segment?filename=segment_NNNNN.tswith the raw.tsbody andX-Node-API-Keyheader - Backend stores the bytes in
_segment_cache[camera_id][filename], evicting the oldest onceSEGMENT_CACHE_MAX_PER_CAMERAis exceeded - CloudNode calls
POST /api/cameras/{id}/playlistwith the rollingstream.m3u8text - Backend rewrites playlist segment filenames to relative
segment/<file>proxy URLs and caches the result in_playlist_cache - Browser calls
GET /api/cameras/{id}/stream.m3u8with JWT → served instantly from_playlist_cache - Browser fetches each segment via
GET /api/cameras/{id}/segment/{filename}→ served from_segment_cachein memory - Cache eviction sweeps every
CLEANUP_INTERVALplaylist updates; the daily cleanup loop flushes caches for cameras offline >INACTIVE_CAMERA_CLEANUP_HOURS
main.py SPA middleware:
/api/*,/ws/*,/install.*,/mcp-setup.*→ FastAPI handlersPOST /mcp→ FastMCP ASGI app (streamable HTTP)GET /mcp→ ReactMcpPage(dashboard route)/assets/*→ static files from React build- Everything else →
index.html(SPA client-side routing)
GET /docs is owned by the React DocsPage; FastAPI's auto docs live at /api-docs (Swagger) and /api-redoc (ReDoc); the OpenAPI schema is at /api/openapi.json.
get_current_user() dependency in core/auth.py:
- Extracts
Authorization: Bearer <token>header - Authenticates with Clerk SDK
- Extracts
sub(user_id),org_id, and permissions from JWT claims (V1 or V2 format) - Returns an
AuthUserobject withis_admin,permissions, etc.
V2 permission decoding (decode_v2_permissions()):
oclaim contains org object withfpm(feature permission map) andper(permissions)feaclaim contains feature list (e.g.o:admin,o:cameras)- Decoded to
org:{feature}:{permission}format
Dependencies:
require_view()→ any authenticated org member (no extra permission check)require_admin()→ Clerk roleorg:admin/admin, ororg:cameras:manage_cameraspermission
CloudNode endpoints validate X-Node-API-Key:
- SHA-256 hash the provided key
- Match against
api_key_hashonCameraNode - Derive
org_idfrom the matched node row
MCP endpoint (POST /mcp) validates Authorization: Bearer osc_<hex>:
- SHA-256 hash the raw key
- Match against
McpApiKey.key_hashwithrevoked=FalseANDkind="mcp" ScopeMiddleware(see below) filters tool discovery + invocation per-key
/api/integration/* endpoints (Home Assistant) validate Authorization: Bearer osi_<hex>
via core/integration_auth.py::require_integration_org:
- SHA-256 hash the raw key
- Match against
McpApiKey.key_hashwithrevoked=FalseANDkind="integration" - Derive
org_idfrom the matched row
Integration keys share the mcp_api_keys table with MCP keys, split by kind
(mcp vs integration). The kind filter is applied to every McpApiKey
query — both auth paths and both management list/revoke endpoints — so the two
key kinds can never cross surfaces. require_integration_org uses a one-shot
DB session (not Depends(get_db)) so the long-lived motion SSE doesn't pin a
connection for its lifetime. No plan gate — available on all tiers.
All 20 models in backend/app/models/models.py. Every model has org_id for tenant isolation EXCEPT ProcessedWebhook (global webhook dedup, keyed on Svix msg_id) and EmailSuppression (operator-global bounce / complaint list, intentionally cross-tenant so a hard-bounced address stops being mailed for every org).
| Model | Key Fields | Purpose |
|---|---|---|
Camera |
camera_id, node_id (FK), name, status, video_codec, audio_codec, group_id, last_seen |
Camera registered by a CloudNode; effective_status flips to offline after a 90s heartbeat gap |
CameraNode |
node_id, api_key_hash, hostname, status, video_codec, audio_codec, last_seen, key_rotated_at, storage_* |
Physical CloudNode device + storage stats from heartbeat (drives cloudnode_disk_low alert) |
CameraGroup |
name, color, icon |
User-defined camera grouping |
Setting |
key, value |
Per-org key/value store. Used for plan slug, recording config, email toggles, motion email cooldown anchors (motion_email_cooldown_start:{camera_id}), CloudNode disk debounce (cloudnode_disk_low_emit_at:{node_id}), payment past-due flags. |
AuditLog |
event, user_id, ip_address, details, (org_id, timestamp) index |
Security audit trail |
StreamAccessLog |
user_id, camera_id, ip_address, user_agent, accessed_at, (org_id, accessed_at) index |
Stream playback audit |
McpApiKey |
name, key_hash, kind (mcp/integration), scope_mode, scope_tools (JSON text), last_used_at, revoked |
Both MCP keys (osc_, AI agents) AND Home Assistant integration keys (osi_) — one table, split by kind; scope_mode (MCP only): all / readonly / custom |
McpActivityLog |
tool_name, key_name, status, duration_ms, args_summary, error, timestamp, (org_id, timestamp) index |
Per-call MCP audit log |
Incident |
title, summary, report (markdown), severity, status, camera_id, created_by, resolved_at, resolved_by |
AI-generated incident (open / acknowledged / resolved / dismissed) |
IncidentEvidence |
incident_id (FK cascade), kind (snapshot / clip / observation), text, camera_id, data (LargeBinary), data_mime |
Snapshot JPEG, clip (MPEG-TS bytes), or text observation — evidence travels inline with the incident |
MotionEvent |
camera_id, node_id, score (0–100), segment_seq, timestamp, (org_id, timestamp) index |
Motion detected by CloudNode scene-change analysis |
Notification |
kind, audience (all / admin), title, body, severity, link, camera_id, node_id, meta_json |
Unified inbox entry (15 kinds: motion, motion_digest, camera_offline / camera_online, node_offline / node_online, incident_created, mcp_key_created / mcp_key_revoked, cloudnode_disk_low, member_added / member_role_changed / member_removed / member_promotion_requested, welcome) |
UserNotificationState |
clerk_user_id + org_id (unique), last_viewed_at |
Per-user read cursor for the inbox |
OrgMonthlyUsage |
org_id + year_month (unique), viewer_seconds |
One row per org per calendar month; aggregates live-playback viewer-seconds for viewer-hour cap enforcement |
EmailOutbox |
recipient_email, subject, body_text, body_html, kind, notification_id (soft FK), status (pending/sending/sent/failed/suppressed), attempts, resend_message_id, (status, created_at) index |
Pending email send queue. Drained by email_worker_loop every 5s. Survives process restart. |
EmailLog |
recipient_email, kind, status, resend_message_id, error, timestamp, (org_id, timestamp) index |
Per-org email send/delivery audit. Per-tier retention via run_log_cleanup. |
EmailSuppression |
address (unique, lowercased), reason, source, created_at |
Local mirror of Resend's suppression list. Worker checks before every send. Cross-tenant by design (no org_id) — a hard-bounced address stops getting mail across every org. |
ProcessedWebhook |
svix_msg_id (unique), event_type, created_at |
Webhook dedup table for both Clerk and Resend (idempotency under Svix retry). Cross-tenant by design (no org_id). |
SentinelConfig |
org_id (unique), enabled, motion_enabled, incident_opened_enabled, motion_cooldown_min, schedule_mode (always / scheduled / off), schedule_start/end (HH:MM), active_days (JSON), camera_scope (JSON) |
Per-org Sentinel AI configuration. Lazily upserted on first GET via _ensure_config_row(). |
SentinelRun |
id (UUID hex), org_id, triggered_at, trigger_type (motion / incident_opened / manual / scheduled), camera_id, outcome (pending / running / incident / no_action / error), severity, incident_id (no FK), started_at, completed_at, tool_trace |
One row per Sentinel agent invocation. Drives the Sentinel AI dashboard's run history + the monthly-cap counter. |
Validation constants (also in models.py):
INCIDENT_STATUSES=("open", "acknowledged", "resolved", "dismissed")INCIDENT_SEVERITIES=("low", "medium", "high", "critical")
| File | Prefix | Tags |
|---|---|---|
cameras.py |
/api |
api |
nodes.py |
/api/nodes |
nodes |
hls.py |
/api/cameras/{camera_id} |
streaming |
audit.py |
/api |
audit |
incidents.py |
/api/incidents |
incidents |
mcp_keys.py |
/api/mcp |
mcp |
mcp_activity.py |
/api/mcp/activity |
mcp-activity |
integration.py |
/api/integration |
integration |
motion.py |
/api/motion |
motion |
notifications.py |
/api/notifications |
notifications |
sentinel.py |
/api/sentinel |
sentinel |
install.py |
(none) | installation |
ws.py |
(none) | ws |
webhooks.py |
/api/webhooks |
webhooks |
cameras.py (prefix /api):
GET /cameras— list cameras (view)GET /cameras/{camera_id}— get camera (view)POST /cameras/{camera_id}/snapshot— ask node to capture & store a snapshot locally (view, 30/min)POST /cameras/{camera_id}/recording— manual start/stop recording on the camera; thin wrapper that flipscontinuous_24_7(admin, 30/min)PATCH /cameras/{camera_id}/recording-settings— partial-update per-camera recording policy (continuous_24_7,scheduled_recording,scheduled_start,scheduled_end). Per-camera since v0.1.43 — replaced the retired org-level/settings/recordingendpoint pair (admin, 30/min)POST /cameras/{camera_id}/codec— CloudNode reports codec after first segment (node API key, 30/min)GET /camera-groups— list groups (view)POST /camera-groups— create group (admin, 20/min)DELETE /camera-groups/{group_id}— delete group; member cameras unassigned (admin, 60/min)PUT /cameras/{camera_id}/group— assign / unassign a camera's group (admin, 60/min)GET /settings— all org-level settings (view)POST /settings/timezone— set the org's IANA timezone for scheduled-recording-window interpretation (admin, 30/min)GET /settings/notifications— read inbox + email toggle prefs (view)POST /settings/notifications— update inbox + email toggle prefs (admin, 30/min)GET /settings/motion-ingestion— read motion-event ingestion toggle (admin)POST /settings/motion-ingestion— toggle motion-event ingestion org-wide (admin, 30/min)GET /audit-logs— audit logs (admin, 120/min)POST /settings/danger/wipe-logs— selectively delete stream + MCP activity logs while keeping the org running (admin + Pro/Pro Plus, 5/hour). Operator-convenience feature, not a right-to-erasure obligation.POST /settings/danger/full-reset— GDPR Article 17 right-to-erasure: wipe all nodes/cameras/recordings/snapshots/incidents/logs/settings for the org (admin, every plan, 3/hour). Routes through the sharedapp.core.gdpr.delete_org_datahelper so this end-state matches whatorganization.deletedClerk webhook produces.
nodes.py (prefix /api/nodes):
POST /validate— validate node_id + API key pair, used by CloudNode setup wizard (10/min)POST /register— CloudNode registration (API key, 10/min)POST /heartbeat— CloudNode heartbeat (API key, 60/min)GET /— list nodes (admin)GET /plan— current plan, node usage, and limits (view)POST /— create node (admin, requires active billing + plan capacity, 20/hour)GET /ws-status— which nodes are WebSocket-connected (admin)POST /self/decommission— node-initiated factory reset (API key, 10/hour); called from CloudNode's/wipe confirmbefore the local wipe runs so a freshly-wiped box doesn't linger as a stale offline nodeGET /{node_id}— get node (admin)DELETE /{node_id}— delete node (admin; cascades cameras + flushes segment caches, 20/hour)POST /{node_id}/rotate-key— rotate API key (admin, 5/min)
hls.py (prefix /api/cameras/{camera_id}):
GET /stream.m3u8— HLS playlist served from cache (JWT)GET /segment/{filename}— serve cached.tssegment from memory (JWT)POST /push-segment?filename=…— CloudNode pushes.tssegment into cache (API key, 1200/min)POST /playlist— update playlist (API key, 600/min)POST /motion— motion event delivery (API key, 120/min). HTTP-only post v0.1.61; the pre-v0.1.61 WebSocket-forwarding branch had no producer and was removed.
audit.py (prefix /api):
GET /audit/stream-logs— stream access logs (admin)GET /audit/stream-logs/stats— aggregates by camera/user/day (admin)
incidents.py (prefix /api/incidents):
GET /— list (admin; filters:status,severity,camera_id,limit,offset)GET /counts— aggregate counts (admin)GET /{incident_id}— detail with evidence list (admin)PATCH /{incident_id}— update status / severity / summary / report (admin)DELETE /{incident_id}— delete incident + cascade evidence (admin)GET /{incident_id}/evidence/{evidence_id}— stream snapshot or clip blob (admin)GET /{incident_id}/evidence/{evidence_id}/playlist.m3u8— synthetic single-segment HLS playlist for in-dashboard clip playback (admin)
mcp_keys.py (prefix /api/mcp):
POST /keys— generate key; JSON body{name, scopeMode, scopeTools?}; returns plaintextosc_...once (admin + active billing)GET /tools— live tool catalog with read/write kind (admin)GET /keys— list MCP keys for the org (admin)DELETE /keys/{key_id}— revoke (admin)
mcp_activity.py (prefix /api/mcp/activity):
GET /stream— SSE stream of live MCP tool calls (admin)GET /recent— recent tool calls (admin)GET /sessions— session summaries (admin)GET /stats— aggregated stats by tool / key / time (admin)GET /logs— filterable MCP tool call log (admin)GET /logs/stats— summary counts for logs (admin)
integration.py (prefix /api/integration) — Home Assistant; key mgmt is admin (Clerk JWT), data plane is osi_ integration-key auth (all tiers):
POST /keys— mint anosi_integration key; returns plaintext once (admin)GET /keys— list integration keys (admin)DELETE /keys/{key_id}— revoke (admin)GET /cameras— all cameras across all nodes with LAN-directlocal_url, snapshot URL, recording state (integration key)GET /cameras/{id}/snapshot— live JPEG via the node round-trip (integration key)POST /cameras/{id}/recording— togglecontinuous_24_7; body{recording: bool}(integration key)GET /status— org rollup: camera/node online counts, per-node disk + version, plan (integration key)GET /motion/stream— SSE motion feed for HAbinary_sensors; separate subscriber pool from the dashboard's/api/motion/events/stream(integration key)
motion.py (prefix /api/motion):
GET /events— list motion events; filters:camera_id,hours,limit,offset(view)GET /events/stats— per-camera aggregates (view)GET /events/stream— SSE motion feed for dashboard notifications (view)
notifications.py (prefix /api/notifications):
GET /— paginated inbox, newest first; applies audience filter (view)GET /unread-count— cheap count for the bell badge (capped at 99) (view)POST /mark-viewed— bumplast_viewed_atto now (view)POST /clear-all— empty the inbox for this org (admin)POST /request-admin-promotion— member-initiated admin-access request; fires themember_promotion_requestednotification kind to org admins (view)GET /stream— SSE stream for the bell; audience filter applied server-side (view)GET /email/preferences— read the org's per-kind email toggles (view)POST /email/preferences— update the org's per-kind email toggles (admin)GET /email/unsubscribe— one-click unsubscribe link target. Validates a signed JWT in the query string, flips the matching email toggle off, returns a confirmation HTML page (no auth required — auth comes from the signed token).
install.py (no prefix, no auth):
GET /install.sh— CloudNode installer for Linux/macOS. Windows users install via the MSI from the latest CloudNode GitHub release; the legacy/install.ps1route was removed when the MSI shipped.GET /mcp-setup.sh/GET /mcp-setup.ps1— MCP client config helpers (separate from CloudNode install — these configure Claude / Cursor / etc. to talk to this Command Center)
ws.py (no prefix):
WS /ws/node— WebSocket channel for CloudNode realtime. Preferred auth (v0.1.65+):X-Node-API-Key+X-Node-Idheaders on the upgrade request. Back-compat fallback (pre-v0.1.65 clients):?api_key=…&node_id=…query string — still accepted, but the handler logs a deprecation warning on every successful auth so we can sunset the path once the install base has rolled forward. Headers > query because URLs land in too many log sinks (uvicorn access, Fly platform logs, log-shipper exports).- Node → Backend:
heartbeat,command_result - Backend → Node:
ack,command(take_snapshot,list_snapshots,list_recordings,wipe_data),error - Motion events do not flow over WS — they reach Command Center via
POST /api/cameras/{id}/motion. The pre-v0.1.61 wire format reserved anevent/motion_detectedframe for this path but it was never produced; the unused branch was removed in v0.1.61.
- Node → Backend:
sentinel.py (prefix /api/sentinel):
GET /config— read the org's Sentinel AI config + plan-awaremonthly_cap+plan_gatedflag (view; always 200 — non-eligible orgs get a read-only payload for the upgrade banner)PATCH /config— partial update of Sentinel config (admin + Pro/Pro Plus; 402 otherwise)GET /runs— list recent runs + stats (admin)GET /runs/{run_id}— single run with full tool trace (admin)POST /runs/manual— operator "Run now"; skips schedule + scope gates but cap-enforced (admin + Pro/Pro Plus; 429 at cap)GET /runs/pending— service-to-service. Agent polls this on wakeup to drain runs across all orgs (FIFO). Auth:X-Sentinel-Agent-Keyheader.POST /runs/{run_id}/start— service-to-service. Agent claims a pending run (pending → running). Idempotent.POST /runs/{run_id}/complete— service-to-service. Agent posts terminal outcome (incident/no_action/error) + full tool trace. Cross-checksincident_idbelongs to the run's org. Idempotent on terminal rows.
webhooks.py (prefix /api/webhooks):
POST /clerk— Clerk subscription + organizationMembership events (Svix signature whenCLERK_WEBHOOK_SECRETis set; 120/min)POST /resend— Resend bounce / complaint / unsubscribe webhooks (Svix signature whenRESEND_WEBHOOK_SECRETis set); writes toEmailSuppressionso subsequent sends short-circuit before the API call
Top-level (main.py):
GET /api/health— minimal liveness for load balancers:{"status": "healthy", "version": "2.1.2"}(no auth)GET /api/health/detailed— verbose status for status-page polling and on-call diagnostics:{status, version, uptime_seconds, started_at, time, checks: {database: {status, latency_ms}, hls_cache: {playlists_cached, segment_cameras}, viewer_usage: {pending_writes, status}, sse: {subscriber_orgs, subscriber_total}}}. Public on purpose — every value is metric-shaped, never an org/camera/user identifier (pinned by a privacy regression test intests/test_health.py).- FastAPI docs:
/api-docs(Swagger),/api-redoc(ReDoc), OpenAPI at/api/openapi.json./docsis the ReactDocsPage.
Mounted at /mcp via FastMCP streamable HTTP. Authenticates with Authorization: Bearer osc_... against McpApiKey.key_hash. Exposes 23 tools (16 read + 7 write).
ScopeMiddleware (in app/mcp/server.py) runs before every list_tools and call_tool request:
- Extracts the Bearer token from request headers via
get_http_headers() - SHA-256-hashes the key and looks up the matching
McpApiKeyrow - Computes the allowed-tool frozenset from
scope_mode+scope_tools - Filters
list_toolsresponses and raisesToolErroron disallowedcall_toolinvocations
Scope modes:
"all"(default; NULL also treated as "all" for legacy rows) → every tool"readonly"→ intersection withMCP_READ_TOOLS(16 tools)"custom"→ intersection ofscope_toolsJSON list withMCP_ALL_TOOLS(unknown names silently dropped — can't accidentally enable a new server-side WRITE tool via typo)
Read tools (MCP_READ_TOOLS, 16):
| Tool | Purpose |
|---|---|
list_cameras |
All cameras with status/codec/group |
get_camera |
One camera by id |
get_stream_url |
Authenticated HLS URL for a camera |
view_camera |
Live JPEG from a camera (agent can see it) |
watch_camera |
Multi-frame burst (2–10 frames, 1–30s apart) |
list_camera_groups |
Camera groups for the org |
list_nodes |
CloudNodes + their status |
get_node |
One node by id |
get_camera_recording_policy |
One camera's recording policy (continuous / scheduled / off) |
get_stream_logs |
Stream access audit entries |
get_stream_stats |
Aggregated views by camera/user/day |
get_system_status |
Org-wide snapshot (cameras on/offline, plan, nodes) |
list_incidents |
Previous incidents (filter by status/severity/camera) |
get_incident |
Full detail of one incident incl. evidence metadata |
get_incident_snapshot |
Fetch a previously attached snapshot JPEG |
get_incident_clip |
Metadata about a previously attached clip |
Write tools (MCP_WRITE_TOOLS, 7):
| Tool | Purpose |
|---|---|
create_incident |
Open a new incident (title, summary, severity) |
add_observation |
Append a text observation to an incident |
attach_snapshot |
Capture a JPEG and attach it as evidence |
attach_clip |
Save the recent live buffer as a video clip (pulls from in-memory HLS cache) |
update_incident |
Change status / severity / summary / report body (revisions) |
finalize_incident |
Write the markdown report body for the first time |
set_camera_recording_policy |
Flip a camera between continuous / scheduled / off (mutually exclusive; HH:MM windows in org timezone) |
Configured in main.py:
cors_origins = [
"http://localhost:5173",
"http://localhost:8000",
"https://opensentry-command.fly.dev",
]Plus FRONTEND_URL if set (validated: must have scheme, no trailing slash, no embedded whitespace). All methods and headers allowed; credentials allowed.
slowapi with a tenant-aware key:
POST /api/nodes/validate,POST /api/nodes/register— 10/minPOST /api/nodes/heartbeat— 60/minPOST /api/nodes/{id}/rotate-key— 5/minPOST /api/cameras/{id}/codec— 30/minPOST /api/cameras/{id}/push-segment— 1200/minPOST /api/cameras/{id}/playlist— 600/minPOST /api/cameras/{id}/motion— 120/min
HLS GET paths (stream.m3u8, segment/{file}) are not per-request rate limited — segment fetches are fast-path with no per-request DB work. They are however metered against the caller's monthly viewer-hour cap (see Plan Enforcement → Viewer-hours below): every served segment increments an in-memory counter, and the 429 kicks in when the counter exceeds max_viewer_hours_per_month * 3600.
Two endpoints, both Svix-signed (signature verification mandatory in production):
- Verifies signature with Svix when
CLERK_WEBHOOK_SECRETis set; accepts unsigned JSON otherwise (dev mode) - Dedup via
ProcessedWebhook(svix_msg_id)so retries are idempotent
Subscription lifecycle:
subscription.{created,updated,active}— writesSetting(org_plan), updates the Clerk org member limit, and runsenforce_camera_capso a plan change (in either direction) flips theCamera.disabled_by_planflags to match the new cap.subscription.pastDue/subscriptionItem.pastDue— writesSetting(payment_past_due="true")and a timestampedpayment_past_due_at. No camera enforcement at this stage; see the grace-period note below.paymentAttempt.updatedwithstatus="paid"— clears both past-due settings and re-runsenforce_camera_cap, so cameras suspended during a grace-expired past-due window light back up.subscriptionItem.{canceled,ended}— reverts tofree_org, resets member limit, and re-runsenforce_camera_cap. Camera rows are preserved (not deleted) so re-subscribe instantly restores streaming.
Organization membership lifecycle (security audit):
organizationMembership.created— emitsmember_addednotification (audience: admin). Severity iswarningfor promotion-to-admin,infofor member-tier additions.organizationMembership.updated— emitsmember_role_changednotification.organizationMembership.deleted— emitsmember_removednotification.- All three notifications wrapped in try/except so a notification fault never causes Clerk webhook backpressure.
Org lifecycle:
organization.deleted— full org wipe (cameras, nodes, groups, MCP keys, all logs, settings).
- Verifies signature with Svix using
RESEND_WEBHOOK_SECRET - Dedup via the same
ProcessedWebhooktable (Svix msg_ids are high-entropy; cross-source collision is astronomical) email.bounced→ insertEmailSuppression(address, reason='bounce', source='resend_webhook')so the worker stops sending to that addressemail.complained→ insertEmailSuppression(address, reason='complaint', source='resend_webhook'). User marked our email as spam — protects sender reputation by removing them from the list immediately.
app/core/plans.py owns plan-cap policy. PLAN_LIMITS is the source of truth for every per-tier number — camera/node/seat caps (abuse rails), monthly viewer-hour cap (the real tier axis), per-channel SSE concurrency cap, and log retention days. Three tiers: free_org, pro, pro_plus. (An earlier business slug was renamed to pro_plus during a Clerk-side reorg; the transitional alias was carried briefly and removed once every known org had rolled over — see ADR docs/adr/0002-viewer-hour-billing.md for the original tier names.)
Two entry points for plan resolution:
resolve_org_plan(db, org_id)— nominal plan (what Clerk says the org pays for). Fast-path readsSetting(org_plan); falls back to a throttledclerk.organizations.get_billing_subscriptioncall for free/missing orgs. Used for the status-bar badge CloudNode shows the operator.effective_plan_for_caps(db, org_id)— plan to use for cap enforcement. Returnsresolve_org_planunless the org has beenpayment_past_duefor more thanPAYMENT_GRACE_DAYS(7), in which case it returns"free_org". Used insideenforce_camera_cap; keeps the two concerns separate so brief card failures don't punish paying users but long-unpaid accounts don't keep getting Pro service.
enforce_camera_cap(db, org_id) — idempotent. Orders the org's cameras by created_at ASC, keeps the oldest N (N = effective plan's max_cameras), flags the rest as disabled_by_plan=True. Oldest-first is deterministic and preserves long-running cameras with history. On upgrade, flags clear in the same call.
app/api/hls.py maintains a per-org monthly viewer-second counter. Each successful GET /segment/{filename} serve calls record_viewer_second(org_id) which increments a thread-safe in-memory dict keyed on (org_id, "YYYY-MM"). The _viewer_usage_flush_loop background task flushes accumulated deltas to OrgMonthlyUsage rows every 60 seconds with one UPSERT per active org, so the hot serve path never touches SQLite.
Before serving each segment, get_hls_segment calls _warm_cached_viewer_seconds(org_id) to get the authoritative running total (cached DB value + pending in-memory delta). If that total is ≥ max_viewer_hours_per_month * 3600, the route returns HTTP 429 with Retry-After: 3600 and an upgrade message. The first request for an org in a given process lifetime amortizes a DB read to warm the cache; thereafter it's all in-memory.
The counter is exposed on GET /api/nodes/plan as usage.viewer_hours_used / usage.viewer_hours_limit so the dashboard can render a live gauge.
Every SSE broadcaster (MotionBroadcaster, NotificationBroadcaster, McpActivityTracker) accepts a per-call cap argument. Route handlers look up get_plan_limits(user.plan)["max_sse_subscribers"] and pass that; the broadcaster refuses subscribe() when the org is at cap and the route turns it into a 429 with the tier-specific cap in the message.
app/mcp/server.py::_RateLimiter tracks two windows per API key hash: a rolling 60-second window (minute cap) and a rolling 24-hour window (daily cap). RATE_LIMITS[plan] supplies both numbers (Pro 30/min × 5000/day, Pro Plus 120/min × 30000/day). The check() method returns a breach reason string so the caller can generate a "you're spamming" vs "you've been looping for hours" error message.
_log_cleanup_loop in main.py runs daily. It collects distinct org_id values from the log tables, resolves each org's plan via resolve_org_plan, and deletes each log type older than that org's log_retention_days. Free gets 30d, Pro 90d, Pro Plus 365d.
Triggers for enforce_camera_cap:
- Webhook: subscription lifecycle events (create/update/cancel/paid).
- Register (
POST /api/nodes/register): safety net for any missed webhook. Idempotent so cost is just one indexed query in the steady state. - Heartbeat (
POST /api/nodes/heartbeat): gated onpayment_past_due=="true". Drives the time-based grace-expiration transition since no webhook fires for that clock tick.
Push-segment gate (POST /api/cameras/{id}/push-segment): when camera.disabled_by_plan is set, returns HTTP 402 with a structured plan_limit_hit body (plan display name, cap, camera name, upgrade copy). CloudNode treats 402 as non-retryable and surfaces the suspension in its TUI.
Heartbeat response also carries disabled_cameras: list[str] scoped to the calling node, so CloudNode can skip the upload task entirely for suspended cameras (no 402 flood) and mark those rows suspended (plan) in its live dashboard.
main.py starts EIGHT long-running tasks on lifespan startup:
| Task | Cadence | What it does |
|---|---|---|
_log_cleanup_loop |
Every LOG_CLEANUP_INTERVAL_HOURS (24h) |
Thin scheduler around run_log_cleanup(db) -> dict (extracted for direct test coverage). Iterates distinct org_id values across all log tables (StreamAccessLog, McpActivityLog, AuditLog, MotionEvent, Notification, EmailLog), resolves each org's plan, and deletes records older than that org's log_retention_days (30 / 90 / 365). Also sweeps terminal-state EmailOutbox rows older than 7 days (cross-org; pending/sending NEVER deleted regardless of age). |
_offline_sweep_loop |
Every OFFLINE_SWEEP_INTERVAL_SECONDS (30s) |
Flips nodes/cameras whose last_seen is older than 90s from status='online' to 'offline' and emits Notification rows + broadcasts SSE events |
_viewer_usage_flush_loop |
Every 60s | Flushes pending in-memory viewer-second counters to the org_monthly_usage table with one UPSERT per active org. Keeps the hot HLS-serve path O(1) in memory. |
_release_cache_refresh_loop |
Every RELEASE_CACHE_REFRESH_INTERVAL_SECONDS (600s) |
Polls GitHub /releases/latest for the CloudNode repo so the heartbeat handler's update_available field stays fresh without blocking on GitHub per request. Cold-boot fallback is LATEST_NODE_VERSION env var. |
_disk_check_loop |
Every DISK_CHECK_INTERVAL_SECONDS (300s = 5min) |
Polls /data disk usage. When ≥95%, fires a single logger.error() with structured extra fields (Sentry-captured). 6h re-emit debounce per process. Operator-side only — does NOT route through customer notifications (multi-tenant violation removed 2026-05-04). |
_motion_digest_loop |
Every MOTION_DIGEST_INTERVAL_SECONDS (60s) |
Drains expired per-camera motion email cooldown anchors (Setting rows keyed motion_email_cooldown_start:{camera_id}). For each expired window: counts MotionEvent rows in the window, emits a motion_digest notification (which itself enqueues a digest email) if extras > 0, deletes the anchor regardless. Volume cap: 2 emails per cycle per camera. |
_sentinel_reaper_loop |
Every SENTINEL_REAPER_INTERVAL_SECONDS (300s = 5min) |
Sweeps SentinelRun rows stuck in running for more than STRANDED_RUN_AGE_MINUTES (20 min) and marks them outcome='error' with a "stranded — agent never finished" note. Catches the rare case where the agent's own wall-clock cleanup wrapper doesn't fire (process crash, network partition); without this the run drawer would show a permanent in-progress spinner. |
email_worker_loop (in app/core/email_worker.py, spawned from main.py lifespan) |
Every EMAIL_WORKER_INTERVAL_SECONDS (5s) |
Drains EmailOutbox status='pending' rows in batches of EMAIL_WORKER_BATCH_SIZE. Per row: check suppression list → call email.send_email() → mark sent/failed/suppressed → write EmailLog row. Reclaims rows stuck in sending for >60s (worker crash). Idempotency-Key on the Resend send protects against duplicate delivery on retry. |
Tenant isolation: every query filters by org_id from the authenticated user/node.
Error handling: Two layers. New endpoints raise app.core.errors.ApiError(status, code, message, **extras) for a structured envelope ({detail: {error, message, ...extras}}); the frontend's services/api.js::parseErrorBody reads e.message for toasts and e.code for branching. Older endpoints still use bare HTTPException(detail="...") and the frontend parser handles both shapes plus the rate-limit handler's top-level {error, message, ...} shape, so call sites never see [object Object]. Pydantic 422s are normalised through the same envelope by a RequestValidationError handler in main.py. ADR docstring on ApiError warns: REST-only — MCP tools at /mcp use ToolError (the JSON-RPC error envelope is fixed by the protocol).
Database sessions: get_db() dependency yields a SQLAlchemy session per request.
In-memory segment cache: live .ts segments live in _segment_cache (a dict[camera_id, dict[filename, (bytes, ts)]]) inside hls.py. Backend never touches S3 for live video. Recordings and snapshots live on the CloudNode. Incident snapshots + clips are stored inline on IncidentEvidence.data (LargeBinary).
Codec detection: CloudNode reports codec via POST /api/cameras/{id}/codec after the first segment. Stored on the Camera row and injected into HLS playlists as #EXT-X-CODECS.
Notification broadcaster: notification_broadcaster (in notifications.py) is a per-process pub/sub — SSE subscribers register per org + admin flag; emit_camera_transition, emit_node_transition, and motion event handlers write a Notification row then broadcast.
Motion broadcaster: the motion SSE stream pushes events that arrive on POST /api/cameras/{id}/motion. Motion delivery is HTTP-only since CloudNode v0.1.61; the pre-v0.1.61 WebSocket variant had no producer on the node side and was removed.
Shared Clerk token: frontend's useSharedToken serialises the Clerk JWT for HLS.js's xhrSetup so segment fetches ride on the same auth as API calls.
First-heartbeat UX: when the admin creates a node, the dashboard stashes the new node_id in localStorage and HeartbeatBanner starts polling GET /api/nodes/{node_id} every few seconds. As soon as last_seen is non-null the banner auto-dismisses. Users don't have to refresh — it's a reassurance loop for the 30–60s window where the node is downloading ffmpeg / registering cameras.
Role-split welcome hero: WelcomeHero.jsx exports two components — AdminWelcomeHero shows the "Install a CloudNode → Camera goes live" checklist with CTAs into Settings + the install guide; MemberWelcomeHero shows a capability-focused welcome (live monitoring, motion alerts, team workspace) because members can't act on a setup checklist. DashboardPage picks the right one based on is_admin.
backend/scripts/mcp-setup.sh + mcp-setup.ps1 are served verbatim from install.py. They:
- Accept
<api_key> <server_url>(positional) - Detect installed MCP clients (Claude Code, Claude Desktop, Cursor, Windsurf)
- Prompt the user for which ones to configure
- Merge an
opensentryentry into each client's JSON config (creating directories + backing up corrupted files)
Windows invocation pattern — irm … | iex -Args … does not work (iex has no -Args). Use the scriptblock pattern instead, which is what the dashboard prints:
& ([scriptblock]::Create((irm <url>/mcp-setup.ps1))) '<api_key>' '<server_url>'Bash invocation — when run via curl … | bash -s --, stdin is the piped script, so read would hit EOF immediately. The script falls back to </dev/tty when stdin isn't a TTY.
fastapi/uvicorn— Web framework and ASGI serverfastmcp— Model Context Protocol server (streamable HTTP)sqlalchemy— ORM (SQLite both dev + prod; production runs on a Fly volume)pydantic— Request/response validationclerk-backend-api— Clerk authenticationpyjwt— JWT token handling (V2 permission decoding + signed unsubscribe tokens)jinja2— Email template rendering (backend/app/templates/emails/)resend— Resend SDK for transactional emailslowapi— Rate limiting (Redis-backed viaREDIS_URLin production; in-memory fallback)httpx— HTTP clientsvix— Webhook signature verification (Clerk + Resend share the library)sentry-sdk— Error tracking (no-op whenSENTRY_DSNis unset)python-dotenv— Environment variable loading
- Database tables auto-created on startup via
Base.metadata.create_all() - Backend serves the React build as static files in production (SPA middleware in
main.py) - Frontend uses HLS.js for video playback with a Clerk JWT injected via
xhrSetup VITE_LOCAL_HLS=truebypasses the backend and streams directly from CloudNode on localhost:8080 (for local dev only)- Tests live in
backend/tests/and run withuv run pytest; scope middleware has dedicated coverage (test_mcp_keys.py)