Skip to content

Commit af5ba99

Browse files
Sbussisoclaude
andcommitted
Enforce plan limits across entire app and fix cold starts
Backend: - Gate audit endpoints behind "admin" feature (Pro/Business only) - Gate danger zone (wipe-logs, full-reset) behind "admin" feature - Enforce camera cap during node registration (skip new cameras over limit) - Persist org plan in DB via webhooks for API-key-auth lookups - Add get_plan_limits_for_org() helper for non-JWT endpoints Frontend: - Add reusable UpgradeModal with plan comparison table - Admin page shows upgrade prompt for free tier users - Settings: Add Node button shows UpgradeModal when at node limit - Settings: Danger zone locked with upgrade prompt for free tier - Dashboard: Camera limit banners trigger UpgradeModal - Dashboard: Warning banner at 80% camera capacity Infrastructure: - Disable auto_stop_machines (was "stop", now "off") to eliminate cold start delays for this always-on security camera app Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 9083d84 commit af5ba99

11 files changed

Lines changed: 484 additions & 15 deletions

File tree

backend/app/api/audit.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from datetime import datetime, timedelta
22
from typing import Optional
3-
from fastapi import APIRouter, Depends, Query
3+
from fastapi import APIRouter, Depends, HTTPException, Query
44
from sqlalchemy.orm import Session
55

66
from app.core.database import get_db
@@ -10,6 +10,15 @@
1010
router = APIRouter(prefix="/api", tags=["audit"])
1111

1212

13+
def _require_admin_feature(user: AuthUser):
14+
"""Raise 403 if the org's plan doesn't include the admin feature."""
15+
if "admin" not in user.features:
16+
raise HTTPException(
17+
status_code=403,
18+
detail="Audit dashboard requires a Pro or Business plan. Upgrade at /pricing.",
19+
)
20+
21+
1322
@router.get("/audit/stream-logs")
1423
async def get_stream_logs(
1524
camera_id: Optional[str] = None,
@@ -24,6 +33,7 @@ async def get_stream_logs(
2433
Only org admins can access this endpoint.
2534
Logs are automatically cleaned up after the retention period.
2635
"""
36+
_require_admin_feature(admin)
2737
query = db.query(StreamAccessLog).filter(StreamAccessLog.org_id == admin.org_id)
2838

2939
if camera_id:
@@ -62,6 +72,7 @@ async def get_stream_stats(
6272
Get stream access statistics for the admin's organization.
6373
Returns counts by camera, user, and day.
6474
"""
75+
_require_admin_feature(admin)
6576
from sqlalchemy import func
6677

6778
since = datetime.utcnow() - timedelta(days=days)

backend/app/api/cameras.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -432,6 +432,11 @@ async def wipe_stream_logs(
432432
db: Session = Depends(get_db),
433433
):
434434
"""Permanently delete all stream access logs for this organization."""
435+
if "admin" not in user.features:
436+
raise HTTPException(
437+
status_code=403,
438+
detail="Danger zone requires a Pro or Business plan.",
439+
)
435440
from app.models import StreamAccessLog
436441

437442
count = db.query(StreamAccessLog).filter_by(org_id=user.org_id).delete()
@@ -449,6 +454,11 @@ async def full_reset(
449454
Full organization reset: wipe all nodes (with CloudNode notification),
450455
delete Tigris storage, clear stream logs, clear settings.
451456
"""
457+
if "admin" not in user.features:
458+
raise HTTPException(
459+
status_code=403,
460+
detail="Danger zone requires a Pro or Business plan.",
461+
)
452462
from app.models import StreamAccessLog, CameraNode
453463
from app.services.storage import get_storage
454464

backend/app/api/nodes.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from app.core.database import get_db
99
from app.core.auth import AuthUser, require_admin, get_current_user
1010
from app.core.limiter import limiter
11-
from app.core.plans import get_plan_limits, get_plan_display_name
11+
from app.core.plans import get_plan_limits, get_plan_limits_for_org, get_plan_display_name
1212
from app.models.models import CameraNode, Camera
1313
from app.schemas.schemas import NodeRegister, NodeHeartbeat, CameraReport, NodeCreate
1414
from app.services.storage import get_storage
@@ -92,8 +92,14 @@ async def register_node(
9292
existing_node.audio_codec = data.audio_codec
9393
existing_node.codec_detected_at = datetime.utcnow()
9494

95+
# Enforce camera cap: count existing org cameras vs plan limit
96+
org_id = existing_node.org_id
97+
limits = get_plan_limits_for_org(db, org_id)
98+
current_cameras = db.query(Camera).filter_by(org_id=org_id).count()
99+
95100
# Map device_path to camera_id for response
96101
camera_mapping = {}
102+
new_camera_count = 0
97103

98104
for cam_data in data.cameras or []:
99105
# Generate camera_id from node_id and device_path
@@ -122,6 +128,15 @@ async def register_node(
122128
existing_cam.video_codec = data.video_codec
123129
existing_cam.audio_codec = data.audio_codec
124130
else:
131+
# Check camera cap before creating
132+
if current_cameras + new_camera_count >= limits["max_cameras"]:
133+
plan_name = get_plan_display_name(limits.get("_plan", "free_org"))
134+
logger.warning(
135+
"Camera limit reached for org %s (%d/%d on %s plan), skipping camera %s",
136+
org_id, current_cameras + new_camera_count, limits["max_cameras"], plan_name, camera_id,
137+
)
138+
continue
139+
125140
logger.debug("Creating new camera %s", camera_id)
126141
new_cam = Camera(
127142
camera_id=camera_id,
@@ -139,6 +154,7 @@ async def register_node(
139154
codec_detected_at=datetime.utcnow() if data.video_codec else None,
140155
)
141156
db.add(new_cam)
157+
new_camera_count += 1
142158

143159
# Remove stale camera records that are no longer reported by this node.
144160
# This handles cases where old camera_ids (e.g. with spaces) linger after

backend/app/api/webhooks.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
import logging
2-
from fastapi import APIRouter, Request, HTTPException, status
2+
from fastapi import APIRouter, Request, HTTPException, status, Depends
3+
from sqlalchemy.orm import Session
34
from svix.webhooks import Webhook, WebhookVerificationError
45
from app.core.config import settings
56
from app.core.clerk import clerk
7+
from app.core.database import get_db
8+
from app.models.models import Setting
69

710
logger = logging.getLogger(__name__)
811

@@ -35,7 +38,7 @@ def get_active_plan_slug(items: list) -> str:
3538

3639

3740
@router.post("/clerk")
38-
async def clerk_webhook(request: Request):
41+
async def clerk_webhook(request: Request, db: Session = Depends(get_db)):
3942
payload = await request.body()
4043
headers = dict(request.headers)
4144

@@ -61,6 +64,8 @@ async def clerk_webhook(request: Request):
6164
plan_slug = get_active_plan_slug(data.get("items", []))
6265
limit = PLAN_MEMBER_LIMITS.get(plan_slug, PLAN_MEMBER_LIMITS["free_org"])
6366
set_org_member_limit(org_id, limit)
67+
# Persist plan in DB so API-key-authenticated endpoints can look it up
68+
Setting.set(db, org_id, "org_plan", plan_slug)
6469
logger.info("Org %s subscription active on plan '%s'", org_id, plan_slug)
6570

6671
# ── Payment failure ─────────────────────────────────────────────
@@ -78,6 +83,7 @@ async def clerk_webhook(request: Request):
7883
# Clerk auto-assigns the free default plan on cancellation,
7984
# so the JWT pla claim will revert. Just reset member limit.
8085
set_org_member_limit(org_id, PLAN_MEMBER_LIMITS["free_org"])
86+
Setting.set(db, org_id, "org_plan", "free_org")
8187
logger.info("Org %s subscription canceled — reverted to free limits", org_id)
8288

8389
# ── Free trial ending soon ──────────────────────────────────────

backend/app/core/plans.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,21 @@ def get_plan_limits(plan: str) -> dict:
2828
return PLAN_LIMITS.get(plan, PLAN_LIMITS["free_org"])
2929

3030

31+
def get_plan_limits_for_org(db, org_id: str) -> dict:
32+
"""Look up an org's plan from the database and return its limits.
33+
34+
Used by endpoints that authenticate via API key (e.g. node registration)
35+
where JWT claims are not available. The plan is stored as a Setting
36+
by the Clerk webhook handler whenever a subscription changes.
37+
"""
38+
from app.models.models import Setting
39+
40+
plan = Setting.get(db, org_id, "org_plan", "free_org")
41+
limits = get_plan_limits(plan)
42+
# Attach the plan slug so callers can show a display name
43+
return {**limits, "_plan": plan}
44+
45+
3146
def get_plan_display_name(plan: str) -> str:
3247
"""Human-readable plan name."""
3348
names = {

fly.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ primary_region = "sjc"
2121
[http_service]
2222
internal_port = 8000
2323
force_https = true
24-
auto_stop_machines = "stop"
24+
auto_stop_machines = "off"
2525
auto_start_machines = true
2626
min_machines_running = 1
2727
processes = ["app"]
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import { Link } from "react-router-dom"
2+
3+
const UPGRADE_MESSAGES = {
4+
nodes: {
5+
title: "Node Limit Reached",
6+
icon: "🖥️",
7+
description: "You've hit the maximum number of camera nodes on your current plan.",
8+
benefit: "Upgrade to connect more nodes and expand your security coverage.",
9+
},
10+
cameras: {
11+
title: "Camera Limit Reached",
12+
icon: "📹",
13+
description: "You've reached the maximum number of cameras for your plan.",
14+
benefit: "Upgrade to monitor more cameras across all your locations.",
15+
},
16+
admin: {
17+
title: "Pro Feature",
18+
icon: "📊",
19+
description: "The Admin Dashboard with stream access logs and analytics is a Pro feature.",
20+
benefit: "Upgrade to get full visibility into who's accessing your streams.",
21+
},
22+
"danger-zone": {
23+
title: "Pro Feature",
24+
icon: "⚙️",
25+
description: "Advanced management tools like log wiping and full resets are Pro features.",
26+
benefit: "Upgrade for complete control over your organization data.",
27+
},
28+
}
29+
30+
const PLAN_COMPARISON = [
31+
{ label: "Cameras", free: "2", pro: "10", business: "50" },
32+
{ label: "Nodes", free: "1", pro: "5", business: "Unlimited" },
33+
{ label: "Admin Dashboard", free: false, pro: true, business: true },
34+
{ label: "Stream Analytics", free: false, pro: true, business: true },
35+
{ label: "Danger Zone Tools", free: false, pro: true, business: true },
36+
{ label: "Priority Support", free: false, pro: false, business: true },
37+
]
38+
39+
function UpgradeModal({ isOpen, onClose, feature, currentPlan }) {
40+
if (!isOpen) return null
41+
42+
const msg = UPGRADE_MESSAGES[feature] || UPGRADE_MESSAGES.nodes
43+
const planName = currentPlan === "free_org" ? "Free" : currentPlan === "pro" ? "Pro" : "Business"
44+
45+
return (
46+
<div className="modal-overlay" onClick={onClose}>
47+
<div className="modal-content upgrade-modal" onClick={(e) => e.stopPropagation()}>
48+
<div className="modal-header">
49+
<h2>{msg.title}</h2>
50+
<button className="modal-close" onClick={onClose}>&times;</button>
51+
</div>
52+
53+
<div className="modal-body">
54+
<div className="upgrade-modal-hero">
55+
<div className="upgrade-modal-icon">{msg.icon}</div>
56+
<p className="upgrade-modal-desc">{msg.description}</p>
57+
<p className="upgrade-modal-benefit">{msg.benefit}</p>
58+
<div className="upgrade-modal-current">
59+
Currently on the <strong>{planName}</strong> plan
60+
</div>
61+
</div>
62+
63+
<div className="upgrade-comparison">
64+
<table className="comparison-table">
65+
<thead>
66+
<tr>
67+
<th></th>
68+
<th className={currentPlan === "free_org" ? "current-col" : ""}>Free</th>
69+
<th className={currentPlan === "pro" ? "current-col" : "highlight-col"}>Pro</th>
70+
<th className={currentPlan === "business" ? "current-col" : ""}>Business</th>
71+
</tr>
72+
</thead>
73+
<tbody>
74+
{PLAN_COMPARISON.map((row) => (
75+
<tr key={row.label}>
76+
<td className="comparison-label">{row.label}</td>
77+
<td className={currentPlan === "free_org" ? "current-col" : ""}>
78+
{typeof row.free === "boolean" ? (row.free ? "✓" : "—") : row.free}
79+
</td>
80+
<td className={currentPlan === "pro" ? "current-col" : "highlight-col"}>
81+
{typeof row.pro === "boolean" ? (row.pro ? "✓" : "—") : row.pro}
82+
</td>
83+
<td className={currentPlan === "business" ? "current-col" : ""}>
84+
{typeof row.business === "boolean" ? (row.business ? "✓" : "—") : row.business}
85+
</td>
86+
</tr>
87+
))}
88+
</tbody>
89+
</table>
90+
</div>
91+
92+
<div className="modal-actions">
93+
<button className="btn btn-secondary" onClick={onClose}>
94+
Maybe Later
95+
</button>
96+
<Link to="/pricing" className="btn btn-primary" onClick={onClose}>
97+
View Plans
98+
</Link>
99+
</div>
100+
</div>
101+
</div>
102+
</div>
103+
)
104+
}
105+
106+
export default UpgradeModal

0 commit comments

Comments
 (0)