Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
af11e98
chore(deps-dev): bump @humanfs/node from 0.16.7 to 0.16.8 in /frontend
dependabot[bot] Sep 3, 2026
6c2220c
Merge pull request #130 from jhd3197/dependabot/npm_and_yarn/frontend…
jhd3197 Sep 5, 2026
63db42b
chore: bump version to 1.9.26 [skip ci]
github-actions[bot] Sep 5, 2026
c8c93a8
fix(auth): enforce MFA, scoped keys, and revocable sessions
jhd3197 Sep 5, 2026
785c578
fix(sockets): authorize subscriptions and revalidate event delivery
jhd3197 Sep 5, 2026
6bf3490
fix(ai): enforce resource access and bounded protected chat
jhd3197 Sep 5, 2026
c62358d
fix(settings): restore table surfaces and improve access review
jhd3197 Sep 5, 2026
da0f181
ci(security): expand scans and narrow Bandit exceptions
jhd3197 Sep 5, 2026
3068fc8
docs: record security review and remediation handoff
jhd3197 Sep 5, 2026
f490d75
refactor: share backend lifecycle and fleet metrics operations
jhd3197 Sep 5, 2026
92a3ffe
refactor: consolidate frontend reuse and eliminate lint warnings
jhd3197 Sep 5, 2026
aae180a
docs: refresh migration inventory after reuse cleanup
jhd3197 Sep 5, 2026
d67e5d0
fix: complete audit follow-ups and add regression coverage
jhd3197 Sep 5, 2026
2de3a97
docs: publish reproducible README measurements
jhd3197 Sep 5, 2026
ccba314
docs: unify README asset size into one total
jhd3197 Sep 5, 2026
9cb6a9e
chore: bump version to 1.9.27 [skip ci]
github-actions[bot] Sep 5, 2026
4ab6d37
fix(docker): prevent inspector from covering actions
JRebertt Sep 5, 2026
3c925f1
fix: clear stale service metrics across runtime changes [skip version]
jhd3197 Sep 5, 2026
01e0925
fix(auth): backfill users.auth_version so upgrades keep logins working
jhd3197 Sep 5, 2026
b2dbd23
chore: bump version to 1.9.28 [skip ci]
jhd3197 Sep 5, 2026
e211cd7
ci: rerun promotion checks [skip version]
jhd3197 Sep 5, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -155,3 +155,7 @@ SERVERKIT_GITHUB_REPO=jhd3197/ServerKit

# Mark this panel as a staging install (reported by /health).
# SERVERKIT_STAGING=false

# Add request duration, SQL duration and statement count to Server-Timing.
# Temporary local/staging diagnosis; disabled by default. No SQL text is emitted.
# SERVERKIT_PROFILE_REQUESTS=false
4 changes: 3 additions & 1 deletion .githooks/pre-commit
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@ cd frontend || exit 0
[ -d node_modules ] || exit 0

rel=$(echo "$files" | sed 's#^frontend/##')
echo "$rel" | xargs npx eslint
# Keep each invocation below Windows' command-line limit on broad migrations.
# xargs still returns a failure if any batch fails.
echo "$rel" | xargs -n 20 npx eslint
status=$?

# Theme-token guard (plan 60): whitelist/validators/alias expansion/seed themes.
Expand Down
10 changes: 5 additions & 5 deletions .github/workflows/backend-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,8 @@ jobs:
run: python tests/check_test_count.py

# Sharded because this job WAS the entire wait: 21m50s of a ~22m pipeline,
# while every other workflow finished inside a minute. Splitting the 3173
# tests over 4 runners cuts the critical path to roughly a quarter.
# while every other workflow finished inside a minute. Splitting the suite
# over 4 runners cuts the critical path to roughly a quarter.
#
# Sharding rather than pytest-xdist is deliberate: each shard is its own VM,
# so the process-shared state that makes in-process parallelism unsafe here
Expand Down Expand Up @@ -86,9 +86,9 @@ jobs:
pip install -r requirements.txt
pip install pytest pytest-split
- name: Run tests (shard ${{ matrix.group }} of 4)
# Scoped to `tests` rather than a bare `pytest`. Identical here (3173
# either way, since backend/dev-data/ is gitignored and absent from a
# CI checkout), but it makes the command reproducible on a dev box: a
# Scoped to `tests` rather than a bare `pytest`. This collects the same
# suite in CI (backend/dev-data/ is gitignored), while keeping the
# command reproducible on a dev box: a
# bare pytest there tries to collect the locally deployed apps under
# backend/dev-data/ and dies during collection. Copy this line verbatim
# to debug a red shard locally.
Expand Down
49 changes: 29 additions & 20 deletions .github/workflows/frontend-ci.yml
Original file line number Diff line number Diff line change
@@ -1,20 +1,7 @@
name: Frontend CI

# The frontend's lint gate ran nowhere until now — `npm run lint` was in
# package.json and in CLAUDE.md, but no workflow ever invoked it. That silently
# unenforced three project-specific checkers that exist precisely because a
# human review keeps missing what they catch:
#
# check-settings-index every Settings tab has a search-index entry
# check-theme-tokens the theme-token whitelist stays in 3-way sync
# check-html-sinks every raw-HTML sink is sanitized or annotated (XSS)
#
# `npm run lint` and `npm test` run here. The test suite (`node --test`,
# 141 assertions over pure-logic modules) sat invocable-but-uninvoked the
# same way lint once did — written insurance no workflow cashed in. The
# frontend is already COMPILED in CI by Release Build Smoke Test, whose
# scripts/build-release.sh does `npm ci && npm run build` — adding a build
# job here would just duplicate that.
# Run the warning ratchet, repository integrity checks, unit tests and browser
# regressions. Release Build Smoke Test separately compiles the production app.
#
# backend/app/** is in the paths because check-html-sinks scans it too (for
# `|safe`, `Markup(`, `render_template_string`), so a backend-only commit can
Expand Down Expand Up @@ -52,14 +39,36 @@ jobs:
working-directory: frontend
run: npm ci
- name: Lint
# eslint + the three checkers, chained by the package.json script.
# Currently 926 warnings / 0 errors, and eslint exits 0 on warnings —
# so this gates on errors only. If you ever want the warning count
# ratcheted the way backend/tests/BASELINE_COUNT ratchets test count,
# add --max-warnings=<N> here rather than mass-fixing in one commit.
# Reject errors and warning growth by file/rule, then run all integrity
# checks chained by package.json. Keep the baseline reviewed in Git.
working-directory: frontend
run: npm run lint
- name: Unit tests
# node --test over src/**/__tests__ — pure-logic modules, no jsdom.
working-directory: frontend
run: npm test
- name: Install browser for regressions
working-directory: frontend
run: npx playwright install --with-deps chromium
- name: Settings browser regressions
working-directory: frontend
run: npm run test:browser
- name: Shared controls browser regressions
working-directory: frontend
run: npm run test:controls
- name: Metrics and widget browser regressions
working-directory: frontend
run: npm run test:metrics
- name: Authentication and layout hooks browser regressions
working-directory: frontend
run: npm run test:hooks
- name: Backup request and form browser regressions
working-directory: frontend
run: npm run test:backups
- name: Upload Settings screenshots
if: always()
uses: actions/upload-artifact@v4
with:
name: settings-browser-screenshots
path: frontend/test-results/
if-no-files-found: ignore
42 changes: 42 additions & 0 deletions .github/workflows/measurements-ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
name: Measurement tools and README tables

on:
push:
branches: [dev]
paths:
- 'README.md'
- 'docs/README*.md'
- 'docs/measurements/**'
- 'scripts/measure-repository.py'
- 'scripts/update-readme-measurements.py'
- 'scripts/profile-api.py'
- 'scripts/test/test_repository_measurements.py'
- 'scripts/test/test_profile_api.py'
- '.github/workflows/measurements-ci.yml'
pull_request:
branches: [main]
paths:
- 'README.md'
- 'docs/README*.md'
- 'docs/measurements/**'
- 'scripts/measure-repository.py'
- 'scripts/update-readme-measurements.py'
- 'scripts/profile-api.py'
- 'scripts/test/test_repository_measurements.py'
- 'scripts/test/test_profile_api.py'
- '.github/workflows/measurements-ci.yml'

jobs:
measurements:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Test measurement tools (stdlib only)
run: |
python -m unittest discover -s scripts/test -p test_repository_measurements.py
python -m unittest discover -s scripts/test -p test_profile_api.py
- name: Check translated README tables against reviewed snapshots
run: python scripts/update-readme-measurements.py
71 changes: 50 additions & 21 deletions .github/workflows/security-scan.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,28 @@ on:
branches: [dev]
paths:
- 'backend/**'
- 'builtin-extensions/**'
- '**/requirements*.txt'
- 'frontend/package*.json'
- 'scripts/check-bandit-report.py'
- 'scripts/security/**'
- '.github/workflows/security-scan.yml'
pull_request:
branches: [main]
paths:
- 'backend/**'
- 'builtin-extensions/**'
- '**/requirements*.txt'
- 'frontend/package*.json'
- 'scripts/check-bandit-report.py'
- 'scripts/security/**'
- '.github/workflows/security-scan.yml'
schedule:
- cron: '17 9 * * 1' # Weekly; advisories change even without repository changes.
workflow_dispatch:

permissions:
contents: read

jobs:
# ──────────────────────────────────────────────────────────────────
Expand All @@ -28,30 +44,21 @@ jobs:
python-version: '3.11'

- name: Install Bandit
run: pip install bandit
run: pip install bandit==1.9.3

- name: Run Bandit scan
working-directory: backend
run: |
echo "## Full report (MEDIUM+ severity, MEDIUM+ confidence)"
echo ""
bandit -r app/ \
--severity-level medium \
--confidence-level medium \
-f txt \
--exit-zero
echo ""
echo "---"
echo ""
echo "## Strict gate (HIGH severity, HIGH confidence, new issues only)"
echo "Known accepts: B602 (shell=True for build/deploy scripts),"
echo " B402/B321 (FTP management feature), B202 (tarfile for backups)"
echo ""
bandit -r app/ \
--severity-level high \
--confidence-level high \
--skip B602,B402,B321,B202 \
-f txt
python -m unittest discover -s scripts/security -p 'test_*.py'
bandit -r backend/app builtin-extensions -f json -o bandit-report.json --exit-zero
python scripts/check-bandit-report.py bandit-report.json

- name: Upload full Bandit report
if: always()
uses: actions/upload-artifact@v4
with:
name: bandit-report
path: bandit-report.json
if-no-files-found: warn

# ──────────────────────────────────────────────────────────────────
# Job 2: pip-audit — dependency vulnerability scanning (the gate)
Expand Down Expand Up @@ -87,6 +94,28 @@ jobs:
- name: Audit test-harness requirements
run: pip-audit -r scripts/test/harness/requirements.txt --progress-spinner off

- name: Audit extension requirements
shell: bash
run: |
while IFS= read -r -d '' requirements; do
pip-audit -r "$requirements" --progress-spinner off
done < <(find builtin-extensions -type f -name 'requirements*.txt' -print0)

npm-audit:
name: Frontend Production Dependency Scan
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: npm
cache-dependency-path: frontend/package-lock.json
- name: Audit production dependency lockfile
working-directory: frontend
# No installation or package scripts are needed to audit the lockfile.
run: npm audit --package-lock-only --omit=dev --audit-level=high

# ──────────────────────────────────────────────────────────────────
# Job 3: Safety — advisory only
#
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/test-system-utils.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@ on:
- 'backend/tests/test_utils_system*.py'

# The mocked `unit-tests` job that used to lead this file was removed: it ran
# `pytest tests/test_utils_system.py` (44 tests), and Backend CI's bare
# `pytest -v` already collects that exact file — there is no pytest.ini,
# `pytest tests/test_utils_system.py`, and Backend CI's
# `pytest tests -v` already collects that exact file — there is no pytest.ini,
# addopts, or collect_ignore narrowing it. What is left here is the part
# Backend CI genuinely cannot do: exercise the package-manager detection
# against real apt/dnf inside real distro images.
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -222,3 +222,6 @@ frontend/src/plugins/serverkit-wordpress/

# Extension release signing keys (private halves — never commit; see scripts/sign-extension.mjs)
scripts/keys/

# Local/CI browser regression screenshots
frontend/test-results/
22 changes: 11 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,25 +44,25 @@ English | [Español](docs/README.es.md) | [中文版](docs/README.zh-CN.md) | [P

## 📊 By the Numbers

Everything below is measured from this repository, not estimated.
<!-- BEGIN GENERATED MEASUREMENTS -->
Snapshot: **2026-09-05**. [Definitions, raw measurements and reproduction commands](docs/METRICS.md).

| | |
|---|---|
| **1,350+** REST endpoints | across 109 blueprints — `/api/v1/*`, with OpenAPI + Swagger UI at `/api/v1/docs` |
| **118** one-click app templates | bundled in the repo, no registry account needed |
| **4,300+** backend tests | the whole suite runs on every change, with a CI-enforced floor on the collected count |
| **1.75 MB** gzipped web UI | 60+ screens, assets served from your own box — no CDN |
| **~180 MB** resident | the entire panel, single process — sits comfortably beside your apps on a 1 GB VPS |
| **501 MB** container image | or install straight onto the host; Docker is optional for the panel itself |
| **$0** | MIT-licensed. No tiers, no seat limits, no upsell — and nothing phones home. |
| **1,212** core route declarations | in **104** blueprint declarations under `backend/app/api`; source inventory, excluding extensions |
| **118** bundled app templates | root-level app YAML files; database extension templates counted separately |
| **5,089** backend test cases collected | clean-checkout collection; this is not a claim that every case passed or ran |
| **3.31 MB** total JS/CSS, gzipped | includes lazy chunks, locale bundles and vendor shims; excludes fonts and images |
| **$0** license cost | MIT-licensed, without subscription or seat fees |

Self-hosted and Docker-native, on hardware you already pay for.
The total sums files individually compressed with gzip at level 9; it is not a measured page-load time. RAM usage and image size depend on the build, platform and workload; no universal footprint is claimed.
<!-- END GENERATED MEASUREMENTS -->

---

## 🚀 Quick Start

> ⏱️ Up and running in under 2 minutes
> Installation time depends on the server, network and required packages.

### Option 1: One-Line Install (Recommended)

Expand Down Expand Up @@ -114,7 +114,7 @@ See the [Installation Guide](docs/INSTALLATION.md) for step-by-step instructions
| **Disk** | 10 GB | 20+ GB |
| **Docker** | 24.0+ (optional for the panel itself) | Latest |

> The panel itself only needs ~180 MB of RAM and ~500 MB of disk — the rest is headroom for your apps. It runs happily on a 1 GB VPS, a spare laptop, or a Raspberry Pi (ARM64), so it's as much at home in a homelab as it is on production hardware.
> These requirements are sizing guidance, not a capacity benchmark. Allow additional RAM and disk for managed apps, images, databases, logs and backups. Measure your own workload using the [measurement guide](docs/METRICS.md).

---

Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
1.9.25
1.9.28
44 changes: 25 additions & 19 deletions backend/app/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,26 @@
@jwt.user_identity_loader
def _user_identity(user_id):
return str(user_id)


@jwt.additional_claims_loader
def _session_claims(user_id):
import time
import secrets
from app.models import User
user = db.session.get(User, user_id)
session_id = secrets.token_hex(16)
return {'auth_version': user.auth_version if user else None,
'session_id': session_id,
'auth_time': int(time.time())}


@jwt.token_in_blocklist_loader
def _session_revoked(_header, claims):
from app.middleware.session_auth import validate_session_claims
return validate_session_claims(claims, token_type=claims.get('type')) is None


limiter = Limiter(key_func=get_remote_address, default_limits=["100 per minute"])
# Note: key_func is updated to get_rate_limit_key after app init
socketio = None
Expand Down Expand Up @@ -108,6 +128,8 @@ def create_app(config_name=None):

# Initialize extensions
db.init_app(app)
from app.middleware.request_profiling import register_request_profiling
register_request_profiling(app, db)
migrate.init_app(app, db)
jwt.init_app(app)
# Storage backend comes from app.config's RATELIMIT_STORAGE_URI when set
Expand Down Expand Up @@ -451,25 +473,9 @@ def _sqlite_tune(dbapi_connection, _record):
# Request body size limit
app.config['MAX_CONTENT_LENGTH'] = 100 * 1024 * 1024 # 100MB limit

# Reject 2FA pending tokens on non-2FA endpoints
@app.before_request
def check_2fa_pending():
"""Reject 2FA pending tokens on non-2FA endpoints."""
from flask_jwt_extended import verify_jwt_in_request, get_jwt
if request.endpoint and request.path.startswith('/api/'):
# Allow 2FA verification endpoints
if '/two-factor/verify' in request.path or '/two-factor/verify-backup' in request.path:
return
# Allow auth endpoints (login, refresh)
if '/auth/login' in request.path or '/auth/refresh' in request.path:
return
try:
verify_jwt_in_request()
claims = get_jwt()
if claims.get('2fa_pending'):
return jsonify({'error': '2FA verification required'}), 403
except Exception:
pass # Let @jwt_required handle actual auth errors
# JWTManager's blocklist callback enforces session validity for every JWT
# route. MFA verification alone decodes its body token explicitly, so no
# path-prefix exceptions can turn a pending token into a full session.

# Serve frontend for root path
@app.route('/')
Expand Down
Loading
Loading