diff --git a/backend/app/api/connect.py b/backend/app/api/connect.py
index 6d7e9406..5e810adc 100644
--- a/backend/app/api/connect.py
+++ b/backend/app/api/connect.py
@@ -17,3 +17,14 @@ connect_bp = Blueprint('connect', __name__)
def get_status():
"""Current ServerKit Cloud connection state (unpaired/paired_offline/...)."""
return jsonify(connect_client.status())
+
+
+@connect_bp.route('/managed-profile', methods=['GET'])
+@admin_required
+def get_managed_profile():
+ """The managed profile in force (plan 25). Read-only: the signed
+ ui.managed_profile command handler is the only writer, and lifting the
+ profile from ServerKit Cloud — never from here — is the only off switch
+ other than expiry or revocation."""
+ from app.services import connect_managed_profile
+ return jsonify(connect_managed_profile.current())
diff --git a/backend/app/services/connect_client.py b/backend/app/services/connect_client.py
index 48d83142..237038f2 100644
--- a/backend/app/services/connect_client.py
+++ b/backend/app/services/connect_client.py
@@ -432,6 +432,10 @@ def status() -> dict:
def disconnect(remove_key: bool = False) -> dict:
"""Forget the pairing locally. Does NOT revoke anything on ServerKit Cloud."""
stop_relay_client() # no-op unless the client runs in this process
+ # The managed profile belongs to the pairing and leaves with it: a panel
+ # that is no longer connected is not managed (plan 25).
+ from app.services import connect_managed_profile
+ connect_managed_profile.clear('disconnected')
removed = []
for path in (connect_file_path(), state_file_path()):
if os.path.exists(path):
@@ -773,6 +777,7 @@ class RelayClient:
# Same terminal handling as the ws path: limited mode is
# where a panel behind a code-stripping edge learns it.
self._set_state('revoked', 'revoked', transport=None)
+ self._clear_managed_profile()
logger.warning('Connect relay: device revoked on ServerKit Cloud; stopping')
return
ws_blocked_until = 0.0 # time to re-probe WS
@@ -786,6 +791,7 @@ class RelayClient:
if kind == 'revoked':
self._set_state('revoked', 'revoked', transport=None)
+ self._clear_managed_profile()
logger.warning('Connect relay: device revoked on ServerKit Cloud; stopping')
return
@@ -804,6 +810,16 @@ class RelayClient:
self._set_state('paired_offline', reason, transport=None)
self._sleep(backoff_delay(attempt))
+ def _clear_managed_profile(self):
+ """A revoked panel is not managed: lift the profile immediately
+ (plan 25 "Revoked or unpaired")."""
+ try:
+ from app.services import connect_managed_profile
+ connect_managed_profile.clear('revoked', app=self.app)
+ except Exception:
+ logger.warning('Connect relay: could not lift the managed profile',
+ exc_info=True)
+
def _note_attempt(self):
now = time.time()
self._attempts_at.append(now)
diff --git a/backend/app/services/connect_commands.py b/backend/app/services/connect_commands.py
index 2afef238..16cb2a3e 100644
--- a/backend/app/services/connect_commands.py
+++ b/backend/app/services/connect_commands.py
@@ -217,4 +217,4 @@ def result_frame(cmd_id: str, state: str, result: dict = None, stream: str = Non
# Importing the handler modules is what registers them. Kept at the bottom so
# a handler module may import this one for @handler.
-from app.services import connect_policy, connect_storage # noqa: E402,F401
+from app.services import connect_managed_profile, connect_policy, connect_storage # noqa: E402,F401
diff --git a/backend/app/services/connect_managed_profile.py b/backend/app/services/connect_managed_profile.py
new file mode 100644
index 00000000..6ca38ddd
--- /dev/null
+++ b/backend/app/services/connect_managed_profile.py
@@ -0,0 +1,264 @@
+"""The managed profile: ServerKit Cloud's signed presentation policy.
+
+When a customer's Managed scope says ServerKit Cloud runs the server for them,
+Cloud sends one ``ui.managed_profile`` command over the same signed path as
+every other command. What it carries is a *presentation* policy: which
+capabilities Cloud has taken over, who holds them, and when the policy lapses.
+This module is the only writer of ``managed-profile.json`` — kept next to
+``connect.json`` because it belongs to the pairing and leaves with it — and
+the only place that decides whether the profile currently applies.
+
+The rules that keep this truthful (plan 25):
+
+- **Hidden, never removed.** The policy changes what the panel *shows*. Every
+ route stays registered and every API keeps answering with the caller's
+ normal role; support, break-glass, exports and Cloud's own handoff to
+ ``/fleet`` all keep landing.
+- **Expiry lifts the profile.** A panel that cannot reach Cloud past
+ ``expires_at`` shows everything again — a managed customer whose provider
+ disappears must be able to run their own server. An expired document is
+ kept (the UI says the profile lapsed) but holds nothing.
+- **Revoked or unpaired clears it.** A panel that is no longer connected is
+ not managed; ``clear()`` runs on local disconnect and on relay revocation.
+- **An override lifts it without deleting it.** A support session sees the
+ full panel until the override's end time, and the profile returns on its
+ own afterwards.
+
+Every apply and lift is written to the panel's audit log with the policy id.
+"""
+import json
+import logging
+import os
+from datetime import datetime, timezone
+
+from app import paths
+from app.services.connect_commands import handler
+
+logger = logging.getLogger(__name__)
+
+PROFILE_FILENAME = 'managed-profile.json'
+
+# The launch capability set (plan 25 §Vocabulary). Cloud validates against its
+# own copy before signing; the panel stores what it received and the UI maps
+# the ids it knows, so an id from a newer Cloud simply maps to nothing here.
+CAPABILITIES = (
+ 'fleet', # Servers, Agent Fleet, Fleet Proxy
+ 'provisioning', # Cloud Servers
+ 'config_templates', # Server Templates
+ 'updates', # panel/agent update screens and self-update
+ 'backups_schedule', # backup scheduling and destinations; restore stays
+ 'monitoring_alerts', # alert rules and channels; live charts stay
+ 'firewall',
+)
+
+ACTION_APPLIED = 'connect.managed_profile_applied'
+ACTION_CLEARED = 'connect.managed_profile_cleared'
+
+
+def profile_path() -> str:
+ return os.path.join(paths.SERVERKIT_CONFIG_DIR, PROFILE_FILENAME)
+
+
+def _read() -> dict:
+ path = profile_path()
+ if not os.path.exists(path):
+ return {}
+ try:
+ with open(path, 'r', encoding='utf-8') as f:
+ data = json.load(f)
+ return data if isinstance(data, dict) else {}
+ except (OSError, ValueError) as exc:
+ logger.warning('Could not read %s: %s', path, exc)
+ return {}
+
+
+def _write(payload: dict):
+ path = profile_path()
+ os.makedirs(os.path.dirname(path), exist_ok=True)
+ with open(path, 'w', encoding='utf-8') as f:
+ json.dump(payload, f, indent=2)
+ f.write('\n')
+ if os.name != 'nt':
+ os.chmod(path, 0o600)
+
+
+def _parse_dt(value):
+ if not value:
+ return None
+ try:
+ dt = datetime.fromisoformat(str(value).replace('Z', '+00:00'))
+ except ValueError:
+ return None
+ if dt.tzinfo is None:
+ dt = dt.replace(tzinfo=timezone.utc)
+ return dt
+
+
+def _audit(action: str, details: dict, app=None):
+ """Best effort and never in the policy's way: a profile that applied but
+ could not be audited still applies, and the warning says why."""
+ try:
+ if app is None:
+ from flask import has_app_context
+ if not has_app_context():
+ return
+ from app.services.audit_service import AuditService
+ def go():
+ AuditService.log(action, target_type='connect',
+ target_id=details.get('policy_id'), details=details)
+ if app is not None:
+ with app.app_context():
+ go()
+ else:
+ go()
+ except Exception:
+ logger.warning('Managed profile: could not write the audit entry', exc_info=True)
+
+
+def clear(reason: str, app=None, policy_id: str = None) -> bool:
+ """Remove the profile document. Returns True when one was there to lift.
+
+ Every lift is audited with the policy id that was in force and the reason
+ ('cleared' from Cloud, 'revoked', 'disconnected')."""
+ doc = _read()
+ path = profile_path()
+ if os.path.exists(path):
+ try:
+ os.unlink(path)
+ except OSError as exc:
+ logger.warning('Could not remove %s: %s', path, exc)
+ return False
+ if doc:
+ _audit(ACTION_CLEARED, {
+ 'policy_id': doc.get('policy_id') or policy_id,
+ 'reason': reason,
+ 'capabilities': doc.get('capabilities') or [],
+ }, app=app)
+ return bool(doc)
+
+
+def current(now: datetime = None) -> dict:
+ """The effective profile for the API and the UI.
+
+ ``active`` is the single question the sidebar, pages and in-page controls
+ ask. An expired policy or a live override both answer False — the full
+ panel shows — while the document's detail still comes back so Settings
+ can say exactly what lapsed or who lifted it and until when.
+ """
+ doc = _read()
+ if not doc:
+ return {
+ 'profile': 'none',
+ 'active': False,
+ 'capabilities': [],
+ 'held_by': None,
+ 'since': None,
+ 'expires_at': None,
+ 'support_url': None,
+ 'policy_id': None,
+ 'applied_at': None,
+ 'expired': False,
+ 'override': None,
+ }
+ now = now or datetime.now(timezone.utc)
+ expires_at = _parse_dt(doc.get('expires_at'))
+ expired = expires_at is None or now >= expires_at
+ override = doc.get('override') if isinstance(doc.get('override'), dict) else None
+ override_until = _parse_dt((override or {}).get('until'))
+ override_active = override is not None and override_until is not None and now < override_until
+ capabilities = [c for c in (doc.get('capabilities') or []) if isinstance(c, str)]
+ active = bool(capabilities) and not expired and not override_active
+ return {
+ 'profile': doc.get('profile') or 'managed',
+ 'active': active,
+ # The UI only ever reads held capabilities when active is True; the
+ # full list stays in the document either way for Settings.
+ 'capabilities': capabilities if active else [],
+ 'held_capabilities': capabilities,
+ 'held_by': doc.get('held_by'),
+ 'since': doc.get('since'),
+ 'expires_at': doc.get('expires_at'),
+ 'support_url': doc.get('support_url'),
+ 'policy_id': doc.get('policy_id'),
+ 'applied_at': doc.get('applied_at'),
+ 'expired': expired,
+ 'override': {'until': override.get('until'), 'reason': override.get('reason')}
+ if override_active else None,
+ }
+
+
+def _validate(args: dict) -> dict:
+ """The document to store, or ValueError with the sentence Cloud shows."""
+ policy_id = str(args.get('policy_id') or '').strip()
+ if not policy_id:
+ raise ValueError('The policy carried no id, so it was not applied.')
+ profile = str(args.get('profile') or 'managed')
+ if profile == 'none':
+ return {'policy_id': policy_id, 'profile': 'none', 'capabilities': []}
+ if profile != 'managed':
+ raise ValueError(f'Unknown managed profile {profile!r}.')
+ capabilities = args.get('capabilities')
+ if not isinstance(capabilities, list) or not all(isinstance(c, str) for c in capabilities):
+ raise ValueError('The policy capabilities were not a list of names.')
+ expires_at = _parse_dt(args.get('expires_at'))
+ if expires_at is None:
+ # A policy without an end never lifts itself, which is exactly the
+ # failure the expiry rule exists to prevent. Refuse it rather than
+ # store it.
+ raise ValueError('The policy carried no expiry, so it was not applied.')
+ override = args.get('override')
+ if override is not None:
+ if not isinstance(override, dict) or _parse_dt(override.get('until')) is None:
+ raise ValueError('The policy override carried no end time, so it was not applied.')
+ override = {'until': override.get('until'),
+ 'reason': str(override.get('reason') or '')[:500]}
+ return {
+ 'policy_id': policy_id,
+ 'profile': 'managed',
+ 'capabilities': sorted({c.strip() for c in capabilities if c.strip()}),
+ 'held_by': str(args.get('held_by') or 'ServerKit Cloud')[:200],
+ 'since': args.get('since'),
+ 'expires_at': expires_at.isoformat(),
+ 'support_url': (str(args.get('support_url'))[:500] if args.get('support_url') else None),
+ 'override': override,
+ }
+
+
+@handler('ui.managed_profile')
+def apply_profile(args: dict, app=None) -> dict:
+ """Apply (or clear) the managed profile Cloud signed.
+
+ The result output is a small JSON document carrying the policy id back as
+ the acknowledgement; Cloud matches it against what it sent and re-sends
+ once when they disagree."""
+ try:
+ doc = _validate(args or {})
+ except ValueError as exc:
+ return {'ok': False, 'code': 400, 'summary': str(exc)}
+
+ if doc['profile'] == 'none' or not doc['capabilities']:
+ had = clear('cleared', app=app, policy_id=doc['policy_id'])
+ summary = ('The managed profile was lifted; the full panel is back.'
+ if had else 'No managed profile was in force; nothing changed.')
+ return {'ok': True, 'summary': summary,
+ 'output': json.dumps({'policy_id': doc['policy_id'], 'applied': False})}
+
+ doc['applied_at'] = datetime.now(timezone.utc).isoformat()
+ try:
+ _write(doc)
+ except OSError as exc:
+ return {'ok': False, 'code': 500,
+ 'summary': f'The profile could not be stored ({exc}), so nothing was applied.'}
+ _audit(ACTION_APPLIED, {
+ 'policy_id': doc['policy_id'],
+ 'capabilities': doc['capabilities'],
+ 'held_by': doc['held_by'],
+ 'since': doc['since'],
+ 'expires_at': doc['expires_at'],
+ 'override': doc['override'],
+ }, app=app)
+ return {'ok': True,
+ 'summary': f"Managed profile applied: {len(doc['capabilities'])} "
+ f"capabilit{'y is' if len(doc['capabilities']) == 1 else 'ies are'} "
+ f"held by {doc['held_by']} until {doc['expires_at']}.",
+ 'output': json.dumps({'policy_id': doc['policy_id'], 'applied': True})}
diff --git a/backend/tests/test_connect_managed_profile.py b/backend/tests/test_connect_managed_profile.py
new file mode 100644
index 00000000..bb96d7d1
--- /dev/null
+++ b/backend/tests/test_connect_managed_profile.py
@@ -0,0 +1,137 @@
+"""Managed profile (plan 25 M1): apply, expiry, override, clear, audit."""
+import json
+from datetime import datetime, timedelta, timezone
+
+import pytest
+
+from app.services import connect_managed_profile as mp
+
+
+@pytest.fixture(autouse=True)
+def profile_dir(tmp_path, monkeypatch):
+ monkeypatch.setattr(mp.paths, 'SERVERKIT_CONFIG_DIR', str(tmp_path))
+ yield tmp_path
+
+
+def _args(**over):
+ base = {
+ 'policy_id': 'mpol_test1',
+ 'profile': 'managed',
+ 'capabilities': ['fleet', 'updates'],
+ 'held_by': 'ServerKit Cloud',
+ 'since': '2026-10-01T00:00:00Z',
+ 'expires_at': (datetime.now(timezone.utc) + timedelta(days=7)).isoformat(),
+ 'support_url': 'https://app.serverkit.ai/orgs/acme/servers/dev_1',
+ 'override': None,
+ }
+ base.update(over)
+ return base
+
+
+def _read_doc(profile_dir):
+ with open(profile_dir / 'managed-profile.json', 'r', encoding='utf-8') as f:
+ return json.load(f)
+
+
+def test_apply_stores_document_and_acks_policy_id(profile_dir):
+ out = mp.apply_profile(_args())
+ assert out['ok'] is True
+ doc = _read_doc(profile_dir)
+ assert doc['policy_id'] == 'mpol_test1'
+ assert doc['capabilities'] == ['fleet', 'updates']
+ assert doc['override'] is None
+ assert doc['applied_at']
+ ack = json.loads(out['output'])
+ assert ack == {'policy_id': 'mpol_test1', 'applied': True}
+
+
+def test_current_active_profile():
+ mp.apply_profile(_args())
+ state = mp.current()
+ assert state['active'] is True
+ assert state['capabilities'] == ['fleet', 'updates']
+ assert state['held_by'] == 'ServerKit Cloud'
+ assert state['expired'] is False
+ assert state['override'] is None
+
+
+def test_no_profile_is_inactive():
+ state = mp.current()
+ assert state['active'] is False
+ assert state['profile'] == 'none'
+ assert state['capabilities'] == []
+
+
+def test_expired_policy_holds_nothing_but_stays_visible():
+ mp.apply_profile(_args(expires_at=(datetime.now(timezone.utc)
+ - timedelta(hours=1)).isoformat()))
+ state = mp.current()
+ assert state['active'] is False
+ assert state['capabilities'] == []
+ assert state['expired'] is True
+ # The document's detail survives so Settings can say what lapsed.
+ assert state['held_capabilities'] == ['fleet', 'updates']
+ assert state['policy_id'] == 'mpol_test1'
+
+
+def test_override_lifts_profile_until_its_end():
+ until = (datetime.now(timezone.utc) + timedelta(hours=2)).isoformat()
+ mp.apply_profile(_args(override={'until': until, 'reason': 'support session'}))
+ state = mp.current()
+ assert state['active'] is False
+ assert state['capabilities'] == []
+ assert state['override'] == {'until': until, 'reason': 'support session'}
+
+
+def test_elapsed_override_returns_the_profile():
+ until = (datetime.now(timezone.utc) - timedelta(minutes=5)).isoformat()
+ mp.apply_profile(_args(override={'until': until, 'reason': 'support session'}))
+ state = mp.current()
+ assert state['active'] is True
+ assert state['capabilities'] == ['fleet', 'updates']
+ assert state['override'] is None
+
+
+def test_profile_none_clears_and_acks(profile_dir):
+ mp.apply_profile(_args())
+ out = mp.apply_profile(_args(policy_id='mpol_clear1', profile='none',
+ capabilities=[]))
+ assert out['ok'] is True
+ assert 'lifted' in out['summary']
+ assert not (profile_dir / 'managed-profile.json').exists()
+ assert mp.current()['active'] is False
+ ack = json.loads(out['output'])
+ assert ack == {'policy_id': 'mpol_clear1', 'applied': False}
+
+
+def test_empty_capability_list_clears():
+ mp.apply_profile(_args())
+ out = mp.apply_profile(_args(capabilities=[]))
+ assert out['ok'] is True
+ assert mp.current()['active'] is False
+
+
+def test_policy_without_expiry_is_refused(profile_dir):
+ out = mp.apply_profile(_args(expires_at=None))
+ assert out['ok'] is False
+ assert 'expiry' in out['summary']
+ assert not (profile_dir / 'managed-profile.json').exists()
+
+
+def test_policy_without_id_is_refused(profile_dir):
+ out = mp.apply_profile(_args(policy_id=''))
+ assert out['ok'] is False
+ assert not (profile_dir / 'managed-profile.json').exists()
+
+
+def test_override_without_end_is_refused(profile_dir):
+ out = mp.apply_profile(_args(override={'reason': 'support session'}))
+ assert out['ok'] is False
+ assert not (profile_dir / 'managed-profile.json').exists()
+
+
+def test_clear_reports_whether_one_was_in_force():
+ assert mp.clear('revoked') is False
+ mp.apply_profile(_args())
+ assert mp.clear('revoked') is True
+ assert mp.current()['active'] is False
diff --git a/frontend/src/components/ManagedCard.jsx b/frontend/src/components/ManagedCard.jsx
new file mode 100644
index 00000000..183ac213
--- /dev/null
+++ b/frontend/src/components/ManagedCard.jsx
@@ -0,0 +1,61 @@
+import { CloudCog, ExternalLink } from 'lucide-react';
+import { useTranslation } from 'react-i18next';
+import useLabel from '../i18n/labels';
+import { MANAGED_CAPABILITY_MAP } from './sidebarItems';
+
+// The managed card (plan 25): what a held capability's page shows instead of
+// its content. It names the capability, who holds it and since when, and links
+// to the server's page in ServerKit Cloud through the support URL the signed
+// policy carried. It never claims the page is gone — hidden is not removed:
+// the route still renders, the API still answers, and support, break-glass and
+// exports keep working.
+//
+// `compact` is the card in miniature, for the spot an in-page control used to
+// occupy (the self-update section in Settings › About, backup scheduling).
+export default function ManagedCard({ capability, profile, compact = false }) {
+ const { t } = useTranslation();
+ const label_ = useLabel();
+ const entry = MANAGED_CAPABILITY_MAP[capability];
+ const label = entry ? label_(entry) : capability;
+ const heldBy = profile?.held_by || 'ServerKit Cloud';
+ const since = profile?.since
+ ? new Date(profile.since).toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' })
+ : null;
+
+ return (
+
+
+
+
+
+ {t('managed.card.title', '{{label}} is managed by {{heldBy}}', { label, heldBy })}
+
+
+ {since
+ ? t('managed.card.bodySince', 'ServerKit Cloud took this over on {{date}} as part of your Managed service. The page is hidden, not removed — its API still answers and everything returns the moment the Managed scope ends.', { date: since })
+ : t('managed.card.body', 'ServerKit Cloud takes care of this as part of your Managed service. The page is hidden, not removed — its API still answers and everything returns the moment the Managed scope ends.')}
+ {capability === 'updates' && (
+ <> {t('managed.card.updatesNote', 'The panel still updates itself; only the update screens are hidden.')}>
+ )}
+