diff --git a/backend/account_lifecycle.py b/backend/account_lifecycle.py
new file mode 100644
index 0000000..00bfcf2
--- /dev/null
+++ b/backend/account_lifecycle.py
@@ -0,0 +1,77 @@
+"""
+Account-Lifecycle (CAPABILITY_CATALOG.v1.md §3, M3 C0).
+
+Zustände: unverified → verified_pending_club → active_member; platform_admin separat.
+"""
+from __future__ import annotations
+
+import os
+from typing import TYPE_CHECKING, Optional
+
+from fastapi import HTTPException
+
+from club_tenancy import is_platform_admin
+
+if TYPE_CHECKING:
+ from tenant_context import TenantContext
+
+_ACCOUNT_STATE_RANK = {
+ "unverified": 1,
+ "verified_pending_club": 2,
+ "active_member": 3,
+ "platform_admin": 4,
+}
+
+
+def resolve_account_state(
+ *,
+ email_verified: bool,
+ global_role: str,
+ has_active_membership: bool,
+) -> str:
+ """Ermittelt account_state für ein Profil."""
+ if is_platform_admin(global_role):
+ return "platform_admin"
+ if not email_verified:
+ return "unverified"
+ if not has_active_membership:
+ return "verified_pending_club"
+ return "active_member"
+
+
+def account_state_satisfies(current: str, required: str) -> bool:
+ """True wenn current mindestens required ist."""
+ cur = _ACCOUNT_STATE_RANK.get(current, 0)
+ req = _ACCOUNT_STATE_RANK.get(required, 99)
+ if current == "platform_admin":
+ return True
+ return cur >= req
+
+
+def account_gate_enforcement_enabled() -> bool:
+ """Account-Gates aktiv (Default an — nur wenige Endpoints in M3)."""
+ return os.getenv("ACCOUNT_GATE_ENFORCE", "1").strip() == "1"
+
+
+def assert_min_account_state(
+ tenant: "TenantContext",
+ min_state: str,
+ *,
+ endpoint: Optional[str] = None,
+) -> None:
+ """
+ Prüft Mindest-Account-Status. Wirft 403 wenn ACCOUNT_GATE_ENFORCE=1 (Default).
+ """
+ current = getattr(tenant, "account_state", "active_member")
+ ok = account_state_satisfies(current, min_state)
+ if ok:
+ return
+ if not account_gate_enforcement_enabled():
+ return
+ detail = (
+ f"Account-Status „{current}“ reicht nicht für diese Aktion "
+ f"(erforderlich: {min_state})."
+ )
+ if endpoint:
+ detail = f"{detail} ({endpoint})"
+ raise HTTPException(status_code=403, detail=detail)
diff --git a/backend/capabilities.py b/backend/capabilities.py
new file mode 100644
index 0000000..83d6f2a
--- /dev/null
+++ b/backend/capabilities.py
@@ -0,0 +1,232 @@
+"""
+Capability-Auflösung (CAPABILITY_CATALOG.v1.md, M3 C1).
+
+Phase 2: probe_capability — JSON-Log, kein Block (CAPABILITY_ENFORCE=0).
+Phase 3+: CAPABILITY_ENFORCE=1 — HTTP 403 bei fehlender Capability.
+"""
+from __future__ import annotations
+
+import os
+from typing import Any, Dict, List, Optional, TYPE_CHECKING
+
+from fastapi import HTTPException
+
+from account_lifecycle import account_state_satisfies
+from club_tenancy import is_platform_admin
+from db import get_db, get_cursor
+
+if TYPE_CHECKING:
+ from tenant_context import TenantContext
+
+
+def capability_enforcement_enabled() -> bool:
+ return os.getenv("CAPABILITY_ENFORCE", "0").strip() == "1"
+
+
+def club_roles_in_club(tenant: "TenantContext", club_id: Optional[int]) -> List[str]:
+ if club_id is None:
+ return []
+ for m in tenant.memberships or []:
+ if int(m.get("id") or 0) == int(club_id):
+ roles = m.get("roles") or []
+ if hasattr(roles, "tolist"):
+ roles = roles.tolist()
+ return list(roles)
+ return []
+
+
+def check_capability(
+ cur,
+ tenant: "TenantContext",
+ capability_id: str,
+ *,
+ club_id: Optional[int] = None,
+) -> Dict[str, Any]:
+ """
+ Prüft eine Capability für Tenant + optionalen Vereinskontext.
+
+ Returns: allowed, reason, account_state, club_roles, linked_feature_id
+ """
+ account_state = getattr(tenant, "account_state", "active_member")
+ eff_club = club_id if club_id is not None else tenant.effective_club_id
+ club_roles = club_roles_in_club(tenant, eff_club) if eff_club is not None else []
+
+ cur.execute(
+ """
+ SELECT id, min_account_state, linked_feature_id, active, domain
+ FROM capabilities
+ WHERE id = %s
+ """,
+ (capability_id,),
+ )
+ cap = cur.fetchone()
+ if not cap or not cap.get("active"):
+ return {
+ "allowed": False,
+ "reason": "capability_not_found",
+ "account_state": account_state,
+ "club_roles": club_roles,
+ "linked_feature_id": None,
+ }
+
+ min_state = cap.get("min_account_state") or "active_member"
+ if not account_state_satisfies(account_state, min_state):
+ return {
+ "allowed": False,
+ "reason": "account_state_insufficient",
+ "account_state": account_state,
+ "club_roles": club_roles,
+ "linked_feature_id": cap.get("linked_feature_id"),
+ }
+
+ domain = (cap.get("domain") or "").strip().lower()
+
+ # Plattform-Capabilities
+ if domain == "platform" or capability_id.startswith("platform."):
+ role_lc = (tenant.global_role or "").lower()
+ if not is_platform_admin(role_lc):
+ return {
+ "allowed": False,
+ "reason": "portal_role_required",
+ "account_state": account_state,
+ "club_roles": club_roles,
+ "linked_feature_id": cap.get("linked_feature_id"),
+ }
+ cur.execute(
+ """
+ SELECT 1 FROM portal_role_capability_grants
+ WHERE portal_role = %s AND capability_id = %s
+ LIMIT 1
+ """,
+ (role_lc, capability_id),
+ )
+ if not cur.fetchone():
+ return {
+ "allowed": False,
+ "reason": "portal_capability_denied",
+ "account_state": account_state,
+ "club_roles": club_roles,
+ "linked_feature_id": cap.get("linked_feature_id"),
+ }
+ return {
+ "allowed": True,
+ "reason": "portal_granted",
+ "account_state": account_state,
+ "club_roles": club_roles,
+ "linked_feature_id": cap.get("linked_feature_id"),
+ }
+
+ # Plattform-Admin-Bypass für Mandanten-Funktionen (Audit-Pflicht, s. Katalog §9)
+ if is_platform_admin(tenant.global_role):
+ return {
+ "allowed": True,
+ "reason": "platform_admin_bypass",
+ "account_state": account_state,
+ "club_roles": club_roles,
+ "linked_feature_id": cap.get("linked_feature_id"),
+ }
+
+ # Vereins-Capabilities: aktive Mitgliedschaft im Zielverein
+ if min_state == "active_member":
+ if eff_club is None:
+ return {
+ "allowed": False,
+ "reason": "no_club_context",
+ "account_state": account_state,
+ "club_roles": club_roles,
+ "linked_feature_id": cap.get("linked_feature_id"),
+ }
+ if eff_club not in tenant.club_ids:
+ return {
+ "allowed": False,
+ "reason": "not_club_member",
+ "account_state": account_state,
+ "club_roles": club_roles,
+ "linked_feature_id": cap.get("linked_feature_id"),
+ }
+
+ cur.execute(
+ """
+ SELECT role_code FROM club_role_capability_grants
+ WHERE capability_id = %s
+ """,
+ (capability_id,),
+ )
+ required_roles = [r["role_code"] for r in cur.fetchall()]
+
+ if required_roles:
+ if not any(r in required_roles for r in club_roles):
+ return {
+ "allowed": False,
+ "reason": "club_role_denied",
+ "account_state": account_state,
+ "club_roles": club_roles,
+ "linked_feature_id": cap.get("linked_feature_id"),
+ }
+ elif min_state == "active_member" and eff_club is not None:
+ # Offene Capability für alle aktiven Mitglieder — Mitgliedschaft reicht
+ pass
+
+ return {
+ "allowed": True,
+ "reason": "granted",
+ "account_state": account_state,
+ "club_roles": club_roles,
+ "linked_feature_id": cap.get("linked_feature_id"),
+ }
+
+
+def resolve_capabilities_map(
+ cur,
+ tenant: "TenantContext",
+ *,
+ club_id: Optional[int] = None,
+) -> Dict[str, bool]:
+ """Alle aktiven Capabilities → bool (für späteres /me/entitlements)."""
+ cur.execute("SELECT id FROM capabilities WHERE active = true ORDER BY id")
+ ids = [r["id"] for r in cur.fetchall()]
+ out: Dict[str, bool] = {}
+ for cid in ids:
+ res = check_capability(cur, tenant, cid, club_id=club_id)
+ out[cid] = bool(res.get("allowed"))
+ return out
+
+
+def probe_capability(
+ tenant: "TenantContext",
+ capability_id: str,
+ *,
+ action: str,
+ club_id: Optional[int] = None,
+ endpoint: Optional[str] = None,
+ conn=None,
+) -> Dict[str, Any]:
+ """Phase 2: Capability prüfen + JSON-Log; blockiert nur bei CAPABILITY_ENFORCE=1."""
+ from capability_logger import log_capability_check
+
+ def _run(c):
+ cur = get_cursor(c)
+ result = check_capability(cur, tenant, capability_id, club_id=club_id)
+ log_capability_check(
+ club_id=club_id if club_id is not None else tenant.effective_club_id,
+ profile_id=tenant.profile_id,
+ capability_id=capability_id,
+ action=action,
+ result=result,
+ endpoint=endpoint,
+ phase="enforce" if capability_enforcement_enabled() else "probe",
+ )
+ if capability_enforcement_enabled() and not result.get("allowed"):
+ raise HTTPException(
+ status_code=403,
+ detail=(
+ f"Keine Berechtigung für {capability_id} "
+ f"({result.get('reason', 'denied')})."
+ ),
+ )
+ return result
+
+ if conn is not None:
+ return _run(conn)
+ with get_db() as c:
+ return _run(c)
diff --git a/backend/capability_logger.py b/backend/capability_logger.py
new file mode 100644
index 0000000..ccc905d
--- /dev/null
+++ b/backend/capability_logger.py
@@ -0,0 +1,64 @@
+"""
+JSON-Log für Capability-Checks (M3 Phase 2 — analog club_feature_logger).
+"""
+from __future__ import annotations
+
+import json
+import logging
+import os
+from datetime import datetime, timezone
+from pathlib import Path
+from typing import Any, Dict, Optional
+
+
+def _log_dir() -> Path:
+ custom = (os.getenv("CAPABILITY_LOG_DIR") or "").strip()
+ if custom:
+ return Path(custom)
+ return Path("/app/logs")
+
+
+capability_logger = logging.getLogger("shinkan.capability_usage")
+capability_logger.setLevel(logging.INFO)
+capability_logger.propagate = False
+
+if not capability_logger.handlers:
+ log_dir = _log_dir()
+ try:
+ log_dir.mkdir(parents=True, exist_ok=True)
+ log_file = log_dir / "capability-usage.log"
+ file_handler = logging.FileHandler(log_file, encoding="utf-8")
+ file_handler.setLevel(logging.INFO)
+ file_handler.setFormatter(logging.Formatter("%(message)s"))
+ capability_logger.addHandler(file_handler)
+ except OSError:
+ stream_handler = logging.StreamHandler()
+ stream_handler.setFormatter(logging.Formatter("[capability-usage] %(message)s"))
+ capability_logger.addHandler(stream_handler)
+
+
+def log_capability_check(
+ *,
+ club_id: Optional[int],
+ profile_id: Optional[int],
+ capability_id: str,
+ action: str,
+ result: Dict[str, Any],
+ endpoint: Optional[str] = None,
+ phase: str = "probe",
+) -> None:
+ entry = {
+ "timestamp": datetime.now(timezone.utc).isoformat(),
+ "club_id": club_id,
+ "profile_id": profile_id,
+ "capability": capability_id,
+ "action": action,
+ "endpoint": endpoint,
+ "phase": phase,
+ "allowed": result.get("allowed", True),
+ "reason": result.get("reason", "unknown"),
+ "account_state": result.get("account_state"),
+ "club_roles": result.get("club_roles"),
+ "enforcement": os.getenv("CAPABILITY_ENFORCE", "0") == "1",
+ }
+ capability_logger.info(json.dumps(entry, ensure_ascii=False))
diff --git a/backend/club_features.py b/backend/club_features.py
index 0bda5df..bc1ef25 100644
--- a/backend/club_features.py
+++ b/backend/club_features.py
@@ -446,8 +446,9 @@ def increment_club_feature_usage(
_run(c)
-def list_club_entitlements(cur, club_id: int) -> Dict[str, Any]:
- """Alle aktiven Shinkan-Features mit effektivem Limit und Verbrauch (für API/UI)."""
+def list_club_entitlements(cur, club_id: int, *, conn=None) -> Dict[str, Any]:
+ """Alle aktiven Shinkan-Features mit effektivem Limit und Verbrauch (Liste, intern)."""
+ db_conn = conn if conn is not None else cur.connection
plan_id = get_effective_club_plan(cur, club_id)
cur.execute(
"""
@@ -461,7 +462,7 @@ def list_club_entitlements(cur, club_id: int) -> Dict[str, Any]:
features_out = []
for row in rows:
fid = row["id"]
- access = _check_club_impl(club_id, fid, cur.connection)
+ access = _check_club_impl(club_id, fid, db_conn)
features_out.append(
{
"id": fid,
@@ -474,6 +475,32 @@ def list_club_entitlements(cur, club_id: int) -> Dict[str, Any]:
"used": access.get("used"),
"remaining": access.get("remaining"),
"reason": access.get("reason"),
+ "reset_at": access.get("reset_at"),
}
)
return {"club_id": club_id, "plan_id": plan_id, "features": features_out}
+
+
+def club_features_map(cur, club_id: int, *, conn=None) -> Dict[str, Any]:
+ """Feature-Kontingente als Dict feature_id → Zustand (für /me/entitlements)."""
+ raw = list_club_entitlements(cur, club_id, conn=conn)
+ features_dict: Dict[str, Any] = {}
+ for row in raw.get("features") or []:
+ fid = row["id"]
+ features_dict[fid] = {
+ "name": row.get("name"),
+ "category": row.get("category"),
+ "limit_type": row.get("limit_type"),
+ "reset_period": row.get("reset_period"),
+ "allowed": row.get("allowed"),
+ "limit": row.get("limit"),
+ "used": row.get("used"),
+ "remaining": row.get("remaining"),
+ "reason": row.get("reason"),
+ "reset_at": row.get("reset_at"),
+ }
+ return {
+ "club_id": raw.get("club_id"),
+ "plan_id": raw.get("plan_id"),
+ "features": features_dict,
+ }
diff --git a/backend/entitlements.py b/backend/entitlements.py
new file mode 100644
index 0000000..60cced8
--- /dev/null
+++ b/backend/entitlements.py
@@ -0,0 +1,89 @@
+"""
+Zusammenstellung effektiver Rechte für GET /api/me/entitlements (M4).
+
+Spez: CAPABILITY_CATALOG.v1.md §7.1, CLUB_MEMBERSHIP_AND_FEATURES.v1.md §8.1
+"""
+from __future__ import annotations
+
+from datetime import datetime
+from typing import Any, Dict, Optional, TYPE_CHECKING
+
+from fastapi import HTTPException
+
+from capabilities import club_roles_in_club, resolve_capabilities_map
+from club_features import club_features_map
+from club_tenancy import is_platform_admin
+from tenant_context import _club_exists
+
+if TYPE_CHECKING:
+ from tenant_context import TenantContext
+
+
+def _serialize_reset_at(value: Any) -> Optional[str]:
+ if value is None:
+ return None
+ if isinstance(value, datetime):
+ if value.tzinfo is None:
+ return value.replace(tzinfo=None).isoformat() + "Z"
+ return value.isoformat()
+ return str(value)
+
+
+def _resolve_target_club_id(
+ cur,
+ tenant: "TenantContext",
+ club_id: Optional[int],
+) -> Optional[int]:
+ """Effektiver Verein für Entitlements (Query > Tenant)."""
+ target = int(club_id) if club_id is not None else tenant.effective_club_id
+ if target is None:
+ return None
+
+ if is_platform_admin(tenant.global_role):
+ if not _club_exists(cur, target):
+ raise HTTPException(status_code=400, detail="Verein nicht gefunden")
+ return target
+
+ if target not in tenant.club_ids:
+ raise HTTPException(status_code=403, detail="Keine Mitgliedschaft in diesem Verein")
+ return target
+
+
+def build_me_entitlements(
+ cur,
+ tenant: "TenantContext",
+ *,
+ club_id: Optional[int] = None,
+) -> Dict[str, Any]:
+ """
+ Kombiniert Account-Status, Capabilities und Feature-Kontingente.
+ """
+ target_club = _resolve_target_club_id(cur, tenant, club_id)
+ club_roles = club_roles_in_club(tenant, target_club) if target_club is not None else []
+
+ capabilities = resolve_capabilities_map(cur, tenant, club_id=target_club)
+
+ features: Dict[str, Any] = {}
+ plan_id = None
+ if target_club is not None:
+ raw = club_features_map(cur, target_club)
+ plan_id = raw.get("plan_id")
+ for fid, row in (raw.get("features") or {}).items():
+ features[fid] = {
+ "allowed": row.get("allowed"),
+ "used": row.get("used"),
+ "limit": row.get("limit"),
+ "remaining": row.get("remaining"),
+ "reset_at": _serialize_reset_at(row.get("reset_at")),
+ "reason": row.get("reason"),
+ }
+
+ return {
+ "account_state": tenant.account_state,
+ "portal_role": tenant.global_role,
+ "club_id": target_club,
+ "plan_id": plan_id,
+ "club_roles": club_roles,
+ "capabilities": capabilities,
+ "features": features,
+ }
diff --git a/backend/main.py b/backend/main.py
index 93b1e36..81884bf 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -193,7 +193,7 @@ def read_root():
return out
# Register routers
-from routers import auth, profiles, exercises, exercise_progression_graphs, clubs, club_memberships, club_join_requests, admin_users, admin_user_content, platform_media_storage, media_assets, skills, skill_profiles, training_planning, planning_exercise_suggest, dashboard, training_modules, training_framework_programs, catalogs, maturity_models, matrix_stack_bundle, matrix_editor, import_wiki, import_wiki_admin, legal_documents, content_reports, ai_prompts_admin, ai_skill_retrieval_admin, exercise_enrichment_admin
+from routers import auth, profiles, exercises, exercise_progression_graphs, clubs, club_memberships, club_join_requests, admin_users, admin_user_content, me_entitlements, platform_media_storage, media_assets, skills, skill_profiles, training_planning, planning_exercise_suggest, dashboard, training_modules, training_framework_programs, catalogs, maturity_models, matrix_stack_bundle, matrix_editor, import_wiki, import_wiki_admin, legal_documents, content_reports, ai_prompts_admin, ai_skill_retrieval_admin, exercise_enrichment_admin
app.include_router(auth.router)
app.include_router(profiles.router)
@@ -204,6 +204,7 @@ app.include_router(club_memberships.router)
app.include_router(club_join_requests.router)
app.include_router(admin_users.router)
app.include_router(admin_user_content.router)
+app.include_router(me_entitlements.router)
app.include_router(platform_media_storage.router)
app.include_router(media_assets.router)
app.include_router(media_assets.admin_rights_router)
diff --git a/backend/migrations/079_capabilities.sql b/backend/migrations/079_capabilities.sql
new file mode 100644
index 0000000..4c6ae24
--- /dev/null
+++ b/backend/migrations/079_capabilities.sql
@@ -0,0 +1,211 @@
+-- Migration 079: Capability-Registry + Rollen-Grants (M3 / CAPABILITY_CATALOG.v1.md C1)
+-- Account-Gates und Enforcement in Python (account_lifecycle.py, capabilities.py).
+
+CREATE TABLE IF NOT EXISTS capabilities (
+ id TEXT PRIMARY KEY,
+ name TEXT NOT NULL,
+ description TEXT,
+ domain TEXT NOT NULL,
+ min_account_state TEXT NOT NULL DEFAULT 'active_member'
+ CHECK (min_account_state IN (
+ 'unverified', 'verified_pending_club', 'active_member', 'platform_admin'
+ )),
+ linked_feature_id TEXT REFERENCES features(id) ON DELETE SET NULL,
+ active BOOLEAN NOT NULL DEFAULT true,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+CREATE INDEX IF NOT EXISTS idx_capabilities_domain ON capabilities(domain) WHERE active = true;
+
+CREATE TABLE IF NOT EXISTS club_role_capability_grants (
+ role_code TEXT NOT NULL,
+ capability_id TEXT NOT NULL REFERENCES capabilities(id) ON DELETE CASCADE,
+ PRIMARY KEY (role_code, capability_id)
+);
+
+CREATE INDEX IF NOT EXISTS idx_club_role_cap_grants_cap ON club_role_capability_grants(capability_id);
+
+CREATE TABLE IF NOT EXISTS portal_role_capability_grants (
+ portal_role TEXT NOT NULL,
+ capability_id TEXT NOT NULL REFERENCES capabilities(id) ON DELETE CASCADE,
+ PRIMARY KEY (portal_role, capability_id)
+);
+
+-- ── Seed: Capabilities (v1 Katalog §5) ───────────────────────────────────────
+INSERT INTO capabilities (id, name, domain, min_account_state, linked_feature_id) VALUES
+ ('account.settings.read', 'Einstellungen lesen', 'account', 'unverified', NULL),
+ ('account.settings.update', 'Einstellungen ändern', 'account', 'unverified', NULL),
+ ('account.password.change', 'Passwort ändern', 'account', 'unverified', NULL),
+ ('account.resend_verification', 'Verifizierung erneut senden', 'account', 'unverified', NULL),
+ ('club.directory.read', 'Vereinsverzeichnis', 'club', 'verified_pending_club', NULL),
+ ('club.join_request.create', 'Vereinsbeitritt beantragen', 'club', 'verified_pending_club', NULL),
+ ('club.join_request.withdraw', 'Beitrittsantrag zurückziehen', 'club', 'verified_pending_club', NULL),
+ ('club.join_request.read_own', 'Eigene Beitrittsanträge', 'club', 'verified_pending_club', NULL),
+ ('org.club.read', 'Vereine lesen', 'org', 'active_member', NULL),
+ ('org.club.create', 'Verein anlegen', 'org', 'platform_admin', NULL),
+ ('org.club.update', 'Verein bearbeiten', 'org', 'active_member', NULL),
+ ('org.club.delete', 'Verein löschen', 'org', 'platform_admin', NULL),
+ ('org.structure.manage', 'Vereinsstruktur verwalten', 'org', 'active_member', 'training_groups'),
+ ('org.members.read', 'Mitgliederliste', 'org', 'active_member', NULL),
+ ('org.members.manage', 'Mitglieder verwalten', 'org', 'active_member', 'active_members'),
+ ('org.members.directory', 'Mitglieder-Verzeichnis', 'org', 'active_member', NULL),
+ ('org.join_request.review', 'Beitrittsanträge prüfen', 'org', 'active_member', NULL),
+ ('org.inbox.read', 'Posteingang', 'org', 'active_member', NULL),
+ ('exercises.read', 'Übungen lesen', 'exercises', 'active_member', NULL),
+ ('exercises.create', 'Übung anlegen', 'exercises', 'active_member', 'exercises'),
+ ('exercises.update', 'Übung bearbeiten', 'exercises', 'active_member', NULL),
+ ('exercises.delete', 'Übung löschen', 'exercises', 'active_member', NULL),
+ ('exercises.bulk_metadata', 'Übungen Stapel-Metadaten', 'exercises', 'active_member', NULL),
+ ('exercises.ai.suggest', 'KI-Vorschlag Übung', 'exercises', 'active_member', 'ai_calls'),
+ ('exercises.ai.regenerate', 'KI neu generieren', 'exercises', 'active_member', 'ai_calls'),
+ ('exercises.media.read', 'Übungsmedien lesen', 'exercises', 'active_member', NULL),
+ ('exercises.media.upload', 'Übungsmedien hochladen', 'exercises', 'active_member', 'exercise_media'),
+ ('exercises.variants.manage', 'Übungsvarianten', 'exercises', 'active_member', NULL),
+ ('media.library.read', 'Medienbibliothek lesen', 'media', 'active_member', NULL),
+ ('media.library.upload', 'Medienbibliothek Upload', 'media', 'active_member', 'exercise_media'),
+ ('media.library.update', 'Medienbibliothek bearbeiten', 'media', 'active_member', NULL),
+ ('media.library.lifecycle', 'Medien-Lifecycle', 'media', 'active_member', NULL),
+ ('media.rights.declare', 'Medienrechte erklären', 'media', 'active_member', NULL),
+ ('media.admin.rights_review', 'Medienrechte Review (Plattform)', 'media', 'platform_admin', NULL),
+ ('modules.read', 'Trainingsmodule lesen', 'modules', 'active_member', NULL),
+ ('modules.create', 'Trainingsmodul anlegen', 'modules', 'active_member', 'training_programs'),
+ ('modules.update', 'Trainingsmodul bearbeiten', 'modules', 'active_member', NULL),
+ ('modules.delete', 'Trainingsmodul löschen', 'modules', 'active_member', NULL),
+ ('framework.read', 'Rahmenprogramme lesen', 'framework', 'active_member', NULL),
+ ('framework.create', 'Rahmenprogramm anlegen', 'framework', 'active_member', 'training_programs'),
+ ('framework.update', 'Rahmenprogramm bearbeiten', 'framework', 'active_member', NULL),
+ ('framework.delete', 'Rahmenprogramm löschen', 'framework', 'active_member', NULL),
+ ('plan_templates.read', 'Planungsvorlagen lesen', 'planning', 'active_member', NULL),
+ ('plan_templates.manage', 'Planungsvorlagen verwalten', 'planning', 'active_member', NULL),
+ ('progression.read', 'Progressionspfade lesen', 'progression', 'active_member', NULL),
+ ('progression.manage', 'Progressionspfade verwalten', 'progression', 'active_member', NULL),
+ ('planning.calendar.read', 'Planungskalender lesen', 'planning', 'active_member', NULL),
+ ('planning.units.create', 'Trainingseinheit anlegen', 'planning', 'active_member', 'training_units'),
+ ('planning.units.update', 'Trainingseinheit bearbeiten', 'planning', 'active_member', NULL),
+ ('planning.units.delete', 'Trainingseinheit löschen', 'planning', 'active_member', NULL),
+ ('planning.units.run', 'Training durchführen', 'planning', 'active_member', NULL),
+ ('planning.coach.execute', 'Coach ausführen', 'planning', 'active_member', NULL),
+ ('planning.ai.suggest', 'Planungs-KI Suggest', 'planning', 'active_member', 'ai_calls'),
+ ('planning.ai.progression_path', 'Planungs-KI Progressionspfad', 'planning', 'active_member', 'ai_calls'),
+ ('skills.catalog.read', 'Fähigkeitenkatalog', 'skills', 'active_member', NULL),
+ ('skills.discovery.read', 'Fähigkeiten-Discovery', 'skills', 'active_member', NULL),
+ ('skill_profiles.read', 'Skill-Profile lesen', 'skills', 'active_member', NULL),
+ ('governance.content_report.create', 'Inhalt melden', 'governance', 'active_member', NULL),
+ ('governance.content_report.review', 'Meldungen prüfen', 'governance', 'active_member', NULL),
+ ('platform.admin.access', 'Plattform-Admin-Bereich', 'platform', 'platform_admin', NULL),
+ ('platform.users.manage', 'Nutzer verwalten', 'platform', 'platform_admin', NULL),
+ ('platform.catalogs.manage', 'Kataloge verwalten', 'platform', 'platform_admin', NULL),
+ ('platform.maturity_models.manage', 'Reifegradmodelle', 'platform', 'platform_admin', NULL),
+ ('platform.wiki_import.execute', 'Wiki-Import', 'platform', 'platform_admin', 'wiki_import'),
+ ('platform.ai_prompts.manage', 'KI-Prompts verwalten', 'platform', 'platform_admin', NULL),
+ ('platform.exercise_enrichment.execute', 'Übungs-Anreicherung KI', 'platform', 'platform_admin', 'ai_calls'),
+ ('platform.user_content.moderate', 'Nutzer-Inhalte moderieren', 'platform', 'platform_admin', NULL),
+ ('platform.legal_documents.manage', 'Rechtstexte verwalten', 'platform', 'platform_admin', NULL),
+ ('platform.media_storage.manage', 'Medienspeicher verwalten', 'platform', 'platform_admin', NULL),
+ ('platform.club_creation.approve', 'Vereinsgründung freigeben', 'platform', 'platform_admin', NULL)
+ON CONFLICT (id) DO NOTHING;
+
+-- ── Vereinsrollen-Grants (§6 — nur eingeschränkte Capabilities) ─────────────
+-- Konvention: keine Grant-Zeile = alle aktiven Mitglieder (min_account_state reicht).
+
+INSERT INTO club_role_capability_grants (role_code, capability_id)
+SELECT r.role_code, c.cap_id
+FROM (VALUES
+ ('club_admin', 'org.structure.manage'),
+ ('division_lead', 'org.structure.manage'),
+ ('club_admin', 'org.members.manage'),
+ ('club_admin', 'org.join_request.review'),
+ ('club_admin', 'org.inbox.read'),
+ ('club_admin', 'exercises.create'),
+ ('trainer', 'exercises.create'),
+ ('content_editor', 'exercises.create'),
+ ('division_lead', 'exercises.create'),
+ ('club_admin', 'exercises.update'),
+ ('trainer', 'exercises.update'),
+ ('content_editor', 'exercises.update'),
+ ('division_lead', 'exercises.update'),
+ ('club_admin', 'exercises.delete'),
+ ('club_admin', 'exercises.bulk_metadata'),
+ ('content_editor', 'exercises.bulk_metadata'),
+ ('club_admin', 'exercises.ai.suggest'),
+ ('trainer', 'exercises.ai.suggest'),
+ ('content_editor', 'exercises.ai.suggest'),
+ ('division_lead', 'exercises.ai.suggest'),
+ ('club_admin', 'exercises.ai.regenerate'),
+ ('trainer', 'exercises.ai.regenerate'),
+ ('content_editor', 'exercises.ai.regenerate'),
+ ('division_lead', 'exercises.ai.regenerate'),
+ ('club_admin', 'exercises.media.upload'),
+ ('trainer', 'exercises.media.upload'),
+ ('content_editor', 'exercises.media.upload'),
+ ('club_admin', 'exercises.variants.manage'),
+ ('trainer', 'exercises.variants.manage'),
+ ('content_editor', 'exercises.variants.manage'),
+ ('club_admin', 'media.library.upload'),
+ ('trainer', 'media.library.upload'),
+ ('content_editor', 'media.library.upload'),
+ ('club_admin', 'media.library.update'),
+ ('trainer', 'media.library.update'),
+ ('content_editor', 'media.library.update'),
+ ('club_admin', 'media.library.lifecycle'),
+ ('trainer', 'media.library.lifecycle'),
+ ('club_admin', 'media.rights.declare'),
+ ('trainer', 'media.rights.declare'),
+ ('club_admin', 'modules.create'),
+ ('trainer', 'modules.create'),
+ ('content_editor', 'modules.create'),
+ ('club_admin', 'modules.update'),
+ ('trainer', 'modules.update'),
+ ('content_editor', 'modules.update'),
+ ('club_admin', 'modules.delete'),
+ ('club_admin', 'framework.create'),
+ ('trainer', 'framework.create'),
+ ('club_admin', 'framework.update'),
+ ('trainer', 'framework.update'),
+ ('club_admin', 'framework.delete'),
+ ('club_admin', 'plan_templates.manage'),
+ ('trainer', 'plan_templates.manage'),
+ ('club_admin', 'progression.manage'),
+ ('trainer', 'progression.manage'),
+ ('content_editor', 'progression.manage'),
+ ('club_admin', 'planning.units.create'),
+ ('trainer', 'planning.units.create'),
+ ('division_lead', 'planning.units.create'),
+ ('club_admin', 'planning.units.update'),
+ ('trainer', 'planning.units.update'),
+ ('division_lead', 'planning.units.update'),
+ ('club_admin', 'planning.units.delete'),
+ ('trainer', 'planning.units.delete'),
+ ('club_admin', 'planning.units.run'),
+ ('trainer', 'planning.units.run'),
+ ('division_lead', 'planning.units.run'),
+ ('club_admin', 'planning.coach.execute'),
+ ('trainer', 'planning.coach.execute'),
+ ('club_admin', 'planning.ai.suggest'),
+ ('trainer', 'planning.ai.suggest'),
+ ('division_lead', 'planning.ai.suggest'),
+ ('club_admin', 'planning.ai.progression_path'),
+ ('trainer', 'planning.ai.progression_path'),
+ ('division_lead', 'planning.ai.progression_path'),
+ ('club_admin', 'skills.discovery.read'),
+ ('trainer', 'skills.discovery.read'),
+ ('content_editor', 'skills.discovery.read'),
+ ('club_admin', 'governance.content_report.review')
+) AS r(role_code, cap_id)
+JOIN capabilities c ON c.id = r.cap_id
+ON CONFLICT DO NOTHING;
+
+-- org.club.update: club_admin (zusätzlich zu platform_admin via Bypass)
+INSERT INTO club_role_capability_grants (role_code, capability_id)
+VALUES ('club_admin', 'org.club.update')
+ON CONFLICT DO NOTHING;
+
+-- ── Portal-Rollen ───────────────────────────────────────────────────────────
+INSERT INTO portal_role_capability_grants (portal_role, capability_id)
+SELECT 'admin', id FROM capabilities WHERE id = 'platform.admin.access'
+ON CONFLICT DO NOTHING;
+
+INSERT INTO portal_role_capability_grants (portal_role, capability_id)
+SELECT 'superadmin', id FROM capabilities WHERE domain = 'platform'
+ON CONFLICT DO NOTHING;
diff --git a/backend/routers/exercises.py b/backend/routers/exercises.py
index 879296e..5b63e66 100644
--- a/backend/routers/exercises.py
+++ b/backend/routers/exercises.py
@@ -37,6 +37,8 @@ from media_rights import assert_rights_for_exercise_link, validate_rights_declar
from media_legal_hold import assert_not_under_legal_hold
from ai_prompt_context import ExerciseFormAiFocusRow, ExerciseFormAiPromptContext
from ai_prompt_job import run_exercise_form_ai_suggestion
+from account_lifecycle import assert_min_account_state
+from capabilities import probe_capability
from club_features import probe_club_feature_access, resolve_club_id_for_probe
from exercise_rich_text import (
@@ -2318,6 +2320,14 @@ def exercise_ai_suggest_endpoint(
KI-Vorschlaege (Kurzfassung und/oder Skill-Zuordnung) ohne Speichern.
OPENROUTER_API_KEY erforderlich.
"""
+ assert_min_account_state(tenant, "active_member", endpoint="POST /exercises/ai/suggest")
+ probe_capability(
+ tenant,
+ "exercises.ai.suggest",
+ action="suggest",
+ club_id=resolve_club_id_for_probe(tenant),
+ endpoint="POST /exercises/ai/suggest",
+ )
probe_club_feature_access(
feature_id="ai_calls",
action="suggest",
@@ -2344,6 +2354,14 @@ def exercise_ai_regenerate_endpoint(
tenant: TenantContext = Depends(get_tenant_context),
):
"""Neu-Anfrage KI fuer eine gespeicherte Uebung; schreibendes Ergebnis nur im Frontend (PUT)."""
+ assert_min_account_state(tenant, "active_member", endpoint="POST /exercises/{id}/ai/regenerate")
+ probe_capability(
+ tenant,
+ "exercises.ai.regenerate",
+ action="regenerate",
+ club_id=resolve_club_id_for_probe(tenant),
+ endpoint="POST /exercises/{id}/ai/regenerate",
+ )
probe_club_feature_access(
feature_id="ai_calls",
action="regenerate",
@@ -2436,6 +2454,14 @@ def create_exercise(
club_id = tenant.effective_club_id
if club_id is not None:
+ assert_min_account_state(tenant, "active_member", endpoint="POST /exercises")
+ probe_capability(
+ tenant,
+ "exercises.create",
+ action="create",
+ club_id=int(club_id),
+ endpoint="POST /exercises",
+ )
probe_club_feature_access(
feature_id="exercises",
action="create",
@@ -3245,6 +3271,15 @@ async def upload_exercise_media(
ex_club = cur.fetchone()
media_club_id = ex_club.get("club_id") if ex_club else None
if media_club_id is not None:
+ assert_min_account_state(tenant, "active_member", endpoint="POST /exercises/{id}/media")
+ probe_capability(
+ tenant,
+ "exercises.media.upload",
+ action="upload",
+ club_id=int(media_club_id),
+ endpoint="POST /exercises/{id}/media",
+ conn=conn,
+ )
probe_club_feature_access(
feature_id="exercise_media",
action="upload",
diff --git a/backend/routers/me_entitlements.py b/backend/routers/me_entitlements.py
new file mode 100644
index 0000000..90dd4a9
--- /dev/null
+++ b/backend/routers/me_entitlements.py
@@ -0,0 +1,27 @@
+"""
+GET /api/me/entitlements — effektive Capabilities + Feature-Kontingente (M4).
+"""
+from typing import Optional
+
+from fastapi import APIRouter, Depends, Query
+
+from db import get_db, get_cursor
+from entitlements import build_me_entitlements
+from tenant_context import TenantContext, get_tenant_context
+
+router = APIRouter(prefix="/api", tags=["entitlements"])
+
+
+@router.get("/me/entitlements")
+def get_me_entitlements(
+ tenant: TenantContext = Depends(get_tenant_context),
+ club_id: Optional[int] = Query(default=None, ge=1, description="Verein (Default: effective_club_id)"),
+):
+ """
+ Effektive Rechte für Frontend: Account-Status, Capabilities, Feature-Limits.
+
+ Spez: CAPABILITY_CATALOG.v1.md §7.1
+ """
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ return build_me_entitlements(cur, tenant, club_id=club_id)
diff --git a/backend/routers/planning_exercise_suggest.py b/backend/routers/planning_exercise_suggest.py
index 229219e..9bcba18 100644
--- a/backend/routers/planning_exercise_suggest.py
+++ b/backend/routers/planning_exercise_suggest.py
@@ -7,6 +7,8 @@ from db import get_db, get_cursor
from tenant_context import TenantContext, get_tenant_context
from planning_exercise_suggest import PlanningExerciseSuggestRequest, suggest_planning_exercises
from planning_exercise_path_builder import ProgressionPathSuggestRequest, suggest_progression_path
+from account_lifecycle import assert_min_account_state
+from capabilities import probe_capability
from club_features import probe_club_feature_access, resolve_club_id_for_probe
router = APIRouter(prefix="/api/planning", tags=["planning_exercise_suggest"])
@@ -18,6 +20,14 @@ def post_planning_exercise_suggest(
tenant: TenantContext = Depends(get_tenant_context),
):
if body.include_llm_intent or body.include_llm_rank:
+ assert_min_account_state(tenant, "active_member", endpoint="POST /planning/exercise-suggest")
+ probe_capability(
+ tenant,
+ "planning.ai.suggest",
+ action="planning_suggest",
+ club_id=resolve_club_id_for_probe(tenant),
+ endpoint="POST /planning/exercise-suggest",
+ )
probe_club_feature_access(
feature_id="ai_calls",
action="planning_suggest",
@@ -40,6 +50,16 @@ def post_progression_path_suggest(
or body.include_llm_path_qa
or body.include_ai_gap_fill
):
+ assert_min_account_state(
+ tenant, "active_member", endpoint="POST /planning/progression-path-suggest"
+ )
+ probe_capability(
+ tenant,
+ "planning.ai.progression_path",
+ action="progression_path_suggest",
+ club_id=resolve_club_id_for_probe(tenant),
+ endpoint="POST /planning/progression-path-suggest",
+ )
probe_club_feature_access(
feature_id="ai_calls",
action="progression_path_suggest",
diff --git a/backend/routers/profiles.py b/backend/routers/profiles.py
index c554b3e..ad4c950 100644
--- a/backend/routers/profiles.py
+++ b/backend/routers/profiles.py
@@ -22,6 +22,7 @@ from club_tenancy import (
is_superadmin,
memberships_with_roles,
)
+from capabilities import club_roles_in_club
from tenant_context import resolve_tenant_context, TenantContext, get_tenant_context
from models import ProfileCreate, ProfileUpdate
@@ -118,6 +119,11 @@ def get_current_profile(
invalid_header_policy="ignore",
)
data["effective_club_id"] = tenant.effective_club_id
+ data["account_state"] = tenant.account_state
+ if tenant.effective_club_id is not None:
+ data["club_roles"] = club_roles_in_club(tenant, tenant.effective_club_id)
+ else:
+ data["club_roles"] = []
return data
diff --git a/backend/tenant_context.py b/backend/tenant_context.py
index fe76393..175221e 100644
--- a/backend/tenant_context.py
+++ b/backend/tenant_context.py
@@ -12,6 +12,7 @@ from typing import Any, Dict, List, Optional
from fastapi import Depends, Header, HTTPException
from auth import require_auth, require_auth_flexible
+from account_lifecycle import resolve_account_state
from club_tenancy import is_platform_admin, memberships_with_roles
from db import get_db, get_cursor
@@ -142,6 +143,8 @@ class TenantContext:
effective_club_id: Optional[int]
club_ids: frozenset[int]
memberships: List[Dict[str, Any]]
+ email_verified: bool = True
+ account_state: str = "active_member"
def resolve_tenant_context(
@@ -153,6 +156,7 @@ def resolve_tenant_context(
memberships: Optional[List[Dict[str, Any]]] = None,
stored_active_club_id: Optional[int] = None,
invalid_header_policy: str = "reject",
+ email_verified: Optional[bool] = None,
) -> TenantContext:
"""
Mitgliedschaften: wenn nicht übergeben, lädt ``active_only=True`` aus der DB.
@@ -176,6 +180,21 @@ def resolve_tenant_context(
club_ids = frozenset(int(r["id"]) for r in membership_rows if r.get("id") is not None)
+ if email_verified is None:
+ cur.execute(
+ "SELECT COALESCE(email_verified, false) AS email_verified FROM profiles WHERE id = %s",
+ (profile_id,),
+ )
+ prof_row = cur.fetchone()
+ email_verified = bool(prof_row.get("email_verified")) if prof_row else False
+ else:
+ email_verified = bool(email_verified)
+ account_state = resolve_account_state(
+ email_verified=email_verified,
+ global_role=role_lc,
+ has_active_membership=len(club_ids) > 0,
+ )
+
if is_platform_admin(role_lc):
if header_cid is not None:
if not _club_exists(cur, header_cid):
@@ -194,6 +213,8 @@ def resolve_tenant_context(
effective_club_id=effective,
club_ids=club_ids,
memberships=membership_rows,
+ email_verified=email_verified,
+ account_state=account_state,
)
chosen_header = header_cid
@@ -222,6 +243,8 @@ def resolve_tenant_context(
effective_club_id=effective,
club_ids=club_ids,
memberships=membership_rows,
+ email_verified=email_verified,
+ account_state=account_state,
)
diff --git a/backend/tests/test_access_layer.py b/backend/tests/test_access_layer.py
index d74fe7a..915c0ee 100644
--- a/backend/tests/test_access_layer.py
+++ b/backend/tests/test_access_layer.py
@@ -92,6 +92,7 @@ def test_resolve_platform_admin_uses_stored_club_without_header(monkeypatch):
header_raw=None,
memberships=[{"id": 10}],
stored_active_club_id=99,
+ email_verified=True,
)
assert ctx.effective_club_id == 99
@@ -110,6 +111,7 @@ def test_resolve_platform_admin_header_overrides_stored(monkeypatch):
header_raw="5",
memberships=[{"id": 10}],
stored_active_club_id=99,
+ email_verified=True,
)
assert ctx.effective_club_id == 5
@@ -124,6 +126,7 @@ def test_resolve_platform_admin_no_header_stored_invalid(monkeypatch):
header_raw=None,
memberships=[{"id": 1}],
stored_active_club_id=123,
+ email_verified=True,
)
assert ctx.effective_club_id is None
@@ -142,6 +145,7 @@ def test_resolve_trainer_club_ids_excludes_inactive_memberships():
],
stored_active_club_id=None,
invalid_header_policy="ignore",
+ email_verified=True,
)
assert ctx.club_ids == frozenset({20})
assert ctx.effective_club_id == 20
@@ -157,6 +161,7 @@ def test_resolve_all_memberships_inactive_no_effective_club():
memberships=[{"id": 10, "membership_status": "inactive"}],
stored_active_club_id=10,
invalid_header_policy="ignore",
+ email_verified=True,
)
assert ctx.club_ids == frozenset()
assert ctx.effective_club_id is None
diff --git a/backend/tests/test_account_capabilities.py b/backend/tests/test_account_capabilities.py
new file mode 100644
index 0000000..6e3d92b
--- /dev/null
+++ b/backend/tests/test_account_capabilities.py
@@ -0,0 +1,86 @@
+"""Unit-Tests für Account-Lifecycle und Capability-Helfer (ohne DB)."""
+import pytest
+from fastapi import HTTPException
+
+from account_lifecycle import (
+ account_state_satisfies,
+ assert_min_account_state,
+ resolve_account_state,
+)
+from capabilities import club_roles_in_club
+from tenant_context import TenantContext
+
+
+def test_resolve_account_state_platform_admin():
+ assert (
+ resolve_account_state(email_verified=False, global_role="superadmin", has_active_membership=False)
+ == "platform_admin"
+ )
+
+
+def test_resolve_account_state_unverified():
+ assert (
+ resolve_account_state(email_verified=False, global_role="trainer", has_active_membership=True)
+ == "unverified"
+ )
+
+
+def test_resolve_account_state_pending_club():
+ assert (
+ resolve_account_state(email_verified=True, global_role="user", has_active_membership=False)
+ == "verified_pending_club"
+ )
+
+
+def test_resolve_account_state_active_member():
+ assert (
+ resolve_account_state(email_verified=True, global_role="trainer", has_active_membership=True)
+ == "active_member"
+ )
+
+
+def test_account_state_satisfies():
+ assert account_state_satisfies("active_member", "active_member")
+ assert account_state_satisfies("active_member", "verified_pending_club")
+ assert not account_state_satisfies("verified_pending_club", "active_member")
+ assert account_state_satisfies("platform_admin", "active_member")
+
+
+def test_assert_min_account_state_blocks(monkeypatch):
+ monkeypatch.setenv("ACCOUNT_GATE_ENFORCE", "1")
+ tenant = TenantContext(
+ profile_id=1,
+ global_role="user",
+ effective_club_id=None,
+ club_ids=frozenset(),
+ memberships=[],
+ account_state="verified_pending_club",
+ )
+ with pytest.raises(HTTPException) as exc:
+ assert_min_account_state(tenant, "active_member")
+ assert exc.value.status_code == 403
+
+
+def test_assert_min_account_state_off(monkeypatch):
+ monkeypatch.setenv("ACCOUNT_GATE_ENFORCE", "0")
+ tenant = TenantContext(
+ profile_id=1,
+ global_role="user",
+ effective_club_id=None,
+ club_ids=frozenset(),
+ memberships=[],
+ account_state="verified_pending_club",
+ )
+ assert_min_account_state(tenant, "active_member")
+
+
+def test_club_roles_in_club():
+ tenant = TenantContext(
+ profile_id=1,
+ global_role="trainer",
+ effective_club_id=5,
+ club_ids=frozenset({5}),
+ memberships=[{"id": 5, "roles": ["trainer", "club_admin"]}],
+ )
+ assert club_roles_in_club(tenant, 5) == ["trainer", "club_admin"]
+ assert club_roles_in_club(tenant, 99) == []
diff --git a/backend/tests/test_entitlements.py b/backend/tests/test_entitlements.py
new file mode 100644
index 0000000..cc50509
--- /dev/null
+++ b/backend/tests/test_entitlements.py
@@ -0,0 +1,75 @@
+"""Tests für GET /me/entitlements Zusammenstellung."""
+from datetime import datetime, timezone
+
+from entitlements import _serialize_reset_at, build_me_entitlements
+from tenant_context import TenantContext
+
+
+def test_serialize_reset_at():
+ dt = datetime(2026, 7, 1, tzinfo=timezone.utc)
+ assert _serialize_reset_at(dt) == "2026-07-01T00:00:00+00:00"
+ assert _serialize_reset_at(None) is None
+
+
+def test_build_me_entitlements_no_club(monkeypatch):
+ monkeypatch.setattr(
+ "entitlements.resolve_capabilities_map",
+ lambda cur, tenant, club_id=None: {"exercises.read": False},
+ )
+
+ tenant = TenantContext(
+ profile_id=1,
+ global_role="user",
+ effective_club_id=None,
+ club_ids=frozenset(),
+ memberships=[],
+ account_state="verified_pending_club",
+ )
+ out = build_me_entitlements(object(), tenant)
+ assert out["account_state"] == "verified_pending_club"
+ assert out["club_id"] is None
+ assert out["features"] == {}
+ assert out["capabilities"]["exercises.read"] is False
+
+
+def test_build_me_entitlements_with_club(monkeypatch):
+ monkeypatch.setattr(
+ "entitlements.resolve_capabilities_map",
+ lambda cur, tenant, club_id=None: {
+ "exercises.read": True,
+ "exercises.ai.suggest": True,
+ },
+ )
+ monkeypatch.setattr(
+ "entitlements.club_features_map",
+ lambda cur, club_id, conn=None: {
+ "plan_id": "free",
+ "club_id": club_id,
+ "features": {
+ "ai_calls": {
+ "allowed": False,
+ "used": 0,
+ "limit": 0,
+ "remaining": 0,
+ "reason": "feature_disabled",
+ "reset_at": None,
+ }
+ },
+ },
+ )
+ monkeypatch.setattr("entitlements._club_exists", lambda cur, cid: True)
+
+ tenant = TenantContext(
+ profile_id=3,
+ global_role="trainer",
+ effective_club_id=1,
+ club_ids=frozenset({1}),
+ memberships=[{"id": 1, "roles": ["trainer"]}],
+ account_state="active_member",
+ )
+ out = build_me_entitlements(object(), tenant, club_id=1)
+ assert out["club_id"] == 1
+ assert out["plan_id"] == "free"
+ assert out["club_roles"] == ["trainer"]
+ assert out["features"]["ai_calls"]["limit"] == 0
+ assert out["capabilities"]["exercises.ai.suggest"] is True
diff --git a/backend/version.py b/backend/version.py
index bab4dec..f8d695f 100644
--- a/backend/version.py
+++ b/backend/version.py
@@ -2,18 +2,21 @@
APP_VERSION = "0.8.190"
BUILD_DATE = "2026-05-23"
-DB_SCHEMA_VERSION = "20260606078"
+DB_SCHEMA_VERSION = "20260606079"
MODULE_VERSIONS = {
"legal_documents": "1.4.0", # Admin: Live-Vorschau pro Abschnitt + modale Vollvorschau (Editor + Dokumentenliste)
"auth": "1.2.3", # P-05b: reset-password min_length=8 via Pydantic PasswordResetConfirm
- "profiles": "1.8.0", # training_planning_prefs JSONB (Planungs-UI); Patch via ProfileUpdate + Json(), Migration 055
- "tenant_context": "1.0.5", # Plattform-Admin: effective_club ohne Header aus Profil active_club_id wenn Verein existiert
+ "profiles": "1.8.1", # GET /profiles/me: account_state + club_roles
+ "tenant_context": "1.1.0", # M3: account_state + email_verified im TenantContext
+ "capabilities": "1.0.1", # resolve_capabilities_map für /me/entitlements
+ "account_lifecycle": "1.0.0", # resolve_account_state + assert_min_account_state (ACCOUNT_GATE_ENFORCE)
"clubs": "0.4.1", # Alle geschützten Endpoints Depends(get_tenant_context); profile_id/role aus TenantContext
"club_memberships": "1.0.1", # Depends(get_tenant_context)
"club_join_requests": "1.0.1", # Depends(get_tenant_context)
"admin_users": "1.0.0", # GET /api/admin/users
- "club_features": "1.1.0", # M2: probe_club_feature_access + JSON-Log (Phase 2, non-blocking)
+ "club_features": "1.2.0", # M4: club_features_map für /me/entitlements
+ "entitlements": "1.0.0", # GET /api/me/entitlements — capabilities + features
"platform_media_storage": "1.0.0", # GET/PUT /api/admin/platform-media-storage (Superadmin-Pfad unter MEDIA_ROOT)
"media_rights": "1.3.1", # acting_profile_id in write_audit_log_entry auf Optional[int] (P-13 anonyme Meldungen)
"media_assets": "1.18.1", # P-13: open_report_count in Listendaten (fuer Admins)
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index ba38f7f..7d19542 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -8,6 +8,7 @@ import {
Outlet,
} from 'react-router-dom'
import { AuthProvider, useAuth } from './context/AuthContext'
+import { EntitlementsProvider } from './context/EntitlementsContext'
import { FormEditorActionsProvider, FormEditorBottomSlot } from './context/FormEditorActionsContext'
import { ToastProvider } from './context/ToastContext'
import { OrgInboxProvider, useOrgInbox } from './context/OrgInboxContext'
@@ -345,11 +346,13 @@ const appRouter = createBrowserRouter([
function App() {
return (