Skip to content

Commit 72a0ebe

Browse files
Sbussisoclaude
andcommitted
feat: subscription transparency + /security page
Five related changes, all driven by the same goal: make paying customers see exactly what they're paying for, and make our privacy story honest enough to hold up against a line-by-line audit. ### Past-due grace countdown (backend + frontend) `GET /api/nodes/plan` now returns `grace_days_remaining`, `grace_expires_at`, and `grace_window_days`. The dashboard past-due banner renders a live "N days left" countdown while in grace and flips to a deeper-red "Grace period expired" variant once caps tighten. Previously the ToS promised 7 days but the UI was silent — the first signal a user got was cameras going dark. Banner picks up `role="status"` + `aria-live="polite"` so screen readers announce state changes. ### subscription.updated clears past-due on a paid plan If a customer upgraded to fix a past-due state, `effective_plan_for_caps` kept returning "free_org" until the next paymentAttempt.updated webhook trickled in — their newly-paid plan stayed capped at free-tier limits for as long as Clerk took to re-deliver. Now any subscription.* event landing on pro/business clears `payment_past_due` + `payment_past_due_at` immediately (Clerk wouldn't mark the subscription active without the card having gone through). ### Custom 429 handler Replaces slowapi's bare `{"detail": "429 ..."}` with a structured body (`error`, `message`, `limit`, `retry_after_seconds`) and a proper `Retry-After: 60` header. Off-the-shelf HTTP clients now back off correctly without special-casing. ### /docs#api-rate-limits New sidebar entry + full section documenting every REST rate limit, MCP per-key budgets, SSE subscriber caps, payload caps, and pagination bounds. Also fixes the outdated "429 MCP only" claim in the Error Format subsection — 429 applies to REST routes too, now with docs. ### /security page New public page at `/security` (linked from the footer) covering the privacy architecture: what lives on-device vs in cloud, AES-256-GCM encryption posture (now including recordings — see the paired CloudNode commit), motion detection running locally via FFmpeg scene-change (not any cloud ML service), third-party list, law-enforcement policy, and the deletion cascade. Every claim cites the implementing file so "open source you can verify" isn't just marketing. All 226 backend tests pass; frontend builds clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 61ca87d commit 72a0ebe

9 files changed

Lines changed: 707 additions & 16 deletions

File tree

backend/app/api/nodes.py

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from app.core.auth import AuthUser, require_admin, require_active_billing, get_current_user
1212
from app.core.limiter import limiter
1313
from app.core.plans import (
14+
PAYMENT_GRACE_DAYS,
1415
enforce_camera_cap,
1516
get_plan_limits,
1617
get_plan_limits_for_org,
@@ -506,13 +507,43 @@ async def get_plan_info(
506507
user: AuthUser = Depends(get_current_user),
507508
db: Session = Depends(get_db),
508509
):
509-
"""Return the org's current plan, usage, and limits."""
510+
"""Return the org's current plan, usage, limits, and (when past-due) the
511+
remaining grace window. The countdown lets the dashboard show a clear
512+
"X days until suspension" banner instead of the static "7 days" copy the
513+
ToS guarantees but operators can't see live.
514+
"""
510515
from app.models.models import Setting
516+
from datetime import datetime, timedelta, timezone
511517

512518
limits = get_plan_limits(user.plan)
513519
current_nodes = db.query(CameraNode).filter_by(org_id=user.org_id).count()
514520
current_cameras = db.query(Camera).filter_by(org_id=user.org_id).count()
515521
payment_past_due = Setting.get(db, user.org_id, "payment_past_due", "false") == "true"
522+
523+
# Compute grace window when past-due. The ToS promises PAYMENT_GRACE_DAYS
524+
# from payment_past_due_at; after that, effective_plan_for_caps tightens
525+
# everyone to free_org. We expose the remaining days + expiry timestamp
526+
# so the dashboard banner can warn users before it bites.
527+
grace_days_remaining: int | None = None
528+
grace_expires_at: str | None = None
529+
if payment_past_due:
530+
past_due_at_str = Setting.get(db, user.org_id, "payment_past_due_at", "")
531+
if past_due_at_str:
532+
try:
533+
past_due_at = datetime.fromisoformat(past_due_at_str.replace("Z", "+00:00"))
534+
if past_due_at.tzinfo is None:
535+
past_due_at = past_due_at.replace(tzinfo=timezone.utc)
536+
expires_at = past_due_at + timedelta(days=PAYMENT_GRACE_DAYS)
537+
remaining = expires_at - datetime.now(tz=timezone.utc)
538+
# Negative remaining means grace already expired — surface as
539+
# 0 so the UI shows "suspended" rather than a negative number.
540+
grace_days_remaining = max(0, remaining.days)
541+
grace_expires_at = expires_at.isoformat()
542+
except (ValueError, TypeError):
543+
# Unparseable timestamp — leave fields None, same posture as
544+
# effective_plan_for_caps (keep nominal plan until resolved).
545+
pass
546+
516547
return {
517548
"plan": user.plan,
518549
"plan_name": get_plan_display_name(user.plan),
@@ -523,6 +554,9 @@ async def get_plan_info(
523554
"cameras": current_cameras,
524555
},
525556
"payment_past_due": payment_past_due,
557+
"grace_days_remaining": grace_days_remaining,
558+
"grace_expires_at": grace_expires_at,
559+
"grace_window_days": PAYMENT_GRACE_DAYS,
526560
}
527561

528562

backend/app/api/webhooks.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,12 @@
2121
"business": 20,
2222
}
2323

24+
# Paid plan slugs. Seeing a subscription.updated with one of these means the
25+
# payment card is active (Clerk wouldn't mark the subscription live otherwise),
26+
# so we can clear any past-due flag we were holding. Kept local to this module
27+
# rather than imported from plans.py to keep webhook semantics self-contained.
28+
PAID_PLAN_SLUGS_WEBHOOK = frozenset({"pro", "business"})
29+
2430

2531
def set_org_member_limit(org_id: str, limit: int):
2632
"""Update the Clerk org's max allowed memberships."""
@@ -70,6 +76,17 @@ async def clerk_webhook(request: Request, db: Session = Depends(get_db)):
7076
set_org_member_limit(org_id, limit)
7177
# Persist plan in DB so API-key-authenticated endpoints can look it up
7278
Setting.set(db, org_id, "org_plan", plan_slug)
79+
# If the subscription is now on a paid plan, clear any lingering
80+
# past-due flag. Clerk only emits subscription.active/updated once
81+
# the payment has actually gone through, so seeing this event with
82+
# a paid plan means the card is good again — the org should get
83+
# their paid caps back immediately, not after the next
84+
# paymentAttempt.updated trickles in. Without this clear, an org
85+
# that upgrades *during* the grace window stays capped at free
86+
# because effective_plan_for_caps still sees past_due=true.
87+
if plan_slug in PAID_PLAN_SLUGS_WEBHOOK:
88+
Setting.set(db, org_id, "payment_past_due", "false")
89+
Setting.set(db, org_id, "payment_past_due_at", "")
7390
# Re-evaluate camera cap — a plan change (up or down) may flip
7491
# rows in either direction. Flushing the Setting first ensures
7592
# `resolve_org_plan` inside enforce_camera_cap reads the new value.

backend/app/main.py

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,9 @@
99
from fastapi.middleware.cors import CORSMiddleware
1010
from fastapi.staticfiles import StaticFiles
1111
from fastapi.responses import FileResponse
12-
from slowapi import _rate_limit_exceeded_handler
1312
from slowapi.errors import RateLimitExceeded
1413
from slowapi.middleware import SlowAPIMiddleware
14+
from fastapi.responses import JSONResponse
1515
from app.core.config import settings
1616
from app.core.database import Base, engine, SessionLocal
1717
from app.core.limiter import limiter
@@ -70,7 +70,37 @@ async def lifespan(app):
7070
)
7171

7272
app.state.limiter = limiter
73-
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
73+
74+
75+
async def rate_limit_exceeded_handler(request: Request, exc: RateLimitExceeded) -> JSONResponse:
76+
"""Custom 429 response — gives the client everything it needs to retry.
77+
78+
The slowapi default emits a bare `{"detail": "429 ..."}` string with no
79+
Retry-After header, which leaves integrators guessing at the backoff
80+
window and which limit they hit. We return:
81+
- a stable JSON shape matching the rest of the API's error envelope
82+
- the exact limit string (e.g. "60 per 1 minute") so callers know what
83+
bucket they tripped
84+
- a `Retry-After: 60` header, per RFC 9110, so off-the-shelf HTTP
85+
clients back off without special handling
86+
60s is a safe upper bound because our tightest rate windows are minute-
87+
scoped; callers that honour Retry-After will idle through the window and
88+
succeed on the next attempt.
89+
"""
90+
limit_str = str(exc.detail) if getattr(exc, "detail", None) else "rate limit exceeded"
91+
body = {
92+
"error": "rate_limit_exceeded",
93+
"message": (
94+
"Too many requests. Back off and retry after the Retry-After window. "
95+
"See /docs#api-rate-limits for per-route limits."
96+
),
97+
"limit": limit_str,
98+
"retry_after_seconds": 60,
99+
}
100+
return JSONResponse(status_code=429, content=body, headers={"Retry-After": "60"})
101+
102+
103+
app.add_exception_handler(RateLimitExceeded, rate_limit_exceeded_handler)
74104
app.add_middleware(SlowAPIMiddleware)
75105

76106
# Get frontend URL from environment (set in fly.toml or .env)

frontend/src/App.jsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ const PricingPage = lazy(() => import("./pages/PricingPage.jsx"))
1818
const McpPage = lazy(() => import("./pages/McpPage.jsx"))
1919
const SentinelPage = lazy(() => import("./pages/SentinelPage.jsx"))
2020
const LegalPage = lazy(() => import("./pages/LegalPage.jsx"))
21+
const SecurityPage = lazy(() => import("./pages/SecurityPage.jsx"))
2122

2223
function RequireOrg({ children }) {
2324
const { organization, isLoaded } = useOrganization()
@@ -98,6 +99,7 @@ function App() {
9899
<Route path="/pricing" element={<PricingPage />} />
99100
<Route path="/sentinel" element={<SentinelPage />} />
100101
<Route path="/legal/:page" element={<LegalPage />} />
102+
<Route path="/security" element={<SecurityPage />} />
101103
</Route>
102104

103105
{/* Auth routes (public but use Clerk components) */}

frontend/src/components/LandingFooter.jsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ function LandingFooter() {
3838

3939
<div className="landing-footer-col">
4040
<h5>Legal</h5>
41+
<Link to="/security">Security</Link>
4142
<Link to="/legal/terms">Terms of Service</Link>
4243
<Link to="/legal/privacy">Privacy Policy</Link>
4344
<a href="https://github.com/SourceBox-LLC/OpenSentry-Command/blob/master/LICENSE" target="_blank" rel="noopener noreferrer">

frontend/src/index.css

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1401,6 +1401,120 @@ body {
14011401
text-decoration: underline;
14021402
}
14031403

1404+
.payment-past-due-banner.payment-past-due-expired {
1405+
background: rgba(220, 38, 38, 0.18);
1406+
border-color: rgba(220, 38, 38, 0.55);
1407+
color: #fecaca;
1408+
}
1409+
1410+
/* Security Page */
1411+
.security-page {
1412+
min-height: 100vh;
1413+
color: var(--text-primary);
1414+
}
1415+
1416+
.security-hero {
1417+
padding: 4rem 1.5rem 2.5rem;
1418+
text-align: center;
1419+
background: linear-gradient(180deg, rgba(59, 130, 246, 0.08), transparent);
1420+
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
1421+
}
1422+
1423+
.security-title {
1424+
font-size: clamp(1.875rem, 4vw, 2.75rem);
1425+
margin: 0 0 0.75rem;
1426+
line-height: 1.15;
1427+
letter-spacing: -0.02em;
1428+
}
1429+
1430+
.security-subtitle {
1431+
max-width: 42rem;
1432+
margin: 0 auto;
1433+
color: var(--text-secondary);
1434+
font-size: 1.05rem;
1435+
line-height: 1.55;
1436+
}
1437+
1438+
.security-body {
1439+
max-width: 880px;
1440+
padding: 2.5rem 1.5rem 5rem;
1441+
}
1442+
1443+
.security-section {
1444+
margin-bottom: 3rem;
1445+
}
1446+
1447+
.security-section h2 {
1448+
font-size: 1.5rem;
1449+
margin-bottom: 1rem;
1450+
padding-bottom: 0.5rem;
1451+
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
1452+
}
1453+
1454+
.security-section h3 {
1455+
font-size: 1.05rem;
1456+
margin-top: 1.5rem;
1457+
margin-bottom: 0.5rem;
1458+
color: var(--text-primary);
1459+
}
1460+
1461+
.security-section p {
1462+
color: var(--text-secondary);
1463+
line-height: 1.65;
1464+
margin-bottom: 1rem;
1465+
}
1466+
1467+
.security-section code {
1468+
background: rgba(255, 255, 255, 0.06);
1469+
padding: 0.1rem 0.35rem;
1470+
border-radius: 4px;
1471+
font-size: 0.9em;
1472+
}
1473+
1474+
.security-bullets {
1475+
list-style: disc;
1476+
padding-left: 1.25rem;
1477+
color: var(--text-secondary);
1478+
line-height: 1.65;
1479+
}
1480+
1481+
.security-bullets li {
1482+
margin-bottom: 0.6rem;
1483+
}
1484+
1485+
.security-bullets li strong {
1486+
color: var(--text-primary);
1487+
}
1488+
1489+
.security-dataflow {
1490+
overflow-x: auto;
1491+
margin: 1rem 0 1.5rem;
1492+
}
1493+
1494+
.security-dataflow table {
1495+
width: 100%;
1496+
border-collapse: collapse;
1497+
font-size: 0.9rem;
1498+
}
1499+
1500+
.security-dataflow th,
1501+
.security-dataflow td {
1502+
padding: 0.65rem 0.75rem;
1503+
text-align: left;
1504+
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
1505+
vertical-align: top;
1506+
}
1507+
1508+
.security-dataflow th {
1509+
color: var(--text-primary);
1510+
font-weight: 600;
1511+
border-bottom-color: rgba(255, 255, 255, 0.15);
1512+
}
1513+
1514+
.security-dataflow td {
1515+
color: var(--text-secondary);
1516+
}
1517+
14041518
/* Legal Pages */
14051519
.legal-container {
14061520
max-width: 800px;

frontend/src/pages/DashboardPage.jsx

Lines changed: 46 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -149,18 +149,52 @@ function DashboardPage() {
149149
<div className="dashboard-container">
150150
<HeartbeatBanner />
151151

152-
{isAdmin && planInfo?.payment_past_due && (
153-
<div className="payment-past-due-banner">
154-
<span>
155-
Your payment is past due. Cameras beyond your free-tier limit will be
156-
suspended after a 7-day grace period — update your payment method to
157-
keep streaming.
158-
</span>
159-
<Link to="/pricing" className="btn btn-primary btn-small">
160-
Manage Billing
161-
</Link>
162-
</div>
163-
)}
152+
{isAdmin && planInfo?.payment_past_due && (() => {
153+
// Grace countdown: backend returns grace_days_remaining + grace_expires_at.
154+
// When the timestamp parses cleanly we show the live number; otherwise
155+
// fall back to the ToS-guaranteed window (grace_window_days) so the
156+
// copy still reads correctly for older backends that haven't shipped
157+
// the countdown fields yet.
158+
const daysLeft = planInfo.grace_days_remaining
159+
const windowDays = planInfo.grace_window_days ?? 7
160+
let copy
161+
if (daysLeft === 0) {
162+
copy = (
163+
<>
164+
<strong>Grace period expired.</strong> Cameras beyond the free-tier
165+
limit are now suspended. Update your payment method to restore them.
166+
</>
167+
)
168+
} else if (typeof daysLeft === "number") {
169+
copy = (
170+
<>
171+
<strong>Payment past due — {daysLeft} day{daysLeft === 1 ? "" : "s"} left.</strong>
172+
{" "}After that, cameras beyond the free-tier limit will be suspended.
173+
Update your payment method now to avoid interruption.
174+
</>
175+
)
176+
} else {
177+
copy = (
178+
<>
179+
Your payment is past due. Cameras beyond your free-tier limit will be
180+
suspended after a {windowDays}-day grace period — update your payment method to
181+
keep streaming.
182+
</>
183+
)
184+
}
185+
return (
186+
<div
187+
className={`payment-past-due-banner${daysLeft === 0 ? " payment-past-due-expired" : ""}`}
188+
role="status"
189+
aria-live="polite"
190+
>
191+
<span>{copy}</span>
192+
<Link to="/pricing" className="btn btn-primary btn-small">
193+
Manage Billing
194+
</Link>
195+
</div>
196+
)
197+
})()}
164198

165199
{planInfo && planInfo.features?.includes("admin") && (
166200
<div className={`pro-status-bar pro-status-${planInfo.plan}`}>

0 commit comments

Comments
 (0)