diff --git a/.env.example b/.env.example index fe7deb145..bc5902484 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/.githooks/pre-commit b/.githooks/pre-commit index 7abe00e91..2b0ab8fd6 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -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. diff --git a/.github/workflows/backend-ci.yml b/.github/workflows/backend-ci.yml index 50bafc121..cb733df0b 100644 --- a/.github/workflows/backend-ci.yml +++ b/.github/workflows/backend-ci.yml @@ -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 @@ -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. diff --git a/.github/workflows/frontend-ci.yml b/.github/workflows/frontend-ci.yml index 513c349f7..b27b8f758 100644 --- a/.github/workflows/frontend-ci.yml +++ b/.github/workflows/frontend-ci.yml @@ -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 @@ -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= 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 diff --git a/.github/workflows/measurements-ci.yml b/.github/workflows/measurements-ci.yml new file mode 100644 index 000000000..9ba5c28df --- /dev/null +++ b/.github/workflows/measurements-ci.yml @@ -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 diff --git a/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml index 1861401c3..b96944412 100644 --- a/.github/workflows/security-scan.yml +++ b/.github/workflows/security-scan.yml @@ -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: # ────────────────────────────────────────────────────────────────── @@ -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) @@ -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 # diff --git a/.github/workflows/test-system-utils.yml b/.github/workflows/test-system-utils.yml index 133ac1ed6..bd21c4603 100644 --- a/.github/workflows/test-system-utils.yml +++ b/.github/workflows/test-system-utils.yml @@ -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. diff --git a/.gitignore b/.gitignore index aeefdab7a..065d10bf8 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/README.md b/README.md index 594c730a9..6cc1d0f47 100644 --- a/README.md +++ b/README.md @@ -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. + +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. + --- ## 🚀 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) @@ -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). --- diff --git a/VERSION b/VERSION index 96839476b..d5580fe11 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.9.25 +1.9.28 diff --git a/backend/app/__init__.py b/backend/app/__init__.py index eef513987..c5813a27e 100644 --- a/backend/app/__init__.py +++ b/backend/app/__init__.py @@ -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 @@ -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 @@ -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('/') diff --git a/backend/app/api/ai.py b/backend/app/api/ai.py index a99375bc6..a15f51e02 100644 --- a/backend/app/api/ai.py +++ b/backend/app/api/ai.py @@ -28,7 +28,8 @@ import threading from flask import Blueprint, Response, current_app, jsonify, request, stream_with_context -from flask_jwt_extended import jwt_required +from flask_jwt_extended import get_jwt, jwt_required +from werkzeug.exceptions import RequestEntityTooLarge, TooManyRequests from app import db from app.middleware.rbac import admin_required, get_current_user @@ -40,11 +41,52 @@ ) from app.services.ai_tool_registry import ai_tool_registry from app.error_reporting import unexpected_response +from app.exceptions import DependencyUnavailableError, ValidationError logger = logging.getLogger(__name__) ai_bp = Blueprint('ai', __name__) HEARTBEAT_SECONDS = 15 +MAX_CHAT_BODY_BYTES = 128 * 1024 +MAX_MESSAGE_CHARS = 16000 +MAX_CONTEXT_CHARS = 16000 +MAX_ACTIVE_TURNS = 8 +_turn_lock = threading.Lock() +_active_turns = set() + + +@ai_bp.before_request +def _bound_chat_body(): + if request.endpoint in ('ai.chat', 'ai.chat_stream'): + if request.content_length and request.content_length > MAX_CHAT_BODY_BYTES: + raise RequestEntityTooLarge('AI request is too large') + # Bound chunked bodies too, before JSON parsing allocates the payload. + request.max_content_length = MAX_CHAT_BODY_BYTES + + +def _claim_turn(user_id): + with _turn_lock: + if user_id in _active_turns or len(_active_turns) >= MAX_ACTIVE_TURNS: + return False + _active_turns.add(user_id) + return True + + +def _release_turn(user_id): + with _turn_lock: + _active_turns.discard(user_id) + + +def _validate_chat_data(data): + if not isinstance(data, dict) or not isinstance(data.get('message'), str): + return 'message must be a string' + if len(data['message']) > MAX_MESSAGE_CHARS: + return 'message is too long' + context = data.get('page_context') or {} + if not isinstance(context, dict) or len(json.dumps(context)) > MAX_CONTEXT_CHARS: + return 'page_context must be a small object' + if data.get('mode', 'assistant') not in ('assistant', 'simple'): + return 'Invalid assistant mode' # --------------------------------------------------------------------------- @@ -203,6 +245,7 @@ def tools(): 'qualified_name': d.qualified_name, 'name': d.name, 'description': d.description, 'plugin_slug': d.plugin_slug, 'rbac_feature': d.rbac_feature, 'rbac_level': d.rbac_level, 'is_write': d.is_write, + 'admin_only': d.admin_only, } for d in ai_tool_registry.all_descriptors() ]}) @@ -287,6 +330,9 @@ def chat(): if not ai_service.is_configured(): return jsonify({'error': 'AI assistant is not configured'}), 503 data = request.get_json(silent=True) or {} + invalid = _validate_chat_data(data) + if invalid: + raise ValidationError(invalid) message = (data.get('message') or '').strip() if not message: return jsonify({'error': 'message is required'}), 400 @@ -300,12 +346,24 @@ def chat(): if row is None: return jsonify({'error': 'Conversation not found'}), 404 - if ai_service.injection_flagged(message): - return jsonify({'error': 'Your message was flagged by the prompt-injection guardrail.'}), 400 + try: + if ai_service.injection_flagged(message): + raise ValidationError('Your message was flagged by the prompt-injection guardrail.') + safe_message = ai_service.redact_input(message) + except ai_service.AIProtectionError as exc: + raise DependencyUnavailableError(str(exc)) from exc + + if not _claim_turn(user.id): + raise TooManyRequests('AI is busy. Wait for the current turn to finish.') + try: + return _run_chat(row, user, mode, page_context, attachment_refs, message, safe_message) + finally: + _release_turn(user.id) + +def _run_chat(row, user, mode, page_context, attachment_refs, message, safe_message): attachment_result = resolve_attachments(user, attachment_refs) _persist_user_message(row, message, attachments=attachment_result['manifest']) - safe_message = ai_service.redact_input(message) try: # gate=None: write tools refuse (no interactive confirmation in this mode). conv = ai_service.build_conversation( @@ -313,6 +371,8 @@ def chat(): attachment_context=attachment_result['context'], ) reply = conv.ask(safe_message) + except ai_service.AIProtectionError as exc: + raise DependencyUnavailableError(str(exc)) from exc except Exception as exc: # noqa: BLE001 - reported, not swallowed return unexpected_response(exc) @@ -337,6 +397,9 @@ def chat_stream(): if not ai_service.is_configured(): return jsonify({'error': 'AI assistant is not configured'}), 503 data = request.get_json(silent=True) or {} + invalid = _validate_chat_data(data) + if invalid: + raise ValidationError(invalid) message = (data.get('message') or '').strip() if not message: return jsonify({'error': 'message is required'}), 400 @@ -361,21 +424,35 @@ def chat_stream(): attachment_result = resolve_attachments(user, attachment_refs) _persist_user_message(row, message, attachments=attachment_result['manifest']) - flagged = ai_service.injection_flagged(message) - safe_message = ai_service.redact_input(message) + try: + flagged = ai_service.injection_flagged(message) + safe_message = ai_service.redact_input(message) + except ai_service.AIProtectionError as exc: + raise DependencyUnavailableError(str(exc)) from exc + if not _claim_turn(user_id): + raise TooManyRequests('AI is busy. Wait for the current turn to finish.') + claims = dict(get_jwt()) q: "queue.Queue" = queue.Queue(maxsize=512) cancel_event = threading.Event() def emit(event_name: str, payload: dict) -> None: - q.put(('frame', (event_name, payload))) + enqueue(('frame', (event_name, payload))) + + def enqueue(item): + while not cancel_event.is_set(): + try: + q.put(item, timeout=0.25) + return + except queue.Full: + continue gate = ai_service.ConfirmationGate(conversation_id, user_id, emit, cancel_event, ttl) + gate.session_claims = claims ai_service.register_gate(conversation_id, gate) def producer(): with app.app_context(): - from app.models.user import User acc_text: list[str] = [] tool_calls: dict[str, dict] = {} tool_order: list[str] = [] @@ -387,7 +464,11 @@ def producer(): return conv_row = db.session.get(AiConversation, conversation_id) - conv_user = db.session.get(User, user_id) + from app.middleware.session_auth import validate_session_claims + conv_user = validate_session_claims(claims) + if conv_user is None or conv_row is None: + emit('error', {'message': 'Your session is no longer authorized.'}) + return conv = ai_service.build_conversation( conv_row, conv_user, mode, page_context, gate, attachment_context=attachment_result['context'], @@ -423,9 +504,10 @@ def producer(): emit(name, payload) except Exception as exc: logger.exception("AI stream worker failed") - emit('error', {'message': str(exc)}) + emit('error', {'message': str(exc) if isinstance(exc, ai_service.AIProtectionError) + else 'AI request failed. Please try again.'}) finally: - ai_service.unregister_gate(conversation_id) + ai_service.unregister_gate(conversation_id, gate) try: text = ''.join(acc_text) if text or tool_order: @@ -440,18 +522,25 @@ def producer(): ai_service.persist_conversation(conv_row, conv, page_context=page_context) except Exception: logger.warning("Failed to persist assistant turn", exc_info=True) - q.put(('frame', ('done', {'conversation_id': conversation_id, 'usage': last_usage}))) - q.put(('end', None)) + emit('done', {'conversation_id': conversation_id, 'usage': last_usage}) + enqueue(('end', None)) + _release_turn(user_id) - threading.Thread(target=producer, daemon=True).start() + try: + threading.Thread(target=producer, daemon=True).start() + except Exception: + gate.cancel_all() + ai_service.unregister_gate(conversation_id, gate) + _release_turn(user_id) + raise @stream_with_context def gen(): - yield _sse('open', {'conversation_id': conversation_id}) - for warning in attachment_result['warnings']: - yield _sse('attachment_warning', warning) try: - while True: + yield _sse('open', {'conversation_id': conversation_id}) + for warning in attachment_result['warnings']: + yield _sse('attachment_warning', warning) + while not cancel_event.is_set(): try: kind, payload = q.get(timeout=HEARTBEAT_SECONDS) except queue.Empty: @@ -464,7 +553,7 @@ def gen(): finally: cancel_event.set() gate.cancel_all() - ai_service.unregister_gate(conversation_id) + ai_service.unregister_gate(conversation_id, gate) return Response(gen(), mimetype='text/event-stream', headers={ 'Cache-Control': 'no-cache', @@ -487,7 +576,7 @@ def chat_confirm(): return jsonify({'error': 'Conversation not found'}), 404 # Validate the pending action belongs to this user and is still actionable. pending = db.session.get(AiPendingAction, token) - if pending is None or pending.user_id != user.id: + if pending is None or pending.user_id != user.id or pending.conversation_id != conversation_id: return jsonify({'error': 'Pending action not found'}), 404 if pending.status != AiPendingAction.STATUS_PENDING or pending.is_expired(): return jsonify({'error': 'Pending action is no longer actionable'}), 409 diff --git a/backend/app/api/apps.py b/backend/app/api/apps.py index e2fe5b858..e0f3a78d3 100644 --- a/backend/app/api/apps.py +++ b/backend/app/api/apps.py @@ -4,7 +4,6 @@ import os import json import re -import shutil from datetime import datetime from flask import Blueprint, request, jsonify, current_app from flask_jwt_extended import jwt_required, get_jwt_identity @@ -21,11 +20,13 @@ from app.services.remote_docker_service import RemoteDockerService from app.services.container_registry_service import ContainerRegistryService from app.services.image_update_service import ImageUpdateService -from app.services import container_status_service +from app.services import container_status_service, application_lifecycle_service, repository_application_service +from app.services.application_lifecycle_service import ( + _compose_target, _local_compose_file, _agent_result_failed, + _agent_result_error, _assert_managed_app_path, +) from app.services.container_sleep_service import ContainerSleepService from app.services.container_scale_service import ContainerScaleService -from app.services.unit_compose_service import UnitComposeService -from app.services.app_port_service import AppPortService from app.services.log_service import LogService from app.services.process_service import ProcessService from app.services.backup_policy_service import BackupPolicyService, BackupPolicyError @@ -48,18 +49,6 @@ apps_bp = Blueprint('apps', __name__) -def _compose_target(app): - """Return the effective compose file path for remote agent deployments.""" - if app.server_id and app.root_path: - return os.path.join(app.root_path, app.compose_file or 'docker-compose.yml') - return os.path.join(app.root_path, app.compose_file or 'docker-compose.yml') if app.root_path else None - - -def _local_compose_file(app): - """Return the compose file to pass to DockerService for local deployments.""" - return app.compose_file if app.compose_file else None - - def _remove_data_flag(app): """Should ``DELETE /apps/`` take the data volumes down with the app? @@ -99,18 +88,6 @@ def _sync_manual_app_status(app): db.session.commit() -def _agent_result_failed(result): - data = result.get('data') if isinstance(result, dict) else None - return isinstance(data, dict) and data.get('success') is False - - -def _agent_result_error(result, fallback): - data = result.get('data') if isinstance(result, dict) else None - if isinstance(data, dict): - return data.get('error') or result.get('error') or fallback - return result.get('error') or fallback - - def _service_slug(value): return slugify(value) @@ -129,91 +106,6 @@ def _derive_repo_app_type(detection): return 'static' -def _assert_managed_app_path(app_name): - base_dir = os.path.abspath(paths.APPS_DIR) - app_path = os.path.abspath(os.path.join(base_dir, app_name)) - if app_path != base_dir and app_path.startswith(base_dir + os.sep): - return app_path - raise ValueError('Invalid application path') - - -def _is_single_container_app(app): - """True when this app's deploy produced one container, not a compose project. - - ``app_type == 'docker'`` is not the same as "compose-managed". The build - pack deploys by building an image and running a single container - (``DeploymentService._deploy_docker``, named ``serverkit-app-``) and - nothing in that path ever writes a compose file, so ``compose_file`` stays - NULL. Handing that directory to ``docker compose`` fails with "no - configuration file provided: not found" -- which is what start, stop and - restart did for every build-pack app. - - Deliberately narrow, and phrased as a positive test rather than "has no - compose file": compose stays the default for everything, including an app - whose compose file has not been rendered yet. Only a local build-pack app - with no compose file recorded and none on disk takes the container path. - """ - if app.server_id or app.compose_file or not app.root_path: - return False - if not app.buildpack_type: - return False - return not any( - os.path.isfile(os.path.join(app.root_path, name)) - for name in ('docker-compose.yml', 'docker-compose.yaml', - 'compose.yml', 'compose.yaml') - ) - - -def _app_container_name(app): - """The single container a build-pack deploy creates for this app. - - Must match ``DeploymentService._deploy_docker``, which names it - ``serverkit-app-``. - """ - return f'serverkit-app-{app.id}' - - -def _ensure_local_image_compose(app): - """Materialize a compose project for a local docker app that carries a - ``docker_image`` but has no source on disk yet. - - A BYO-image app (e.g. an ``image:`` manifest service with no repository) - is created with ``docker_image`` set but ``root_path``/``compose_file`` - NULL, so it has nothing to launch. Render a one-service compose from the - image and its typed ports, write it under APPS_DIR, and persist the paths; - the normal ``compose_up`` overlay then injects the app's effective env - (including resolved vault secrets), so nothing sensitive is written here. - - No-op when a source already exists, the app has no image, or it targets a - remote server (the file must live where the deploy actually runs). - """ - if app.root_path or not app.docker_image or app.server_id: - return - app_path = _assert_managed_app_path(app.name) - os.makedirs(app_path, exist_ok=True) - - container = {'name': 'app', 'image': app.docker_image} - ports = AppPortService.get_ports(app) - if not ports and app.port: - # Bind the legacy scalar port to loopback — nginx fronts it; publishing - # 0.0.0.0 (AppPortService._clean's default) would expose it past nginx. - ports = [{'host_port': app.port, 'container_port': app.port, 'expose': 'local'}] - if ports: - container['ports'] = ports - if app.healthcheck_path: - container['health_check'] = {'http_path': app.healthcheck_path} - - compose_yaml = UnitComposeService.render_yaml(app.name, [container]) - with open(os.path.join(app_path, 'docker-compose.yml'), 'w') as f: - f.write(compose_yaml) - - app.root_path = app_path - app.compose_file = 'docker-compose.yml' - if not app.managed_by: - app.managed_by = 'docker_compose' - db.session.commit() - - def _safe_repo_url(repo_url): return re.sub(r'^(https?://)[^@]+@', r'\1', repo_url or '') @@ -961,92 +853,36 @@ def create_app_from_repository(): ) try: - db.session.add(app) - db.session.commit() - - deploy_result = GitService.configure_deployment( - app_id=app.id, - app_path=app.root_path, - repo_url=deploy_repo_url, - branch=branch or 'main', - auto_deploy=auto_deploy, - ) - if not deploy_result.get('success'): - raise RuntimeError(deploy_result.get('error', 'Failed to configure deployment')) - - build_result = BuildService.configure_build( - app_id=app.id, - app_path=app.root_path, - build_method=resolved_build_method, - dockerfile_path=dockerfile_path, - custom_build_cmd=custom_build_cmd, - custom_start_cmd=custom_start_cmd, - buildpack_plan=buildpack_plan, - buildpack_overrides=buildpack_overrides, + created = repository_application_service.finalize_repository_application( + app, user_id=user.id, repo_url=deploy_repo_url, branch=branch or 'main', + auto_deploy=auto_deploy, manifest=manifest, + build_options={ + 'build_method': resolved_build_method, + 'dockerfile_path': dockerfile_path, + 'custom_build_cmd': custom_build_cmd, + 'custom_start_cmd': custom_start_cmd, + 'buildpack_plan': buildpack_plan, + 'buildpack_overrides': buildpack_overrides, + }, ) - if not build_result.get('success'): - raise RuntimeError(build_result.get('error', 'Failed to configure build')) - - # Stop dropping what we detect: persist the manifest, seed non-secret - # env values, and record the health-check path (plan 17, Phase 1). - manifest_summary = None - try: - from app.services.manifest_persistence_service import ManifestPersistenceService - manifest_summary = ManifestPersistenceService.apply_import( - app, manifest, user_id=user.id, - source_repo=deploy_repo_url, source_ref=branch or 'main', - ) - except Exception: - manifest_summary = None - - # Actually deploy + start the service: hand it to the same observable - # DeploymentJob pipeline template installs use (kind 'app_deploy' → - # unified job 'deploy.app' → DeploymentService.deploy). A queue failure - # must NOT fail app creation — the service stays 'stopped' and the user - # can deploy manually from the Builds tab. - deploy_job_id = None - try: - from app.services.deployment_job_service import DeploymentJobService - enqueue_result = DeploymentJobService.enqueue_app_deploy( - app, user_id=user.id, trigger='install') - if enqueue_result.get('success'): - deploy_job_id = enqueue_result.get('job_id') - else: - current_app.logger.warning( - 'app deploy enqueue failed for app %s: %s', - app.id, enqueue_result.get('error')) - except Exception as exc: - current_app.logger.warning( - 'app deploy enqueue failed for app %s: %s', app.id, exc) return jsonify({ 'message': 'Repository service created', 'app': _attach_deploy_config(app.to_dict(include_linked=True)), - 'deploy_job_id': deploy_job_id, - 'manifest_import': manifest_summary, + 'deploy_job_id': created['deploy_job_id'], + 'manifest_import': created['manifest_import'], 'deploy_config': { 'repo_url': _safe_repo_url(deploy_repo_url), 'branch': branch or 'main', 'auto_deploy': auto_deploy, - 'webhook_url': deploy_result.get('webhook_url'), + 'webhook_url': created['deploy_result'].get('webhook_url'), }, - 'build_config': build_result.get('config'), + 'build_config': created['build_config'], 'detection': detection, 'manifest': manifest, }), 201 except Exception as exc: - db.session.rollback() - if app.id: - GitService.remove_deployment(app.id) - # Unwinding a create that failed, not deleting an app someone made: - # a hard delete (no query_active, no tombstone) is what belongs - # here — a half-created service must not land in the recycle bin. - existing_app = Application.query.get(app.id) - if existing_app: - db.session.delete(existing_app) - db.session.commit() - if os.path.abspath(app_path).startswith(os.path.abspath(paths.APPS_DIR) + os.sep): - shutil.rmtree(app_path, ignore_errors=True) + repository_application_service.abort_repository_creation(app) return jsonify({'error': str(exc)}), 400 @@ -1544,51 +1380,10 @@ def start_app(app_id): if not _can_edit_app(user, app): return jsonify({'error': 'Access denied'}), 403 - # A local BYO-image docker app (image set, no source yet) has no compose to - # launch — materialize one on first start so it can actually run. - if app.app_type == 'docker' and not app.root_path and app.docker_image and not app.server_id: - try: - _ensure_local_image_compose(app) - except ValueError as e: - return jsonify({'error': str(e)}), 400 - - # Handle Docker apps - if app.app_type == 'docker' and app.root_path: - if app.server_id: - result = RemoteDockerService.compose_up( - app.server_id, - _compose_target(app), - detach=True, - user_id=current_user_id - ) - elif _is_single_container_app(app): - # Build-pack app: one container, created by the deploy. - container = _app_container_name(app) - if not DockerService.get_container(container): - return jsonify({'error': 'This application has not been deployed yet. ' - 'Run a deploy to build its image and create ' - 'the container.'}), 400 - result = DockerService.start_container(container) - else: - # Authenticate a bound private registry before compose pulls the - # image; best-effort, always logs back out. No-op without registry_id. - _registry = ContainerRegistryService.login_for_app(app) - try: - result = DockerService.compose_up( - app.root_path, - detach=True, - compose_file=_local_compose_file(app) - ) - finally: - ContainerRegistryService.logout_for_app(_registry) - if not result.get('success') or _agent_result_failed(result): - return jsonify({'error': _agent_result_error(result, 'Failed to start containers')}), 400 - - app.status = 'running' - db.session.commit() - # The cached aggregate now describes the pre-start world. Drop it: a status - # pill that survives the action that changed it reads as a broken panel. - container_status_service.invalidate(app_id) + try: + application_lifecycle_service.start_application(app, user_id=current_user_id) + except application_lifecycle_service.ApplicationLifecycleError as exc: + return jsonify({'error': str(exc)}), 400 return jsonify({ 'message': 'Application started', @@ -1997,31 +1792,10 @@ def stop_app(app_id): if not _can_edit_app(user, app): return jsonify({'error': 'Access denied'}), 403 - # Handle Docker apps - if app.app_type == 'docker' and app.root_path: - if app.server_id: - result = RemoteDockerService.compose_down( - app.server_id, - _compose_target(app), - user_id=current_user_id - ) - elif _is_single_container_app(app): - # A container that no longer exists is already stopped -- reporting - # that as a failure would strand the app in `running` forever. - container = _app_container_name(app) - result = ({'success': True} if not DockerService.get_container(container) - else DockerService.stop_container(container)) - else: - result = DockerService.compose_down( - app.root_path, - compose_file=_local_compose_file(app) - ) - if not result.get('success') or _agent_result_failed(result): - return jsonify({'error': _agent_result_error(result, 'Failed to stop containers')}), 400 - - app.status = 'stopped' - db.session.commit() - container_status_service.invalidate(app_id) + try: + application_lifecycle_service.stop_application(app, user_id=current_user_id) + except application_lifecycle_service.ApplicationLifecycleError as exc: + return jsonify({'error': str(exc)}), 400 return jsonify({ 'message': 'Application stopped', @@ -2042,32 +1816,10 @@ def restart_app(app_id): if not _can_edit_app(user, app): return jsonify({'error': 'Access denied'}), 403 - # Handle Docker apps - if app.app_type == 'docker' and app.root_path: - if app.server_id: - result = RemoteDockerService.compose_restart( - app.server_id, - _compose_target(app), - user_id=current_user_id - ) - elif _is_single_container_app(app): - container = _app_container_name(app) - if not DockerService.get_container(container): - return jsonify({'error': 'This application has not been deployed yet. ' - 'Run a deploy to build its image and create ' - 'the container.'}), 400 - result = DockerService.restart_container(container) - else: - result = DockerService.compose_restart( - app.root_path, - compose_file=_local_compose_file(app) - ) - if not result.get('success') or _agent_result_failed(result): - return jsonify({'error': _agent_result_error(result, 'Failed to restart containers')}), 400 - - app.status = 'running' - db.session.commit() - container_status_service.invalidate(app_id) + try: + application_lifecycle_service.restart_application(app, user_id=current_user_id) + except application_lifecycle_service.ApplicationLifecycleError as exc: + return jsonify({'error': str(exc)}), 400 return jsonify({ 'message': 'Application restarted', diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py index b31205996..975c78c94 100644 --- a/backend/app/api/auth.py +++ b/backend/app/api/auth.py @@ -1,10 +1,9 @@ import logging -from datetime import datetime -from flask import Blueprint, request, jsonify +from datetime import datetime, timedelta +from flask import Blueprint, request, jsonify, g from sqlalchemy import func from flask_jwt_extended import ( create_access_token, - create_refresh_token, jwt_required, get_jwt_identity, get_jwt @@ -13,6 +12,7 @@ from app.models import User, AuditLog, SystemSettings # Aliased: this module already has a `get_current_user` route handler. from app.middleware.rbac import admin_required, get_current_user as get_request_user, require_admin_user +from app.middleware.session_auth import session_required, issue_session_tokens from app.services.settings_service import SettingsService from app.services.audit_service import AuditService from app.services import login_link_service @@ -154,8 +154,7 @@ def register(): ) db.session.commit() - access_token = create_access_token(identity=user.id) - refresh_token = create_refresh_token(identity=user.id) + access_token, refresh_token = issue_session_tokens(user.id) return jsonify({ 'message': 'User registered successfully', @@ -291,7 +290,7 @@ def login(): temp_token = create_access_token( identity=user.id, additional_claims={'2fa_pending': True}, - expires_delta=False # Use default (short) expiry + expires_delta=timedelta(minutes=5) ) return jsonify({ @@ -309,8 +308,7 @@ def login(): AuditService.log_login(user.id, success=True) db.session.commit() - access_token = create_access_token(identity=user.id) - refresh_token = create_refresh_token(identity=user.id) + access_token, refresh_token = issue_session_tokens(user.id) return jsonify({ 'user': user.to_dict(), @@ -323,10 +321,10 @@ def login(): # ONE-TIME LOGIN LINKS # ========================================== @auth_bp.route('/login-links', methods=['POST']) -@admin_required +@session_required def create_login_link(): """Mint a single-use login URL. The raw token is returned exactly once.""" - current = get_request_user() + current = require_admin_user() data = request.get_json() or {} target_id = data.get('user_id') or current.id @@ -413,6 +411,14 @@ def redeem_login_link(): raise AuthenticationError('Invalid or expired link', code='auth.link_invalid') auth_throttle_service.reset(client_ip) + if user.totp_enabled: + return jsonify({ + 'requires_2fa': True, + 'temp_token': create_access_token( + identity=user.id, additional_claims={'2fa_pending': True}, + expires_delta=timedelta(minutes=5)), + 'message': 'Two-factor authentication required', + }), 200 user.reset_failed_login() user.last_login_at = datetime.utcnow() db.session.commit() @@ -420,8 +426,7 @@ def redeem_login_link(): AuditService.log_login(user.id, success=True, details={'method': 'login_link'}) db.session.commit() - access_token = create_access_token(identity=user.id) - refresh_token = create_refresh_token(identity=user.id) + access_token, refresh_token = issue_session_tokens(user.id) return jsonify({ 'user': user.to_dict(), @@ -481,13 +486,27 @@ def refresh(): if not user or not user.is_active: return jsonify({'error': 'Invalid user'}), 401 - access_token = create_access_token(identity=current_user_id) + access_token = create_access_token( + identity=current_user_id, + additional_claims={'auth_time': get_jwt().get('auth_time', 0), + 'session_id': get_jwt()['session_id']}) return jsonify({ 'access_token': access_token }), 200 +@auth_bp.route('/logout', methods=['POST']) +@session_required +def logout(): + """Revoke this browser's access and refresh session, across workers.""" + from app.models import RevokedSession + db.session.add(RevokedSession(session_id=get_jwt()['session_id'], + user_id=g.session_user.id)) + db.session.commit() + return jsonify({'message': 'Browser session signed out'}), 200 + + @auth_bp.route('/me', methods=['GET']) @jwt_required() def get_current_user(): @@ -527,7 +546,23 @@ def update_current_user(): if not user: return jsonify({'error': 'User not found'}), 404 - data = request.get_json() + data = request.get_json() or {} + + if 'password' in data: + import time + if not isinstance(data['password'], str) or len(data['password']) < 8: + raise ValidationError('Password must be at least 8 characters') + if user.has_password: + current_password = data.get('current_password') + if (not isinstance(current_password, str) + or not user.check_password(current_password)): + raise PermissionDeniedError('Current password is required and must be correct') + else: + # SSO/passkey users without a local password must have completed a + # fresh sign-in. Refresh preserves auth_time and cannot renew it. + auth_time = get_jwt().get('auth_time', 0) + if not isinstance(auth_time, (int, float)) or time.time() - auth_time > 300: + raise PermissionDeniedError('Sign in again before setting a password') if 'username' in data: existing = User.query.filter_by(username=data['username']).first() @@ -544,8 +579,6 @@ def update_current_user(): user.email = data['email'] if 'password' in data: - if len(data['password']) < 8: - return jsonify({'error': 'Password must be at least 8 characters'}), 400 user.set_password(data['password']) if 'sidebar_config' in data: @@ -573,7 +606,13 @@ def update_current_user(): db.session.commit() - return jsonify({'user': user.to_dict()}), 200 + response = {'user': user.to_dict()} + if 'password' in data: + # Keep this freshly authenticated browser signed in; every prior + # access/refresh token (including its old pair) has been revoked. + access_token, refresh_token = issue_session_tokens(user.id) + response.update(access_token=access_token, refresh_token=refresh_token) + return jsonify(response), 200 # ========================================== @@ -668,8 +707,7 @@ def passkey_authenticate(): AuditService.log_login(user.id, success=True, details={'method': 'passkey'}) db.session.commit() - access_token = create_access_token(identity=user.id) - refresh_token = create_refresh_token(identity=user.id) + access_token, refresh_token = issue_session_tokens(user.id) return jsonify({ 'user': user.to_dict(), diff --git a/backend/app/api/backups.py b/backend/app/api/backups.py index a00b3182d..f13bd008e 100644 --- a/backend/app/api/backups.py +++ b/backend/app/api/backups.py @@ -474,8 +474,9 @@ def cleanup_backups(): @admin_required def list_schedules(): """List backup schedules.""" + from app.services.backup_schedule_service import server_timezone schedules = BackupService.get_schedules() - return jsonify({'schedules': schedules}), 200 + return jsonify({'schedules': schedules, 'timezone': str(server_timezone())}), 200 @backups_bp.route('/schedules', methods=['POST']) diff --git a/backend/app/api/runs.py b/backend/app/api/runs.py index 6af9a45fd..e53a95084 100644 --- a/backend/app/api/runs.py +++ b/backend/app/api/runs.py @@ -8,7 +8,8 @@ """ from flask import Blueprint, jsonify, request -from app.middleware.rbac import auth_required +from app.middleware.rbac import auth_required, get_current_user +from app.services.run_access import can_read_run from app.services.run_log_service import list_run_logs runs_bp = Blueprint('runs', __name__) @@ -17,5 +18,7 @@ @runs_bp.route('///logs', methods=['GET']) @auth_required() def get_run_logs(run_kind, run_id): + if not can_read_run(get_current_user(), run_kind, run_id): + return jsonify({'error': 'Run not found or access denied'}), 403 after_id = request.args.get('after_id', type=int) return jsonify({'logs': list_run_logs(run_kind, run_id, after_id=after_id)}), 200 diff --git a/backend/app/api/servers.py b/backend/app/api/servers.py index 0a4ee3cf6..d8cda7a6d 100644 --- a/backend/app/api/servers.py +++ b/backend/app/api/servers.py @@ -15,6 +15,7 @@ from sqlalchemy.orm import joinedload from app import db, limiter +from app.services.server_metrics_service import latest_metrics_by_server from app.api._query import apply_query, QueryParseError from app.models import User from app.models.server import Server, ServerGroup, ServerMetrics, ServerCommand, AgentSession, AgentVersion, AgentRollout @@ -301,25 +302,10 @@ def list_servers(): servers = (query.all() if request.args.get('$orderby') else query.order_by(Server.name).all()) - # Latest metrics per server, under the SAME 'metrics' key the detail - # endpoint uses (see get_server_status below) — the servers list renders - # CPU/Memory/Disk gauges from it, and without it every one of those cells - # fell back to the "no data" dash on a live panel. - # - # Batched deliberately: `server.metrics` is lazy='dynamic', so doing this - # per row inside the loop would be one extra SELECT per server. Newest row - # per server = highest id, because the id autoincrements on insert — that - # also avoids the tie a max(timestamp) join would hit for same-second rows. - metrics_by_server = {} - if servers: - server_ids = [s.id for s in servers] - newest = ( - db.session.query(db.func.max(ServerMetrics.id)) - .filter(ServerMetrics.server_id.in_(server_ids)) - .group_by(ServerMetrics.server_id) - ) - for row in ServerMetrics.query.filter(ServerMetrics.id.in_(newest)).all(): - metrics_by_server[row.server_id] = row.to_dict() + # Keep the list's insertion-order contract; monitoring uses sample time. + metrics_by_server = latest_metrics_by_server( + (server.id for server in servers), order_by='id', + ) result = [] for server in servers: @@ -327,7 +313,7 @@ def list_servers(): server_dict['is_connected'] = agent_registry.is_agent_connected(server.id) metrics = metrics_by_server.get(server.id) if metrics: - server_dict['metrics'] = metrics + server_dict['metrics'] = metrics.to_dict() result.append(server_dict) return jsonify(result) @@ -930,7 +916,8 @@ def compare_server_metrics(): @jwt_required() def get_servers_overview(): """Get overview of all servers health""" - servers = Server.query.all() + servers = Server.query.options(joinedload(Server.group)).all() + metrics_by_server = latest_metrics_by_server(server.id for server in servers) connected_ids = set(agent_registry.get_connected_servers()) total = len(servers) @@ -945,7 +932,7 @@ def get_servers_overview(): is_online = server.id in connected_ids # Get latest metrics - latest = server.metrics.order_by(ServerMetrics.timestamp.desc()).first() + latest = metrics_by_server.get(server.id) server_summary = { 'id': server.id, diff --git a/backend/app/api/sso.py b/backend/app/api/sso.py index 85a99dfd5..223a4463c 100644 --- a/backend/app/api/sso.py +++ b/backend/app/api/sso.py @@ -1,8 +1,8 @@ """SSO / OAuth API blueprint.""" -from datetime import datetime +from datetime import datetime, timedelta from flask import Blueprint, request, jsonify, session from flask_jwt_extended import ( - create_access_token, create_refresh_token, jwt_required, get_jwt_identity + create_access_token, jwt_required, get_jwt_identity ) from app import db from app.models import AuditLog @@ -11,6 +11,7 @@ from app.services.settings_service import SettingsService from app.services.audit_service import AuditService from app.middleware.rbac import admin_required, get_current_user +from app.middleware.session_auth import issue_session_tokens from app.error_reporting import record_unexpected, unexpected_response sso_bp = Blueprint('sso', __name__) @@ -290,7 +291,7 @@ def _complete_sso_login(user, provider, is_new): temp_token = create_access_token( identity=user.id, additional_claims={'2fa_pending': True}, - expires_delta=False, + expires_delta=timedelta(minutes=5), ) return jsonify({ 'requires_2fa': True, @@ -305,8 +306,7 @@ def _complete_sso_login(user, provider, is_new): AuditService.log_login(user.id, success=True, details={'provider': provider, 'is_new': is_new}) db.session.commit() - access_token = create_access_token(identity=user.id) - refresh_token = create_refresh_token(identity=user.id) + access_token, refresh_token = issue_session_tokens(user.id) return jsonify({ 'user': user.to_dict(), diff --git a/backend/app/api/two_factor.py b/backend/app/api/two_factor.py index 062d0456c..fbb81696e 100644 --- a/backend/app/api/two_factor.py +++ b/backend/app/api/two_factor.py @@ -8,6 +8,7 @@ from flask import Blueprint, request, jsonify from flask_jwt_extended import jwt_required, get_jwt_identity from app import limiter +from app.middleware.session_auth import issue_session_tokens from app.models import User from app.services.totp_service import TOTPService, TwoFactorSetup from app.services import auth_throttle_service @@ -171,7 +172,7 @@ def verify_2fa_code(): This endpoint is used when login returns requires_2fa=true. Expects a temporary token from the login response. """ - from flask_jwt_extended import create_access_token, create_refresh_token, decode_token + from flask_jwt_extended import decode_token from app import db # Per-IP brute-force throttle — 2FA codes are a small keyspace, so guessing @@ -205,9 +206,10 @@ def verify_2fa_code(): except Exception as e: return jsonify({'error': 'Invalid or expired token'}), 401 - user = User.query.get(user_id) + from app.middleware.session_auth import validate_session_claims + user = validate_session_claims(token_data, allow_pending=True) if not user: - return jsonify({'error': 'User not found'}), 404 + return jsonify({'error': 'Invalid or expired token'}), 401 if not user.totp_enabled: return jsonify({'error': '2FA is not enabled for this account'}), 400 @@ -219,8 +221,7 @@ def verify_2fa_code(): user.reset_failed_login() db.session.commit() - access_token = create_access_token(identity=user.id) - refresh_token = create_refresh_token(identity=user.id) + access_token, refresh_token = issue_session_tokens(user.id) return jsonify({ 'user': user.to_dict(), @@ -239,8 +240,7 @@ def verify_2fa_code(): user.reset_failed_login() db.session.commit() - access_token = create_access_token(identity=user.id) - refresh_token = create_refresh_token(identity=user.id) + access_token, refresh_token = issue_session_tokens(user.id) # Warn about remaining backup codes remaining = len(user.get_backup_codes()) diff --git a/backend/app/middleware/api_key_auth.py b/backend/app/middleware/api_key_auth.py index bd8538068..754eee581 100644 --- a/backend/app/middleware/api_key_auth.py +++ b/backend/app/middleware/api_key_auth.py @@ -8,6 +8,10 @@ def register_api_key_auth(app): @app.before_request def authenticate_api_key(): """Check for X-API-Key header and validate.""" + # Keep credentials request-local even when an embedding application + # deliberately keeps its Flask application context across requests. + g.pop('api_key', None) + g.pop('api_key_user', None) api_key_header = request.headers.get('X-API-Key') if not api_key_header: @@ -19,6 +23,11 @@ def authenticate_api_key(): if not api_key: return jsonify({'error': 'Invalid or expired API key'}), 401 + from app.middleware.api_scope_middleware import enforce_request_scope + denied = enforce_request_scope(api_key) + if denied is not None: + return denied + # Record usage against the trusted client IP (see app.utils.client_ip). from app.utils.client_ip import get_client_ip api_key.record_usage(get_client_ip()) diff --git a/backend/app/middleware/api_scope_middleware.py b/backend/app/middleware/api_scope_middleware.py index b43e3abff..e51cb352c 100644 --- a/backend/app/middleware/api_scope_middleware.py +++ b/backend/app/middleware/api_scope_middleware.py @@ -13,7 +13,7 @@ """ from functools import wraps -from flask import g, jsonify +from flask import current_app, g, jsonify, request # --------------------------------------------------------------------------- @@ -78,6 +78,26 @@ SCOPE_KEYS = {entry['key'] for entry in SCOPES} +def enforce_request_scope(api_key): + """Fail closed for restricted keys when a route has no scope policy. + + Role checks alone cannot constrain an administrator-owned read-only key. + Legacy empty scope lists, like '*', retain their existing full-access + meaning; both still pass through the route's authentication/RBAC policy. + """ + assigned = api_key.get_scopes() + if not assigned or FULL_ACCESS_SCOPE in assigned: + return None + view = current_app.view_functions.get(request.endpoint) + required = getattr(view, '_sk_api_scopes', ()) if view else () + if not required: + return jsonify({'error': 'This endpoint does not allow restricted API keys'}), 403 + for scope in required: + if not api_key.has_scope(scope): + return jsonify({'error': f'Insufficient API key scope: {scope}'}), 403 + return None + + def require_scope(*scopes): """Enforce that an API-key request carries ALL of ``scopes``. @@ -113,5 +133,9 @@ def wrapper(*args, **kwargs): }), 403 # JWT/session requests pass through (governed by RBAC). return fn(*args, **kwargs) + # functools.wraps preserves this metadata through outer RBAC wrappers. + # Merge nested declarations: a later decorator may only add constraints. + wrapper._sk_api_scopes = tuple(dict.fromkeys( + (*getattr(fn, '_sk_api_scopes', ()), *scopes))) return wrapper return decorator diff --git a/backend/app/middleware/request_profiling.py b/backend/app/middleware/request_profiling.py new file mode 100644 index 000000000..570fb0c3a --- /dev/null +++ b/backend/app/middleware/request_profiling.py @@ -0,0 +1,66 @@ +"""Opt-in request/SQL costs, without SQL text, parameters or persistent storage.""" + +from time import perf_counter_ns + +from flask import g, has_request_context, request +from sqlalchemy import event + + +def register_request_profiling(app, database): + """Attach only when enabled; normal deployments install no query listeners. + + Counts SQLAlchemy statements on the request thread, including failed SQL. + Streaming body iteration and background jobs occur outside this interval. + """ + if not app.config.get('PROFILE_REQUESTS'): + return + if app.extensions.get('serverkit_request_profiling'): + return + app.extensions['serverkit_request_profiling'] = True + + @app.before_request + def begin_request(): + if request.path.startswith('/api/'): + g.serverkit_profile = { + 'start': perf_counter_ns(), 'queries': 0, 'sql_ns': 0, + } + + def before_sql(_conn, _cursor, _statement, _parameters, context, _many): + if not has_request_context(): + return + state = getattr(g, 'serverkit_profile', None) + if state is not None: + state['queries'] += 1 + context._serverkit_profile = (state, perf_counter_ns()) + + def finish_sql(context): + timing = getattr(context, '_serverkit_profile', None) + if timing is not None: + state, start = timing + state['sql_ns'] += perf_counter_ns() - start + context._serverkit_profile = None + + def after_sql(_conn, _cursor, _statement, _parameters, context, _many): + finish_sql(context) + + def failed_sql(error_context): + finish_sql(error_context.execution_context) + + with app.app_context(): + for engine in set(database.engines.values()): + event.listen(engine, 'before_cursor_execute', before_sql) + event.listen(engine, 'after_cursor_execute', after_sql) + event.listen(engine, 'handle_error', failed_sql) + + @app.after_request + def expose_profile(response): + state = getattr(g, 'serverkit_profile', None) + if state is not None: + app_ms = (perf_counter_ns() - state['start']) / 1_000_000 + sql_ms = state['sql_ns'] / 1_000_000 + response.headers.add( + 'Server-Timing', + f'app;dur={app_ms:.3f}, db;dur={sql_ms:.3f};' + f'desc="{state["queries"]} queries"', + ) + return response diff --git a/backend/app/middleware/session_auth.py b/backend/app/middleware/session_auth.py new file mode 100644 index 000000000..f806a5b21 --- /dev/null +++ b/backend/app/middleware/session_auth.py @@ -0,0 +1,61 @@ +"""Browser-session policy shared by HTTP, sockets, and long-running work.""" +import time +from functools import wraps + +from flask import g, jsonify +from flask_jwt_extended import get_jwt, verify_jwt_in_request + + +def issue_session_tokens(user_id): + """Issue one browser's access/refresh pair with an explicit shared id.""" + import secrets + from flask_jwt_extended import create_access_token, create_refresh_token + claims = {'session_id': secrets.token_hex(16)} + return (create_access_token(identity=user_id, additional_claims=claims), + create_refresh_token(identity=user_id, additional_claims=claims)) + + +def validate_session_claims(claims, *, allow_pending=False, token_type='access'): + """Return the live user only for an unexpired, unrevoked session JWT.""" + from app.models import User, RevokedSession + + if not isinstance(claims, dict) or claims.get('type') != token_type: + return None + if claims.get('2fa_pending') and not allow_pending: + return None + session_id = claims.get('session_id') + if not isinstance(session_id, str) or len(session_id) != 32: + return None + if RevokedSession.query.filter_by(session_id=session_id).first() is not None: + return None + expiration = claims.get('exp') + if not isinstance(expiration, (int, float)) or expiration <= time.time(): + return None + try: + user_id = int(claims['sub']) + except (KeyError, TypeError, ValueError): + return None + user = User.query.populate_existing().filter_by(id=user_id).first() + if (not user or not user.is_active or not claims.get('auth_version') + or claims['auth_version'] != user.auth_version): + return None + return user + + +def session_required(fn): + """Explicit JWT-only policy for actions on the caller's browser sessions. + + Credential/session administration must never turn a scoped API key into + unrestricted browser credentials, even when its owner is an admin. + """ + @wraps(fn) + def wrapper(*args, **kwargs): + if getattr(g, 'api_key_user', None): + return jsonify({'error': 'Browser authentication required'}), 403 + verify_jwt_in_request() + g.session_user = validate_session_claims(get_jwt()) + if not g.session_user: + return jsonify({'error': 'Session is no longer valid'}), 401 + return fn(*args, **kwargs) + wrapper._sk_authz = ('session_required',) + return wrapper diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 01bfcd3eb..1fef9d75e 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -2,6 +2,7 @@ # FKs (the alternative is an IntegrityError on every parent hard-delete). from app.models import _delete_cascade_policy # noqa: F401 from app.models.user import User +from app.models.revoked_session import RevokedSession from app.models.application import Application from app.models.domain import Domain from app.models.env_variable import EnvironmentVariable, EnvironmentVariableHistory diff --git a/backend/app/models/revoked_session.py b/backend/app/models/revoked_session.py new file mode 100644 index 000000000..3b0673bf9 --- /dev/null +++ b/backend/app/models/revoked_session.py @@ -0,0 +1,13 @@ +"""Persistent session-family revocations (access and refresh share an id).""" +from datetime import datetime +from app import db + + +class RevokedSession(db.Model): + __tablename__ = 'revoked_sessions' + + session_id = db.Column(db.String(32), primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey('users.id', ondelete='CASCADE'), + nullable=False, index=True) + revoked_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + user = db.relationship('User', backref=db.backref('revoked_sessions', lazy='dynamic')) diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 0f52271bf..294adc0bc 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -4,6 +4,7 @@ from app import db from app.models.json_column_mixin import JsonColumnMixin import json +import secrets class User(JsonColumnMixin, db.Model): @@ -23,6 +24,8 @@ class User(JsonColumnMixin, db.Model): role = db.Column(db.String(20), default='developer') # 'admin', 'developer', 'viewer' permissions = db.Column(db.Text, nullable=True) # JSON per-feature read/write flags is_active = db.Column(db.Boolean, default=True) + auth_version = db.Column(db.String(32), nullable=False, + default=lambda: secrets.token_hex(16), server_default='0') created_at = db.Column(db.DateTime, default=datetime.utcnow, index=True) updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) last_login_at = db.Column(db.DateTime, nullable=True) @@ -134,6 +137,22 @@ def reset_failed_login(self): def set_password(self, password): self.password_hash = generate_password_hash(password) + self.revoke_sessions() + + @validates('is_active') + def _invalidate_disabled_sessions(self, key, active): + if not active: + self.revoke_sessions() + return active + + def revoke_sessions(self): + """Invalidate access/refresh/MFA tokens and unused login links.""" + self.auth_version = secrets.token_hex(16) + if self.id is not None: + from app.models.login_link import LoginLink + LoginLink.query.filter( + (LoginLink.user_id == self.id) | (LoginLink.created_by_id == self.id) + ).delete(synchronize_session=False) def check_password(self, password): if not self.password_hash: diff --git a/backend/app/services/ai_service.py b/backend/app/services/ai_service.py index c12f9075b..b58817521 100644 --- a/backend/app/services/ai_service.py +++ b/backend/app/services/ai_service.py @@ -22,6 +22,7 @@ import dataclasses import json import logging +import re import secrets import threading from typing import TYPE_CHECKING, Any, Callable, Iterator, Optional @@ -289,8 +290,27 @@ def injection_flagged(text: str) -> bool: return False try: return bool(_get_injection_detector().is_injection(text)) - except Exception: - return False + except Exception as exc: + raise AIProtectionError('AI injection protection is unavailable. Please try again later.') from exc + + +class AIProtectionError(RuntimeError): + """Enabled protection failed; never send the original data as a fallback.""" + + +def _filter_secrets(text: str) -> str: + """Deterministic credential filtering, independent of optional PII detection.""" + text = re.sub(r'-----BEGIN [^-]*PRIVATE KEY-----.*?-----END [^-]*PRIVATE KEY-----', + '[redacted]', text, flags=re.DOTALL) + text = re.sub(r'(?i)\b(Bearer|Basic)\s+[A-Za-z0-9_./+=-]+', r'\1 [redacted]', text) + text = re.sub(r'(?i)([a-z][a-z0-9+.-]*://)[^\s/@:]+:[^\s/@]+@', + r'\1[redacted]@', text) + # Handle assignments in log lines, pasted config, URLs and serialized JSON. + from app.utils.sensitive_data_filter import SENSITIVE_KEY_PARTS + parts = '|'.join(re.escape(part) for part in SENSITIVE_KEY_PARTS) + pattern = (r'(?i)([\w.-]*(?:' + parts + r')[\w.-]*["\x27]?\s*[:=]\s*)' + r'(?:"[^"\n]*"|\x27[^\x27\n]*\x27|[^\s,;&}\]]+)') + return re.sub(pattern, r'\1[redacted]', text) def _pii_enabled() -> bool: @@ -298,21 +318,30 @@ def _pii_enabled() -> bool: def redact_input(text: str) -> str: + text = _filter_secrets(text) if not _pii_enabled(): return text try: return _get_pii_redactor().redact(text).text - except Exception: - return text + except Exception as exc: + raise AIProtectionError('AI privacy protection is unavailable. Please try again later.') from exc def _maybe_redact_result(result: Any) -> Any: - if not _pii_enabled() or not isinstance(result, str): - return result - try: - return _get_pii_redactor().redact(result).text - except Exception: - return result + from app.utils.sensitive_data_filter import mask_payload + + def walk(value): + if isinstance(value, dict): + return {redact_input(str(k)): walk(v) for k, v in value.items()} + if isinstance(value, (list, tuple)): + return [walk(item) for item in value] + if isinstance(value, str): + return redact_input(value) + if value is None or isinstance(value, (bool, int, float)): + return value + return redact_input(str(value)) + + return walk(mask_payload(result)) # =========================================================================== @@ -356,7 +385,7 @@ def build_system_prompt(user, mode: str, page_context: Optional[dict], f"\nThe operator's role is '{role}'. Do not offer or attempt actions they " "lack permission for; tools you cannot see are unavailable to this user." ) - return "".join(parts) + return redact_input("".join(parts)) # =========================================================================== @@ -370,24 +399,70 @@ def build_tool_registry(user, mode: str, gate: Optional["ConfirmationGate"]) -> reg = ToolRegistry() for d in descriptors: - fn = _make_write_wrapper(d, user, gate) if d.is_write else _make_read_wrapper(d, user) + fn = (_make_write_wrapper(d, user, gate) if d.is_write else + _make_read_wrapper(d, user, session_claims=getattr(gate, 'session_claims', None))) reg.add(ToolDefinition(name=d.qualified_name, description=d.description, parameters=d.parameters, function=fn)) return reg -def _make_read_wrapper(descriptor, user) -> Callable[..., Any]: +def _caller_validator(user, claims=None): + """Capture authentication before a long model/confirmation wait.""" + from flask import has_request_context + from flask_jwt_extended import get_jwt + uid = getattr(user, 'id', None) + version = getattr(user, 'auth_version', None) + if claims is None and has_request_context(): + try: + claims = get_jwt() + except RuntimeError: + claims = None # In-process plugin callers may have no JWT. + + def validate(): + if claims: + from app.middleware.session_auth import validate_session_claims + return validate_session_claims(claims) + from app.models.user import User + fresh = db.session.get(User, uid, populate_existing=True) if uid is not None else None + if fresh is None or not fresh.is_active or getattr(fresh, 'auth_version', None) != version: + return None + return fresh + return validate + + +def _invoke_tool(descriptor, user, kwargs): + from app.services.ai_tools_builtin import tool_caller + token = tool_caller.set(user) + try: + return descriptor.func(**kwargs) + finally: + tool_caller.reset(token) + + +def _make_read_wrapper(descriptor, user, session_claims=None) -> Callable[..., Any]: + validate = _caller_validator(user, session_claims) def wrapper(**kwargs): - if not descriptor.allowed_for(user): + caller = validate() + if caller is None or not descriptor.allowed_for(caller): return f"Permission denied: you lack {descriptor.rbac_feature} {descriptor.rbac_level} access." - result = descriptor.func(**kwargs) - return _maybe_redact_result(result) + try: + result = _invoke_tool(descriptor, caller, kwargs) + return _maybe_redact_result(result) + except AIProtectionError: + raise + except Exception: + # Provider SDKs commonly turn exception text into tool output. + # Service exception messages can contain credentials/connection URLs. + logger.warning('AI read tool %s failed', descriptor.qualified_name) + return 'The tool failed to retrieve data.' return wrapper def _make_write_wrapper(descriptor, user, gate: Optional["ConfirmationGate"]) -> Callable[..., Any]: + validate = _caller_validator(user, getattr(gate, 'session_claims', None)) def wrapper(**kwargs): - if not descriptor.allowed_for(user): + caller = validate() + if caller is None or not descriptor.allowed_for(caller): return f"Permission denied: you lack {descriptor.rbac_feature} write access." if gate is None: return ("This is a state-changing action and needs explicit confirmation. " @@ -395,15 +470,25 @@ def wrapper(**kwargs): decision, token = gate.request_confirmation(descriptor, kwargs) if decision != "approve": return f"The user declined to run '{descriptor.name}'." + caller = validate() + if gate.is_cancelled() or caller is None or not descriptor.allowed_for(caller): + gate.mark_failed(token, 'Authorization changed or the stream was cancelled.') + return 'Permission denied: authorization changed or the stream was cancelled.' try: - result = descriptor.func(**kwargs) + result = _maybe_redact_result(_invoke_tool(descriptor, caller, kwargs)) + if isinstance(result, dict) and (result.get('success') is False or result.get('ok') is False): + gate.mark_failed(token, 'The service reported that the action failed.') + _audit_tool_execute(descriptor, kwargs, caller, ok=False, error='Service action failed') + return result gate.mark_executed(token, result) _audit_tool_execute(descriptor, kwargs, user, ok=True) return result except Exception as exc: - gate.mark_failed(token, str(exc)) - _audit_tool_execute(descriptor, kwargs, user, ok=False, error=str(exc)) - return f"Action '{descriptor.name}' failed: {exc}" + error = ('AI privacy protection failed after execution; the action may have completed.' + if isinstance(exc, AIProtectionError) else 'The action failed.') + gate.mark_failed(token, error) + _audit_tool_execute(descriptor, kwargs, caller, ok=False, error=error) + return error return wrapper @@ -444,7 +529,7 @@ def build_conversation(row, user, mode: str, page_context: Optional[dict], ) export = row.export if export: - conv = Conversation.from_export(export, tools=registry) + conv = Conversation.from_export(_maybe_redact_result(export), tools=registry) conv.system_prompt = system # refresh page context each turn return conv return Conversation( @@ -558,11 +643,15 @@ def __init__(self, conversation_id: str, user_id: int, emit: Callable[[str, dict self._ttl = ttl_seconds self._pending: dict[str, dict] = {} self._lock = threading.Lock() + self.session_claims = None def request_confirmation(self, descriptor, params: dict) -> tuple[str, str]: """Block until the user approves/denies. Returns ('approve'|'deny', token).""" from app.models.ai import AiPendingAction + if self._cancel.is_set(): + return 'deny', '' + token = secrets.token_urlsafe(16) summary = summarize_action(descriptor, params) row = AiPendingAction( @@ -578,6 +667,7 @@ def request_confirmation(self, descriptor, params: dict) -> tuple[str, str]: except Exception: db.session.rollback() logger.warning("Failed to persist pending action", exc_info=True) + return 'deny', token ev = threading.Event() with self._lock: @@ -616,6 +706,9 @@ def has_pending(self) -> bool: with self._lock: return bool(self._pending) + def is_cancelled(self) -> bool: + return self._cancel.is_set() + def cancel_all(self) -> None: """Deny and unblock every confirmation when a stream disconnects.""" self._cancel.set() @@ -657,9 +750,10 @@ def register_gate(conversation_id: str, gate: ConfirmationGate) -> None: _active_gates[conversation_id] = gate -def unregister_gate(conversation_id: str) -> None: +def unregister_gate(conversation_id: str, gate: Optional[ConfirmationGate] = None) -> None: with _gates_lock: - _active_gates.pop(conversation_id, None) + if gate is None or _active_gates.get(conversation_id) is gate: + _active_gates.pop(conversation_id, None) def resolve_pending(conversation_id: str, token: str, decision: str) -> bool: diff --git a/backend/app/services/ai_tool_registry.py b/backend/app/services/ai_tool_registry.py index 3ded15d57..71fa85978 100644 --- a/backend/app/services/ai_tool_registry.py +++ b/backend/app/services/ai_tool_registry.py @@ -48,9 +48,14 @@ class ToolDescriptor: rbac_feature: Optional[str] = None # e.g. 'docker'; None => any authenticated user rbac_level: str = "read" # 'read' | 'write' is_write: bool = False # write tools go through the confirmation handshake + admin_only: bool = False # raw host operations mirror REST's admin gate def allowed_for(self, user) -> bool: """True if *user* may use this tool given its RBAC tagging.""" + if user is None or not getattr(user, 'is_active', True): + return False + if self.admin_only and not getattr(user, 'is_admin', False): + return False if self.rbac_feature is None: return True try: @@ -104,6 +109,7 @@ def register( rbac_feature: Optional[str] = None, rbac_level: str = "read", is_write: bool = False, + admin_only: bool = False, ) -> ToolDescriptor: """Register a tool callable. Idempotent per qualified name (re-register overwrites).""" prefix = plugin_slug or CORE_PREFIX @@ -125,6 +131,7 @@ def register( rbac_feature=rbac_feature, rbac_level=("write" if is_write else rbac_level), is_write=is_write, + admin_only=admin_only, ) with self._mutex: self._tools[qualified] = descriptor diff --git a/backend/app/services/ai_tools_builtin.py b/backend/app/services/ai_tools_builtin.py index e6f75f31d..336138bf3 100644 --- a/backend/app/services/ai_tools_builtin.py +++ b/backend/app/services/ai_tools_builtin.py @@ -16,12 +16,25 @@ from __future__ import annotations import logging +from contextvars import ContextVar from app.services.ai_tool_registry import ai_tool_registry logger = logging.getLogger(__name__) _REGISTERED = False +tool_caller = ContextVar('ai_tool_caller', default=None) + + +def _caller(*, admin=False): + user = tool_caller.get() + if user is None or not user.is_active or (admin and not user.is_admin): + raise PermissionError('Permission denied for this AI tool.') + return user + + +def _summary(row, fields): + return {field: getattr(row, field, None) for field in fields} # --------------------------------------------------------------------------- @@ -44,42 +57,54 @@ def list_docker_containers(include_stopped: bool = True) -> list: include_stopped: If true, include stopped containers; otherwise only running ones. """ from app.services.docker_service import DockerService - return DockerService.list_containers(all_containers=include_stopped) + _caller() + rows = DockerService.list_containers(all_containers=include_stopped) + fields = ('id', 'name', 'image', 'status', 'state', 'ports', 'protected') + return [{key: row.get(key) for key in fields if key in row} for row in rows] def get_docker_info() -> dict: """Get Docker engine status and summary info (version, container/image counts).""" from app.services.docker_service import DockerService - return DockerService.get_docker_info() + _caller() + info = DockerService.get_docker_info() or {} + fields = ('ServerVersion', 'Containers', 'ContainersRunning', 'ContainersPaused', + 'ContainersStopped', 'Images', 'NCPU', 'MemTotal', 'OperatingSystem', + 'version', 'containers', 'images', 'running') + return {key: info[key] for key in fields if key in info} -def list_applications() -> list: +def list_applications(workspace_id: int = None) -> list: """List the web applications managed by this ServerKit panel. Returns: A list of apps with name, status, type, and port. """ from app.models.application import Application + from app.services.workspace_service import WorkspaceService + user = _caller() + ws_id = WorkspaceService.resolve_workspace_id(user, workspace_id) # The assistant answers questions about what is RUNNING; a deleted app in # its context would be reported as though it still existed. - return [a.to_dict() for a in Application.query_active().all()] + query = WorkspaceService.scope_query( + Application.query_active(), Application, user, workspace_id=ws_id, + owner_attr='user_id', grant_resource_type='application', + ) + return [_summary(a, ('id', 'name', 'status', 'app_type', 'port')) for a in query.all()] -def list_servers() -> list: +def list_servers(workspace_id: int = None) -> list: """List the servers in this ServerKit fleet (the panel host plus paired agents). Returns: A list of servers with name, status, and address. """ from app.models.server import Server - out = [] - for s in Server.query.all(): - if hasattr(s, "to_dict"): - out.append(s.to_dict()) - else: - out.append({"id": getattr(s, "id", None), "name": getattr(s, "name", None), - "status": getattr(s, "status", None)}) - return out + from app.services.workspace_service import WorkspaceService + user = _caller() + ws_id = WorkspaceService.resolve_workspace_id(user, workspace_id) + query = WorkspaceService.scope_query(Server.query, Server, user, workspace_id=ws_id) + return [_summary(s, ('id', 'name', 'status', 'hostname')) for s in query.all()] def list_databases() -> dict: @@ -89,14 +114,15 @@ def list_databases() -> dict: A dict with the database list, or a message if MySQL is not available. """ from app.services.database_service import DatabaseService + _caller(admin=True) try: if not DatabaseService.mysql_is_installed(): return {"available": False, "message": "MySQL/MariaDB is not installed on this host."} if not DatabaseService.mysql_is_running(): return {"available": False, "message": "MySQL/MariaDB is installed but not running."} return {"available": True, "databases": DatabaseService.mysql_list_databases()} - except Exception as exc: # pragma: no cover - environment dependent - return {"available": False, "message": f"Could not list databases: {exc}"} + except Exception: # pragma: no cover - environment dependent + return {"available": False, "message": "Could not list databases on this host."} # --------------------------------------------------------------------------- @@ -109,8 +135,10 @@ def restart_docker_container(container_id: str) -> dict: container_id: The container id or name to restart. """ from app.services.docker_service import DockerService - DockerService.restart_container(container_id) - return {"ok": True, "action": "restart", "container": container_id} + _caller(admin=True) + if DockerService.is_protected_container(container_id): + raise PermissionError('ServerKit system containers cannot be controlled here.') + return DockerService.restart_container(container_id) def stop_docker_container(container_id: str) -> dict: @@ -120,8 +148,10 @@ def stop_docker_container(container_id: str) -> dict: container_id: The container id or name to stop. """ from app.services.docker_service import DockerService - DockerService.stop_container(container_id) - return {"ok": True, "action": "stop", "container": container_id} + _caller(admin=True) + if DockerService.is_protected_container(container_id): + raise PermissionError('ServerKit system containers cannot be controlled here.') + return DockerService.stop_container(container_id) def register_builtin_tools() -> None: @@ -152,16 +182,16 @@ def register_builtin_tools() -> None: ) ai_tool_registry.register( name="list_databases", func=list_databases, - rbac_feature="databases", rbac_level="read", + rbac_feature="databases", rbac_level="read", admin_only=True, ) # --- guarded write tools --- ai_tool_registry.register( name="restart_docker_container", func=restart_docker_container, - rbac_feature="docker", is_write=True, + rbac_feature="docker", is_write=True, admin_only=True, ) ai_tool_registry.register( name="stop_docker_container", func=stop_docker_container, - rbac_feature="docker", is_write=True, + rbac_feature="docker", is_write=True, admin_only=True, ) _REGISTERED = True diff --git a/backend/app/services/application_lifecycle_service.py b/backend/app/services/application_lifecycle_service.py new file mode 100644 index 000000000..955c6e8a6 --- /dev/null +++ b/backend/app/services/application_lifecycle_service.py @@ -0,0 +1,244 @@ +"""Application start/stop/restart operations for HTTP, jobs and automation. + +Callers authorize access before entering this service. Successful operations +commit their status then invalidate container aggregates; a rejected runtime +operation leaves both untouched. Image materialization retains its separate +commit so a failed first start can be retried using the saved compose project. +""" + +import os + +from app import db, paths +from app.services.docker_service import DockerService +from app.services.remote_docker_service import RemoteDockerService +from app.services.container_registry_service import ContainerRegistryService +from app.services.unit_compose_service import UnitComposeService +from app.services.app_port_service import AppPortService +from app.services import container_status_service + + +class ApplicationLifecycleError(Exception): + """An operation rejected by the runtime, safe to present to the caller.""" + + +def _compose_target(app): + """Return the effective compose file path for remote agent deployments.""" + return os.path.join(app.root_path, app.compose_file or 'docker-compose.yml') if app.root_path else None + + +def _local_compose_file(app): + """Return the compose file to pass to DockerService for local deployments.""" + return app.compose_file if app.compose_file else None + + +def _agent_result_failed(result): + data = result.get('data') if isinstance(result, dict) else None + return isinstance(data, dict) and data.get('success') is False + + +def _agent_result_error(result, fallback): + data = result.get('data') if isinstance(result, dict) else None + if isinstance(data, dict): + return data.get('error') or result.get('error') or fallback + return result.get('error') or fallback + + +def _assert_managed_app_path(app_name): + base_dir = os.path.abspath(paths.APPS_DIR) + app_path = os.path.abspath(os.path.join(base_dir, app_name)) + if app_path != base_dir and app_path.startswith(base_dir + os.sep): + return app_path + raise ValueError('Invalid application path') + + +def _is_single_container_app(app): + """True when this app's deploy produced one container, not a compose project. + + ``app_type == 'docker'`` is not the same as "compose-managed". The build + pack deploys by building an image and running a single container + (``DeploymentService._deploy_docker``, named ``serverkit-app-``) and + nothing in that path ever writes a compose file, so ``compose_file`` stays + NULL. Handing that directory to ``docker compose`` fails with "no + configuration file provided: not found" -- which is what start, stop and + restart did for every build-pack app. + + Deliberately narrow, and phrased as a positive test rather than "has no + compose file": compose stays the default for everything, including an app + whose compose file has not been rendered yet. Only a local build-pack app + with no compose file recorded and none on disk takes the container path. + """ + if app.server_id or app.compose_file or not app.root_path: + return False + if not app.buildpack_type: + return False + return not any( + os.path.isfile(os.path.join(app.root_path, name)) + for name in ('docker-compose.yml', 'docker-compose.yaml', + 'compose.yml', 'compose.yaml') + ) + + +def _app_container_name(app): + """The single container a build-pack deploy creates for this app. + + Must match ``DeploymentService._deploy_docker``, which names it + ``serverkit-app-``. + """ + return f'serverkit-app-{app.id}' + + +def _ensure_local_image_compose(app): + """Materialize a compose project for a local docker app that carries a + ``docker_image`` but has no source on disk yet. + + A BYO-image app (e.g. an ``image:`` manifest service with no repository) + is created with ``docker_image`` set but ``root_path``/``compose_file`` + NULL, so it has nothing to launch. Render a one-service compose from the + image and its typed ports, write it under APPS_DIR, and persist the paths; + the normal ``compose_up`` overlay then injects the app's effective env + (including resolved vault secrets), so nothing sensitive is written here. + + No-op when a source already exists, the app has no image, or it targets a + remote server (the file must live where the deploy actually runs). + """ + if app.root_path or not app.docker_image or app.server_id: + return + app_path = _assert_managed_app_path(app.name) + os.makedirs(app_path, exist_ok=True) + + container = {'name': 'app', 'image': app.docker_image} + ports = AppPortService.get_ports(app) + if not ports and app.port: + # Bind the legacy scalar port to loopback — nginx fronts it; publishing + # 0.0.0.0 (AppPortService._clean's default) would expose it past nginx. + ports = [{'host_port': app.port, 'container_port': app.port, 'expose': 'local'}] + if ports: + container['ports'] = ports + if app.healthcheck_path: + container['health_check'] = {'http_path': app.healthcheck_path} + + compose_yaml = UnitComposeService.render_yaml(app.name, [container]) + with open(os.path.join(app_path, 'docker-compose.yml'), 'w') as f: + f.write(compose_yaml) + + app.root_path = app_path + app.compose_file = 'docker-compose.yml' + if not app.managed_by: + app.managed_by = 'docker_compose' + db.session.commit() + + +def start_application(app, *, user_id=None): + """Start the configured runtime, materializing local image apps if needed.""" + # A local BYO-image docker app (image set, no source yet) has no compose to + # launch — materialize one on first start so it can actually run. + if app.app_type == 'docker' and not app.root_path and app.docker_image and not app.server_id: + try: + _ensure_local_image_compose(app) + except ValueError as e: + raise ApplicationLifecycleError(str(e)) from e + + # Handle Docker apps + if app.app_type == 'docker' and app.root_path: + if app.server_id: + result = RemoteDockerService.compose_up( + app.server_id, + _compose_target(app), + detach=True, + user_id=user_id + ) + elif _is_single_container_app(app): + # Build-pack app: one container, created by the deploy. + container = _app_container_name(app) + if not DockerService.get_container(container): + raise ApplicationLifecycleError( + 'This application has not been deployed yet. ' + 'Run a deploy to build its image and create the container.' + ) + result = DockerService.start_container(container) + else: + # Authenticate a bound private registry before compose pulls the + # image; best-effort, always logs back out. No-op without registry_id. + _registry = ContainerRegistryService.login_for_app(app) + try: + result = DockerService.compose_up( + app.root_path, + detach=True, + compose_file=_local_compose_file(app) + ) + finally: + ContainerRegistryService.logout_for_app(_registry) + if not result.get('success') or _agent_result_failed(result): + raise ApplicationLifecycleError( + _agent_result_error(result, 'Failed to start containers') + ) + + app.status = 'running' + db.session.commit() + # The cached aggregate now describes the pre-start world. Drop it: a status + # pill that survives the action that changed it reads as a broken panel. + container_status_service.invalidate(app.id) + + +def stop_application(app, *, user_id=None): + """Stop the configured runtime; a vanished single container is stopped.""" + # Handle Docker apps + if app.app_type == 'docker' and app.root_path: + if app.server_id: + result = RemoteDockerService.compose_down( + app.server_id, + _compose_target(app), + user_id=user_id + ) + elif _is_single_container_app(app): + # A container that no longer exists is already stopped -- reporting + # that as a failure would strand the app in `running` forever. + container = _app_container_name(app) + result = ({'success': True} if not DockerService.get_container(container) + else DockerService.stop_container(container)) + else: + result = DockerService.compose_down( + app.root_path, + compose_file=_local_compose_file(app) + ) + if not result.get('success') or _agent_result_failed(result): + raise ApplicationLifecycleError( + _agent_result_error(result, 'Failed to stop containers') + ) + + app.status = 'stopped' + db.session.commit() + container_status_service.invalidate(app.id) + + +def restart_application(app, *, user_id=None): + """Restart the configured runtime, requiring a deployed single container.""" + # Handle Docker apps + if app.app_type == 'docker' and app.root_path: + if app.server_id: + result = RemoteDockerService.compose_restart( + app.server_id, + _compose_target(app), + user_id=user_id + ) + elif _is_single_container_app(app): + container = _app_container_name(app) + if not DockerService.get_container(container): + raise ApplicationLifecycleError( + 'This application has not been deployed yet. ' + 'Run a deploy to build its image and create the container.' + ) + result = DockerService.restart_container(container) + else: + result = DockerService.compose_restart( + app.root_path, + compose_file=_local_compose_file(app) + ) + if not result.get('success') or _agent_result_failed(result): + raise ApplicationLifecycleError( + _agent_result_error(result, 'Failed to restart containers') + ) + + app.status = 'running' + db.session.commit() + container_status_service.invalidate(app.id) diff --git a/backend/app/services/backup_schedule_service.py b/backend/app/services/backup_schedule_service.py new file mode 100644 index 000000000..1c16d4850 --- /dev/null +++ b/backend/app/services/backup_schedule_service.py @@ -0,0 +1,98 @@ +"""Server-local legacy backup schedules, shared by the API and job tick. + +Wall-clock schedules retain their original behavior: a missing spring-forward +minute is skipped; both occurrences of a repeated autumn minute can run. +All comparisons use UTC so Python's same-zone arithmetic cannot collapse folds. +""" + +from datetime import datetime, timedelta, timezone +import re + +from tzlocal import reload_localzone + +WEEKDAYS = ('monday', 'tuesday', 'wednesday', 'thursday', 'friday', + 'saturday', 'sunday') + + +def server_timezone(): + # A panel timezone change must take effect without restarting the process. + return reload_localzone() + + +def validate_schedule(schedule): + value = schedule.get('schedule_time') + if not isinstance(value, str) or not re.fullmatch(r'(?:[01]\d|2[0-3]):[0-5]\d', value): + raise ValueError('schedule_time must be a valid HH:MM time') + days = schedule.get('days', ['daily']) + if (not isinstance(days, list) or not days + or any(not isinstance(day, str) or day not in (*WEEKDAYS, 'daily') for day in days)): + raise ValueError('days must contain daily or valid lowercase weekday names') + return tuple(map(int, value.split(':'))), days + + +def _last_run(schedule, zone): + try: + last = datetime.fromisoformat(schedule.get('last_run') or '') + # Existing records have naive server-local timestamps. New writes carry + # an offset; retain compatibility without changing persisted schedules. + return (last if last.tzinfo else last.replace(tzinfo=zone)).astimezone(timezone.utc) + except (TypeError, ValueError): + return None + + +def next_run(schedule, *, now=None, zone=None): + """Return the next eligible minute (including the current minute), or None. + + Uses the same 120-second duplicate suppression as the original job tick. + There is no missed-run catchup: only this minute or a future minute qualifies. + Invalid legacy schedules remain readable and inert rather than breaking ticks. + """ + try: + (hour, minute), days = validate_schedule(schedule) + except ValueError: + return None + if not schedule.get('enabled', False): + return None + zone = zone or server_timezone() + now = now or datetime.now(timezone.utc) + if now.tzinfo is None: + raise ValueError('now must include a timezone') + local_now = now.astimezone(zone) + start = local_now.replace(second=0, microsecond=0).astimezone(timezone.utc) + last = _last_run(schedule, zone) + # A weekly occurrence can be absent during a DST jump; search through the + # following week's occurrence as well. + for offset in range(15): + date = local_now.date() + timedelta(days=offset) + if 'daily' not in days and WEEKDAYS[date.weekday()] not in days: + continue + wall = datetime(date.year, date.month, date.day, hour, minute) + candidates = set() + for fold in (0, 1): + candidate = wall.replace(tzinfo=zone, fold=fold).astimezone(timezone.utc) + # A nonexistent local minute round-trips to a different wall time. + if candidate.astimezone(zone).replace(tzinfo=None) == wall: + candidates.add(candidate) + for candidate in sorted(candidates): + if candidate < start: + continue + # For a due minute compare against the real tick time, preserving + # dedup behavior for records stamped partway through that minute. + check_time = max(candidate, now.astimezone(timezone.utc)) + if last is not None and (check_time - last).total_seconds() < 120: + continue + return candidate.astimezone(zone) + return None + + +def describe_schedule(schedule, *, globally_enabled=True, now=None, zone=None): + zone = zone or server_timezone() + result = dict(schedule) + result['timezone'] = str(zone) + try: + validate_schedule(schedule) + except ValueError as exc: + result['schedule_error'] = str(exc) + following = next_run(schedule, now=now, zone=zone) if globally_enabled else None + result['next_run_at'] = following.isoformat() if following else None + return result diff --git a/backend/app/services/backup_service.py b/backend/app/services/backup_service.py index 1ae4fc8a8..40d361de0 100644 --- a/backend/app/services/backup_service.py +++ b/backend/app/services/backup_service.py @@ -5,7 +5,7 @@ import tarfile import gzip import tempfile -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from typing import Dict, List, Optional from pathlib import Path import threading @@ -19,6 +19,7 @@ from app.utils.formatting import format_bytes from app.utils.system import is_command_available, run_checked from app.services.telemetry_service import TelemetryService, generate_correlation_id +from app.services import backup_schedule_service # Unified job kind for asynchronous scheduled backups (see register_jobs). BACKUP_JOB_KIND = 'backup.run' @@ -660,18 +661,23 @@ def add_schedule(cls, name: str, backup_type: str, target: str, 'backup_type': backup_type, 'target': target, 'schedule_time': schedule_time, - 'days': days or ['daily'], + 'days': ['daily'] if days is None else days, 'enabled': True, 'upload_remote': upload_remote, 'last_run': None, 'last_status': None } + try: + backup_schedule_service.validate_schedule(schedule_entry) + except ValueError as exc: + return {'success': False, 'error': str(exc)} config.setdefault('schedules', []).append(schedule_entry) result = cls.save_config(config) if result.get('success'): - return {'success': True, 'schedule': schedule_entry} + return {'success': True, 'schedule': backup_schedule_service.describe_schedule( + schedule_entry, globally_enabled=config.get('enabled', False))} return result @classmethod @@ -684,9 +690,12 @@ def update_schedule(cls, schedule_id: str, updates: Dict) -> Dict: if s.get('id') == schedule_id: allowed_fields = ['name', 'backup_type', 'target', 'schedule_time', 'days', 'enabled', 'upload_remote'] - for field in allowed_fields: - if field in updates: - schedules[i][field] = updates[field] + updated = {**s, **{field: updates[field] for field in allowed_fields if field in updates}} + try: + backup_schedule_service.validate_schedule(updated) + except ValueError as exc: + return {'success': False, 'error': str(exc)} + schedules[i] = updated config['schedules'] = schedules return cls.save_config(config) @@ -710,7 +719,11 @@ def remove_schedule(cls, schedule_id: str) -> Dict: def get_schedules(cls) -> List[Dict]: """Get all backup schedules.""" config = cls.get_config() - return config.get('schedules', []) + zone = backup_schedule_service.server_timezone() + now = datetime.now(timezone.utc) + return [backup_schedule_service.describe_schedule( + entry, globally_enabled=config.get('enabled', False), now=now, zone=zone, + ) for entry in config.get('schedules', [])] @classmethod def get_backup_stats(cls) -> Dict: @@ -895,27 +908,15 @@ def check_backup_schedules(cls) -> None: if not config.get('enabled', False): return - now = datetime.now() + zone = backup_schedule_service.server_timezone() + now = datetime.now(timezone.utc).astimezone(zone) current_time = now.strftime('%H:%M') - current_day = now.strftime('%A').lower() dirty = False for sched in config.get('schedules', []): - if not sched.get('enabled', False): - continue - if sched.get('schedule_time') != current_time: - continue - days = sched.get('days', ['daily']) - if 'daily' not in days and current_day not in days: + due = backup_schedule_service.next_run(sched, now=now, zone=zone) + if due is None or due.astimezone(timezone.utc) > now.astimezone(timezone.utc): continue - # Skip if a run was enqueued / ran within the last ~2 minutes. - last_run = sched.get('last_run') - if last_run: - try: - if (now - datetime.fromisoformat(last_run)).total_seconds() < 120: - continue - except Exception: - pass JobService.enqueue( BACKUP_JOB_KIND, @@ -1018,7 +1019,7 @@ def _run_scheduled_backup(cls, sched: Dict) -> None: config = cls.get_config() for s in config.get('schedules', []): if s.get('id') == sched.get('id'): - s['last_run'] = datetime.now().isoformat() + s['last_run'] = datetime.now(timezone.utc).isoformat() s['last_status'] = 'success' if result and result.get('success') else 'failed' break cls.save_config(config) @@ -1037,7 +1038,7 @@ def _run_scheduled_backup(cls, sched: Dict) -> None: config = cls.get_config() for s in config.get('schedules', []): if s.get('id') == sched.get('id'): - s['last_run'] = datetime.now().isoformat() + s['last_run'] = datetime.now(timezone.utc).isoformat() s['last_status'] = 'failed' break cls.save_config(config) diff --git a/backend/app/services/cf_ops_change_service.py b/backend/app/services/cf_ops_change_service.py index b7efd8cbd..735424371 100644 --- a/backend/app/services/cf_ops_change_service.py +++ b/backend/app/services/cf_ops_change_service.py @@ -5,11 +5,12 @@ The ops-layer sibling of :class:`DnsChangeService` (which covers DNS *record* writes). Recording is best-effort and **never raises** — an audit write must not break the operation it describes. The current user is captured opportunistically -from the JWT when called inside a request. +from the JWT or API-key identity when called inside a request. """ import logging from app import db +from app.utils.actor import current_actor_id from app.models.cf_ops_change import CfOpsChange logger = logging.getLogger(__name__) @@ -17,19 +18,7 @@ class CfOpsChangeService: - @staticmethod - def _current_user_id(): - """Best-effort current user id (None outside a request context). - - Via rbac.get_current_user() so an API-key caller is attributed to the - key's owner; reading the JWT directly raises for those requests and - the blanket except turned that into a silent None.""" - try: - from app.middleware.rbac import get_current_user - user = get_current_user() - return user.id if user else None - except Exception: - return None + _current_user_id = staticmethod(current_actor_id) @staticmethod def record(*, provider_zone_id, product, action, target=None, result='ok', diff --git a/backend/app/services/connect_format.py b/backend/app/services/connect_format.py new file mode 100644 index 000000000..52270eb7c --- /dev/null +++ b/backend/app/services/connect_format.py @@ -0,0 +1,16 @@ +"""Serialization shared by Connect protocol snapshots.""" + +from datetime import datetime, timezone + + +def iso_datetime(value): + """Preserve strings; serialize naive panel timestamps as UTC.""" + if value is None: + return None + if isinstance(value, str): + return value + if isinstance(value, datetime): + if value.tzinfo is None: + value = value.replace(tzinfo=timezone.utc) + return value.isoformat() + return None diff --git a/backend/app/services/connect_policy.py b/backend/app/services/connect_policy.py index a20690c50..01f3f286a 100644 --- a/backend/app/services/connect_policy.py +++ b/backend/app/services/connect_policy.py @@ -21,24 +21,13 @@ from datetime import datetime, timezone from app.services.connect_commands import handler +from app.services.connect_format import iso_datetime as _iso logger = logging.getLogger(__name__) FACTS_VERSION = 1 -def _iso(value): - if value is None: - return None - if isinstance(value, str): - return value - if isinstance(value, datetime): - if value.tzinfo is None: - value = value.replace(tzinfo=timezone.utc) - return value.isoformat() - return None - - # ==================== facts ==================== diff --git a/backend/app/services/connect_storage.py b/backend/app/services/connect_storage.py index 0b28c6aa5..e4a3f7a9a 100644 --- a/backend/app/services/connect_storage.py +++ b/backend/app/services/connect_storage.py @@ -18,9 +18,9 @@ rather than watching their edit disappear. """ import logging -from datetime import datetime, timezone from app.services.connect_commands import handler +from app.services.connect_format import iso_datetime as _iso from app.services.storage_provider_service import StorageProviderService logger = logging.getLogger(__name__) @@ -31,18 +31,6 @@ PANEL_PROVIDER = 's3' -def _iso(value): - if value is None: - return None - if isinstance(value, str): - return value - if isinstance(value, datetime): - if value.tzinfo is None: - value = value.replace(tzinfo=timezone.utc) - return value.isoformat() - return None - - # ==================== commands ==================== diff --git a/backend/app/services/fleet_monitor_service.py b/backend/app/services/fleet_monitor_service.py index e8e86bda2..7be04e088 100644 --- a/backend/app/services/fleet_monitor_service.py +++ b/backend/app/services/fleet_monitor_service.py @@ -13,11 +13,13 @@ from typing import List, Dict, Optional, Any from sqlalchemy import func, and_ +from sqlalchemy.orm import joinedload from app import db from app.models.server import Server, ServerMetrics, ServerGroup from app.models.metric_alert import ServerAlertThreshold, MetricAlert from app.services.agent_registry import agent_registry +from app.services.server_metrics_service import latest_metrics_by_server logger = logging.getLogger(__name__) @@ -42,13 +44,12 @@ def get_fleet_heatmap(group_id: str = None) -> List[Dict]: if group_id: query = query.filter_by(group_id=group_id) - servers = query.all() + servers = query.options(joinedload(Server.group)).all() + metrics_by_server = latest_metrics_by_server(server.id for server in servers) result = [] for server in servers: - latest = ServerMetrics.query.filter_by( - server_id=server.id - ).order_by(ServerMetrics.timestamp.desc()).first() + latest = metrics_by_server.get(server.id) result.append({ 'id': server.id, @@ -520,6 +521,7 @@ def get_prometheus_metrics() -> str: """Generate Prometheus exposition format metrics for all servers.""" lines = [] servers = Server.query.all() + metrics_by_server = latest_metrics_by_server(server.id for server in servers) metrics_defs = [ ('serverkit_cpu_percent', 'CPU usage percentage', 'cpu_percent'), @@ -533,9 +535,7 @@ def get_prometheus_metrics() -> str: lines.append(f'# TYPE {metric_name} gauge') for server in servers: - latest = ServerMetrics.query.filter_by( - server_id=server.id - ).order_by(ServerMetrics.timestamp.desc()).first() + latest = metrics_by_server.get(server.id) if latest: val = getattr(latest, col_name) diff --git a/backend/app/services/migration_service.py b/backend/app/services/migration_service.py index ef0867120..15b3ac356 100644 --- a/backend/app/services/migration_service.py +++ b/backend/app/services/migration_service.py @@ -52,6 +52,19 @@ def _sqlite_type(cls, sa_type): type_name = type(sa_type).__name__.upper() return cls._TYPE_MAP.get(type_name, 'TEXT') + @classmethod + def _sqlite_default(cls, col): + """Render a column's literal server_default as a SQLite DEFAULT value.""" + default = getattr(col, 'server_default', None) + arg = getattr(default, 'arg', None) + if isinstance(arg, bool): + return '1' if arg else '0' + if isinstance(arg, (int, float)): + return repr(arg) + if isinstance(arg, str): + return "'" + arg.replace("'", "''") + "'" + return None + @classmethod def _fix_missing_columns(cls, db): """Sync database schema with ORM models. @@ -78,6 +91,13 @@ def _fix_missing_columns(cls, db): sqlite_type = cls._sqlite_type(col.type) sql = f'ALTER TABLE {table_name} ADD COLUMN {col.name} {sqlite_type}' + # Honour a declared server_default so existing rows are + # backfilled instead of left NULL — this runs BEFORE alembic, + # so a migration that only adds the column when missing would + # otherwise skip its own default (users.auth_version → no login). + default = cls._sqlite_default(col) + if default is not None: + sql += f' DEFAULT {default}' try: with db.engine.begin() as conn: diff --git a/backend/app/services/passkey_service.py b/backend/app/services/passkey_service.py index a30b838a7..9bf7f0d29 100644 --- a/backend/app/services/passkey_service.py +++ b/backend/app/services/passkey_service.py @@ -8,10 +8,15 @@ from webauthn import generate_registration_options, verify_registration_response from webauthn import generate_authentication_options, verify_authentication_response +from webauthn import options_to_json from webauthn.helpers.structs import ( - RegistrationResult, - AuthenticationResult, + AttestationConveyancePreference, + AuthenticatorAttachment, + AuthenticatorSelectionCriteria, + AuthenticatorTransport, PublicKeyCredentialDescriptor, + ResidentKeyRequirement, + UserVerificationRequirement, ) from webauthn.helpers.exceptions import InvalidRegistrationResponse, InvalidAuthenticationResponse @@ -100,15 +105,15 @@ def begin_registration(cls, user: User) -> Dict: user_display_name=user.username or user.email, challenge=os.urandom(32), timeout=60000, - attestation='none', - authenticator_selection={ - 'resident_key': 'preferred', - 'user_verification': 'preferred', - 'authenticator_attachment': 'platform', - }, + attestation=AttestationConveyancePreference.NONE, + authenticator_selection=AuthenticatorSelectionCriteria( + resident_key=ResidentKeyRequirement.PREFERRED, + user_verification=UserVerificationRequirement.REQUIRED, + authenticator_attachment=AuthenticatorAttachment.PLATFORM, + ), ) cls._set_challenge(user.id, 'register', options.challenge) - return json.loads(options.json()) + return json.loads(options_to_json(options)) @classmethod def verify_registration(cls, user: User, credential: Dict, device_name: str = '') -> Dict: @@ -118,11 +123,12 @@ def verify_registration(cls, user: User, credential: Dict, device_name: str = '' return {'success': False, 'error': 'Registration challenge expired or missing'} try: - result: RegistrationResult = verify_registration_response( + result = verify_registration_response( credential=credential, expected_challenge=challenge, expected_rp_id=_get_rp_id(), expected_origin=_get_origin(), + require_user_verification=True, ) except InvalidRegistrationResponse as e: return {'success': False, 'error': str(e)} @@ -139,8 +145,9 @@ def verify_registration(cls, user: User, credential: Dict, device_name: str = '' sign_count=result.sign_count, device_name=device_name or 'Passkey', ) - if credential.get('transports'): - passkey.set_transports(credential['transports']) + transports = credential.get('transports') or credential.get('response', {}).get('transports') + if transports: + passkey.set_transports(transports) db.session.add(passkey) db.session.commit() @@ -154,7 +161,11 @@ def begin_authentication(cls, user: Optional[User] = None) -> Dict: if user: creds = PasskeyCredential.query.filter_by(user_id=user.id, is_active=True).all() allow_credentials = [ - PublicKeyCredentialDescriptor(id=_b64decode_url(c.credential_id), transports=c.get_transports()) + PublicKeyCredentialDescriptor( + id=_b64decode_url(c.credential_id), + transports=[AuthenticatorTransport(t) for t in c.get_transports() + if t in {item.value for item in AuthenticatorTransport}], + ) for c in creds ] @@ -163,12 +174,12 @@ def begin_authentication(cls, user: Optional[User] = None) -> Dict: challenge=os.urandom(32), timeout=60000, allow_credentials=allow_credentials, - user_verification='preferred', + user_verification=UserVerificationRequirement.REQUIRED, ) # Store challenge globally or per-user. We store per-user if known. challenge_user_id = user.id if user else 0 cls._set_challenge(challenge_user_id, 'auth', options.challenge) - return json.loads(options.json()) + return json.loads(options_to_json(options)) @classmethod def verify_authentication(cls, credential: Dict, user: Optional[User] = None) -> Dict: @@ -183,17 +194,18 @@ def verify_authentication(cls, credential: Dict, user: Optional[User] = None) -> return {'success': False, 'error': 'Missing credential id'} passkey = PasskeyCredential.query.filter_by(credential_id=credential_id_b64, is_active=True).first() - if not passkey: + if not passkey or (user is not None and passkey.user_id != user.id): return {'success': False, 'error': 'Unknown passkey'} try: - result: AuthenticationResult = verify_authentication_response( + result = verify_authentication_response( credential=credential, expected_challenge=challenge, expected_rp_id=_get_rp_id(), expected_origin=_get_origin(), credential_public_key=_b64decode_url(passkey.public_key), credential_current_sign_count=passkey.sign_count, + require_user_verification=True, ) except InvalidAuthenticationResponse as e: return {'success': False, 'error': str(e)} diff --git a/backend/app/services/repository_application_service.py b/backend/app/services/repository_application_service.py new file mode 100644 index 000000000..f46625723 --- /dev/null +++ b/backend/app/services/repository_application_service.py @@ -0,0 +1,80 @@ +"""Finalize repository imports with consistent compensation on setup failure. + +The caller validates and authorizes the repository/workspace before constructing +the application. Deployment configuration requires its committed ID. Mandatory +configuration failure removes that half-created record and its managed source; +optional manifest enrichment or queue failure keeps the registered application. +""" + +import logging +import os +import shutil + +from app import db, paths +from app.models import Application +from app.services.git_service import GitService +from app.services.build_service import BuildService + +logger = logging.getLogger(__name__) + + +def abort_repository_creation(app): + """Compensate a failed import, never placing half-created rows in the bin.""" + db.session.rollback() + if app.id: + GitService.remove_deployment(app.id) + existing = Application.query.get(app.id) + if existing: + db.session.delete(existing) + db.session.commit() + if os.path.abspath(app.root_path).startswith(os.path.abspath(paths.APPS_DIR) + os.sep): + shutil.rmtree(app.root_path, ignore_errors=True) + + +def finalize_repository_application(app, *, user_id, repo_url, branch, + auto_deploy, build_options, manifest): + """Commit and configure an authorized app, returning presentation data. + + Compensation is exposed separately so an HTTP caller can also unwind a + failure while building its response, retaining the existing route contract. + Non-HTTP callers should use the same compensation if finalization raises. + """ + db.session.add(app) + db.session.commit() + deploy_result = GitService.configure_deployment( + app_id=app.id, app_path=app.root_path, repo_url=repo_url, + branch=branch, auto_deploy=auto_deploy, + ) + if not deploy_result.get('success'): + raise RuntimeError(deploy_result.get('error', 'Failed to configure deployment')) + build_result = BuildService.configure_build( + app_id=app.id, app_path=app.root_path, **build_options, + ) + if not build_result.get('success'): + raise RuntimeError(build_result.get('error', 'Failed to configure build')) + + manifest_summary = None + try: + from app.services.manifest_persistence_service import ManifestPersistenceService + manifest_summary = ManifestPersistenceService.apply_import( + app, manifest, user_id=user_id, source_repo=repo_url, source_ref=branch, + ) + except Exception: + # Enrichment has always been best-effort; creation remains usable. + pass + + deploy_job_id = None + try: + from app.services.deployment_job_service import DeploymentJobService + result = DeploymentJobService.enqueue_app_deploy(app, user_id=user_id, trigger='install') + if result.get('success'): + deploy_job_id = result.get('job_id') + else: + logger.warning('app deploy enqueue failed for app %s: %s', app.id, result.get('error')) + except Exception as exc: + logger.warning('app deploy enqueue failed for app %s: %s', app.id, exc) + + return { + 'deploy_result': deploy_result, 'build_config': build_result.get('config'), + 'manifest_import': manifest_summary, 'deploy_job_id': deploy_job_id, + } diff --git a/backend/app/services/resource_tier_service.py b/backend/app/services/resource_tier_service.py index 47122bc34..f0ba94f3e 100644 --- a/backend/app/services/resource_tier_service.py +++ b/backend/app/services/resource_tier_service.py @@ -153,29 +153,9 @@ def _data_path(): @staticmethod def _detect_container(): - """ - Identify container virtualisation, or None on bare metal / a full VM. - - This matters more than core count on small hosts: Docker frequently - cannot run inside an unprivileged LXC or OpenVZ container at all, so a - box can look adequate on paper and still be unable to host anything. - """ - try: - if os.path.exists('/.dockerenv'): - return 'docker' - if os.path.isdir('/proc/vz') and not os.path.isdir('/proc/bc'): - return 'openvz' - # systemd-nspawn and LXC both advertise themselves here. - with open('/proc/1/environ', 'rb') as fh: - environ = fh.read().decode('utf-8', 'replace') - for entry in environ.split('\0'): - if entry.startswith('container='): - return entry.split('=', 1)[1] or 'container' - except Exception: - # /proc is absent on Windows dev boxes and restricted in some - # sandboxes — "unknown" is the honest answer, not an error. - return None - return None + """Share host inventory's container capability detection.""" + from app.services import host_inventory_service + return host_inventory_service._detect_container() @classmethod def get_headroom(cls, specs=None): diff --git a/backend/app/services/run_access.py b/backend/app/services/run_access.py new file mode 100644 index 000000000..92f02bfbc --- /dev/null +++ b/backend/app/services/run_access.py @@ -0,0 +1,25 @@ +"""Resource visibility shared by run sockets and their polling endpoint.""" + + +def can_read_run(user, run_kind, run_id): + if not user or not user.is_active: + return False + if not isinstance(run_id, (str, int)) or isinstance(run_id, bool): + return False + if run_kind == 'deploy': + from app.models.deployment_job import DeploymentJob + from app.models import Application + from app.services.resource_grant_service import ResourceGrantService + job = DeploymentJob.query.populate_existing().filter_by(id=str(run_id)).first() + if job is None: + return False + if job.app_id: + application = Application.query_active().populate_existing().filter_by(id=job.app_id).first() + return bool(application and ResourceGrantService.can_access_app(user, application)) + return user.is_admin or job.requested_by == user.id + if run_kind == 'job': + # The unified jobs REST surface is admin-only, regardless of owner metadata. + from app.jobs.models import Job + return user.is_admin and Job.query.filter_by(id=str(run_id)).first() is not None + # Adding a new producer requires an explicit visibility policy. + return False diff --git a/backend/app/services/server_metrics_service.py b/backend/app/services/server_metrics_service.py index 3371ee191..8f0c1564a 100644 --- a/backend/app/services/server_metrics_service.py +++ b/backend/app/services/server_metrics_service.py @@ -424,3 +424,37 @@ def _calculate_summary(cls, records: List[ServerMetrics]) -> Dict[str, Any]: 'memory_max': round(max(memory_values), 1) if memory_values else None, 'disk_avg': round(sum(disk_values) / len(disk_values), 1) if disk_values else None, } + + +def latest_metrics_by_server(server_ids, *, order_by='timestamp'): + """Return one model per requested server, with one query (none if empty). + + The server list historically uses insertion order; monitoring uses sample + time so late arrivals cannot replace a newer observation. Callers choose + explicitly when they need insertion order. Equal timestamps resolve to the + highest insertion ID, making ties deterministic without duplicating rows. + Authorization belongs to the caller: only its selected IDs are queried. + """ + if order_by not in ('timestamp', 'id'): + raise ValueError('Latest metrics order must be timestamp or id') + server_ids = list(server_ids) + if not server_ids: + return {} + if order_by == 'id': + # Preserve the list's existing grouped max query without a window sort. + newest = db.session.query(func.max(ServerMetrics.id)).filter( + ServerMetrics.server_id.in_(server_ids), + ).group_by(ServerMetrics.server_id) + rows = ServerMetrics.query.filter(ServerMetrics.id.in_(newest)).all() + else: + ranked = db.session.query( + ServerMetrics.id.label('id'), + func.row_number().over( + partition_by=ServerMetrics.server_id, + order_by=[ServerMetrics.timestamp.desc(), ServerMetrics.id.desc()], + ).label('rank'), + ).filter(ServerMetrics.server_id.in_(server_ids)).subquery() + rows = ServerMetrics.query.join( + ranked, ServerMetrics.id == ranked.c.id, + ).filter(ranked.c.rank == 1).all() + return {row.server_id: row for row in rows} diff --git a/backend/app/services/shared_resource_service.py b/backend/app/services/shared_resource_service.py index 3889a26b9..62c8a70f6 100644 --- a/backend/app/services/shared_resource_service.py +++ b/backend/app/services/shared_resource_service.py @@ -19,6 +19,7 @@ import re from app import db +from app.utils.actor import current_actor_id as _current_user_id from app.models.shared_resource import ( ResourceTag, SharedVariable, @@ -48,21 +49,6 @@ def _audit(action, **kwargs): pass -def _current_user_id(): - """Resolve the acting user id, or None outside a request. - - Goes through rbac.get_current_user() rather than get_jwt_identity() so an - API-key caller is attributed to the key's owner. Reading the JWT directly - *raises* for those requests, and the blanket except below turned that into - a silent user_id=None on every row written here.""" - try: - from app.middleware.rbac import get_current_user - user = get_current_user() - return user.id if user else None - except Exception: - return None - - class SharedResourceService: """Static facade for tags and shared variable groups.""" diff --git a/backend/app/sockets.py b/backend/app/sockets.py index 8a0685409..1e99ee957 100644 --- a/backend/app/sockets.py +++ b/backend/app/sockets.py @@ -1,9 +1,10 @@ from flask_socketio import SocketIO, emit, join_room, leave_room from flask_jwt_extended import decode_token -from flask import request, current_app +from flask import request, current_app, has_app_context import threading import time import queue +import re from app.services.system_service import SystemService from app.services.log_service import LogService, LogStreamer @@ -11,7 +12,26 @@ from app import sockets_rooms as rooms from app.utils.background_loop import BackgroundLoop -socketio = SocketIO() + +class AuthorizedSocketIO(SocketIO): + """Recheck audience authorization before every server-side delivery. + + Background producers also use this instance, so a disabled user or a + removed grant cannot retain access simply by leaving their socket open. + """ + def emit(self, event, *args, **kwargs): + if kwargs.get('namespace', '/') in (None, '/') and self.server is not None: + application = getattr(self, '_security_app', None) + if application is not None: + if has_app_context(): + _prune_audience(kwargs.get('to', kwargs.get('room'))) + else: + with application.app_context(): + _prune_audience(kwargs.get('to', kwargs.get('room'))) + return super().emit(event, *args, **kwargs) + + +socketio = AuthorizedSocketIO() log_streamer = LogStreamer() # Store active metric subscriptions @@ -30,7 +50,7 @@ # what role they hold — so a viewer could join a developer's live terminal # room. Keyed by request.sid; cleaned up on disconnect. Guarded by a lock # because async_mode='threading' runs handlers across worker threads. -connected_clients = {} # sid -> {'user_id': ..., 'role': ...} +connected_clients = {} # sid -> {'user_id': ..., 'role': ..., 'claims': ...} _connected_clients_lock = threading.Lock() # Roles permitted to drive/observe privileged server surfaces (remote @@ -39,20 +59,98 @@ _PRIVILEGED_ROLES = ('admin', 'developer') -def _client_role(sid): - """Return the authenticated role for a connected socket, or None if the - socket isn't in our authenticated set (shouldn't happen post-connect).""" +def _client_user(sid): + from app.middleware.session_auth import validate_session_claims with _connected_clients_lock: info = connected_clients.get(sid) - return info['role'] if info else None + if not info: + return None + user = validate_session_claims(info.get('claims', {})) + # Disconnect on role changes instead of retaining previously joined rooms. + if not user or user.role != info['role']: + _disconnect_client(sid) + return None + return user + + +def _disconnect_client(sid): + with _connected_clients_lock: + connected_clients.pop(sid, None) + metric_subscribers.discard(sid) + container_status_subscribers.discard(sid) + log_streamer.stop_stream(sid) + stop_container_log_stream(sid) + socketio.server.disconnect(sid, namespace='/') + + +def _client_role(sid): + user = _client_user(sid) + return user.role if user else None def _client_is_privileged(sid): - """True when the connected socket belongs to an admin/developer — the - roles allowed to attach to remote terminal streams.""" return _client_role(sid) in _PRIVILEGED_ROLES +def _app_visible(user, app_id): + from app.models import Application + from app.middleware.rbac import app_access_tier + if not isinstance(app_id, (str, int)) or isinstance(app_id, bool): + return False + application = Application.query_active().populate_existing().filter_by(id=app_id).first() + return bool(application and app_access_tier(user, application)) + + +def _server_visible(user, server_id): + from app.models.server import Server + from app.services.workspace_service import WorkspaceService + server = Server.query.populate_existing().filter_by(id=server_id).first() + if not server: + return False + return (user.is_admin or not server.workspace_id + or WorkspaceService.get_user_role(server.workspace_id, user.id) is not None) + + +def _room_allowed(user, room): + from app.services.run_access import can_read_run + if room == rooms.user_room(user.id): + return True + if room.startswith('deploy_'): + return can_read_run(user, 'deploy', room[len('deploy_'):]) + if room.startswith('run_'): + parts = room.split('_', 2) + return len(parts) == 3 and can_read_run(user, parts[1], parts[2]) + match = re.fullmatch(r'logs_(\d+)', room) + if match: + return _app_visible(user, int(match[1])) + match = re.fullmatch(r'server_([^_]+)_(.+)', room) + if not match: + return False + server_id, channel = match.groups() + if channel.startswith('terminal:'): + from app.services.terminal_service import TerminalService + session = TerminalService.get_session(channel[len('terminal:'):]) + return bool(user.role in _PRIVILEGED_ROLES and session + and str(session['user_id']) == str(user.id) + and str(session['server_id']) == server_id + and _server_visible(user, server_id)) + # These are the only generic stream rooms used by the browser. Host jobs + # may print secrets (e.g. cloudflared login URLs), so require an operator. + return bool(re.fullmatch(r'job:[A-Za-z0-9-]+', channel) + and user.role in _PRIVILEGED_ROLES + and _server_visible(user, server_id)) + + +def _prune_audience(room=None): + # Inspect only this delivery's audience; per-sid metrics should not scan + # every other connection for each subscriber. + sids = [sid for sid, _ in socketio.server.manager.get_participants('/', room)] + for sid in sids: + user = _client_user(sid) + if user and room and room != sid and not _room_allowed(user, room): + socketio.server.leave_room(sid, room, namespace='/') + + # ==================== DECLARATIVE CHANNEL REGISTRY (plan 77 E2) ==================== # # Eight hand-rolled subscribe/unsubscribe pairs used four different registry @@ -97,9 +195,9 @@ def _payload(data): return payload def _subscribe(data=None): - data = data or {} + data = data if isinstance(data, dict) else {} sid = request.sid - if sid not in connected_clients: + if not _client_user(sid): emit('error', {'message': 'Authentication required'}) return if auth: @@ -121,7 +219,7 @@ def _subscribe(data=None): emit('subscribed', _payload(data)) def _unsubscribe(data=None): - data = data or {} + data = data if isinstance(data, dict) else {} sid = request.sid if room_fn: try: @@ -155,6 +253,7 @@ def init_socketio(app): cors_allowed_origins=app.config.get('CORS_ORIGINS', '*'), async_mode='threading' ) + socketio._security_app = app return socketio @@ -183,18 +282,16 @@ def handle_connect(auth): emit('error', {'message': 'Invalid token'}) return False - # Resolve the identity to a live, active user. decode_token only proves the - # token is well-formed and unexpired — it says nothing about whether the - # account is still allowed in. - from app.models import User - user_id = decoded.get('sub') - user = User.query.get(user_id) if user_id is not None else None - if not user or not user.is_active: - emit('error', {'message': 'Account not found or deactivated'}) + from app.middleware.session_auth import validate_session_claims + user = validate_session_claims(decoded) + if not user: + emit('error', {'message': 'Invalid or revoked access token'}) return False with _connected_clients_lock: - connected_clients[request.sid] = {'user_id': user.id, 'role': user.role} + connected_clients[request.sid] = { + 'user_id': user.id, 'role': user.role, 'claims': decoded, + } # Join a per-user room so the Notification Bus can push in-app notifications # to every tab/device this user has open. @@ -229,7 +326,8 @@ def handle_disconnect(): def _metrics_tick(): metrics = SystemService.get_all_metrics() - socketio.emit('metrics', metrics, room=None) # Broadcast to all + for sid in list(metric_subscribers): + socketio.emit('metrics', metrics, room=sid) # Ends itself when the last subscriber leaves; restarted by the next @@ -242,7 +340,7 @@ def _metrics_tick(): def _metrics_on_subscribe(sid, data): metric_subscribers.add(sid) - metrics_loop.start() + metrics_loop.start(app=current_app._get_current_object()) def _metrics_on_unsubscribe(sid, data): @@ -267,10 +365,15 @@ def _container_status_tick(): from app.services import container_status_service as css changed = css.get_changed_app_statuses() if changed: - socketio.emit('container_status', { - 'statuses': changed, - 'timestamp': time.time(), - }, room=None) + for sid in list(container_status_subscribers): + user = _client_user(sid) + if not user: + continue + visible = [item for item in changed if _app_visible(user, item['app_id'])] + if visible: + socketio.emit('container_status', { + 'statuses': visible, 'timestamp': time.time(), + }, room=sid) container_status_loop = BackgroundLoop( @@ -306,13 +409,24 @@ def _container_status_on_unsubscribe(sid, data): def _terminal_auth(sid, data): if not _client_is_privileged(sid): return 'Developer role required for terminal access' + user = _client_user(sid) + from app.services.terminal_service import TerminalService + session_id = data.get('session_id') + if not isinstance(session_id, str) or not session_id: + return 'session_id required' + session = TerminalService.get_session(session_id) + if not session: + return 'Unknown terminal session' + if (str(session['user_id']) != str(user.id) + or not _server_visible(user, session['server_id'])): + return 'Terminal access denied' return None def _terminal_room(data): from app.services.terminal_service import TerminalService session_id = data.get('session_id') - if not session_id: + if not isinstance(session_id, str) or not session_id: raise ChannelError('session_id required') session = TerminalService.get_session(session_id) if not session: @@ -333,6 +447,11 @@ def _terminal_room(data): def handle_subscribe_logs(data): """Subscribe to real-time log streaming.""" sid = request.sid + user = _client_user(sid) + if not user or not user.is_admin: + emit('error', {'message': 'Admin access required for host logs'}) + return + data = data if isinstance(data, dict) else {} filepath = data.get('path') if not filepath: @@ -370,23 +489,12 @@ def handle_unsubscribe_logs(): @socketio.on('join_room') def handle_join_room(data): - """Join a specific room for targeted broadcasts. - - This is the generic join used by job-progress and cloudflared-login - streaming (rooms shaped `server__`). It is deliberately - permissive for those — the data mirrors what any authenticated user can - already pull over REST — but it must NOT become a side door into the - privileged terminal stream rooms (`server__terminal:`), which - `subscribe_terminal` gates by role. Enforce that gate here too so the - generic primitive can't be used to bypass it. - """ + """Join only recognized rooms after their resource authorization check.""" + user = _client_user(request.sid) + data = data if isinstance(data, dict) else {} room = data.get('room') - if not room or not isinstance(room, str): - emit('error', {'message': 'room required'}) - return - - if rooms.is_terminal_room(room) and not _client_is_privileged(request.sid): - emit('error', {'message': 'Developer role required for terminal access'}) + if not user or not isinstance(room, str) or not _room_allowed(user, room): + emit('error', {'message': 'Room access denied'}) return join_room(room) @@ -396,8 +504,9 @@ def handle_join_room(data): @socketio.on('leave_room') def handle_leave_room(data): """Leave a specific room.""" + data = data if isinstance(data, dict) else {} room = data.get('room') - if room: + if isinstance(room, str) and room: leave_room(room) emit('left', {'room': room}) @@ -411,9 +520,16 @@ def handle_leave_room(data): # on top of the `GET /deployment-jobs//logs?after_id=` polling endpoint — # the console stays 100% functional with sockets disabled (D2). -# Deploy Console: any authenticated user may watch (mirrors the read API — -# job ids are unguessable UUIDs and the REST read endpoint exposes them the -# same way). +# Deploy visibility matches the persisted job's REST resource gate. + +def _run_auth(sid, kind, run_id): + from app.services.run_access import can_read_run + if not run_id: + return 'job_id required' if kind == 'deploy' else 'run_kind and run_id required' + if not can_read_run(_client_user(sid), kind, run_id): + return 'Run not found or access denied' + return None + def _deploy_room(data): job_id = data.get('job_id') @@ -425,6 +541,7 @@ def _deploy_room(data): register_channel( 'deploy', room_fn=_deploy_room, + auth=lambda sid, data: _run_auth(sid, 'deploy', data.get('job_id')), ack=lambda data: {'job_id': data.get('job_id')}, ) @@ -488,11 +605,11 @@ def _run_channel_room(data): return rooms.run_room(run_kind, run_id) -# Auth mirrors the deploy channel: any authenticated user may watch — run ids -# are unguessable and the REST twin exposes the same data the same way. +# Unknown run kinds are denied until they have an explicit visibility policy. register_channel( 'run', room_fn=_run_channel_room, + auth=lambda sid, data: _run_auth(sid, data.get('run_kind'), data.get('run_id')), ack=lambda data: {'run_kind': data.get('run_kind'), 'run_id': data.get('run_id')}, ) @@ -520,7 +637,12 @@ def handle_subscribe_container_logs(data): from app import db sid = request.sid + user = _client_user(sid) + data = data if isinstance(data, dict) else {} app_id = data.get('app_id') + if not user or not _app_visible(user, app_id): + emit('error', {'message': 'Application access denied'}) + return tail = data.get('tail', 100) since = data.get('since') service = data.get('service') diff --git a/backend/app/utils/actor.py b/backend/app/utils/actor.py new file mode 100644 index 000000000..2fdb1f318 --- /dev/null +++ b/backend/app/utils/actor.py @@ -0,0 +1,15 @@ +"""Optional identity attribution for best-effort service audit records.""" + + +def current_actor_id(): + """Return the JWT/API-key owner ID, or None if unavailable. + + This helper is for attribution only, never for access control. Background + jobs and audit callers without a request keep their existing None actor. + """ + try: + from app.middleware.rbac import get_current_user + user = get_current_user() + return user.id if user else None + except Exception: + return None diff --git a/backend/config.py b/backend/config.py index 5bfe07366..4c8141cc3 100644 --- a/backend/config.py +++ b/backend/config.py @@ -66,6 +66,8 @@ class Config: # Database - use instance folder for Flask convention SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL', 'sqlite:////app/instance/serverkit.db') SQLALCHEMY_TRACK_MODIFICATIONS = False + # Temporary local/staging profiling. Off by default; no SQL text is exposed. + PROFILE_REQUESTS = _env_bool('SERVERKIT_PROFILE_REQUESTS', False) # JWT JWT_SECRET_KEY = os.environ.get('JWT_SECRET_KEY', 'jwt-secret-key-change-in-production') diff --git a/backend/migrations/versions/097_user_auth_version.py b/backend/migrations/versions/097_user_auth_version.py new file mode 100644 index 000000000..079fcba86 --- /dev/null +++ b/backend/migrations/versions/097_user_auth_version.py @@ -0,0 +1,38 @@ +"""Persist the revocation epoch for all browser authentication tokens. + +Existing JWTs lack this claim and intentionally require a fresh login. +""" +from alembic import op +import sqlalchemy as sa + +revision = '097_user_auth_version' +down_revision = '096_index_all_fk_columns' +branch_labels = None +depends_on = None + + +def upgrade(): + # The initial migration bootstraps from current model metadata, so fresh + # installs may already have the new schema by the time they reach here. + inspector = sa.inspect(op.get_bind()) + columns = {column['name'] for column in inspector.get_columns('users')} + if 'auth_version' not in columns: + op.add_column('users', sa.Column('auth_version', sa.String(32), + nullable=False, server_default='0')) + # The startup schema sync may have added the column ahead of us as a bare + # nullable TEXT (no default). A NULL auth_version can never match a token + # claim, which locks every existing user out — always backfill. + op.execute("UPDATE users SET auth_version = '0' " + "WHERE auth_version IS NULL OR auth_version = ''") + if 'revoked_sessions' not in inspector.get_table_names(): + op.create_table('revoked_sessions', + sa.Column('session_id', sa.String(32), primary_key=True), + sa.Column('user_id', sa.Integer(), sa.ForeignKey('users.id', ondelete='CASCADE'), nullable=False), + sa.Column('revoked_at', sa.DateTime(), nullable=False)) + op.create_index('ix_revoked_sessions_user_id', 'revoked_sessions', ['user_id']) + + +def downgrade(): + op.drop_table('revoked_sessions') + with op.batch_alter_table('users') as batch: + batch.drop_column('auth_version') diff --git a/backend/tests/BASELINE_COUNT b/backend/tests/BASELINE_COUNT index f55a50d65..99d2c4f9c 100644 --- a/backend/tests/BASELINE_COUNT +++ b/backend/tests/BASELINE_COUNT @@ -1 +1 @@ -4791 +5089 diff --git a/backend/tests/api_controller_boundary_baseline.json b/backend/tests/api_controller_boundary_baseline.json index a10e1291f..2ea631443 100644 --- a/backend/tests/api_controller_boundary_baseline.json +++ b/backend/tests/api_controller_boundary_baseline.json @@ -2,8 +2,8 @@ "schema_version": 1, "policy": "Exact reviewed inventory of direct persistence, subprocess, and filesystem mutation calls in Flask API controllers. New calls are forbidden; remove entries as controllers delegate to services.", "counts": { - "filesystem": 11, - "persistence": 500 + "filesystem": 8, + "persistence": 488 }, "owners": { "api/admin.py": { @@ -245,7 +245,6 @@ "api/ai.py::chat_confirm::persistence::db.session.get(AiPendingAction, token)::1", "api/ai.py::chat_stream.producer::persistence::db.session.get(AiConversation, conversation_id)::1", "api/ai.py::chat_stream.producer::persistence::db.session.get(AiConversation, conversation_id)::2", - "api/ai.py::chat_stream.producer::persistence::db.session.get(User, user_id)::1", "api/ai.py::create_conversation::persistence::db.session.add(row)::1", "api/ai.py::create_conversation::persistence::db.session.commit()::1", "api/ai.py::delete_conversation::persistence::db.session.commit()::1", @@ -253,9 +252,6 @@ "api/ai.py::list_conversations::persistence::AiConversation.query.filter_by(user_id=user.id).order_by(AiConversation.updated_at.desc()).limit(100).all()::1", "api/ai.py::rename_conversation::persistence::db.session.commit()::1", "api/app_volumes.py::_load_app_for::persistence::Application.query_active().filter_by(id=app_id).first()::1", - "api/apps.py::_ensure_local_image_compose::filesystem::open(os.path.join(app_path, 'docker-compose.yml'), 'w')::1", - "api/apps.py::_ensure_local_image_compose::filesystem::os.makedirs(app_path, exist_ok=True)::1", - "api/apps.py::_ensure_local_image_compose::persistence::db.session.commit()::1", "api/apps.py::_load_app_for_backup::persistence::Application.query_active().filter_by(id=app_id).first()::1", "api/apps.py::_resolve_project_env::persistence::Environment.query.get(environment_id)::1", "api/apps.py::_resolve_project_env::persistence::Project.query.get(project_id)::1", @@ -269,14 +265,7 @@ "api/apps.py::create_app::persistence::db.session.commit()::1", "api/apps.py::create_app_db_snapshot::persistence::db.session.add(snapshot)::1", "api/apps.py::create_app_db_snapshot::persistence::db.session.commit()::1", - "api/apps.py::create_app_from_repository::filesystem::shutil.rmtree(app_path, ignore_errors=True)::1", "api/apps.py::create_app_from_repository::persistence::Application.query.filter_by(name=name, server_id=None).first()::1", - "api/apps.py::create_app_from_repository::persistence::Application.query.get(app.id)::1", - "api/apps.py::create_app_from_repository::persistence::db.session.add(app)::1", - "api/apps.py::create_app_from_repository::persistence::db.session.commit()::1", - "api/apps.py::create_app_from_repository::persistence::db.session.commit()::2", - "api/apps.py::create_app_from_repository::persistence::db.session.delete(existing_app)::1", - "api/apps.py::create_app_from_repository::persistence::db.session.rollback()::1", "api/apps.py::create_manual_app::persistence::Application.query.filter_by(name=name).first()::1", "api/apps.py::create_manual_app::persistence::db.session.add(app)::1", "api/apps.py::create_manual_app::persistence::db.session.commit()::1", @@ -326,7 +315,6 @@ "api/apps.py::purge_micro_cache::persistence::Application.query_active().filter_by(id=app_id).first()::1", "api/apps.py::restart_app::persistence::Application.query_active().filter_by(id=app_id).first()::1", "api/apps.py::restart_app::persistence::User.query.get(current_user_id)::1", - "api/apps.py::restart_app::persistence::db.session.commit()::1", "api/apps.py::revoke_app_access::persistence::Application.query_active().filter_by(id=app_id).first()::1", "api/apps.py::rollback_app_version::persistence::Application.query_active().filter_by(id=app_id).first()::1", "api/apps.py::rollback_app_version::persistence::User.query.get(current_user_id)::1", @@ -340,10 +328,8 @@ "api/apps.py::sleep_app::persistence::Application.query_active().filter_by(id=app_id).first()::1", "api/apps.py::start_app::persistence::Application.query_active().filter_by(id=app_id).first()::1", "api/apps.py::start_app::persistence::User.query.get(current_user_id)::1", - "api/apps.py::start_app::persistence::db.session.commit()::1", "api/apps.py::stop_app::persistence::Application.query_active().filter_by(id=app_id).first()::1", "api/apps.py::stop_app::persistence::User.query.get(current_user_id)::1", - "api/apps.py::stop_app::persistence::db.session.commit()::1", "api/apps.py::unlink_apps::persistence::Application.query.get(app.linked_app_id)::1", "api/apps.py::unlink_apps::persistence::Application.query_active().filter_by(id=app_id).first()::1", "api/apps.py::unlink_apps::persistence::User.query.get(current_user_id)::1", @@ -381,6 +367,8 @@ "api/auth.py::login::persistence::db.session.commit()::2", "api/auth.py::login::persistence::db.session.commit()::3", "api/auth.py::login::persistence::db.session.commit()::4", + "api/auth.py::logout::persistence::db.session.add(RevokedSession(session_id=get_jwt()['session_id'], user_id=g.session_user.id))::1", + "api/auth.py::logout::persistence::db.session.commit()::1", "api/auth.py::passkey_auth_options::persistence::User.query.get(user_id)::1", "api/auth.py::passkey_authenticate::persistence::User.query.get(user_id)::1", "api/auth.py::passkey_authenticate::persistence::db.session.commit()::1", @@ -631,11 +619,9 @@ "api/servers.py::get_server_onboarding_status::persistence::Server.query.get(server_id)::1", "api/servers.py::get_server_security_alerts::persistence::Server.query.get(server_id)::1", "api/servers.py::get_server_status::persistence::Server.query.get(server_id)::1", - "api/servers.py::get_servers_overview::persistence::Server.query.all()::1", + "api/servers.py::get_servers_overview::persistence::Server.query.options(joinedload(Server.group)).all()::1", "api/servers.py::list_agent_versions::persistence::AgentVersion.query.order_by(AgentVersion.version.desc()).all()::1", "api/servers.py::list_groups::persistence::ServerGroup.query.all()::1", - "api/servers.py::list_servers::persistence::ServerMetrics.query.filter(ServerMetrics.id.in_(newest)).all()::1", - "api/servers.py::list_servers::persistence::db.session.query(db.func.max(ServerMetrics.id))::1", "api/servers.py::ping_server::persistence::Server.query.get(server_id)::1", "api/servers.py::regenerate_token::persistence::Server.query.get(server_id)::1", "api/servers.py::regenerate_token::persistence::db.session.commit()::1", @@ -700,7 +686,6 @@ "api/two_factor.py::get_2fa_status::persistence::User.query.get(current_user_id)::1", "api/two_factor.py::initiate_2fa_setup::persistence::User.query.get(current_user_id)::1", "api/two_factor.py::regenerate_backup_codes::persistence::User.query.get(current_user_id)::1", - "api/two_factor.py::verify_2fa_code::persistence::User.query.get(user_id)::1", "api/two_factor.py::verify_2fa_code::persistence::db.session.commit()::1", "api/two_factor.py::verify_2fa_code::persistence::db.session.commit()::2", "api/two_factor.py::verify_2fa_code::persistence::db.session.commit()::3", diff --git a/backend/tests/factories.py b/backend/tests/factories.py index 98d17864e..384221a25 100644 --- a/backend/tests/factories.py +++ b/backend/tests/factories.py @@ -39,11 +39,16 @@ def make_user(db, username=None, role='developer', password='x', **overrides): return user -def headers_for(user): - """JWT auth headers for a User row (or a raw user id).""" +def access_token_for(user, **token_options): + """Mint through one door, allowing explicit malformed/expired test claims.""" from flask_jwt_extended import create_access_token user_id = getattr(user, 'id', user) - return {'Authorization': f'Bearer {create_access_token(identity=user_id)}'} + return create_access_token(identity=user_id, **token_options) + + +def headers_for(user, **token_options): + """JWT auth headers for a User row (or a raw user id).""" + return {'Authorization': f'Bearer {access_token_for(user, **token_options)}'} def make_workspace(db, name='ws', created_by=None, **overrides): diff --git a/backend/tests/test_ai_security_boundaries.py b/backend/tests/test_ai_security_boundaries.py new file mode 100644 index 000000000..90f752e61 --- /dev/null +++ b/backend/tests/test_ai_security_boundaries.py @@ -0,0 +1,336 @@ +"""Offline regressions for AI authorization and provider-boundary protection.""" +import json +import threading +from types import SimpleNamespace + +import pytest + +from app import db +from app.services import ai_service +from app.services.ai_tool_registry import ToolDescriptor, ai_tool_registry +from app.services.ai_tools_builtin import register_builtin_tools, tool_caller +from factories import headers_for, make_application, make_server, make_user, make_workspace + + +@pytest.fixture +def no_pii(monkeypatch): + monkeypatch.setattr(ai_service, '_pii_enabled', lambda: False) + + +def descriptor(func, *, write=False): + return ToolDescriptor('probe', 'probe__probe', func, 'Security probe', {}, + plugin_slug='probe', is_write=write) + + +def test_ai_app_list_matches_rest_visibility_and_omits_config(app, client, no_pii): + from app.services.resource_grant_service import ResourceGrantService + with app.app_context(): + viewer = make_user(db, role='viewer') + owner = make_user(db) + admin = make_user(db, role='admin') + own = make_application(db, name='own', user_id=viewer.id) + shared = make_application(db, name='shared', user_id=owner.id) + foreign = make_application(db, name='foreign', user_id=owner.id) + deleted = make_application(db, name='deleted', user_id=viewer.id) + from datetime import datetime + deleted.deleted_at = datetime.utcnow() + db.session.commit() + ResourceGrantService.grant(viewer.id, 'application', shared.id, role='viewer') + register_builtin_tools() + d = ai_tool_registry.get('core__list_applications') + result = ai_service._make_read_wrapper(d, viewer)() + assert {r['id'] for r in result} == {own.id, shared.id} + assert all(set(r) == {'id', 'name', 'status', 'app_type', 'port'} for r in result) + assert client.get(f'/api/v1/apps/{foreign.id}', headers=headers_for(viewer)).status_code == 403 + assert client.get(f'/api/v1/apps/{shared.id}', headers=headers_for(viewer)).status_code == 200 + assert {r['id'] for r in ai_service._make_read_wrapper(d, admin)()} == { + own.id, shared.id, foreign.id, + } + assert tool_caller.get() is None + + +def test_workspace_membership_does_not_grant_foreign_apps(app, no_pii): + from app.models.workspace import WorkspaceMember + with app.app_context(): + viewer = make_user(db, role='viewer') + owner = make_user(db) + ws = make_workspace(db, created_by=owner.id) + db.session.add(WorkspaceMember(workspace_id=ws.id, user_id=viewer.id, role='member')) + db.session.commit() + mine = make_application(db, user_id=viewer.id, workspace_id=ws.id) + make_application(db, user_id=owner.id, workspace_id=ws.id) + other = make_application(db, user_id=viewer.id) + register_builtin_tools() + wrapper = ai_service._make_read_wrapper(ai_tool_registry.get('core__list_applications'), viewer) + assert {r['id'] for r in wrapper(workspace_id=ws.id)} == {mine.id} + assert {r['id'] for r in wrapper()} == {mine.id, other.id} + + +def test_ai_server_list_uses_workspace_scope_and_summary(app, no_pii): + with app.app_context(): + user = make_user(db, role='admin') + ws = make_workspace(db, created_by=user.id) + selected = make_server(db, name='selected', workspace_id=ws.id) + make_server(db, name='other') + register_builtin_tools() + result = ai_service._make_read_wrapper(ai_tool_registry.get('core__list_servers'), user)(workspace_id=ws.id) + assert [r['id'] for r in result] == [selected.id] + assert set(result[0]) == {'id', 'name', 'status', 'hostname'} + + +def test_raw_host_database_and_docker_write_tools_require_admin(app): + with app.app_context(): + developer = make_user(db) + register_builtin_tools() + for name in ('list_databases', 'stop_docker_container', 'restart_docker_container'): + d = ai_tool_registry.get('core__' + name) + assert not d.allowed_for(developer) + assert 'core__stop_docker_container' not in { + d.qualified_name for d in ai_tool_registry.list_for(developer, 'assistant') + } + + +class ApprovingGate: + def __init__(self, on_approval=lambda: None, cancelled=False): + self.on_approval = on_approval + self.cancelled = cancelled + self.result = None + self.failed = False + + def request_confirmation(self, d, params): + self.on_approval() + return 'approve', 'test-token' + + def is_cancelled(self): + return self.cancelled + + def mark_executed(self, token, result): + self.result = result + + def mark_failed(self, token, error): + self.failed = True + + +@pytest.mark.parametrize('change', ['disable', 'role', 'session', 'cancel']) +def test_write_rechecks_caller_after_confirmation(app, no_pii, change): + with app.app_context(): + user = make_user(db, role='admin') + called = [] + d = descriptor(lambda: called.append(True), write=True) + d.admin_only = True + + def change_access(): + if change == 'disable': + user.is_active = False + elif change == 'role': + user.role = 'viewer' + elif change == 'session': + user.auth_version = 'revoked' + db.session.commit() + + gate = ApprovingGate(change_access, cancelled=change == 'cancel') + result = ai_service._make_write_wrapper(d, user, gate)() + assert not called + assert 'Permission denied' in result + assert gate.failed + + +def test_protected_docker_container_never_restarts(app, monkeypatch, no_pii): + from app.services.docker_service import DockerService + with app.app_context(): + user = make_user(db, role='admin') + called = [] + monkeypatch.setattr(DockerService, 'is_protected_container', lambda cid: True) + monkeypatch.setattr(DockerService, 'restart_container', lambda cid: called.append(cid)) + register_builtin_tools() + gate = ApprovingGate() + ai_service._make_write_wrapper(ai_tool_registry.get('core__restart_docker_container'), user, gate)(container_id='panel') + assert not called + assert gate.failed + + +def test_nested_results_redact_secrets_and_pii(monkeypatch): + monkeypatch.setattr(ai_service, '_pii_enabled', lambda: True) + monkeypatch.setattr(ai_service, '_get_pii_redactor', lambda: SimpleNamespace( + redact=lambda value: SimpleNamespace(text=value.replace('private@example.test', '[email]')))) + data = {'rows': [{'password': {'value': 'never-send'}, 'email': 'private@example.test', + 'log': 'password=hidden Authorization: Bearer abcdef'}]} + result = ai_service._maybe_redact_result(data) + rendered = json.dumps(result) + assert all(secret not in rendered for secret in ('never-send', 'private@example.test', 'hidden', 'abcdef')) + assert data['rows'][0]['password']['value'] == 'never-send' + + +def test_secret_filter_runs_with_pii_disabled(no_pii): + for text in ('password=hunter2', 'Authorization: Bearer abcdef', + 'mysql://root:dbpass@host/db', + '-----BEGIN RSA PRIVATE KEY-----\nsecret\n-----END RSA PRIVATE KEY-----'): + safe = ai_service.redact_input(text) + assert '[redacted]' in safe + assert all(secret not in safe for secret in ('hunter2', 'abcdef', 'dbpass', '\nsecret\n')) + + +def test_read_and_write_result_share_protection(app, no_pii): + with app.app_context(): + user = make_user(db) + data = {'items': [{'api_key': 'never-send'}]} + read = ai_service._make_read_wrapper(descriptor(lambda: data), user)() + gate = ApprovingGate() + write = ai_service._make_write_wrapper(descriptor(lambda: data, write=True), user, gate)() + assert read == write == gate.result == {'items': [{'api_key': '[redacted]'}]} + + +def test_enabled_protection_failure_never_returns_original(monkeypatch): + monkeypatch.setattr(ai_service, '_pii_enabled', lambda: True) + def broken(): + raise RuntimeError('offline') + monkeypatch.setattr(ai_service, '_get_pii_redactor', broken) + with pytest.raises(ai_service.AIProtectionError): + ai_service.redact_input('private@example.test') + with pytest.raises(ai_service.AIProtectionError): + ai_service._maybe_redact_result({'email': 'private@example.test'}) + monkeypatch.setattr(ai_service, '_setting', lambda key, default=None: True) + monkeypatch.setattr(ai_service, '_get_injection_detector', broken) + with pytest.raises(ai_service.AIProtectionError): + ai_service.injection_flagged('message') + + +@pytest.mark.parametrize('route', ['/chat', '/chat/stream']) +def test_chat_bounds_and_visible_protection_failure(client, auth_headers, monkeypatch, route): + monkeypatch.setattr(ai_service, 'ensure_initialized', lambda: None) + monkeypatch.setattr(ai_service, 'is_configured', lambda: True) + path = '/api/v1/ai' + route + invalid = client.post(path, headers=auth_headers, json={'message': 42}) + assert invalid.status_code == 400 and invalid.json['code'] == 'validation_error' + assert client.post(path, headers=auth_headers, json={'message': 'x' * 16001}).status_code == 400 + oversized = client.post(path, headers=auth_headers, json={'message': 'x' * 140000}) + assert oversized.status_code == 413 and oversized.json['code'] == 'request_entity_too_large' + def broken(text): + raise ai_service.AIProtectionError('AI privacy protection is unavailable.') + monkeypatch.setattr(ai_service, 'injection_flagged', broken) + response = client.post(path, headers=auth_headers, json={'message': 'hello'}) + assert response.status_code == 503 + assert response.json['code'] == 'dependency_unavailable' + assert 'protection is unavailable' in response.json['error'] + + +@pytest.mark.parametrize('route', ['/chat', '/chat/stream']) +def test_chat_busy_uses_typed_http_error(client, auth_headers, monkeypatch, route): + from app.api import ai + monkeypatch.setattr(ai_service, 'ensure_initialized', lambda: None) + monkeypatch.setattr(ai_service, 'is_configured', lambda: True) + monkeypatch.setattr(ai_service, 'injection_flagged', lambda text: False) + monkeypatch.setattr(ai_service, 'redact_input', lambda text: text) + monkeypatch.setattr(ai, '_claim_turn', lambda uid: False) + response = client.post('/api/v1/ai' + route, headers=auth_headers, json={'message': 'hello'}) + assert response.status_code == 429 + assert response.json['code'] == 'too_many_requests' + assert response.json['error'] == 'AI is busy. Wait for the current turn to finish.' + assert response.json['request_id'] + + +def test_turn_limits_bound_users_and_panel(): + from app.api import ai + try: + for uid in range(ai.MAX_ACTIVE_TURNS): + assert ai._claim_turn(uid) + assert not ai._claim_turn(0) + assert not ai._claim_turn(999) + ai._release_turn(0) + assert ai._claim_turn(999) + finally: + for uid in (*range(ai.MAX_ACTIVE_TURNS), 999): + ai._release_turn(uid) + + +def test_cancelled_confirmation_does_not_create_pending_action(app): + from app.models.ai import AiPendingAction + with app.app_context(): + cancelled = threading.Event() + cancelled.set() + gate = ai_service.ConfirmationGate('unused', 1, lambda *args: None, cancelled, 5) + assert gate.request_confirmation(descriptor(lambda: None), {}) == ('deny', '') + assert AiPendingAction.query.count() == 0 + + +def test_stream_tool_rejects_logged_out_session_after_approval(app, no_pii): + from flask_jwt_extended import decode_token + from factories import access_token_for + from app.models import RevokedSession + with app.app_context(): + user = make_user(db, role='admin') + claims = decode_token(access_token_for(user)) + called = [] + + def logout(): + db.session.add(RevokedSession(session_id=claims['session_id'], user_id=user.id)) + db.session.commit() + + gate = ApprovingGate(logout) + gate.session_claims = claims + wrapper = ai_service._make_write_wrapper(descriptor(lambda: called.append(True), write=True), user, gate) + assert 'Permission denied' in wrapper() + assert not called + assert 'Permission denied' in ai_service._make_read_wrapper( + descriptor(lambda: called.append(True)), user, session_claims=claims)() + assert not called + + +def test_service_failure_is_not_recorded_as_success(app, no_pii): + with app.app_context(): + user = make_user(db) + gate = ApprovingGate() + output = {'success': False, 'error': 'Service could not restart'} + result = ai_service._make_write_wrapper(descriptor(lambda: output, write=True), user, gate)() + assert result == output + assert gate.failed and gate.result is None + + +def test_disconnecting_at_open_cancels_stream_and_releases_slot(client, auth_headers, monkeypatch): + from app.api import ai + from prompture.agents.live_events import TextDelta + released = threading.Event() + original_release = ai._release_turn + + def release(uid): + original_release(uid) + released.set() + + class FakeConversation: + def ask_live(self, message): + for _ in range(2000): + yield TextDelta(text='x') + + monkeypatch.setattr(ai, '_release_turn', release) + monkeypatch.setattr(ai_service, 'ensure_initialized', lambda: None) + monkeypatch.setattr(ai_service, 'is_configured', lambda: True) + monkeypatch.setattr(ai_service, 'injection_flagged', lambda message: False) + monkeypatch.setattr(ai_service, 'redact_input', lambda message: message) + monkeypatch.setattr(ai_service, 'build_conversation', lambda *args, **kwargs: FakeConversation()) + response = client.post('/api/v1/ai/chat/stream', headers=auth_headers, + json={'message': 'hello'}, buffered=False) + assert response.status_code == 200 + assert 'event: open' in next(iter(response.response)).decode() + response.close() + assert released.wait(5), 'Disconnected worker retained a concurrency slot' + + +def test_old_stream_cleanup_does_not_unregister_new_gate(): + first = ai_service.ConfirmationGate('same-conversation', 1, lambda *args: None, threading.Event(), 5) + second = ai_service.ConfirmationGate('same-conversation', 1, lambda *args: None, threading.Event(), 5) + try: + ai_service.register_gate('same-conversation', first) + ai_service.register_gate('same-conversation', second) + ai_service.unregister_gate('same-conversation', first) + assert ai_service._active_gates['same-conversation'] is second + finally: + ai_service.unregister_gate('same-conversation', second) + + +def test_read_exception_does_not_leak_credentials_to_model(app, no_pii): + with app.app_context(): + user = make_user(db) + def broken(): + raise RuntimeError('Cannot connect to mysql://root:secret@host/db') + result = ai_service._make_read_wrapper(descriptor(broken), user)() + assert result == 'The tool failed to retrieve data.' diff --git a/backend/tests/test_api_key_scope_boundary.py b/backend/tests/test_api_key_scope_boundary.py new file mode 100644 index 000000000..908b5f7fa --- /dev/null +++ b/backend/tests/test_api_key_scope_boundary.py @@ -0,0 +1,62 @@ +"""Restricted keys must never inherit their owner's unrestricted role.""" +import pytest +from flask import Blueprint, jsonify + +from factories import make_user, headers_for +from app.middleware.api_scope_middleware import require_scope +from app.middleware.rbac import admin_required, auth_required +from app.services.api_key_service import ApiKeyService + + +def _headers(db, scopes, role='admin'): + user = make_user(db, role=role) + _, raw_key = ApiKeyService.create_key(user.id, name='scope-boundary', scopes=scopes) + return {'X-API-Key': raw_key} + + +@pytest.mark.parametrize('scopes', [['apps:read'], ['read'], ['apps:*'], ['write']]) +def test_restricted_admin_key_cannot_modify_unscoped_ai_settings(client, db_session, scopes): + response = client.put('/api/v1/ai/settings', headers=_headers(db_session, scopes), + json={'ai_enabled': True}) + assert response.status_code == 403 + assert response.json['error'] == 'This endpoint does not allow restricted API keys' + + +@pytest.mark.fresh_app +def test_scope_declarations_are_required_and_do_not_replace_role_policy(app, db_session): + bp = Blueprint('scope_boundary', __name__) + + @bp.route('/read') + @auth_required() + @require_scope('apps:read') + def read(): + return jsonify({'ok': True}) + + @bp.route('/write', methods=['PUT']) + @admin_required + @require_scope('apps:write') + def write(): + return jsonify({'ok': True}) + + @bp.route('/undeclared', methods=['PUT']) + @admin_required + def undeclared(): + return jsonify({'ok': True}) + + app.register_blueprint(bp, url_prefix='/__scope_boundary') + client = app.test_client() + read_key = _headers(db_session, ['apps:read']) + assert client.get('/__scope_boundary/read', headers=read_key).status_code == 200 + assert client.put('/__scope_boundary/write', headers=read_key).status_code == 403 + assert client.put('/__scope_boundary/undeclared', headers=read_key).status_code == 403 + wrong_resource = _headers(db_session, ['databases:read']) + assert client.get('/__scope_boundary/read', headers=wrong_resource).status_code == 403 + wildcard = _headers(db_session, ['apps:*']) + assert client.put('/__scope_boundary/write', headers=wildcard).status_code == 200 + assert client.put('/__scope_boundary/undeclared', headers=wildcard).status_code == 403 + full = _headers(db_session, ['*']) + assert client.put('/__scope_boundary/undeclared', headers=full).status_code == 200 + viewer = _headers(db_session, ['*'], role='viewer') + assert client.put('/__scope_boundary/write', headers=viewer).status_code == 403 + jwt = headers_for(make_user(db_session, role='admin')) + assert client.put('/__scope_boundary/undeclared', headers=jwt).status_code == 200 diff --git a/backend/tests/test_app_deploy_jobs.py b/backend/tests/test_app_deploy_jobs.py index 34055e5cd..7e6c65440 100644 --- a/backend/tests/test_app_deploy_jobs.py +++ b/backend/tests/test_app_deploy_jobs.py @@ -264,6 +264,26 @@ def test_enqueue_failure_does_not_fail_creation(self, app, client, auth_headers, assert res.status_code == 201 assert res.get_json()['deploy_job_id'] is None + @pytest.mark.parametrize('stage', ['deployment', 'build']) + def test_setup_failure_unwinds_committed_app(self, app, client, auth_headers, monkeypatch, stage): + from unittest.mock import Mock + from app.models import Application + from app.services.git_service import GitService + from app.services.build_service import BuildService + self._mock_create_stack(monkeypatch, enqueue_result={'success': True, 'job_id': 'unused'}) + cleanup = Mock() + monkeypatch.setattr(GitService, 'remove_deployment', cleanup) + service, method = ((GitService, 'configure_deployment') if stage == 'deployment' + else (BuildService, 'configure_build')) + monkeypatch.setattr(service, method, Mock(return_value={'success': False, 'error': 'setup rejected'})) + res = client.post('/api/v1/apps/from-repository', headers=auth_headers, json={ + 'name': 'repo-failed', 'repo_url': 'https://github.com/acme/repo-failed.git', + }) + assert res.status_code == 400 + assert res.get_json()['error'] == 'setup rejected' + assert Application.query.filter_by(name='repo-failed').first() is None + cleanup.assert_called_once() + class TestListJobsAppIdFilter: def test_service_filter(self, app): diff --git a/backend/tests/test_app_lifecycle_noncompose.py b/backend/tests/test_app_lifecycle_noncompose.py index b2235b2d7..56857c0e2 100644 --- a/backend/tests/test_app_lifecycle_noncompose.py +++ b/backend/tests/test_app_lifecycle_noncompose.py @@ -20,7 +20,7 @@ import pytest -from app.api.apps import _is_single_container_app +from app.services.application_lifecycle_service import _is_single_container_app from app.models import Application from factories import headers_for, make_application, make_user @@ -62,10 +62,10 @@ def test_a_non_buildpack_app_keeps_the_compose_path(buildpack_app): # ── start ──────────────────────────────────────────────────────────────────── def test_start_drives_the_container_and_never_compose(client, buildpack_app, owner): - with patch('app.api.apps.DockerService.get_container', return_value={'Id': 'abc'}), \ - patch('app.api.apps.DockerService.start_container', + with patch('app.services.application_lifecycle_service.DockerService.get_container', return_value={'Id': 'abc'}), \ + patch('app.services.application_lifecycle_service.DockerService.start_container', return_value={'success': True}) as start, \ - patch('app.api.apps.DockerService.compose_up') as compose: + patch('app.services.application_lifecycle_service.DockerService.compose_up') as compose: response = client.post(f'/api/v1/apps/{buildpack_app.id}/start', headers=headers_for(owner)) @@ -75,8 +75,8 @@ def test_start_drives_the_container_and_never_compose(client, buildpack_app, own def test_start_before_any_deploy_says_so(client, buildpack_app, owner): - with patch('app.api.apps.DockerService.get_container', return_value=None), \ - patch('app.api.apps.DockerService.compose_up') as compose: + with patch('app.services.application_lifecycle_service.DockerService.get_container', return_value=None), \ + patch('app.services.application_lifecycle_service.DockerService.compose_up') as compose: response = client.post(f'/api/v1/apps/{buildpack_app.id}/start', headers=headers_for(owner)) @@ -88,10 +88,10 @@ def test_start_before_any_deploy_says_so(client, buildpack_app, owner): # ── restart ────────────────────────────────────────────────────────────────── def test_restart_drives_the_container_and_never_compose(client, buildpack_app, owner): - with patch('app.api.apps.DockerService.get_container', return_value={'Id': 'abc'}), \ - patch('app.api.apps.DockerService.restart_container', + with patch('app.services.application_lifecycle_service.DockerService.get_container', return_value={'Id': 'abc'}), \ + patch('app.services.application_lifecycle_service.DockerService.restart_container', return_value={'success': True}) as restart, \ - patch('app.api.apps.DockerService.compose_restart') as compose: + patch('app.services.application_lifecycle_service.DockerService.compose_restart') as compose: response = client.post(f'/api/v1/apps/{buildpack_app.id}/restart', headers=headers_for(owner)) @@ -103,8 +103,8 @@ def test_restart_drives_the_container_and_never_compose(client, buildpack_app, o # ── stop ───────────────────────────────────────────────────────────────────── def test_stop_of_a_vanished_container_is_not_a_failure(client, buildpack_app, owner): - with patch('app.api.apps.DockerService.get_container', return_value=None), \ - patch('app.api.apps.DockerService.compose_down') as compose: + with patch('app.services.application_lifecycle_service.DockerService.get_container', return_value=None), \ + patch('app.services.application_lifecycle_service.DockerService.compose_down') as compose: response = client.post(f'/api/v1/apps/{buildpack_app.id}/stop', headers=headers_for(owner)) @@ -114,10 +114,10 @@ def test_stop_of_a_vanished_container_is_not_a_failure(client, buildpack_app, ow def test_stop_drives_the_container_when_it_exists(client, buildpack_app, owner): - with patch('app.api.apps.DockerService.get_container', return_value={'Id': 'abc'}), \ - patch('app.api.apps.DockerService.stop_container', + with patch('app.services.application_lifecycle_service.DockerService.get_container', return_value={'Id': 'abc'}), \ + patch('app.services.application_lifecycle_service.DockerService.stop_container', return_value={'success': True}) as stop, \ - patch('app.api.apps.DockerService.compose_down') as compose: + patch('app.services.application_lifecycle_service.DockerService.compose_down') as compose: response = client.post(f'/api/v1/apps/{buildpack_app.id}/stop', headers=headers_for(owner)) diff --git a/backend/tests/test_application_lifecycle_service.py b/backend/tests/test_application_lifecycle_service.py new file mode 100644 index 000000000..d691778ba --- /dev/null +++ b/backend/tests/test_application_lifecycle_service.py @@ -0,0 +1,153 @@ +"""Lifecycle dispatch, failure handling and side effects without HTTP context.""" + +from types import SimpleNamespace +from unittest.mock import Mock + +import pytest + +from app.services import application_lifecycle_service as lifecycle + + +@pytest.fixture +def runtime(monkeypatch, tmp_path): + docker = Mock() + remote = Mock() + registry = Mock() + session = Mock() + invalidate = Mock() + monkeypatch.setattr(lifecycle, 'DockerService', docker) + monkeypatch.setattr(lifecycle, 'RemoteDockerService', remote) + monkeypatch.setattr(lifecycle, 'ContainerRegistryService', registry) + monkeypatch.setattr(lifecycle, 'db', SimpleNamespace(session=session)) + monkeypatch.setattr(lifecycle.container_status_service, 'invalidate', invalidate) + app = SimpleNamespace( + id=42, app_type='docker', root_path=str(tmp_path), server_id=None, + compose_file='custom.yml', buildpack_type=None, docker_image=None, + status='original', + ) + return SimpleNamespace( + app=app, docker=docker, remote=remote, registry=registry, + session=session, invalidate=invalidate, + ) + + +@pytest.mark.parametrize('operation,method,status', [ + ('start', 'up', 'running'), ('stop', 'down', 'stopped'), + ('restart', 'restart', 'running'), +]) +@pytest.mark.parametrize('target', ['local', 'remote', 'single']) +def test_dispatch_and_commit_before_invalidation(runtime, operation, method, status, target): + app = runtime.app + if target == 'remote': + app.server_id = 'remote-server' + service = runtime.remote + else: + service = runtime.docker + if target == 'single': + app.compose_file = None + app.buildpack_type = 'nixpacks' + command = getattr(service, f'{operation}_container') + else: + command = getattr(service, f'compose_{method}') + command.return_value = {'success': True} + effects = [] + runtime.session.commit.side_effect = lambda: effects.append('commit') + runtime.invalidate.side_effect = lambda value: effects.append(('invalidate', value)) + + getattr(lifecycle, f'{operation}_application')(app, user_id=7) + + assert app.status == status + assert effects == ['commit', ('invalidate', 42)] + kwargs = {'detach': True} if operation == 'start' else {} + if target == 'remote': + command.assert_called_once_with( + 'remote-server', lifecycle._compose_target(app), user_id=7, **kwargs, + ) + assert not runtime.docker.mock_calls + elif target == 'single': + command.assert_called_once_with('serverkit-app-42') + else: + command.assert_called_once_with(app.root_path, compose_file='custom.yml', **kwargs) + if operation == 'start': + runtime.registry.login_for_app.assert_called_once_with(app) + runtime.registry.logout_for_app.assert_called_once_with(runtime.registry.login_for_app.return_value) + + +@pytest.mark.parametrize('operation,method', [('start', 'up'), ('stop', 'down'), ('restart', 'restart')]) +def test_agent_failure_preserves_status_and_cache(runtime, operation, method): + runtime.app.server_id = 'remote-server' + getattr(runtime.remote, f'compose_{method}').return_value = { + 'success': True, 'data': {'success': False, 'error': 'agent rejected'}, + } + with pytest.raises(lifecycle.ApplicationLifecycleError, match='agent rejected'): + getattr(lifecycle, f'{operation}_application')(runtime.app, user_id=7) + assert runtime.app.status == 'original' + runtime.session.commit.assert_not_called() + runtime.invalidate.assert_not_called() + + +def test_registry_logout_even_when_compose_raises(runtime): + runtime.docker.compose_up.side_effect = RuntimeError('docker unavailable') + with pytest.raises(RuntimeError, match='docker unavailable'): + lifecycle.start_application(runtime.app) + runtime.registry.logout_for_app.assert_called_once_with(runtime.registry.login_for_app.return_value) + runtime.session.commit.assert_not_called() + runtime.invalidate.assert_not_called() + + +def test_commit_failure_does_not_invalidate(runtime): + runtime.docker.compose_up.return_value = {'success': True} + runtime.session.commit.side_effect = RuntimeError('database unavailable') + with pytest.raises(RuntimeError, match='database unavailable'): + lifecycle.start_application(runtime.app) + runtime.invalidate.assert_not_called() + + +def test_first_image_start_persists_compose_before_runtime_failure(runtime, monkeypatch, tmp_path): + app = runtime.app + app.root_path = None + app.compose_file = None + app.docker_image = 'example/private:latest' + app.name = 'image-app' + app.port = 8080 + app.healthcheck_path = '/health' + app.managed_by = None + monkeypatch.setattr(lifecycle.paths, 'APPS_DIR', str(tmp_path)) + monkeypatch.setattr(lifecycle.AppPortService, 'get_ports', lambda value: []) + render = Mock(return_value='services: {}\n') + monkeypatch.setattr(lifecycle.UnitComposeService, 'render_yaml', render) + runtime.docker.compose_up.return_value = {'success': False, 'error': 'pull failed'} + + with pytest.raises(lifecycle.ApplicationLifecycleError, match='pull failed'): + lifecycle.start_application(app) + + assert (tmp_path / 'image-app' / 'docker-compose.yml').read_text() == 'services: {}\n' + assert app.compose_file == 'docker-compose.yml' + assert app.managed_by == 'docker_compose' + assert app.status == 'original' + runtime.session.commit.assert_called_once() + runtime.invalidate.assert_not_called() + render.assert_called_once_with('image-app', [{ + 'name': 'app', 'image': 'example/private:latest', + 'ports': [{'host_port': 8080, 'container_port': 8080, 'expose': 'local'}], + 'health_check': {'http_path': '/health'}, + }]) + # A retry uses the saved project instead of writing/committing it again. + runtime.docker.compose_up.return_value = {'success': True} + lifecycle.start_application(app) + assert runtime.session.commit.call_count == 2 + assert render.call_count == 1 + runtime.invalidate.assert_called_once_with(app.id) + + +def test_image_path_validation_rejects_escape_before_write(runtime, monkeypatch, tmp_path): + app = runtime.app + app.root_path = None + app.docker_image = 'example/image:latest' + app.name = '../outside' + monkeypatch.setattr(lifecycle.paths, 'APPS_DIR', str(tmp_path)) + with pytest.raises(lifecycle.ApplicationLifecycleError, match='Invalid application path'): + lifecycle.start_application(app) + assert not list(tmp_path.iterdir()) + runtime.session.commit.assert_not_called() + runtime.invalidate.assert_not_called() diff --git a/backend/tests/test_auth_version_backfill.py b/backend/tests/test_auth_version_backfill.py new file mode 100644 index 000000000..f3f4920e1 --- /dev/null +++ b/backend/tests/test_auth_version_backfill.py @@ -0,0 +1,82 @@ +"""users.auth_version must never be NULL for an existing row. + +Regression for the 1.9.27 dev upgrade on a live box: the startup schema sync +(`MigrationService._fix_missing_columns`) runs BEFORE alembic and added +`auth_version` as a bare nullable TEXT, so migration 097 saw the column and +skipped its `'0'` server_default. Every pre-existing user then carried a NULL +auth_version, no JWT claim could match it, and nobody could log in. +""" +import importlib.util +import os +from types import SimpleNamespace + +import sqlalchemy as sa +from alembic.migration import MigrationContext +from alembic.operations import Operations + +from app import db +from app.services.migration_service import MigrationService + +_MIGRATION = os.path.join(os.path.dirname(os.path.dirname(__file__)), + 'migrations', 'versions', '097_user_auth_version.py') + + +def _legacy_users_engine(tmp_path, *, with_auth_version): + """A pre-097 users table holding one real row (plus alembic_version).""" + engine = sa.create_engine(f'sqlite:///{tmp_path / "legacy.db"}') + users = db.metadata.tables['users'] + legacy = sa.MetaData() + columns = [c.copy() for c in users.columns + if c.name != 'auth_version' or with_auth_version] + sa.Table('users', legacy, *columns) + legacy.create_all(engine) + with engine.begin() as conn: + conn.execute(sa.text( + "INSERT INTO users (id, username, email, password_hash, role, is_active) " + "VALUES (1, 'legacy', 'legacy@example.com', 'x', 'admin', 1)")) + return engine + + +def test_startup_schema_sync_backfills_server_default(tmp_path, app): + engine = _legacy_users_engine(tmp_path, with_auth_version=False) + fake_db = SimpleNamespace(engine=engine, metadata=db.metadata, create_all=lambda: None) + + MigrationService._fix_missing_columns(fake_db) + + with engine.connect() as conn: + assert conn.execute(sa.text('SELECT auth_version FROM users')).scalar() == '0' + ddl = conn.execute(sa.text( + "SELECT sql FROM sqlite_master WHERE name = 'users'")).scalar() + assert "auth_version TEXT DEFAULT '0'" in ddl + + +def test_migration_097_backfills_a_pre_added_null_column(tmp_path, app): + engine = _legacy_users_engine(tmp_path, with_auth_version=False) + with engine.begin() as conn: + # Exactly what the pre-fix schema sync produced on the live box. + conn.execute(sa.text('ALTER TABLE users ADD COLUMN auth_version TEXT')) + assert conn.execute(sa.text('SELECT auth_version FROM users')).scalar() is None + + spec = importlib.util.spec_from_file_location('migration_097', _MIGRATION) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + + with engine.begin() as conn: + ctx = MigrationContext.configure(conn) + with Operations.context(ctx): + module.upgrade() + + with engine.connect() as conn: + assert conn.execute(sa.text('SELECT auth_version FROM users')).scalar() == '0' + assert sa.inspect(conn).has_table('revoked_sessions') + + +def test_sqlite_default_rendering(): + def col(default): + return sa.Column('c', sa.String(), server_default=default) + + assert MigrationService._sqlite_default(col('0')) == "'0'" + assert MigrationService._sqlite_default(col("it's")) == "'it''s'" + assert MigrationService._sqlite_default(col(sa.true())) is None + assert MigrationService._sqlite_default(col(sa.text('now()'))) is None + assert MigrationService._sqlite_default(sa.Column('c', sa.String())) is None diff --git a/backend/tests/test_backup_schedule_service.py b/backend/tests/test_backup_schedule_service.py new file mode 100644 index 000000000..c76a6516f --- /dev/null +++ b/backend/tests/test_backup_schedule_service.py @@ -0,0 +1,121 @@ +"""Legacy schedule API and execution agree across zones and DST boundaries.""" + +from copy import deepcopy +from datetime import datetime, timezone +from unittest.mock import Mock +from zoneinfo import ZoneInfo + +import pytest + +from app.services import backup_schedule_service as schedules +from app.services.backup_service import BackupService + + +def schedule(**overrides): + return {'id': 'nightly', 'enabled': True, 'schedule_time': '02:30', + 'days': ['daily'], **overrides} + + +@pytest.mark.parametrize('zone_name,now,expected', [ + ('America/New_York', '2026-09-05T03:00:00+00:00', '2026-09-05T02:30:00-04:00'), + ('Asia/Kolkata', '2026-09-05T00:00:00+00:00', '2026-09-06T02:30:00+05:30'), + # 02:30 never occurs on the spring-forward date. + ('America/New_York', '2026-03-08T05:00:00+00:00', '2026-03-09T02:30:00-04:00'), +]) +def test_next_run_uses_server_zone(zone_name, now, expected): + result = schedules.next_run(schedule(), now=datetime.fromisoformat(now), zone=ZoneInfo(zone_name)) + assert result.isoformat() == expected + + +@pytest.mark.parametrize('now', ['2026-03-08T05:00:00+00:00', '2026-03-01T09:00:00+00:00']) +def test_weekly_spring_gap_skips_to_next_week(now): + result = schedules.next_run(schedule(days=['sunday']), + now=datetime.fromisoformat(now), + zone=ZoneInfo('America/New_York')) + assert result.isoformat() == '2026-03-15T02:30:00-04:00' + + +def test_fall_back_retains_both_legacy_wall_clock_runs_without_double_enqueue(): + zone = ZoneInfo('America/New_York') + entry = schedule(schedule_time='01:30') + first = datetime.fromisoformat('2026-11-01T05:30:10+00:00') + assert schedules.next_run(entry, now=first, zone=zone).isoformat() == '2026-11-01T01:30:00-04:00' + entry['last_run'] = first.isoformat() + assert schedules.next_run(entry, now=first, zone=zone).isoformat() == '2026-11-01T01:30:00-05:00' + second = datetime.fromisoformat('2026-11-01T06:30:10+00:00') + assert schedules.next_run(entry, now=second, zone=zone).isoformat() == '2026-11-01T01:30:00-05:00' + entry['last_run'] = second.isoformat() + assert schedules.next_run(entry, now=second, zone=zone).isoformat() == '2026-11-02T01:30:00-05:00' + + +def test_naive_legacy_last_run_is_server_local_and_suppresses_current_minute(): + result = schedules.next_run(schedule(last_run='2026-09-05T02:30:05'), + now=datetime.fromisoformat('2026-09-05T06:30:10+00:00'), + zone=ZoneInfo('America/New_York')) + assert result.isoformat() == '2026-09-06T02:30:00-04:00' + + +@pytest.mark.parametrize('overrides', [ + {'schedule_time': '25:00'}, {'schedule_time': '2:30'}, {'schedule_time': None}, + {'days': []}, {'days': 'daily'}, {'days': ['MONDAY']}, {'days': [None]}, +]) +def test_invalid_legacy_entries_are_visible_but_never_due(overrides): + entry = schedule(**overrides) + result = schedules.describe_schedule(entry, zone=ZoneInfo('UTC')) + assert result['next_run_at'] is None + assert result['schedule_error'] + assert result['timezone'] == 'UTC' + assert 'next_run_at' not in entry + + +@pytest.mark.parametrize('global_enabled,entry_enabled', [(False, True), (True, False)]) +def test_disabled_schedules_have_no_countdown(global_enabled, entry_enabled): + result = schedules.describe_schedule(schedule(enabled=entry_enabled), + globally_enabled=global_enabled, zone=ZoneInfo('UTC')) + assert result['next_run_at'] is None + + +def test_invalid_create_or_update_does_not_write_or_mutate_config(monkeypatch): + config = {'enabled': True, 'schedules': [schedule()]} + original = deepcopy(config) + save = Mock() + monkeypatch.setattr(BackupService, 'get_config', lambda: config) + monkeypatch.setattr(BackupService, 'save_config', save) + assert not BackupService.add_schedule('test', 'files', '/srv', '24:01')['success'] + assert not BackupService.add_schedule('test', 'files', '/srv', '02:30', days=[])['success'] + assert not BackupService.update_schedule('nightly', {'schedule_time': '99:99'})['success'] + assert config == original + save.assert_not_called() + + +def test_list_and_scheduler_use_same_due_instant_without_persisting_metadata(monkeypatch): + from app.jobs.service import JobService + from app.services import backup_service + zone = ZoneInfo('America/New_York') + now = datetime.fromisoformat('2026-09-05T06:30:10+00:00') + clock = Mock() + clock.now.return_value = now + monkeypatch.setattr(backup_service, 'datetime', clock) + monkeypatch.setattr(schedules, 'server_timezone', lambda: zone) + config = {'enabled': True, 'schedules': [schedule()]} + monkeypatch.setattr(BackupService, 'get_config', lambda: config) + monkeypatch.setattr(BackupService, 'save_config', Mock()) + enqueue = Mock() + monkeypatch.setattr(JobService, 'enqueue', enqueue) + shown = BackupService.get_schedules()[0] + assert shown['next_run_at'] == '2026-09-05T02:30:00-04:00' + BackupService.check_backup_schedules() + enqueue.assert_called_once() + assert datetime.fromisoformat(config['schedules'][0]['last_run']).astimezone(timezone.utc) == now + assert 'next_run_at' not in config['schedules'][0] + BackupService.check_backup_schedules() + enqueue.assert_called_once() + assert BackupService.get_schedules()[0]['next_run_at'] == '2026-09-06T02:30:00-04:00' + + +def test_empty_schedule_api_still_exposes_server_timezone(client, auth_headers, monkeypatch): + monkeypatch.setattr(BackupService, 'get_config', lambda: {'enabled': True, 'schedules': []}) + monkeypatch.setattr(schedules, 'server_timezone', lambda: ZoneInfo('Asia/Kolkata')) + response = client.get('/api/v1/backups/schedules', headers=auth_headers) + assert response.status_code == 200 + assert response.get_json() == {'schedules': [], 'timezone': 'Asia/Kolkata'} diff --git a/backend/tests/test_channel_registry.py b/backend/tests/test_channel_registry.py index e18b877e6..302a652a7 100644 --- a/backend/tests/test_channel_registry.py +++ b/backend/tests/test_channel_registry.py @@ -11,7 +11,7 @@ import app.sockets as sk from app import db -from factories import make_user +from factories import make_user, access_token_for BACKEND = Path(__file__).resolve().parents[1] @@ -38,7 +38,12 @@ def _run(app, handler, data=None, sid='sid-1'): def _authed(sid='sid-1', role='developer'): with sk._connected_clients_lock: - sk.connected_clients[sid] = {'user_id': 1, 'role': role} + from flask_jwt_extended import decode_token + user = make_user(db, role=role) + sk.connected_clients[sid] = { + 'user_id': user.id, 'role': role, + 'claims': decode_token(access_token_for(user)), + } def test_registered_channels_exist(app): @@ -72,6 +77,9 @@ def test_deploy_requires_job_id_then_joins_room(app, wire): assert ('error', {'message': 'job_id required'}) in wire wire.clear() + from app.models.deployment_job import DeploymentJob + db.session.add(DeploymentJob(id='job-1', kind='test', requested_by=sk.connected_clients['sid-1']['user_id'])) + db.session.commit() _run(app, sk.CHANNELS['deploy']['subscribe'], {'job_id': 'job-1'}) assert ('__join__', 'deploy_job-1') in wire assert ('subscribed', {'channel': 'deploy', 'job_id': 'job-1'}) in wire @@ -99,7 +107,7 @@ def test_terminal_unsubscribe_does_not_ack(app, wire): def test_generic_join_room_still_gates_terminal_rooms(app, wire): _authed(role='viewer') _run(app, sk.handle_join_room, {'room': 'server_s1_terminal:sess'}) - assert wire == [('error', {'message': 'Developer role required for terminal access'})] + assert wire == [('error', {'message': 'Room access denied'})] def test_raw_subscribe_handlers_are_frozen(): diff --git a/backend/tests/test_deploy_console.py b/backend/tests/test_deploy_console.py index c0d9f25ac..238d6b8ad 100644 --- a/backend/tests/test_deploy_console.py +++ b/backend/tests/test_deploy_console.py @@ -88,9 +88,8 @@ def __init__(self, sid): class TestSubscribeDeployAuth: - """The socketio test client is unusable on this Flask/Werkzeug pairing - (`ctx.session` became read-only), so exercise the handler's auth guard - directly by faking the socket context (request.sid / emit / join_room).""" + """Exercise handler gates directly, with the same persisted user, session + claims and resource checks as a socket authenticated at connect time.""" def _patch(self, monkeypatch, sid): import app.sockets as sk @@ -100,6 +99,11 @@ def _patch(self, monkeypatch, sid): monkeypatch.setattr(sk, 'request', _FakeReq(sid)) return sk, emitted, joined + def _authenticate(self, sk, sid, auth_headers): + from flask_jwt_extended import decode_token + claims = decode_token(auth_headers['Authorization'].removeprefix('Bearer ')) + sk.connected_clients[sid] = {'user_id': int(claims['sub']), 'role': 'admin', 'claims': claims} + def test_unauthenticated_join_rejected(self, app, monkeypatch): sk, emitted, joined = self._patch(monkeypatch, 'sid-unauth') sk.connected_clients.pop('sid-unauth', None) # not authenticated @@ -107,19 +111,30 @@ def test_unauthenticated_join_rejected(self, app, monkeypatch): assert joined == [] # never joined a room assert any(a and a[0] == 'error' for a, k in emitted) - def test_authenticated_join_succeeds(self, app, monkeypatch): + def test_authenticated_join_succeeds(self, app, auth_headers, monkeypatch): sk, emitted, joined = self._patch(monkeypatch, 'sid-auth') - sk.connected_clients['sid-auth'] = {'user_id': 1, 'role': 'admin'} + job = _make_job() + self._authenticate(sk, 'sid-auth', auth_headers) try: - sk.CHANNELS['deploy']['subscribe']({'job_id': 'job-xyz'}) + sk.CHANNELS['deploy']['subscribe']({'job_id': job.id}) finally: sk.connected_clients.pop('sid-auth', None) - assert 'deploy_job-xyz' in joined + assert f'deploy_{job.id}' in joined assert any(a and a[0] == 'subscribed' for a, k in emitted) - def test_missing_job_id_rejected(self, app, monkeypatch): + def test_authenticated_unknown_job_is_rejected(self, app, auth_headers, monkeypatch): + sk, emitted, joined = self._patch(monkeypatch, 'sid-unknown-job') + self._authenticate(sk, 'sid-unknown-job', auth_headers) + try: + sk.CHANNELS['deploy']['subscribe']({'job_id': 'no-such-job'}) + finally: + sk.connected_clients.pop('sid-unknown-job', None) + assert joined == [] + assert any(a and a[0] == 'error' for a, k in emitted) + + def test_missing_job_id_rejected(self, app, auth_headers, monkeypatch): sk, emitted, joined = self._patch(monkeypatch, 'sid-auth2') - sk.connected_clients['sid-auth2'] = {'user_id': 1, 'role': 'admin'} + self._authenticate(sk, 'sid-auth2', auth_headers) try: sk.CHANNELS['deploy']['subscribe']({}) finally: diff --git a/backend/tests/test_deployment_jobs_authz.py b/backend/tests/test_deployment_jobs_authz.py index 56896b7d3..1a06abe87 100644 --- a/backend/tests/test_deployment_jobs_authz.py +++ b/backend/tests/test_deployment_jobs_authz.py @@ -116,8 +116,9 @@ def test_appless_job_detail_requester_or_admin(client, job_rbac): """An app-less job belongs to its requester (or a panel admin).""" s = job_rbac.s url = f'/api/v1/deployment-jobs/{job_rbac.owner_job_id}' - assert client.get(url, headers=s.owner).status_code == 200 - assert client.get(url, headers=s.admin).status_code == 200 + for headers in (s.owner, s.admin): + response = client.get(url, headers=headers) + assert response.status_code == 200, response.get_json() for persona in ('member', 'viewer', 'foreign'): assert client.get(url, headers=getattr(s, persona)).status_code == 403, persona diff --git a/backend/tests/test_fleet_metrics_batching.py b/backend/tests/test_fleet_metrics_batching.py new file mode 100644 index 000000000..7d7b07d36 --- /dev/null +++ b/backend/tests/test_fleet_metrics_batching.py @@ -0,0 +1,123 @@ +"""Fleet readers share one bounded query without changing latest semantics.""" + +from contextlib import contextmanager +from datetime import datetime, timedelta + +import pytest +from sqlalchemy import event + +from app import db +from app.models.server import Server, ServerMetrics +from app.services.fleet_monitor_service import FleetMonitorService +from app.services.server_metrics_service import latest_metrics_by_server + + +@contextmanager +def metric_queries(): + queries = [] + + def before(conn, cursor, statement, params, context, executemany): + if 'server_metrics' in statement.lower(): + queries.append(statement) + + engine = db.engine + event.listen(engine, 'before_cursor_execute', before) + try: + yield queries + finally: + event.remove(engine, 'before_cursor_execute', before) + + +def seed_server(name): + server = Server(name=name, hostname=f'{name}.test', status='online') + db.session.add(server) + db.session.flush() + return server + + +def seed_sample(server, when, value): + sample = ServerMetrics( + server_id=server.id, timestamp=when, cpu_percent=value, + memory_percent=value + 1, disk_percent=value + 2, + container_running=int(value), container_count=int(value) + 1, + ) + db.session.add(sample) + db.session.flush() + return sample + + +def test_latest_order_scope_missing_rows_and_ties(app, db_session): + server = seed_server('ordering') + missing = seed_server('missing') + excluded = seed_server('excluded') + now = datetime(2026, 9, 5, 12) + seed_sample(server, now, 10) + tie = seed_sample(server, now, 20) + late = seed_sample(server, now - timedelta(hours=1), 30) + seed_sample(excluded, now, 99) + db.session.commit() + ids = [server.id, missing.id] + + assert latest_metrics_by_server(ids) == {server.id: tie} + assert latest_metrics_by_server(ids, order_by='id') == {server.id: late} + with metric_queries() as queries: + assert latest_metrics_by_server([]) == {} + assert not queries + with pytest.raises(ValueError): + latest_metrics_by_server(ids, order_by='invalid') + + +@pytest.mark.parametrize('size', [1, 8, 31]) +@pytest.mark.parametrize('reader', ['heatmap', 'prometheus', 'overview', 'list']) +def test_each_fleet_reader_uses_one_metrics_query( + app, client, auth_headers, db_session, size, reader, +): + now = datetime(2026, 9, 5, 12) + for index in range(size): + server = seed_server(f'fleet-{index}') + seed_sample(server, now, 10) + # Arrives later, but describes an older observation. + seed_sample(server, now - timedelta(hours=1), 20) + db.session.commit() + + with metric_queries() as queries: + if reader == 'heatmap': + result = FleetMonitorService.get_fleet_heatmap() + assert len(result) == size + assert all(row['cpu'] == 10 for row in result) + elif reader == 'prometheus': + result = FleetMonitorService.get_prometheus_metrics() + samples = [line for line in result.splitlines() if line.startswith('serverkit_cpu_percent{')] + assert len(samples) == size + assert all(line.endswith(' 10.0') for line in samples) + elif reader == 'overview': + response = client.get('/api/v1/servers/overview', headers=auth_headers) + assert response.status_code == 200 + result = response.get_json() + assert result['summary']['running_containers'] == size * 10 + assert all(row['cpu_percent'] == 10 for row in result['servers']) + else: + response = client.get('/api/v1/servers', headers=auth_headers) + assert response.status_code == 200 + result = response.get_json() + assert len(result) == size + assert all(row['metrics']['cpu_percent'] == 20 for row in result) + assert len(queries) == 1 + + +def test_heatmap_keeps_group_scope_and_missing_metrics(app, db_session): + from app.models.server import ServerGroup + + group = ServerGroup(name='selected') + db.session.add(group) + db.session.flush() + included = seed_server('included') + included.group_id = group.id + seed_server('excluded') + db.session.commit() + result = FleetMonitorService.get_fleet_heatmap(group.id) + assert len(result) == 1 + assert result[0]['id'] == included.id + assert result[0]['group_name'] == 'selected' + assert result[0]['cpu'] is None + assert result[0]['last_update'] is None diff --git a/backend/tests/test_notification_chat_connections.py b/backend/tests/test_notification_chat_connections.py index 645aaa1e2..24bf70cf9 100644 --- a/backend/tests/test_notification_chat_connections.py +++ b/backend/tests/test_notification_chat_connections.py @@ -664,7 +664,7 @@ def test_set_default_connection(self, app, client, auth_headers): headers=auth_headers, ) - assert resp.status_code == 200 + assert resp.status_code == 200, resp.get_json() body = resp.get_json() assert body['success'] is True assert body['connection']['id'] == second.id diff --git a/backend/tests/test_passkey_security.py b/backend/tests/test_passkey_security.py new file mode 100644 index 000000000..6107b1f90 --- /dev/null +++ b/backend/tests/test_passkey_security.py @@ -0,0 +1,134 @@ +"""Real pinned WebAuthn verifier coverage with locally signed synthetic credentials.""" +import hashlib +import json + +import cbor2 +import pytest +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.asymmetric import ec +from flask_jwt_extended import decode_token + +from app import db +from app.models import PasskeyCredential +from app.services.passkey_service import PasskeyService, _b64decode_url, _b64encode_url +from factories import make_user + + +@pytest.fixture(autouse=True) +def relying_party(monkeypatch): + monkeypatch.setenv('SERVERKIT_PASSKEY_RP_ID', 'localhost') + monkeypatch.setenv('SERVERKIT_PASSKEY_ORIGIN', 'http://localhost') + + +def _key_material(): + key = ec.generate_private_key(ec.SECP256R1()) + numbers = key.public_key().public_numbers() + cose = cbor2.dumps({1: 2, 3: -7, -1: 1, + -2: numbers.x.to_bytes(32, 'big'), -3: numbers.y.to_bytes(32, 'big')}) + return key, cose + + +def _client_data(challenge, ceremony): + return json.dumps({'type': ceremony, 'challenge': challenge, 'origin': 'http://localhost'}).encode() + + +def _registration(challenge, credential_id, cose, verified): + client_data = _client_data(challenge, 'webauthn.create') + flags = 0x41 | (0x04 if verified else 0) + auth_data = (hashlib.sha256(b'localhost').digest() + bytes([flags]) + bytes(4) + + bytes(16) + len(credential_id).to_bytes(2, 'big') + credential_id + cose) + attestation = cbor2.dumps({'fmt': 'none', 'attStmt': {}, 'authData': auth_data}) + return {'id': _b64encode_url(credential_id), 'rawId': _b64encode_url(credential_id), + 'type': 'public-key', 'response': {'clientDataJSON': _b64encode_url(client_data), + 'attestationObject': _b64encode_url(attestation), + 'transports': ['internal']}} + + +def _assertion(challenge, credential_id, private_key, verified, counter=1): + client_data = _client_data(challenge, 'webauthn.get') + auth_data = hashlib.sha256(b'localhost').digest() + bytes([0x05 if verified else 0x01]) + counter.to_bytes(4, 'big') + signed_data = auth_data + hashlib.sha256(client_data).digest() + signature = private_key.sign(signed_data, ec.ECDSA(hashes.SHA256())) + return {'id': _b64encode_url(credential_id), 'rawId': _b64encode_url(credential_id), + 'type': 'public-key', 'response': {'clientDataJSON': _b64encode_url(client_data), + 'authenticatorData': _b64encode_url(auth_data), + 'signature': _b64encode_url(signature)}} + + +def test_pinned_webauthn_generates_browser_options_and_requires_uv(app): + with app.app_context(): + user = make_user(db) + registration = PasskeyService.begin_registration(user) + assert registration['authenticatorSelection']['userVerification'] == 'required' + assert registration['attestation'] == 'none' + assert len(_b64decode_url(registration['challenge'])) == 32 + _, cose = _key_material() + saved = PasskeyCredential(user_id=user.id, credential_id=_b64encode_url(b'test'), + public_key=_b64encode_url(cose)) + saved.set_transports(['internal', 'unknown-future-transport']) + db.session.add(saved) + db.session.commit() + authentication = PasskeyService.begin_authentication(user) + assert authentication['userVerification'] == 'required' + assert authentication['allowCredentials'][0]['transports'] == ['internal'] + assert authentication['allowCredentials'][0]['id'] == saved.credential_id + + +def test_registration_enforces_uv_with_real_attestation_verifier(app): + with app.app_context(): + user = make_user(db) + _, cose = _key_material() + options = PasskeyService.begin_registration(user) + credential = _registration(options['challenge'], b'registration-test', cose, False) + denied = PasskeyService.verify_registration(user, credential) + assert not denied['success'] and 'verified' in denied['error'].lower() + assert PasskeyCredential.query.count() == 0 + credential = _registration(options['challenge'], b'registration-test', cose, True) + accepted = PasskeyService.verify_registration(user, credential) + assert accepted['success'] + assert accepted['passkey']['transports'] == ['internal'] + assert PasskeyService._get_challenge(user.id, 'register') is None + + +@pytest.mark.parametrize('discoverable', [False, True]) +def test_uvless_passkey_cannot_skip_mfa_but_verified_passkey_mints_revocable_pair(app, client, discoverable): + with app.app_context(): + user = make_user(db, role='admin', totp_enabled=True, password_hash=None) + key, cose = _key_material() + cid = b'authentication-test' + saved = PasskeyCredential(user_id=user.id, credential_id=_b64encode_url(cid), public_key=_b64encode_url(cose)) + db.session.add(saved) + db.session.commit() + identity = {} if discoverable else {'user_id': user.id} + options_response = client.post('/api/v1/auth/passkeys/options/authenticate', json=identity) + assert options_response.status_code == 200 + challenge = options_response.json['challenge'] + response = client.post('/api/v1/auth/passkeys/authenticate', json={ + **identity, 'credential': _assertion(challenge, cid, key, False), + }) + assert response.status_code == 401 + assert 'access_token' not in response.json + response = client.post('/api/v1/auth/passkeys/authenticate', json={ + **identity, 'credential': _assertion(challenge, cid, key, True), + }) + assert response.status_code == 200, response.json + access, refresh = response.json['access_token'], response.json['refresh_token'] + assert decode_token(access)['session_id'] == decode_token(refresh)['session_id'] + assert decode_token(access)['auth_version'] == user.auth_version + assert PasskeyService._get_challenge(0 if discoverable else user.id, 'auth') is None + assert client.post('/api/v1/auth/logout', headers={'Authorization': f'Bearer {access}'}).status_code == 200 + assert client.post('/api/v1/auth/refresh', headers={'Authorization': f'Bearer {refresh}'}).status_code == 401 + + +def test_named_user_authentication_rejects_another_users_credential(app): + with app.app_context(): + expected = make_user(db) + foreign = make_user(db) + key, cose = _key_material() + cid = b'foreign-passkey' + db.session.add(PasskeyCredential(user_id=foreign.id, credential_id=_b64encode_url(cid), + public_key=_b64encode_url(cose))) + db.session.commit() + options = PasskeyService.begin_authentication(expected) + assertion = _assertion(options['challenge'], cid, key, True) + assert not PasskeyService.verify_authentication(assertion, expected)['success'] diff --git a/backend/tests/test_real_buildpack_docker.py b/backend/tests/test_real_buildpack_docker.py index ce7765f26..247197553 100644 --- a/backend/tests/test_real_buildpack_docker.py +++ b/backend/tests/test_real_buildpack_docker.py @@ -74,7 +74,9 @@ def _deploy_and_probe(repo_path, expect_language, expect_framework=None, build = subprocess.run( ['docker', 'build', '-f', str(repo_path / 'Dockerfile.serverkit'), '-t', tag, str(repo_path)], - capture_output=True, text=True, timeout=600, + # Docker emits UTF-8 build logs even when Windows uses a legacy locale. + capture_output=True, text=True, encoding='utf-8', errors='replace', + timeout=600, ) try: assert build.returncode == 0, ( @@ -87,13 +89,15 @@ def _deploy_and_probe(repo_path, expect_language, expect_framework=None, run = subprocess.run( ['docker', 'run', '-d', '--rm', '--name', tag, '-p', f'127.0.0.1:0:{port}', tag], - capture_output=True, text=True, timeout=60, + capture_output=True, text=True, encoding='utf-8', errors='replace', + timeout=60, ) assert run.returncode == 0, run.stderr try: mapped = subprocess.run( ['docker', 'port', tag, str(port)], - capture_output=True, text=True, timeout=20, + capture_output=True, text=True, encoding='utf-8', errors='replace', + timeout=20, ).stdout.strip().splitlines()[0] # e.g. 127.0.0.1:49321 url = f'http://{mapped}{probe_path}' @@ -127,7 +131,8 @@ def _deploy_and_probe(repo_path, expect_language, expect_framework=None, if body is None: logs = subprocess.run( ['docker', 'logs', tag], - capture_output=True, text=True, timeout=20, + capture_output=True, text=True, encoding='utf-8', errors='replace', + timeout=20, ) raise AssertionError( f'container never answered {url}; logs:\n' diff --git a/backend/tests/test_repository_application_service.py b/backend/tests/test_repository_application_service.py new file mode 100644 index 000000000..c21f6cf03 --- /dev/null +++ b/backend/tests/test_repository_application_service.py @@ -0,0 +1,99 @@ +"""Repository registration ordering and compensating cleanup, without HTTP.""" + +from types import SimpleNamespace +from unittest.mock import Mock + +import pytest + +from app.services import repository_application_service as imports +from app.services.deployment_job_service import DeploymentJobService +from app.services.manifest_persistence_service import ManifestPersistenceService + + +@pytest.fixture +def runtime(monkeypatch, tmp_path): + app = SimpleNamespace(id=42, root_path=str(tmp_path / 'apps' / 'demo')) + session = Mock() + git = Mock() + build = Mock() + git.configure_deployment.return_value = {'success': True, 'webhook_url': '/hook'} + build.configure_build.return_value = {'success': True, 'config': {'build_method': 'dockerfile'}} + manifest = Mock(return_value={'imported': True}) + enqueue = Mock(return_value={'success': True, 'job_id': 'job-42'}) + monkeypatch.setattr(imports, 'db', SimpleNamespace(session=session)) + monkeypatch.setattr(imports, 'GitService', git) + monkeypatch.setattr(imports, 'BuildService', build) + monkeypatch.setattr(imports.paths, 'APPS_DIR', str(tmp_path / 'apps')) + monkeypatch.setattr(ManifestPersistenceService, 'apply_import', manifest) + monkeypatch.setattr(DeploymentJobService, 'enqueue_app_deploy', enqueue) + options = dict(user_id=7, repo_url='https://github.com/acme/demo', branch='release', + auto_deploy=False, manifest={'strategy': 'dockerfile'}, + build_options={'build_method': 'dockerfile', 'custom_start_cmd': 'serve'}) + return SimpleNamespace(app=app, session=session, git=git, build=build, + manifest=manifest, enqueue=enqueue, options=options) + + +def test_commit_precedes_configuration_and_observable_deployment(runtime): + effects = Mock() + for name, method in [('commit', runtime.session.commit), + ('deploy', runtime.git.configure_deployment), + ('build', runtime.build.configure_build), + ('manifest', runtime.manifest), ('enqueue', runtime.enqueue)]: + effects.attach_mock(method, name) + result = imports.finalize_repository_application(runtime.app, **runtime.options) + assert [call[0] for call in effects.mock_calls] == ['commit', 'deploy', 'build', 'manifest', 'enqueue'] + assert result['deploy_job_id'] == 'job-42' + assert result['manifest_import'] == {'imported': True} + runtime.git.configure_deployment.assert_called_once_with( + app_id=42, app_path=runtime.app.root_path, repo_url=runtime.options['repo_url'], + branch='release', auto_deploy=False) + runtime.build.configure_build.assert_called_once_with( + app_id=42, app_path=runtime.app.root_path, **runtime.options['build_options']) + runtime.enqueue.assert_called_once_with(runtime.app, user_id=7, trigger='install') + + +@pytest.mark.parametrize('stage', ['deploy', 'build']) +def test_mandatory_setup_failure_propagates_for_compensation(runtime, stage): + method = runtime.git.configure_deployment if stage == 'deploy' else runtime.build.configure_build + method.return_value = {'success': False, 'error': 'setup failed'} + with pytest.raises(RuntimeError, match='setup failed'): + imports.finalize_repository_application(runtime.app, **runtime.options) + runtime.manifest.assert_not_called() + runtime.enqueue.assert_not_called() + + +@pytest.mark.parametrize('queue_failure', [False, True]) +def test_optional_enrichment_and_queue_failure_keep_registered_app(runtime, queue_failure): + runtime.manifest.side_effect = RuntimeError('unsupported manifest') + if queue_failure: + runtime.enqueue.side_effect = RuntimeError('queue unavailable') + else: + runtime.enqueue.return_value = {'success': False, 'error': 'queue unavailable'} + result = imports.finalize_repository_application(runtime.app, **runtime.options) + assert result['manifest_import'] is None + assert result['deploy_job_id'] is None + runtime.session.commit.assert_called_once() + runtime.session.rollback.assert_not_called() + runtime.git.remove_deployment.assert_not_called() + + +@pytest.mark.parametrize('location', ['managed', 'sibling', 'base']) +def test_compensation_hard_deletes_record_but_only_removes_managed_child_path(runtime, monkeypatch, location): + from pathlib import Path + base = Path(imports.paths.APPS_DIR) + target = {'managed': base / 'demo', 'sibling': base.with_name('apps-other'), 'base': base}[location] + target.mkdir(parents=True) + marker = target / 'keep.txt' + marker.write_text('source') + runtime.app.root_path = str(target) + query = Mock() + query.get.return_value = runtime.app + monkeypatch.setattr(imports, 'Application', SimpleNamespace(query=query)) + effects = Mock() + effects.attach_mock(runtime.session.rollback, 'rollback') + effects.attach_mock(runtime.git.remove_deployment, 'deployment') + effects.attach_mock(runtime.session.delete, 'delete') + effects.attach_mock(runtime.session.commit, 'commit') + imports.abort_repository_creation(runtime.app) + assert [call[0] for call in effects.mock_calls] == ['rollback', 'deployment', 'delete', 'commit'] + assert marker.exists() == (location != 'managed') diff --git a/backend/tests/test_request_profiling.py b/backend/tests/test_request_profiling.py new file mode 100644 index 000000000..ca9e80934 --- /dev/null +++ b/backend/tests/test_request_profiling.py @@ -0,0 +1,76 @@ +"""Profiling is opt-in, request-local and never discloses SQL or parameters.""" + +import re + +from flask import Flask +from flask_sqlalchemy import SQLAlchemy +from sqlalchemy import text +from sqlalchemy.exc import SQLAlchemyError + +from app.middleware.request_profiling import register_request_profiling + + +def profile_app(enabled): + app = Flask(__name__) + app.config.update(SQLALCHEMY_DATABASE_URI='sqlite://', PROFILE_REQUESTS=enabled) + database = SQLAlchemy(app) + register_request_profiling(app, database) + register_request_profiling(app, database) # registration must be idempotent + + @app.get('/api/v1/query') + def query(): + database.session.execute(text('SELECT :secret'), {'secret': 'private-value'}) + database.session.execute(text('SELECT 2')) + return 'ok', 200, {'Server-Timing': 'existing;dur=1'} + + @app.get('/api/v1/empty') + def empty(): + return 'ok' + + @app.get('/api/v1/fail') + def fail(): + try: + database.session.execute(text('SELECT * FROM private_missing_table')) + except SQLAlchemyError: + return 'failed', 400 + raise AssertionError('Expected invalid SQL') + + @app.get('/page') + def page(): + database.session.execute(text('SELECT 1')) + return 'page' + + return app, database + + +def test_default_off_installs_no_profile_and_preserves_headers(): + app, _database = profile_app(False) + response = app.test_client().get('/api/v1/query') + assert response.headers.getlist('Server-Timing') == ['existing;dur=1'] + assert 'serverkit_request_profiling' not in app.extensions + + +def test_counts_queries_without_leaking_sql_and_isolates_requests(): + app, database = profile_app(True) + client = app.test_client() + # A job/startup query must not be attributed to the next HTTP request. + with app.app_context(): + database.session.execute(text('SELECT 1')) + response = client.get('/api/v1/query') + headers = response.headers.getlist('Server-Timing') + assert headers[0] == 'existing;dur=1' + assert re.fullmatch(r'app;dur=\d+\.\d{3}, db;dur=\d+\.\d{3};desc="2 queries"', headers[1]) + assert 'private' not in str(headers) + assert 'SELECT' not in str(headers) + assert 'desc="0 queries"' in client.get('/api/v1/empty').headers['Server-Timing'] + assert 'Server-Timing' not in client.get('/page').headers + + +def test_failed_sql_is_counted_and_does_not_leak_into_the_next_request(): + app, _database = profile_app(True) + client = app.test_client() + response = client.get('/api/v1/fail') + assert response.status_code == 400 + assert 'desc="1 queries"' in response.headers['Server-Timing'] + assert 'private_missing_table' not in response.headers['Server-Timing'] + assert 'desc="0 queries"' in client.get('/api/v1/empty').headers['Server-Timing'] diff --git a/backend/tests/test_restore_points_api.py b/backend/tests/test_restore_points_api.py index 87e57dc70..dcfefb30c 100644 --- a/backend/tests/test_restore_points_api.py +++ b/backend/tests/test_restore_points_api.py @@ -437,8 +437,10 @@ def test_deleted_and_inactive_jwt_users_fail_closed_on_get( response = client.get( f'/api/v1/restore-points/{point.id}', headers=inactive_headers, ) - assert response.status_code == 403 - assert response.get_json()['error'] == 'Account is deactivated' + # Session validation rejects inactive/deleted identities before the route's + # resource policy runs. They no longer carry authenticated credentials. + assert response.status_code == 401 + assert response.get_json()['msg'] == 'Token has been revoked' deleted = make_user(db_session, role='developer') deleted_headers = headers_for(deleted) @@ -447,8 +449,8 @@ def test_deleted_and_inactive_jwt_users_fail_closed_on_get( response = client.get( f'/api/v1/restore-points/{point.id}', headers=deleted_headers, ) - assert response.status_code == 403 - assert response.get_json()['error'] == 'Authenticated user not found' + assert response.status_code == 401 + assert response.get_json()['msg'] == 'Token has been revoked' def test_developer_api_key_reaches_policy_guarded_post( diff --git a/backend/tests/test_route_authz_sweep.py b/backend/tests/test_route_authz_sweep.py index b4c4ad539..4265b385b 100644 --- a/backend/tests/test_route_authz_sweep.py +++ b/backend/tests/test_route_authz_sweep.py @@ -202,6 +202,9 @@ def test_queue_group_mutations_are_owner_scoped(app, client, db_session): # SELF — the viewer acts only on their own account/data. 'ai.create_conversation', 'auth.update_current_user', + # Registration options bind exclusively to get_jwt_identity(); a viewer + # may enroll their own passkey, never nominate another account in JSON. + 'auth.passkey_register_options', 'notifications.mark_inbox_all_read', 'notifications.test_user_notification', 'notifications.unmute_own_email', @@ -218,13 +221,44 @@ def test_queue_group_mutations_are_owner_scoped(app, client, db_session): 'docker.get_containers_stats', # PUBLIC transport — agent long-poll fallback; user JWT is ignored here. 'agent_poll.disconnect', + # PUBLIC authentication challenge only; credential verification and UV are + # required by passkey_authenticate before any session tokens are issued. + 'auth.passkey_auth_options', } + +def test_passkey_enrollment_options_are_self_scoped(client, db_session): + from app.services.passkey_service import PasskeyService, _b64decode_url + viewer = make_user(db_session, role='viewer') + foreign = make_user(db_session, role='admin') + assert client.post('/api/v1/auth/passkeys/options/register', json={}).status_code == 401 + response = client.post('/api/v1/auth/passkeys/options/register', + headers=headers_for(viewer), json={'user_id': foreign.id}) + assert response.status_code == 200 + assert _b64decode_url(response.json['user']['id']) == str(viewer.id).encode() + assert PasskeyService._get_challenge(viewer.id, 'register') is not None + assert PasskeyService._get_challenge(foreign.id, 'register') is None + + +def test_public_passkey_options_issue_only_a_challenge(client, db_session): + from app.models import PasskeyCredential + viewer = make_user(db_session, role='viewer') + response = client.post('/api/v1/auth/passkeys/options/authenticate', + json={'user_id': viewer.id}) + assert response.status_code == 200 + assert response.json['challenge'] + assert response.json['userVerification'] == 'required' + assert 'access_token' not in response.json and 'refresh_token' not in response.json + assert PasskeyCredential.query.count() == 0 + # Endpoints skipped by the live-fire net because firing them reaches out to the # network / external providers (slow, flaky in CI). Their gating is asserted by # dedicated per-feature tests, not here. _SKIP_ENDPOINTS = { 'servers.check_agent_version', + # Revokes the sweep's shared viewer token, masking later missing gates. + # Covered with real viewer sessions in test_session_security.py instead. + 'auth.logout', } diff --git a/backend/tests/test_seed_one_door_ratchet.py b/backend/tests/test_seed_one_door_ratchet.py index 772089f27..0cea54408 100644 --- a/backend/tests/test_seed_one_door_ratchet.py +++ b/backend/tests/test_seed_one_door_ratchet.py @@ -117,7 +117,6 @@ 'test_site_base_domains.py', 'test_site_routing.py', 'test_sites_https.py', - 'test_soft_delete_leaks.py', 'test_ssl_acme_contact.py', 'test_url_swap.py', 'test_workspace_scope.py', diff --git a/backend/tests/test_session_security.py b/backend/tests/test_session_security.py new file mode 100644 index 000000000..896b9130d --- /dev/null +++ b/backend/tests/test_session_security.py @@ -0,0 +1,241 @@ +"""Regression coverage for cross-authentication credential and revocation chains.""" +from datetime import timedelta + +import pyotp +import pytest +from flask_jwt_extended import decode_token + +from factories import make_user, headers_for, access_token_for +from app.services.api_key_service import ApiKeyService + + +@pytest.fixture(autouse=True) +def _isolated_throttles(app): + from app import limiter + limiter.reset() + yield + limiter.reset() + + +def _headers(token): + return {'Authorization': f'Bearer {token}'} + + +def _login(client, user): + response = client.post('/api/v1/auth/login', json={ + 'email': user.email, 'password': 'password123', + }) + assert response.status_code == 200, response.get_json() + return response.get_json() + + +def _refresh(client, token): + return client.post('/api/v1/auth/refresh', headers=_headers(token)) + + +def test_pending_mfa_cannot_mint_link_and_has_five_minute_expiry(client, db_session): + user = make_user(db_session, role='admin', password='password123', + totp_enabled=True, totp_secret=pyotp.random_base32()) + pending = _login(client, user) + claims = decode_token(pending['temp_token']) + assert 0 < claims['exp'] - claims['iat'] <= 300 + for path in ('/api/v1/auth/login-links', '/api/v1/auth/logout'): + response = client.post(path, headers=_headers(pending['temp_token']), json={}) + assert response.status_code in (401, 403) + assert client.get('/api/v1/auth/me', headers=_headers(pending['temp_token'])).status_code == 401 + + verified = client.post('/api/v1/auth/2fa/verify', json={ + 'temp_token': pending['temp_token'], 'code': pyotp.TOTP(user.totp_secret).now(), + }) + assert verified.status_code == 200 + assert client.get('/api/v1/auth/me', headers=_headers(verified.json['access_token'])).status_code == 200 + + +@pytest.mark.parametrize('scopes', [['apps:read'], ['*']]) +def test_api_keys_cannot_mint_browser_sessions(client, db_session, scopes): + user = make_user(db_session, role='admin') + _, raw_key = ApiKeyService.create_key(user.id, name='session-probe', scopes=scopes) + # Even combining a valid JWT with a key must not silently ignore the key. + for headers in ({'X-API-Key': raw_key}, {**headers_for(user), 'X-API-Key': raw_key}): + response = client.post('/api/v1/auth/login-links', json={}, headers=headers) + assert response.status_code == 403 + + +def test_login_link_redemption_respects_target_mfa(client, db_session): + admin = make_user(db_session, role='admin') + target = make_user(db_session, totp_enabled=True, totp_secret=pyotp.random_base32()) + created = client.post('/api/v1/auth/login-links', headers=headers_for(admin), + json={'user_id': target.id}) + assert created.status_code == 201 + redeemed = client.post('/api/v1/auth/login-links/redeem', json={'token': created.json['token']}) + assert redeemed.status_code == 200 + assert redeemed.json['requires_2fa'] is True + assert 'access_token' not in redeemed.json and 'refresh_token' not in redeemed.json + result = client.post('/api/v1/auth/2fa/verify', json={ + 'temp_token': redeemed.json['temp_token'], 'code': pyotp.TOTP(target.totp_secret).now(), + }) + assert result.status_code == 200 and result.json['user']['id'] == target.id + + +def test_password_change_requires_current_password_and_revokes_previous_tokens(client, db_session): + user = make_user(db_session, password='password123') + original = _login(client, user) + headers = _headers(original['access_token']) + for body in ({'password': 'replacement123'}, + {'password': 'replacement123', 'current_password': 'incorrect'}): + assert client.put('/api/v1/auth/me', headers=headers, json=body).status_code == 403 + updated = client.put('/api/v1/auth/me', headers=headers, json={ + 'password': 'replacement123', 'current_password': 'password123', + }) + assert updated.status_code == 200 + assert _refresh(client, original['refresh_token']).status_code == 401 + assert client.get('/api/v1/auth/me', headers=headers).status_code == 401 + assert client.get('/api/v1/auth/me', headers=_headers(updated.json['access_token'])).status_code == 200 + assert _refresh(client, updated.json['refresh_token']).status_code == 200 + + +def test_disable_reenable_and_admin_password_reset_revoke_existing_tokens(client, db_session): + user = make_user(db_session, password='password123') + tokens = _login(client, user) + user.is_active = False + db_session.session.commit() + assert client.get('/api/v1/auth/me', headers=_headers(tokens['access_token'])).status_code == 401 + user.is_active = True + db_session.session.commit() + assert _refresh(client, tokens['refresh_token']).status_code == 401 + fresh = _login(client, user) + admin = make_user(db_session, role='admin') + response = client.put(f'/api/v1/admin/users/{user.id}', headers=headers_for(admin), + json={'password': 'adminreset123'}) + assert response.status_code == 200 + assert _refresh(client, fresh['refresh_token']).status_code == 401 + + +def test_logout_revokes_entire_browser_family_but_preserves_other_browser(client, db_session): + user = make_user(db_session, role='viewer', password='password123') + first = _login(client, user) + second = _login(client, user) + refreshed = _refresh(client, first['refresh_token']) + assert refreshed.status_code == 200 + assert decode_token(first['access_token'])['session_id'] == decode_token(first['refresh_token'])['session_id'] + assert client.post('/api/v1/auth/logout', headers=_headers(first['access_token'])).status_code == 200 + for token in (first['access_token'], refreshed.json['access_token']): + assert client.get('/api/v1/auth/me', headers=_headers(token)).status_code == 401 + assert _refresh(client, first['refresh_token']).status_code == 401 + assert client.get('/api/v1/auth/me', headers=_headers(second['access_token'])).status_code == 200 + assert _refresh(client, second['refresh_token']).status_code == 200 + + +@pytest.mark.parametrize('change', ['disable', 'password']) +def test_revoked_pending_mfa_cannot_finish_login(client, db_session, change): + user = make_user(db_session, password='password123', totp_enabled=True, + totp_secret=pyotp.random_base32()) + pending = _login(client, user) + if change == 'disable': + user.is_active = False + else: + user.set_password('resetpassword123') + db_session.session.commit() + response = client.post('/api/v1/auth/2fa/verify', json={ + 'temp_token': pending['temp_token'], 'code': pyotp.TOTP(user.totp_secret).now(), + }) + assert response.status_code == 401 + + +def test_unexpired_legacy_or_expired_mfa_tokens_are_rejected(client, db_session): + user = make_user(db_session, totp_enabled=True, totp_secret=pyotp.random_base32()) + legacy = access_token_for(user, additional_claims={'auth_version': None}) + assert client.get('/api/v1/auth/me', headers=_headers(legacy)).status_code == 401 + for expiration in (False, timedelta(seconds=-1)): + pending = access_token_for(user, additional_claims={'2fa_pending': True}, + expires_delta=expiration) + response = client.post('/api/v1/auth/2fa/verify', json={ + 'temp_token': pending, 'code': pyotp.TOTP(user.totp_secret).now(), + }) + assert response.status_code == 401 + + +def test_password_reset_invalidates_outstanding_login_links(client, db_session): + admin = make_user(db_session, role='admin') + target = make_user(db_session) + created = client.post('/api/v1/auth/login-links', headers=headers_for(admin), + json={'user_id': target.id}) + assert created.status_code == 201 + target.set_password('resetpassword123') + db_session.session.commit() + assert client.post('/api/v1/auth/login-links/redeem', + json={'token': created.json['token']}).status_code == 401 + + +def test_sso_tokens_share_session_and_pending_tokens_expire(app, db_session): + from app.api.sso import _complete_sso_login + user = make_user(db_session, auth_provider='oidc', password_hash=None) + with app.test_request_context('/api/v1/sso/callback/oidc'): + response, status = _complete_sso_login(user, 'oidc', False) + assert status == 200 + pair = response.get_json() + assert decode_token(pair['access_token'])['session_id'] == decode_token(pair['refresh_token'])['session_id'] + user.totp_enabled = True + db_session.session.commit() + response, status = _complete_sso_login(user, 'oidc', False) + assert status == 200 + claims = decode_token(response.get_json()['temp_token']) + assert claims['2fa_pending'] is True + assert 0 < claims['exp'] - claims['iat'] <= 300 + + +def test_refresh_cannot_renew_recent_authentication(client, db_session): + import time + from flask_jwt_extended import create_refresh_token + user = make_user(db_session, auth_provider='oidc', password_hash=None) + stale_auth = int(time.time()) - 600 + refresh_token = create_refresh_token(identity=user.id, additional_claims={'auth_time': stale_auth}) + refreshed = _refresh(client, refresh_token) + assert refreshed.status_code == 200 + token = refreshed.json['access_token'] + assert decode_token(token)['auth_time'] == stale_auth + assert client.put('/api/v1/auth/me', headers=_headers(token), + json={'password': 'replacement123'}).status_code == 403 + + +def test_session_migration_upgrades_existing_users_and_is_repeatable(tmp_path, monkeypatch): + import importlib.util + from pathlib import Path + import sqlalchemy as sa + from alembic.migration import MigrationContext + from alembic.operations import Operations + + path = Path(__file__).parents[1] / 'migrations/versions/097_user_auth_version.py' + spec = importlib.util.spec_from_file_location('session_migration', path) + migration = importlib.util.module_from_spec(spec) + spec.loader.exec_module(migration) + engine = sa.create_engine('sqlite:///' + (tmp_path / 'previous.db').as_posix()) + with engine.begin() as connection: + connection.exec_driver_sql('CREATE TABLE users (id INTEGER PRIMARY KEY, username TEXT)') + connection.exec_driver_sql("INSERT INTO users VALUES (1, 'existing')") + monkeypatch.setattr(migration, 'op', Operations(MigrationContext.configure(connection))) + migration.upgrade() + migration.upgrade() + assert connection.exec_driver_sql('SELECT username, auth_version FROM users').one() == ('existing', '0') + assert 'revoked_sessions' in sa.inspect(connection).get_table_names() + migration.downgrade() + assert 'auth_version' not in {c['name'] for c in sa.inspect(connection).get_columns('users')} + engine.dispose() + + +def test_revocation_rows_cascade_on_user_deletion(db_session): + from app.models import RevokedSession + user = make_user(db_session) + db_session.session.add(RevokedSession(session_id='a' * 32, user_id=user.id)) + db_session.session.commit() + assert RevokedSession.query.filter_by(user_id=user.id).count() == 1 + connection = db_session.session.connection() + connection.exec_driver_sql('PRAGMA foreign_keys=ON') + assert connection.exec_driver_sql('PRAGMA foreign_keys').scalar() == 1 + try: + db_session.session.delete(user) + db_session.session.commit() + assert RevokedSession.query.count() == 0 + finally: + db_session.session.rollback() + db_session.session.connection().exec_driver_sql('PRAGMA foreign_keys=OFF') diff --git a/backend/tests/test_shared_queue_authz.py b/backend/tests/test_shared_queue_authz.py index 9a397eb39..a6f16fb0e 100644 --- a/backend/tests/test_shared_queue_authz.py +++ b/backend/tests/test_shared_queue_authz.py @@ -165,9 +165,9 @@ def test_detach_requires_group_and_target_write(client, sr_rbac): def test_create_group_requires_scope_write(client, sr_rbac): s = sr_rbac.s body = {'scope_type': 'workspace', 'scope_id': str(s.ws_id), 'name': 'New'} - assert client.post(G, json=body, headers=s.member).status_code == 201 - assert client.post(G, json=body, headers=s.viewer).status_code == 403 - assert client.post(G, json=body, headers=s.foreign).status_code == 403 + for persona, status in [('member', 201), ('viewer', 403), ('foreign', 403)]: + response = client.post(G, json=body, headers=getattr(s, persona)) + assert response.status_code == status, (persona, response.get_json()) def test_create_group_application_scope_foreign_denied(client, sr_rbac): diff --git a/backend/tests/test_shared_service_helpers.py b/backend/tests/test_shared_service_helpers.py new file mode 100644 index 000000000..85b68e77e --- /dev/null +++ b/backend/tests/test_shared_service_helpers.py @@ -0,0 +1,60 @@ +"""Shared protocol/actor/host helpers retain their callers' contracts.""" + +from datetime import datetime, timedelta, timezone +import pytest +from flask import g +from flask_jwt_extended import verify_jwt_in_request + +from app.services.cf_ops_change_service import CfOpsChangeService +from app.services.connect_format import iso_datetime +from app.services.resource_tier_service import ResourceTierService +from app.services.shared_resource_service import _current_user_id +from app.utils.actor import current_actor_id +from factories import make_user, headers_for + + +@pytest.mark.parametrize('lookup', [current_actor_id, _current_user_id, CfOpsChangeService._current_user_id]) +def test_optional_actor_without_request(lookup): + assert lookup() is None + + +@pytest.mark.parametrize('kind', ['jwt', 'api-key']) +def test_shared_actor_preserves_authenticated_owner(app, db_session, kind): + user = make_user(db_session, role='admin', username=f'actor-{kind}') + headers = {} + if kind == 'jwt': + headers = headers_for(str(user.id)) + with app.test_request_context(headers=headers): + if kind == 'jwt': + verify_jwt_in_request() + else: + g.api_key_user = user + assert current_actor_id() == user.id + assert _current_user_id() == user.id + assert CfOpsChangeService._current_user_id() == user.id + + +def test_actor_lookup_failure_remains_best_effort(monkeypatch): + def unavailable(): + raise RuntimeError('identity unavailable') + monkeypatch.setattr('app.middleware.rbac.get_current_user', unavailable) + assert current_actor_id() is None + + +@pytest.mark.parametrize('value,expected', [ + (None, None), ('already formatted', 'already formatted'), (123, None), + (datetime(2026, 9, 5), '2026-09-05T00:00:00+00:00'), + (datetime(2026, 9, 5, tzinfo=timezone(timedelta(hours=2))), '2026-09-05T00:00:00+02:00'), +]) +def test_connect_date_serialization(value, expected): + from app.services.connect_policy import _iso as policy_iso + from app.services.connect_storage import _iso as storage_iso + assert iso_datetime(value) == expected + assert policy_iso(value) == expected + assert storage_iso(value) == expected + + +@pytest.mark.parametrize('container', [None, 'docker', 'lxc', 'openvz']) +def test_resource_tier_uses_host_inventory_detection(monkeypatch, container): + monkeypatch.setattr('app.services.host_inventory_service._detect_container', lambda: container) + assert ResourceTierService._detect_container() == container diff --git a/backend/tests/test_socket_security.py b/backend/tests/test_socket_security.py new file mode 100644 index 000000000..0822bec5a --- /dev/null +++ b/backend/tests/test_socket_security.py @@ -0,0 +1,230 @@ +"""Socket authorization must hold both when joining and during delivery.""" +import pytest +from flask_jwt_extended import create_refresh_token + +from app import sockets as sk +from app.models.deployment_job import DeploymentJob +from factories import make_user, make_application, headers_for, access_token_for + + +@pytest.fixture +def clients(app): + opened = [] + + def connect(user=None, token=None): + client = sk.socketio.test_client(app, auth={ + 'token': token or access_token_for(user), + }) + opened.append(client) + if client.is_connected(): + client.get_received() + return client + + yield connect + for client in opened: + if client.is_connected(): + client.disconnect() + + +@pytest.mark.parametrize('kind', ['pending', 'refresh', 'no-expiry']) +def test_socket_rejects_non_session_tokens(db_session, clients, kind): + user = make_user(db_session, role='admin') + if kind == 'pending': + token = access_token_for(user, additional_claims={'2fa_pending': True}) + elif kind == 'refresh': + token = create_refresh_token(identity=user.id) + else: + token = access_token_for(user, expires_delta=False) + assert not clients(token=token).is_connected() + + +def test_other_user_room_cannot_receive_notifications(db_session, clients): + user = make_user(db_session, role='viewer') + other = make_user(db_session) + sock = clients(user) + sock.emit('join_room', {'room': sk.rooms.user_room(other.id)}) + assert any(e['name'] == 'error' for e in sock.get_received()) + sk.socketio.emit('private', {'secret': 'foreign'}, room=sk.rooms.user_room(other.id)) + assert sock.get_received() == [] + sk.socketio.emit('private', {'secret': 'own'}, room=sk.rooms.user_room(user.id)) + assert sock.get_received()[0]['name'] == 'private' + + +def test_app_and_deploy_rooms_follow_resource_access(db_session, clients): + user = make_user(db_session, role='viewer') + own = make_application(db_session, user_id=user.id) + foreign = make_application(db_session) + for application in (own, foreign): + db_session.session.add(DeploymentJob(id=f'd-{application.id}', kind='test', app_id=application.id)) + db_session.session.commit() + sock = clients(user) + for application, allowed in ((own, True), (foreign, False)): + job_id = f'd-{application.id}' + for event, data in [ + ('join_room', {'room': sk.rooms.app_logs_room(application.id)}), + ('join_room', {'room': sk.rooms.deploy_room(job_id)}), + ('join_room', {'room': sk.rooms.run_room('deploy', job_id)}), + ('subscribe_deploy', {'job_id': job_id}), + ('subscribe_run', {'run_kind': 'deploy', 'run_id': job_id}), + ]: + sock.emit(event, data) + events = sock.get_received() + assert any(e['name'] in ('joined', 'subscribed') for e in events) is allowed + assert any(e['name'] == 'error' for e in events) is not allowed + if event == 'subscribe_deploy': + room = sk.rooms.deploy_room(job_id) + elif event == 'subscribe_run': + room = sk.rooms.run_room('deploy', job_id) + else: + room = data['room'] + sk.socketio.emit('delivery-proof', {'ok': True}, room=room) + assert any(e['name'] == 'delivery-proof' for e in sock.get_received()) is allowed + + +def test_requester_can_watch_appless_deploy_but_not_missing_or_unknown_runs(db_session, clients): + user = make_user(db_session, role='developer') + db_session.session.add(DeploymentJob(id='own', kind='test', requested_by=user.id)) + db_session.session.commit() + sock = clients(user) + sock.emit('subscribe_deploy', {'job_id': 'own'}) + assert sock.get_received()[0]['name'] == 'subscribed' + for kind, rid in [('deploy', 'missing'), ('unknown', 'own'), ('job', 'own')]: + sock.emit('subscribe_run', {'run_kind': kind, 'run_id': rid}) + assert sock.get_received()[0]['name'] == 'error' + + +def test_host_and_foreign_container_logs_denied_before_io(db_session, clients, monkeypatch): + user = make_user(db_session, role='viewer') + foreign = make_application(db_session) + monkeypatch.setattr(sk.log_streamer, 'start_stream', lambda *a: pytest.fail('host I/O reached')) + monkeypatch.setattr(sk.DockerService, 'get_all_app_containers', lambda *a: pytest.fail('Docker reached')) + sock = clients(user) + for event, data in [('subscribe_logs', {'path': '/var/log/auth.log'}), + ('subscribe_container_logs', {'app_id': foreign.id})]: + sock.emit(event, data) + assert sock.get_received()[0]['name'] == 'error' + + +def test_server_job_and_terminal_require_operator_and_session_owner(db_session, clients, monkeypatch): + from app.models.server import Server + from app.services.terminal_service import TerminalService + server = Server(name='test', id='server-1') + db_session.session.add(server) + operator = make_user(db_session, role='developer') + viewer = make_user(db_session, role='viewer') + other = make_user(db_session, role='developer') + monkeypatch.setattr(TerminalService, 'get_session', lambda sid: { + 'server_id': server.id, 'user_id': operator.id, + } if sid == 'session-1' else None) + for user in (operator, viewer, other): + sock = clients(user) + sock.emit('join_room', {'room': sk.rooms.server_channel_room(server.id, 'job:job-1')}) + assert sock.get_received()[0]['name'] == ('error' if user == viewer else 'joined') + sk.socketio.emit('server_stream', {'channel': 'job:job-1'}, + room=sk.rooms.server_channel_room(server.id, 'job:job-1')) + assert any(e['name'] == 'server_stream' for e in sock.get_received()) is (user != viewer) + for event, data in [ + ('subscribe_terminal', {'session_id': 'session-1'}), + ('join_room', {'room': sk.rooms.server_terminal_room(server.id, 'session-1')}), + ]: + sock.emit(event, data) + assert (sock.get_received()[0]['name'] != 'error') is (user == operator) + sk.socketio.emit('server_stream', {'channel': 'terminal:session-1'}, + room=sk.rooms.server_terminal_room(server.id, 'session-1')) + assert any(e['name'] == 'server_stream' for e in sock.get_received()) is (user == operator) + sock.emit('join_room', {'room': 'server_missing_job:job-1'}) + assert sock.get_received()[0]['name'] == 'error' + + +@pytest.mark.parametrize('change', ['disabled', 'role', 'revoke']) +def test_open_socket_is_revoked_before_next_delivery(db_session, clients, change): + user = make_user(db_session, role='developer') + sock = clients(user) + if change == 'disabled': + user.is_active = False + elif change == 'role': + user.role = 'viewer' + else: + user.revoke_sessions() + db_session.session.commit() + sk.socketio.emit('private', {'secret': 'must not arrive'}, room=sk.rooms.user_room(user.id)) + assert not sock.is_connected() + + +def test_removed_app_grant_stops_already_joined_stream(db_session, clients): + from app.models.workspace import ResourceGrant + user = make_user(db_session, role='viewer') + application = make_application(db_session) + grant = ResourceGrant(user_id=user.id, resource_type='application', resource_id=application.id, role='viewer') + db_session.session.add(grant) + db_session.session.commit() + sock = clients(user) + sock.emit('join_room', {'room': sk.rooms.app_logs_room(application.id)}) + assert sock.get_received()[0]['name'] == 'joined' + db_session.session.delete(grant) + db_session.session.commit() + sk.emit_container_log(application.id, 'private') + assert sock.get_received() == [] + + +def test_status_broadcast_is_scoped_to_subscribers_and_visible_apps(db_session, clients, monkeypatch): + from app.services import container_status_service as css + user = make_user(db_session, role='viewer') + own = make_application(db_session, user_id=user.id) + foreign = make_application(db_session) + sock, idle = clients(user), clients(user) + monkeypatch.setattr(sk.container_status_loop, 'start', lambda **kwargs: None) + monkeypatch.setattr(css, 'get_changed_app_statuses', lambda: [ + {'app_id': own.id, 'status': 'running'}, + {'app_id': foreign.id, 'status': 'running'}, + ]) + sock.emit('subscribe_container_status') + sock.get_received() + sk._container_status_tick() + statuses = sock.get_received()[0]['args'][0]['statuses'] + assert [row['app_id'] for row in statuses] == [own.id] + assert idle.get_received() == [] + + +def test_metrics_only_reach_subscribers(db_session, clients, monkeypatch): + user = make_user(db_session) + sock, idle = clients(user), clients(user) + monkeypatch.setattr(sk.metrics_loop, 'start', lambda **kwargs: None) + monkeypatch.setattr(sk.SystemService, 'get_all_metrics', lambda: {'cpu': 1}) + sock.emit('subscribe_metrics') + sock.get_received() + sk._metrics_tick() + assert sock.get_received()[0]['name'] == 'metrics' + assert idle.get_received() == [] + + +def test_run_polling_cannot_bypass_socket_gate(client, db_session): + user = make_user(db_session, role='viewer') + foreign = make_application(db_session) + db_session.session.add(DeploymentJob(id='foreign', kind='test', app_id=foreign.id)) + db_session.session.commit() + assert client.get('/api/v1/runs/deploy/foreign/logs', headers=headers_for(user)).status_code == 403 + + +def test_logout_revokes_existing_socket(client, db_session, clients): + user = make_user(db_session) + token = access_token_for(user) + sock = clients(token=token) + response = client.post('/api/v1/auth/logout', headers={'Authorization': f'Bearer {token}'}) + assert response.status_code == 200 + sk.socketio.emit('private', {'secret': 'must not arrive'}, room=sk.rooms.user_room(user.id)) + assert not sock.is_connected() + + +def test_operator_cannot_join_other_workspace_server(db_session, clients): + from app.models.server import Server + from app.models.workspace import Workspace + workspace = Workspace(name='Private', slug='private') + db_session.session.add(workspace) + db_session.session.flush() + server = Server(id='private-server', name='Private', workspace_id=workspace.id) + db_session.session.add(server) + user = make_user(db_session, role='developer') + sock = clients(user) + sock.emit('join_room', {'room': sk.rooms.server_channel_room(server.id, 'job:job-1')}) + assert sock.get_received()[0]['name'] == 'error' diff --git a/backend/tests/test_soft_delete_leaks.py b/backend/tests/test_soft_delete_leaks.py index 23e431bba..39fa62fab 100644 --- a/backend/tests/test_soft_delete_leaks.py +++ b/backend/tests/test_soft_delete_leaks.py @@ -11,20 +11,20 @@ from app import db from app.models import Application, Domain from app.services.domain_attach_service import DomainAttachService +from factories import make_application @pytest.fixture def deleted_domain(app): with app.app_context(): - application = Application(name='shop', app_type='docker', port=8001, user_id=1) - db.session.add(application) - db.session.flush() + application = make_application(db, name='shop', port=8001) gone = Domain(name='old.example.com', application_id=application.id, is_primary=True) db.session.add(gone) db.session.commit() gone.soft_delete() db.session.commit() - yield {'app_id': application.id, 'domain_id': gone.id, 'name': gone.name} + yield {'app_id': application.id, 'user_id': application.user_id, + 'domain_id': gone.id, 'name': gone.name} def test_app_payload_hides_deleted_domains(app, deleted_domain): @@ -42,9 +42,8 @@ def test_a_deleted_name_is_not_a_clash(app, deleted_domain): """Migration 083 made the unique index partial so deleting frees the name. An application-level clash check must not re-impose the burn.""" with app.app_context(): - other = Application(name='other', app_type='docker', port=8002, user_id=1) - db.session.add(other) - db.session.commit() + make_application(db, name='other', port=8002, + user_id=deleted_domain['user_id']) clash = Domain.query_active().filter_by(name=deleted_domain['name']).first() assert clash is None, 'a tombstone still blocks the name for another app' diff --git a/builtin-extensions/serverkit-remote-access/frontend/styles/remote-access.scss b/builtin-extensions/serverkit-remote-access/frontend/styles/remote-access.scss index 2401ad23b..e4f14d03c 100644 --- a/builtin-extensions/serverkit-remote-access/frontend/styles/remote-access.scss +++ b/builtin-extensions/serverkit-remote-access/frontend/styles/remote-access.scss @@ -254,3 +254,22 @@ } .ra-svc__right { width: 100%; justify-content: space-between; } } + +// Semantic layouts and controls; declarations preserved from the legacy compatibility layer. +.ra-service-form > * + * { margin-top: 1rem; } +.ra-service-field > * + * { margin-top: 0.375rem; } +.ra-service-hint { font-size: 0.75rem; color: $text-tertiary; } +.ra-service-routing { display: grid; grid-template-columns: repeat(1, minmax(0, 1fr)); gap: 0.75rem; } +@media (min-width: $breakpoint-sm) { + .ra-service-routing { grid-template-columns: repeat(3, minmax(0, 1fr)); } +} +@media (min-width: $breakpoint-sm) { + .ra-service-field--host { grid-column: span 2 / span 2; } +} +.ra-service-field--host > * + * { margin-top: 0.375rem; } +.ra-service-option { display: flex; align-items: center; justify-content: space-between; } +.ra-service-credentials { display: grid; grid-template-columns: repeat(1, minmax(0, 1fr)); gap: 0.75rem; } +@media (min-width: $breakpoint-sm) { + .ra-service-credentials { grid-template-columns: repeat(2, minmax(0, 1fr)); } +} +.ra-service-description { font-size: 0.875rem; color: $text-tertiary; } diff --git a/builtin-extensions/serverkit-walkthrough-studio/frontend/index.jsx b/builtin-extensions/serverkit-walkthrough-studio/frontend/index.jsx index b11b9300b..819c07ab6 100644 --- a/builtin-extensions/serverkit-walkthrough-studio/frontend/index.jsx +++ b/builtin-extensions/serverkit-walkthrough-studio/frontend/index.jsx @@ -409,18 +409,18 @@ export function WalkthroughStudioPage() {
{loading &&

{t('common.loading', 'Loading…')}

} {!loading && library.length === 0 && ( - + )} {library.map((guide) => (
- + @@ -486,7 +486,7 @@ export function WalkthroughStudioPage() {
{draft.steps.map((step, index) => ( - + ))}
diff --git a/docs/API_SURFACE.md b/docs/API_SURFACE.md index cb893a85f..d59e8bb27 100644 --- a/docs/API_SURFACE.md +++ b/docs/API_SURFACE.md @@ -796,6 +796,7 @@ Regenerate (backend/): - `POST /auth/login` - `POST /auth/login-links` - `POST /auth/login-links/redeem` +- `POST /auth/logout` - `POST /auth/passkeys/authenticate` - `POST /auth/passkeys/options/authenticate` - `POST /auth/passkeys/options/register` diff --git a/docs/METRICS.md b/docs/METRICS.md new file mode 100644 index 000000000..85a53481e --- /dev/null +++ b/docs/METRICS.md @@ -0,0 +1,115 @@ +# ServerKit measurements + +The README tables are a dated snapshot, generated from the same JSON files for +English, Spanish, Portuguese and Chinese. They separate source inventory from +build sizes and runtime measurements. A count of collected tests is not a count +of passing tests, and compressed asset bytes are not a page-load benchmark. + +## Recorded snapshot + +- [Source inventory](measurements/repository.json): measured on September 5, + 2026 with Python 3.11.7 on Windows. The clean collection count was also checked + on Linux. The base source revision, whether measured source has uncommitted + changes, and a hash of that measured source are recorded. The hash describes + the working files, so a pre-commit snapshot is not attributed to an unchanged + historical revision. +- [Production build inventory](measurements/frontend-build.json): Node version, + per-asset byte/gzip sizes and a content hash are recorded. Built from an + isolated copy of tracked/new frontend source after the request/form cleanup, + excluding ignored files and reusing the installed dependencies, with + `npm run build`. Its code hash matched the ordinary working-checkout build. +- Runtime memory and container image bytes are **unmeasured in this snapshot**. + The previous ~180 MB RAM and 501 MB image figures had no reproducible + environment or image digest, so they were removed instead of repeated. + +### What each number includes + +| Measurement | Definition | +| --- | --- | +| Core route declarations | Route decorators in Git-tracked Python under `backend/app/api/`. A function with two route decorators counts twice. This is source inventory, not the set of enabled runtime endpoints. | +| Core blueprint declarations | `Blueprint(...)` declarations in the same files. Extension blueprints and conditionally loaded routes are outside this count. | +| Explicit method/route pairs | Literal route methods, excluding automatic HEAD/OPTIONS. The JSON separately reports declarations whose methods cannot be resolved statically. | +| App templates | Git-tracked YAML files directly inside `backend/templates/`; nested database-extension templates are excluded. | +| Backend tests | Pytest collection from `backend/tests` using the existing clean-collection instrument; ignored installed extension copies do not inflate the count. Skipped/opt-in tests can still be collected. | +| HTML-linked code | JS/MJS/CSS referenced by script, stylesheet or modulepreload tags in production `index.html`. Later runtime requests, fonts and images are excluded. | +| All built code | Every JS/MJS/CSS file in the production output, including lazy chunks, translations, extension code and public vendor shims. HTML, fonts, images, source maps and other assets are excluded. | +| Gzip bytes | Sum of each file compressed independently with gzip level 9. MB means 1,000,000 bytes. These are reproducible compression estimates; actual transfer depends on server compression and browser caching. | + +The old “1.75 MB web UI” number had no precise asset scope. The new HTML-linked +and all-code totals must not be interpreted as a measured speedup over that +old figure. Non-English locale chunks are already loaded on demand; their +build-size warnings alone do not show that every visitor downloads them. + +## Refresh the source and build measurements + +Use the backend Python environment with its dependencies installed. From the +repository root: + +```bash +python scripts/measure-repository.py --collect-tests --output docs/measurements/repository.json +cd frontend +npm run build +npm run measure:build -- --output ../docs/measurements/frontend-build.json +cd .. +python scripts/update-readme-measurements.py --write +python scripts/update-readme-measurements.py +``` + +On Windows, `backend\venv\Scripts\python.exe` can replace `python`. Review the +snapshot and README diffs together. The final command checks agreement between +all translated tables and the snapshots; it does not pretend an old build +snapshot is a fresh measurement. Rebuild when publishing new size claims. + +## Measure API latency and database work + +The panel already records API timings through its analytics middleware. For a +bounded investigation, enable `SERVERKIT_PROFILE_REQUESTS=true` on an authorized +local or staging panel and restart it. This adds a `Server-Timing` response +header containing application elapsed time, SQL elapsed time and statement +count. It records neither SQL text nor parameters and creates no new database +table. The profiler is off by default and installs no SQL listeners when off. + +Use the real HTTP sampler against the running panel: + +```bash +python scripts/profile-api.py --base-url http://127.0.0.1:47927 --path /api/v1/system/health --samples 20 --warmup 2 --output .reviews/api-profile.json +``` + +For protected endpoints, supply a short-lived JWT through +`SERVERKIT_PROFILE_TOKEN`, or `SERVERKIT_PROFILE_API_KEY` only for routes that +already support API keys. Do not supply both. The sampler makes GET requests, +refuses redirects, and does not include bodies, credentials or query values in +its report. HTTP/transport failures are recorded and make the command fail. +Missing profiler headers produce `null` fields, never invented zero costs. + +Reports contain sample count, response sizes/statuses, min/p50/p95/max latency, +and optional application/SQL distributions. Warmup is excluded from the latency +summary, but its requests and failures remain visible. Turn profiling off when +the investigation is finished. + +Record panel revision, OS/architecture, Python version, database engine, +server/resource counts, cache state, concurrency and whether the panel is idle +or loaded. Compare the same dataset and environment before and after a change. +The sampler is sequential and does not establish throughput under concurrency. +Profiler SQL counts cover statements on the HTTP request thread through response +construction; background jobs and streamed body iteration are not included. + +## Measure memory, images and browser loading separately + +- **Memory:** record the exact process/container, workload, uptime and sampling + interval. Process RSS and Docker memory accounting are different measurements. + Include workers and enabled services; do not describe one idle sample as a + universal minimum or capacity guarantee. +- **Image size:** record image digest, architecture, build arguments and whether + bytes describe the local uncompressed image or registry transfer. A cached + image from a different commit is not a measurement of the current source. +- **Browser loading:** record cold/warm cache, device, connection, route, locale + and extension set. Use a production build and inspect actual requests and + navigation timings; source-file counts and total dist size cannot substitute + for this. Large assets should be optimized when that trace shows they affect + a relevant user flow. + +No production latency, memory, capacity or percentage-speedup claim is made by +the source/build snapshot. Existing fleet tests enforce one metrics query per +reader at multiple fleet sizes; the opt-in profiler makes that kind of query +budget observable on a running panel too. diff --git a/docs/MIGRATION_INVENTORY.md b/docs/MIGRATION_INVENTORY.md index e7989f1db..a20d68944 100644 --- a/docs/MIGRATION_INVENTORY.md +++ b/docs/MIGRATION_INVENTORY.md @@ -16,15 +16,15 @@ debt. Regenerate with `python scripts/generate-migration-inventory.py`. | Routes on bare @jwt_required() | auth_required() / role decorators | 608 | 608 | REGISTERED EXCEPTION: JWT-only is the deliberate default; conversion grants API-key access and happens per route, on decision | | HTTP statuses chosen by sniffing error text | typed errors from app.exceptions | 0 | 0 | INVARIANT at 0 - migration completed 2026-08-19 | | API crashes swallowed without recording | app.error_reporting | 0 | 0 | INVARIANT at 0 | -| Hand-shaped {'error': ...} bodies in app/api | typed errors + the global handler | 1145 | 1150 | migrate when touched; new endpoints raise | +| Hand-shaped {'error': ...} bodies in app/api | typed errors + the global handler | 1141 | 1150 | migrate when touched; new endpoints raise | | Raw subprocess calls outside the runners | app/utils/system.py runners | 24 | 24 | migrate when touched | -| Controller-boundary violations (routes doing service work) | service layer extraction | 511 | 511 | migrate when touched (first-wave ratchet) | -| raw api.* calls in pages/ | E1: useServerQuery/useServerMutation | 405 | 405 | migrate when touched | -| per-page toast.error extractions in pages/ | E1: query-layer error presentation | 218 | 218 | migrate when touched | -| hand-rolled form-group blocks | F2: FormField/useForm | 326 | 343 | migrate when touched | -| unencoded ?k=${v} query interpolations in services/api | C4: buildQuery/encoding template | 87 | 97 | migrate when touched | -| raw setInterval pollers | E2: usePolling/refetchInterval | 9 | 9 | DELIBERATE RESIDUE: clock ticks, socket-fallback hooks, and sibling-repo extension timers - each listed per file | +| Controller-boundary violations (routes doing service work) | service layer extraction | 496 | 496 | migrate when touched (first-wave ratchet) | +| raw api.* calls in pages/ | E1: useServerQuery/useServerMutation | 398 | 398 | migrate when touched | +| per-page toast.error extractions in pages/ | E1: query-layer error presentation | 206 | 206 | migrate when touched | +| hand-rolled form-group blocks | F2: FormField/useForm | 321 | 321 | migrate when touched | +| unencoded ?k=${v} query interpolations in services/api | C4: buildQuery/encoding template | 0 | 0 | INVARIANT at 0 | +| raw setInterval pollers | E2: usePolling/refetchInterval | 7 | 7 | DELIBERATE RESIDUE: clock ticks, socket-fallback hooks, and sibling-repo extension timers - each listed per file | | direct navigator.clipboard call sites | F3: copyToClipboard | 0 | 0 | INVARIANT at 0 | | hex colour literals outside token files | G: var(--token) | 150 | 150 | migrate when touched | -| SCSS class names defined in multiple files | single-owner partials | 114 | 114 | needs eyes on pages (no byte-identical proof available) | +| SCSS class names defined in multiple files | single-owner partials | 0 | 0 | needs eyes on pages (no byte-identical proof available) | diff --git a/docs/README.es.md b/docs/README.es.md index 3b3bb1e8d..1c784f797 100644 --- a/docs/README.es.md +++ b/docs/README.es.md @@ -44,25 +44,25 @@ ni el coste de las plataformas gestionadas. ## 📊 En Números -Todo lo siguiente está medido sobre este repositorio, no estimado. + +Instantánea: **2026-09-05**. [Definiciones, medidas y comandos para reproducirlas](METRICS.md). | | | |---|---| -| **1.519** endpoints REST | repartidos en 107 blueprints — `/api/v1/*`, con OpenAPI y Swagger UI en `/api/v1/docs` | -| **106** plantillas de apps | incluidas en el repositorio, sin necesidad de cuenta en ningún registro | -| **2.633** pruebas de backend | la suite completa se ejecuta ante cada cambio | -| **1,75 MB** de interfaz web (gzip) | 65 pantallas, servidas desde tu propio servidor — sin CDN | -| **~180 MB** en memoria | el panel entero, un solo proceso — convive sin apreturas con tus apps en un VPS de 1 GB | -| **501 MB** de imagen de contenedor | o instálalo directamente en el host; Docker es opcional para el panel | -| **$0** | Licencia MIT. Sin planes, sin límite de usuarios, sin ventas adicionales — y nada se comunica hacia afuera. | +| **1.212** declaraciones de rutas del núcleo | en **104** declaraciones de blueprints de `backend/app/api`; inventario del código, sin extensiones | +| **118** plantillas de apps incluidas | archivos YAML de apps en el directorio raíz; las plantillas de extensiones de bases de datos se cuentan aparte | +| **5.089** casos de prueba de backend recopilados | recopilación de un checkout limpio; no significa que todos se hayan ejecutado o aprobado | +| **3,31 MB** de JS/CSS en total, comprimido con gzip | incluye módulos diferidos, idiomas y adaptadores de dependencias; excluye fuentes e imágenes | +| **$0** de licencia | Licencia MIT, sin suscripciones ni tarifas por usuario | -Autoalojado y nativo en Docker, sobre hardware que ya estás pagando. +Los valores gzip suman archivos comprimidos por separado a nivel 9; no son tiempos de carga medidos. La RAM y el tamaño de imagen dependen de la compilación, plataforma y carga de trabajo. + --- ## 🚀 Inicio Rápido -> ⏱️ En funcionamiento en menos de 2 minutos +> El tiempo de instalación depende del servidor, la red y los paquetes necesarios. ### Opción 1: Instalación en Una Línea (Recomendada) @@ -115,7 +115,7 @@ Consulta la [Guía de Instalación](INSTALLATION.md) para instrucciones paso a p | **Disco** | 10 GB | 20+ GB | | **Docker** | 24.0+ (opcional para el panel) | Última versión | -> El panel en sí solo usa ~180 MB de RAM y ~500 MB de disco — el resto es margen para tus apps. Funciona bien en un VPS de 1 GB, un portátil viejo o una Raspberry Pi (ARM64): pensado tanto para homelabs como para producción. +> Estos requisitos orientan el dimensionamiento; no son una prueba de capacidad. Reserva RAM y disco adicionales para apps, imágenes, bases de datos, registros y copias de seguridad. Consulta la [guía de medición](METRICS.md) para medir tu carga de trabajo. --- diff --git a/docs/README.pt.md b/docs/README.pt.md index 39ce1760b..3b1e530dd 100644 --- a/docs/README.pt.md +++ b/docs/README.pt.md @@ -44,25 +44,25 @@ ou o custo de plataformas gerenciadas. ## 📊 Em Números -Tudo abaixo é medido a partir deste repositório, não estimado. + +Registro: **2026-09-05**. [Definições, medições e comandos de reprodução](METRICS.md). | | | |---|---| -| **1.519** endpoints REST | distribuídos em 107 blueprints — `/api/v1/*`, com OpenAPI e Swagger UI em `/api/v1/docs` | -| **106** templates de aplicações | incluídos no repositório, sem precisar de conta em nenhum registro | -| **2.633** testes de backend | a suíte completa roda a cada alteração | -| **1,75 MB** de interface web (gzip) | 65 telas, servidas do seu próprio servidor — sem CDN | -| **~180 MB** residentes | o painel inteiro, um único processo — convive folgadamente com suas apps num VPS de 1 GB | -| **501 MB** de imagem de contêiner | ou instale direto no host; Docker é opcional para o painel | -| **$0** | Licença MIT. Sem planos, sem limite de usuários, sem upsell — e nada é enviado para fora. | +| **1.212** declarações de rotas do núcleo | em **104** declarações de blueprints em `backend/app/api`; inventário do código, sem extensões | +| **118** templates de apps incluídos | arquivos YAML de apps no diretório raiz; templates de extensões de banco são contados separadamente | +| **5.089** casos de teste de backend coletados | coleta de um checkout limpo; não significa que todos executaram ou passaram | +| **3,31 MB** de JS/CSS no total, comprimido com gzip | inclui módulos sob demanda, idiomas e adaptadores de dependências; exclui fontes e imagens | +| **$0** de licença | Licença MIT, sem assinaturas ou taxas por usuário | -Auto-hospedado e nativo em Docker, em hardware que você já paga. +Os valores gzip somam arquivos comprimidos separadamente no nível 9; não são tempos de carregamento medidos. A RAM e o tamanho da imagem dependem da compilação, plataforma e carga de trabalho. + --- ## 🚀 Início Rápido -> ⏱️ Pronto para usar em menos de 2 minutos +> O tempo de instalação depende do servidor, da rede e dos pacotes necessários. ### Opção 1: Instalação em Uma Linha (Recomendado) @@ -115,7 +115,7 @@ Consulte o [Guia de Instalação](INSTALLATION.md) para instruções passo a pas | **Disco** | 10 GB | 20+ GB | | **Docker** | 24.0+ (opcional para o painel) | Mais recente | -> O painel em si usa apenas ~180 MB de RAM e ~500 MB de disco — o resto é margem para os seus apps. Funciona bem num VPS de 1 GB, num notebook antigo ou num Raspberry Pi (ARM64): feito tanto para homelabs quanto para produção. +> Estes requisitos orientam o dimensionamento; não são um teste de capacidade. Reserve RAM e disco adicionais para apps, imagens, bancos, logs e backups. Consulte o [guia de medição](METRICS.md) para medir sua carga de trabalho. --- diff --git a/docs/README.zh-CN.md b/docs/README.zh-CN.md index 9f13c1c70..1e836b8e6 100644 --- a/docs/README.zh-CN.md +++ b/docs/README.zh-CN.md @@ -44,25 +44,25 @@ Docker 容器和安全策略——无需 Kubernetes 的复杂性, ## 📊 数据一览 -以下数据均实测自本仓库,并非估算。 + +测量快照:**2026-09-05**。[定义、原始测量结果和复现命令](METRICS.md)。 | | | |---|---| -| **1,519** 个 REST 接口 | 分布在 107 个蓝图中 — `/api/v1/*`,并在 `/api/v1/docs` 提供 OpenAPI 与 Swagger UI | -| **106** 个一键应用模板 | 随仓库内置,无需注册任何应用市场账号 | -| **2,633** 项后端测试 | 每次变更都会跑完整套件 | -| **1.75 MB** 前端界面(gzip 后) | 65 个页面,静态资源全部由你自己的服务器提供 — 不依赖 CDN | -| **~180 MB** 常驻内存 | 整个面板仅一个进程 — 在 1 GB 内存的 VPS 上与你的应用从容共存 | -| **501 MB** 容器镜像 | 也可直接装在宿主机上;面板本身并不强制使用 Docker | -| **$0** | MIT 许可证。没有套餐分级、没有席位限制、没有增值推销 — 也不会向外回传任何数据。 | +| **1,212** 个核心路由声明 | 来自 `backend/app/api` 中 **104** 个蓝图声明;这是源码清单,不包括扩展 | +| **118** 个内置应用模板 | 模板目录根层级的应用 YAML 文件;数据库扩展模板另计 | +| **5,089** 个已收集的后端测试用例 | 基于干净检出的测试收集结果;不代表所有用例均已运行或通过 | +| **3.31 MB** 全部 JS/CSS 的 gzip 压缩总大小 | 包括按需模块、语言包和依赖适配文件;不含字体和图片 | +| **$0** 许可费用 | MIT 许可证,无订阅或席位费用 | -自托管、Docker 原生,跑在你已经付过钱的机器上。 +gzip 数值为各文件分别按级别 9 压缩后的总和,并非实测页面加载时间。内存占用与镜像大小取决于构建、平台和工作负载,不作通用占用保证。 + --- ## 🚀 快速开始 -> ⏱️ 不到 2 分钟即可启动运行 +> 安装时间取决于服务器、网络和所需的软件包。 ### 方式一:一键安装(推荐) @@ -113,7 +113,7 @@ docker compose up -d # 访问 http://localhost:5000 | **磁盘** | 10 GB | 20+ GB | | **Docker** | 24.0+(面板本身可选) | 最新版 | -> 面板本身仅占用约 180 MB 内存和 500 MB 磁盘——其余都是为你的应用预留的空间。在 1 GB VPS、闲置笔记本或树莓派(ARM64)上都能流畅运行:既适合家庭实验室,也适合生产环境。 +> 这些要求是资源规划建议,并非容量基准测试。请为托管应用、镜像、数据库、日志和备份预留额外内存与磁盘空间。可参考[测量指南](METRICS.md)评估自己的工作负载。 --- diff --git a/docs/measurements/frontend-build.json b/docs/measurements/frontend-build.json new file mode 100644 index 000000000..b086ef602 --- /dev/null +++ b/docs/measurements/frontend-build.json @@ -0,0 +1,1008 @@ +{ + "schema_version": 1, + "measured_at_utc": "2026-09-05T18:36:33.444Z", + "node_version": "v22.17.0", + "measured_code_sha256": "150d84e581e07c19533a81a25946e1e62e9666ef3287c6a99159625e8ad8acd6", + "compression": "gzip, level 9, each file separately; sizes are bytes", + "scope": "Built JS/MJS/CSS, including public vendor shims. Excludes HTML, fonts, images, maps and other non-code assets.", + "html_linked_scope": "Only script, stylesheet and modulepreload URLs in index.html; not a complete runtime network trace or every later lazy import.", + "all_code": { + "files": 185, + "bytes": 11683004, + "gzip_bytes": 3306531 + }, + "html_linked_code": { + "files": 74, + "bytes": 3293694, + "gzip_bytes": 828863 + }, + "largest_code_assets": [ + { + "path": "assets/index-CVanDihf.css", + "bytes": 974501, + "gzip_bytes": 138204 + }, + { + "path": "assets/bn-DQJxc4qM.js", + "bytes": 598496, + "gzip_bytes": 131054 + }, + { + "path": "assets/th-DHsDkRJN.js", + "bytes": 578831, + "gzip_bytes": 128442 + }, + { + "path": "assets/ru-BiYZQRSY.js", + "bytes": 487932, + "gzip_bytes": 135557 + }, + { + "path": "assets/ar-0QC_HyYN.js", + "bytes": 415266, + "gzip_bytes": 120978 + }, + { + "path": "assets/vendor-charts-_7lweA91.js", + "bytes": 395861, + "gzip_bytes": 102971 + }, + { + "path": "assets/vi-BBWpEAcO.js", + "bytes": 378809, + "gzip_bytes": 114517 + }, + { + "path": "assets/fr-B3wJvclY.js", + "bytes": 354826, + "gzip_bytes": 115299 + }, + { + "path": "assets/ko-cbntekXb.js", + "bytes": 352061, + "gzip_bytes": 113937 + }, + { + "path": "assets/de-CMeQ0Q04.js", + "bytes": 349793, + "gzip_bytes": 115655 + }, + { + "path": "assets/pl-IIic_FFF.js", + "bytes": 340721, + "gzip_bytes": 117822 + }, + { + "path": "assets/es-XJqtqF-U.js", + "bytes": 339857, + "gzip_bytes": 111055 + } + ], + "assets": [ + { + "path": "assets/AgentFleet-B7_nz7or.js", + "bytes": 25349, + "gzip_bytes": 5714 + }, + { + "path": "assets/AppMap-CTfYFAwx.js", + "bytes": 8785, + "gzip_bytes": 3623 + }, + { + "path": "assets/Backups-Oz9kMTOB.js", + "bytes": 51208, + "gzip_bytes": 13490 + }, + { + "path": "assets/BandwidthSparkline-I7vIa8HL.js", + "bytes": 3706, + "gzip_bytes": 1327 + }, + { + "path": "assets/BuildpackPreview-Q2-EkG9o.js", + "bytes": 5774, + "gzip_bytes": 1804 + }, + { + "path": "assets/Coexistence-si7t-Es2.js", + "bytes": 7182, + "gzip_bytes": 2697 + }, + { + "path": "assets/ConfirmDialog-Xo0CScem.js", + "bytes": 5916, + "gzip_bytes": 2192 + }, + { + "path": "assets/CopyButton-COhap6yN.js", + "bytes": 891, + "gzip_bytes": 531 + }, + { + "path": "assets/CronJobs-BtIZjJkN.js", + "bytes": 21282, + "gzip_bytes": 6357 + }, + { + "path": "assets/DangerZone-CeOUNJX1.js", + "bytes": 436, + "gzip_bytes": 261 + }, + { + "path": "assets/Dashboard-C7wEIJzA.js", + "bytes": 92154, + "gzip_bytes": 27286 + }, + { + "path": "assets/DatabaseMigration-IBv_4l6X.js", + "bytes": 11400, + "gzip_bytes": 3158 + }, + { + "path": "assets/Databases-DnIYu_4Y.js", + "bytes": 140972, + "gzip_bytes": 36620 + }, + { + "path": "assets/DeliveryLog-BaOYzcsI.js", + "bytes": 11805, + "gzip_bytes": 3960 + }, + { + "path": "assets/DeployConsole-CTg1peo-.js", + "bytes": 22982, + "gzip_bytes": 7274 + }, + { + "path": "assets/DeploymentTimeline-Cd5SKe1b.js", + "bytes": 20334, + "gzip_bytes": 5105 + }, + { + "path": "assets/Deployments-Cb4SIJ1_.js", + "bytes": 8442, + "gzip_bytes": 3090 + }, + { + "path": "assets/Docker-NZDQdIHg.js", + "bytes": 72809, + "gzip_bytes": 17031 + }, + { + "path": "assets/DocsLink-Bp7QN8rQ.js", + "bytes": 520, + "gzip_bytes": 361 + }, + { + "path": "assets/Documentation-dM5OBmcr.js", + "bytes": 5673, + "gzip_bytes": 1976 + }, + { + "path": "assets/Domains-Bsg8FNM-.js", + "bytes": 38171, + "gzip_bytes": 10964 + }, + { + "path": "assets/Downloads-ezWmWO2h.js", + "bytes": 11505, + "gzip_bytes": 3157 + }, + { + "path": "assets/EmptyState-Y4MOQukK.js", + "bytes": 9160, + "gzip_bytes": 1935 + }, + { + "path": "assets/Errors-DzPb9SiS.js", + "bytes": 10264, + "gzip_bytes": 3192 + }, + { + "path": "assets/FavoriteStar-1bzf-gC-.js", + "bytes": 1037, + "gzip_bytes": 539 + }, + { + "path": "assets/FileManager-cHPvs09J.js", + "bytes": 51779, + "gzip_bytes": 13761 + }, + { + "path": "assets/FleetProxy-h7e8g-IF.js", + "bytes": 7114, + "gzip_bytes": 2499 + }, + { + "path": "assets/FormField-2_Ug2SOb.js", + "bytes": 854, + "gzip_bytes": 413 + }, + { + "path": "assets/GithubAppCallback-b0_L6wmf.js", + "bytes": 2024, + "gzip_bytes": 972 + }, + { + "path": "assets/ImportWizard-Dzlrl0qj.js", + "bytes": 25368, + "gzip_bytes": 6529 + }, + { + "path": "assets/Incidents-BtO5YA7X.js", + "bytes": 12305, + "gzip_bytes": 4092 + }, + { + "path": "assets/InfoList-ChCWQp3d.js", + "bytes": 527, + "gzip_bytes": 310 + }, + { + "path": "assets/Jobs-DbCbs30I.js", + "bytes": 9760, + "gzip_bytes": 3317 + }, + { + "path": "assets/LogContent-C6VMFag4.js", + "bytes": 6859, + "gzip_bytes": 2564 + }, + { + "path": "assets/Login-CShxPoYB.js", + "bytes": 8043, + "gzip_bytes": 2886 + }, + { + "path": "assets/LogsDrawerContext-CV3lsDLs.js", + "bytes": 367, + "gzip_bytes": 245 + }, + { + "path": "assets/Marketplace-BXogLkIf.js", + "bytes": 48828, + "gzip_bytes": 13409 + }, + { + "path": "assets/Modal-BkNur6mo.js", + "bytes": 811, + "gzip_bytes": 435 + }, + { + "path": "assets/MonitorDetail-DhRumRM0.js", + "bytes": 18171, + "gzip_bytes": 5457 + }, + { + "path": "assets/Monitoring-6f1eTDj5.js", + "bytes": 56756, + "gzip_bytes": 14857 + }, + { + "path": "assets/Monitors-Oo9D7iNc.js", + "bytes": 15393, + "gzip_bytes": 4850 + }, + { + "path": "assets/NewService-Do77mcY9.js", + "bytes": 31523, + "gzip_bytes": 8734 + }, + { + "path": "assets/NotFound-Bf7Nc2HG.js", + "bytes": 1454, + "gzip_bytes": 700 + }, + { + "path": "assets/Notifications-BB-uc34Y.js", + "bytes": 6111, + "gzip_bytes": 2027 + }, + { + "path": "assets/OperationsContext-BWUXcieJ.js", + "bytes": 8343, + "gzip_bytes": 3264 + }, + { + "path": "assets/PageLayout-2EmhsU7a.js", + "bytes": 721, + "gzip_bytes": 365 + }, + { + "path": "assets/PageLoader-B1ff2He2.js", + "bytes": 399, + "gzip_bytes": 293 + }, + { + "path": "assets/ProcessTable-C5Xw0IBc.js", + "bytes": 6783, + "gzip_bytes": 2472 + }, + { + "path": "assets/ProjectDetail-DO9cznS3.js", + "bytes": 9680, + "gzip_bytes": 3157 + }, + { + "path": "assets/Projects-BW641fiW.js", + "bytes": 5805, + "gzip_bytes": 2244 + }, + { + "path": "assets/ProviderBrands-n8ow8BwC.js", + "bytes": 4127, + "gzip_bytes": 1933 + }, + { + "path": "assets/PublicStatusPage-B_FGxbpY.js", + "bytes": 5410, + "gzip_bytes": 1634 + }, + { + "path": "assets/QueueDetail-Ds3f4209.js", + "bytes": 11537, + "gzip_bytes": 3580 + }, + { + "path": "assets/QueueOperations-hNYzIn9Y.js", + "bytes": 18011, + "gzip_bytes": 4699 + }, + { + "path": "assets/Recipes-EH0l2sQ3.js", + "bytes": 9972, + "gzip_bytes": 3508 + }, + { + "path": "assets/Register-BONdPwz7.js", + "bytes": 4791, + "gzip_bytes": 1420 + }, + { + "path": "assets/RemoteAccess-DRR_o5VW.js", + "bytes": 13731, + "gzip_bytes": 4086 + }, + { + "path": "assets/RequiresDocker-DUCyiExP.js", + "bytes": 2139, + "gzip_bytes": 923 + }, + { + "path": "assets/ResourceListPage-3tIVlPoI.js", + "bytes": 4915, + "gzip_bytes": 2138 + }, + { + "path": "assets/ResourcePicker-C_YXfpQh.js", + "bytes": 18302, + "gzip_bytes": 6792 + }, + { + "path": "assets/SSLCertificates-qbXCdUWI.js", + "bytes": 17225, + "gzip_bytes": 4988 + }, + { + "path": "assets/SSOCallback-rgvpTdcS.js", + "bytes": 2078, + "gzip_bytes": 967 + }, + { + "path": "assets/SSOProviderIcon-suLUgNY8.js", + "bytes": 1889, + "gzip_bytes": 989 + }, + { + "path": "assets/SchedulePicker-DAtjeFHA.js", + "bytes": 8809, + "gzip_bytes": 2831 + }, + { + "path": "assets/Security-qC_UMwg3.js", + "bytes": 54385, + "gzip_bytes": 13122 + }, + { + "path": "assets/ServerDetail-CDd5Wkip.js", + "bytes": 129188, + "gzip_bytes": 31455 + }, + { + "path": "assets/ServerKitLogo-aeIpNoKQ.js", + "bytes": 1270, + "gzip_bytes": 649 + }, + { + "path": "assets/ServerTemplates-D7bkBRSw.js", + "bytes": 12569, + "gzip_bytes": 3856 + }, + { + "path": "assets/Servers-TCnaphdY.js", + "bytes": 32392, + "gzip_bytes": 9407 + }, + { + "path": "assets/ServiceDetail-Cn35S5nZ.js", + "bytes": 167733, + "gzip_bytes": 40071 + }, + { + "path": "assets/Services-RVzvCk8b.js", + "bytes": 13044, + "gzip_bytes": 4162 + }, + { + "path": "assets/ServicesTab-BJea8xcn.js", + "bytes": 9478, + "gzip_bytes": 3355 + }, + { + "path": "assets/Settings-BA5eqeLx.js", + "bytes": 288851, + "gzip_bytes": 66778 + }, + { + "path": "assets/Setup-PIskKI_6.js", + "bytes": 30350, + "gzip_bytes": 7839 + }, + { + "path": "assets/SharedVariables-S12uNdkC.js", + "bytes": 12971, + "gzip_bytes": 3701 + }, + { + "path": "assets/SourceConnectionCallback-EDi4BaCM.js", + "bytes": 2060, + "gzip_bytes": 1000 + }, + { + "path": "assets/Spinner-CtgnlSA5.js", + "bytes": 501, + "gzip_bytes": 296 + }, + { + "path": "assets/StatusBadge-DRJArhzg.js", + "bytes": 488, + "gzip_bytes": 327 + }, + { + "path": "assets/StyleGuide-CdNNP6XM.js", + "bytes": 76602, + "gzip_bytes": 16097 + }, + { + "path": "assets/TargetPicker-Bld9Lg9q.js", + "bytes": 2156, + "gzip_bytes": 1032 + }, + { + "path": "assets/Telemetry-1jNM23AA.js", + "bytes": 11187, + "gzip_bytes": 3763 + }, + { + "path": "assets/Templates-DVdE6NeT.js", + "bytes": 19594, + "gzip_bytes": 6928 + }, + { + "path": "assets/Terminal-DCB2JSbv.js", + "bytes": 32222, + "gzip_bytes": 9040 + }, + { + "path": "assets/TestSandbox-B-JFVi1o.js", + "bytes": 11876, + "gzip_bytes": 4025 + }, + { + "path": "assets/Trans-DUhiEKWi.js", + "bytes": 10742, + "gzip_bytes": 4523 + }, + { + "path": "assets/Vaults-B6sy9xuk.js", + "bytes": 11796, + "gzip_bytes": 3422 + }, + { + "path": "assets/WorkspaceDetail-Z46ncHsN.js", + "bytes": 28809, + "gzip_bytes": 7316 + }, + { + "path": "assets/Workspaces-DFAW186J.js", + "bytes": 7880, + "gzip_bytes": 2799 + }, + { + "path": "assets/api-CCJRyK9u.js", + "bytes": 150808, + "gzip_bytes": 30239 + }, + { + "path": "assets/ar-0QC_HyYN.js", + "bytes": 415266, + "gzip_bytes": 120978 + }, + { + "path": "assets/badge-DAehhJoZ.js", + "bytes": 302, + "gzip_bytes": 235 + }, + { + "path": "assets/bn-DQJxc4qM.js", + "bytes": 598496, + "gzip_bytes": 131054 + }, + { + "path": "assets/button-DRJXHEms.js", + "bytes": 2804, + "gzip_bytes": 1263 + }, + { + "path": "assets/card-GJcYnddq.js", + "bytes": 1077, + "gzip_bytes": 363 + }, + { + "path": "assets/clipboard-Dn2VjE_I.js", + "bytes": 468, + "gzip_bytes": 297 + }, + { + "path": "assets/de-CMeQ0Q04.js", + "bytes": 349793, + "gzip_bytes": 115655 + }, + { + "path": "assets/deployActivity-Bhx2ttym.js", + "bytes": 1123, + "gzip_bytes": 589 + }, + { + "path": "assets/dialog-ZZdAdpRd.js", + "bytes": 1416, + "gzip_bytes": 563 + }, + { + "path": "assets/dist-4WJ2Dj-L.js", + "bytes": 303, + "gzip_bytes": 216 + }, + { + "path": "assets/dist-BvEkDNZs.js", + "bytes": 2782, + "gzip_bytes": 1233 + }, + { + "path": "assets/dist-CDyCHLYj.js", + "bytes": 3647, + "gzip_bytes": 1623 + }, + { + "path": "assets/docsLinks-xcEiS2b-.js", + "bytes": 361, + "gzip_bytes": 192 + }, + { + "path": "assets/downloadBlob-DzNlCt_E.js", + "bytes": 384, + "gzip_bytes": 276 + }, + { + "path": "assets/dropdown-menu-DR95iY5T.js", + "bytes": 20837, + "gzip_bytes": 6160 + }, + { + "path": "assets/ds-D9j8MO1I.js", + "bytes": 44492, + "gzip_bytes": 14462 + }, + { + "path": "assets/en-Bo0tKDxj.js", + "bytes": 298916, + "gzip_bytes": 100788 + }, + { + "path": "assets/engineHelpers-C4Oij3xe.js", + "bytes": 2719, + "gzip_bytes": 1354 + }, + { + "path": "assets/es-XJqtqF-U.js", + "bytes": 339857, + "gzip_bytes": 111055 + }, + { + "path": "assets/expiry-CenJDggT.js", + "bytes": 566, + "gzip_bytes": 366 + }, + { + "path": "assets/fleetMetrics-DwDMpmOp.js", + "bytes": 226, + "gzip_bytes": 184 + }, + { + "path": "assets/formatBytes-hXxZgKZa.js", + "bytes": 537, + "gzip_bytes": 385 + }, + { + "path": "assets/fr-B3wJvclY.js", + "bytes": 354826, + "gzip_bytes": 115299 + }, + { + "path": "assets/grid-DeT-2Rw7.js", + "bytes": 39447, + "gzip_bytes": 11750 + }, + { + "path": "assets/id-B0jBKWcn.js", + "bytes": 317606, + "gzip_bytes": 106301 + }, + { + "path": "assets/index-CVanDihf.css", + "bytes": 974501, + "gzip_bytes": 138204 + }, + { + "path": "assets/index-EBJ5Gl3z.js", + "bytes": 261895, + "gzip_bytes": 70767 + }, + { + "path": "assets/input-Bv21FA6l.js", + "bytes": 290, + "gzip_bytes": 222 + }, + { + "path": "assets/intl-CYUOvOJ7.js", + "bytes": 3788, + "gzip_bytes": 1391 + }, + { + "path": "assets/it-cbe2smqs.js", + "bytes": 336985, + "gzip_bytes": 111563 + }, + { + "path": "assets/ko-cbntekXb.js", + "bytes": 352061, + "gzip_bytes": 113937 + }, + { + "path": "assets/label-oL-uqm3Y.js", + "bytes": 966, + "gzip_bytes": 583 + }, + { + "path": "assets/monitorShared-DVkEym-a.js", + "bytes": 2004, + "gzip_bytes": 657 + }, + { + "path": "assets/pl-IIic_FFF.js", + "bytes": 340721, + "gzip_bytes": 117822 + }, + { + "path": "assets/popover-grWqLIUn.js", + "bytes": 65548, + "gzip_bytes": 22239 + }, + { + "path": "assets/pt-Cg2aVclu.js", + "bytes": 334621, + "gzip_bytes": 111049 + }, + { + "path": "assets/recents-FDjDwDOB.js", + "bytes": 908, + "gzip_bytes": 462 + }, + { + "path": "assets/redirectAfterLogin-CPIFFUZ-.js", + "bytes": 915, + "gzip_bytes": 524 + }, + { + "path": "assets/reducedMotion-0K54bujL.js", + "bytes": 191, + "gzip_bytes": 146 + }, + { + "path": "assets/rolldown-runtime-QTnfLwEv.js", + "bytes": 694, + "gzip_bytes": 422 + }, + { + "path": "assets/ru-BiYZQRSY.js", + "bytes": 487932, + "gzip_bytes": 135557 + }, + { + "path": "assets/sanitizeSvg-CXkzvfQS.js", + "bytes": 1168, + "gzip_bytes": 656 + }, + { + "path": "assets/sdk-96DUmOoT.css", + "bytes": 20337, + "gzip_bytes": 4151 + }, + { + "path": "assets/sdk-BEMkzTit.js", + "bytes": 158165, + "gzip_bytes": 42507 + }, + { + "path": "assets/select--cDlodis.js", + "bytes": 21203, + "gzip_bytes": 7215 + }, + { + "path": "assets/si-CWfS2ftP.js", + "bytes": 34706, + "gzip_bytes": 14957 + }, + { + "path": "assets/sidebarItems-BQHByGBH.js", + "bytes": 9738, + "gzip_bytes": 2847 + }, + { + "path": "assets/socket-DQzBk5Bx.js", + "bytes": 44588, + "gzip_bytes": 13691 + }, + { + "path": "assets/status-BxnZ9AU0.js", + "bytes": 2499, + "gzip_bytes": 991 + }, + { + "path": "assets/switch-CdYeyLle.js", + "bytes": 2206, + "gzip_bytes": 1135 + }, + { + "path": "assets/tabs-C3UHtSwH.js", + "bytes": 4682, + "gzip_bytes": 2008 + }, + { + "path": "assets/textarea-Dhv2X_qv.js", + "bytes": 285, + "gzip_bytes": 217 + }, + { + "path": "assets/th-DHsDkRJN.js", + "bytes": 578831, + "gzip_bytes": 128442 + }, + { + "path": "assets/time-CUKTAUl6.js", + "bytes": 155, + "gzip_bytes": 127 + }, + { + "path": "assets/tr-Cgpafv9G.js", + "bytes": 339275, + "gzip_bytes": 113917 + }, + { + "path": "assets/useAuth-DvhpMS6C.js", + "bytes": 277, + "gzip_bytes": 220 + }, + { + "path": "assets/useClipboard-DVlBSA7W.js", + "bytes": 579, + "gzip_bytes": 389 + }, + { + "path": "assets/useConfirm-COuqNMAk.js", + "bytes": 986, + "gzip_bytes": 475 + }, + { + "path": "assets/useDeployJobStream-D1vDRLvz.js", + "bytes": 460, + "gzip_bytes": 335 + }, + { + "path": "assets/useDevMode-Dq_QB4CY.js", + "bytes": 29017, + "gzip_bytes": 8669 + }, + { + "path": "assets/useFocusParam-CreB-V8z.js", + "bytes": 486, + "gzip_bytes": 330 + }, + { + "path": "assets/useForm-C2D0nl4x.js", + "bytes": 3562, + "gzip_bytes": 1350 + }, + { + "path": "assets/useLockBodyScroll-gg82pj_N.js", + "bytes": 352, + "gzip_bytes": 247 + }, + { + "path": "assets/useNotifications-Dg5pvKZV.js", + "bytes": 203, + "gzip_bytes": 171 + }, + { + "path": "assets/usePolling-lF1Jduua.js", + "bytes": 1518, + "gzip_bytes": 762 + }, + { + "path": "assets/useResourceTier-UN-1X2SK.js", + "bytes": 292, + "gzip_bytes": 225 + }, + { + "path": "assets/useRunStream-Dh5UfWEc.js", + "bytes": 2817, + "gzip_bytes": 1369 + }, + { + "path": "assets/useServerQuery-CFbs0jQD.js", + "bytes": 3631, + "gzip_bytes": 1533 + }, + { + "path": "assets/useServerStream-CGv3kSRa.js", + "bytes": 569, + "gzip_bytes": 351 + }, + { + "path": "assets/useServerkitAI-CpuE8vn8.js", + "bytes": 279, + "gzip_bytes": 224 + }, + { + "path": "assets/useShortcut-DO6Hv-Zh.js", + "bytes": 487, + "gzip_bytes": 329 + }, + { + "path": "assets/useTabParam-DRBvsDeG.js", + "bytes": 170, + "gzip_bytes": 162 + }, + { + "path": "assets/useTheme-CpQxtsz9.js", + "bytes": 278, + "gzip_bytes": 220 + }, + { + "path": "assets/useToast-CU7WGWSC.js", + "bytes": 278, + "gzip_bytes": 219 + }, + { + "path": "assets/useTopbarActions-ByKBotYf.js", + "bytes": 571, + "gzip_bytes": 372 + }, + { + "path": "assets/useWorkspace-Cj3WMLIv.js", + "bytes": 286, + "gzip_bytes": 224 + }, + { + "path": "assets/utils-Dc1WCZV1.js", + "bytes": 48525, + "gzip_bytes": 15746 + }, + { + "path": "assets/vendor-charts-_7lweA91.js", + "bytes": 395861, + "gzip_bytes": 102971 + }, + { + "path": "assets/vendor-flow-BW6KGJkc.js", + "bytes": 297879, + "gzip_bytes": 93822 + }, + { + "path": "assets/vendor-flow-CHpVij2M.css", + "bytes": 15395, + "gzip_bytes": 2518 + }, + { + "path": "assets/vendor-icons-jUsvj-1n.js", + "bytes": 55257, + "gzip_bytes": 16367 + }, + { + "path": "assets/vendor-react-hujXv21x.js", + "bytes": 197077, + "gzip_bytes": 62099 + }, + { + "path": "assets/vendor-xterm-DK8RooJ5.js", + "bytes": 291643, + "gzip_bytes": 68496 + }, + { + "path": "assets/vendor-xterm-kHJ-D0s7.css", + "bytes": 2853, + "gzip_bytes": 770 + }, + { + "path": "assets/vi-BBWpEAcO.js", + "bytes": 378809, + "gzip_bytes": 114517 + }, + { + "path": "assets/zh-Hans-CHEKgzkQ.js", + "bytes": 292831, + "gzip_bytes": 111140 + }, + { + "path": "assets/zh-Hant-mu8MTZYn.js", + "bytes": 292985, + "gzip_bytes": 112962 + }, + { + "path": "serverkit-vendor/i18next.mjs", + "bytes": 1020, + "gzip_bytes": 469 + }, + { + "path": "serverkit-vendor/react-dom-client.mjs", + "bytes": 507, + "gzip_bytes": 340 + }, + { + "path": "serverkit-vendor/react-dom.mjs", + "bytes": 1050, + "gzip_bytes": 509 + }, + { + "path": "serverkit-vendor/react-i18next.mjs", + "bytes": 1427, + "gzip_bytes": 538 + }, + { + "path": "serverkit-vendor/react-jsx-runtime.mjs", + "bytes": 518, + "gzip_bytes": 340 + }, + { + "path": "serverkit-vendor/react-router-dom.mjs", + "bytes": 7498, + "gzip_bytes": 1593 + }, + { + "path": "serverkit-vendor/react.mjs", + "bytes": 1958, + "gzip_bytes": 702 + }, + { + "path": "serverkit-vendor/serverkit-sdk.mjs", + "bytes": 5217, + "gzip_bytes": 1437 + }, + { + "path": "sw.js", + "bytes": 2064, + "gzip_bytes": 786 + } + ] +} diff --git a/docs/measurements/repository.json b/docs/measurements/repository.json new file mode 100644 index 000000000..52ab20de7 --- /dev/null +++ b/docs/measurements/repository.json @@ -0,0 +1,20 @@ +{ + "schema_version": 1, + "measured_at_utc": "2026-09-05T18:41:48.234988+00:00", + "source_revision": "d67e5d07d0c8027d7611048ac74b371db43cd7d1", + "measured_source_has_uncommitted_changes": false, + "measured_source_sha256": "4e6556b3e881b20b53caf0e0c9d23956da514d95e0e6ec9a1bf43ffe0546a8b8", + "scope": "Git-tracked core API Python declarations and root-level bundled app YAML templates; excludes extensions, dependencies and generated/install copies.", + "python_version": "3.11.7", + "platform": "Windows", + "core_route_declarations": 1212, + "core_blueprint_declarations": 104, + "explicit_method_route_pairs_excluding_head_options": 1216, + "route_declarations_with_dynamic_methods": 0, + "core_api_source_files": 111, + "bundled_app_templates": 118, + "backend_tests_clean_collected": 5089, + "collection_scope": "backend/tests, SERVERKIT_CLEAN_COLLECT=1; collected cases, not passed tests", + "runtime_memory_bytes": null, + "container_image_bytes": null +} diff --git a/docs/reviews/2026-09-05-remediation.md b/docs/reviews/2026-09-05-remediation.md new file mode 100644 index 000000000..60c71554e --- /dev/null +++ b/docs/reviews/2026-09-05-remediation.md @@ -0,0 +1,65 @@ +# ServerKit review remediation — 2026-09-05 + +This records the implementation following [the original review](2026-09-05-serverkit-review.md). The original report describes the pre-fix checkout and remains historical evidence. The changes are committed locally on `dev`; no push or production deployment was performed. + +## Local implementation commits + +| Commit | Scope | +| --- | --- | +| `c8c93a8e` | Authentication, scoped API keys, passkeys and session revocation | +| `785c5780` | Socket subscriptions and authorized event delivery | +| `6bf34903` | AI resource access, redaction and chat limits | +| `c62358de` | Settings tables, access review and browser regressions | +| `da0f1814` | Security CI coverage and narrow Bandit exceptions | + +The review, historical reproduction probes and this handoff are recorded in a separate documentation commit. + +## Implemented + +| Review item | Change | +| --- | --- | +| MFA/login-link bypass | Pending tokens expire after five minutes and fail the common JWT policy. Login-link management requires a completed browser session, and redemption honors the target account's MFA policy. | +| Scoped API-key escalation | Keys cannot mint browser credentials. Restricted keys fail closed when the endpoint lacks an explicit scope declaration, and declared scopes are checked independently of the owner's role. | +| Socket authentication and room isolation | Require a valid access-token session; authorize user, application, server, deployment and run subscriptions. Revalidate sessions and resource access before delivery, including account/session revocation. Preserve authorized terminal and job streams. | +| AI resource authorization | Built-in tools receive the actual caller, reuse application/workspace visibility, return selected metadata, and recheck authority after write confirmation. Host-wide privileged tools require an administrator. | +| Password changes, disablement and logout | Password changes require the current password, or recent authentication for accounts without a local password. Password changes and account disablement invalidate earlier sessions; logout persistently revokes the current browser's access/refresh family. | +| AI protection gaps | Recursively filter structured read and write results, apply deterministic secret filtering, and return a visible error when enabled protections fail. Sanitize model context and restored conversations too. | +| Settings table backgrounds | Users and Invitations wrappers now own an explicit theme surface. Verified with actual components in Chromium in dark and light themes. | +| Access-review UI | Show MFA, passkey enrollment and sign-in provider; add an active-admins-without-TOTP view; use locale-aware dates, shared deletion confirmation, pending-action guards and visible invitation errors. | +| Regression coverage | Add authentication, API-key, socket, AI and passkey boundary tests; add a browser job for Settings surfaces and interaction states. | + +Cross-review also found and repaired passkey compatibility with pinned WebAuthn 2.5.0. Registration and passwordless authentication now require authenticator user verification. Regression tests use real cryptographic signatures with synthetic credentials, including refusal of assertions without user verification. + +## Additional hardening + +- AI chat accepts at most 128 KiB request bodies and 16,000-character message/context limits. The panel permits one active turn per user and eight panel-wide. Streaming queues are bounded and cancellation-aware. These are concurrency and input limits, not aggregate billing quotas. +- Security CI now includes weekly/manual runs, extension sources and requirement files, a production npm audit, full Bandit report artifacts, and a narrow reviewed exception mechanism. Broad Bandit category skips were removed. Two existing FTP compatibility findings remain explicitly documented and tied to their function fingerprint. +- Browser coverage exercises active/disabled/filtered users, loading, empty/error states, table surfaces in both themes, guarded deletion, and invitation revoke failure with pending controls. + +## Upgrade behavior + +- Apply Alembic migration `097_user_auth_version` with the normal application upgrade. It adds the user authentication version and persistent revoked-session records; existing-install, fresh-install and downgrade paths have regression coverage. +- Existing JWTs intentionally become invalid. Users must sign in again after upgrading. +- Restricted API keys now return 403 for endpoints without `@require_scope`. Most previously API-key-capable routes have no declaration, so integrations using restricted keys need a reviewed endpoint scope policy. Do not broaden keys to full access as a substitute for defining that policy. +- Wildcard `*` and legacy empty-scope keys retain their existing full-access meaning, subject to the owner's role. JWT-only endpoints remain JWT-only. +- Passkeys that cannot perform authenticator user verification cannot complete passwordless login under the new policy. A verified passkey remains an independent sign-in method; the Users MFA column and saved view specifically reflect TOTP enrollment. + +## Validation + +- Final combined backend run: **295 passed** across the affected authentication, API-key, passkey, AI, socket, workspace, run, migration and error-shape suites. +- Frontend: 163 Node tests passed; lint and integrity checks passed with zero errors; production build passed. Existing lint and large-chunk warnings remain. +- Browser: Chromium passed the Settings scenarios in both themes. Screenshots are saved to `frontend/test-results/` and uploaded by the new CI job. +- Security scan: full Bandit 1.9.3 report passed the narrowed gate; seven exception-gate tests passed. The production npm lockfile audit reported zero vulnerabilities. The Python advisory audit was not rerun locally. +- Static/backend integration checks: 25 focused migration-inventory, error-shape, crash-reporting and authorization-boundary checks passed without raising ceilings. +- All security tests used local synthetic users, resources and credentials. Real WebAuthn signature verification was exercised; no external AI provider or production service was called. The full repository backend suite was not completed; validation focused on the affected security, workspace, run and migration suites. + +## Remaining work and limits + +- Aggregate per-user/panel AI spend accounting remains separate work. Starting another conversation can still start another per-conversation budget; the new concurrency limits do not cap daily spend. +- CSP still permits inline scripts and eval. Tightening it requires testing the production import map and extension runtime; no CSP enforcement change is included here. +- Extend browser coverage to API-key management and complete authentication navigation. Current browser tests use synthetic responses; backend authentication tests exercise the real Flask and WebAuthn boundaries. +- Plugin-contributed AI tools still need their own resource-authorization policy. The common wrapper checks the live caller and feature permissions; it cannot infer ownership rules for arbitrary plugin data. +- An existing account-deletion issue involving `audit_logs.user_id` under enforced foreign keys was found during cross-review. The new revoked-session records support account deletion, but this unrelated audit-log relationship still needs a focused fix. +- Existing lint warnings and large build chunks remain. There was no production penetration test, provider-billing test, load test or deployment. + +The historical `2026-09-05-security-probes.py` intentionally asserted the old vulnerabilities. Use the new negative regression tests under `backend/tests/` for the fixed behavior. diff --git a/docs/reviews/2026-09-05-security-probes.py b/docs/reviews/2026-09-05-security-probes.py new file mode 100644 index 000000000..ef3571d38 --- /dev/null +++ b/docs/reviews/2026-09-05-security-probes.py @@ -0,0 +1,117 @@ +"""Review evidence: assertions describe observed defects, not desired policy. + +To reproduce against this checkout, copy this file temporarily into backend/tests/ +as test_review_20260905_probe.py and run it with that directory's pytest fixtures. +Remove the temporary copy afterward. These assertions must not become permanent +security tests: remediation tests should instead assert that the attempts fail. +Only synthetic users, database records and socket events are used; no AI provider +or production service is called. +""" +from flask_jwt_extended import create_refresh_token, decode_token +from factories import make_user, headers_for, make_application + + +def test_review_pending_mfa_can_mint_full_login(client, db_session): + user = make_user(db_session, role='admin', password='ReviewPassword123!', totp_enabled=True) + login = client.post('/api/v1/auth/login', json={'email': user.email, 'password': 'ReviewPassword123!'}) + assert login.status_code == 200, login.get_json() + pending = login.get_json()['temp_token'] + assert 'exp' not in decode_token(pending) + headers = {'Authorization': f'Bearer {pending}'} + assert client.get('/api/v1/auth/me', headers=headers).status_code == 403 + minted = client.post('/api/v1/auth/login-links', headers=headers, json={}) + assert minted.status_code == 201, minted.get_json() + redeemed = client.post('/api/v1/auth/login-links/redeem', json={'token': minted.get_json()['token']}) + assert redeemed.status_code == 200, redeemed.get_json() + full = redeemed.get_json()['access_token'] + assert not decode_token(full).get('2fa_pending') + assert client.get('/api/v1/admin/users', headers={'Authorization': f'Bearer {full}'}).status_code == 200 + + +def test_review_read_scoped_key_can_mint_full_login(client, db_session): + from app.services.api_key_service import ApiKeyService + user = make_user(db_session, role='admin') + key, raw = ApiKeyService.create_key(user.id, 'review-read-only', scopes=['apps:read']) + assert not key.has_scope('write') + minted = client.post('/api/v1/auth/login-links', headers={'X-API-Key': raw}, json={}) + assert minted.status_code == 201, minted.get_json() + redeemed = client.post('/api/v1/auth/login-links/redeem', json={'token': minted.get_json()['token']}) + assert redeemed.status_code == 200, redeemed.get_json() + assert 'refresh_token' in redeemed.get_json() + + +def test_review_ai_lists_foreign_application(client, db_session): + from app.services.ai_tool_registry import ToolDescriptor + from app.services.ai_tools_builtin import list_applications + from app.services.ai_service import _make_read_wrapper + viewer = make_user(db_session, role='viewer') + foreign = make_application(db_session, name='private-review-app') + assert client.get(f'/api/v1/apps/{foreign.id}', headers=headers_for(viewer)).status_code == 403 + descriptor = ToolDescriptor(name='list_applications', qualified_name='core__list_applications', + func=list_applications, description='review', parameters={}, rbac_feature='applications') + result = _make_read_wrapper(descriptor, viewer)() + assert foreign.id in [row['id'] for row in result] + + +def test_review_disabled_user_keeps_jwt_access(client, db_session): + user = make_user(db_session) + foreign = make_application(db_session, user_id=user.id) + headers = headers_for(user) + user.is_active = False + db_session.session.commit() + assert client.get('/api/v1/auth/me', headers=headers).status_code == 200 + assert client.get(f'/api/v1/apps/{foreign.id}', headers=headers).status_code == 200 + + +def test_review_password_change_needs_no_old_password_and_keeps_refresh(client, db_session): + user = make_user(db_session, password='OldReviewPassword123!') + headers = headers_for(user) + refresh = create_refresh_token(identity=user.id) + result = client.put('/api/v1/auth/me', headers=headers, json={'password': 'NewReviewPassword456!'}) + assert result.status_code == 200, result.get_json() + assert user.check_password('NewReviewPassword456!') + renewed = client.post('/api/v1/auth/refresh', headers={'Authorization': f'Bearer {refresh}'}) + assert renewed.status_code == 200, renewed.get_json() + + +def test_review_structured_ai_results_skip_redaction(monkeypatch): + from app.services import ai_service + monkeypatch.setattr(ai_service, '_pii_enabled', lambda: True) + class Redactor: + def redact(self, text): + raise AssertionError('Structured output never reaches redactor') + monkeypatch.setattr(ai_service, '_get_pii_redactor', lambda: Redactor()) + payload = {'email': 'review-person@example.test', 'nested': [{'password': 'synthetic-review-secret'}]} + assert ai_service._maybe_redact_result(payload) == payload + + +def test_review_socket_accepts_pending_mfa_and_refresh(app, db_session): + from flask_jwt_extended import create_access_token + from app.sockets import socketio + user = make_user(db_session, role='admin', totp_enabled=True) + pending = create_access_token(identity=user.id, additional_claims={'2fa_pending': True}) + for token in (pending, create_refresh_token(identity=user.id)): + sock = socketio.test_client(app, auth={'token': token}) + try: + assert sock.is_connected() + finally: + if sock.is_connected(): + sock.disconnect() + + +def test_review_viewer_can_join_other_users_socket_room(app, db_session): + from flask_jwt_extended import create_access_token + from app.sockets import socketio + viewer = make_user(db_session, role='viewer') + other = make_user(db_session, role='admin') + sock = socketio.test_client(app, auth={'token': create_access_token(identity=viewer.id)}) + try: + assert sock.is_connected() + sock.get_received() + sock.emit('join_room', {'room': f'user_{other.id}'}) + socketio.emit('review_synthetic_private_event', {'marker': 'private-test-only'}, to=f'user_{other.id}') + events = sock.get_received() + assert any(event['name'] == 'review_synthetic_private_event' for event in events), events + finally: + if sock.is_connected(): + sock.disconnect() diff --git a/docs/reviews/2026-09-05-serverkit-review.md b/docs/reviews/2026-09-05-serverkit-review.md new file mode 100644 index 000000000..e009d822b --- /dev/null +++ b/docs/reviews/2026-09-05-serverkit-review.md @@ -0,0 +1,159 @@ +# ServerKit security and product review — 2026-09-05 + +Reviewed checkout: `6c2220c5`. Scope: authentication and MFA, API-key scopes, Socket.IO, AI tools and redaction, representative authorization tests, subprocess patterns, CI, and Settings table implementation. + +The largest gap is consistent authorization across entry points. REST routes, API keys, sockets, and AI tools do not always enforce the same policy. Several bypasses below were reproduced locally with disposable database records. These are more urgent than adding features or redesigning the interface. + +This was a source review with isolated Flask/Socket.IO tests, not a penetration test of the production deployment. No production accounts, credentials, services, or AI providers were used. Application source was not changed. Local frontend/backend servers were unavailable on the configured ports, so UI findings are based on source and compiled styles rather than a live visual walkthrough. + +## Fix first + +### 1. High: an MFA-pending administrator can obtain a full session without completing MFA + +Evidence: `backend/app/__init__.py:455`, `backend/app/api/auth.py:294`, `backend/app/api/auth.py:325`, `backend/app/api/auth.py:395`. + +The pending-token guard exempts any path containing `/auth/login`. This also exempts `/auth/login-links`. An admin's valid password produces a `2fa_pending` token; that token passes the login-link route's admin check and can mint a link. Redeeming that link returns ordinary access and refresh tokens without a second-factor challenge. + +**Local proof:** real password login for a synthetic MFA-enabled admin; the temporary token got 403 on `/auth/me`, but 201 on login-link creation. Redemption returned 200, and the resulting access token successfully accessed `/api/v1/admin/users`. No TOTP or backup code was supplied. + +There is a related expiry defect: `expires_delta=False` disables expiration. The comment says it uses a short default expiry, but the actual temporary token has no `exp` claim. This was verified against the installed library and the token generated by the login route; the [library documentation](https://flask-jwt-extended.readthedocs.io/en/stable/api.html#flask_jwt_extended.create_access_token) confirms the semantics. + +**Fix:** use exact endpoint allowlists for MFA challenges; require completed authentication for login-link administration; give pending tokens an explicit short lifetime. Review redemption as an authentication method, including the target user's MFA policy. Add an end-to-end regression for this complete chain, not just a role check on link creation. + +**Effort:** small emergency patch plus focused tests; follow with a broader authentication-boundary review. + +### 2. High: an admin-owned read-only API key can become a full browser session + +Evidence: `backend/app/middleware/rbac.py:27`, `backend/app/middleware/api_key_auth.py:8`, `backend/app/api/auth.py:325`, `backend/app/middleware/api_scope_middleware.py:81`. + +`admin_required` accepts API keys, but does not enforce their scopes. Login-link creation has no separate scope gate. An admin-owned key limited to `apps:read` can therefore mint and redeem a login link, yielding access and refresh tokens outside the original scope restriction. The role prerequisite matters: this is escalation of an admin-owned restricted key, not promotion of a viewer-owned key to admin. + +**Local proof:** created a synthetic `apps:read` key, verified it lacked `write`, then obtained 201 from login-link creation and 200 with fresh session credentials from redemption. + +**Fix:** make credential/session minting require an explicit authentication-method decision and appropriate recent authentication. Audit every API-key-capable route for scope enforcement. Add a test matrix using restricted keys against real routes. Preserve deliberately JWT-only routes; do not mechanically convert their decorators to API-key-capable ones. + +**Effort:** small fix to close this chain; medium effort to finish scope coverage across the API. + +### 3. High: Socket.IO accepts the wrong tokens and allows cross-user room subscriptions + +Evidence: `backend/app/sockets.py:162`, `backend/app/sockets.py:181`, `backend/app/sockets.py:372`. + +Two independent problems exist: + +- Connection authentication decodes a JWT and checks that its user is active, but does not reject `2fa_pending` tokens or refresh tokens. Both connected successfully in local Socket.IO tests. +- The generic `join_room` event accepts arbitrary room names, with a special role check only for terminal rooms. A viewer can join `user_`, bypassing the intended per-user notification boundary. + +**Local proof:** a viewer joined a synthetic admin's user room and received a harmless test event sent only to that room. This proves cross-user delivery without reading anybody's real notifications. It does not establish the sensitivity of every production event payload. + +Roles are also cached at connection time. Account and privilege changes need a defined policy for existing connections. + +**Fix:** apply full access-token validation to socket connections; reject incomplete MFA and refresh-token use; replace arbitrary room joins with authorized, resource-specific subscriptions. Recheck ownership and privileges when subscribing, and disconnect or revalidate sessions after revocation. This matches [OWASP's WebSocket guidance](https://cheatsheetseries.owasp.org/cheatsheets/WebSocket_Security_Cheat_Sheet.html) on message authorization and session handling. + +**Effort:** medium, because all consumers of the generic room event need to be inventoried. + +### 4. High in shared panels: the AI can reveal applications hidden by REST authorization + +Evidence: `backend/app/services/ai_tools_builtin.py:56`, `backend/app/services/ai_service.py:379`, `backend/app/services/ai_tool_registry.py:53`, `backend/app/api/apps.py:723`. + +The AI's `list_applications` tool returns `Application.query_active().all()`. Its wrapper checks feature-level read permission, not app ownership, workspace membership, or resource grants. Viewers have application-read permission by default. + +**Local proof:** a viewer received 403 for another user's app through the real REST detail endpoint. Calling the actual AI read wrapper with the same viewer returned that app. This tests the backend tool boundary; no live model was asked to disclose data. + +The returned shape is broader than the tool description's name/status/type/port summary: it includes infrastructure and configuration metadata such as paths, identifiers, repository/build configuration, and private routing fields. The fleet/database listing tools deserve the same policy comparison, although this review's demonstrated cross-owner case is applications. + +**Fix:** pass caller identity into tools and reuse the existing resource visibility services. Return a deliberately selected set of safe fields. Test that REST and AI show the same resources for owner, member, viewer, foreign user, and admin. This is an [object-level authorization problem](https://owasp.org/API-Security/editions/2023/en/0xa1-broken-object-level-authorization/), not something a prompt-injection detector can fix. + +**Effort:** small-to-medium for application listing; medium for consistent tool coverage. + +### 5. High: password changes and account disablement do not consistently invalidate access + +Evidence: `backend/app/api/auth.py:477`, `backend/app/api/auth.py:493`, `backend/app/api/auth.py:523`, `backend/app/api/auth.py:546`, `backend/app/models/user.py:139`, `frontend/src/services/api/auth.js:39`. + +The self-service password endpoint accepts a new password using an existing access token, without the old password or another recent authentication proof. Changing the password does not invalidate an already-issued refresh token. Refresh tokens default to 30 days. Logout only clears browser tokens. + +Account disablement blocks password login and refresh, and role decorators check activity, but JWT-only routes do not uniformly check it. A disabled user's existing access token still read `/auth/me` and an owned application's details in the local tests. Production access tokens normally expire after 15 minutes; that is a residual-access window, not immediate offboarding. + +**Local proof:** changed a synthetic user's password without providing the old one, then successfully refreshed with the token issued before the change. Separately disabled a synthetic account and verified continued reads with its existing access token. + +**Fix:** require recent authentication for credential changes; add a server-side session/version or revocation mechanism checked consistently by REST and sockets. Revoke affected sessions on password reset, account disablement, and explicit logout/revoke actions. Existing JWT-only routes can gain activity/revocation checks without granting API-key access. + +**Effort:** medium; schema changes require a migration. + +### 6. Medium: the AI redaction toggle overstates its coverage + +Evidence: `backend/app/services/ai_service.py:300`, `backend/app/services/ai_service.py:309`, `backend/app/services/ai_service.py:388`, `frontend/src/components/settings/AISettingsTab.jsx:199`. + +The UI promises redaction of messages and tool output. `_maybe_redact_result` returns dictionary/list results untouched, while built-in tools commonly return exactly those types. Write-tool results also return directly. Input redaction returns the original text if the redactor raises, and prompt-injection detection treats detector failure as no detection. + +**Local proof:** with redaction enabled, a nested synthetic dictionary containing an email and password reached the return boundary unchanged; the redactor was never invoked. No external transmission was performed, so this is a demonstrated coverage defect rather than a claim that a real secret was sent. + +**Fix:** recursively scrub structured data, apply deterministic secret filtering before model calls, sanitize both read and write results, and define a visible failure policy when enabled protection cannot run. Treat injection detection as supplemental; authorization and confirmation must remain enforceable without it. + +**Effort:** small-to-medium. + +## Quick UI and product improvements + +### 7. Confirmed CSS defect: Users and Invitations lose their table background + +Evidence: `frontend/src/styles/pages/_settings.scss:164`, `frontend/src/styles/components/_users.scss:75`, `frontend/src/components/settings/UsersTab.jsx:392`, `frontend/src/components/settings/InvitationsTab.jsx:319`. + +Settings normally gives `.sk-dtable-wrap` a background. A more-specific rule removes it under `.users-table-container`, assuming that outer wrapper already supplies a surface. The outer wrapper only has overflow, a border, and a radius. Active rows have no resting background; hover and disabled-row styles add color, explaining why the background can appear to come and go. + +**Fix:** give the outer wrapper the shared surface token, or let the shared table wrapper own the surface and remove the redundant shell. Verify Users and Invitations in light/dark themes with active, disabled, empty, loading, and filtered states. Keep this in SCSS. + +**Effort:** very small implementation; short visual check. + +### 8. Make Users a useful access-review screen + +The API already returns `totp_enabled`, `passkey_enabled`, and `auth_provider`; the Users table does not expose them. An MFA/passkey/auth-method column and an “Admins without MFA” saved view would make security debt visible using data already available. + +Users also hardcodes `en-US` date formatting, renders status values directly in English, keeps page-local delete-confirmation state, and lacks a pending-state guard around delete/enable/disable actions. Reuse the existing locale, confirmation, and async-action primitives. These are targeted improvements rather than reasons to replace the design system. + +**Effort:** small, well suited to one focused Settings polish change after the security fixes. + +### 9. The frontend needs rendered-state coverage, not just more static rules + +The frontend build and all 159 Node tests passed. Lint passed with **0 errors and 923 warnings**, including 96 hook-dependency warnings. The style-ownership checker reports 114 shared definitions across 50 class names, accepted by the current ceiling. + +The existing checks are useful, but none of those successful checks caught the table-surface contradiction. The inspected frontend CI runs lint and pure-logic Node tests; it has no rendered Settings regression job. + +**Fix:** add a small browser suite for critical Settings states and authentication flows. Start with Users/Invitations/API keys, light/dark themes, errors, and loading states. Review hook warnings where they affect data freshness or permissions; do not spend a sprint mechanically fixing all 923 warnings. A warning ceiling can prevent growth while fixing the backlog gradually. + +**Effort:** small-to-medium initial setup, then add coverage when real regressions justify it. + +## Hardening backlog, after the demonstrated bypasses + +- **AI resource limits:** there is a per-conversation cost ceiling and tool-round limit, but the inspected routes do not enforce an aggregate per-user/panel spend quota or an explicit concurrent-stream cap. A new conversation gets a new budget. Add bounded concurrent work, request-size limits appropriate to chat, cancellation-aware queue writes, and aggregate spend accounting. No billing or load stress test was performed. +- **CSP:** production permits both `unsafe-inline` and `unsafe-eval` in `script-src`. Inventory extension/runtime requirements, then move toward hashes/nonces and remove unnecessary eval permission. This weakens defense in depth; it is not evidence of a standalone XSS exploit. +- **Security CI scope:** the inspected security workflow scans backend Python and requirements, has no schedule, and excludes entire Bandit categories (`B602`, `B402`, `B321`, `B202`) from its strict gate. Use narrow documented exceptions and include relevant extension sources and dependency manifests. Add periodic dependency scanning so new advisories are checked even without code changes. This review did not run a fresh dependency-advisory audit and makes no claim that current dependencies are vulnerable or clean. +- **Authorization tests:** the existing mutation sweep explicitly notes that 404s can hide missing authorization. Add real foreign-owned records and valid payloads, plus restricted keys, pending MFA, disabled users, refresh tokens, sockets, and AI tool calls. The missing dimension is cross-entry-point behavior, not simply the number of tests. + +## What is already helping + +The repo has explicit authorization decorators and ownership helpers, static mutation coverage, tests for scoping and secret filtering, brute-force controls, trusted-proxy handling, security headers, encrypted provider-key storage, and a server-side AI write-confirmation mechanism. Shared SCSS primitives and frontend integrity checks also exist. Build on those mechanisms rather than adding another parallel policy system. + +The subprocess spot-check found extensive use of centralized execution helpers; the raw `subprocess.run` census under `backend/app/services` found one call, an explicitly documented operator-authored backup hook. The examined archive restores generally use data filters or custom validation. This is not a claim that every subprocess, path validator, installer, extension, or Linux distribution was audited. + +## Suggested order + +| Order | Work | Why | +| --- | --- | --- | +| 1 | Close MFA/login-link and API-key/session-minting chains | Direct compromise of intended authentication boundaries | +| 2 | Enforce socket token types and room ownership | Confirmed cross-user real-time delivery | +| 3 | Scope AI tools and fix structured redaction | Prevent the assistant from bypassing existing REST visibility | +| 4 | Implement session revocation and recent authentication | Make password changes and offboarding effective | +| 5 | Repair Settings surfaces and expose MFA status | Cheap, visible improvement with an operational security benefit | +| 6 | Add boundary/visual regressions and targeted hardening | Prevent recurrence without another broad rewrite | + +## Validation record + +- Initial targeted backend suite: **31 passed** (mutation authorization/static boundaries, JWT-only ratchet, setup security policy, app read scoping, socket protocol contract). +- Eight temporary isolated review probes: **8 passed**, meaning the probes successfully reproduced the current defects. These are not eight passing security protections. They covered the two login-link chains, AI foreign-app disclosure, disabled-user access, password/refresh behavior, structured redaction, socket token types, and cross-user room delivery. +- Frontend tests: **159 passed**. +- Frontend lint: passed, **0 errors / 923 warnings**; project-specific integrity checks passed. +- Frontend production build: passed; emitted large-chunk warnings. These warnings alone do not establish a runtime performance regression. +- Extended backend validation: **157 passed** in 130.17 seconds (API scopes, AI assistant/attachments, login links, brute force, headers, subprocess ratchet, raw-infrastructure authorization, files RBAC, and trusted client IP). Combined with the initial suite, **188 existing backend tests passed**. + +Reproduction source is preserved alongside this report as `2026-09-05-security-probes.py`, outside the normal test suite. Its module docstring explains how to run it with the existing backend fixtures. After remediation, convert relevant probes into negative security regressions; do not preserve assertions that expect a bypass to succeed. + +The temporary reproduction tests use synthetic values only. Production exposure depends on the deployed version and configuration; this review establishes behavior in the inspected checkout, not whether anyone has exploited it. diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index f3da3d560..ed6a9a4ff 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -3,6 +3,7 @@ import globals from 'globals'; import reactPlugin from 'eslint-plugin-react'; import reactHooks from 'eslint-plugin-react-hooks'; import reactRefresh from 'eslint-plugin-react-refresh'; +import noStaticInlineStyles from './scripts/eslint-rules/no-static-inline-styles.mjs'; export default [ { ignores: ['dist/**', 'node_modules/**'] }, @@ -26,6 +27,7 @@ export default [ react: reactPlugin, 'react-hooks': reactHooks, 'react-refresh': reactRefresh, + serverkit: { rules: { 'no-static-inline-styles': noStaticInlineStyles } }, }, rules: { ...js.configs.recommended.rules, @@ -35,21 +37,18 @@ export default [ 'react-refresh/only-export-components': ['warn', { allowConstantExport: true }], 'react/prop-types': 'off', 'no-unused-vars': ['warn', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }], + 'serverkit/no-static-inline-styles': 'warn', - // Discourage inline styles — prefer SCSS classes and shared components. + // Prefer shared controls; static styles are checked separately above. 'no-restricted-syntax': [ 'warn', - { - selector: 'JSXAttribute[name.name="style"]', - message: 'Inline styles are discouraged. Use SCSS classes or a shared primitive instead.', - }, { selector: 'JSXOpeningElement[name.name="button"]', message: 'Use the shared Button component (or IconButton for icon-only actions).', }, { // Match the legacy card family (.card, .card-header, .card-body, …) as a - // LEADING class token — not unrelated compounds like `settings-card`, + // class token — not unrelated compounds like `settings-card`, // `sk-spec-card`, or `wp-site-card-skeleton`, which the old `\bcard\b` // pattern flagged as false positives. selector: 'JSXOpeningElement[name.name="div"] > JSXAttribute[name.name="className"] > Literal[value=/(^|\\s)card(\\s|$|-)/]', diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 8ff0909e8..44461b325 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -53,6 +53,7 @@ "eslint-plugin-react-refresh": "^0.4.9", "espree": "^11.2.0", "globals": "^15.9.0", + "playwright": "1.61.1", "sass": "^1.86.0", "vite": "^8.1.4" }, @@ -325,29 +326,43 @@ "license": "MIT" }, "node_modules/@humanfs/core": { - "version": "0.19.1", - "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", - "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", "dev": true, "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, "engines": { "node": ">=18.18.0" } }, "node_modules/@humanfs/node": { - "version": "0.16.7", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", - "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@humanfs/core": "^0.19.1", + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", "@humanwhocodes/retry": "^0.4.0" }, "engines": { "node": ">=18.18.0" } }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, "node_modules/@humanwhocodes/module-importer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", @@ -4785,18 +4800,6 @@ "node": ">= 0.4" } }, - "node_modules/jiti": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", - "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "bin": { - "jiti": "lib/jiti-cli.mjs" - } - }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 360c790a4..f44bdb19c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -7,7 +7,14 @@ "dev": "vite", "build": "vite build", "test": "node --test", - "lint": "eslint . && node scripts/check-font-integrity.mjs && node scripts/check-settings-index.mjs && node scripts/check-theme-tokens.mjs && node scripts/check-frontend-boundaries.mjs && node scripts/check-style-ownership.mjs && node scripts/check-status-one-door.mjs && node scripts/check-status-scss.mjs && node scripts/check-i18n-literals.mjs && node scripts/check-intl-door.mjs && node scripts/extract-i18n.mjs --check && node scripts/check-i18n.mjs && node scripts/check-logical-properties.mjs && node scripts/generate-vendor-shims.mjs --check && node ../scripts/check-html-sinks.mjs", + "test:browser": "node scripts/settings-browser.mjs", + "lint": "node scripts/lint.mjs && node scripts/check-font-integrity.mjs && node scripts/check-settings-index.mjs && node scripts/check-theme-tokens.mjs && node scripts/check-frontend-boundaries.mjs && node scripts/check-style-ownership.mjs && node scripts/check-status-one-door.mjs && node scripts/check-status-scss.mjs && node scripts/check-i18n-literals.mjs && node scripts/check-intl-door.mjs && node scripts/extract-i18n.mjs --check && node scripts/check-i18n.mjs && node scripts/check-logical-properties.mjs && node scripts/generate-vendor-shims.mjs --check && node ../scripts/check-html-sinks.mjs", + "lint:baseline": "node scripts/lint.mjs --update", + "test:controls": "node scripts/controls-browser.mjs", + "test:metrics": "node scripts/metrics-browser.mjs", + "measure:build": "node scripts/measure-build.mjs", + "test:hooks": "node scripts/hooks-browser.mjs", + "test:backups": "node scripts/backups-browser.mjs", "lint:settings-index": "node scripts/check-settings-index.mjs", "lint:fonts": "node scripts/check-font-integrity.mjs", "lint:status": "node scripts/check-status-one-door.mjs", @@ -72,6 +79,7 @@ "eslint-plugin-react-refresh": "^0.4.9", "espree": "^11.2.0", "globals": "^15.9.0", + "playwright": "1.61.1", "sass": "^1.86.0", "vite": "^8.1.4" }, diff --git a/frontend/public/serverkit-vendor/serverkit-sdk.mjs b/frontend/public/serverkit-vendor/serverkit-sdk.mjs index 7f2f32fba..32edccdf6 100644 --- a/frontend/public/serverkit-vendor/serverkit-sdk.mjs +++ b/frontend/public/serverkit-vendor/serverkit-sdk.mjs @@ -86,9 +86,9 @@ export const PluginSlot = m.PluginSlot; export const ResourceListPage = m.ResourceListPage; export const useLogsDrawer = m.useLogsDrawer; export const RepoConnectForm = m.RepoConnectForm; +export const RepoProviderStrip = m.RepoProviderStrip; export const GIT_PROVIDERS = m.GIT_PROVIDERS; export const detectProvider = m.detectProvider; -export const RepoProviderStrip = m.RepoProviderStrip; export const ProtectionPanel = m.ProtectionPanel; export const DataTableFooter = m.DataTableFooter; export const ListToolbar = m.ListToolbar; diff --git a/frontend/scripts/STYLE_OWNERSHIP_CEILING b/frontend/scripts/STYLE_OWNERSHIP_CEILING index dee79f109..573541ac9 100644 --- a/frontend/scripts/STYLE_OWNERSHIP_CEILING +++ b/frontend/scripts/STYLE_OWNERSHIP_CEILING @@ -1 +1 @@ -114 +0 diff --git a/frontend/scripts/backups-browser.mjs b/frontend/scripts/backups-browser.mjs new file mode 100644 index 000000000..3ca347da5 --- /dev/null +++ b/frontend/scripts/backups-browser.mjs @@ -0,0 +1,108 @@ +import assert from 'node:assert/strict'; +import { existsSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createServer } from 'vite'; +import react from '@vitejs/plugin-react'; +import { chromium } from 'playwright'; + +// Real query/mutation/form hooks and the production schedule modal. Only HTTP, +// current workspace, toast output and translation are controlled boundaries. +const root = fileURLToPath(new URL('../', import.meta.url)); +const fixture = ` +import React,{useState} from 'react'; +import {createRoot} from 'react-dom/client'; +import {WorkspaceTestContext} from '/src/contexts/useWorkspace.js'; +import {useBackupSchedules} from '/src/hooks/useBackupSchedules.js'; +import AddScheduleModal from '/src/components/backups/AddScheduleModal.jsx'; +window.calls=[];window.messages=[];window.workspace='one'; +window.toast={error:value=>window.messages.push(['error',value]),success:value=>window.messages.push(['success',value])}; +window.rows={one:[{id:1,name:'Daily',enabled:true,next_run_at:'2026-09-06T02:00:00-04:00',timezone:'America/New_York'}],two:[{id:2,name:'Other workspace',enabled:true}]}; +window.testApi={ + getBackupSchedules:()=>{window.calls.push(['get',window.workspace]);const result={schedules:window.rows[window.workspace].map(row=>({...row})),timezone:'America/New_York'};return window.holdRead?new Promise(resolve=>window.finishRead=()=>resolve(result)):Promise.resolve(result)}, + addBackupSchedule:(...args)=>{window.calls.push(['create',...args]);return new Promise((resolve,reject)=>{window.finishCreate=()=>{window.rows.one.push({id:3,name:args[0],enabled:true});resolve({id:3})};window.failCreate=()=>reject(Object.assign(new Error('Please fix the field'),{fieldErrors:{name:'Already exists'}}))})}, + updateBackupSchedule:(id,body)=>{window.calls.push(['toggle',id,body]);window.rows[window.workspace]=window.rows[window.workspace].map(row=>row.id===id?{...row,...body}:row);return Promise.resolve({})}, + removeBackupSchedule:id=>{window.calls.push(['remove',id]);window.rows[window.workspace]=window.rows[window.workspace].filter(row=>row.id!==id);return Promise.resolve({})} +}; +function Screen(){const store=useBackupSchedules();const [open,setOpen]=useState(true);return <> + {JSON.stringify(store.schedules)} + + + + setOpen(false)} onCreate={store.create} onCreated={()=>setOpen(false)} remoteEnabled timezone={store.timezone}/> + } +function App(){const [workspace,setWorkspace]=useState('one');window.changeWorkspace=()=>{window.workspace='two';setWorkspace('two')};return } +createRoot(document.getElementById('root')).render(); +`; +const server = await createServer({ + root, configFile: false, logLevel: 'error', resolve: { alias: { '@': path.join(root, 'src') } }, + cacheDir: path.join(root, 'node_modules/.vite-backups-regression'), + optimizeDeps: { entries: [] }, server: { host: '127.0.0.1', port: 0 }, + plugins: [{ + name: 'backup-regression-fixtures', enforce: 'pre', + resolveId(id) { + if (id === 'react-i18next') return '\0test-i18n'; + if (id.endsWith('/backups-fixture.jsx')) return path.join(root, 'backups-fixture.jsx'); + }, + load(id) { + const normalized = id.replaceAll('\\', '/'); + if (id === '\0test-i18n') return `const t=(key,fallback)=>typeof fallback==='string'?fallback:key;export const useTranslation=()=>({t});`; + if (normalized.endsWith('/src/contexts/useWorkspace.js')) return `import {createContext,useContext} from 'react';export const WorkspaceTestContext=createContext({});export const useWorkspace=()=>useContext(WorkspaceTestContext);`; + if (normalized.endsWith('/src/contexts/useToast.js')) return `export const useToast=()=>window.toast;`; + if (normalized.endsWith('/src/services/api.js') || normalized.endsWith('/src/services/api/index.js')) return `export default new Proxy({}, {get:(_,key)=>(...args)=>window.testApi[key](...args)});`; + if (normalized.endsWith('/backups-fixture.jsx')) return fixture; + }, + configureServer(vite) { + vite.middlewares.use('/backups-check', async (_req, res) => { + res.setHeader('Content-Type', 'text/html'); + res.end(await vite.transformIndexHtml('/backups-check', '
')); + }); + }, + }, react()], +}); +const executablePath = [process.env.CHROME_PATH, chromium.executablePath(), 'C:/Program Files/Google/Chrome/Application/chrome.exe', 'C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe'].find(candidate => candidate && existsSync(candidate)); +let browser; +try { + await server.listen(); + browser = await chromium.launch({ ...(executablePath ? { executablePath } : {}), headless: true }); + const page = await browser.newPage(); + const errors = []; + page.on('pageerror', error => errors.push(error.message)); + await page.goto(`http://127.0.0.1:${server.httpServer.address().port}/backups-check`); + await page.getByLabel('Schedule Name', { exact: false }).fill('Daily 2'); + await page.getByLabel('Application Name', { exact: false }).fill('my-app'); + assert.equal(await page.locator('.form-field__hint').textContent(), 'America/New_York'); + await page.evaluate(() => { + const form = document.querySelector('[data-walkthrough="backup-schedule-form"]'); + form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); + form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); + }); + await page.waitForFunction(() => window.calls.filter(([type]) => type === 'create').length === 1); + assert.equal(await page.locator('[data-walkthrough="backup-schedule-submit"]').isDisabled(), true); + await page.evaluate(() => window.failCreate()); + await page.getByText('Already exists', { exact: true }).waitFor(); + assert.equal(await page.getByLabel('Schedule Name', { exact: false }).inputValue(), 'Daily 2'); + await page.getByLabel('Schedule Name', { exact: false }).fill('Unique schedule'); + await page.locator('[data-walkthrough="backup-schedule-submit"]').click(); + await page.waitForFunction(() => window.calls.filter(([type]) => type === 'create').length === 2); + await page.evaluate(() => window.finishCreate()); + await page.locator('[data-walkthrough="backup-schedule-form"]').waitFor({ state: 'hidden' }); + await page.waitForFunction(() => document.querySelector('#rows').textContent.includes('Unique schedule')); + console.log('PASS duplicate-submit guard, inline server errors, preserved input, retry and creation cache invalidation'); + + await page.locator('#toggle').click(); + await page.waitForFunction(() => JSON.parse(document.querySelector('#rows').textContent)[0].enabled === false); + await page.locator('#remove').click(); + await page.waitForFunction(() => JSON.parse(document.querySelector('#rows').textContent).length === 1); + assert.equal(await page.evaluate(() => window.messages.filter(([type]) => type === 'success').length), 2); + await page.evaluate(() => { window.holdRead = true; window.changeWorkspace(); }); + await page.waitForFunction(() => Boolean(window.finishRead)); + assert.deepEqual(JSON.parse(await page.locator('#rows').textContent()), []); + await page.evaluate(() => window.finishRead()); + await page.waitForFunction(() => document.querySelector('#rows').textContent.includes('Other workspace')); + assert.deepEqual(errors, []); + console.log('PASS toggle/delete query invalidation and workspace isolation while a new workspace request is pending'); +} finally { + await browser?.close(); + await server.close(); +} diff --git a/frontend/scripts/check-frontend-boundaries.mjs b/frontend/scripts/check-frontend-boundaries.mjs index f81ed32bc..882f33749 100644 --- a/frontend/scripts/check-frontend-boundaries.mjs +++ b/frontend/scripts/check-frontend-boundaries.mjs @@ -92,8 +92,7 @@ const LEGACY_POLLERS = new Map(Object.entries({ 'components/dashboard/widgets/renderers.jsx': 1, 'components/deploy-console/SuccessBanner.jsx': 1, 'components/server/OnboardingWizard.jsx': 1, - 'hooks/useMetrics.js': 1, - 'pages/Dashboard.jsx': 2, + 'pages/Dashboard.jsx': 1, 'pages/DeployConsole.jsx': 1, 'pages/Monitors.jsx': 1, 'plugins/serverkit-gui/components/ServerGui.jsx': 1, @@ -222,25 +221,25 @@ for (const file of LEGACY_POLLERS.keys()) { const ADOPTION_CEILINGS = [ { name: 'raw api.* calls in pages/ (E1: useServerQuery/useServerMutation)', - ceiling: 405, + ceiling: 398, include: (file) => file.startsWith('pages/'), pattern: /\bapi\s*\.\s*\w+\s*\(/g, }, { name: 'per-page toast.error extractions in pages/ (E1: query-layer error presentation)', - ceiling: 218, + ceiling: 206, include: (file) => file.startsWith('pages/'), pattern: /toast\s*\.\s*error\s*\(/g, }, { name: 'hand-rolled form-group blocks (F2: FormField/useForm)', - ceiling: 343, + ceiling: 321, include: (file) => file.startsWith('pages/') || file.startsWith('components/'), pattern: /form-group/g, }, { name: 'unencoded ?k=${v} query interpolations in services/api (C4: buildQuery/encoding template)', - ceiling: 97, + ceiling: 0, include: (file) => file.startsWith('services/api/'), pattern: /[?&][A-Za-z_]+=\$\{(?!encodeURIComponent)/g, }, diff --git a/frontend/scripts/check-status-one-door.mjs b/frontend/scripts/check-status-one-door.mjs index e01a746ed..e38cc983c 100644 --- a/frontend/scripts/check-status-one-door.mjs +++ b/frontend/scripts/check-status-one-door.mjs @@ -20,7 +20,6 @@ const ALLOWED = new Set([ 'components/backups/BackupCalendar.jsx', // STATUS_RANK: severity ordering, not colors 'components/databases/SourceTree.jsx', // STATUS_LABEL: labels only 'pages/DeployConsole.jsx', // STATUS_META: labels+icons+css classes (D4 territory) - 'components/dashboard/widgets/renderers.jsx', // raw CSS-var renderer (D4 territory) 'components/NotificationBell.jsx', // SEVERITY_DOT hex dots (D4 territory) 'pages/Notifications.jsx', // SEVERITY_DOT hex dots (D4 territory) 'components/LinkedAppsSection.jsx', // css-class map (D4 territory) diff --git a/frontend/scripts/check-style-cascade.mjs b/frontend/scripts/check-style-cascade.mjs new file mode 100644 index 000000000..a0d5b1e60 --- /dev/null +++ b/frontend/scripts/check-style-cascade.mjs @@ -0,0 +1,211 @@ +// Before a stylesheet ownership change: +// node scripts/check-style-cascade.mjs --capture /tmp/serverkit-before.css +// After the change: +// node scripts/check-style-cascade.mjs --baseline /tmp/serverkit-before.css +// +// Unlike the ownership census, this checks rendered cascade behavior. Fixtures +// cover the legacy shared classes, their descendant/state selectors, and actual +// static JSX class combinations. It is a supplement to real-page visual review, +// not a replacement for it. Requires Playwright's installed Chromium browser. +import { readFileSync, readdirSync, writeFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { compile } from 'sass'; +import postcss from 'postcss'; +import { chromium } from 'playwright'; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const args = process.argv.slice(2); +const option = (name) => args.includes(name) ? args[args.indexOf(name) + 1] : undefined; +const capturePath = option('--capture'); +const baselinePath = option('--baseline'); +if (!capturePath && !baselinePath) { + throw new Error('Provide --capture before editing, or --baseline afterward.'); +} +const currentCSS = compile(resolve(root, 'src/styles/main.scss'), { logger: { warn() {} } }).css; +if (capturePath) { + writeFileSync(resolve(capturePath), currentCSS); + console.log(`Captured CSS: ${resolve(capturePath)}`); + process.exit(0); +} +const baselineCSS = readFileSync(resolve(baselinePath), 'utf8'); +const classNames = [ + 'text-secondary', 'text-tertiary', 'font-medium', 'font-semibold', 'font-bold', + 'mono', 'truncate', 'top-bar', 'loading', 'loading-state', 'overview-grid', + 'btn-ghost', 'btn-link', 'btn-icon', 'empty-state', 'error-banner', 'modal-lg', + 'form-row', 'status-badge', 'status-dot', 'badge', 'badge-warning', 'tab-btn', + 'info-list', 'info-item', 'info-label', 'info-value', 'env-list', 'env-item', + 'services-grid', 'logs-viewer', 'wp-list', 'legend-item', 'loading-sm', + 'deploy-tab', 'btn-xs', 'checkbox-label', 'settings-nav-spacer', + 'permission-checkbox', 'sk-modal', 'sk-kpiband-wrap', 'conn-status', + 'spinner-inline', 'data-table', 'monitor-detail', 'settings-form', + 'events-tab', 'settings-tab', 'metrics-tab', 'spin', +]; + +function splitSelectors(selector) { + let depth = 0; + let start = 0; + const parts = []; + for (let i = 0; i < selector.length; i += 1) { + if ('(['.includes(selector[i])) depth += 1; + if (')]'.includes(selector[i])) depth -= 1; + if (!depth && selector[i] === ',') { + parts.push(selector.slice(start, i).trim()); + start = i + 1; + } + } + parts.push(selector.slice(start).trim()); + return parts.filter(Boolean); +} + +const selectors = new Set(); +for (const css of [baselineCSS, currentCSS]) { + postcss.parse(css).walkRules((rule) => { + for (const selector of splitSelectors(rule.selector)) { + if (classNames.some((name) => selector.includes(`.${name}`))) selectors.add(selector); + } + }); +} +function walk(directory) { + return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const path = resolve(directory, entry.name); + return entry.isDirectory() ? walk(path) : [path]; + }); +} +for (const path of walk(resolve(root, 'src')).filter((file) => /\.[jt]sx?$/.test(file))) { + for (const match of readFileSync(path, 'utf8').matchAll(/className\s*=\s*["']([^"']+)["']/g)) { + if (match[1].split(/\s+/).some((name) => classNames.includes(name))) { + selectors.add(`.${match[1].trim().replace(/\s+/g, '.')}`); + } + } +} +for (const size of ['', 'btn-sm', 'btn-lg', 'btn-xs']) { + for (const variant of ['', 'btn-primary', 'btn-secondary', 'btn-ghost', 'btn-danger', 'btn-link']) { + for (const state of ['', ':hover', ':disabled', ':hover:disabled']) { + for (const icon of ['', 'btn-icon']) { + selectors.add(['button.btn', icon, size, variant].filter(Boolean).join('.') + state); + } + } + } +} + +// Materialize interaction selectors as classes so every state can be checked +// at once, independently of the mouse position or focus of adjacent fixtures. +function simulatedStates(css) { + return css.replaceAll(':hover', '.test-hover') + .replaceAll(':focus-visible', '.test-focus-visible') + .replaceAll(':focus-within', '.test-focus-within') + .replace(/:focus(?![-\w])/g, '.test-focus'); +} + +const browser = await chromium.launch({ headless: true }); +try { + const page = await browser.newPage(); + await page.setContent(`
`); + await page.evaluate((selectors) => { + function parts(selector) { + let depth = 0; + let start = 0; + const result = []; + for (let i = 0; i < selector.length; i += 1) { + if ('(['.includes(selector[i])) depth += 1; + if (')]'.includes(selector[i])) depth -= 1; + if (!depth && /[ >+~]/.test(selector[i])) { + if (selector.slice(start, i).trim()) result.push(selector.slice(start, i).trim()); + start = i + 1; + } + } + if (selector.slice(start).trim()) result.push(selector.slice(start).trim()); + return result; + } + function make(token) { + token = token.replace(/:not\([^)]*\)/g, '') + .replace(/:(is|where)\(([^)]*)\)/g, (_, name, value) => value.split(',')[0]) + .replaceAll(':hover', '.test-hover') + .replaceAll(':focus-visible', '.test-focus-visible') + .replaceAll(':focus-within', '.test-focus-within') + .replace(/:focus(?![-\w])/g, '.test-focus'); + const has = token.match(/:has\(([^)]*)\)/); + token = token.replace(/:has\([^)]*\)/g, ''); + const element = document.createElement(token.match(/^[A-Za-z][\w-]*/)?.[0] || 'div'); + for (const match of token.matchAll(/\.([\w-]+)/g)) element.classList.add(match[1]); + for (const match of token.matchAll(/\[([\w-]+)(?:[~|^$*]?=['"]?([^'"\]]+)['"]?)?\]/g)) { + element.setAttribute(match[1], match[2] ?? ''); + } + if (token.includes(':disabled')) element.setAttribute('disabled', ''); + if (token.includes(':checked')) element.checked = true; + if (has) { + let parent = element; + for (const part of parts(has[1])) { + const child = make(part); + parent.append(child); + parent = child; + } + } + return element; + } + for (const selector of selectors) { + const wrapper = document.createElement('section'); + wrapper.className = 'fixture-case'; + wrapper.dataset.selector = selector; + let parent = wrapper; + for (const part of parts(selector)) { + const element = make(part); + parent.append(element); + parent = element; + } + if (!parent.children.length && !['INPUT', 'IMG', 'BR', 'HR', 'SVG'].includes(parent.tagName)) { + parent.textContent = 'ServerKit sample'; + } + for (const tag of ['span', 'svg', 'h3', 'p', 'input']) { + const child = document.createElement(tag); + if (tag === 'input') child.type = 'checkbox'; + else child.textContent = tag === 'svg' ? '' : 'Example'; + parent.append(child); + } + document.querySelector('main').append(wrapper); + } + }, [...selectors]); + + const differences = []; + for (const width of [390, 640, 768, 1024, 1440]) { + await page.setViewportSize({ width, height: 900 }); + for (const theme of ['dark', 'light']) { + await page.evaluate((theme) => document.documentElement.setAttribute('data-theme', theme), theme); + await page.evaluate((css) => { document.querySelector('#target').textContent = css; }, simulatedStates(baselineCSS)); + // Keep snapshots inside Chromium: serializing every CSS property + // across the automation boundary is much slower than comparing here. + await page.evaluate(() => { + window.fixtureNodes = [...document.querySelectorAll('.fixture-case *')]; + window.styleBaseline = window.fixtureNodes.map((element) => { + const style = getComputedStyle(element); + return Object.fromEntries([...style].filter((property) => !property.startsWith('--')).map((property) => [property, style.getPropertyValue(property)])); + }); + }); + await page.evaluate((css) => { document.querySelector('#target').textContent = css; }, simulatedStates(currentCSS)); + const changes = await page.evaluate(() => window.fixtureNodes.flatMap((element, index) => { + const style = getComputedStyle(element); + const before = window.styleBaseline[index]; + const changed = Object.fromEntries(Object.entries(before).flatMap(([property, value]) => { + const after = style.getPropertyValue(property); + return value !== after ? [[property, [value, after]]] : []; + })); + return Object.keys(changed).length ? [{ selector: element.closest('.fixture-case').dataset.selector, element: element.tagName + '.' + element.className, changed }] : []; + })); + differences.push(...changes.map((change) => ({ width, theme, ...change }))); + console.log(`${width}px ${theme}: ${changes.length} differing elements`); + } + } + if (differences.length) { + const output = option('--output'); + if (output) writeFileSync(resolve(output), JSON.stringify(differences, null, 2)); + console.error(JSON.stringify(differences.slice(0, 10), null, 2)); + throw new Error(`${differences.length} rendered differences across ${selectors.size} fixtures.`); + } + console.log(`Style cascade preserved across ${selectors.size} fixtures, five widths, and two themes.`); +} finally { + await browser.close(); +} diff --git a/frontend/scripts/check-style-ownership.mjs b/frontend/scripts/check-style-ownership.mjs index 9d215fa5d..28194b18c 100644 --- a/frontend/scripts/check-style-ownership.mjs +++ b/frontend/scripts/check-style-ownership.mjs @@ -6,19 +6,12 @@ // the rendered element is a composite nobody wrote and editing any one file // changes only the properties that file happens to win. // -// `.empty-state` is the worked example. Three partials define it — -// components/_empty-state.scss (flex column, min-height 200px), -// components/_cards.scss (card background, solid border, radius) and -// components/_users.scss (padding, colour) — and the compiled stylesheet -// carries five competing top-level rules for it. What actually renders takes -// its background and dashed border from one, its padding and display from -// another, and its colour from a third. -// -// This is deliberately a ratchet and not a migration. Merging these correctly -// means reproducing the computed result of the cascade, and there is no -// byte-identical-output proof available the way there was for @keyframes — so -// it needs eyes on the affected pages (plan invariant 9: no repo-wide -// mechanical rewrite without behavioural tests). The count may only go down. +// The September 2026 cleanup consolidated 114 competing definitions across +// 50 class names, including `.empty-state`, and reduced the ceiling to zero. +// Browser fixture comparisons checked the effective cascade across themes, +// viewport widths, and interaction states (see check-style-cascade.mjs). +// Keep that ownership intact: shared classes belong in their shared partial; +// page-specific variants should be scoped to the page. // // Usage (from frontend/): // node scripts/check-style-ownership.mjs # check against the ceiling diff --git a/frontend/scripts/controls-browser.mjs b/frontend/scripts/controls-browser.mjs new file mode 100644 index 000000000..7430eb6bd --- /dev/null +++ b/frontend/scripts/controls-browser.mjs @@ -0,0 +1,51 @@ +// Verify that shared-control adoption preserves custom SCSS and native form, +// disabled, keyboard, ref and asChild behavior. No application backend required. +import assert from 'node:assert/strict'; +import { createServer } from 'vite'; +import { chromium } from 'playwright'; + +const server = await createServer({ server: { host: '127.0.0.1', port: 0, strictPort: false, open: false } }); +let browser; +try { + await server.listen(); + browser = await chromium.launch({ headless: true }); + const page = await browser.newPage(); + const errors = []; + page.on('pageerror', (error) => errors.push(error.message)); + await page.goto(`${server.resolvedUrls.local[0]}tests/browser/controls.html`); + await page.locator('#shared-submit').waitFor(); + for (const theme of ['dark', 'light']) { + await page.evaluate((value) => { document.documentElement.dataset.theme = value; }, theme); + for (const suffix of ['submit', 'custom', 'disabled', 'card', 'header', 'content']) { + const pair = await page.evaluate((name) => { + const props = ['display', 'height', 'minWidth', 'padding', 'margin', 'border', 'borderRadius', + 'backgroundColor', 'backgroundImage', 'color', 'fontFamily', 'fontSize', 'fontWeight', + 'opacity', 'cursor', 'pointerEvents', 'boxShadow', 'gap']; + return ['native', 'shared'].map((prefix) => { + const element = document.getElementById(`${prefix}-${name}`); + const css = getComputedStyle(element); + return { tag: element.tagName, classes: [...new Set(element.classList)].sort(), + css: Object.fromEntries(props.map((property) => [property, css[property]])) }; + }); + }, suffix); + assert.deepEqual(pair[1], pair[0], `${theme}: ${suffix} changed its DOM/CSS contract`); + } + } + assert.equal(await page.evaluate(() => window.controlRef === document.getElementById('shared-custom')), true); + await page.locator('#shared-custom').click(); + assert.deepEqual(await page.evaluate(() => window.controlEvents), { submits: 0, clicks: 1 }); + await page.locator('#shared-custom').press('Enter'); + assert.deepEqual(await page.evaluate(() => window.controlEvents), { submits: 0, clicks: 2 }); + await page.locator('#shared-submit').click(); + await page.locator('#native-submit').click(); + assert.equal(await page.evaluate(() => window.controlEvents.submits), 2, 'implicit submit behavior changed'); + await page.locator('#shared-disabled').evaluate((node) => node.click()); + assert.equal(await page.evaluate(() => window.controlEvents.clicks), 2, 'disabled button fired'); + assert.equal(await page.locator('#shared-link').evaluate((node) => node.tagName), 'A'); + assert.equal(await page.locator('#shared-link').getAttribute('href'), '#destination'); + assert.deepEqual(errors, []); + console.log('Controls: dark/light CSS equivalence, refs, form submission, keyboard, disabled and asChild contracts passed.'); +} finally { + await browser?.close(); + await server.close(); +} diff --git a/frontend/scripts/eslint-rules/no-static-inline-styles.mjs b/frontend/scripts/eslint-rules/no-static-inline-styles.mjs new file mode 100644 index 000000000..0ec588423 --- /dev/null +++ b/frontend/scripts/eslint-rules/no-static-inline-styles.mjs @@ -0,0 +1,47 @@ +// Dynamic geometry and runtime colors belong in React; fixed presentation +// belongs in SCSS. Report once per style prop, including conditional objects. +const isStaticValue = (node) => { + if (!node) return false; + if (node.type === 'Literal') return true; + if (node.type === 'TemplateLiteral') return node.expressions.every(isStaticValue); + if (node.type === 'UnaryExpression') return isStaticValue(node.argument); + if (node.type === 'ConditionalExpression') { + return isStaticValue(node.consequent) && isStaticValue(node.alternate); + } + return false; +}; + +function staticProperties(node, names = new Set()) { + if (!node) return names; + if (node.type === 'ObjectExpression') { + for (const property of node.properties) { + if (property.type === 'Property' && isStaticValue(property.value)) { + names.add(property.key.name || String(property.key.value)); + } + } + } else if (node.type === 'ConditionalExpression') { + staticProperties(node.consequent, names); + staticProperties(node.alternate, names); + } else if (node.type === 'LogicalExpression') { + staticProperties(node.left, names); + staticProperties(node.right, names); + } + return names; +} + +export default { + meta: { + type: 'suggestion', + schema: [], + messages: { static: 'Move fixed inline presentation ({{properties}}) to SCSS; keep only computed values in style.' }, + }, + create(context) { + return { + 'JSXAttribute[name.name="style"]'(node) { + const names = staticProperties(node.value?.expression); + if (node.value?.type === 'Literal') names.add('style'); + if (names.size) context.report({ node, messageId: 'static', data: { properties: [...names].join(', ') } }); + }, + }; + }, +}; diff --git a/frontend/scripts/eslint-rules/no-static-inline-styles.test.mjs b/frontend/scripts/eslint-rules/no-static-inline-styles.test.mjs new file mode 100644 index 000000000..1d6ed4612 --- /dev/null +++ b/frontend/scripts/eslint-rules/no-static-inline-styles.test.mjs @@ -0,0 +1,22 @@ +import { RuleTester } from 'eslint'; +import rule from './no-static-inline-styles.mjs'; + +const tester = new RuleTester({ languageOptions: { parserOptions: { ecmaFeatures: { jsx: true } } } }); +tester.run('no-static-inline-styles', rule, { + valid: [ + 'const view =
;', + 'const view =
;', + 'const view =
;', + 'const view =
;', + 'const view =
;', + 'const view =
;', + ], + invalid: [ + { code: 'const view =
;', errors: [{ messageId: 'static' }] }, + { code: 'const view =
;', errors: [{ messageId: 'static' }] }, + { code: 'const view =
;', errors: [{ messageId: 'static' }] }, + { code: 'const view =
;', errors: [{ messageId: 'static' }] }, + { code: 'const view =
;', errors: [{ messageId: 'static' }] }, + { code: 'const view =
;', errors: [{ messageId: 'static' }] }, + ], +}); diff --git a/frontend/scripts/hooks-browser.mjs b/frontend/scripts/hooks-browser.mjs new file mode 100644 index 000000000..d3341ac63 --- /dev/null +++ b/frontend/scripts/hooks-browser.mjs @@ -0,0 +1,108 @@ +import assert from 'node:assert/strict'; +import { existsSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createServer } from 'vite'; +import react from '@vitejs/plugin-react'; +import { chromium } from 'playwright'; + +// Exercise the real login/SSO pages and layout hooks under React StrictMode. +// Only authentication, translation, and HTTP boundaries are replaced by +// controlled fixtures; no backend server or account credentials are needed. +const root = fileURLToPath(new URL('../', import.meta.url)); +const fixture = ` +import React, {useState, useRef, useEffect} from 'react'; +import {createRoot} from 'react-dom/client'; +import {MemoryRouter, Routes, Route} from 'react-router-dom'; +import Login from '/src/pages/Login.jsx'; +import SSOCallback from '/src/pages/SSOCallback.jsx'; +import {useOverflowItems} from '/src/hooks/useOverflowItems.js'; +import useFocusTrap from '/src/hooks/ai/useFocusTrap.js'; +window.calls=[]; window.users=[]; window.reads=0; +window.onUser=user=>window.users.push(user); +window.testApi={ + getDemoInfo: async()=>({enabled:false}), setTokens:()=>{}, + verify2FA:(token,code)=>{window.calls.push({type:'verify',token,code});return new Promise((resolve,reject)=>{window.finish=resolve;window.fail=reject})}, + completeSSOAuth:(provider,code,state)=>{window.calls.push({type:'sso',provider,code,state});return new Promise(resolve=>{window.finish=resolve})} +}; +const mode=new URL(location.href).searchParams.get('mode'); +function Overflow(){ + const [count,setCount]=useState(4);const [extra,setExtra]=useState(0); + const {containerRef,itemRefs,hiddenIndices}=useOverflowItems({count,gap:0,moreWidth:20,deps:[extra]}); + window.changeExtra=()=>setExtra(n=>n+1);window.changeCount=setCount; + return <>
{Array.from({length:count},(_,i)=>
{itemRefs.current[i]=el;if(el)Object.defineProperty(el,'offsetWidth',{configurable:true,get(){window.reads++;return 80}})}} style={{width:80,flexShrink:0}}>Item
)}
{JSON.stringify(hiddenIndices)} +} +function Focus(){ + const [active,setActive]=useState(true);const box=useRef(null);const first=useRef(null);const second=useRef(null);const restore=useRef(null); + useEffect(()=>{restore.current=first.current;window.deactivate=()=>{restore.current=second.current;setActive(false)}},[]); + useFocusTrap(box,{active,restoreFocusRef:restore}); + return <>
+} +function App(){ + const [,setTick]=useState(0);window.bump=()=>setTick(n=>n+1); + if(mode==='overflow')return ;if(mode==='focus')return ; + const entry=mode==='sso'?'/login/callback/test?code=code1&state=state1':{pathname:'/login',state:{requires2FA:true,tempToken:'challenge1'}}; + return }/>}/>done
}/> +} +createRoot(document.getElementById('root')).render(); +`; +const server = await createServer({ + root, configFile:false, logLevel:'error', resolve:{alias:{'@':path.join(root,'src')}}, + cacheDir: path.join(root, 'node_modules/.vite-hooks-regression'), + optimizeDeps: { entries: [] }, + server:{host:'127.0.0.1',port:0}, + plugins:[{ + name:'hook-regression-fixtures',enforce:'pre', + resolveId(id){if(id==='react-i18next')return '\0test-i18n';if(id.endsWith('/hook-fixture.jsx'))return path.join(root,'hook-fixture.jsx')}, + load(id){ + const normalized=id.replaceAll('\\','/'); + if(id==='\0test-i18n')return `const t=(key,fallback)=>typeof fallback==='string'?fallback:key;export const useTranslation=()=>({t});export const Trans=({children})=>children;`; + if(normalized.endsWith('/src/contexts/useAuth.js'))return `export const useAuth=()=>({setUser:window.onUser,ssoProviders:[],passwordLoginEnabled:true,publicTitle:'Control panel'});`; + if(normalized.endsWith('/src/pages/auth/AuthLayout.jsx'))return `export default function AuthLayout({children}){return children}`; + if(normalized.endsWith('/src/services/api.js')||normalized.endsWith('/src/services/api/index.js'))return `export default new Proxy({}, {get:(_,key)=>(...args)=>window.testApi[key](...args)});`; + if(normalized.endsWith('/hook-fixture.jsx'))return fixture; + }, + configureServer(server){server.middlewares.use('/hook-check',async(_req,res)=>{res.setHeader('Content-Type','text/html');res.end(await server.transformIndexHtml('/hook-check','
'))})} + },react()], +}); +const executablePath = [ + process.env.CHROME_PATH, + chromium.executablePath(), + 'C:/Program Files/Google/Chrome/Application/chrome.exe', + 'C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe', +].find(candidate => candidate && existsSync(candidate)); + +let browser; +try { + await server.listen();const origin=`http://127.0.0.1:${server.httpServer.address().port}`; + browser=await chromium.launch({ ...(executablePath ? { executablePath } : {}), headless: true });const page=await browser.newPage(); + const errors=[];page.on('pageerror',error=>errors.push(error.message)); + await page.goto(origin+'/hook-check?mode=login');await page.locator('.totp-input').first().waitFor(); + async function digits(){for(let i=0;i<6;i++)await page.locator('.totp-input').nth(i).fill(String(i+1))} + await digits();await page.waitForFunction(()=>window.calls.length===1); + await page.evaluate(()=>{window.bump();document.querySelector('form').dispatchEvent(new Event('submit',{bubbles:true,cancelable:true}))}); + await page.waitForTimeout(100);assert.equal(await page.evaluate(()=>window.calls.length),1); + await page.evaluate(()=>window.fail(new Error('Invalid verification code'))); + await page.waitForFunction(()=>[...document.querySelectorAll('.totp-input')].every(el=>el.value==='')); + await digits();await page.waitForFunction(()=>window.calls.length===2); + await page.evaluate(()=>window.finish({access_token:'a',refresh_token:'r',user:{id:7}})); + await page.locator('#done').waitFor();assert.equal(await page.evaluate(()=>window.calls.length),2); + console.log('PASS TOTP single in-flight request, rerender guard, retry after rejection and successful navigation'); + await page.goto(origin+'/hook-check?mode=sso');await page.waitForFunction(()=>window.calls.length===1); + await page.evaluate(()=>window.bump());await page.waitForTimeout(100); + assert.equal(await page.evaluate(()=>window.calls.length),1); + await page.evaluate(()=>window.finish({requires_2fa:true,temp_token:'sso-challenge'})); + await page.locator('.totp-input').first().waitFor();assert.equal(await page.evaluate(()=>window.calls.length),1); + console.log('PASS SSO exchanges once under StrictMode and rerender, then forwards MFA challenge'); + await page.goto(origin+'/hook-check?mode=overflow');await page.locator('output').waitFor();await page.waitForTimeout(200); + assert.equal(await page.locator('output').textContent(),'[2,3]'); + const reads=await page.evaluate(()=>window.reads);await page.evaluate(()=>window.bump());await page.waitForTimeout(100); + assert.equal(await page.evaluate(()=>window.reads),reads); + await page.evaluate(()=>window.changeExtra());await page.waitForFunction(n=>window.reads>n,reads); + await page.evaluate(()=>window.changeCount(1));await page.waitForFunction(()=>document.querySelector('output').textContent==='[]'); + console.log('PASS overflow measurement skips identical dependency values and responds to changed values/count'); + await page.goto(origin+'/hook-check?mode=focus');await page.locator('#first').waitFor();await page.waitForTimeout(100); + await page.evaluate(()=>window.deactivate());await page.waitForFunction(()=>document.activeElement.id==='first'); + console.log('PASS focus restores activation target after ref changes'); + assert.deepEqual(errors,[]); +} finally {await browser?.close();await server.close()} diff --git a/frontend/scripts/lint-inventory.mjs b/frontend/scripts/lint-inventory.mjs new file mode 100644 index 000000000..69409eb84 --- /dev/null +++ b/frontend/scripts/lint-inventory.mjs @@ -0,0 +1,29 @@ +import { relative } from 'node:path'; + +export function warningInventory(results, root, sourceFiles) { + const inventory = {}; + for (const result of results) { + if (!sourceFiles.has(result.filePath)) continue; + const file = relative(root, result.filePath).replaceAll('\\', '/'); + for (const message of result.messages) { + if (message.severity !== 1) continue; + const rule = message.ruleId || 'unused-disable'; + inventory[file] ||= {}; + inventory[file][rule] = (inventory[file][rule] || 0) + 1; + } + } + return Object.fromEntries(Object.entries(inventory).sort().map(([file, rules]) => [ + file, Object.fromEntries(Object.entries(rules).sort()), + ])); +} + +export function warningRegressions(current, baseline) { + const regressions = []; + for (const [file, rules] of Object.entries(current)) { + for (const [rule, count] of Object.entries(rules)) { + const ceiling = baseline[file]?.[rule] || 0; + if (count > ceiling) regressions.push(`${file}: ${rule} has ${count} warnings (ceiling ${ceiling})`); + } + } + return regressions; +} diff --git a/frontend/scripts/lint-inventory.test.mjs b/frontend/scripts/lint-inventory.test.mjs new file mode 100644 index 000000000..efa111bb5 --- /dev/null +++ b/frontend/scripts/lint-inventory.test.mjs @@ -0,0 +1,23 @@ +import assert from 'node:assert/strict'; +import { resolve } from 'node:path'; +import test from 'node:test'; +import { warningInventory, warningRegressions } from './lint-inventory.mjs'; + +test('warning baseline excludes installed copies but includes newly added source', () => { + const root = resolve('fixture'); + const file = resolve(root, 'src/new.jsx'); + const installed = resolve(root, 'src/plugins/installed.jsx'); + const messages = [{ severity: 1, ruleId: 'no-unused-vars' }, { severity: 2, ruleId: 'no-undef' }]; + assert.deepEqual(warningInventory([{ filePath: file, messages }, { filePath: installed, messages }], root, new Set([file])), { + 'src/new.jsx': { 'no-unused-vars': 1 }, + }); +}); + +test('new files, new rules, and growing counts fail; reductions pass', () => { + const baseline = { 'src/page.jsx': { 'no-unused-vars': 2 } }; + assert.equal(warningRegressions({ 'src/page.jsx': { 'no-unused-vars': 3 } }, baseline).length, 1); + assert.equal(warningRegressions({ 'src/page.jsx': { 'react-hooks/exhaustive-deps': 1 } }, baseline).length, 1); + assert.equal(warningRegressions({ 'src/new.jsx': { 'no-unused-vars': 1 } }, baseline).length, 1); + assert.deepEqual(warningRegressions({ 'src/page.jsx': { 'no-unused-vars': 1 } }, baseline), []); + assert.deepEqual(warningRegressions({}, baseline), []); +}); diff --git a/frontend/scripts/lint-warning-baseline.json b/frontend/scripts/lint-warning-baseline.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/frontend/scripts/lint-warning-baseline.json @@ -0,0 +1 @@ +{} diff --git a/frontend/scripts/lint.mjs b/frontend/scripts/lint.mjs new file mode 100644 index 000000000..ab2c3d6c1 --- /dev/null +++ b/frontend/scripts/lint.mjs @@ -0,0 +1,40 @@ +// Run normal ESLint, then prevent tracked-source warnings growing by file/rule. +// Installed, ignored extensions still receive normal lint diagnostics, but do +// not change the checked-in baseline on one developer's machine. +import { ESLint } from 'eslint'; +import { execFileSync } from 'node:child_process'; +import { readFileSync, writeFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { warningInventory, warningRegressions } from './lint-inventory.mjs'; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const repo = resolve(root, '..'); +const baselineFile = resolve(root, 'scripts/lint-warning-baseline.json'); +const lint = new ESLint({ cwd: root }); +const results = await lint.lintFiles(['.']); +const formatter = await lint.loadFormatter('stylish'); +const output = formatter.format(results); +if (output) console.log(output); +if (results.some((result) => result.errorCount > 0)) process.exit(1); + +// Include new, non-ignored source files so new warnings cannot slip through +// before the file is first staged. Git paths are repository-relative here. +const paths = execFileSync('git', ['ls-files', '-z', '--cached', '--others', '--exclude-standard', '--', 'frontend'], { + cwd: repo, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], +}).split('\0').filter(Boolean); +const sourceFiles = new Set(paths.map((file) => resolve(repo, file))); +const inventory = warningInventory(results, root, sourceFiles); +if (process.argv.includes('--update')) { + writeFileSync(baselineFile, `${JSON.stringify(inventory, null, 2)}\n`); + console.log('Updated tracked-source warning baseline; review the diff before committing.'); +} else { + const baseline = JSON.parse(readFileSync(baselineFile, 'utf8')); + const regressions = warningRegressions(inventory, baseline); + if (regressions.length) { + console.error(`New ESLint warnings:\n ${regressions.join('\n ')}\nFix new warnings; keep the baseline shrinking.`); + process.exit(1); + } + const count = Object.values(inventory).reduce((total, rules) => total + Object.values(rules).reduce((sum, n) => sum + n, 0), 0); + console.log(`Tracked-source warnings: ${count}; no file/rule exceeds its reviewed baseline.`); +} diff --git a/frontend/scripts/measure-build.mjs b/frontend/scripts/measure-build.mjs new file mode 100644 index 000000000..8b5691c3c --- /dev/null +++ b/frontend/scripts/measure-build.mjs @@ -0,0 +1,69 @@ +// Deterministic byte census of a production dist. Gzip is per file, level 9; +// totals are storage/transfer estimates, never a page-load speed measurement. +import { readFileSync, readdirSync, statSync, mkdirSync, writeFileSync } from 'node:fs'; +import { resolve, relative, dirname, extname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { gzipSync } from 'node:zlib'; +import { createHash } from 'node:crypto'; + +export function linkedAssets(html) { + const result = new Set(); + for (const tag of html.matchAll(/<(?:script|link)\b[^>]*>/gi)) { + const attributes = Object.fromEntries([...tag[0].matchAll(/([\w-]+)\s*=\s*["']([^"']*)["']/g)] + .map((match) => [match[1].toLowerCase(), match[2]])); + const asset = attributes.src || (['stylesheet', 'modulepreload'].includes(attributes.rel) ? attributes.href : null); + if (asset && !/^(?:[a-z][a-z\d+.-]*:|\/\/)/i.test(asset)) { + result.add(decodeURIComponent(new URL(asset, 'https://build.invalid/').pathname).slice(1)); + } + } + return result; +} + +export function measureBuild(dist) { + const root = resolve(dist); + const walk = (directory) => readdirSync(directory).flatMap((name) => { + const path = resolve(directory, name); + return statSync(path).isDirectory() ? walk(path) : [path]; + }); + const fingerprint = createHash('sha256'); + const files = walk(root).filter((path) => ['.js', '.mjs', '.css'].includes(extname(path))).sort().map((path) => { + const bytes = readFileSync(path); + const name = relative(root, path).replaceAll('\\', '/'); + fingerprint.update(name).update('\0').update(bytes).update('\0'); + return { path: name, bytes: bytes.length, gzip_bytes: gzipSync(bytes, { level: 9 }).length }; + }); + const linked = linkedAssets(readFileSync(resolve(root, 'index.html'), 'utf8')); + const missing = [...linked].filter((path) => !files.some((file) => file.path === path)); + if (missing.length) throw new Error(`HTML-linked code assets missing from build: ${missing.join(', ')}`); + const sum = (items) => ({ + files: items.length, + bytes: items.reduce((total, file) => total + file.bytes, 0), + gzip_bytes: items.reduce((total, file) => total + file.gzip_bytes, 0), + }); + return { + schema_version: 1, + measured_at_utc: new Date().toISOString(), + node_version: process.version, + measured_code_sha256: fingerprint.digest('hex'), + compression: 'gzip, level 9, each file separately; sizes are bytes', + scope: 'Built JS/MJS/CSS, including public vendor shims. Excludes HTML, fonts, images, maps and other non-code assets.', + html_linked_scope: 'Only script, stylesheet and modulepreload URLs in index.html; not a complete runtime network trace or every later lazy import.', + all_code: sum(files), + html_linked_code: sum(files.filter((file) => linked.has(file.path))), + largest_code_assets: [...files].sort((a, b) => b.bytes - a.bytes).slice(0, 12), + assets: files, + }; +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const here = dirname(fileURLToPath(import.meta.url)); + const args = process.argv.slice(2); + const value = (flag, fallback) => args.includes(flag) ? args[args.indexOf(flag) + 1] : fallback; + const report = measureBuild(value('--dist', resolve(here, '../dist'))); + const output = value('--output', null); + if (output) { + mkdirSync(dirname(resolve(output)), { recursive: true }); + writeFileSync(output, `${JSON.stringify(report, null, 2)}\n`); + } + console.log(JSON.stringify({ all_code: report.all_code, html_linked_code: report.html_linked_code }, null, 2)); +} diff --git a/frontend/scripts/measure-build.test.mjs b/frontend/scripts/measure-build.test.mjs new file mode 100644 index 000000000..fb7095a80 --- /dev/null +++ b/frontend/scripts/measure-build.test.mjs @@ -0,0 +1,31 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { linkedAssets, measureBuild } from './measure-build.mjs'; + +test('separates HTML-linked code from lazy chunks and excludes fonts/images', () => { + const directory = mkdtempSync(join(tmpdir(), 'serverkit-measure-')); + try { + writeFileSync(join(directory, 'index.html'), ''); + for (const file of ['app.js', 'shared.js', 'lazy.js', 'app.css', 'font.woff2', 'icon.png']) { + writeFileSync(join(directory, file), '12345'); + } + const report = measureBuild(directory); + assert.equal(report.all_code.files, 4); + assert.equal(report.all_code.bytes, 20); + assert.equal(report.html_linked_code.files, 3); + assert.equal(report.html_linked_code.bytes, 15); + assert.ok(report.all_code.gzip_bytes > report.html_linked_code.gzip_bytes); + } finally { rmSync(directory, { recursive: true }); } +}); + +test('deduplicates links, ignores external resources and detects missing assets', () => { + assert.deepEqual([...linkedAssets('')], ['app.js']); + const directory = mkdtempSync(join(tmpdir(), 'serverkit-measure-')); + try { + writeFileSync(join(directory, 'index.html'), ''); + assert.throws(() => measureBuild(directory), /missing.js/); + } finally { rmSync(directory, { recursive: true }); } +}); diff --git a/frontend/scripts/metrics-browser.mjs b/frontend/scripts/metrics-browser.mjs new file mode 100644 index 000000000..34e7b9bb6 --- /dev/null +++ b/frontend/scripts/metrics-browser.mjs @@ -0,0 +1,205 @@ +import assert from 'node:assert/strict'; +import { existsSync } from 'node:fs'; +import { chromium } from 'playwright'; +import { createServer } from 'vite'; + +// Real React mount with controlled HTTP/socket adapters and visibility events. +const fixture = ` +import React, { useState } from 'react'; +import { createRoot } from 'react-dom/client'; +import { useMetrics } from '/src/hooks/useMetrics.js'; +import api from '/src/services/api/index.js'; +import socket from '/src/services/socket.js'; +import { WorkspaceProvider } from '/src/contexts/WorkspaceContext.jsx'; +import { workspaceStore } from '/src/services/workspaceStore.js'; +import { useMetricHistory } from '/src/components/dashboard/widgets/useWidgetData.js'; +import MetricsTab from '/src/components/service-detail/MetricsTab.jsx'; +const handlers = new Map(); +let hidden = false; +const state = { calls: 0, pending: [] }; +api.getSystemMetrics = () => { + state.calls += 1; + return new Promise((resolve) => state.pending.push(resolve)); +}; +socket.socket = { connected: false }; +socket.connect = () => {}; +socket.on = (name, fn) => { handlers.set(name, fn); return () => handlers.delete(name); }; +socket.subscribeMetrics = () => {}; +socket.unsubscribeMetrics = () => {}; +Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => hidden ? 'hidden' : 'visible' }); +function App() { + const [options, setOptions] = useState({ enabled: true, autoRefresh: true }); + const metrics = useMetrics(true, 60, options); + window.metricsFixture = { + state, setOptions, metrics, + emit: (name, data) => handlers.get(name)?.(data), + resolve: () => state.pending.splice(0).forEach((resolve) => resolve({ cpu: 42 })), + hide: (value) => { hidden = value; document.dispatchEvent(new Event('visibilitychange')); }, + }; + return React.createElement('output', {}, metrics.loading ? 'loading' : 'ready'); +} +createRoot(document.getElementById('root')).render(React.createElement(App)); +const widgetState = { calls: 0, pending: [], snapshots: [] }; +api.getMetricsHistory = () => { + widgetState.calls += 1; + return new Promise((resolve) => widgetState.pending.push(resolve)); +}; +function Widget({ index, tick }) { + widgetState.snapshots[index] = useMetricHistory('local', '1h', tick); + return null; +} +function Widgets() { + const [tick, setTick] = useState(0); + window.widgetFixture = { + state: widgetState, refresh: () => setTick((value) => value + 1), + resolve: () => widgetState.pending.splice(0).forEach((resolve) => resolve({ points: [widgetState.calls] })), + workspace: () => workspaceStore.setActiveWorkspace({ id: 'other-workspace', name: 'Other' }), + }; + return React.createElement(React.Fragment, {}, + React.createElement(Widget, { index: 0, tick }), React.createElement(Widget, { index: 1, tick })); +} +createRoot(document.getElementById('widgets')).render(React.createElement(WorkspaceProvider, {}, React.createElement(Widgets))); +const serviceState = { containers: [], pending: [], python: [] }; +api.getContainers = async () => ({ containers: serviceState.containers }); +api.getContainerStats = (id) => new Promise((resolve, reject) => serviceState.pending.push({ id, resolve, reject })); +api.getPythonAppStatus = () => new Promise((resolve) => serviceState.python.push(resolve)); +function Service() { + const [app, setApp] = useState(null); + window.serviceFixture = { state: serviceState, setApp }; + return app ? React.createElement(MetricsTab, { app }) : null; +} +createRoot(document.getElementById('service')).render(React.createElement(Service)); +`; +const server = await createServer({ + server: { host: '127.0.0.1', port: 0, open: false }, + plugins: [{ + name: 'metrics-regression-fixture', + resolveId(id) { if (id === 'virtual:metrics-regression') return '\0metrics-regression'; }, + load(id) { if (id === '\0metrics-regression') return fixture; }, + configureServer(vite) { + vite.middlewares.use('/__metrics-regression', async (_req, res) => { + res.setHeader('Content-Type', 'text/html'); + res.end(await vite.transformIndexHtml('/__metrics-regression', + '
')); + }); + }, + }], +}); +await server.listen(); +const executablePath = [ + process.env.CHROME_PATH, chromium.executablePath(), + 'C:/Program Files/Google/Chrome/Application/chrome.exe', + 'C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe', +].find((path) => path && existsSync(path)); +let browser; +try { + browser = await chromium.launch({ ...(executablePath ? { executablePath } : {}), headless: true }); + const page = await browser.newPage(); + const errors = []; + page.on('pageerror', (error) => errors.push(error.message)); + await page.goto(`http://127.0.0.1:${server.httpServer.address().port}/__metrics-regression`); + await page.waitForFunction(() => window.metricsFixture?.state.calls === 1); + const calls = () => page.evaluate(() => window.metricsFixture.state.calls); + const action = (fn) => page.evaluate(fn); + await page.waitForTimeout(240); + assert.equal(await calls(), 1, 'slow initial response cannot overlap fallback'); + await action(() => { window.metricsFixture.metrics.refresh(); window.metricsFixture.metrics.refresh(); }); + assert.equal(await calls(), 1, 'manual refresh shares initial request'); + await action(() => window.metricsFixture.emit('connected')); + await action(() => window.metricsFixture.resolve()); + await page.waitForTimeout(180); + assert.equal(await calls(), 1, 'connected socket stops fallback'); + await action(() => window.metricsFixture.emit('disconnected')); + await page.waitForFunction(() => window.metricsFixture.state.calls === 2); + await action(() => window.metricsFixture.hide(true)); + await action(() => window.metricsFixture.resolve()); + await page.waitForTimeout(180); + assert.equal(await calls(), 2, 'hidden tabs suspend fallback'); + await action(() => window.metricsFixture.hide(false)); + await page.waitForFunction(() => window.metricsFixture.state.calls === 3); + await action(() => window.metricsFixture.setOptions({ enabled: false, autoRefresh: true })); + await page.waitForTimeout(30); + await action(() => window.metricsFixture.resolve()); + await page.waitForTimeout(180); + assert.equal(await calls(), 3, 'remote selection stops local HTTP work'); + await action(() => window.metricsFixture.setOptions({ enabled: true, autoRefresh: false })); + await page.waitForFunction(() => window.metricsFixture.state.calls === 4); + await action(() => window.metricsFixture.resolve()); + await page.waitForTimeout(180); + assert.equal(await calls(), 4, 'refresh off loads one snapshot without polling'); + await action(() => { window.metricsFixture.metrics.refresh(); window.metricsFixture.metrics.refresh(); }); + await page.waitForFunction(() => window.metricsFixture.state.calls === 5); + await action(() => window.metricsFixture.resolve()); + await page.waitForTimeout(120); + assert.equal(await calls(), 5, 'manual refresh while off does not restart timer'); + await page.waitForFunction(() => window.widgetFixture?.state.calls === 1); + await action(() => window.widgetFixture.resolve()); + await page.waitForFunction(() => window.widgetFixture.state.snapshots.every((item) => !item.loading)); + await action(() => window.widgetFixture.refresh()); + await page.waitForFunction(() => window.widgetFixture.state.calls === 2); + assert.deepEqual(await action(() => window.widgetFixture.state.snapshots.map((item) => item.data)), + [{ points: [1] }, { points: [1] }], 'refresh retains both widgets while sharing one next request'); + await action(() => window.widgetFixture.resolve()); + await page.waitForFunction(() => window.widgetFixture.state.snapshots.every((item) => !item.loading)); + await action(() => window.widgetFixture.workspace()); + await page.waitForFunction(() => window.widgetFixture.state.calls === 3); + assert.deepEqual(await action(() => window.widgetFixture.state.snapshots.map((item) => item.data)), + [null, null], 'workspace switching cannot display another workspace payload'); + await action(() => window.widgetFixture.resolve()); + await page.waitForFunction(() => window.widgetFixture.state.snapshots.every((item) => !item.loading)); + // Exercise the actual service component: disappearance, rejected polls, + // runtime switches and a late response from the previous application. + await page.clock.install(); + await action(() => { + window.serviceFixture.state.containers = [{ Id: 'a', Names: ['alpha'] }]; + window.serviceFixture.setApp({ id: 1, name: 'alpha', app_type: 'docker' }); + }); + await page.waitForFunction(() => window.serviceFixture.state.pending.length === 1); + await action(() => window.serviceFixture.state.pending.shift().resolve({ cpu_percent: 42 })); + await page.locator('#service .metrics-tab').waitFor(); + assert.match(await page.locator('#service').innerText(), /42\.0%/); + await action(() => { window.serviceFixture.state.containers = []; }); + await page.clock.fastForward(10001); + await page.locator('#service .empty-state__title').waitFor(); + assert.equal(await page.locator('#service .metrics-tab').count(), 0, 'missing container clears previous stats'); + await action(() => { window.serviceFixture.state.containers = [{ Id: 'a', Names: ['alpha'] }]; }); + await page.clock.fastForward(10001); + await page.waitForFunction(() => window.serviceFixture.state.pending.length === 1); + await action(() => window.serviceFixture.state.pending.shift().resolve({ cpu_percent: 51 })); + await page.locator('#service .metrics-tab').waitFor(); + await page.clock.fastForward(10001); + await page.waitForFunction(() => window.serviceFixture.state.pending.length === 1); + await action(() => window.serviceFixture.state.pending.shift().reject(new Error('container stopped'))); + await page.locator('#service .empty-state__title').waitFor(); + await page.clock.fastForward(10001); + await page.waitForFunction(() => window.serviceFixture.state.pending.length === 1); + await action(() => { + window.serviceFixture.state.containers = [{ Id: 'b', Names: ['beta'] }]; + window.serviceFixture.setApp({ id: 2, name: 'beta', app_type: 'docker' }); + }); + await page.waitForFunction(() => window.serviceFixture.state.pending.length === 2); + assert.equal(await page.locator('#service [aria-busy="true"]').count(), 1, 'new app starts loading'); + await action(() => window.serviceFixture.state.pending.pop().resolve({ cpu_percent: 17 })); + await page.locator('#service .metrics-tab').waitFor(); + await action(() => window.serviceFixture.state.pending.shift().resolve({ cpu_percent: 99 })); + await page.waitForTimeout(50); + assert.match(await page.locator('#service').innerText(), /17\.0%/, 'late previous app response is ignored'); + assert.doesNotMatch(await page.locator('#service').innerText(), /99\.0%/); + await action(() => window.serviceFixture.setApp({ id: 3, name: 'python', app_type: 'flask' })); + await page.waitForFunction(() => window.serviceFixture.state.python.length === 1); + assert.equal(await page.locator('#service .metrics-tab').count(), 0, 'Python switch clears Docker state'); + await action(() => window.serviceFixture.state.python.shift()({ active: true, pid: 123 })); + await page.locator('#service .metrics-tab').waitFor(); + await action(() => window.serviceFixture.setApp({ id: 4, name: 'other-python', app_type: 'flask' })); + await page.waitForFunction(() => window.serviceFixture.state.python.length === 1); + assert.equal(await page.locator('#service [aria-busy="true"]').count(), 1, 'Python switch also resets loading'); + assert.doesNotMatch(await page.locator('#service').innerText(), /123/); + await action(() => window.serviceFixture.state.python.shift()({ active: false })); + await page.locator('#service .metrics-tab').waitFor(); + assert.deepEqual(errors, []); + console.log('Service metrics regression passed: missing container, failed poll, app switch, late response and Python state reset.'); + console.log('Metrics browser regression passed: slow response, manual sharing, socket loss/reconnect, hidden tab, remote selection, refresh off; widget sharing, refresh retention, workspace isolation.'); +} finally { + await browser?.close(); + await server.close(); +} diff --git a/frontend/scripts/settings-browser.mjs b/frontend/scripts/settings-browser.mjs new file mode 100644 index 000000000..7de363897 --- /dev/null +++ b/frontend/scripts/settings-browser.mjs @@ -0,0 +1,135 @@ +// Render actual Settings components with synthetic responses; no backend needed. +import assert from 'node:assert/strict'; +import { createServer } from 'vite'; +import { chromium } from 'playwright'; +import { mkdir } from 'node:fs/promises'; + +async function withinDeadline(signal) { + let timer; + try { + return await Promise.race([ + signal, + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error('Expected API request was not sent within 10 seconds')), 10000); + }), + ]); + } finally { + clearTimeout(timer); + } +} + +const users = [ + { id: 1, username: 'operator', email: 'operator@example.test', role: 'admin', is_active: true, totp_enabled: true, passkey_enabled: true }, + { id: 2, username: 'needs-mfa', email: 'second@example.test', role: 'admin', is_active: true, totp_enabled: false }, + { id: 3, username: 'disabled-admin', email: 'disabled@example.test', role: 'admin', is_active: false, totp_enabled: false }, +].map((user) => ({ ...user, auth_provider: 'local', permissions: {}, created_at: '2026-08-15T14:30:00Z' })); +const invitations = [{ id: 1, email: 'invite@example.test', role: 'viewer', status: 'pending', token: 'synthetic', created_at: '2026-08-15T14:30:00Z', expires_at: '2099-01-01T00:00:00Z', is_expired: false }]; + +const server = await createServer({ server: { host: '127.0.0.1', port: 0, strictPort: false, open: false } }); +let browser; +try { + await server.listen(); + const base = server.resolvedUrls.local[0]; + browser = await chromium.launch({ headless: true }); + await mkdir('test-results', { recursive: true }); + for (const theme of ['dark', 'light']) { + const context = await browser.newContext({ viewport: { width: 1440, height: 1000 } }); + await context.addInitScript((value) => { + localStorage.setItem('access_token', 'synthetic-browser-fixture'); + document.addEventListener('DOMContentLoaded', () => { + document.documentElement.setAttribute('data-theme', value); + }, { once: true }); + }, theme); + const page = await context.newPage(); + const errors = []; + page.on('pageerror', (error) => errors.push(error.message)); + let resolveUsers; + const ready = new Promise((resolve) => { resolveUsers = resolve; }); + let empty = false; + let fail = false; + let deletes = 0; + let releaseDelete; + let notifyDelete; + const deleteStarted = new Promise((resolve) => { notifyDelete = resolve; }); + let revokes = 0; + let releaseRevoke; + let notifyRevoke; + const revokeStarted = new Promise((resolve) => { notifyRevoke = resolve; }); + await page.route('**/api/v1/**', async (route) => { + const url = new URL(route.request().url()); + let payload = {}; + if (url.pathname.endsWith('/admin/users') && route.request().method() === 'GET') { + await ready; + if (fail) return route.fulfill({ status: 500, json: { error: 'Synthetic users failure' } }); + payload = { users: empty ? [] : users }; + } else if (/\/admin\/users\/\d+$/.test(url.pathname) && route.request().method() === 'DELETE') { + deletes += 1; + await new Promise((resolve) => { releaseDelete = resolve; notifyDelete(); }); + return route.fulfill({ json: { message: 'Deleted' } }); + } else if (url.pathname.endsWith('/auth/setup-status')) payload = { needs_setup: false }; + else if (url.pathname.endsWith('/auth/me')) payload = { user: users[0] }; + else if (url.pathname.includes('/admin/invitations') && route.request().method() === 'DELETE') { + revokes += 1; + await new Promise((resolve) => { releaseRevoke = resolve; notifyRevoke(); }); + return route.fulfill({ status: 500, json: { error: 'Synthetic revoke failure' } }); + } + else if (url.pathname.includes('/admin/invitations')) payload = { invitations }; + else if (url.pathname.endsWith('/auth/login-links')) payload = { links: [] }; + else if (url.pathname.endsWith('/views')) payload = { views: [] }; + await route.fulfill({ json: payload }); + }); + await page.goto(`${base}tests/browser/settings.html`); + await page.getByText('Loading users…', { exact: true }).waitFor(); + resolveUsers(); + await page.locator('.users-table').first().getByText('needs-mfa', { exact: true }).waitFor(); + await page.getByText('invite@example.test', { exact: true }).waitFor(); + const surfaces = await page.locator('.users-table-container').evaluateAll((nodes) => nodes.map((node) => { + const style = getComputedStyle(node); + return { color: style.backgroundColor, surface: style.getPropertyValue('--surface').trim() }; + })); + assert.equal(surfaces.length, 2); + for (const surface of surfaces) assert.notEqual(surface.color, 'rgba(0, 0, 0, 0)', `${theme} table has no resting surface`); + await page.screenshot({ path: `test-results/settings-${theme}.png`, fullPage: true }); + assert.equal(await page.getByRole('columnheader', { name: /^MFA\b/ }).count(), 1); + assert.equal(await page.getByRole('columnheader', { name: /^Passkey\b/ }).count(), 1); + + await page.getByRole('button', { name: 'All users', exact: true }).click(); + await page.getByRole('button', { name: 'Admins without MFA', exact: true }).click(); + await page.locator('.users-table').first().getByText('needs-mfa', { exact: true }).waitFor(); + assert.equal(await page.locator('.users-table').first().locator('tbody tr').count(), 1); + assert.equal(await page.locator('.users-table').first().getByText('disabled-admin', { exact: true }).count(), 0); + + await page.getByRole('button', { name: 'Delete user', exact: true }).click(); + await page.getByRole('alertdialog').waitFor(); + await page.getByRole('alertdialog').getByRole('button', { name: 'Delete User', exact: true }).click(); + await withinDeadline(deleteStarted); + await page.waitForFunction(() => document.querySelector('.users-table button[aria-busy="true"]')); + assert.equal(await page.locator('.users-table').first().locator('tbody button:not(:disabled)').count(), 0); + assert.equal(deletes, 1); + releaseDelete(); + await page.locator('.users-table').first().getByText('needs-mfa', { exact: true }).waitFor(); + + const inviteSection = page.locator('.invitations-section'); + await inviteSection.getByRole('button', { name: 'Revoke', exact: true }).click(); + await withinDeadline(revokeStarted); + assert.equal(await inviteSection.getByRole('button', { name: 'Revoke', exact: true }).isDisabled(), true); + assert.equal(await inviteSection.getByRole('button', { name: 'Resend', exact: true }).isDisabled(), true); + assert.equal(revokes, 1); + releaseRevoke(); + await inviteSection.getByRole('alert').filter({ hasText: 'Synthetic revoke failure' }).waitFor(); + assert.equal(await inviteSection.getByRole('button', { name: 'Revoke', exact: true }).isEnabled(), true); + + empty = true; + await page.reload(); + await page.locator('.users-tab .empty-state').first().waitFor(); + fail = true; + await page.reload(); + await page.getByRole('alert').filter({ hasText: 'Synthetic users failure' }).waitFor(); + assert.deepEqual(errors, []); + await context.close(); + console.log(`Settings ${theme}: surfaces, MFA view, guarded delete/revoke, loading, empty and error states passed`); + } +} finally { + await browser?.close(); + await server.close(); +} diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 3e3cbdbc8..2c7d36726 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,6 +1,7 @@ import { Suspense, useEffect } from 'react'; import { BrowserRouter as Router, Routes, Route, Navigate, useLocation, useParams } from 'react-router-dom'; -import { AuthProvider, useAuth } from './contexts/AuthContext'; +import { AuthProvider } from './contexts/AuthContext'; +import { useAuth } from './contexts/useAuth.js'; import { ToastProvider } from './contexts/ToastContext'; import { ThemeProvider } from './contexts/ThemeContext'; import { LocaleProvider } from './contexts/LocaleContext'; diff --git a/frontend/src/components/CommandPalette.jsx b/frontend/src/components/CommandPalette.jsx index 5314f69ac..1c1871aa0 100644 --- a/frontend/src/components/CommandPalette.jsx +++ b/frontend/src/components/CommandPalette.jsx @@ -17,9 +17,9 @@ import { CommandItem, } from '@/components/ui/command'; import { useContributions } from '../plugins/contributions'; -import { useAuth } from '../contexts/AuthContext'; -import { useShellDock } from '../contexts/ShellDockContext'; -import { useTheme } from '../contexts/ThemeContext'; +import { useAuth } from '../contexts/useAuth.js'; +import { useShellDock } from '../contexts/useShellDock.js'; +import { useTheme } from '../contexts/useTheme.js'; import { useWalkthroughs } from '../contexts/walkthroughContextValue'; import usePaletteAuthz from '../hooks/usePaletteAuthz'; import { CREATE_ITEMS } from '../data/createItems'; @@ -30,6 +30,7 @@ import { DOCS_LINKS } from '../utils/docsLinks'; import { scoreItem } from '../utils/paletteScore'; import { frecencyScore, recordUse, recentIds } from '../utils/paletteFrecency'; import { getFavorites, getRecents } from '../utils/recents'; +import { Button as SharedButton } from '@/components/ui/button'; // Group order in the list, plus a per-category icon and a scoring weight so the // headline categories (Settings, Pages, Actions) outrank raw entity hits on ties. @@ -427,7 +428,7 @@ const CommandPalette = ({ open, onClose }) => { ); })} {hiddenCount > 0 && ( - + )} ); diff --git a/frontend/src/components/DocsLink.jsx b/frontend/src/components/DocsLink.jsx index 796bd5e5c..b14b56961 100644 --- a/frontend/src/components/DocsLink.jsx +++ b/frontend/src/components/DocsLink.jsx @@ -1,5 +1,5 @@ import { BookOpen } from 'lucide-react'; -import { useTheme } from '../contexts/ThemeContext'; +import { useTheme } from '../contexts/useTheme.js'; import { docsUrl } from '../utils/docsLinks'; // Small "Docs" link (book icon) that opens a serverkit.ai docs page in a new diff --git a/frontend/src/components/EmailProviders.jsx b/frontend/src/components/EmailProviders.jsx index 33fccd6c0..fe94a5768 100644 --- a/frontend/src/components/EmailProviders.jsx +++ b/frontend/src/components/EmailProviders.jsx @@ -4,7 +4,7 @@ import api from '../services/api'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; -import { useToast } from '../contexts/ToastContext'; +import { useToast } from '../contexts/useToast.js'; import { useTranslation } from 'react-i18next'; const FIELD_LABELS = { diff --git a/frontend/src/components/EnvironmentVariables.jsx b/frontend/src/components/EnvironmentVariables.jsx index bdfbdebdd..bc306ed2f 100644 --- a/frontend/src/components/EnvironmentVariables.jsx +++ b/frontend/src/components/EnvironmentVariables.jsx @@ -3,7 +3,7 @@ import { Copy, Download, Eye, EyeOff, History, Pencil, Plus, Trash2, Upload, Variable, } from 'lucide-react'; import api from '../services/api'; -import { useToast } from '../contexts/ToastContext'; +import { useToast } from '../contexts/useToast.js'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Textarea } from '@/components/ui/textarea'; @@ -70,6 +70,7 @@ const ENV_VIEWS = [ const EnvironmentVariables = ({ appId }) => { const { t } = useTranslation(); const toast = useToast(); + const toastError = toast.error; const { confirm } = useConfirm(); const { copy } = useClipboard(); const [envVars, setEnvVars] = useState([]); @@ -110,12 +111,7 @@ const EnvironmentVariables = ({ appId }) => { const fileInputRef = useRef(null); - useEffect(() => { - loadEnvVars(); - loadComposeServices(); - }, [appId]); - - async function loadComposeServices() { + const loadComposeServices = useCallback(async () => { try { const data = await api.getComposeServices(appId); setComposeServices(data.services || []); @@ -123,20 +119,26 @@ const EnvironmentVariables = ({ appId }) => { // Non-compose apps or errors: keep single-container UX (no selector). setComposeServices([]); } - } + }, [appId]); - async function loadEnvVars() { + const loadEnvVars = useCallback(async () => { try { setLoading(true); const data = await api.getEnvVars(appId); setEnvVars(data.env_vars || []); } catch (err) { - toast.error(t('app.environmentVariables.failedToLoadEnvironmentVariables', 'Failed to load environment variables')); + toastError(t('app.environmentVariables.failedToLoadEnvironmentVariables', 'Failed to load environment variables')); console.error('Failed to load env vars:', err); } finally { setLoading(false); } - } + }, [appId, t, toastError]); + + useEffect(() => { + loadEnvVars(); + loadComposeServices(); + }, [loadEnvVars, loadComposeServices]); + function openAddModal() { setNewKey(''); @@ -250,7 +252,7 @@ const EnvironmentVariables = ({ appId }) => { const data = await api.exportEnvFile(appId, includeSecrets); downloadBlob(data.content, data.filename || 'app.env'); toast.success(t('app.environmentVariables.environmentFileExported', 'Environment file exported')); - } catch (err) { + } catch { toast.error(t('app.environmentVariables.failedToExport', 'Failed to export')); } } @@ -291,7 +293,7 @@ const EnvironmentVariables = ({ appId }) => { const data = await api.getEnvVarHistory(appId); setHistory(data.history || []); setShowHistoryModal(true); - } catch (err) { + } catch { toast.error(t('app.environmentVariables.failedToLoadHistory', 'Failed to load history')); } } @@ -312,7 +314,7 @@ const EnvironmentVariables = ({ appId }) => { await api.clearEnvVars(appId); toast.success(t('app.environmentVariables.allEnvironmentVariablesCleared', 'All environment variables cleared')); loadEnvVars(); - } catch (err) { + } catch { toast.error(t('app.environmentVariables.failedToClear', 'Failed to clear')); } } @@ -437,38 +439,38 @@ const EnvironmentVariables = ({ appId }) => { cellClassName: 'actions-cell', render: (ev) => ( <> - - - - + ), }, diff --git a/frontend/src/components/FavoriteStar.jsx b/frontend/src/components/FavoriteStar.jsx index 801a2876f..30aea4508 100644 --- a/frontend/src/components/FavoriteStar.jsx +++ b/frontend/src/components/FavoriteStar.jsx @@ -3,6 +3,7 @@ import { Star } from 'lucide-react'; import { cn } from '@/lib/utils'; import { isFavorite, toggleFavorite } from '@/utils/recents'; import { useTranslation } from 'react-i18next'; +import { Button as SharedButton } from '@/components/ui/button'; // Pin/unpin an entity as a favorite (surfaced in the command palette's // Favorites section). Sits in detail-page title areas. @@ -17,7 +18,7 @@ export function FavoriteStar({ type, id, path, label, className }) { }; return ( - + ); } diff --git a/frontend/src/components/GlobalStatusBar.jsx b/frontend/src/components/GlobalStatusBar.jsx index bf0f3daad..f03ece1f7 100644 --- a/frontend/src/components/GlobalStatusBar.jsx +++ b/frontend/src/components/GlobalStatusBar.jsx @@ -7,15 +7,16 @@ import { import { useTranslation } from 'react-i18next'; import api from '../services/api'; -import { useAuth } from '../contexts/AuthContext'; -import { useNotifications } from '../contexts/NotificationsContext'; +import { useAuth } from '../contexts/useAuth.js'; +import { useNotifications } from '../contexts/useNotifications.js'; import { useOperations } from '../contexts/OperationsContext'; -import { useServerkitAI } from '../contexts/AIContext'; -import { useShellDock } from '../contexts/ShellDockContext'; +import { useServerkitAI } from '../contexts/useServerkitAI.js'; +import { useShellDock } from '../contexts/useShellDock.js'; import { useWalkthroughs } from '../contexts/walkthroughContextValue'; -import { useWorkspace } from '../contexts/WorkspaceContext'; +import { useWorkspace } from '../contexts/useWorkspace.js'; import { timeAgo } from '../utils/time'; import ShellDockTabs from './ShellDockTabs'; +import { Button as SharedButton } from '@/components/ui/button'; const SERVER_SCOPE_KEY = 'serverkit.activeServerScope'; @@ -49,7 +50,7 @@ function ScopeMenu({ label, options, value, onPick, wide = false }) { >
{label}
{options.map((option) => ( - + ))}
); @@ -107,7 +108,7 @@ function AlertsPanel({ onClose }) {
- - + {items.some((item) => item.kind !== 'notice' && !item.read) && ( - + )}
@@ -143,7 +144,7 @@ function AlertsPanel({ onClose }) { key={item.delivery_id || item.notice_id} className={`shell-alerts__row is-${alertTone(item)}${item.read ? ' is-read' : ''}`} > - + {item.kind === 'notice' && ( - + )}
))} @@ -171,9 +172,9 @@ function AlertsPanel({ onClose }) {
{t('notifications.recentActivity', 'Recent system and delivery activity')} - +
); @@ -304,7 +305,7 @@ export default function GlobalStatusBar({ onOpenPalette }) { onPick={pickWorkspace} /> )} - +
) : (
@@ -195,10 +192,10 @@ const LinkAppModal = ({ app, onClose, onLinked }) => { )}
- - +
)} diff --git a/frontend/src/components/LinkedAppsSection.jsx b/frontend/src/components/LinkedAppsSection.jsx index 4520e9fc4..99bce2d47 100644 --- a/frontend/src/components/LinkedAppsSection.jsx +++ b/frontend/src/components/LinkedAppsSection.jsx @@ -1,5 +1,7 @@ import { GitBranch, Link2, Unlink, ExternalLink, Server } from 'lucide-react'; import { useTranslation } from 'react-i18next'; +import { Button as SharedButton } from '@/components/ui/button'; +import { Card as SharedCard } from '@/components/ui/card'; const LinkedAppsSection = ({ app, @@ -33,21 +35,21 @@ const LinkedAppsSection = ({ }; return ( -
+

{t('app.linkedAppsSection.environmentLinking', 'Environment Linking')}

{!app.has_linked_app && ( - + )}
@@ -89,21 +91,21 @@ const LinkedAppsSection = ({
- - +
))} @@ -111,14 +113,14 @@ const LinkedAppsSection = ({ ) : app.environment_type !== 'standalone' ? (

{t('app.linkedAppsSection.noLinkedAppsLinkAnotherApp', 'No linked apps. Link another app to share database resources.')}

- +
) : (
@@ -144,7 +146,7 @@ const LinkedAppsSection = ({
)}
-
+ ); }; diff --git a/frontend/src/components/LocaleSync.jsx b/frontend/src/components/LocaleSync.jsx index 94dd2098a..e3ffdbf19 100644 --- a/frontend/src/components/LocaleSync.jsx +++ b/frontend/src/components/LocaleSync.jsx @@ -1,6 +1,6 @@ import { useEffect } from 'react'; -import { useAuth } from '../contexts/AuthContext'; -import { useLocale } from '../contexts/LocaleContext'; +import { useAuth } from '../contexts/useAuth.js'; +import { useLocale } from '../contexts/useLocale.js'; import api from '../services/api'; // Bridges auth -> locale (plan 79 B1/B2). LocaleProvider sits above diff --git a/frontend/src/components/MetricsGraph.jsx b/frontend/src/components/MetricsGraph.jsx index 9da81e095..e5e45aa8b 100644 --- a/frontend/src/components/MetricsGraph.jsx +++ b/frontend/src/components/MetricsGraph.jsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useMemo } from 'react'; +import { useCallback, useState, useEffect, useMemo } from 'react'; import { XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Area, AreaChart @@ -6,6 +6,7 @@ import { import { Cpu, MemoryStick, HardDrive, TrendingUp } from 'lucide-react'; import api from '../services/api'; import { useTranslation } from 'react-i18next'; +import { Button as SharedButton } from '@/components/ui/button'; // Chart series colors — redesign "infra console" palette (see // docs/REDESIGN_MAP.md). Kept as hex (not CSS var()) on purpose: var() does not @@ -53,11 +54,7 @@ const MetricsGraph = ({ compact = false, timezone, serverId }) => { })); }; - useEffect(() => { - loadHistory(); - }, [period, serverId]); - - async function loadHistory() { + const loadHistory = useCallback(async () => { try { setLoading(true); const response = serverId @@ -70,26 +67,33 @@ const MetricsGraph = ({ compact = false, timezone, serverId }) => { } finally { setLoading(false); } - } + }, [period, serverId]); - function formatTimestamp(isoString) { - const date = new Date(isoString); - const tz = safeTimeZone(timezone); - if (period === '1h' || period === '6h' || period === '24h') { - return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', timeZone: tz }); - } else if (period === '7d') { - return date.toLocaleDateString([], { weekday: 'short', hour: '2-digit', timeZone: tz }); - } else { - return date.toLocaleDateString([], { month: 'short', day: 'numeric', timeZone: tz }); + useEffect(() => { + loadHistory(); + }, [loadHistory]); + + + const chartData = useMemo(() => { + function formatTimestamp(isoString) { + const date = new Date(isoString); + const tz = safeTimeZone(timezone); + if (period === '1h' || period === '6h' || period === '24h') { + return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', timeZone: tz }); + } else if (period === '7d') { + return date.toLocaleDateString([], { weekday: 'short', hour: '2-digit', timeZone: tz }); + } else { + return date.toLocaleDateString([], { month: 'short', day: 'numeric', timeZone: tz }); + } } - } - const chartData = data?.data?.map(point => ({ - time: formatTimestamp(point.timestamp), - cpu: point.cpu?.percent ?? point.cpu_percent ?? 0, - memory: point.memory?.percent ?? point.memory_percent ?? 0, - disk: point.disk?.percent ?? point.disk_percent ?? 0 - })) || []; + return data?.data?.map(point => ({ + time: formatTimestamp(point.timestamp), + cpu: point.cpu?.percent ?? point.cpu_percent ?? 0, + memory: point.memory?.percent ?? point.memory_percent ?? 0, + disk: point.disk?.percent ?? point.disk_percent ?? 0 + })) || []; + }, [data, timezone, period]); // Auto-zoom: compute Y-axis ceiling from visible metrics const yDomain = useMemo(() => { @@ -171,13 +175,13 @@ const MetricsGraph = ({ compact = false, timezone, serverId }) => {
{periods.map(p => ( - + ))}
@@ -229,34 +233,34 @@ const MetricsGraph = ({ compact = false, timezone, serverId }) => { {t('app.metricsGraph.realTimePerformance', 'Real-time Performance')}
- - - +
{periods.map(p => ( - + ))}
diff --git a/frontend/src/components/MobileTopBar.jsx b/frontend/src/components/MobileTopBar.jsx index 761ec5754..a27f344c5 100644 --- a/frontend/src/components/MobileTopBar.jsx +++ b/frontend/src/components/MobileTopBar.jsx @@ -1,9 +1,10 @@ import { Menu, X } from 'lucide-react'; import { t } from '../i18n/t'; -import { useTheme } from '../contexts/ThemeContext'; +import { useTheme } from '../contexts/useTheme.js'; import ServerKitLogo from './ServerKitLogo'; import NotificationBell from './NotificationBell'; import QuickCreate from './QuickCreate'; +import { Button as SharedButton } from '@/components/ui/button'; // Fixed header shown only on narrow viewports (< 768px). Houses the // hamburger toggle that opens the sidebar as an off-canvas drawer, since @@ -16,7 +17,7 @@ const MobileTopBar = ({ navOpen, onToggle }) => { return (
- +
{!branded && ( diff --git a/frontend/src/components/NotificationBell.jsx b/frontend/src/components/NotificationBell.jsx index 489ae45c4..d98f3f48d 100644 --- a/frontend/src/components/NotificationBell.jsx +++ b/frontend/src/components/NotificationBell.jsx @@ -1,9 +1,10 @@ import { useState, useRef, useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; import { Bell, Check, X } from 'lucide-react'; -import { useNotifications } from '../contexts/NotificationsContext'; +import { useNotifications } from '../contexts/useNotifications.js'; import { timeAgo } from '../utils/time'; import { useTranslation } from 'react-i18next'; +import { Button as SharedButton } from '@/components/ui/button'; // Severity → dot color, mirroring the email/brand palette. const SEVERITY_DOT = { @@ -65,7 +66,7 @@ export default function NotificationBell() { return (
- + {open && (
{t('notifications.heading', 'Notifications')} {unreadCount > 0 && ( - + )}
@@ -97,7 +98,7 @@ export default function NotificationBell() { items.map((item) => ( item.kind === 'notice' ? (
- - +
) : ( - + ) )) )}
- +
)}
diff --git a/frontend/src/components/OperationsDock.jsx b/frontend/src/components/OperationsDock.jsx index 85ebf3a53..0e368bb41 100644 --- a/frontend/src/components/OperationsDock.jsx +++ b/frontend/src/components/OperationsDock.jsx @@ -17,8 +17,8 @@ import { useTranslation } from 'react-i18next'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { useOperations } from '../contexts/OperationsContext'; -import { useShellDock } from '../contexts/ShellDockContext'; -import { useToast } from '../contexts/ToastContext'; +import { useShellDock } from '../contexts/useShellDock.js'; +import { useToast } from '../contexts/useToast.js'; import { useConfirm } from '../hooks/useConfirm'; import { usePolling } from '../hooks/usePolling'; import { useShortcut } from '../hooks/useShortcut'; diff --git a/frontend/src/components/PrivateURLSection.jsx b/frontend/src/components/PrivateURLSection.jsx index d8d56f08c..5bd4c0e7d 100644 --- a/frontend/src/components/PrivateURLSection.jsx +++ b/frontend/src/components/PrivateURLSection.jsx @@ -1,9 +1,10 @@ import { useState } from 'react'; import api from '../services/api'; -import { useToast } from '../contexts/ToastContext'; +import { useToast } from '../contexts/useToast.js'; import { useConfirm } from '../hooks/useConfirm'; import { useClipboard } from '../hooks/useClipboard'; import { useTranslation } from 'react-i18next'; +import { Button as SharedButton } from '@/components/ui/button'; const PrivateURLSection = ({ app, onUpdate }) => { const { t } = useTranslation(); @@ -128,13 +129,13 @@ const PrivateURLSection = ({ app, onUpdate }) => { maxLength={50} />
- +

{t('app.privateURLSection.leaveEmptyToAutoGenerateA', 'Leave empty to auto-generate a random slug, or enter your own custom slug.')} @@ -148,7 +149,7 @@ const PrivateURLSection = ({ app, onUpdate }) => { {privateUrl}

- - +
@@ -190,14 +191,14 @@ const PrivateURLSection = ({ app, onUpdate }) => { autoFocus /> - - + ) : ( - + )}
- +
)} diff --git a/frontend/src/components/ProcessTable.jsx b/frontend/src/components/ProcessTable.jsx index 80fec5238..41e575f39 100644 --- a/frontend/src/components/ProcessTable.jsx +++ b/frontend/src/components/ProcessTable.jsx @@ -1,3 +1,4 @@ +import { procUser } from './processData'; import { useCallback, useMemo, useState } from 'react'; import { processStateVariant } from '@/components/ds/status'; import { X, AlertTriangle } from 'lucide-react'; @@ -78,7 +79,6 @@ const PROCESS_VIEWS = [ // psutil reports the owner as `username`; the per-PID detail endpoint and the // style-guide fixtures call it `user`. Read both so the column is never blank // against a real payload. -export const procUser = (p) => p.user || p.username || ''; export function UsageCell({ percent = 0, variant = 'cpu', label }) { const clamped = Math.min(Number(percent) || 0, 100); diff --git a/frontend/src/components/QuickCreate.jsx b/frontend/src/components/QuickCreate.jsx index c15fc952f..f2ef548bc 100644 --- a/frontend/src/components/QuickCreate.jsx +++ b/frontend/src/components/QuickCreate.jsx @@ -1,6 +1,7 @@ import { Plus } from 'lucide-react'; import { cn } from '@/lib/utils'; import { useTranslation } from 'react-i18next'; +import { Button as SharedButton } from '@/components/ui/button'; // Global quick-create (the "+" button beside the brand). It used to carry its // own little dropdown; now it opens the command palette, whose empty state @@ -13,7 +14,7 @@ export function QuickCreate({ className, variant = 'icon' }) { // create button beside the ServerKit mark and GitHub star. const header = variant === 'header'; return ( - + ); } diff --git a/frontend/src/components/RemoteTerminal.jsx b/frontend/src/components/RemoteTerminal.jsx index 339535926..806c77dd8 100644 --- a/frontend/src/components/RemoteTerminal.jsx +++ b/frontend/src/components/RemoteTerminal.jsx @@ -6,6 +6,7 @@ import '@xterm/xterm/css/xterm.css'; import api from '../services/api'; import socketService from '../services/socket'; import { useTranslation } from 'react-i18next'; +import { Button as SharedButton } from '@/components/ui/button'; /** * RemoteTerminal - Interactive terminal component for remote server access @@ -261,7 +262,7 @@ export default function RemoteTerminal({ serverId, onClose }) { {sessionId && {sessionId}}
- +
{presets.map((p) => ( - + ))}
)} @@ -332,14 +333,14 @@ export default function SchedulePicker({ value = '', onChange, compact = false,
{WEEKDAYS.map((name, d) => ( - + ))}
diff --git a/frontend/src/components/ScheduledTasksCard.jsx b/frontend/src/components/ScheduledTasksCard.jsx index feebdf050..06294019f 100644 --- a/frontend/src/components/ScheduledTasksCard.jsx +++ b/frontend/src/components/ScheduledTasksCard.jsx @@ -3,6 +3,7 @@ import { Clock, CheckCircle2, AlertCircle, History } from 'lucide-react'; import api from '../services/api'; import { Pill, Drawer } from '@/components/ds'; import { useTranslation } from 'react-i18next'; +import { Button as SharedButton } from '@/components/ui/button'; // Read-only summary of an app's scheduled (cron) jobs. Rendered on the service // detail and WordPress detail Overview tabs. The backend endpoint is @@ -74,14 +75,14 @@ const ScheduledTasksCard = ({ appId }) => {
{formatWhen(job.next_run)} - + {job.enabled ? 'Enabled' : 'Disabled'} diff --git a/frontend/src/components/ShellDockTabs.jsx b/frontend/src/components/ShellDockTabs.jsx index 564abb159..817271ce1 100644 --- a/frontend/src/components/ShellDockTabs.jsx +++ b/frontend/src/components/ShellDockTabs.jsx @@ -1,11 +1,12 @@ import { Maximize2, Minimize2, X } from 'lucide-react'; import { useTranslation } from 'react-i18next'; -import { useNotifications } from '../contexts/NotificationsContext'; +import { useNotifications } from '../contexts/useNotifications.js'; import { useOperations } from '../contexts/OperationsContext'; -import { useServerkitAI } from '../contexts/AIContext'; -import { useShellDock } from '../contexts/ShellDockContext'; +import { useServerkitAI } from '../contexts/useServerkitAI.js'; +import { useShellDock } from '../contexts/useShellDock.js'; import { useWalkthroughs } from '../contexts/walkthroughContextValue'; +import { Button as SharedButton } from '@/components/ui/button'; // Shared header strip for every shell console panel. Wherever a panel opens // (Operations, Alerts, Recipes bottom console; Assistant right drawer), the @@ -38,7 +39,7 @@ export default function ShellDockTabs({ controls = null, expandable = true }) { return (
{tabs.map((tab) => ( - + ))} {controls} {expandable && ( - + )} - +
); } diff --git a/frontend/src/components/Sidebar.jsx b/frontend/src/components/Sidebar.jsx index c2443de49..26b126941 100644 --- a/frontend/src/components/Sidebar.jsx +++ b/frontend/src/components/Sidebar.jsx @@ -1,9 +1,9 @@ import React, { useState, useEffect, useRef, useMemo } from 'react'; import { NavLink, useNavigate, useLocation } from 'react-router-dom'; -import { useAuth } from '../contexts/AuthContext'; -import { useTheme } from '../contexts/ThemeContext'; -import { useLayout } from '../contexts/LayoutContext'; -import { Star, Settings, LogOut, Sun, Moon, Monitor, ChevronRight, ChevronDown, ChevronUp, Layers, Palette, PanelLeft, PanelLeftClose, PanelTop, Check, X, Server } from 'lucide-react'; +import { useAuth } from '../contexts/useAuth.js'; +import { useTheme } from '../contexts/useTheme.js'; +import { useLayout } from '../contexts/useLayout.js'; +import { Star, Settings, LogOut, Sun, Moon, Monitor, ChevronRight, ChevronUp, Layers, Palette, PanelLeft, PanelLeftClose, PanelTop, Check, X, Server } from 'lucide-react'; import { api } from '../services/api'; import { SIDEBAR_CATEGORIES, SIDEBAR_CATEGORY_LABELS, SIDEBAR_PRESETS, getHiddenItemIds, getVisibleItems, applyWorkspaceNavPermissions } from './sidebarItems'; import { useTranslation } from 'react-i18next'; @@ -13,13 +13,14 @@ import { sanitizeSvgInner } from '../utils/sanitizeSvg'; import useModules from '../hooks/useModules'; import useDevMode from '../hooks/useDevMode'; import QuickCreate from './QuickCreate'; -import { useWorkspace } from '../contexts/WorkspaceContext'; +import { useWorkspace } from '../contexts/useWorkspace.js'; +import { Button as SharedButton } from '@/components/ui/button'; const Sidebar = ({ mobileOpen = false, isMobile = false, onMobileClose = () => {} }) => { const { t } = useTranslation(); const label = useLabel(); const { user, logout, updateUser, hasPermission } = useAuth(); - const { theme, resolvedTheme, setTheme, whiteLabel } = useTheme(); + const { theme, setTheme, whiteLabel } = useTheme(); const { layout, setLayout } = useLayout(); const { activeWorkspace } = useWorkspace(); const navigate = useNavigate(); @@ -209,7 +210,7 @@ const Sidebar = ({ mobileOpen = false, isMobile = false, onMobileClose = () => { // and it defines a nav map. This lets a workspace restrict which sidebar // items its members see based on their effective workspace role. return applyWorkspaceNavPermissions(items, activeWorkspace, user); - }, [user?.sidebar_config, pluginNav, pluginTabs, wpInstalled, gpuAvailable, wordpressEnabled, devMode, user, hasPermission, activeWorkspace]); + }, [pluginNav, pluginTabs, wpInstalled, gpuAvailable, wordpressEnabled, devMode, user, hasPermission, activeWorkspace]); // Group visible items by category const groupedItems = useMemo(() => { @@ -269,7 +270,7 @@ const Sidebar = ({ mobileOpen = false, isMobile = false, onMobileClose = () => { {label(item)} {visibleSubs.length > 0 && ( - + )}
{isExpanded && visibleSubs.map(sub => ( @@ -307,14 +308,14 @@ const Sidebar = ({ mobileOpen = false, isMobile = false, onMobileClose = () => { aria-label={t('nav.mainNavigation', 'Main navigation')} > {isMobile && ( - + )} {whiteLabel.enabled ? (
@@ -442,7 +443,7 @@ const Sidebar = ({ mobileOpen = false, isMobile = false, onMobileClose = () => {
{t('nav.theme', 'Theme')}
- - - +
{t('nav.layout', 'Layout')}
- - - +
{Object.entries(SIDEBAR_PRESETS).map(([key, preset]) => ( - + ))}
- - - +
- +
)}
- +
diff --git a/frontend/src/components/SystemNotices.jsx b/frontend/src/components/SystemNotices.jsx index 12c697768..b037dde26 100644 --- a/frontend/src/components/SystemNotices.jsx +++ b/frontend/src/components/SystemNotices.jsx @@ -82,14 +82,14 @@ export default function SystemNotices() { )} - +
diff --git a/frontend/src/components/ThemeSync.jsx b/frontend/src/components/ThemeSync.jsx index 19113eebc..12fd8c3d5 100644 --- a/frontend/src/components/ThemeSync.jsx +++ b/frontend/src/components/ThemeSync.jsx @@ -1,6 +1,6 @@ import { useEffect } from 'react'; -import { useAuth } from '../contexts/AuthContext'; -import { useTheme } from '../contexts/ThemeContext'; +import { useAuth } from '../contexts/useAuth.js'; +import { useTheme } from '../contexts/useTheme.js'; import api from '../services/api'; // Bridges auth → theme (plan 60). ThemeProvider sits above AuthProvider, so it diff --git a/frontend/src/components/WalkthroughHub.jsx b/frontend/src/components/WalkthroughHub.jsx index 10090f69c..eb7d6c10d 100644 --- a/frontend/src/components/WalkthroughHub.jsx +++ b/frontend/src/components/WalkthroughHub.jsx @@ -17,7 +17,7 @@ import { useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { Button } from '@/components/ui/button'; -import { useShellDock } from '../contexts/ShellDockContext'; +import { useShellDock } from '../contexts/useShellDock.js'; import { useWalkthroughs } from '../contexts/walkthroughContextValue'; import { getWalkthroughProgress } from '../services/walkthroughState'; import Pill from './ds/Pill'; @@ -59,7 +59,7 @@ function RecipeLibrary({ walkthroughs, state, onStart, onStop, t }) { const completed = status === 'completed'; return (
- +
); })} {secondary.length > 0 && ( - + )} ); @@ -142,7 +142,7 @@ export default function WalkthroughHub() { setBrowse(false); }, [activeWalkthrough?.id]); - const steps = activeWalkthrough?.steps || []; + const steps = useMemo(() => activeWalkthrough?.steps || [], [activeWalkthrough?.steps]); const viewStep = useMemo(() => { const chosen = steps.find((step) => step.id === viewStepId); return chosen || currentStep || steps[steps.length - 1] || null; @@ -221,7 +221,7 @@ export default function WalkthroughHub() { const done = completedSteps.includes(step.id); const viewing = viewStep && step.id === viewStep.id; return ( - + ); })} diff --git a/frontend/src/components/WorkspaceSwitcher.jsx b/frontend/src/components/WorkspaceSwitcher.jsx index 86a410ee5..0d4e50dd5 100644 --- a/frontend/src/components/WorkspaceSwitcher.jsx +++ b/frontend/src/components/WorkspaceSwitcher.jsx @@ -1,7 +1,7 @@ import { useEffect, useState } from 'react'; import { Building2 } from 'lucide-react'; import { api } from '../services/api'; -import { useWorkspace } from '../contexts/WorkspaceContext'; +import { useWorkspace } from '../contexts/useWorkspace.js'; import { useTranslation } from 'react-i18next'; import { Select, diff --git a/frontend/src/components/ai/AIAssistant.jsx b/frontend/src/components/ai/AIAssistant.jsx index 0bd65b4e6..7b788c2ba 100644 --- a/frontend/src/components/ai/AIAssistant.jsx +++ b/frontend/src/components/ai/AIAssistant.jsx @@ -1,6 +1,6 @@ import { useEffect } from 'react'; -import { useAuth } from '../../contexts/AuthContext'; -import { useServerkitAI } from '../../contexts/AIContext'; +import { useAuth } from '../../contexts/useAuth.js'; +import { useServerkitAI } from '../../contexts/useServerkitAI.js'; import ChatBubble from './ChatBubble'; import ChatDrawer from './ChatDrawer'; diff --git a/frontend/src/components/ai/ChatBubble.jsx b/frontend/src/components/ai/ChatBubble.jsx index 1a9bec72a..3b643d985 100644 --- a/frontend/src/components/ai/ChatBubble.jsx +++ b/frontend/src/components/ai/ChatBubble.jsx @@ -1,12 +1,13 @@ import { MessageSquare, X } from 'lucide-react'; import { useTranslation } from 'react-i18next'; +import { Button as SharedButton } from '@/components/ui/button'; // Intercom-style launcher. `raised` lifts it above the serverkit-gui FAB on // server-detail routes so the two don't overlap. const ChatBubble = ({ open, unread, streaming, raised, onToggle }) => { const { t } = useTranslation(); return ( - + ); }; diff --git a/frontend/src/components/ai/ChatDrawer.jsx b/frontend/src/components/ai/ChatDrawer.jsx index 96990984c..1973074b4 100644 --- a/frontend/src/components/ai/ChatDrawer.jsx +++ b/frontend/src/components/ai/ChatDrawer.jsx @@ -1,5 +1,5 @@ import { useCallback, useRef, useState } from 'react'; -import { useServerkitAI } from '../../contexts/AIContext'; +import { useServerkitAI } from '../../contexts/useServerkitAI.js'; import useMediaQuery from '../../hooks/useMediaQuery'; import { useLockBodyScroll } from '../../hooks/useLockBodyScroll'; import useFocusTrap from '../../hooks/ai/useFocusTrap'; diff --git a/frontend/src/components/ai/Composer.jsx b/frontend/src/components/ai/Composer.jsx index e64c4efef..a68d9e2f2 100644 --- a/frontend/src/components/ai/Composer.jsx +++ b/frontend/src/components/ai/Composer.jsx @@ -1,6 +1,6 @@ import { useRef, useState } from 'react'; import { Paperclip, Send, Square } from 'lucide-react'; -import { useServerkitAI } from '../../contexts/AIContext'; +import { useServerkitAI } from '../../contexts/useServerkitAI.js'; import { useTranslation } from 'react-i18next'; import ResourcePicker from '../ResourcePicker'; import { Button } from '../ui/button'; diff --git a/frontend/src/components/ai/ConfirmActionCard.jsx b/frontend/src/components/ai/ConfirmActionCard.jsx index 8b39ecefe..a133b8462 100644 --- a/frontend/src/components/ai/ConfirmActionCard.jsx +++ b/frontend/src/components/ai/ConfirmActionCard.jsx @@ -1,7 +1,8 @@ import { useEffect, useRef } from 'react'; import { ShieldAlert } from 'lucide-react'; -import { useServerkitAI } from '../../contexts/AIContext'; +import { useServerkitAI } from '../../contexts/useServerkitAI.js'; import { useTranslation } from 'react-i18next'; +import { Button as SharedButton } from '@/components/ui/button'; const formatParams = (params) => { if (!params || !Object.keys(params).length) return null; @@ -33,21 +34,21 @@ const ConfirmActionCard = () => {
{params}
) : null}
- - +
); diff --git a/frontend/src/components/ai/ContextChip.jsx b/frontend/src/components/ai/ContextChip.jsx index 64297015d..e8393eca6 100644 --- a/frontend/src/components/ai/ContextChip.jsx +++ b/frontend/src/components/ai/ContextChip.jsx @@ -1,6 +1,7 @@ import { MapPin } from 'lucide-react'; -import { useServerkitAI } from '../../contexts/AIContext'; +import { useServerkitAI } from '../../contexts/useServerkitAI.js'; import { useTranslation } from 'react-i18next'; +import { Button as SharedButton } from '@/components/ui/button'; // Shows the page the assistant is aware of, and lets the user toggle whether // that context is attached to the next message. Assistant mode only. @@ -10,7 +11,7 @@ const ContextChip = () => { if (mode !== 'assistant') return null; return ( - + ); }; diff --git a/frontend/src/components/ai/ConversationMenu.jsx b/frontend/src/components/ai/ConversationMenu.jsx index 606196640..2d16a3089 100644 --- a/frontend/src/components/ai/ConversationMenu.jsx +++ b/frontend/src/components/ai/ConversationMenu.jsx @@ -1,7 +1,8 @@ import { useEffect, useRef, useState } from 'react'; import { History, Plus, Trash2 } from 'lucide-react'; -import { useServerkitAI } from '../../contexts/AIContext'; +import { useServerkitAI } from '../../contexts/useServerkitAI.js'; import { useTranslation } from 'react-i18next'; +import { Button as SharedButton } from '@/components/ui/button'; const ConversationMenu = () => { const { t } = useTranslation(); @@ -23,7 +24,7 @@ const ConversationMenu = () => { return (
- + {open ? (
- +
{conversations.length === 0 ? (
{t('app.conversationMenu.noPastConversations', 'No past conversations')}
@@ -49,22 +50,22 @@ const ConversationMenu = () => { key={c.id} className={`sk-ai-convo__item${c.id === activeId ? ' is-active' : ''}`} > - - +
))}
diff --git a/frontend/src/components/ai/DrawerHeader.jsx b/frontend/src/components/ai/DrawerHeader.jsx index 099022141..bdba0de61 100644 --- a/frontend/src/components/ai/DrawerHeader.jsx +++ b/frontend/src/components/ai/DrawerHeader.jsx @@ -1,10 +1,11 @@ import { useNavigate } from 'react-router-dom'; import { Settings } from 'lucide-react'; -import { useAuth } from '../../contexts/AuthContext'; -import { useServerkitAI } from '../../contexts/AIContext'; +import { useAuth } from '../../contexts/useAuth.js'; +import { useServerkitAI } from '../../contexts/useServerkitAI.js'; import ModeToggle from './ModeToggle'; import ConversationMenu from './ConversationMenu'; import { useTranslation } from 'react-i18next'; +import { Button as SharedButton } from '@/components/ui/button'; // Slim toolbar under the shared console tabs. Close lives in the tab strip // above, so this row carries only the assistant-specific controls. @@ -23,14 +24,14 @@ const DrawerHeader = () => { {isAdmin ? ( - + ) : null}
diff --git a/frontend/src/components/ai/MessageList.jsx b/frontend/src/components/ai/MessageList.jsx index 4e030eacb..28ffc866a 100644 --- a/frontend/src/components/ai/MessageList.jsx +++ b/frontend/src/components/ai/MessageList.jsx @@ -1,11 +1,12 @@ import { ArrowDown, Sparkles } from 'lucide-react'; -import { useServerkitAI } from '../../contexts/AIContext'; +import { useServerkitAI } from '../../contexts/useServerkitAI.js'; import { useContributions } from '../../plugins/contributions'; import useAutoScroll from '../../hooks/ai/useAutoScroll'; import Message from './Message'; import TypingIndicator from './TypingIndicator'; import ConfirmActionCard from './ConfirmActionCard'; import { useTranslation } from 'react-i18next'; +import { Button as SharedButton } from '@/components/ui/button'; const routeMatches = (pattern, route) => { if (!pattern || pattern === '*') return true; @@ -51,14 +52,14 @@ const MessageList = () => { ) : (
{suggestions.map((p) => ( - + ))}
)} @@ -72,9 +73,9 @@ const MessageList = () => { )} {!isPinned && !isEmpty ? ( - + ) : null} ); diff --git a/frontend/src/components/ai/ModeToggle.jsx b/frontend/src/components/ai/ModeToggle.jsx index 4b9df0131..108c3f5ea 100644 --- a/frontend/src/components/ai/ModeToggle.jsx +++ b/frontend/src/components/ai/ModeToggle.jsx @@ -1,5 +1,6 @@ -import { useServerkitAI } from '../../contexts/AIContext'; +import { useServerkitAI } from '../../contexts/useServerkitAI.js'; import { useTranslation } from 'react-i18next'; +import { Button as SharedButton } from '@/components/ui/button'; const MODES = [ { id: 'assistant', labelKey: 'app.modeToggle.assistant', label: 'Assistant' }, @@ -13,7 +14,7 @@ const ModeToggle = () => { return (
{MODES.map((m) => ( - + ))}
); diff --git a/frontend/src/components/ai/ToolCallCard.jsx b/frontend/src/components/ai/ToolCallCard.jsx index a00a31160..3cbd906f4 100644 --- a/frontend/src/components/ai/ToolCallCard.jsx +++ b/frontend/src/components/ai/ToolCallCard.jsx @@ -1,7 +1,8 @@ import { useState } from 'react'; import { Wrench, Check, AlertTriangle, Loader2, ChevronDown } from 'lucide-react'; -import { useServerkitAI } from '../../contexts/AIContext'; +import { useServerkitAI } from '../../contexts/useServerkitAI.js'; import { useTranslation } from 'react-i18next'; +import { Button as SharedButton } from '@/components/ui/button'; // Strip the "__" namespace for display (e.g. core__list_apps -> list_apps). const displayName = (qualified) => { @@ -34,7 +35,7 @@ const ToolCallCard = ({ call }) => { return (
- + {expanded && (
{Custom ? ( diff --git a/frontend/src/components/appdetail/BuildTab.jsx b/frontend/src/components/appdetail/BuildTab.jsx index e7599365c..366983df9 100644 --- a/frontend/src/components/appdetail/BuildTab.jsx +++ b/frontend/src/components/appdetail/BuildTab.jsx @@ -1,8 +1,8 @@ -import { useState, useEffect } from 'react'; +import { useCallback, useState, useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; import api from '../../services/api'; import EmptyState from '../EmptyState'; -import { useToast } from '../../contexts/ToastContext'; +import { useToast } from '../../contexts/useToast.js'; import { useConfirm } from '../../hooks/useConfirm'; import { InfoList, InfoItem } from '../InfoList'; import BuildpackPreview from '../buildpack/BuildpackPreview'; @@ -11,23 +11,21 @@ import { Input } from '@/components/ui/input'; import { Pill, statusKind } from '@/components/ds'; import Modal from '@/components/Modal'; import { useTranslation } from 'react-i18next'; +import { Card as SharedCard } from '@/components/ui/card'; +import { CardHeader as SharedCardHeader, CardFooter as SharedCardFooter } from '@/components/ui/card'; -const BuildTab = ({ appId, appPath, app }) => { +const BuildTab = ({ appId, app }) => { const { t } = useTranslation(); const toast = useToast(); const { confirm: confirmBuild } = useConfirm(); const [buildConfig, setBuildConfig] = useState(null); const [detection, setDetection] = useState(null); const [deployments, setDeployments] = useState([]); - const [currentDeployment, setCurrentDeployment] = useState(null); const [loading, setLoading] = useState(true); const [building, setBuilding] = useState(false); const [deploying, setDeploying] = useState(false); const navigate = useNavigate(); const [showConfigModal, setShowConfigModal] = useState(false); - const [showLogsModal, setShowLogsModal] = useState(false); - const [selectedLog, setSelectedLog] = useState(null); - const [buildLogs, setBuildLogs] = useState([]); const [error, setError] = useState(null); const [bpDockerfile, setBpDockerfile] = useState(null); @@ -53,11 +51,7 @@ const BuildTab = ({ appId, appPath, app }) => { keepDeployments: 5 }); - useEffect(() => { - loadData(); - }, [appId]); - - async function loadData() { + const loadData = useCallback(async () => { try { setLoading(true); const [configRes, detectRes, deploymentsRes] = await Promise.all([ @@ -82,14 +76,18 @@ const BuildTab = ({ appId, appPath, app }) => { } setDeployments(deploymentsRes.deployments || []); - setCurrentDeployment(deploymentsRes.current); } catch (err) { setError(err.message); } finally { setLoading(false); } - } + }, [appId]); + + useEffect(() => { + loadData(); + }, [loadData]); + async function handleConfigureBuild(e) { e.preventDefault(); @@ -191,12 +189,12 @@ const BuildTab = ({ appId, appPath, app }) => { {error && (
{error} - +
)} {detection && ( -
+

{t('app.buildTab.autoDetectionResults', 'Auto-Detection Results')}

@@ -216,27 +214,27 @@ const BuildTab = ({ appId, appPath, app }) => {
)}
-
+ )} {app?.buildpack_plan && ( -
+

{t('app.buildTab.buildPack', 'Build Pack')}

-
+ )} -
-
+ +

{t('app.buildTab.buildConfiguration', 'Build Configuration')}

-
+ {buildConfig ? ( @@ -245,7 +243,7 @@ const BuildTab = ({ appId, appPath, app }) => { ) : (

{t('app.buildTab.noBuildConfigurationClickConfigureTo', 'No build configuration. Click Configure to set up.')}

)} -
+ -
-
+ + {deployments.length > 0 && ( -
+

{t('app.buildTab.deploymentHistory', 'Deployment History')}

{deployments.map(dep => ( @@ -289,7 +287,7 @@ const BuildTab = ({ appId, appPath, app }) => {
))}
-
+ )} setShowConfigModal(false)} title={t('app.buildTab.buildConfiguration', 'Build Configuration')}> diff --git a/frontend/src/components/appdetail/CommandsTab.jsx b/frontend/src/components/appdetail/CommandsTab.jsx deleted file mode 100644 index 8452b4c3c..000000000 --- a/frontend/src/components/appdetail/CommandsTab.jsx +++ /dev/null @@ -1,91 +0,0 @@ -import { useState } from 'react'; -import api from '../../services/api'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { useTranslation } from 'react-i18next'; - -const CommandsTab = ({ appId, appType }) => { - const { t } = useTranslation(); - const [command, setCommand] = useState(''); - const [output, setOutput] = useState(null); - const [running, setRunning] = useState(false); - - const quickCommands = appType === 'django' ? [ - { labelKey: 'app.commandsTab.runMigrations', label: 'Run Migrations', cmd: 'python manage.py migrate' }, - { labelKey: 'app.commandsTab.collectStatic', label: 'Collect Static', cmd: 'python manage.py collectstatic --noinput' }, - { labelKey: 'app.commandsTab.createSuperuser', label: 'Create Superuser', cmd: 'python manage.py createsuperuser' }, - { labelKey: 'app.commandsTab.shell', label: 'Shell', cmd: 'python manage.py shell' }, - { labelKey: 'app.commandsTab.check', label: 'Check', cmd: 'python manage.py check' }, - ] : [ - { labelKey: 'app.commandsTab.flaskRoutes', label: 'Flask Routes', cmd: 'flask routes' }, - { labelKey: 'app.commandsTab.flaskShell', label: 'Flask Shell', cmd: 'flask shell' }, - { labelKey: 'app.commandsTab.dbUpgrade', label: 'DB Upgrade', cmd: 'flask db upgrade' }, - { labelKey: 'app.commandsTab.dbMigrate', label: 'DB Migrate', cmd: 'flask db migrate' }, - ]; - - async function handleRun(cmd) { - const commandToRun = cmd || command; - if (!commandToRun.trim()) return; - - setRunning(true); - setOutput(null); - - try { - const result = await api.runPythonCommand(appId, commandToRun); - setOutput(result); - } catch (err) { - setOutput({ success: false, stderr: err.message }); - } finally { - setRunning(false); - } - } - - return ( -
-

{t('app.commandsTab.runCommands', 'Run Commands')}

-

{t('app.commandsTab.commandsRunInTheAppS', 'Commands run in the app\'s virtual environment context.')}

- -
- {quickCommands.map(({ label, cmd }) => ( - - ))} -
- -
- setCommand(e.target.value)} - placeholder={t('app.commandsTab.enterCommand', 'Enter command…')} - onKeyDown={(e) => e.key === 'Enter' && handleRun()} - /> - -
- - {output && ( -
- {output.stdout &&
{output.stdout}
} - {output.stderr &&
{output.stderr}
} - {!output.stdout && !output.stderr && ( -
{output.success ? 'Command completed successfully' : 'Command failed'}
- )} -
- )} -
- ); -}; - -export default CommandsTab; diff --git a/frontend/src/components/appdetail/DeployTab.jsx b/frontend/src/components/appdetail/DeployTab.jsx index 082bb65d2..67c658b62 100644 --- a/frontend/src/components/appdetail/DeployTab.jsx +++ b/frontend/src/components/appdetail/DeployTab.jsx @@ -1,8 +1,8 @@ -import { useState, useEffect } from 'react'; +import { useCallback, useState, useEffect } from 'react'; import { GitMerge } from 'lucide-react'; import api from '../../services/api'; import EmptyState from '../EmptyState'; -import { useToast } from '../../contexts/ToastContext'; +import { useToast } from '../../contexts/useToast.js'; import { useConfirm } from '../../hooks/useConfirm'; import { InfoList, InfoItem } from '../InfoList'; import DeploymentTimeline from '../deployments/DeploymentTimeline'; @@ -12,24 +12,23 @@ import { Textarea } from '@/components/ui/textarea'; import { Pill, statusKind } from '@/components/ds'; import Modal from '@/components/Modal'; import { useTranslation } from 'react-i18next'; +import { Card as SharedCard } from '@/components/ui/card'; +import { CardFooter as SharedCardFooter } from '@/components/ui/card'; // `embedded` renders this inside the Settings → Git & Deploy section, where the // shared RepoConnectForm already owns the connect/disconnect + repo identity. In // that mode we drop the empty-state CTA and the repo-config fields (repo/branch/ // auto-deploy) and surface only the deploy pipeline: run actions, deploy scripts, // history and config checkpoints. -const DeployTab = ({ appId, appPath, embedded = false }) => { +const DeployTab = ({ appId, embedded = false }) => { const { t } = useTranslation(); const toast = useToast(); const { confirm: confirmDeploy } = useConfirm(); const [config, setConfig] = useState(null); - const [gitStatus, setGitStatus] = useState(null); const [history, setHistory] = useState([]); const [loading, setLoading] = useState(true); const [deploying, setDeploying] = useState(false); const [showConfigModal, setShowConfigModal] = useState(false); - const [loadingBranches, setLoadingBranches] = useState(false); - const [branches, setBranches] = useState([]); const [error, setError] = useState(null); const [configForm, setConfigForm] = useState({ @@ -40,11 +39,7 @@ const DeployTab = ({ appId, appPath, embedded = false }) => { postDeployScript: '' }); - useEffect(() => { - loadData(); - }, [appId]); - - async function loadData() { + const loadData = useCallback(async () => { try { setLoading(true); const [configRes, historyRes] = await Promise.all([ @@ -61,10 +56,6 @@ const DeployTab = ({ appId, appPath, embedded = false }) => { preDeployScript: configRes.config.pre_deploy_script || '', postDeployScript: configRes.config.post_deploy_script || '' }); - try { - const statusRes = await api.getAppGitStatus(appId); - setGitStatus(statusRes); - } catch { /* git status is optional context for the deploy tab */ } } else { setConfig(null); } @@ -75,7 +66,12 @@ const DeployTab = ({ appId, appPath, embedded = false }) => { } finally { setLoading(false); } - } + }, [appId]); + + useEffect(() => { + loadData(); + }, [loadData]); + async function handleConfigureDeployment(e) { e.preventDefault(); @@ -101,7 +97,6 @@ const DeployTab = ({ appId, appPath, embedded = false }) => { try { await api.removeDeployment(appId); setConfig(null); - setGitStatus(null); loadData(); } catch (err) { setError(err.message); @@ -153,7 +148,7 @@ const DeployTab = ({ appId, appPath, embedded = false }) => { {error && (
{error} - +
)} @@ -212,7 +207,7 @@ const DeployTab = ({ appId, appPath, embedded = false }) => {
-
+

{embedded ? 'Deploy Scripts' : 'Configuration'}

{embedded ? ( @@ -226,7 +221,7 @@ const DeployTab = ({ appId, appPath, embedded = false }) => { )} -
+ @@ -235,11 +230,11 @@ const DeployTab = ({ appId, appPath, embedded = false }) => { {t('common.actions.remove', 'Remove')} )} -
-
+ + {history.length > 0 && ( -
+

{t('app.deployTab.deploymentHistory', 'Deployment History')}

{history.slice(0, 5).map((dep, idx) => ( @@ -249,7 +244,7 @@ const DeployTab = ({ appId, appPath, embedded = false }) => {
))}
-
+ )} @@ -257,13 +252,13 @@ const DeployTab = ({ appId, appPath, embedded = false }) => { {/* Config snapshot timeline + diff — additive, independent of git config so it shows the deploy history & config changes for any app. */} -
+

{t('app.deployTab.configCheckpoints', 'Config Checkpoints')}

{t('app.deployTab.anImmutableConfigCheckpointEnvKeys', 'An immutable config checkpoint (env keys, domains, image, build method, volumes) is captured before each deployment. Secret values are masked. Open a checkpoint to diff it against the previous one or restore it.')}

-
+ setShowConfigModal(false)} title={embedded ? t('app.deployTab.editDeployScripts', 'Edit Deploy Scripts') : t('app.deployTab.configureDeployment', 'Configure Deployment')}>
diff --git a/frontend/src/components/appdetail/GunicornTab.jsx b/frontend/src/components/appdetail/GunicornTab.jsx deleted file mode 100644 index 9912fec01..000000000 --- a/frontend/src/components/appdetail/GunicornTab.jsx +++ /dev/null @@ -1,65 +0,0 @@ -import { useState, useEffect } from 'react'; -import api from '../../services/api'; -import EmptyState from '../EmptyState'; -import { useToast } from '../../contexts/ToastContext'; -import { Button } from '@/components/ui/button'; -import { useTranslation } from 'react-i18next'; - -const GunicornTab = ({ appId }) => { - const { t } = useTranslation(); - const toast = useToast(); - const [config, setConfig] = useState(''); - const [loading, setLoading] = useState(true); - const [saving, setSaving] = useState(false); - - useEffect(() => { - loadConfig(); - }, [appId]); - - async function loadConfig() { - try { - const data = await api.getGunicornConfig(appId); - setConfig(data.content || ''); - } catch (err) { - console.error('Failed to load config:', err); - } finally { - setLoading(false); - } - } - - async function handleSave() { - setSaving(true); - try { - await api.updateGunicornConfig(appId, config); - toast.success(t('app.gunicornTab.configurationSavedRestartTheAppTo', 'Configuration saved. Restart the app to apply changes.')); - } catch (err) { - toast.error(t('app.gunicornTab.failedToSaveConfiguration', 'Failed to save configuration')); - console.error('Failed to save config:', err); - } finally { - setSaving(false); - } - } - - if (loading) { - return ; - } - - return ( -
-
-

{t('app.gunicornTab.gunicornConfiguration', 'Gunicorn Configuration')}

- -
-