a31cc5d3 widened the jwt-only census to app/plugins and shipped a ceiling
of 611 measured on a dev box. CI measured 591 and went red: most
app/plugins/ live copies are GITIGNORED, so the count depended on which
extensions a machine happened to have installed. That is the same
machine-dependence the builtin-copy drift census exists to prevent, and
this walked straight into it.
Count builtin-extensions/ instead -- the source, pinned equal to the live
copy by test_builtin_extension_drift.py, and tracked. Filter to
git-tracked files as well, because extractions leave stale ignored
directories behind (cloudflare-ops, git, localkit after plan 52) that
would otherwise be counted on some working copies but not others.
Stable population: 608 (35 of them extension routes across
cloud-provision, remote-access and status). Also regenerates
docs/MIGRATION_INVENTORY.md, whose row is derived from this ceiling, and
points the CLAUDE.md / AGENTS.md notes at the scope actually scanned.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
14 KiB
AGENTS.md
This file provides guidance to Codex (Codex.ai/code) when working with code in this repository.
Project Overview
ServerKit is a server control panel for managing web applications, databases, Docker containers, and security on VPS/dedicated servers. Flask backend (Python 3.11+), React frontend (Vite + SCSS), SQLite/PostgreSQL database, real-time updates via Socket.IO.
Development Commands
# Backend (launcher default port 47927, hot-reload)
cd backend && python -m venv venv && source venv/bin/activate # Windows: venv\Scripts\activate
pip install -r requirements.txt
python run.py
# Frontend (launcher default port 41921, Vite HMR)
cd frontend && npm install && npm run dev
# Both at once (Linux/WSL)
./dev.sh
# Frontend lint
cd frontend && npm run lint
# Frontend production build
cd frontend && npm run build
# Backend tests
cd backend && pytest
cd backend && pytest --cov=app
# Docker (all-in-one panel container; no host management)
docker compose up -d --build
Default dev credentials: admin / admin
Architecture
Backend (backend/)
Flask app factory in app/__init__.py using create_app(). Three-layer architecture:
app/api/— Flask Blueprints, one file per feature (90+ files). All routes prefixed/api/v1/. JWT-protected via@jwt_required().app/services/— Business logic (165+ files). Services are stateless modules called by API routes. Heavy lifting (shell commands, Docker API, file operations) happens here.app/models/— SQLAlchemy ORM models (80+ files). Schema is managed by Alembic migrations inbackend/migrations/versions/— add a migration for every schema change; the app does not calldb.create_all().
Other backend components:
app/sockets.py— Socket.IO event handlers for real-time metrics, logs, terminalapp/agent_gateway.py— Multi-server agent communicationapp/middleware/security.py— Security headers middlewareconfig.py— Environment-based config (development/production/testing)run.py— Entry point
Frontend (frontend/src/)
React 18 SPA with client-side routing:
pages/— Route-level components (60+ files). Each maps to a route inApp.jsx.components/— Reusable UI components shared across pages.contexts/— React Context providers:AuthContext(JWT auth + token refresh),ThemeContext,ToastContext,ResourceTierContext(feature gating).services/api.js— CentralizedApiServiceclass handling all HTTP requests, token management, and auto-refresh.hooks/— Custom React hooks for reusable logic.styles/— SCSS stylesheets with design system variables. Main entry ismain.scss. Page-specific styles instyles/pages/.layouts/—DashboardLayoutwraps authenticated pages (sidebar + header).
Route guards: PrivateRoute (auth check), PublicRoute (redirect if logged in), SetupRoute (redirect to /setup if not configured).
Request Flow
Browser → Nginx (:80/:443) → proxy_pass to Docker containers (:8001-8999) for managed apps, or to Flask (:5000) for the panel API. The 404 handler in Flask serves index.html for SPA client-side routing; API routes return JSON errors.
Production Build
The Dockerfile is multi-stage: Node 20 builds frontend, Python 3.11 serves everything via Gunicorn with a single plain threaded worker (--workers 1 --threads 100, no --worker-class — see the single-worker note above). Built frontend is served from Flask's static folder.
Platform & Distro Awareness
ServerKit deploys on Linux (bare metal, VPS, or Docker). Development may happen on Windows/macOS.
- Service layer is Linux-only — nginx, systemctl, apt/dnf, PHP-FPM, etc. are inherently Linux. No need to abstract these for Windows.
- Platform-agnostic code (config management, storage, API layer) should guard Unix-only calls like
os.chmodwithif os.name != 'nt'so the dev server can run locally on any OS. - Distro differences matter — use
backend/app/utils/system.pyhelpers (get_package_manager,is_package_installed,install_package) instead of callingapt/dpkg/dnfdirectly. Not all targets are Debian-based.
Code Style
Python
- PEP 8, type hints where helpful
- Service functions are standalone (no classes unless stateful)
- Consistent JSON error responses:
{'error': 'message'}, status_code
React/JavaScript
- Functional components with hooks only
- PascalCase for components (
Sidebar.jsx), camelCase for everything else - SCSS for styling — use existing design system variables (
$bg-card,$primary-color,$spacing-md, etc.) and BEM-like naming (.block__element--modifier) - Context API for global state; props drilling is fine for 2-3 levels
- No inline styles; no Tailwind/CSS-in-JS
Diffs & Commits
- One logical change per commit
- Minimal, focused diffs — don't silently refactor surrounding code
- Branch naming:
feature/,fix/,docs/,refactor/prefixes - Pre-commit hook: a shared git hook (
.githooks/pre-commit) lints staged frontend JS/JSX and blocks the commit on ESLint errors (warnings allowed; backend/SCSS-only commits are skipped). Enable once per clone:git config core.hooksPath .githooks(orsh frontend/setup-hooks.sh). Keep the repo at 0 ESLint errors.
Styling Standard
SCSS only. ServerKit standardizes on its internal SCSS design system. Do not add Tailwind utility classes, CSS-in-JS, or new inline styles.
- Forbidden: Tailwind utility strings (
flex gap-3 px-4,text-sm,bg-card,border-border,rounded-full, etc.),tailwindcss/tailwind-mergeimports, and CSS-in-JS. Inlinestyle={{ ... }}is allowed only for true dynamic values (a width computed from a prop, chart dimensions). - Class namespaces:
.sk-*— redesign "infra console" design-system primitives.- Legacy BEM (
.page-container,.card,.modal-*,.form-*,.empty-state__title) — allowed during migration; deprecate gradually. - No utility-class strings.
- Shared primitives (reuse before rewrite):
- Buttons —
styles/components/_buttons.scss(.btn,.btn-primary,.btn-danger,.btn-ghost,.btn-sm,.btn-icon). - Cards —
styles/components/_cards.scss(.card,.card-header,.stat-strip). - Modals —
styles/components/_modals.scss(.modal-overlay,.modal,.modal-lg,.modal-header,.modal-body,.modal-actions). - Forms —
styles/components/_forms.scss(.form-group,.form-field,.form-row,.error-message); prefer theFormFieldcomponent. - Tables —
.sk-dtableinstyles/components/_design-system.scss; prefer thecomponents/ds/DataTable.jsxprimitive. Supporting pieces:ds/ListToolbar.jsx(the one list toolbar row),ds/SortMenu.jsx+ds/ColumnsMenu.jsx(toolbar popovers),ds/SortChipBar.jsx(active-sort chips),ds/ViewMenu.jsx(saved views),ds/DataTableFooter.jsx(standard "Shown X of Y · Load more / page-size" bar),hooks/useTableSort.js+hooks/useColumnVisibility.js+hooks/useTableViews.js(state, localStorage- or server-persisted). - Pills/badges —
.sk-pill,.sk-state,.sk-tag. - Tokens —
styles/_variables.scss,styles/_theme-variables.scss.
- Buttons —
- Shared utilities (reuse before rewrite):
utils/formatBytes.js— byte/size formatting (no localformatBytes/formatSize/formatMemory).utils/time.js—timeAgo(compact) andformatRelativeTime(verbose).utils/clipboard.js+hooks/useClipboard.js— copy-to-clipboard with toast feedback (no rawnavigator.clipboard.writeTextcall sites).
- Fonts:
IBM Plex Sans/IBM Plex Mono(the established SCSS stack).
Migration status: Tailwind removal is in progress (see
docs/plans/05_FRONTEND_UX_TAILWIND_CLEANUP_PLAN.md). Do not introduce new Tailwind
usage; convert any you touch to the SCSS equivalents above.
Page Anatomy (List Pages)
One anatomy for every list page — do not invent per-page toolbar/empty-state markup:
PageTopbar (tabs + actions: Add/Refresh/…, FilterButton, SearchField)
└─ ListToolbar (.sk-listhead) [title?] [quick filters/SegControl] [extras] … [count] [ViewMenu SortMenu ColumnsMenu]
└─ SortChipBar (only while a sort is active)
└─ table card: DataTable + DataTableFooter
└─ floating .sk-bulkbar (only while rows are selected)
- Placement rule: page-level actions,
FilterButton(advanced drawer filters) andSearchField(always rightmost) publish into the shared topbar viauseTopbarActions; quick filters (SegControl), counts,ViewMenu,SortMenu,ColumnsMenuand secondary selects live in the in-pageListToolbar(components/ds/ListToolbar.jsx). Pages with no quick filters skip the ListToolbar and put everything in the topbar. - Tables:
DataTable+useTableSort/useColumnVisibility(see the Styling Standard primitives). Saved views viauseTableViews+ViewMenu. - Empty states: always the shared
EmptyStatecomponent — never a new__emptyclass. DataTable takesemptyTitle/emptyMessage. - Drawers:
ds/Draweronly, widths from the scale 380 / 520 / 640 / 720 (1100 for the domain-detail exception). Modals:components/Modal.jsx— do not use rawui/dialogfor ordinary dialogs. - Confirms:
useConfirm()(promise-based) — nowindow.confirm, no per-page confirm-dialog state. - Row clicks: entity with a detail page → navigate; entity without one → Drawer; action-only tables → no row click.
Adding a New Feature (Full Stack)
- Model: Add SQLAlchemy model in
backend/app/models/ - Service: Add business logic in
backend/app/services/ - API: Create Blueprint in
backend/app/api/, register it inapp/__init__.pywithurl_prefix='/api/v1/<feature>' - Frontend API: Add methods to
ApiServiceinfrontend/src/services/api.js - Page: Create page component in
frontend/src/pages/, add route inApp.jsx - Styles: Add SCSS file in
frontend/src/styles/pages/, import inmain.scss
Authorization (default-deny, test-enforced)
Every mutating route (POST/PUT/PATCH/DELETE) must carry an explicit
authorization decision — @jwt_required() alone is never sufficient
(GHSA-r4q7-x795-vr4j). Three tests enforce this and will fail your PR:
backend/tests/test_route_authz_static.py(coverage): the route must carry a gate decorator (@admin_required/@developer_required/@permission_required), reach an inline authorization primitive (require_admin_user,check_app_access,_check_scope, …), or be listed in a reviewed allowlist (SELF_SCOPED/PUBLIC_ALT_AUTH/STATELESS) with a specific justification.backend/tests/test_route_authz_sweep.py(enforcement): no mutating route may answer a viewer with a 2xx unless allowlisted inVIEWER_WRITABLE.backend/tests/test_auth_decorator_boundaries.py(one boundary): the policy decorators already authenticate throughauth_required(), so do not stack@jwt_required()outside one — it rejects API-key callers before the policy can authorize them. Add the gate instead of@jwt_required(), and read the caller withget_current_user(), neverget_jwt_identity()(which raises for an API-key request).
When in doubt: admin-only for panel-wide resources, @developer_required for
operational actions, the file's own inline ownership helper for user data.
Do not sweep @jwt_required() + inline is_admin to rbac decorators
An existing route shaped like this already satisfies default-deny —
test_route_authz_static.py counts inspecting .is_admin / .is_developer as
an inline role check (ATTRIBUTE_GATES):
@bp.route('/<int:page_id>', methods=['PUT'])
@jwt_required()
def update_page(page_id):
user = get_current_user()
if not user or not user.is_admin:
return jsonify({'error': 'Admin access required'}), 403
It is also the majority shape: ~91 routes across 21 files (audited 2026-08-31).
Converting one to @admin_required is not a refactor. Every rbac decorator
authenticates through auth_required(), which accepts an X-API-Key, so the
conversion grants API-key callers access to that route. The decision recorded
when plan 76 closed (2026-08-19) still governs:
JWT-only is the deliberate default. A route becomes API-key capable one at a time, when an API-key use case exists for it, in a commit that says so — never as a mechanical sweep.
backend/tests/jwt_only_census.py ratchets the population (JWT_ONLY_CEILING,
scanning app/api and the git-tracked builtin-extensions/ sources): it
may shrink through deliberate conversions and may not grow, so a NEW route must pick a policy decorator
instead of bare @jwt_required().
The rule above ("do not stack @jwt_required() outside a gate") is about not
having both. It is not an instruction to convert routes that have neither.
Automated reviewers suggest this conversion routinely — decline it unless the
commit can name the API-key use case.
Key Environment Variables
| Variable | Purpose |
|---|---|
SECRET_KEY |
Flask session signing |
JWT_SECRET_KEY |
JWT token signing |
DATABASE_URL |
DB connection string (sqlite:///... or PostgreSQL) |
CORS_ORIGINS |
Comma-separated allowed origins |
FLASK_ENV |
development or production |
Builtin Extensions
Extensions live in builtin-extensions/<name>/ with a plugin.json manifest
(required keys: name, display_name, version; entry_point as
module:attr) and a backend/ package (loaded as app.plugins.<name>).
API-key auth: guard routes with @auth_required() / @admin_required from
app/middleware/rbac.py — bare flask @jwt_required() rejects X-API-Key
callers.
serverkit-localkit— API surface for the LocalKit desktop app, mounted at/api/v1/localkit(GET /pair,GET|POST /sites,POST /push/code,POST /push/db,GET /pull/db). It is the API-key-friendly surface for WordPress automation (the hubGET /api/v1/wordpress/sitesis JWT-only). Reuses theserverkit-wordpressservice layer (imported cross-extension viaimportlibasapp.plugins.serverkit-wordpress.wordpress_service) andDatabaseSyncService.import_to_container/export_from_containerfor DB push/pull; wp-content arrives as a tar.gz and is installed viadocker cp.