Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 33 additions & 6 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,7 @@ All 13 models in `backend/app/models/models.py`. Every model has `org_id` for te

| 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 |
| `Camera` | `camera_id`, `node_id` (FK), `name`, `status`, `last_error`, `video_codec`, `audio_codec`, `group_id`, `last_seen` | Camera registered by a CloudNode. `status` carries the real pipeline state reported by the node supervisor — `starting` / `streaming` / `restarting` / `failed` / `error` / `offline` — and `last_error` holds the human-readable failure reason while the pipeline is broken (cleared on recovery). `effective_status` flips to offline after a 90s heartbeat gap; `to_dict()` only surfaces `last_error` while the camera is in `restarting` / `failed` / `error` |
| `CameraNode` | `node_id`, `api_key_hash`, `hostname`, `status`, `video_codec`, `audio_codec`, `last_seen`, `key_rotated_at` | Physical CloudNode device |
| `CameraGroup` | `name`, `color`, `icon` | User-defined camera grouping |
| `Setting` | `key`, `value` | Per-org key/value settings |
Expand Down Expand Up @@ -397,10 +397,36 @@ HLS `GET` paths (`stream.m3u8`, `segment/{file}`) are intentionally unlimited

## Webhook Handling

`POST /api/webhooks/clerk` handles Clerk subscription events:
- Verifies signature with Svix when `CLERK_WEBHOOK_SECRET` is set; accepts unsigned JSON otherwise (dev mode)
- On `subscription.created` / `updated` with a paid plan → sets org member limit appropriately
- On `subscription.deleted` / `cancelled` → resets to free tier
`POST /api/webhooks/clerk` handles Clerk subscription + organization lifecycle events. **`CLERK_WEBHOOK_SECRET` must be set** — an unsigned request is rejected with `400 "Webhook processing unavailable"`; every delivery is verified with Svix.

Plan slugs (must match Clerk Dashboard keys) drive Clerk organization member limits via `PLAN_MEMBER_LIMITS`:

| Slug | Member limit |
|------|--------------|
| `free_org` | 2 |
| `pro` | 10 |
| `business` | 20 |

The plan slug is also persisted to `Setting(org_plan)` so API-key-authenticated endpoints (node register, MCP gate) can read it without a JWT — `app/core/plans.py` also does a throttled live Clerk lookup (one call per org per 60s) when the cache has no recognised paid plan, so freshly-upgraded orgs don't have to wait for a webhook replay.

Handled events (`event.type`):

| Event | What it does |
|-------|--------------|
| `subscription.created` / `subscription.updated` / `subscription.active` | Resolve active item's plan slug → set Clerk member limit → `Setting.set(org_plan)` |
| `subscription.pastDue` / `subscriptionItem.pastDue` | Mark `Setting(payment_past_due=true)` + `payment_past_due_at`; keep plan access during dunning grace period |
| `paymentAttempt.updated` | On `status=paid` clear `payment_past_due`; log failed attempts |
| `subscriptionItem.canceled` / `subscriptionItem.ended` | Reset member limit to free tier, `org_plan=free_org`, clear past-due |
| `subscriptionItem.freeTrialEnding` | Log that the org's free trial ends in 3 days |
| `organization.deleted` | Flush in-memory segment caches for every camera in the org, then cascade-delete all `CameraNode` / `CameraGroup` / `McpApiKey` / `McpActivityLog` / `StreamAccessLog` / `AuditLog` / `Setting` rows for that org |

Plan → dashboard quotas (nodes / cameras) are enforced by `app/core/plans.py::PLAN_LIMITS`, not by the webhook handler:

| Slug | max_nodes | max_cameras |
|------|-----------|-------------|
| `free_org` | 1 | 2 |
| `pro` | 5 | 10 |
| `business` | 999 (effectively unlimited) | 50 |

## Background Loops

Expand Down Expand Up @@ -435,7 +461,8 @@ HLS `GET` paths (`stream.m3u8`, `segment/{file}`) are intentionally unlimited
1. Accept `<api_key> <server_url>` (positional)
2. Detect installed MCP clients (Claude Code, Claude Desktop, Cursor, Windsurf)
3. Prompt the user for which ones to configure
4. Merge an `opensentry` entry into each client's JSON config (creating directories + backing up corrupted files)
4. **Refuse to touch the config of any client that is currently running** (`pgrep` on Linux/macOS, `Get-Process` on Windows) to avoid corrupting an open JSON file or getting the entry silently overwritten. Running clients are listed under "Skipped (still running)" in the final summary so the user can close them and re-run.
5. Merge an `opensentry` entry into each remaining client's JSON config, creating directories as needed and backing up corrupted files. Config files are written as UTF-8 **without a BOM** (some clients refuse to parse a BOM-prefixed JSON config).

**Windows invocation pattern** — `irm … | iex -Args …` **does not work** (`iex` has no `-Args`). Use the scriptblock pattern instead, which is what the dashboard prints:

Expand Down
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ curl -fsSL https://opensentry-command.fly.dev/mcp-setup.sh | bash -s -- <api_key
& ([scriptblock]::Create((irm https://opensentry-command.fly.dev/mcp-setup.ps1))) '<api_key>' '<mcp_url>'
```

The scripts detect which clients you already have and merge an `opensentry` entry into each one's MCP config.
The scripts detect which clients you already have and merge an `opensentry` entry into each one's MCP config. Any client that is **currently running** is skipped (to avoid overwriting a config the client has open); close it and re-run to finish the install.

---

Expand All @@ -103,6 +103,8 @@ The scripts detect which clients you already have and merge an `opensentry` entr

**Authentication:** Clerk handles user sign-up, login, and organization management. The backend validates JWT tokens (V1 and V2 permission formats) and extracts organization-scoped permissions. CloudNodes authenticate with API keys (SHA-256 hashed in the database) passed via `X-Node-API-Key`. MCP clients authenticate with `Authorization: Bearer osc_...` keys (also hashed).

**Pipeline state:** CloudNodes report real FFmpeg pipeline state on every heartbeat, not a hardcoded "online". Each camera's `status` can be `starting`, `streaming`, `restarting`, `failed`, `error`, or `offline`; when it isn't healthy the node also sends a human-readable `last_error` that the dashboard surfaces so users can tell *why* a camera isn't live (disk full, V4L2 disconnect, FFmpeg crash-looping, etc.). `last_error` is cleared automatically as soon as the pipeline recovers.

**Storage:** Live segments live in the backend's in-memory cache; they expire automatically once `SEGMENT_CACHE_MAX_PER_CAMERA` is exceeded. Recordings and snapshots live on the CloudNode itself. SQLite is used for development (`opensentry.db`); PostgreSQL for production. Incident snapshots and clips are stored inline on `IncidentEvidence.data` (LargeBinary) — evidence travels with the incident.

**Real-time:** CloudNodes maintain a WebSocket channel (`/ws/node`) used for commands, status, and motion events. The dashboard subscribes to SSE feeds for motion events (`/api/motion/events/stream`), notifications (`/api/notifications/stream`), and MCP activity (`/api/mcp/activity/stream`).
Expand Down Expand Up @@ -264,7 +266,7 @@ See [AGENTS.md](AGENTS.md) for the full per-tool list.
| Method | Endpoint | Auth | Description |
|--------|----------|------|-------------|
| GET | `/api/health` | None | Health check |
| POST | `/api/webhooks/clerk` | Webhook | Clerk subscription events (Svix-signed) |
| POST | `/api/webhooks/clerk` | Webhook | Clerk subscription + org-lifecycle events — requires `CLERK_WEBHOOK_SECRET` (Svix-verified; unsigned requests are rejected) |
| WS | `/ws/node` | Node (query) | CloudNode real-time channel (heartbeat, commands, motion) |

**Auth types:** `User` = Clerk JWT, `Admin` = Clerk JWT with admin permission, `Node` = `X-Node-API-Key` header, `MCP Key` = `Authorization: Bearer osc_...`.
Expand All @@ -291,7 +293,7 @@ All 13 ORM models live in `backend/app/models/models.py`; every row is scoped by

| Model | Purpose |
|-------|---------|
| `Camera` | Camera device registered by a CloudNode; tracks codec, status, group |
| `Camera` | Camera device registered by a CloudNode; tracks codec, pipeline `status` (`starting` / `streaming` / `restarting` / `failed` / `error` / `offline`), `last_error`, group |
| `CameraNode` | Physical CloudNode device; holds `api_key_hash` + codec info |
| `CameraGroup` | User-defined grouping (name, color, icon) |
| `Setting` | Per-org key/value store (e.g. recording config) |
Expand Down
Loading