From fdc8d9cb6866f3f2c6e2d5a1ae78be397a4ce268 Mon Sep 17 00:00:00 2001 From: Jason Staack Date: Thu, 19 Mar 2026 22:28:56 -0500 Subject: [PATCH] feat(license): add BSL license enforcement with device limit indicator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add LICENSE_DEVICES env var (default 250, matches BSL 1.1 free tier) - Add /api/settings/license endpoint returning device count vs limit - Header shows flashing red "502/500 licensed" badge when over limit - About page shows license tier, device count, and over-limit warning - Nothing is crippled — all features work regardless of device count - Bump version to 9.7.1 Co-Authored-By: Claude Opus 4.6 (1M context) --- VERSION | 2 +- backend/app/config.py | 6 ++++- backend/app/routers/settings.py | 21 ++++++++++++++++ backend/pyproject.toml | 2 +- frontend/package.json | 2 +- .../src/components/layout/ContextStrip.tsx | 14 +++++++++++ frontend/src/lib/settingsApi.ts | 12 +++++++++ frontend/src/routes/_authenticated/about.tsx | 25 +++++++++++++++++++ infrastructure/helm/Chart.yaml | 2 +- 9 files changed, 81 insertions(+), 5 deletions(-) diff --git a/VERSION b/VERSION index a458a24..ff32e76 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -9.7.0 +9.7.1 diff --git a/backend/app/config.py b/backend/app/config.py index 9a26bc3..b4ddd50 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -135,9 +135,13 @@ class Settings(BaseSettings): # Retention cleanup — delete config snapshots older than N days CONFIG_RETENTION_DAYS: int = 90 + # Licensing — BSL 1.1 free tier allows up to 250 devices. + # Commercial license required above this limit. Set to 0 for unlimited. + LICENSE_DEVICES: int = 250 + # App settings APP_NAME: str = "TOD - The Other Dude" - APP_VERSION: str = "9.7.0" + APP_VERSION: str = "9.7.1" DEBUG: bool = False @field_validator("CREDENTIAL_ENCRYPTION_KEY") diff --git a/backend/app/routers/settings.py b/backend/app/routers/settings.py index 842c36c..9bab5a0 100644 --- a/backend/app/routers/settings.py +++ b/backend/app/routers/settings.py @@ -176,3 +176,24 @@ async def clear_winbox_sessions(user=Depends(require_role("super_admin"))): return {"status": "ok", "deleted": deleted} finally: await rd.aclose() + + +# --------------------------------------------------------------------------- +# License status (any authenticated user) +# --------------------------------------------------------------------------- + + +@router.get("/license", summary="Get license status") +async def get_license_status(): + """Return current license tier, device limit, and actual device count.""" + async with AdminAsyncSessionLocal() as session: + result = await session.execute(text("SELECT count(*)::int FROM devices")) + device_count = result.scalar() or 0 + + limit = settings.LICENSE_DEVICES + return { + "licensed_devices": limit, + "actual_devices": device_count, + "over_limit": limit > 0 and device_count > limit, + "tier": "commercial" if limit > 250 else "free", + } diff --git a/backend/pyproject.toml b/backend/pyproject.toml index b3c9a55..2fea5e5 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "the-other-dude-backend" -version = "9.7.0" +version = "9.7.1" description = "MikroTik Fleet Management Portal - Backend API" requires-python = ">=3.12" dependencies = [ diff --git a/frontend/package.json b/frontend/package.json index a44d25a..61701cb 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "9.7.0", + "version": "9.7.1", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/components/layout/ContextStrip.tsx b/frontend/src/components/layout/ContextStrip.tsx index 212b846..3746dd5 100644 --- a/frontend/src/components/layout/ContextStrip.tsx +++ b/frontend/src/components/layout/ContextStrip.tsx @@ -14,6 +14,7 @@ import { import { useAuth, isSuperAdmin } from '@/lib/auth' import { useUIStore } from '@/lib/store' import { tenantsApi, metricsApi } from '@/lib/api' +import { getLicenseStatus } from '@/lib/settingsApi' import { useEventStreamContext } from '@/contexts/EventStreamContext' import type { ConnectionState } from '@/hooks/useEventStream' import { NotificationBell } from '@/components/alerts/NotificationBell' @@ -88,6 +89,14 @@ export function ContextStrip() { const offlineCount = fleet?.filter((d) => d.status === 'offline').length ?? 0 const degradedCount = fleet?.filter((d) => d.status === 'degraded').length ?? 0 + // License status (super_admin only) + const { data: license } = useQuery({ + queryKey: ['license-status'], + queryFn: getLicenseStatus, + enabled: superAdmin, + refetchInterval: 60_000, + }) + const handleLogout = async () => { await logout() void navigate({ to: '/login' }) @@ -188,6 +197,11 @@ export function ContextStrip() { ) : ( Status loading... )} + {license?.over_limit && ( + + {license.actual_devices}/{license.licensed_devices} licensed + + )} {/* Right: Actions */} diff --git a/frontend/src/lib/settingsApi.ts b/frontend/src/lib/settingsApi.ts index 9dedc43..01ccec3 100644 --- a/frontend/src/lib/settingsApi.ts +++ b/frontend/src/lib/settingsApi.ts @@ -45,3 +45,15 @@ export async function testSMTPSettings(data: { const res = await api.post('/api/settings/smtp/test', data) return res.data } + +export interface LicenseStatus { + licensed_devices: number + actual_devices: number + over_limit: boolean + tier: 'free' | 'commercial' +} + +export async function getLicenseStatus(): Promise { + const res = await api.get('/api/settings/license') + return res.data +} diff --git a/frontend/src/routes/_authenticated/about.tsx b/frontend/src/routes/_authenticated/about.tsx index 4fc995c..784f4e0 100644 --- a/frontend/src/routes/_authenticated/about.tsx +++ b/frontend/src/routes/_authenticated/about.tsx @@ -1,8 +1,10 @@ import { createFileRoute } from '@tanstack/react-router' import { useEffect, useRef, useState } from 'react' +import { useQuery } from '@tanstack/react-query' import { RugLogo } from '@/components/brand/RugLogo' import { APP_VERSION } from '@/lib/version' import { AnsiNfoModal } from '@/components/about/AnsiNfoModal' +import { getLicenseStatus } from '@/lib/settingsApi' export const Route = createFileRoute('/_authenticated/about')({ component: AboutPage, @@ -483,6 +485,7 @@ function AboutPage() { const [copied, setCopied] = useState(false) const [showQR, setShowQR] = useState(false) const [showNfo, setShowNfo] = useState(false) + const { data: license } = useQuery({ queryKey: ['license-status'], queryFn: getLicenseStatus }) const copyAddress = async () => { try { @@ -516,6 +519,28 @@ function AboutPage() { + {/* License */} + {license && ( +
+

+ License +

+
+ + {license.tier === 'commercial' ? 'Commercial License' : 'BSL 1.1 — Free Tier'} + + + {license.actual_devices} / {license.licensed_devices === 0 ? 'Unlimited' : license.licensed_devices} devices + +
+ {license.over_limit && ( +

+ Device count exceeds licensed limit. A commercial license is required. +

+ )} +
+ )} + {/* Features summary */}

diff --git a/infrastructure/helm/Chart.yaml b/infrastructure/helm/Chart.yaml index f0b4b31..41a3cce 100644 --- a/infrastructure/helm/Chart.yaml +++ b/infrastructure/helm/Chart.yaml @@ -3,7 +3,7 @@ name: tod description: The Other Dude — MikroTik fleet management platform type: application version: 1.0.0 -appVersion: "9.7.0" +appVersion: "9.7.1" kubeVersion: ">=1.28.0-0" keywords: - mikrotik