Skip to content

Commit 4b3ef2f

Browse files
Sbussisoclaude
andcommitted
Production readiness: remove fake features, add tests/CI, security headers, legal pages, payment handling
- Remove fake detection features (motion/face/object recording settings, notification settings) - Add test infrastructure with 17 tests (health, nodes, cameras, MCP keys) and CI pipeline - Fix Swagger/Redoc access by adding /docs and /redoc to SPA middleware passthrough - Add security headers (X-Content-Type-Options, X-Frame-Options, HSTS, Referrer-Policy, Permissions-Policy) - Add Terms of Service and Privacy Policy pages with footer links - Handle payment failure webhooks with in-app past-due banner - Fix StaticPool/NullPool selection for in-memory vs file-based SQLite - Clean up ~200 lines of dead CSS from removed features Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 11a4754 commit 4b3ef2f

21 files changed

Lines changed: 909 additions & 477 deletions

File tree

.github/workflows/deploy.yml

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,35 @@
1-
name: Deploy
1+
name: Test & Deploy
22

33
on:
44
push:
55
branches:
66
- master
77

88
jobs:
9+
test:
10+
name: Run Tests
11+
runs-on: ubuntu-latest
12+
steps:
13+
- uses: actions/checkout@v4
14+
15+
- name: Install uv
16+
uses: astral-sh/setup-uv@v4
17+
18+
- name: Set up Python
19+
run: uv python install 3.12
20+
21+
- name: Install dependencies
22+
working-directory: backend
23+
run: uv sync --extra dev
24+
25+
- name: Run tests
26+
working-directory: backend
27+
run: uv run pytest -v
28+
929
deploy:
1030
name: Deploy to Fly.io
1131
runs-on: ubuntu-latest
32+
needs: test
1233
concurrency:
1334
group: deploy
1435
cancel-in-progress: true

backend/app/api/cameras.py

Lines changed: 0 additions & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@
99
from app.schemas.schemas import (
1010
CameraGroupCreate,
1111
RecordingSettings,
12-
NotificationSettings,
1312
)
1413

1514
router = APIRouter(prefix="/api", tags=["api"])
@@ -211,36 +210,7 @@ async def get_all_settings(
211210
):
212211
"""Get all settings for the user's organization."""
213212
return {
214-
"notifications": {
215-
"motion_notifications": Setting.get(
216-
db, user.org_id, "motion_notifications", "true"
217-
)
218-
== "true",
219-
"face_notifications": Setting.get(
220-
db, user.org_id, "face_notifications", "true"
221-
)
222-
== "true",
223-
"object_notifications": Setting.get(
224-
db, user.org_id, "object_notifications", "true"
225-
)
226-
== "true",
227-
"toast_notifications": Setting.get(
228-
db, user.org_id, "toast_notifications", "true"
229-
)
230-
== "true",
231-
},
232213
"recording": {
233-
"motion_recording": Setting.get(
234-
db, user.org_id, "motion_recording", "false"
235-
)
236-
== "true",
237-
"face_recording": Setting.get(db, user.org_id, "face_recording", "false")
238-
== "true",
239-
"object_recording": Setting.get(
240-
db, user.org_id, "object_recording", "false"
241-
)
242-
== "true",
243-
"post_buffer": int(Setting.get(db, user.org_id, "post_buffer", "5")),
244214
"scheduled_recording": Setting.get(
245215
db, user.org_id, "scheduled_recording", "false"
246216
)
@@ -253,64 +223,12 @@ async def get_all_settings(
253223
}
254224

255225

256-
@router.get("/settings/notifications")
257-
async def get_notification_settings(
258-
user: AuthUser = Depends(require_view), db: Session = Depends(get_db)
259-
):
260-
"""Get notification settings."""
261-
return {
262-
"motion_notifications": Setting.get(
263-
db, user.org_id, "motion_notifications", "true"
264-
)
265-
== "true",
266-
"face_notifications": Setting.get(db, user.org_id, "face_notifications", "true")
267-
== "true",
268-
"object_notifications": Setting.get(
269-
db, user.org_id, "object_notifications", "true"
270-
)
271-
== "true",
272-
"toast_notifications": Setting.get(
273-
db, user.org_id, "toast_notifications", "true"
274-
)
275-
== "true",
276-
}
277-
278-
279-
@router.post("/settings/notifications")
280-
async def update_notification_settings(
281-
data: NotificationSettings,
282-
user: AuthUser = Depends(require_admin),
283-
db: Session = Depends(get_db),
284-
):
285-
"""Update notification settings. Requires admin."""
286-
Setting.set(
287-
db, user.org_id, "motion_notifications", str(data.motion_notifications).lower()
288-
)
289-
Setting.set(
290-
db, user.org_id, "face_notifications", str(data.face_notifications).lower()
291-
)
292-
Setting.set(
293-
db, user.org_id, "object_notifications", str(data.object_notifications).lower()
294-
)
295-
Setting.set(
296-
db, user.org_id, "toast_notifications", str(data.toast_notifications).lower()
297-
)
298-
return {"success": True}
299-
300-
301226
@router.get("/settings/recording")
302227
async def get_recording_settings(
303228
user: AuthUser = Depends(require_view), db: Session = Depends(get_db)
304229
):
305230
"""Get recording settings."""
306231
return {
307-
"motion_recording": Setting.get(db, user.org_id, "motion_recording", "false")
308-
== "true",
309-
"face_recording": Setting.get(db, user.org_id, "face_recording", "false")
310-
== "true",
311-
"object_recording": Setting.get(db, user.org_id, "object_recording", "false")
312-
== "true",
313-
"post_buffer": int(Setting.get(db, user.org_id, "post_buffer", "5")),
314232
"scheduled_recording": Setting.get(
315233
db, user.org_id, "scheduled_recording", "false"
316234
)
@@ -329,10 +247,6 @@ async def update_recording_settings(
329247
db: Session = Depends(get_db),
330248
):
331249
"""Update recording settings. Requires admin."""
332-
Setting.set(db, user.org_id, "motion_recording", str(data.motion_recording).lower())
333-
Setting.set(db, user.org_id, "face_recording", str(data.face_recording).lower())
334-
Setting.set(db, user.org_id, "object_recording", str(data.object_recording).lower())
335-
Setting.set(db, user.org_id, "post_buffer", str(data.post_buffer))
336250
Setting.set(
337251
db, user.org_id, "scheduled_recording", str(data.scheduled_recording).lower()
338252
)

backend/app/api/nodes.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,9 +274,12 @@ async def get_plan_info(
274274
db: Session = Depends(get_db),
275275
):
276276
"""Return the org's current plan, usage, and limits."""
277+
from app.models.models import Setting
278+
277279
limits = get_plan_limits(user.plan)
278280
current_nodes = db.query(CameraNode).filter_by(org_id=user.org_id).count()
279281
current_cameras = db.query(Camera).filter_by(org_id=user.org_id).count()
282+
payment_past_due = Setting.get(db, user.org_id, "payment_past_due", "false") == "true"
280283
return {
281284
"plan": user.plan,
282285
"plan_name": get_plan_display_name(user.plan),
@@ -286,6 +289,7 @@ async def get_plan_info(
286289
"nodes": current_nodes,
287290
"cameras": current_cameras,
288291
},
292+
"payment_past_due": payment_past_due,
289293
}
290294

291295

backend/app/api/webhooks.py

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import logging
2+
from datetime import datetime, timezone
23
from fastapi import APIRouter, Request, HTTPException, status, Depends
34
from sqlalchemy.orm import Session
45
from svix.webhooks import Webhook, WebhookVerificationError
@@ -69,12 +70,27 @@ async def clerk_webhook(request: Request, db: Session = Depends(get_db)):
6970
logger.info("Org %s subscription active on plan '%s'", org_id, plan_slug)
7071

7172
# ── Payment failure ─────────────────────────────────────────────
72-
elif event_type == "subscription.pastDue":
73+
elif event_type in ("subscription.pastDue", "subscriptionItem.pastDue"):
7374
org_id = data.get("payer", {}).get("organization_id")
7475
if org_id:
76+
# Record past-due timestamp for grace period tracking.
77+
# Clerk will retry payment via Stripe dunning. We keep current
78+
# plan access during the grace period but flag the org.
79+
Setting.set(db, org_id, "payment_past_due", "true")
80+
past_due_at = data.get("pastDueAt") or datetime.now(tz=timezone.utc).isoformat()
81+
Setting.set(db, org_id, "payment_past_due_at", str(past_due_at))
7582
logger.warning("Org %s subscription is past due — payment failed", org_id)
76-
# Keep current limits for a grace period; Clerk will retry payment.
77-
# If you want to restrict access, downgrade here.
83+
84+
# ── Payment attempt result ──────────────────────────────────────
85+
elif event_type == "paymentAttempt.updated":
86+
org_id = data.get("payer", {}).get("organization_id")
87+
payment_status = data.get("status")
88+
if org_id and payment_status == "paid":
89+
# Payment succeeded — clear past-due flag
90+
Setting.set(db, org_id, "payment_past_due", "false")
91+
logger.info("Org %s payment succeeded — past-due cleared", org_id)
92+
elif org_id and payment_status == "failed":
93+
logger.warning("Org %s payment attempt failed", org_id)
7894

7995
# ── Cancellation / end ──────────────────────────────────────────
8096
elif event_type in ("subscriptionItem.canceled", "subscriptionItem.ended"):
@@ -84,13 +100,13 @@ async def clerk_webhook(request: Request, db: Session = Depends(get_db)):
84100
# so the JWT pla claim will revert. Just reset member limit.
85101
set_org_member_limit(org_id, PLAN_MEMBER_LIMITS["free_org"])
86102
Setting.set(db, org_id, "org_plan", "free_org")
103+
Setting.set(db, org_id, "payment_past_due", "false")
87104
logger.info("Org %s subscription canceled — reverted to free limits", org_id)
88105

89106
# ── Free trial ending soon ──────────────────────────────────────
90107
elif event_type == "subscriptionItem.freeTrialEnding":
91108
org_id = data.get("payer", {}).get("organization_id")
92109
if org_id:
93110
logger.info("Org %s free trial ending in 3 days", org_id)
94-
# Future: send notification email
95111

96112
return {"received": True}

backend/app/core/database.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,20 @@
11
from sqlalchemy import create_engine, event
2-
from sqlalchemy.pool import NullPool
2+
from sqlalchemy.pool import NullPool, StaticPool
33
from sqlalchemy.orm import sessionmaker, declarative_base
44
from app.core.config import settings
55

66
# NullPool: each request gets a fresh connection and releases it immediately.
77
# This is the recommended approach for SQLite in async/high-concurrency apps —
88
# avoids pool exhaustion when HLS segment uploads create bursts of 15+ concurrent requests.
9+
#
10+
# Exception: in-memory SQLite requires StaticPool (shared single connection)
11+
# so that all operations see the same database.
12+
_is_memory_db = settings.DATABASE_URL == "sqlite:///:memory:" or ":memory:" in settings.DATABASE_URL
13+
914
engine = create_engine(
1015
settings.DATABASE_URL,
11-
connect_args={"check_same_thread": False, "timeout": 30},
12-
poolclass=NullPool,
16+
connect_args={"check_same_thread": False, **({} if _is_memory_db else {"timeout": 30})},
17+
poolclass=StaticPool if _is_memory_db else NullPool,
1318
)
1419

1520

backend/app/main.py

Lines changed: 26 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,15 @@
2121
Base.metadata.create_all(bind=engine)
2222

2323
# Migrate existing tables: add columns that create_all won't add to existing tables.
24-
from sqlalchemy import inspect, text
25-
with engine.connect() as conn:
26-
columns = [c["name"] for c in inspect(engine).get_columns("stream_access_logs")]
27-
if "user_email" not in columns:
28-
conn.execute(text("ALTER TABLE stream_access_logs ADD COLUMN user_email VARCHAR(255) DEFAULT ''"))
29-
conn.commit()
24+
from sqlalchemy import inspect as sa_inspect, text
25+
try:
26+
with engine.connect() as conn:
27+
columns = [c["name"] for c in sa_inspect(engine).get_columns("stream_access_logs")]
28+
if "user_email" not in columns:
29+
conn.execute(text("ALTER TABLE stream_access_logs ADD COLUMN user_email VARCHAR(255) DEFAULT ''"))
30+
conn.commit()
31+
except Exception:
32+
pass # Table doesn't exist yet (fresh DB) — create_all handles it
3033

3134
# Build the MCP ASGI app — path="/" because the mount prefix handles /mcp
3235
mcp_app = mcp.http_app(path="/", stateless_http=True, json_response=True)
@@ -59,10 +62,23 @@
5962
CORSMiddleware,
6063
allow_origins=cors_origins,
6164
allow_credentials=True,
62-
allow_methods=["*"],
63-
allow_headers=["*"],
65+
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
66+
allow_headers=["Authorization", "Content-Type", "X-API-Key", "X-Node-API-Key"],
6467
)
6568

69+
70+
@app.middleware("http")
71+
async def security_headers(request: Request, call_next):
72+
"""Add security headers to all responses."""
73+
response = await call_next(request)
74+
response.headers["X-Content-Type-Options"] = "nosniff"
75+
response.headers["X-Frame-Options"] = "DENY"
76+
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
77+
response.headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=()"
78+
if request.url.scheme == "https" or os.getenv("FLY_APP_NAME"):
79+
response.headers["Strict-Transport-Security"] = "max-age=63072000; includeSubDomains"
80+
return response
81+
6682
# Include API routers
6783
app.include_router(cameras.router)
6884
app.include_router(webhooks.router)
@@ -129,8 +145,8 @@ async def shutdown_event():
129145

130146
@app.middleware("http")
131147
async def spa_middleware(request: Request, call_next):
132-
# Let API, WebSocket, and install routes pass through
133-
if request.url.path.startswith(("/api", "/ws", "/install.", "/mcp-setup.")):
148+
# Let API, WebSocket, install, and OpenAPI docs routes pass through
149+
if request.url.path.startswith(("/api", "/ws", "/install.", "/mcp-setup.", "/docs", "/redoc", "/openapi.json")):
134150
return await call_next(request)
135151

136152
# MCP endpoint: only pass POST requests (JSON-RPC) to the MCP server;

backend/app/schemas/schemas.py

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,23 +9,12 @@ class CameraGroupCreate(BaseModel):
99

1010

1111
class RecordingSettings(BaseModel):
12-
motion_recording: bool = False
13-
face_recording: bool = False
14-
object_recording: bool = False
15-
post_buffer: int = Field(5, ge=0, le=300)
1612
scheduled_recording: bool = False
1713
scheduled_start: str = Field("06:00", max_length=5)
1814
scheduled_end: str = Field("17:00", max_length=5)
1915
continuous_24_7: bool = False
2016

2117

22-
class NotificationSettings(BaseModel):
23-
motion_notifications: bool = True
24-
face_notifications: bool = True
25-
object_notifications: bool = True
26-
toast_notifications: bool = True
27-
28-
2918
class CameraReport(BaseModel):
3019
camera_id: Optional[str] = Field(None, max_length=150)
3120
device_path: Optional[str] = Field(None, max_length=255)

backend/pyproject.toml

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,4 +19,16 @@ dependencies = [
1919
"slowapi>=0.1.9",
2020
"websockets>=12.0",
2121
"fastmcp>=3.0.0",
22-
]
22+
]
23+
24+
[project.optional-dependencies]
25+
dev = [
26+
"pytest>=8.0.0",
27+
"pytest-asyncio>=0.23.0",
28+
"httpx>=0.27.0",
29+
]
30+
31+
[tool.pytest.ini_options]
32+
testpaths = ["tests"]
33+
asyncio_mode = "auto"
34+
pythonpath = ["."]

0 commit comments

Comments
 (0)