From af11e98765920630be5303d6dd8500702bd13fda Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 3 Sep 2026 04:59:59 +0000 Subject: [PATCH 01/20] chore(deps-dev): bump @humanfs/node from 0.16.7 to 0.16.8 in /frontend Bumps [@humanfs/node](https://github.com/humanwhocodes/humanfs/tree/HEAD/packages/node) from 0.16.7 to 0.16.8. - [Release notes](https://github.com/humanwhocodes/humanfs/releases) - [Changelog](https://github.com/humanwhocodes/humanfs/blob/main/packages/node/CHANGELOG.md) - [Commits](https://github.com/humanwhocodes/humanfs/commits/node-v0.16.8/packages/node) --- updated-dependencies: - dependency-name: "@humanfs/node" dependency-version: 0.16.8 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- frontend/package-lock.json | 40 ++++++++++++++++++++------------------ 1 file changed, 21 insertions(+), 19 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 8ff0909e..f1dd467f 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -325,29 +325,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 +4799,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", From 63db42ba21efc8a391ac19d0986e37268a884fd3 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 5 Sep 2026 05:11:13 +0000 Subject: [PATCH 02/20] chore: bump version to 1.9.26 [skip ci] --- VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION b/VERSION index 96839476..7efa6d6f 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.9.25 +1.9.26 From c8c93a8e0c7bd0e69bea05c84c49624e97b658f4 Mon Sep 17 00:00:00 2001 From: Juan Denis Date: Sat, 5 Sep 2026 04:29:16 -0400 Subject: [PATCH 03/20] fix(auth): enforce MFA, scoped keys, and revocable sessions Add session-family revocation and migration 097, require current credentials for password changes, constrain API keys, and require verified WebAuthn authentication. Update browser token handling and add boundary regressions. Existing JWTs require fresh login; restricted keys deny undeclared endpoints. --- backend/app/__init__.py | 42 +-- backend/app/api/auth.py | 76 ++++-- backend/app/api/sso.py | 10 +- backend/app/api/two_factor.py | 14 +- backend/app/middleware/api_key_auth.py | 9 + .../app/middleware/api_scope_middleware.py | 26 +- backend/app/middleware/session_auth.py | 61 +++++ backend/app/models/__init__.py | 1 + backend/app/models/revoked_session.py | 13 + backend/app/models/user.py | 19 ++ backend/app/services/passkey_service.py | 46 ++-- .../versions/097_user_auth_version.py | 33 +++ backend/tests/test_api_key_scope_boundary.py | 62 +++++ backend/tests/test_passkey_security.py | 134 ++++++++++ backend/tests/test_route_authz_sweep.py | 34 +++ backend/tests/test_session_security.py | 241 ++++++++++++++++++ docs/MIGRATION_INVENTORY.md | 2 +- .../settings/SecuritySettingsTab.jsx | 5 +- frontend/src/contexts/AuthContext.jsx | 11 +- frontend/src/pages/Login.jsx | 7 + .../src/services/api/__tests__/auth.test.mjs | 50 ++++ frontend/src/services/api/auth.js | 20 +- 22 files changed, 839 insertions(+), 77 deletions(-) create mode 100644 backend/app/middleware/session_auth.py create mode 100644 backend/app/models/revoked_session.py create mode 100644 backend/migrations/versions/097_user_auth_version.py create mode 100644 backend/tests/test_api_key_scope_boundary.py create mode 100644 backend/tests/test_passkey_security.py create mode 100644 backend/tests/test_session_security.py create mode 100644 frontend/src/services/api/__tests__/auth.test.mjs diff --git a/backend/app/__init__.py b/backend/app/__init__.py index eef51398..07cc6fa2 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 @@ -451,25 +471,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/auth.py b/backend/app/api/auth.py index b3120599..975c78c9 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/sso.py b/backend/app/api/sso.py index 85a99dfd..223a4463 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 062d0456..fbb81696 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 bd853806..754eee58 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 b43e3abf..e51cb352 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/session_auth.py b/backend/app/middleware/session_auth.py new file mode 100644 index 00000000..f806a5b2 --- /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 01bfcd3e..1fef9d75 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 00000000..3b0673bf --- /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 0f52271b..294adc0b 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/passkey_service.py b/backend/app/services/passkey_service.py index a30b838a..9bf7f0d2 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/migrations/versions/097_user_auth_version.py b/backend/migrations/versions/097_user_auth_version.py new file mode 100644 index 00000000..8cc5578b --- /dev/null +++ b/backend/migrations/versions/097_user_auth_version.py @@ -0,0 +1,33 @@ +"""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')) + 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/test_api_key_scope_boundary.py b/backend/tests/test_api_key_scope_boundary.py new file mode 100644 index 00000000..908b5f7f --- /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_passkey_security.py b/backend/tests/test_passkey_security.py new file mode 100644 index 00000000..6107b1f9 --- /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_route_authz_sweep.py b/backend/tests/test_route_authz_sweep.py index b4c4ad53..4265b385 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_session_security.py b/backend/tests/test_session_security.py new file mode 100644 index 00000000..e9e633c4 --- /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 create_access_token, decode_token + +from factories import make_user, headers_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 = create_access_token(identity=user.id, 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 = create_access_token(identity=user.id, 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/docs/MIGRATION_INVENTORY.md b/docs/MIGRATION_INVENTORY.md index e7989f1d..826a452c 100644 --- a/docs/MIGRATION_INVENTORY.md +++ b/docs/MIGRATION_INVENTORY.md @@ -16,7 +16,7 @@ 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 | 1144 | 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 | diff --git a/frontend/src/components/settings/SecuritySettingsTab.jsx b/frontend/src/components/settings/SecuritySettingsTab.jsx index 7c69991a..26837bbe 100644 --- a/frontend/src/components/settings/SecuritySettingsTab.jsx +++ b/frontend/src/components/settings/SecuritySettingsTab.jsx @@ -176,7 +176,10 @@ const SecuritySettingsTab = () => { setLoading(true); try { - await updateUser({ password: formData.newPassword }); + await updateUser({ + password: formData.newPassword, + current_password: formData.currentPassword, + }); setMessage({ type: 'success', text: 'Password changed successfully' }); setFormData({ currentPassword: '', newPassword: '', confirmPassword: '' }); } catch (err) { diff --git a/frontend/src/contexts/AuthContext.jsx b/frontend/src/contexts/AuthContext.jsx index 368865cd..b3777b41 100644 --- a/frontend/src/contexts/AuthContext.jsx +++ b/frontend/src/contexts/AuthContext.jsx @@ -144,9 +144,14 @@ export function AuthProvider({ children }) { })); } - function logout() { - api.logout(); - setUser(null); + async function logout() { + try { + await api.logout(); + } catch (err) { + console.error('Server sign-out failed:', err); + } finally { + setUser(null); + } } async function updateUser(data) { diff --git a/frontend/src/pages/Login.jsx b/frontend/src/pages/Login.jsx index e2697c27..6132abda 100644 --- a/frontend/src/pages/Login.jsx +++ b/frontend/src/pages/Login.jsx @@ -56,6 +56,13 @@ const Login = () => { setRedeemingLink(true); api.redeemLoginLink(token) .then((response) => { + if (response.requires_2fa) { + setRequires2FA(true); + setTempToken(response.temp_token); + setRedeemingLink(false); + navigate('/login', { replace: true }); + return; + } setUser(response.user); navigate(consumeRedirect(), { replace: true }); }) diff --git a/frontend/src/services/api/__tests__/auth.test.mjs b/frontend/src/services/api/__tests__/auth.test.mjs new file mode 100644 index 00000000..cb0eae4c --- /dev/null +++ b/frontend/src/services/api/__tests__/auth.test.mjs @@ -0,0 +1,50 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { login, logout, redeemLoginLink, updateCurrentUser } from '../auth.js'; + +test('MFA challenges do not persist incomplete session credentials', async () => { + const challenge = { requires_2fa: true, temp_token: 'pending' }; + const client = { + request: async () => challenge, + setTokens: () => assert.fail('MFA challenge must not install session tokens'), + }; + assert.equal(await login.call(client, 'user', 'password'), challenge); + assert.equal(await redeemLoginLink.call(client, 'link'), challenge); +}); + +test('password changes replace the revoked token pair before returning', async () => { + const replacement = { user: { id: 1 }, access_token: 'new-access', refresh_token: 'new-refresh' }; + const events = []; + const client = { + request: async (endpoint, options) => { + assert.equal(endpoint, '/auth/me'); + assert.equal(options.body.current_password, 'current'); + return replacement; + }, + setTokens: (...pair) => events.push(pair), + }; + assert.equal(await updateCurrentUser.call(client, { password: 'new-password', current_password: 'current' }), replacement); + assert.deepEqual(events, [['new-access', 'new-refresh']]); +}); + +test('logout submits server revocation before clearing local credentials', async () => { + const events = []; + await logout.call({ + request: async (endpoint, options) => { + assert.equal(endpoint, '/auth/logout'); + assert.equal(options.method, 'POST'); + events.push('revoke'); + }, + clearTokens: () => events.push('clear'), + }); + assert.deepEqual(events, ['revoke', 'clear']); +}); + +test('logout still clears credentials when server revocation fails', async () => { + let cleared = false; + await assert.rejects(logout.call({ + request: async () => { throw new Error('offline'); }, + clearTokens: () => { cleared = true; }, + }), /offline/); + assert.equal(cleared, true); +}); diff --git a/frontend/src/services/api/auth.js b/frontend/src/services/api/auth.js index c3796aa1..4e22005d 100644 --- a/frontend/src/services/api/auth.js +++ b/frontend/src/services/api/auth.js @@ -10,7 +10,9 @@ export async function login(email, password) { method: 'POST', body: { email, password }, }); - this.setTokens(data.access_token, data.refresh_token); + if (data.access_token) { + this.setTokens(data.access_token, data.refresh_token); + } return data; } @@ -37,7 +39,11 @@ export async function completeOnboarding(useCases, installedExtensions = [], sec } export async function logout() { - this.clearTokens(); + try { + await this.request('/auth/logout', { method: 'POST' }); + } finally { + this.clearTokens(); + } } export async function getCurrentUser() { @@ -45,10 +51,14 @@ export async function getCurrentUser() { } export async function updateCurrentUser(data) { - return this.request('/auth/me', { + const response = await this.request('/auth/me', { method: 'PUT', body: data }); + if (response.access_token) { + this.setTokens(response.access_token, response.refresh_token); + } + return response; } // One-time login links @@ -72,7 +82,9 @@ export async function redeemLoginLink(token) { method: 'POST', body: { token }, }); - this.setTokens(data.access_token, data.refresh_token); + if (data.access_token) { + this.setTokens(data.access_token, data.refresh_token); + } return data; } From 785c5780d3ff8793afd30944403010fd5007de3a Mon Sep 17 00:00:00 2001 From: Juan Denis Date: Sat, 5 Sep 2026 04:29:44 -0400 Subject: [PATCH 04/20] fix(sockets): authorize subscriptions and revalidate event delivery Require valid access-token sessions, scope application and server rooms, and enforce run visibility consistently across sockets and polling. Add positive-delivery and cross-user isolation regressions. --- backend/app/api/runs.py | 5 +- backend/app/services/run_access.py | 25 +++ backend/app/sockets.py | 222 ++++++++++++++++++------ backend/tests/test_channel_registry.py | 12 +- backend/tests/test_socket_security.py | 230 +++++++++++++++++++++++++ 5 files changed, 441 insertions(+), 53 deletions(-) create mode 100644 backend/app/services/run_access.py create mode 100644 backend/tests/test_socket_security.py diff --git a/backend/app/api/runs.py b/backend/app/api/runs.py index 6af9a45f..e53a9508 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/services/run_access.py b/backend/app/services/run_access.py new file mode 100644 index 00000000..92f02bfb --- /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/sockets.py b/backend/app/sockets.py index 8a068540..1e99ee95 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/tests/test_channel_registry.py b/backend/tests/test_channel_registry.py index e18b877e..77c9a3e4 100644 --- a/backend/tests/test_channel_registry.py +++ b/backend/tests/test_channel_registry.py @@ -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 create_access_token, decode_token + user = make_user(db, role=role) + sk.connected_clients[sid] = { + 'user_id': user.id, 'role': role, + 'claims': decode_token(create_access_token(identity=user.id)), + } 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_socket_security.py b/backend/tests/test_socket_security.py new file mode 100644 index 00000000..4788eb8f --- /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_access_token, 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 + + +@pytest.fixture +def clients(app): + opened = [] + + def connect(user=None, token=None): + client = sk.socketio.test_client(app, auth={ + 'token': token or create_access_token(identity=user.id), + }) + 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 = create_access_token(identity=user.id, additional_claims={'2fa_pending': True}) + elif kind == 'refresh': + token = create_refresh_token(identity=user.id) + else: + token = create_access_token(identity=user.id, 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 = create_access_token(identity=user.id) + 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' From 6bf34903459af7418122731bdb140e1fd4ce831b Mon Sep 17 00:00:00 2001 From: Juan Denis Date: Sat, 5 Sep 2026 04:29:45 -0400 Subject: [PATCH 05/20] fix(ai): enforce resource access and bounded protected chat Scope built-in tools to the live caller, recheck write authorization after confirmation, redact structured results, and fail closed when protections fail. Bound chat inputs, concurrent turns, and cancellation-aware streaming queues. --- backend/app/api/ai.py | 129 +++++-- backend/app/services/ai_service.py | 142 ++++++-- backend/app/services/ai_tool_registry.py | 7 + backend/app/services/ai_tools_builtin.py | 74 ++-- backend/tests/test_ai_security_boundaries.py | 335 +++++++++++++++++++ 5 files changed, 621 insertions(+), 66 deletions(-) create mode 100644 backend/tests/test_ai_security_boundaries.py diff --git a/backend/app/api/ai.py b/backend/app/api/ai.py index a99375bc..a15f51e0 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/services/ai_service.py b/backend/app/services/ai_service.py index c12f9075..b5881752 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 3ded15d5..71fa8597 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 e6f75f31..336138bf 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/tests/test_ai_security_boundaries.py b/backend/tests/test_ai_security_boundaries.py new file mode 100644 index 00000000..5911ab93 --- /dev/null +++ b/backend/tests/test_ai_security_boundaries.py @@ -0,0 +1,335 @@ +"""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 create_access_token, decode_token + from app.models import RevokedSession + with app.app_context(): + user = make_user(db, role='admin') + claims = decode_token(create_access_token(identity=user.id)) + 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.' From c62358de7329787c25b62724ee8437cfa44711c8 Mon Sep 17 00:00:00 2001 From: Juan Denis Date: Sat, 5 Sep 2026 04:30:05 -0400 Subject: [PATCH 06/20] fix(settings): restore table surfaces and improve access review Expose MFA and passkey enrollment, use shared confirmations and locale formatting, and guard pending user and invitation actions. Add Chromium regressions for both themes and failure states to frontend CI. --- .github/workflows/frontend-ci.yml | 13 ++ .gitignore | 3 + frontend/package-lock.json | 1 + frontend/package.json | 2 + frontend/scripts/settings-browser.mjs | 135 ++++++++++++++++++ .../components/settings/InvitationsTab.jsx | 58 +++++--- frontend/src/components/settings/UsersTab.jsx | 134 +++++++++++------ frontend/src/i18n/locales/ar.json | 2 - frontend/src/i18n/locales/bn.json | 2 - frontend/src/i18n/locales/de.json | 2 - frontend/src/i18n/locales/en.json | 21 ++- frontend/src/i18n/locales/es.json | 2 - frontend/src/i18n/locales/fr.json | 2 - frontend/src/i18n/locales/id.json | 2 - frontend/src/i18n/locales/it.json | 2 - frontend/src/i18n/locales/ko.json | 2 - frontend/src/i18n/locales/pl.json | 2 - frontend/src/i18n/locales/pt.json | 2 - frontend/src/i18n/locales/ru.json | 2 - frontend/src/i18n/locales/th.json | 2 - frontend/src/i18n/locales/tr.json | 2 - frontend/src/i18n/locales/vi.json | 2 - frontend/src/i18n/locales/zh-Hans.json | 2 - frontend/src/i18n/locales/zh-Hant.json | 2 - frontend/src/styles/components/_users.scss | 5 + frontend/tests/browser/settings.html | 5 + frontend/tests/browser/settings.jsx | 18 +++ 27 files changed, 332 insertions(+), 95 deletions(-) create mode 100644 frontend/scripts/settings-browser.mjs create mode 100644 frontend/tests/browser/settings.html create mode 100644 frontend/tests/browser/settings.jsx diff --git a/.github/workflows/frontend-ci.yml b/.github/workflows/frontend-ci.yml index 513c349f..7c92f965 100644 --- a/.github/workflows/frontend-ci.yml +++ b/.github/workflows/frontend-ci.yml @@ -63,3 +63,16 @@ jobs: # node --test over src/**/__tests__ — pure-logic modules, no jsdom. working-directory: frontend run: npm test + - name: Install browser for Settings regressions + working-directory: frontend + run: npx playwright install --with-deps chromium + - name: Settings browser regressions + working-directory: frontend + run: npm run test:browser + - 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/.gitignore b/.gitignore index aeefdab7..065d10bf 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/frontend/package-lock.json b/frontend/package-lock.json index f1dd467f..44461b32 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" }, diff --git a/frontend/package.json b/frontend/package.json index 360c790a..610f1f89 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -7,6 +7,7 @@ "dev": "vite", "build": "vite build", "test": "node --test", + "test:browser": "node scripts/settings-browser.mjs", "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", "lint:settings-index": "node scripts/check-settings-index.mjs", "lint:fonts": "node scripts/check-font-integrity.mjs", @@ -72,6 +73,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/scripts/settings-browser.mjs b/frontend/scripts/settings-browser.mjs new file mode 100644 index 00000000..7de36389 --- /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/components/settings/InvitationsTab.jsx b/frontend/src/components/settings/InvitationsTab.jsx index cf01d96b..8c237b78 100644 --- a/frontend/src/components/settings/InvitationsTab.jsx +++ b/frontend/src/components/settings/InvitationsTab.jsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useCallback, useRef } from 'react'; import api from '../../services/api'; import InviteModal from './InviteModal'; import { Button } from '@/components/ui/button'; @@ -14,6 +14,7 @@ import EmptyState from '../EmptyState'; import { copyToClipboard } from '@/utils/clipboard'; import { useTranslation } from 'react-i18next'; import useFocusParam from '@/hooks/useFocusParam'; +import useFormat from '@/hooks/useFormat'; // A pending invite whose window has closed still carries status 'pending' in // the database — the row only becomes 'expired' on screen. One accessor for @@ -72,6 +73,10 @@ const InvitationsTab = () => { const [loading, setLoading] = useState(true); const [showInviteModal, setShowInviteModal] = useState(false); const [copied, setCopied] = useState(null); + const [error, setError] = useState(''); + const [pendingId, setPendingId] = useState(null); + const actionInFlight = useRef(false); + const { formatDate: formatLocaleDate } = useFormat(); useFocusParam('create', (target) => { if (target === 'invitation') setShowInviteModal(true); @@ -87,36 +92,48 @@ const InvitationsTab = () => { storageKey: 'serverkit-table-settings-invitations-cols', }); - useEffect(() => { - loadInvitations(); - }, []); - - async function loadInvitations() { + const loadInvitations = useCallback(async () => { try { setLoading(true); const data = await api.getInvitations(); setInvitations(data.invitations || []); - } catch { - // Silently handle + setError(''); + } catch (err) { + setError(err.message || t('app.invitationsTab.loadFailed', 'Failed to load invitations')); } finally { setLoading(false); } - } + }, [t]); + + useEffect(() => { loadInvitations(); }, [loadInvitations]); async function handleRevoke(id) { + if (actionInFlight.current) return; + actionInFlight.current = true; + setPendingId(id); try { await api.revokeInvitation(id); await loadInvitations(); - } catch { - // Silently handle + } catch (err) { + setError(err.message || t('app.invitationsTab.revokeFailed', 'Failed to revoke invitation')); + } finally { + actionInFlight.current = false; + setPendingId(null); } } async function handleResend(id) { + if (actionInFlight.current) return; + actionInFlight.current = true; + setPendingId(id); try { await api.resendInvitation(id); - } catch { - // Silently handle + setError(''); + } catch (err) { + setError(err.message || t('app.invitationsTab.resendFailed', 'Failed to resend invitation')); + } finally { + actionInFlight.current = false; + setPendingId(null); } } @@ -128,10 +145,7 @@ const InvitationsTab = () => { } function formatDate(dateString) { - if (!dateString) return 'Never'; - return new Date(dateString).toLocaleDateString('en-US', { - year: 'numeric', month: 'short', day: 'numeric' - }); + return formatLocaleDate(dateString, { fallback: t('app.invitationsTab.never', 'Never') }); } function getRoleBadgeVariant(role) { @@ -240,6 +254,8 @@ const InvitationsTab = () => { variant="ghost" size="sm" onClick={() => handleResend(inv.id)} + disabled={pendingId !== null} + aria-busy={pendingId === inv.id} title={t('app.invitationsTab.resendEmail', 'Resend email')} > {t('app.invitationsTab.resend', 'Resend')} @@ -249,8 +265,10 @@ const InvitationsTab = () => { variant="ghost" size="sm" onClick={() => handleRevoke(inv.id)} + disabled={pendingId !== null} + aria-busy={pendingId === inv.id} title={t('app.invitationsTab.revokeInvitation', 'Revoke invitation')} - className="text-destructive hover:text-destructive" + className="users-action--danger" > {t('app.invitationsTab.revoke', 'Revoke')} @@ -311,9 +329,11 @@ const InvitationsTab = () => { + {error &&
{error}
} + {loading ? (
{t('app.invitationsTab.loadingInvitations', 'Loading invitations…')}
- ) : invitations.length === 0 ? ( + ) : invitations.length === 0 && !error ? ( ) : (
diff --git a/frontend/src/components/settings/UsersTab.jsx b/frontend/src/components/settings/UsersTab.jsx index 0b89aeb0..0c2c5030 100644 --- a/frontend/src/components/settings/UsersTab.jsx +++ b/frontend/src/components/settings/UsersTab.jsx @@ -1,10 +1,11 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useCallback, useRef } from 'react'; import api from '../../services/api'; import { useAuth } from '../../contexts/AuthContext'; import UserModal from './UserModal'; import InvitationsTab from './InvitationsTab'; import LoginLinksSection from './LoginLinksSection'; -import Modal from '../Modal'; +import { useConfirm } from '../../hooks/useConfirm'; +import useFormat from '../../hooks/useFormat'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { DataTable } from '@/components/ds'; @@ -26,6 +27,22 @@ const statusLabel = (user) => (user.is_active ? 'Active' : 'Disabled'); // strings here are the LABELS the cells render ('Disabled', 'admin') rather // than whatever the API happens to call the field. const USER_VIEWS = [ + { + name: 'Admins without MFA', + state: { + sorts: [{ key: 'user', direction: 'asc' }], + hiddenKeys: [], + groupBy: null, + columnFilters: { + match: 'all', + rules: [ + { id: 'uv-mfa-role', field: 'role', op: 'any', value: ['admin'] }, + { id: 'uv-mfa-active', field: 'status', op: 'any', value: ['Active'] }, + { id: 'uv-mfa-off', field: 'mfa', op: 'is', value: false }, + ], + }, + }, + }, { // Who can do anything on this panel. It is the first question of an // access review and the one a flat alphabetical list buries as soon as @@ -77,7 +94,10 @@ const UsersTab = () => { const [error, setError] = useState(''); const [showModal, setShowModal] = useState(false); const [editingUser, setEditingUser] = useState(null); - const [deleteConfirm, setDeleteConfirm] = useState(null); + const [pendingUserId, setPendingUserId] = useState(null); + const actionInFlight = useRef(false); + const { confirm } = useConfirm(); + const { formatDateTime } = useFormat(); const { user: currentUser } = useAuth(); // Lifted out of so a saved view can capture them. The storage @@ -92,22 +112,22 @@ const UsersTab = () => { // Not persisted on its own: a grouping worth keeping is a saved view. const [groupBy, setGroupBy] = useState(null); - useEffect(() => { - loadUsers(); - }, []); - - async function loadUsers() { + const loadUsers = useCallback(async () => { try { setLoading(true); const data = await api.getUsers(); - setUsers(data.users); + setUsers(data.users || []); setError(''); } catch (err) { - setError(err.message || 'Failed to load users'); + setError(err.message || t('app.usersTab.loadFailed', 'Failed to load users')); } finally { setLoading(false); } - } + }, [t]); + + useEffect(() => { + loadUsers(); + }, [loadUsers]); function handleAddUser() { setEditingUser(null); @@ -135,21 +155,38 @@ const UsersTab = () => { } async function handleDeleteUser(user) { + if (actionInFlight.current) return; + actionInFlight.current = true; + setPendingUserId(user.id); try { + if (!await confirm({ + title: t('app.usersTab.deleteUser2', 'Delete User'), + message: t('app.usersTab.confirmDeleteUser', 'Delete {{username}}? This action cannot be undone.', { username: user.username }), + confirmText: t('app.usersTab.deleteUser2', 'Delete User'), + variant: 'danger', + })) return; await api.deleteUser(user.id); - setDeleteConfirm(null); await loadUsers(); } catch (err) { - setError(err.message || 'Failed to delete user'); + setError(err.message || t('app.usersTab.deleteFailed', 'Failed to delete user')); + } finally { + actionInFlight.current = false; + setPendingUserId(null); } } async function handleToggleActive(user) { + if (actionInFlight.current) return; + actionInFlight.current = true; + setPendingUserId(user.id); try { await api.updateUser(user.id, { is_active: !user.is_active }); await loadUsers(); } catch (err) { - setError(err.message || 'Failed to update user status'); + setError(err.message || t('app.usersTab.updateFailed', 'Failed to update user status')); + } finally { + actionInFlight.current = false; + setPendingUserId(null); } } @@ -162,13 +199,8 @@ const UsersTab = () => { } function formatDate(dateString) { - if (!dateString) return 'Never'; - return new Date(dateString).toLocaleDateString('en-US', { - year: 'numeric', - month: 'short', - day: 'numeric', - hour: '2-digit', - minute: '2-digit' + return formatDateTime(dateString, { + fallback: t('app.usersTab.never', 'Never'), }); } @@ -235,10 +267,38 @@ const UsersTab = () => { value: statusLabel, render: (user) => ( - {statusLabel(user)} + {user.is_active ? t('app.usersTab.active', 'Active') : t('app.usersTab.disabled', 'Disabled')} ), }, + { + key: 'mfa', + headerKey: 'app.usersTab.mfa', header: 'MFA', + type: 'bool', + sortable: true, + value: (user) => Boolean(user.totp_enabled), + render: (user) => ( + + {user.totp_enabled ? t('app.usersTab.enabled', 'Enabled') : t('app.usersTab.notEnabled', 'Not enabled')} + + ), + }, + { + key: 'passkey', + headerKey: 'app.usersTab.passkey', header: 'Passkey', + type: 'bool', + value: (user) => Boolean(user.passkey_enabled), + render: (user) => user.passkey_enabled + ? t('app.usersTab.enrolled', 'Enrolled') : t('app.usersTab.notEnrolled', 'Not enrolled'), + }, + { + key: 'authProvider', + headerKey: 'app.usersTab.signInMethod', header: 'Sign-in provider', + type: 'enum', + value: (user) => user.auth_provider || 'local', + render: (user) => (!user.auth_provider || user.auth_provider === 'local') + ? t('app.usersTab.local', 'Local') : user.auth_provider, + }, { // Sortable and typed rather than render-only: "when did this person // last sign in" is the access-review question, and without an @@ -277,6 +337,7 @@ const UsersTab = () => { variant="ghost" size="sm" onClick={() => handleEditUser(user)} + disabled={pendingUserId !== null} title={t('app.usersTab.editUser', 'Edit user')} > @@ -290,8 +351,10 @@ const UsersTab = () => { variant="ghost" size="sm" onClick={() => handleToggleActive(user)} + disabled={pendingUserId !== null} + aria-busy={pendingUserId === user.id} title={user.is_active ? t('app.usersTab.disableUser', 'Disable user') : t('app.usersTab.enableUser', 'Enable user')} - className={user.is_active ? 'text-warning' : 'text-success'} + className={user.is_active ? 'users-action--warning' : 'users-action--success'} > {user.is_active ? ( @@ -308,9 +371,11 @@ const UsersTab = () => {
{error}
} + {error &&
{error}
}
{ /> )} - {deleteConfirm && ( - setDeleteConfirm(null)} title={t('app.usersTab.deleteUser2', 'Delete User')} size="sm"> -

{t('app.usersTab.areYouSureYouWantTo', 'Are you sure you want to delete')} {deleteConfirm.username}?

-

{t('app.usersTab.thisActionCannotBeUndone', 'This action cannot be undone.')}

-
- - -
-
- )} - diff --git a/frontend/src/i18n/locales/ar.json b/frontend/src/i18n/locales/ar.json index 8a72ea65..65b880de 100644 --- a/frontend/src/i18n/locales/ar.json +++ b/frontend/src/i18n/locales/ar.json @@ -6210,7 +6210,6 @@ }, "usersTab": { "addUser": "إضافة مستخدم", - "areYouSureYouWantTo": "هل أنت متأكد أنك تريد الحذف", "deleteUser": "حذف المستخدم", "deleteUser2": "حذف المستخدم", "disableUser": "تعطيل المستخدم", @@ -6219,7 +6218,6 @@ "lastLogin": "تسجيل الدخول الأخير", "loadingUsers": "جارٍ تحميل المستخدمين...", "role": "دور", - "thisActionCannotBeUndone": "لا يمكن التراجع عن هذا الإجراء.", "you": "أنت" }, "vaults": { diff --git a/frontend/src/i18n/locales/bn.json b/frontend/src/i18n/locales/bn.json index aa33177f..348fc94f 100644 --- a/frontend/src/i18n/locales/bn.json +++ b/frontend/src/i18n/locales/bn.json @@ -6210,7 +6210,6 @@ }, "usersTab": { "addUser": "ব্যবহারকারী যোগ করুন", - "areYouSureYouWantTo": "আপনি কি নিশ্চিত আপনি মুছে দিতে চান", "deleteUser": "ব্যবহারকারী মুছুন", "deleteUser2": "ব্যবহারকারী মুছুন", "disableUser": "ব্যবহারকারী অক্ষম করুন", @@ -6219,7 +6218,6 @@ "lastLogin": "শেষ লগইন", "loadingUsers": "ব্যবহারকারী লোড হচ্ছে...", "role": "ভূমিকা", - "thisActionCannotBeUndone": "এই ক্রিয়াটি পূর্বাবস্থায় ফেরানো যাবে না৷", "you": "আপনি" }, "vaults": { diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index b2f0f69d..2282bd8d 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -6210,7 +6210,6 @@ }, "usersTab": { "addUser": "Benutzer hinzufügen", - "areYouSureYouWantTo": "Sind Sie sicher, dass Sie löschen möchten?", "deleteUser": "Benutzer löschen", "deleteUser2": "Benutzer löschen", "disableUser": "Benutzer deaktivieren", @@ -6219,7 +6218,6 @@ "lastLogin": "Letzte Anmeldung", "loadingUsers": "Benutzer werden geladen...", "role": "Rolle", - "thisActionCannotBeUndone": "Diese Aktion kann nicht rückgängig gemacht werden.", "you": "Du" }, "vaults": { diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 45725390..39a2f44a 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -2662,12 +2662,16 @@ "expires": "Expires", "inviteUser": "Invite User", "linkOnly": "Link only", + "loadFailed": "Failed to load invitations", "loadingInvitations": "Loading invitations…", + "never": "Never", "noInvitationsYet": "No invitations yet", "recipient": "Recipient", "resend": "Resend", "resendEmail": "Resend email", + "resendFailed": "Failed to resend invitation", "revoke": "Revoke", + "revokeFailed": "Failed to revoke invitation", "revokeInvitation": "Revoke invitation", "role": "Role" }, @@ -6210,17 +6214,30 @@ "youCannotDeactivateYourOwnAccount": "You cannot deactivate your own account" }, "usersTab": { + "active": "Active", "addUser": "Add User", - "areYouSureYouWantTo": "Are you sure you want to delete", + "confirmDeleteUser": "Delete {{username}}? This action cannot be undone.", + "deleteFailed": "Failed to delete user", "deleteUser": "Delete user", "deleteUser2": "Delete User", "disableUser": "Disable user", + "disabled": "Disabled", "editUser": "Edit user", "enableUser": "Enable user", + "enabled": "Enabled", + "enrolled": "Enrolled", "lastLogin": "Last Login", + "loadFailed": "Failed to load users", "loadingUsers": "Loading users…", + "local": "Local", + "mfa": "MFA", + "never": "Never", + "notEnabled": "Not enabled", + "notEnrolled": "Not enrolled", + "passkey": "Passkey", "role": "Role", - "thisActionCannotBeUndone": "This action cannot be undone.", + "signInMethod": "Sign-in provider", + "updateFailed": "Failed to update user status", "you": "You" }, "vaults": { diff --git a/frontend/src/i18n/locales/es.json b/frontend/src/i18n/locales/es.json index 09261b44..ac4f4e1c 100644 --- a/frontend/src/i18n/locales/es.json +++ b/frontend/src/i18n/locales/es.json @@ -6210,7 +6210,6 @@ }, "usersTab": { "addUser": "Añadir usuario", - "areYouSureYouWantTo": "¿Seguro que quieres eliminar a", "deleteUser": "Eliminar usuario", "deleteUser2": "Eliminar usuario", "disableUser": "Desactivar usuario", @@ -6219,7 +6218,6 @@ "lastLogin": "Último inicio de sesión", "loadingUsers": "Cargando usuarios…", "role": "Rol", - "thisActionCannotBeUndone": "Esta acción no se puede deshacer.", "you": "Tú" }, "vaults": { diff --git a/frontend/src/i18n/locales/fr.json b/frontend/src/i18n/locales/fr.json index 11ff3893..34718f5f 100644 --- a/frontend/src/i18n/locales/fr.json +++ b/frontend/src/i18n/locales/fr.json @@ -6210,7 +6210,6 @@ }, "usersTab": { "addUser": "Ajouter un utilisateur", - "areYouSureYouWantTo": "Etes-vous sûr de vouloir supprimer", "deleteUser": "Supprimer un utilisateur", "deleteUser2": "Supprimer un utilisateur", "disableUser": "Désactiver l'utilisateur", @@ -6219,7 +6218,6 @@ "lastLogin": "Dernière connexion", "loadingUsers": "Chargement des utilisateurs...", "role": "Rôle", - "thisActionCannotBeUndone": "Cette action ne peut pas être annulée.", "you": "Toi" }, "vaults": { diff --git a/frontend/src/i18n/locales/id.json b/frontend/src/i18n/locales/id.json index ff59da71..c61aebac 100644 --- a/frontend/src/i18n/locales/id.json +++ b/frontend/src/i18n/locales/id.json @@ -6210,7 +6210,6 @@ }, "usersTab": { "addUser": "Tambahkan Pengguna", - "areYouSureYouWantTo": "Apakah Anda yakin ingin menghapus", "deleteUser": "Hapus pengguna", "deleteUser2": "Hapus Pengguna", "disableUser": "Nonaktifkan pengguna", @@ -6219,7 +6218,6 @@ "lastLogin": "Masuk Terakhir", "loadingUsers": "Memuat pengguna...", "role": "Peran", - "thisActionCannotBeUndone": "Tindakan ini tidak dapat dibatalkan.", "you": "Anda" }, "vaults": { diff --git a/frontend/src/i18n/locales/it.json b/frontend/src/i18n/locales/it.json index 6a2eabd2..d4d143e2 100644 --- a/frontend/src/i18n/locales/it.json +++ b/frontend/src/i18n/locales/it.json @@ -6210,7 +6210,6 @@ }, "usersTab": { "addUser": "Aggiungi utente", - "areYouSureYouWantTo": "Sei sicuro di voler eliminare?", "deleteUser": "Elimina utente", "deleteUser2": "Elimina utente", "disableUser": "Disabilita utente", @@ -6219,7 +6218,6 @@ "lastLogin": "Ultimo accesso", "loadingUsers": "Caricamento utenti...", "role": "Ruolo", - "thisActionCannotBeUndone": "Questa azione non può essere annullata.", "you": "Voi" }, "vaults": { diff --git a/frontend/src/i18n/locales/ko.json b/frontend/src/i18n/locales/ko.json index fffff8e5..1f2e3291 100644 --- a/frontend/src/i18n/locales/ko.json +++ b/frontend/src/i18n/locales/ko.json @@ -6210,7 +6210,6 @@ }, "usersTab": { "addUser": "사용자 추가", - "areYouSureYouWantTo": "삭제하시겠습니까?", "deleteUser": "사용자 삭제", "deleteUser2": "사용자 삭제", "disableUser": "사용자 비활성화", @@ -6219,7 +6218,6 @@ "lastLogin": "마지막 로그인", "loadingUsers": "사용자 로드 중...", "role": "역할", - "thisActionCannotBeUndone": "이 작업은 취소할 수 없습니다.", "you": "당신" }, "vaults": { diff --git a/frontend/src/i18n/locales/pl.json b/frontend/src/i18n/locales/pl.json index ca6afc09..4f8a1852 100644 --- a/frontend/src/i18n/locales/pl.json +++ b/frontend/src/i18n/locales/pl.json @@ -6210,7 +6210,6 @@ }, "usersTab": { "addUser": "Dodaj użytkownika", - "areYouSureYouWantTo": "Czy na pewno chcesz usunąć", "deleteUser": "Usuń użytkownika", "deleteUser2": "Usuń użytkownika", "disableUser": "Wyłącz użytkownika", @@ -6219,7 +6218,6 @@ "lastLogin": "Ostatnie logowanie", "loadingUsers": "Ładowanie użytkowników...", "role": "Rola", - "thisActionCannotBeUndone": "Tej akcji nie można cofnąć.", "you": "Ty" }, "vaults": { diff --git a/frontend/src/i18n/locales/pt.json b/frontend/src/i18n/locales/pt.json index 94ef2186..4288da5f 100644 --- a/frontend/src/i18n/locales/pt.json +++ b/frontend/src/i18n/locales/pt.json @@ -6210,7 +6210,6 @@ }, "usersTab": { "addUser": "Adicionar usuário", - "areYouSureYouWantTo": "Tem certeza de que deseja excluir", "deleteUser": "Excluir usuário", "deleteUser2": "Excluir usuário", "disableUser": "Desativar usuário", @@ -6219,7 +6218,6 @@ "lastLogin": "Último login", "loadingUsers": "Carregando usuários...", "role": "Função", - "thisActionCannotBeUndone": "Esta ação não pode ser desfeita.", "you": "Você" }, "vaults": { diff --git a/frontend/src/i18n/locales/ru.json b/frontend/src/i18n/locales/ru.json index 64203681..1a85d6fa 100644 --- a/frontend/src/i18n/locales/ru.json +++ b/frontend/src/i18n/locales/ru.json @@ -6210,7 +6210,6 @@ }, "usersTab": { "addUser": "Добавить пользователя", - "areYouSureYouWantTo": "Вы уверены, что хотите удалить", "deleteUser": "Удалить пользователя", "deleteUser2": "Удалить пользователя", "disableUser": "Отключить пользователя", @@ -6219,7 +6218,6 @@ "lastLogin": "Последний вход", "loadingUsers": "Загрузка пользователей...", "role": "Роль", - "thisActionCannotBeUndone": "Это действие невозможно отменить.", "you": "Ты" }, "vaults": { diff --git a/frontend/src/i18n/locales/th.json b/frontend/src/i18n/locales/th.json index f0fec66b..7a61680b 100644 --- a/frontend/src/i18n/locales/th.json +++ b/frontend/src/i18n/locales/th.json @@ -6210,7 +6210,6 @@ }, "usersTab": { "addUser": "เพิ่มผู้ใช้", - "areYouSureYouWantTo": "คุณแน่ใจหรือไม่ว่าต้องการลบ", "deleteUser": "ลบผู้ใช้", "deleteUser2": "ลบผู้ใช้", "disableUser": "ปิดการใช้งานผู้ใช้", @@ -6219,7 +6218,6 @@ "lastLogin": "เข้าสู่ระบบครั้งล่าสุด", "loadingUsers": "กำลังโหลดผู้ใช้...", "role": "บทบาท", - "thisActionCannotBeUndone": "การดำเนินการนี้ไม่สามารถยกเลิกได้", "you": "คุณ" }, "vaults": { diff --git a/frontend/src/i18n/locales/tr.json b/frontend/src/i18n/locales/tr.json index 86decb4e..d140fee8 100644 --- a/frontend/src/i18n/locales/tr.json +++ b/frontend/src/i18n/locales/tr.json @@ -6210,7 +6210,6 @@ }, "usersTab": { "addUser": "Kullanıcı Ekle", - "areYouSureYouWantTo": "Silmek istediğinizden emin misiniz?", "deleteUser": "Kullanıcıyı sil", "deleteUser2": "Kullanıcıyı Sil", "disableUser": "Kullanıcıyı devre dışı bırak", @@ -6219,7 +6218,6 @@ "lastLogin": "Son Giriş", "loadingUsers": "Kullanıcılar yükleniyor...", "role": "Rol", - "thisActionCannotBeUndone": "Bu eylem geri alınamaz.", "you": "Sen" }, "vaults": { diff --git a/frontend/src/i18n/locales/vi.json b/frontend/src/i18n/locales/vi.json index 9b3a61a8..3c2f0cc8 100644 --- a/frontend/src/i18n/locales/vi.json +++ b/frontend/src/i18n/locales/vi.json @@ -6210,7 +6210,6 @@ }, "usersTab": { "addUser": "Thêm người dùng", - "areYouSureYouWantTo": "Bạn có chắc chắn muốn xóa", "deleteUser": "Xóa người dùng", "deleteUser2": "Xóa người dùng", "disableUser": "Vô hiệu hóa người dùng", @@ -6219,7 +6218,6 @@ "lastLogin": "Đăng nhập lần cuối", "loadingUsers": "Đang tải người dùng...", "role": "Vai trò", - "thisActionCannotBeUndone": "Không thể hoàn tác hành động này.", "you": "Bạn" }, "vaults": { diff --git a/frontend/src/i18n/locales/zh-Hans.json b/frontend/src/i18n/locales/zh-Hans.json index 8c434624..c00ae91a 100644 --- a/frontend/src/i18n/locales/zh-Hans.json +++ b/frontend/src/i18n/locales/zh-Hans.json @@ -6210,7 +6210,6 @@ }, "usersTab": { "addUser": "添加用户", - "areYouSureYouWantTo": "您确定要删除吗", "deleteUser": "删除用户", "deleteUser2": "删除用户", "disableUser": "禁用用户", @@ -6219,7 +6218,6 @@ "lastLogin": "上次登录", "loadingUsers": "正在加载用户...", "role": "角色", - "thisActionCannotBeUndone": "此操作无法撤消。", "you": "你" }, "vaults": { diff --git a/frontend/src/i18n/locales/zh-Hant.json b/frontend/src/i18n/locales/zh-Hant.json index 3df4f12e..7b08226e 100644 --- a/frontend/src/i18n/locales/zh-Hant.json +++ b/frontend/src/i18n/locales/zh-Hant.json @@ -6210,7 +6210,6 @@ }, "usersTab": { "addUser": "新增用戶", - "areYouSureYouWantTo": "您確定要刪除嗎", "deleteUser": "刪除用戶", "deleteUser2": "刪除用戶", "disableUser": "停用用戶", @@ -6219,7 +6218,6 @@ "lastLogin": "上次登入", "loadingUsers": "正在載入用戶...", "role": "角色", - "thisActionCannotBeUndone": "此操作無法撤銷。", "you": "你" }, "vaults": { diff --git a/frontend/src/styles/components/_users.scss b/frontend/src/styles/components/_users.scss index bd846515..a2d57854 100644 --- a/frontend/src/styles/components/_users.scss +++ b/frontend/src/styles/components/_users.scss @@ -74,10 +74,15 @@ .users-table-container { overflow-x: auto; + background: var(--surface); border: 1px solid $border-default; border-radius: $radius-md; } +.users-action--warning { color: $warning; } +.users-action--success { color: $success; } +.users-action--danger { color: $danger; } + .users-table { width: 100%; border-collapse: collapse; diff --git a/frontend/tests/browser/settings.html b/frontend/tests/browser/settings.html new file mode 100644 index 00000000..4ded7589 --- /dev/null +++ b/frontend/tests/browser/settings.html @@ -0,0 +1,5 @@ + + + Settings regression fixture +
+ diff --git a/frontend/tests/browser/settings.jsx b/frontend/tests/browser/settings.jsx new file mode 100644 index 00000000..a4fd8fdc --- /dev/null +++ b/frontend/tests/browser/settings.jsx @@ -0,0 +1,18 @@ +import { createRoot } from 'react-dom/client'; +import { MemoryRouter } from 'react-router-dom'; +import { AuthProvider } from '../../src/contexts/AuthContext'; +import { LocaleProvider } from '../../src/contexts/LocaleContext'; +import { ToastProvider } from '../../src/contexts/ToastContext'; +import { ConfirmProvider } from '../../src/contexts/ConfirmContext'; +import UsersTab from '../../src/components/settings/UsersTab'; +import '../../src/styles/main.scss'; + +// Vite-only fixture: real components/providers and the complete SCSS cascade. +// Network responses are synthetic and intercepted by the browser regression. +createRoot(document.getElementById('root')).render( + + +
+
+
, +); From da0f18146bceb304de95ecb5ec54d8eca187d65b Mon Sep 17 00:00:00 2001 From: Juan Denis Date: Sat, 5 Sep 2026 04:30:11 -0400 Subject: [PATCH 07/20] ci(security): expand scans and narrow Bandit exceptions Schedule dependency and extension scans, audit production frontend dependencies, and replace category suppressions with reviewed function-specific exceptions. Preserve full reports and test the exception gate. --- .github/workflows/security-scan.yml | 71 ++++++++++++++------ scripts/check-bandit-report.py | 89 +++++++++++++++++++++++++ scripts/security/README.md | 26 ++++++++ scripts/security/bandit-exceptions.json | 23 +++++++ scripts/security/test_bandit_gate.py | 65 ++++++++++++++++++ 5 files changed, 253 insertions(+), 21 deletions(-) create mode 100644 scripts/check-bandit-report.py create mode 100644 scripts/security/README.md create mode 100644 scripts/security/bandit-exceptions.json create mode 100644 scripts/security/test_bandit_gate.py diff --git a/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml index 1861401c..b9694441 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/scripts/check-bandit-report.py b/scripts/check-bandit-report.py new file mode 100644 index 00000000..f0e6a692 --- /dev/null +++ b/scripts/check-bandit-report.py @@ -0,0 +1,89 @@ +"""Gate HIGH/HIGH Bandit findings with explicit, function-scoped exceptions. + +Run from the repository root after producing a Bandit JSON report. Only +git-tracked sources are evaluated, excluding locally installed plugin copies. +An exception binds to a path, rule, function AST and occurrence count: adding +another unsafe call or changing its guard requires reviewing the exception. +""" +import ast +from collections import Counter +import hashlib +import json +from pathlib import Path +import subprocess +import sys + + +def normalized_path(filename): + return filename.replace('\\', '/').removeprefix('./') + + +def function_fingerprint(root, filename, line): + tree = ast.parse((root / filename).read_text(encoding='utf-8-sig')) + matches = [] + + def visit(node, scope=''): + if isinstance(node, (ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)): + scope = f'{scope}.{node.name}'.lstrip('.') + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + if node.lineno <= line <= node.end_lineno: + matches.append((node.end_lineno - node.lineno, scope, node)) + for child in ast.iter_child_nodes(node): + visit(child, scope) + + visit(tree) + if not matches: + return '', hashlib.sha256(ast.dump(tree).encode()).hexdigest() + _, scope, node = min(matches, key=lambda match: match[0]) + return scope, hashlib.sha256(ast.dump(node).encode()).hexdigest() + + +def check_report(report, exceptions, root, tracked): + failures, accepted = [], Counter() + expected = {} + for entry in exceptions: + key = (entry['path'], entry['test_id'], entry['function'], entry['sha256']) + if key in expected or not entry.get('reason') or entry.get('count') != 1: + raise ValueError('Each exception must be unique, documented and accept exactly one finding') + expected[key] = entry['count'] + + for error in report.get('errors', []): + if normalized_path(error['filename']) in tracked: + failures.append(f"Scan error: {error['filename']}: {error['reason']}") + + for issue in report['results']: + filename = normalized_path(issue['filename']) + if filename not in tracked: + continue + if (issue['issue_severity'], issue['issue_confidence']) != ('HIGH', 'HIGH'): + continue + function, fingerprint = function_fingerprint(root, filename, issue['line_number']) + key = (filename, issue['test_id'], function, fingerprint) + accepted[key] += 1 + if accepted[key] > expected.get(key, 0): + failures.append(f"{filename}:{issue['line_number']} {issue['test_id']}: {issue['issue_text']}") + + for key, count in expected.items(): + if accepted[key] < count: + failures.append(f'Stale or changed exception: {key[0]} {key[1]} {key[2]}; review or remove it') + return failures + + +def main(): + root = Path(__file__).resolve().parents[1] + report = json.loads(Path(sys.argv[1]).read_text(encoding='utf-8')) + exceptions = json.loads((root / 'scripts/security/bandit-exceptions.json').read_text())['exceptions'] + tracked = set(subprocess.check_output( + ['git', 'ls-files', '-z'], cwd=root, text=True).split('\0')) + failures = check_report(report, exceptions, root, tracked) + for failure in failures: + print(failure) + if failures: + print(f'Bandit gate failed: {len(failures)} issue(s).') + return 1 + print(f'Bandit HIGH/HIGH gate passed; {len(exceptions)} explicit findings accepted.') + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/scripts/security/README.md b/scripts/security/README.md new file mode 100644 index 00000000..70add14a --- /dev/null +++ b/scripts/security/README.md @@ -0,0 +1,26 @@ +# Security scan exceptions + +Security Scan runs weekly and on relevant changes. Bandit scans backend and +builtin-extension Python sources; the full report is uploaded even if the gate +fails. HIGH severity / HIGH confidence findings block CI. Lower-level findings +remain visible in the report. + +Run the same gate locally with Python 3.11 and Bandit 1.9.3: + +```sh +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 +``` + +The gate evaluates git-tracked source files; locally installed copies of +extensions are excluded because their canonical source is in `builtin-extensions`. +The exception file accepts two specific findings in the existing admin-only FTP +connection probe. FTP still transmits credentials without encryption; this is a +documented compatibility limitation, not an assertion that the protocol is safe. + +Each exception binds its rule to one function's AST fingerprint and one finding. +Changing the function, adding another finding, or fixing an accepted finding +requires reviewing and updating/removing the entry. Do not regenerate exceptions +from an entire report or suppress a Bandit category. Review the function, its +callers and guards, record the justification/date, and update only that entry. diff --git a/scripts/security/bandit-exceptions.json b/scripts/security/bandit-exceptions.json new file mode 100644 index 00000000..95651525 --- /dev/null +++ b/scripts/security/bandit-exceptions.json @@ -0,0 +1,23 @@ +{ + "bandit_version": "1.9.3", + "exceptions": [ + { + "path": "builtin-extensions/serverkit-ftp/backend/ftp_service.py", + "test_id": "B402", + "function": "FTPService.test_connection", + "sha256": "77ea0cc9afcb77e596beea5a8d9fc63786807bcb9a190f4d0dc8070bdb5195ba", + "count": 1, + "reviewed_on": "2026-09-05", + "reason": "Existing FTP-management compatibility probe: admin-only POST /api/v1/ftp/test explicitly authenticates to an operator-selected legacy FTP server. FTP credentials are unencrypted; this remains a known protocol limitation, not a safe general-purpose transport. Acceptance is limited to this unchanged function; migrate the probe to explicit FTPS/SFTP support before retiring it." + }, + { + "path": "builtin-extensions/serverkit-ftp/backend/ftp_service.py", + "test_id": "B321", + "function": "FTPService.test_connection", + "sha256": "77ea0cc9afcb77e596beea5a8d9fc63786807bcb9a190f4d0dc8070bdb5195ba", + "count": 1, + "reviewed_on": "2026-09-05", + "reason": "Existing FTP-management compatibility probe: admin-only POST /api/v1/ftp/test explicitly authenticates to an operator-selected legacy FTP server. FTP credentials are unencrypted; this remains a known protocol limitation, not a safe general-purpose transport. Acceptance is limited to this unchanged function; migrate the probe to explicit FTPS/SFTP support before retiring it." + } + ] +} diff --git a/scripts/security/test_bandit_gate.py b/scripts/security/test_bandit_gate.py new file mode 100644 index 00000000..500e7d26 --- /dev/null +++ b/scripts/security/test_bandit_gate.py @@ -0,0 +1,65 @@ +"""The security gate must reject new findings even in an excepted function.""" +import copy +import importlib.util +from pathlib import Path +import tempfile +import unittest + + +spec = importlib.util.spec_from_file_location( + 'bandit_gate', Path(__file__).resolve().parents[1] / 'check-bandit-report.py') +gate = importlib.util.module_from_spec(spec) +spec.loader.exec_module(gate) + + +class BanditGateTests(unittest.TestCase): + def setUp(self): + self.directory = tempfile.TemporaryDirectory() + self.addCleanup(self.directory.cleanup) + self.root = Path(self.directory.name) + self.file = self.root / 'example.py' + self.file.write_text('def legacy():\n return unsafe()\n') + function, sha = gate.function_fingerprint(self.root, 'example.py', 2) + self.exceptions = [{ + 'path': 'example.py', 'test_id': 'B321', 'function': function, + 'sha256': sha, 'count': 1, 'reason': 'Synthetic reviewed fixture', + }] + self.issue = { + 'filename': './example.py', 'test_id': 'B321', 'line_number': 2, + 'issue_severity': 'HIGH', 'issue_confidence': 'HIGH', + 'issue_text': 'synthetic finding', + } + + def check(self, results=None, errors=None): + return gate.check_report( + {'results': [self.issue] if results is None else results, 'errors': errors or []}, + self.exceptions, self.root, {'example.py'}) + + def test_exact_accepted_finding_passes(self): + self.assertEqual(self.check(), []) + + def test_duplicate_finding_in_accepted_function_fails(self): + self.assertTrue(self.check([self.issue, copy.deepcopy(self.issue)])) + + def test_changed_function_requires_review(self): + self.file.write_text('def legacy():\n return unsafe(other_secret)\n') + self.assertTrue(self.check()) + + def test_new_finding_in_same_file_is_not_accepted(self): + self.file.write_text(self.file.read_text() + '\ndef additional():\n return unsafe()\n') + issue = dict(self.issue, line_number=5) + self.assertTrue(self.check([self.issue, issue])) + + def test_unused_exception_must_be_removed(self): + self.assertTrue(self.check([])) + + def test_scanner_errors_fail_closed(self): + self.assertTrue(self.check(errors=[{'filename': './example.py', 'reason': 'syntax error'}])) + + def test_runtime_plugin_copies_are_not_baselined(self): + copied = dict(self.issue, filename='backend/app/plugins/local-copy.py') + self.assertEqual(self.check([self.issue, copied]), []) + + +if __name__ == '__main__': + unittest.main() From 3068fc81f0918c6feda7b31899763ca891239fb6 Mon Sep 17 00:00:00 2001 From: Juan Denis Date: Sat, 5 Sep 2026 04:30:47 -0400 Subject: [PATCH 08/20] docs: record security review and remediation handoff Preserve synthetic historical probes, document fixes and validation, and list upgrade behavior and remaining hardening work with the local implementation commits. --- docs/reviews/2026-09-05-remediation.md | 65 ++++++++ docs/reviews/2026-09-05-security-probes.py | 117 ++++++++++++++ docs/reviews/2026-09-05-serverkit-review.md | 159 ++++++++++++++++++++ 3 files changed, 341 insertions(+) create mode 100644 docs/reviews/2026-09-05-remediation.md create mode 100644 docs/reviews/2026-09-05-security-probes.py create mode 100644 docs/reviews/2026-09-05-serverkit-review.md diff --git a/docs/reviews/2026-09-05-remediation.md b/docs/reviews/2026-09-05-remediation.md new file mode 100644 index 00000000..60c71554 --- /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 00000000..ef3571d3 --- /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 00000000..e009d822 --- /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. From f490d75bee2cd0f84700709fc65ebae71cb06a7e Mon Sep 17 00:00:00 2001 From: Juan Denis Date: Sat, 5 Sep 2026 05:06:41 -0400 Subject: [PATCH 09/20] refactor: share backend lifecycle and fleet metrics operations --- backend/app/api/apps.py | 225 ++-------------- backend/app/api/servers.py | 31 +-- .../services/application_lifecycle_service.py | 244 ++++++++++++++++++ backend/app/services/cf_ops_change_service.py | 17 +- backend/app/services/connect_format.py | 16 ++ backend/app/services/connect_policy.py | 13 +- backend/app/services/connect_storage.py | 14 +- backend/app/services/fleet_monitor_service.py | 14 +- backend/app/services/resource_tier_service.py | 26 +- .../app/services/server_metrics_service.py | 34 +++ .../app/services/shared_resource_service.py | 16 +- backend/app/utils/actor.py | 15 ++ .../api_controller_boundary_baseline.json | 18 +- .../tests/test_app_lifecycle_noncompose.py | 28 +- .../test_application_lifecycle_service.py | 153 +++++++++++ backend/tests/test_fleet_metrics_batching.py | 123 +++++++++ backend/tests/test_shared_service_helpers.py | 60 +++++ 17 files changed, 706 insertions(+), 341 deletions(-) create mode 100644 backend/app/services/application_lifecycle_service.py create mode 100644 backend/app/services/connect_format.py create mode 100644 backend/app/utils/actor.py create mode 100644 backend/tests/test_application_lifecycle_service.py create mode 100644 backend/tests/test_fleet_metrics_batching.py create mode 100644 backend/tests/test_shared_service_helpers.py diff --git a/backend/app/api/apps.py b/backend/app/api/apps.py index e2fe5b85..c23840f1 100644 --- a/backend/app/api/apps.py +++ b/backend/app/api/apps.py @@ -21,11 +21,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 +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 +50,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 +89,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 +107,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 '') @@ -1544,51 +1437,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 +1849,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 +1873,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/servers.py b/backend/app/api/servers.py index 0a4ee3cf..d8cda7a6 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/services/application_lifecycle_service.py b/backend/app/services/application_lifecycle_service.py new file mode 100644 index 00000000..955c6e8a --- /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/cf_ops_change_service.py b/backend/app/services/cf_ops_change_service.py index b7efd8cb..73542437 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 00000000..52270eb7 --- /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 a20690c5..01f3f286 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 0b28c6aa..e4a3f7a9 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 e8e86bda..7be04e08 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/resource_tier_service.py b/backend/app/services/resource_tier_service.py index 47122bc3..f0ba94f3 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/server_metrics_service.py b/backend/app/services/server_metrics_service.py index 3371ee19..8f0c1564 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 3889a26b..62c8a70f 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/utils/actor.py b/backend/app/utils/actor.py new file mode 100644 index 00000000..2fdb1f31 --- /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/tests/api_controller_boundary_baseline.json b/backend/tests/api_controller_boundary_baseline.json index a10e1291..a7d0c55a 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": 9, + "persistence": 494 }, "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", @@ -326,7 +322,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 +335,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 +374,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 +626,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 +693,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/test_app_lifecycle_noncompose.py b/backend/tests/test_app_lifecycle_noncompose.py index b2235b2d..56857c0e 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 00000000..d691778b --- /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_fleet_metrics_batching.py b/backend/tests/test_fleet_metrics_batching.py new file mode 100644 index 00000000..7d7b07d3 --- /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_shared_service_helpers.py b/backend/tests/test_shared_service_helpers.py new file mode 100644 index 00000000..2773157b --- /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 create_access_token, 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 + + +@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['Authorization'] = f'Bearer {create_access_token(identity=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 From 92a3ffe6c3abc107efb74aa540124057e331f3f2 Mon Sep 17 00:00:00 2001 From: Juan Denis Date: Sat, 5 Sep 2026 05:24:35 -0400 Subject: [PATCH 10/20] refactor: consolidate frontend reuse and eliminate lint warnings --- .githooks/pre-commit | 4 +- .../frontend/styles/remote-access.scss | 19 + .../frontend/index.jsx | 12 +- frontend/eslint.config.js | 11 +- frontend/package.json | 8 +- .../public/serverkit-vendor/serverkit-sdk.mjs | 2 +- frontend/scripts/STYLE_OWNERSHIP_CEILING | 2 +- .../scripts/check-frontend-boundaries.mjs | 3 +- frontend/scripts/check-status-one-door.mjs | 1 - frontend/scripts/check-style-cascade.mjs | 211 +++++ frontend/scripts/check-style-ownership.mjs | 19 +- frontend/scripts/controls-browser.mjs | 51 ++ .../eslint-rules/no-static-inline-styles.mjs | 47 + .../no-static-inline-styles.test.mjs | 22 + frontend/scripts/hooks-browser.mjs | 108 +++ frontend/scripts/lint-inventory.mjs | 29 + frontend/scripts/lint-inventory.test.mjs | 23 + frontend/scripts/lint-warning-baseline.json | 1 + frontend/scripts/lint.mjs | 40 + frontend/scripts/metrics-browser.mjs | 144 +++ frontend/src/App.jsx | 3 +- frontend/src/components/CommandPalette.jsx | 11 +- frontend/src/components/DocsLink.jsx | 2 +- frontend/src/components/EmailProviders.jsx | 2 +- .../src/components/EnvironmentVariables.jsx | 46 +- frontend/src/components/FavoriteStar.jsx | 5 +- frontend/src/components/GlobalStatusBar.jsx | 71 +- frontend/src/components/JournalControls.jsx | 4 +- frontend/src/components/LinkAppModal.jsx | 37 +- frontend/src/components/LinkedAppsSection.jsx | 22 +- frontend/src/components/LocaleSync.jsx | 4 +- frontend/src/components/MetricsGraph.jsx | 70 +- frontend/src/components/MobileTopBar.jsx | 7 +- frontend/src/components/NotificationBell.jsx | 27 +- frontend/src/components/OperationsDock.jsx | 4 +- frontend/src/components/PrivateURLSection.jsx | 31 +- frontend/src/components/ProcessTable.jsx | 2 +- frontend/src/components/QuickCreate.jsx | 5 +- frontend/src/components/RemoteTerminal.jsx | 5 +- frontend/src/components/RequiresDocker.jsx | 2 +- frontend/src/components/ResourceAdvisory.jsx | 2 +- frontend/src/components/SchedulePicker.jsx | 9 +- .../src/components/ScheduledTasksCard.jsx | 5 +- frontend/src/components/ShellDockTabs.jsx | 19 +- frontend/src/components/Sidebar.jsx | 71 +- frontend/src/components/SystemNotices.jsx | 4 +- frontend/src/components/ThemeSync.jsx | 4 +- frontend/src/components/WalkthroughHub.jsx | 20 +- frontend/src/components/WorkspaceSwitcher.jsx | 2 +- frontend/src/components/ai/AIAssistant.jsx | 4 +- frontend/src/components/ai/ChatBubble.jsx | 5 +- frontend/src/components/ai/ChatDrawer.jsx | 2 +- frontend/src/components/ai/Composer.jsx | 2 +- .../src/components/ai/ConfirmActionCard.jsx | 11 +- frontend/src/components/ai/ContextChip.jsx | 7 +- .../src/components/ai/ConversationMenu.jsx | 19 +- frontend/src/components/ai/DrawerHeader.jsx | 9 +- frontend/src/components/ai/MessageList.jsx | 11 +- frontend/src/components/ai/ModeToggle.jsx | 7 +- frontend/src/components/ai/ToolCallCard.jsx | 7 +- .../src/components/appdetail/BuildTab.jsx | 52 +- .../src/components/appdetail/CommandsTab.jsx | 91 -- .../src/components/appdetail/DeployTab.jsx | 47 +- .../src/components/appdetail/GunicornTab.jsx | 65 -- frontend/src/components/appdetail/LogsTab.jsx | 75 -- .../src/components/appdetail/OverviewTab.jsx | 372 -------- .../src/components/appdetail/PackagesTab.jsx | 95 -- .../src/components/appdetail/SettingsTab.jsx | 5 +- frontend/src/components/apps/AppWafPanel.jsx | 6 +- .../src/components/apps/ContainerOpsPanel.jsx | 2 +- .../src/components/apps/HtaccessConverter.jsx | 2 +- .../src/components/apps/MicroCachePanel.jsx | 2 +- .../components/apps/ResourceLimitsPanel.jsx | 2 +- frontend/src/components/apps/VolumesPanel.jsx | 15 +- .../src/components/backups/BackupCalendar.jsx | 13 +- .../components/backups/BackupHistoryList.jsx | 4 +- .../components/backups/BackupsOverview.jsx | 59 +- .../components/backups/ProtectionPanel.jsx | 30 +- .../src/components/backups/SchedulesTable.jsx | 63 +- .../backups/StorageDestinations.jsx | 4 +- .../components/buildpack/BuildpackPreview.jsx | 5 +- .../dashboard/AccountSecurityNudge.jsx | 4 +- .../dashboard/SetupHealthWidget.jsx | 8 +- .../dashboard/grid/WidgetEditor.jsx | 33 +- .../components/dashboard/grid/WidgetFrame.jsx | 29 +- .../dashboard/grid/WidgetFullscreen.jsx | 5 +- .../dashboard/grid/WidgetLibrary.jsx | 9 +- .../dashboard/widgets/renderers.jsx | 60 +- .../dashboard/widgets/useWidgetData.js | 99 +-- .../components/databases/AdminerSsoButton.jsx | 2 +- .../src/components/databases/BackupsTab.jsx | 5 +- .../components/databases/ConfigTunerPanel.jsx | 2 +- .../src/components/databases/ConsoleTab.jsx | 27 +- .../components/databases/CreateTableModal.jsx | 20 +- .../src/components/databases/DbUsersPanel.jsx | 4 +- .../databases/EngineCatalogDrawer.jsx | 19 +- .../src/components/databases/EngineGlyph.jsx | 3 +- .../databases/EngineInstallDrawer.jsx | 18 +- .../components/databases/ImportDumpModal.jsx | 12 +- .../databases/ManagedDatabasesPanel.jsx | 4 +- .../components/databases/ProcessListPanel.jsx | 7 +- .../src/components/databases/ResultsGrid.jsx | 5 +- .../src/components/databases/SourceTree.jsx | 21 +- .../src/components/databases/TableDataTab.jsx | 25 +- .../src/components/databases/engineHelpers.js | 2 +- frontend/src/components/databases/modals.jsx | 8 +- .../deploy-console/ConsoleToolbar.jsx | 41 +- .../components/deploy-console/ErrorCard.jsx | 19 +- .../src/components/deploy-console/LogPane.jsx | 9 +- .../deploy-console/PipelineStrip.jsx | 5 +- .../deploy-console/SuccessBanner.jsx | 5 +- .../deployments/ConfigDiffModal.jsx | 2 +- frontend/src/components/docker/ComposeTab.jsx | 30 +- .../src/components/docker/ContainersTab.jsx | 75 +- frontend/src/components/docker/ImagesTab.jsx | 6 +- .../src/components/docker/NetworksTab.jsx | 6 +- .../src/components/docker/PruneButton.jsx | 2 +- frontend/src/components/docker/VolumesTab.jsx | 6 +- .../src/components/domains/CutoverDrawer.jsx | 2 +- .../components/domains/DdnsTokenCallout.jsx | 5 +- .../src/components/domains/DomainDnsPanel.jsx | 2 +- .../components/domains/RegistrarPortfolio.jsx | 5 +- frontend/src/components/ds/ColumnsMenu.jsx | 4 +- frontend/src/components/ds/DataTable.jsx | 5 +- frontend/src/components/ds/Drawer.jsx | 2 +- frontend/src/components/ds/FilterDrawer.jsx | 22 +- frontend/src/components/ds/GroupMenu.jsx | 8 +- frontend/src/components/ds/KpiBand.jsx | 5 +- frontend/src/components/ds/MetricCard.jsx | 5 +- frontend/src/components/ds/PageTopbar.jsx | 5 +- frontend/src/components/ds/SegControl.jsx | 5 +- frontend/src/components/ds/SortChipBar.jsx | 13 +- frontend/src/components/ds/SortMenu.jsx | 12 +- frontend/src/components/ds/ViewMenu.jsx | 14 +- frontend/src/components/ds/filterValues.js | 16 + .../src/components/ds/grid/ColumnMenu.jsx | 45 +- frontend/src/components/ds/grid/DataGrid.jsx | 5 +- .../src/components/ds/grid/GridBulkBar.jsx | 5 +- frontend/src/components/ds/grid/GridChips.jsx | 11 +- .../components/ds/grid/GridFilterButton.jsx | 5 +- .../components/ds/grid/GridFilterDrawer.jsx | 32 +- .../src/components/ds/grid/GridFooter.jsx | 17 +- .../src/components/ds/grid/GridToolsMenu.jsx | 41 +- .../src/components/ds/grid/GridViewPicker.jsx | 47 +- .../src/components/ds/grid/useTableChrome.js | 2 +- frontend/src/components/ds/index.js | 5 +- frontend/src/components/ds/status.js | 13 +- .../components/file-manager/ContextMenu.jsx | 25 +- .../src/components/file-manager/FileCard.jsx | 5 +- .../src/components/file-manager/FileRow.jsx | 21 +- .../components/file-manager/FolderTree.jsx | 5 +- .../components/file-manager/PreviewDrawer.jsx | 24 +- frontend/src/components/git/GitProviders.jsx | 32 +- frontend/src/components/git/PathSelector.jsx | 8 +- .../src/components/git/RepoConnectForm.jsx | 3 +- frontend/src/components/git/RepoPicker.jsx | 4 +- .../src/components/git/gitProviderData.js | 26 + .../src/components/icons/DatabaseBrands.jsx | 19 +- .../src/components/icons/ExtensionBrands.jsx | 43 +- .../src/components/icons/databaseBrandData.js | 18 + .../components/icons/extensionBrandData.js | 42 + .../components/layouts/ResourceListPage.jsx | 5 +- .../src/components/log-viewer/LogFileList.jsx | 13 +- .../src/components/log-viewer/LogToolbar.jsx | 37 +- .../marketplace/ManualInstallModal.jsx | 6 +- .../monitoring/DiskReclaimModal.jsx | 2 +- .../src/components/monitoring/DoctorPanel.jsx | 2 +- .../monitoring/FleetCapacityPanel.jsx | 2 +- .../monitoring/FleetThresholdsPanel.jsx | 2 +- .../monitoring/MonitoringOverview.jsx | 14 +- .../monitoring/ServerScopePicker.jsx | 13 +- .../src/components/monitoring/UptimeBars.jsx | 5 +- .../src/components/previews/PreviewList.jsx | 4 +- frontend/src/components/processData.js | 2 + .../src/components/proxy/ProxyStackPanel.jsx | 2 +- frontend/src/components/security/AuditTab.jsx | 13 +- .../src/components/security/EventsTab.jsx | 21 +- .../src/components/security/FirewallTab.jsx | 108 +-- .../src/components/security/IPListsTab.jsx | 15 +- .../src/components/security/IntegrityTab.jsx | 25 +- .../src/components/security/OverviewTab.jsx | 13 +- .../src/components/security/SSHKeysTab.jsx | 15 +- .../components/security/SecurityConfigTab.jsx | 37 +- .../components/server/OnboardingWizard.jsx | 6 +- .../serverdetail/CloudflaredTab.jsx | 20 +- .../src/components/serverdetail/CronTab.jsx | 25 +- .../components/serverdetail/PackagesTab.jsx | 8 +- .../serverdetail/ServerDockerTab.jsx | 58 +- .../serverdetail/ServerOverviewTab.jsx | 2 +- .../serverdetail/ServerRestorePointsTab.jsx | 2 +- .../serverdetail/ServerSettingsTab.jsx | 37 +- .../components/serverdetail/ServicesTab.jsx | 16 +- .../src/components/serverdetail/SurveyTab.jsx | 2 +- .../serverdetail/SystemStatusCard.jsx | 2 +- .../serverdetail/serverDetailData.js | 29 + .../serverdetail/serverDetailShared.jsx | 35 +- .../src/components/servers/LinkPanelForm.jsx | 2 +- .../components/service-detail/CommandsTab.jsx | 4 +- .../components/service-detail/EventsTab.jsx | 2 +- .../components/service-detail/GunicornTab.jsx | 18 +- .../src/components/service-detail/LogsTab.jsx | 43 +- .../components/service-detail/MetricsTab.jsx | 13 +- .../components/service-detail/OverviewTab.jsx | 55 +- .../components/service-detail/PackagesTab.jsx | 18 +- .../components/service-detail/SettingsTab.jsx | 29 +- .../components/service-detail/ShellTab.jsx | 33 +- .../src/components/settings/AISettingsTab.jsx | 2 +- frontend/src/components/settings/AboutTab.jsx | 10 +- .../src/components/settings/ActivityTab.jsx | 35 +- .../src/components/settings/ApiKeyModal.jsx | 4 +- .../components/settings/ApiSettingsTab.jsx | 2 +- .../src/components/settings/AppearanceTab.jsx | 28 +- .../components/settings/IconReferenceTab.jsx | 5 +- .../components/settings/LanguageSelector.jsx | 2 +- .../settings/MigrationHistoryTab.jsx | 7 +- .../src/components/settings/ModulesTab.jsx | 2 +- .../components/settings/NotificationsTab.jsx | 18 +- .../src/components/settings/ProfileTab.jsx | 2 +- .../src/components/settings/RecycleBinTab.jsx | 6 +- .../src/components/settings/SSOConfigTab.jsx | 2 +- .../settings/SecuritySettingsTab.jsx | 4 +- .../components/settings/SidebarSettings.jsx | 10 +- .../components/settings/SiteSettingsTab.jsx | 41 +- .../src/components/settings/SystemTab.jsx | 4 +- .../components/settings/ThemeBrowseModal.jsx | 9 +- .../src/components/settings/ThemeGallery.jsx | 15 +- .../components/settings/ThemeStudioModal.jsx | 14 +- .../src/components/settings/UserModal.jsx | 2 +- frontend/src/components/settings/UsersTab.jsx | 2 +- .../src/components/settings/WebhooksTab.jsx | 46 +- .../src/components/settings/WhiteLabelTab.jsx | 6 +- .../connections/ConnectProviderModal.jsx | 20 +- .../settings/connections/ConnectionsHub.jsx | 44 +- .../src/components/setup/SetupStepAccount.jsx | 13 +- .../components/setup/SetupStepCapacity.jsx | 11 +- .../src/components/setup/SetupStepIntent.jsx | 7 +- .../components/setup/SetupStepSecurity.jsx | 16 +- .../src/components/setup/SetupStepSummary.jsx | 19 +- .../shared/EnvironmentVariablesPanel.jsx | 4 +- .../shared/SharedVariableGroups.jsx | 10 +- frontend/src/components/shared/TagsPanel.jsx | 6 +- frontend/src/components/ui/alert-dialog.jsx | 2 +- frontend/src/components/ui/badge.jsx | 1 - frontend/src/components/ui/button.jsx | 35 +- frontend/src/components/ui/buttonVariants.js | 35 + frontend/src/components/ui/card.jsx | 20 +- frontend/src/components/ui/input.jsx | 1 - frontend/src/components/ui/label.jsx | 1 - frontend/src/components/ui/tabs.jsx | 5 +- frontend/src/components/ui/textarea.jsx | 1 - .../workspaces/WorkspaceApplicationsTab.jsx | 186 ++++ .../workspaces/WorkspaceMembersTab.jsx | 2 +- .../workspaces/WorkspaceServicesTab.jsx | 183 +--- .../workspaces/WorkspaceSettingsTab.jsx | 19 +- .../workspaces/WorkspaceSitesTab.jsx | 183 +--- frontend/src/contexts/AIContext.jsx | 14 +- frontend/src/contexts/AuthContext.jsx | 63 +- frontend/src/contexts/ConfirmContext.jsx | 10 +- frontend/src/contexts/LayoutContext.jsx | 14 +- frontend/src/contexts/LocaleContext.jsx | 12 +- .../src/contexts/NotificationsContext.jsx | 12 +- frontend/src/contexts/OperationsContext.jsx | 2 +- frontend/src/contexts/ResourceTierContext.jsx | 14 +- frontend/src/contexts/ShellDockContext.jsx | 14 +- frontend/src/contexts/ThemeContext.jsx | 16 +- frontend/src/contexts/ToastContext.jsx | 12 +- frontend/src/contexts/WalkthroughContext.jsx | 2 +- frontend/src/contexts/WorkspaceContext.jsx | 13 +- frontend/src/contexts/useAuth.js | 11 + frontend/src/contexts/useConfirmContext.js | 9 + frontend/src/contexts/useLayout.js | 13 + frontend/src/contexts/useLocale.js | 9 + frontend/src/contexts/useNotifications.js | 9 + frontend/src/contexts/useResourceTier.js | 11 + frontend/src/contexts/useServerkitAI.js | 9 + frontend/src/contexts/useShellDock.js | 9 + frontend/src/contexts/useTheme.js | 13 + frontend/src/contexts/useToast.js | 11 + frontend/src/contexts/useWorkspace.js | 11 + frontend/src/hooks/ai/useFocusTrap.js | 2 +- frontend/src/hooks/useClipboard.js | 2 +- frontend/src/hooks/useConfirm.js | 2 +- frontend/src/hooks/useDevMode.js | 2 +- frontend/src/hooks/useFormat.js | 4 +- frontend/src/hooks/useMetrics.js | 121 +-- frontend/src/hooks/useOverflowItems.js | 19 +- frontend/src/hooks/usePageTitle.js | 2 +- frontend/src/hooks/usePaletteAuthz.js | 4 +- frontend/src/hooks/useRecipeCatalog.js | 2 +- frontend/src/hooks/useResourceOptions.js | 2 +- frontend/src/hooks/useServerQuery.js | 2 +- frontend/src/i18n/locales/ar.json | 23 - frontend/src/i18n/locales/bn.json | 23 - frontend/src/i18n/locales/de.json | 25 +- frontend/src/i18n/locales/en.json | 29 +- frontend/src/i18n/locales/es.json | 25 +- frontend/src/i18n/locales/fr.json | 25 +- frontend/src/i18n/locales/id.json | 25 +- frontend/src/i18n/locales/it.json | 25 +- frontend/src/i18n/locales/ko.json | 23 - frontend/src/i18n/locales/pl.json | 25 +- frontend/src/i18n/locales/pt.json | 25 +- frontend/src/i18n/locales/ru.json | 23 - frontend/src/i18n/locales/th.json | 23 - frontend/src/i18n/locales/tr.json | 25 +- frontend/src/i18n/locales/vi.json | 25 +- frontend/src/i18n/locales/zh-Hans.json | 23 - frontend/src/i18n/locales/zh-Hant.json | 23 - frontend/src/pages/AgentFleet.jsx | 372 ++++---- frontend/src/pages/AppMap.jsx | 5 +- frontend/src/pages/Backups.jsx | 53 +- frontend/src/pages/CloudProvision.jsx | 21 +- frontend/src/pages/CronJobs.jsx | 13 +- frontend/src/pages/Dashboard.jsx | 70 +- frontend/src/pages/DatabaseMigration.jsx | 8 +- frontend/src/pages/Databases.jsx | 67 +- frontend/src/pages/DeliveryLog.jsx | 4 +- frontend/src/pages/Deployments.jsx | 4 +- frontend/src/pages/Docker.jsx | 8 +- frontend/src/pages/Domains.jsx | 24 +- frontend/src/pages/Downloads.jsx | 10 +- frontend/src/pages/Errors.jsx | 4 +- frontend/src/pages/FTPServer.jsx | 67 +- frontend/src/pages/FileManager.jsx | 102 +-- frontend/src/pages/FleetProxy.jsx | 4 +- frontend/src/pages/GithubAppCallback.jsx | 9 +- frontend/src/pages/ImportWizard.jsx | 29 +- frontend/src/pages/Incidents.jsx | 6 +- frontend/src/pages/Jobs.jsx | 4 +- frontend/src/pages/Login.jsx | 33 +- frontend/src/pages/Marketplace.jsx | 9 +- frontend/src/pages/MonitorDetail.jsx | 2 +- frontend/src/pages/Monitoring.jsx | 4 +- frontend/src/pages/Monitors.jsx | 2 +- frontend/src/pages/Notifications.jsx | 24 +- frontend/src/pages/ProjectDetail.jsx | 18 +- frontend/src/pages/Projects.jsx | 4 +- frontend/src/pages/QueueDetail.jsx | 14 +- frontend/src/pages/QueueOperations.jsx | 26 +- frontend/src/pages/Recipes.jsx | 36 +- frontend/src/pages/Register.jsx | 2 +- frontend/src/pages/RemoteAccess.jsx | 39 +- frontend/src/pages/SSLCertificates.jsx | 2 +- frontend/src/pages/SSOCallback.jsx | 59 +- frontend/src/pages/Security.jsx | 3 +- frontend/src/pages/ServerDetail.jsx | 8 +- frontend/src/pages/ServerTemplates.jsx | 39 +- frontend/src/pages/Servers.jsx | 28 +- frontend/src/pages/ServiceDetail.jsx | 33 +- frontend/src/pages/Services.jsx | 37 +- frontend/src/pages/Settings.jsx | 2 +- frontend/src/pages/Setup.jsx | 8 +- frontend/src/pages/SharedVariables.jsx | 2 +- .../src/pages/SourceConnectionCallback.jsx | 7 +- frontend/src/pages/StatusPages.jsx | 12 +- frontend/src/pages/StyleGuide.jsx | 555 ++++++------ frontend/src/pages/Telemetry.jsx | 6 +- frontend/src/pages/Templates.jsx | 206 ++--- frontend/src/pages/Terminal.jsx | 42 +- frontend/src/pages/TestSandbox.jsx | 2 +- frontend/src/pages/Vaults.jsx | 22 +- frontend/src/pages/WorkspaceDetail.jsx | 12 +- frontend/src/pages/Workspaces.jsx | 6 +- frontend/src/pages/auth/AuthLayout.jsx | 2 +- .../pages/auth/layouts/SplitHeroLayout.jsx | 2 +- .../src/pages/new-service/ConnectStep.jsx | 4 +- frontend/src/pages/new-service/NewService.jsx | 4 +- frontend/src/pages/new-service/ReviewStep.jsx | 7 +- frontend/src/pages/new-service/SourceStep.jsx | 5 +- .../pages/new-service/useNewServiceForm.js | 2 +- frontend/src/plugins/sdk/index.js | 15 +- .../serverkit-gui/components/ServerGui.jsx | 9 +- .../components/ServerGuiLauncher.jsx | 9 +- .../components/SyntheticDesktop.jsx | 2 +- .../styles/remote-access.scss | 19 + .../serverkit-walkthrough-studio/index.jsx | 12 +- .../services/__tests__/apiRegistry.test.mjs | 58 ++ .../services/__tests__/widgetQueries.test.mjs | 50 ++ frontend/src/services/api/apps.js | 2 +- frontend/src/services/api/files.js | 57 -- frontend/src/services/api/index.js | 9 +- frontend/src/services/api/registry.js | 15 + frontend/src/services/api/servers.js | 2 +- frontend/src/services/api/system.js | 2 +- frontend/src/services/queryClient.js | 21 +- frontend/src/services/widgetQueries.js | 22 + frontend/src/styles/base/_typography.scss | 24 +- frontend/src/styles/base/_utilities.scss | 6 - frontend/src/styles/components/_alerts.scss | 17 +- frontend/src/styles/components/_badges.scss | 34 +- frontend/src/styles/components/_build.scss | 43 - frontend/src/styles/components/_buttons.scss | 117 ++- frontend/src/styles/components/_cards.scss | 34 - .../styles/components/_connection-status.scss | 39 + frontend/src/styles/components/_datagrid.scss | 2 + frontend/src/styles/components/_deploy.scss | 113 ++- .../src/styles/components/_design-system.scss | 7 +- .../src/styles/components/_detail-tabs.scss | 218 +++++ .../src/styles/components/_empty-state.scss | 30 + frontend/src/styles/components/_env-vars.scss | 40 +- .../styles/components/_form-affordances.scss | 7 + frontend/src/styles/components/_forms.scss | 140 ++- frontend/src/styles/components/_lists.scss | 77 +- frontend/src/styles/components/_logs.scss | 68 +- .../src/styles/components/_metrics-graph.scss | 1 - frontend/src/styles/components/_modals.scss | 3 +- .../src/styles/components/_notifications.scss | 10 - .../src/styles/components/_private-url.scss | 13 - frontend/src/styles/components/_services.scss | 5 +- frontend/src/styles/components/_skeleton.scss | 8 - frontend/src/styles/components/_spinner.scss | 36 +- .../src/styles/components/_status-badge.scss | 34 +- frontend/src/styles/components/_tables.scss | 47 + frontend/src/styles/components/_tabs.scss | 20 +- .../src/styles/components/_two-factor.scss | 4 - frontend/src/styles/components/_ui.scss | 17 + frontend/src/styles/components/_uptime.scss | 8 - frontend/src/styles/components/_users.scss | 48 - frontend/src/styles/layout/_grid.scss | 8 +- frontend/src/styles/layout/_main-content.scss | 19 +- frontend/src/styles/main.scss | 4 + frontend/src/styles/pages/_agent-fleet.scss | 90 ++ frontend/src/styles/pages/_applications.scss | 121 --- frontend/src/styles/pages/_connections.scss | 17 - frontend/src/styles/pages/_cron.scss | 28 - frontend/src/styles/pages/_dashboard.scss | 79 -- frontend/src/styles/pages/_docker.scss | 24 +- frontend/src/styles/pages/_domains.scss | 14 +- frontend/src/styles/pages/_fleet-monitor.scss | 1 - .../src/styles/pages/_migration-wizard.scss | 4 - frontend/src/styles/pages/_monitoring.scss | 676 +-------------- frontend/src/styles/pages/_monitors.scss | 819 ++++++++++++++++-- .../src/styles/pages/_queue-operations.scss | 3 + frontend/src/styles/pages/_security.scss | 130 --- .../src/styles/pages/_server-templates.scss | 3 + frontend/src/styles/pages/_servers.scss | 235 ----- .../src/styles/pages/_service-detail.scss | 189 +--- frontend/src/styles/pages/_settings.scss | 111 +-- frontend/src/styles/pages/_setup-wizard.scss | 3 + frontend/src/styles/pages/_ssl.scss | 3 - frontend/src/styles/pages/_style-guide.scss | 111 +++ frontend/src/styles/pages/_terminal.scss | 21 - .../utils/__tests__/backupSchedule.test.mjs | 42 + .../__tests__/metricsSubscription.test.mjs | 53 ++ frontend/src/utils/backupSchedule.js | 52 ++ frontend/src/utils/metricsSubscription.js | 21 + frontend/tests/browser/controls.html | 5 + frontend/tests/browser/controls.jsx | 32 + 448 files changed, 6340 insertions(+), 6791 deletions(-) create mode 100644 frontend/scripts/check-style-cascade.mjs create mode 100644 frontend/scripts/controls-browser.mjs create mode 100644 frontend/scripts/eslint-rules/no-static-inline-styles.mjs create mode 100644 frontend/scripts/eslint-rules/no-static-inline-styles.test.mjs create mode 100644 frontend/scripts/hooks-browser.mjs create mode 100644 frontend/scripts/lint-inventory.mjs create mode 100644 frontend/scripts/lint-inventory.test.mjs create mode 100644 frontend/scripts/lint-warning-baseline.json create mode 100644 frontend/scripts/lint.mjs create mode 100644 frontend/scripts/metrics-browser.mjs delete mode 100644 frontend/src/components/appdetail/CommandsTab.jsx delete mode 100644 frontend/src/components/appdetail/GunicornTab.jsx delete mode 100644 frontend/src/components/appdetail/LogsTab.jsx delete mode 100644 frontend/src/components/appdetail/OverviewTab.jsx delete mode 100644 frontend/src/components/appdetail/PackagesTab.jsx create mode 100644 frontend/src/components/ds/filterValues.js create mode 100644 frontend/src/components/git/gitProviderData.js create mode 100644 frontend/src/components/icons/databaseBrandData.js create mode 100644 frontend/src/components/icons/extensionBrandData.js create mode 100644 frontend/src/components/processData.js create mode 100644 frontend/src/components/serverdetail/serverDetailData.js create mode 100644 frontend/src/components/ui/buttonVariants.js create mode 100644 frontend/src/components/workspaces/WorkspaceApplicationsTab.jsx create mode 100644 frontend/src/contexts/useAuth.js create mode 100644 frontend/src/contexts/useConfirmContext.js create mode 100644 frontend/src/contexts/useLayout.js create mode 100644 frontend/src/contexts/useLocale.js create mode 100644 frontend/src/contexts/useNotifications.js create mode 100644 frontend/src/contexts/useResourceTier.js create mode 100644 frontend/src/contexts/useServerkitAI.js create mode 100644 frontend/src/contexts/useShellDock.js create mode 100644 frontend/src/contexts/useTheme.js create mode 100644 frontend/src/contexts/useToast.js create mode 100644 frontend/src/contexts/useWorkspace.js create mode 100644 frontend/src/services/__tests__/apiRegistry.test.mjs create mode 100644 frontend/src/services/__tests__/widgetQueries.test.mjs create mode 100644 frontend/src/services/api/registry.js create mode 100644 frontend/src/services/widgetQueries.js create mode 100644 frontend/src/styles/components/_connection-status.scss create mode 100644 frontend/src/styles/components/_detail-tabs.scss create mode 100644 frontend/src/styles/components/_form-affordances.scss create mode 100644 frontend/src/styles/pages/_agent-fleet.scss create mode 100644 frontend/src/utils/__tests__/backupSchedule.test.mjs create mode 100644 frontend/src/utils/__tests__/metricsSubscription.test.mjs create mode 100644 frontend/src/utils/backupSchedule.js create mode 100644 frontend/src/utils/metricsSubscription.js create mode 100644 frontend/tests/browser/controls.html create mode 100644 frontend/tests/browser/controls.jsx diff --git a/.githooks/pre-commit b/.githooks/pre-commit index 7abe00e9..2b0ab8fd 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/builtin-extensions/serverkit-remote-access/frontend/styles/remote-access.scss b/builtin-extensions/serverkit-remote-access/frontend/styles/remote-access.scss index 2401ad23..e4f14d03 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 b11b9300..819c07ab 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/frontend/eslint.config.js b/frontend/eslint.config.js index f3da3d56..ed6a9a4f 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.json b/frontend/package.json index 610f1f89..fb762a70 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -7,8 +7,12 @@ "dev": "vite", "build": "vite build", "test": "node --test", - "test:browser": "node scripts/settings-browser.mjs", - "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", + "test:hooks": "node scripts/hooks-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", diff --git a/frontend/public/serverkit-vendor/serverkit-sdk.mjs b/frontend/public/serverkit-vendor/serverkit-sdk.mjs index 7f2f32fb..32edccdf 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 dee79f10..573541ac 100644 --- a/frontend/scripts/STYLE_OWNERSHIP_CEILING +++ b/frontend/scripts/STYLE_OWNERSHIP_CEILING @@ -1 +1 @@ -114 +0 diff --git a/frontend/scripts/check-frontend-boundaries.mjs b/frontend/scripts/check-frontend-boundaries.mjs index f81ed32b..ba978eda 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, diff --git a/frontend/scripts/check-status-one-door.mjs b/frontend/scripts/check-status-one-door.mjs index e01a746e..e38cc983 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 00000000..a0d5b1e6 --- /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 9d215fa5..28194b18 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 00000000..7430eb6b --- /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 00000000..0ec58842 --- /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 00000000..1d6ed461 --- /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 00000000..d3341ac6 --- /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 00000000..69409eb8 --- /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 00000000..efa111bb --- /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 00000000..0967ef42 --- /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 00000000..ab2c3d6c --- /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/metrics-browser.mjs b/frontend/scripts/metrics-browser.mjs new file mode 100644 index 00000000..c4e28fd8 --- /dev/null +++ b/frontend/scripts/metrics-browser.mjs @@ -0,0 +1,144 @@ +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'; +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 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)); + assert.deepEqual(errors, []); + 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/src/App.jsx b/frontend/src/App.jsx index 3e3cbdbc..2c7d3672 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 5314f69a..1c1871aa 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 796bd5e5..b14b5696 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 33fccd6c..fe94a576 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 bdfbdebd..bc306ed2 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 801a2876..30aea450 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 bf0f3daa..f03ece1f 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 4520e9fc..99bce2d4 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 94dd2098..e3ffdbf1 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 9da81e09..e5e45aa8 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 761ec575..a27f344c 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 489ae45c..d98f3f48 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 85ebf3a5..0e368bb4 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 d8d56f08..5bd4c0e7 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 80fec523..41e575f3 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 c15fc952..f2ef548b 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 33953592..806c77dd 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 feebdf05..06294019 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 564abb15..817271ce 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 c2443de4..26b12694 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 12c69776..b037dde2 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 19113eeb..12fd8c3d 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 10090f69..eb7d6c10 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 86a410ee..0d4e50dd 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 0bd65b4e..7b788c2b 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 1a9bec72..3b643d98 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 96990984..1973074b 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 e64c4efe..a68d9e2f 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 8b39ecef..a133b846 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 64297015..e8393eca 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 60619664..2d16a308 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 09902214..bdba0de6 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 4e030eac..28ffc866 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 4b9df013..108c3f5e 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 a00a3116..3cbd906f 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 e7599365..366983df 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 8452b4c3..00000000 --- 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 082bb65d..67c658b6 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 9912fec0..00000000 --- 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')}

- -
-