diff --git a/src/routers/sources.py b/src/routers/sources.py
index 20d3f9b..8c70966 100644
--- a/src/routers/sources.py
+++ b/src/routers/sources.py
@@ -11,6 +11,7 @@ import aiosqlite
from auth import get_current_admin
from database import db_dependency
from audit import log_action, get_client_ip, row_to_dict
+from source_meta import get_meta
from config import HEALTH_CHECK_USER_AGENT, HEALTH_CHECK_TIMEOUT_S
from shared.source_rules import (
discover_source,
@@ -28,6 +29,17 @@ router = APIRouter(prefix="/api/sources", tags=["sources"])
SOURCE_UPDATE_COLUMNS = {"name", "url", "domain", "source_type", "category", "status", "notes"}
+@router.get("/meta")
+async def get_sources_meta(admin: dict = Depends(get_current_admin)):
+ """Liefert Kategorien und Typen als Single Source of Truth.
+
+ Frontend lädt das beim Init und befüllt damit Filter-Dropdowns + Label-Lookups.
+ Damit gibt es keine hardcoded Listen mehr im JS/HTML.
+ """
+ return get_meta()
+
+
+
class GlobalSourceCreate(BaseModel):
name: str = Field(min_length=1, max_length=200)
url: Optional[str] = None
diff --git a/src/source_meta.py b/src/source_meta.py
new file mode 100644
index 0000000..d351f6c
--- /dev/null
+++ b/src/source_meta.py
@@ -0,0 +1,73 @@
+"""Single Source of Truth für Quellen-Kategorien und -Typen.
+
+Wird vom Backend über GET /api/sources/meta exportiert.
+Frontend (sources.js, source-health.js, dashboard.html) lädt diese
+beim Init und befüllt damit Filter-Dropdowns und Label-Lookups.
+"""
+from typing import TypedDict
+
+
+class CategoryEntry(TypedDict):
+ key: str
+ label: str
+
+
+class TypeEntry(TypedDict):
+ key: str
+ label: str
+
+
+SOURCE_CATEGORIES: list[CategoryEntry] = [
+ {"key": "nachrichtenagentur", "label": "Nachrichtenagentur"},
+ {"key": "oeffentlich-rechtlich", "label": "Öffentlich-Rechtlich"},
+ {"key": "qualitaetszeitung", "label": "Qualitätszeitung"},
+ {"key": "behoerde", "label": "Behörde"},
+ {"key": "fachmedien", "label": "Fachmedien"},
+ {"key": "think-tank", "label": "Think-Tank"},
+ {"key": "international", "label": "International"},
+ {"key": "regional", "label": "Regional"},
+ {"key": "boulevard", "label": "Boulevard"},
+ {"key": "sonstige", "label": "Sonstige"},
+ {"key": "cybercrime", "label": "Cybercrime / Hacktivismus"},
+ {"key": "cybercrime-leaks", "label": "Cybercrime / Leaks"},
+ {"key": "ukraine-russland-krieg", "label": "Ukraine-Russland-Krieg"},
+ {"key": "irankonflikt", "label": "Irankonflikt"},
+ {"key": "osint-international", "label": "OSINT International"},
+ {"key": "extremismus-deutschland", "label": "Extremismus Deutschland"},
+ {"key": "russische-staatspropaganda", "label": "Russische Staatspropaganda"},
+ {"key": "russische-opposition", "label": "Russische Opposition / Exilmedien"},
+ {"key": "syrien-nahost", "label": "Syrien / Nahost"},
+]
+
+
+SOURCE_TYPES: list[TypeEntry] = [
+ {"key": "rss_feed", "label": "RSS-Feed"},
+ {"key": "web_source", "label": "Webquelle"},
+ {"key": "telegram_channel", "label": "Telegram-Kanal"},
+ {"key": "podcast_feed", "label": "Podcast-Feed"},
+ {"key": "excluded", "label": "Ausgeschlossen"},
+]
+
+
+def get_meta() -> dict:
+ """Vollständige Meta-Information für Frontend-Konsumenten."""
+ return {
+ "categories": SOURCE_CATEGORIES,
+ "types": SOURCE_TYPES,
+ }
+
+
+def category_label(key: str) -> str:
+ """Lookup: Kategorie-Key -> Label. Fallback: Key selbst."""
+ for c in SOURCE_CATEGORIES:
+ if c["key"] == key:
+ return c["label"]
+ return key
+
+
+def type_label(key: str) -> str:
+ """Lookup: Typ-Key -> Label. Fallback: Key selbst."""
+ for t in SOURCE_TYPES:
+ if t["key"] == key:
+ return t["label"]
+ return key
diff --git a/src/static/dashboard.html b/src/static/dashboard.html
index c0ab006..705858a 100644
--- a/src/static/dashboard.html
+++ b/src/static/dashboard.html
@@ -303,32 +303,9 @@