monitor/API.md
fds2610-bot 076dca2aef Tickets eines entfernten Projekts aus dem Kanban entfernen (Issue #43)
Bislang hat RepoConfigController::delete() nur die Projektkonfiguration
geloescht - die Tickets des Projekts blieben (nur ohne Projektfarbe,
Issue #34) dauerhaft im Kanban sichtbar. Jetzt loescht der Endpunkt
zusaetzlich alle Tickets, deren repo-Feld auf das entfernte Projekt
zeigt.
2026-08-09 20:14:14 +02:00

455 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# monitor REST-API (Phase 1)
Diese API ist die zentrale Schnittstelle, ueber die entfernte Agenten (Menschen oder
automatisierte Systeme) ihre Aktivitaet melden: Tickets anlegen/verschieben, Events melden,
Systemstatus berichten, KPIs eintragen und den eigenen Zustand aktualisieren. Sie ist Teil
von Phase 1 der Neuausrichtung (siehe [Visualization.md](Visualization.md), Abschnitt 9).
Implementierung: Vanilla-PHP (kein Framework), Persistenz in SQLite
(`api/data/monitor.sqlite`). Details zum lokalen Setup: [api/README.md](api/README.md).
## Inhaltsverzeichnis
- [Grundlagen](#grundlagen)
- [Authentifizierung](#authentifizierung)
- [Agenten](#agenten)
- [Tickets](#tickets)
- [Events](#events)
- [Systeme](#systeme)
- [KPIs](#kpis)
- [Live-Updates (SSE)](#live-updates-sse)
- [Projekte (Codeberg-Sync konfigurieren)](#projekte-codeberg-sync-konfigurieren)
- [Codeberg-Sync (Backlog/Done)](#codeberg-sync-backlogdone)
- [Fehlerformat](#fehlerformat)
## Grundlagen
- Basis-Pfad: `/api/v1`
- Request-/Response-Body: `application/json`
- Alle Zeitstempel: ISO-8601 UTC (`YYYY-MM-DDTHH:mm:ssZ`), serverseitig erzeugt via
`Database::now()`, sofern nicht explizit im Request angegeben.
- Lesende Endpunkte (`GET`) sind oeffentlich, da sie das Frontend bedienen.
- Schreibende Endpunkte (`POST`/`PATCH`) erfordern einen Agenten- oder Admin-Key
(siehe [Authentifizierung](#authentifizierung)).
- Alle Antworten tragen `Access-Control-Allow-Origin: *` (CORS), da Frontend und API im
Docker-Compose-Setup auf unterschiedlichen Ports laufen (siehe
[docker-compose.yml](docker-compose.yml): Frontend `:8090`, API `:8091`).
- Welche Basis-URL das Frontend anspricht, wird nicht geraten, sondern konfiguriert: Der
Webserver liefert sie zur Laufzeit als `/config.js`
(`window.MONITOR_API_BASE`) aus, gespeist aus der Umgebungsvariable `MONITOR_API_BASE`
(siehe `.env.example` und `docker/nginx/default.conf.template`). Leerer Wert = gleicher
Origin, dann wird der relative Pfad `/api/v1` verwendet.
## Authentifizierung
Zwei Schluesseltypen, beide statisch (F14):
| Header | Verwendet fuer | Herkunft |
|---|---|---|
| `X-Admin-Key` | `POST /api/v1/agents` (Registrierung neuer Agenten), `POST`/`PATCH`/`DELETE /api/v1/repo-configs` (Projekte des Codeberg-Sync) | Umgebungsvariable `MONITOR_ADMIN_API_KEY` |
| `X-Api-Key` | Alle anderen schreibenden Endpunkte | Wird bei der Agenten-Registrierung einmalig zurueckgegeben |
Ausnahme ist der Codeberg-Webhook (`POST /api/v1/webhooks/codeberg`): Codeberg kennt unsere
Keys nicht, signiert den Payload aber mit dem hinterlegten Webhook-Secret - dort gilt die
HMAC-Signatur statt eines Keys, siehe
[Codeberg-Sync](#post-apiv1webhookscodeberg--sofort-sync-per-webhook).
Der Klartext-API-Key eines Agenten wird **nur einmal** bei der Registrierung ausgegeben.
Gespeichert wird ausschliesslich ein SHA-256-Hash in SQLite (Tabelle `agents`,
Spalte `api_key_hash`) - der Klartext ist danach nicht mehr rekonstruierbar und muss vom
Agenten sicher verwahrt werden.
Fehlt oder passt ein Key nicht, antwortet die API mit `401 Unauthorized`.
## Agenten
Rollen (F1/F2): `entwickler`, `product_owner`, `qa`, `user`. Ein Agent bekommt bei der
Registrierung fest einen von `MONITOR_DESK_COUNT` (Standard 8) Schreibtischen zugewiesen
(niedrigster freier Index). Die Rolle `user` bekommt nie einen Schreibtisch (kein fester
Sitzplatz). Sind alle Schreibtische belegt, wird trotzdem registriert, aber ohne
Schreibtisch (`desk_index: null`).
### `POST /api/v1/agents` — Agent registrieren
Auth: `X-Admin-Key`.
```json
// Request
{ "display_name": "Alice", "role": "entwickler" }
```
```json
// Response 201
{
"agent": {
"id": 1, "display_name": "Alice", "role": "entwickler",
"desk_index": 0, "state": "idle", "current_ticket_id": null,
"created_at": "2026-07-27T10:00:00Z", "last_seen_at": "2026-07-27T10:00:00Z",
"is_present": true
},
"api_key": "‹64 Hex-Zeichen, nur jetzt sichtbar›"
}
```
### `GET /api/v1/agents` — Alle Agenten
Response: `{ "agents": [ ‹Agent-Objekte wie oben, ohne api_key› ] }`
Oeffentlich, unauthentifiziert (siehe [Grundlagen](#grundlagen)) - damit laesst sich
`last_seen_at`/`is_present` jedes Agenten jederzeit von aussen pruefen, z.B. auf der
Produktionsmaschine:
```bash
curl -s "$MONITOR_API_BASE/agents" | jq '.agents[] | {id, display_name, last_seen_at, is_present}'
```
`is_present` gilt `true`, solange `last_seen_at` hoechstens zehn Minuten zurueckliegt, sonst
`false` (`AgentRepository::isPresent()`, Issue #17/#20) - laeuft der Timeout ab, bleibt der
Schreibtisch des Agenten leer, obwohl er weiterhin registriert ist (Ausnahme sind die
[Problemzustaende](#problemzustaende-abgelaufene-claude-anmeldung), die sichtbar bleiben). Berechnet wird das
serverseitig via `gmdate()`/`time()` (also unabhaengig von der Zeitzone des Clients); das
Frontend (`isAgentPresent`, `html/js/office-scene.js`) liest `is_present` nur noch direkt aus
der API, statt selbst aus `last_seen_at` zu rechnen - vorher fuehrte das je nach
Client-Zeitzone zu falschen Ergebnissen (Issue #20). Bei direktem Zugriff auf die
Produktionsmaschine laesst sich `last_seen_at` auch direkt aus der SQLite-Datei lesen
(`MONITOR_DB_PATH`, Standard `api/data/monitor.sqlite`), `is_present` selbst steht dort
allerdings nicht drin, da es erst bei der API-Antwort berechnet wird:
```bash
sqlite3 "$MONITOR_DB_PATH" "SELECT id, display_name, last_seen_at FROM agents;"
```
### `POST /api/v1/agents/{id}/activity` — Eigenen Zustand melden
Auth: `X-Api-Key` des betroffenen Agenten (ein Agent darf nur seinen eigenen Zustand
melden, sonst `403`).
```json
// Request
{ "state": "working", "current_ticket": 42 }
```
Response 200: aktualisiertes Agent-Objekt.
`state` ist bewusst nicht serverseitig eingeschraenkt - welche Zustaende es gibt, entscheidet
allein die Darstellung (`AGENT_STATE_VISUALS` in `html/js/office-scene.js`), unbekannte Werte
fallen dort auf "idle am Schreibtisch" zurueck. Gebraeuchlich sind `idle`, `working` sowie die
Lauf-/Interaktionszustaende der Zustandsmaschine (`walking_to_board`, `interacting_with_board`,
`walking_to_monitor`, `interacting_with_monitor`, `walking_back`).
#### Problemzustaende: abgelaufene Claude-Anmeldung
Zwei Zustaende sind Sonderfaelle, weil sie ein menschliches Eingreifen erfordern:
| `state` | Bedeutung | Darstellung |
| --- | --- | --- |
| `auth_expiring` | Die Claude-Anmeldung des Agenten laeuft demnaechst ab, er arbeitet noch. | gelbes Schloss am Avatar, gelbes Namensschild, gelber Header-Banner |
| `auth_expired` | Die Anmeldung ist abgelaufen, der Agent kommt nicht mehr weiter. | rot pulsierendes Schloss, rotes Namensschild, roter Header-Banner |
Hintergrund: Laeuft die Anmeldung aus, laeuft das Cron-Skript um den Agenten herum
unbeeindruckt weiter - es meldet zu Beginn `working`, ruft `claude` auf, bekommt nichts
Brauchbares zurueck und meldet am Ende `idle`. Ohne eigenen Zustand ist der Ausfall vom
Normalbetrieb nicht zu unterscheiden: `last_seen_at` ist frisch, der Agent sieht beschaeftigt
aus, waehrend Lauf fuer Lauf nichts entsteht. Der Monitor-API-Key ist von der Claude-Anmeldung
unabhaengig und bleibt gueltig - der Agent kann den Zustand also weiterhin melden.
Zwei Besonderheiten:
- **Sie ueberleben den Praesenz-Timeout.** Hat ein Agent `auth_expired` gemeldet, bricht sein
Skript den Lauf ab und meldet bis zum naechsten Cron-Start nichts mehr. Das Frontend zeigt ihn
trotzdem weiter (`isAgentVisible()`), sonst verschwaende er genau dann vom Schreibtisch, wenn
seine Warnung dort stehen soll. `is_present`/`agents_online` bleiben davon unberuehrt - ein
blockierter Agent zaehlt nicht als online.
- **Sie loesen sich nur durch eine neue Meldung.** Es gibt bewusst kein Timeout; der naechste
erfolgreiche Lauf meldet ohnehin `working`/`idle` und raeumt den Zustand damit auf.
Ergaenzend gehoert ein Event (`POST /api/v1/events`, severity `error` bzw. `warning`) dazu - der
Zustand allein hat kein Datum, das Event beantwortet "seit wann".
```bash
curl -X POST "$MONITOR_API_BASE/agents/$ID/activity" \
-H "Content-Type: application/json" -H "X-Api-Key: $KEY" \
-d '{"state": "auth_expired", "current_ticket": null}'
```
## Tickets
Spalten (F3): `backlog`, `bereit`, `in_arbeit`, `qa`, `testing`, `fertig`, `done`.
- `backlog`, `bereit`, `in_arbeit`, `testing` und `done` werden regelmaessig aus
Codeberg-Issues aus mehreren konfigurierten Projekten befuellt (siehe
[Codeberg-Sync](#codeberg-sync-backlogdone)); `qa` und `fertig` sind fuer zukuenftige
Labels reserviert und ergeben sich ausschliesslich aus Agenten-Meldungen (F4).
- Ticket-Typ (statt Prioritaet) wird bei Codeberg-Tickets aus dem ersten Issue-Label
uebernommen, die Farbe dagegen fest aus dem Quell-Projekt (F4/F26) - so bleiben Tickets aus
unterschiedlichen Codeberg-Projekten auf einen Blick unterscheidbar, unabhaengig davon,
welche Labels dort jeweils existieren.
- `repo` (Format `owner/name`) identifiziert bei Codeberg-Tickets das Quell-Projekt und macht
`external_ref` (die Issue-Nummer) nur je Projekt eindeutig - Issue #5 in `fds2610/monitor`
und Issue #5 in `fds2610/Steuerkram` sind unterschiedliche Tickets.
- Stapel-Darstellung ("aelteste 4 + Rest als Stapel", F4) ist reine Frontend-Logik;
die API liefert Tickets dafuer sortiert nach Spalte und Alter.
### `GET /api/v1/tickets` — Board-Zustand
Query-Parameter: `column` (optional, filtert auf eine Spalte).
Response: `{ "tickets": [ { "id", "external_ref", "repo", "title", "description", "column_name", "type", "color", "assignee_agent_id", "source", "created_at", "updated_at" }, ... ] }`
### `POST /api/v1/tickets` — Ticket anlegen
Auth: `X-Api-Key`.
```json
// Request (nur "title" ist Pflicht)
{ "title": "Neues Bug-Ticket", "description": "...", "column": "backlog", "type": "bug", "color": "#ff0000" }
```
Response `201` mit dem angelegten Ticket, `422` bei fehlendem `title` oder ungueltiger
`column`.
### `PATCH /api/v1/tickets/{id}` — Ticket verschieben/aendern
Auth: `X-Api-Key`. Erlaubte Felder im Body: `column`, `title`, `description`, `type`,
`color`, `assignee_agent_id` (alle optional, nur uebergebene Felder werden geaendert).
Response `200` mit dem aktualisierten Ticket, `404` falls unbekannt, `422` bei ungueltiger
`column`.
## Events
`severity`: `warning` (gelb) oder `error` (rot). Neuestes Event zuerst (F5).
### `POST /api/v1/events` — Event melden
Auth: `X-Api-Key`.
```json
{ "system_id": "srv1", "name": "Ping", "message": "Host nicht erreichbar", "severity": "error" }
```
Pflichtfelder: `name`, `message`, `severity`. `occurred_at` optional (Default: jetzt).
Response `201` mit dem gespeicherten Event.
### `GET /api/v1/events` — Event-Liste
Query-Parameter: `limit` (Default `5`, entspricht der im Plan festgelegten Zeilenzahl des
Event-Monitors, F5; Maximum intern 200).
Response: `{ "events": [ { "id", "system_id", "name", "message", "severity", "occurred_at", "created_at" }, ... ] }`
## Systeme
Bis zu 9 Kacheln (F6, `SystemRepository::MAX_TILES`), Fehler zuerst sortiert. Aktuell
werden Systeme zusaetzlich aus der bestehenden `status.txt` (geschrieben von
`check_fds.sh`) importiert; per API gemeldete Systeme (`source: "api"`) haben Vorrang und
werden vom Legacy-Import nicht ueberschrieben.
### `POST /api/v1/systems/{id}/status` — Systemstatus melden
Auth: `X-Api-Key`.
```json
{ "status": "ok", "name": "Webserver", "type": "host", "detail": "optional" }
```
`status`: `ok` oder `error`. Response `200` mit dem gespeicherten System, `422` bei
ungueltigem `status`.
### `GET /api/v1/systems` — Systemuebersicht
Response: `{ "systems": [ { "id", "name", "type", "status", "detail", "source", "last_check_at" }, ... ] }`
(maximal 9 Eintraege).
## KPIs
F7: Konkrete Kennzahlen sind noch offen ("kommt spaeter"). Phase 1 stellt nur die
generische Speicher-/Abfragestruktur bereit.
Zusaetzlich zu hier gemeldeten Kennzahlen zeigt der KPI-Monitor im Frontend vier weitere,
nicht per `POST /api/v1/kpis/{key}` gemeldete Kacheln, die direkt aus anderen Endpunkten
abgeleitet sind: "Agenten"/"Online"/"Problem" aus `GET /api/v1/agents` (Issue #39) und "Hooks"
(ganz unten) aus [`GET /api/v1/webhooks/stats`](#get-apiv1webhooksstats--empfangene-webhooks-zaehlen-hooks-kpi),
siehe Issue #38.
"Problem" zaehlt die Agenten in einem
[Problemzustand](#problemzustaende-abgelaufene-claude-anmeldung) und faerbt sich nur rot, wenn
der Wert ueber 0 liegt - eine dauerhaft rote Kachel mit einer 0 waere genau das Rauschen, das den
Ernstfall untergehen liesse.
### `POST /api/v1/kpis/{key}` — KPI-Datenpunkt melden
Auth: `X-Api-Key`.
```json
{ "value": 12.5, "label": "Offene Tickets", "unit": "count", "timestamp": "2026-07-27T10:00:00Z" }
```
Pflichtfeld: `value` (numerisch). `label`/`unit`/`timestamp` optional. Response `201` mit
dem KPI inkl. Verlauf.
### `GET /api/v1/kpis` — Alle KPIs
Response: `{ "kpis": [ { "key", "label", "unit", "current_value", "history": [ { "value", "recorded_at" }, ... ] }, ... ] }`
## Live-Updates (SSE)
F13: Das Frontend haelt sich per [Server-Sent Events](https://developer.mozilla.org/de/docs/Web/API/Server-sent_events)
ueber Aenderungen auf dem Laufenden, statt staendig zu pollen.
### `GET /api/v1/stream` — Aenderungs-Feed
Auth: keine (oeffentlich wie die anderen `GET`-Endpunkte). Antwort:
`Content-Type: text/event-stream`.
Die Verbindung bleibt fuer `MONITOR_SSE_MAX_ITERATIONS * MONITOR_SSE_POLL_INTERVAL` Sekunden
offen (Default: `30 * 2s` = ca. 1 Minute) und prueft alle `MONITOR_SSE_POLL_INTERVAL` Sekunden
serverseitig, ob sich Tickets, Events, Systeme, KPIs oder Agenten veraendert haben (billiger
"Fingerabdruck" aus Anzahl + juengstem Zeitstempel je Ressource, keine vollstaendige
Änderungshistorie). Danach schliesst der Server die Verbindung; `EventSource` im Browser
verbindet automatisch neu (Standardverhalten).
Bei einer Aenderung wird ein `update`-Event gesendet, das nur benennt, **welche** Ressourcen
sich geaendert haben - das Frontend laedt die betroffenen Endpunkte anschliessend regulaer per
`GET` neu:
```
event: update
data: {"changed": ["tickets", "events"]}
```
Ohne Aenderung sendet der Server stattdessen alle `MONITOR_SSE_POLL_INTERVAL` Sekunden einen
Kommentar (`: heartbeat`) als Keep-Alive, damit Proxys die Verbindung nicht vorzeitig
schliessen.
## Projekte (Codeberg-Sync konfigurieren)
Seit Issue #27 ist die Projektliste des Codeberg-Sync (vorher `CODEBERG_REPOS`) in der
Datenbank gepflegt statt in einer Umgebungsvariable - editierbar per API bzw. ueber das
Frontend-Modal (Klick auf den PO in der Buero-Visualisierung). Erster Start seedet die drei
bisherigen Default-Projekte `fds2610/monitor` (gelb), `fds2610/Steuerkram` (gruen) und
`fds2610/nc-gantt` (blau), siehe `api/src/Db/migrations/003_repo_configs.sql`.
| Endpunkt | Auth | Beschreibung |
|---|---|---|
| `GET /api/v1/repo-configs` | - | Liste aller konfigurierten Projekte, sortiert nach `sort_order` |
| `POST /api/v1/repo-configs` | `X-Admin-Key` | Legt ein Projekt an (`owner`, `repo`, `color` als 6-stelliger Hex-Wert, mit oder ohne `#`) |
| `PATCH /api/v1/repo-configs/{id}` | `X-Admin-Key` | Aendert `owner`/`repo`/`color` eines Projekts |
| `DELETE /api/v1/repo-configs/{id}` | `X-Admin-Key` | Entfernt ein Projekt (der Sync liest es danach nicht mehr) und loescht dessen Tickets aus dem Kanban (Issue #43) |
`owner`+`repo` muessen zusammen eindeutig sein (409 -> hier als `422` mit Fehlermeldung, da
kein Konflikt-Statuscode im Fehlerformat vorgesehen ist). Der laufende `sync`-Dienst
(`--watch`) liest die Projektliste bei jedem Lauf frisch ein - eine Aenderung wirkt also ohne
Neustart des Containers.
## Codeberg-Sync (Backlog/Done)
`api/bin/sync-codeberg.php` liest offene und geschlossene Issues aus den konfigurierten
Codeberg-Projekten (siehe [Projekte](#projekte-codeberg-sync-konfigurieren)) und gleicht sie
je Projekt ueber `repo` + `external_ref` (= Issue-Nummer) mit den Tickets ab:
- Offene Issues → Spalte je nach Label: `wip` → `in_arbeit`, sonst `testing` → `testing`,
sonst `automate` → `bereit`, sonst `backlog`.
- Geschlossene Issues → Spalte `done`.
- Nur falls das Ticket noch in einer der Sync-Spalten (`backlog`, `bereit`, `in_arbeit`,
`testing`, `done`) ist - auf `qa`/`fertig` manuell verschobene Tickets werden nicht
zurueckgesetzt.
- Ticket-Typ kommt vom ersten Issue-Label, die Farbe fest vom Projekt (siehe oben).
Ausgefuehrt wird das Skript vom Compose-Service `sync` (siehe
[docker-compose.yml](docker-compose.yml)) - kein Host-Cron noetig. Der Service startet mit
dem Stack (`--watch`), macht sofort einen ersten Lauf und wiederholt ihn danach aus zwei
Gruenden: periodisch alle `MONITOR_SYNC_INTERVAL` Sekunden (Default 600, siehe
`.env.example`) und **sofort**, sobald ein Codeberg-Webhook eintrifft (siehe unten). Er nutzt
dasselbe Image und dieselbe SQLite-Datei wie `api` und verbindet sich selbst ausschliesslich
ausgehend nach codeberg.org - fuer diesen Service ist also kein Port-Mapping noetig.
```bash
docker compose up -d sync # Sync-Service starten
docker compose logs -f sync # Laeufe mitlesen
docker compose run --rm api php bin/sync-codeberg.php # einmalig manuell (Debug)
```
Beim manuellen Aufruf werden Fehler direkt auf der Konsole sichtbar (`Codeberg-API nicht
erreichbar: …` bzw. `Codeberg-API-Fehler (HTTP 4xx)`) - der erste Anlaufpunkt, wenn Backlog
und Done leer bleiben.
Schlaegt ein Lauf fehl (vereinzelte Timeouts nach codeberg.org kommen vor), wird der
naechste Versuch **vorgezogen** statt bis zum regulaeren Intervall zu warten: 5s, 10s, 20s,
… bis hoechstens `MONITOR_SYNC_INTERVAL` (`Sync\SyncSchedule`). Sonst ginge eine per Webhook
angeforderte Aktualisierung verloren - die Anforderung wird vor dem Lauf quittiert und
waere damit erst nach vollen `MONITOR_SYNC_INTERVAL` Sekunden nachgeholt. Nach einem
erfolgreichen Lauf gilt sofort wieder das regulaere Intervall. Im Log sieht das so aus:
```
[sync] Lauf fehlgeschlagen (1x in Folge, naechster Versuch in 5s): Codeberg-API nicht erreichbar: …
[sync] Lauf fehlgeschlagen (2x in Folge, naechster Versuch in 10s): …
```
### `POST /api/v1/webhooks/codeberg` — Sofort-Sync per Webhook
Damit Issue-Aenderungen nicht erst beim naechsten periodischen Lauf auf dem Board landen,
kann Codeberg jede Aenderung aktiv melden. Der Endpunkt wertet den Payload **nicht** aus, er
fordert nur einen regulaeren Sync an; der `sync`-Service greift die Anforderung im
Sekundentakt auf und laedt den aktuellen Stand **aller** konfigurierten Projekte (siehe
[Projekte](#projekte-codeberg-sync-konfigurieren)), nicht nur das des ausloesenden Repos. Vom Aendern des Issues bis zum
Board vergehen so wenige Sekunden (Webhook → Sync → SSE-Push ans Frontend).
Bei mehreren Projekten muss der Webhook in **jedem** Repo eingerichtet werden, dessen
Aenderungen sofort statt erst beim naechsten periodischen Lauf ankommen sollen - ein
fehlender Webhook auf einem der Projekte faengt der periodische Sync (`MONITOR_SYNC_INTERVAL`)
weiterhin ab.
Authentifizierung ist **kein** API-Key, sondern die HMAC-Signatur, die Codeberg selbst
mitschickt: `X-Forgejo-Signature` (bzw. `X-Gitea-Signature`) enthaelt den HMAC-SHA256 des
rohen Request-Bodys, gebildet mit dem Webhook-Secret aus `CODEBERG_WEBHOOK_SECRET`. Ist die
Variable nicht gesetzt, antwortet der Endpunkt **grundsaetzlich** mit 401 - ein offener
Sync-Ausloeser im Internet waere sonst die Folge.
| Antwort | Bedeutung |
|---------|-----------|
| `202` mit `{"status": "sync angefordert"}` | Sync wurde angestossen |
| `200` mit `{"status": "pong"}` | `ping`-Event beim Anlegen des Webhooks |
| `401` | Signatur fehlt, ist falsch oder es ist kein Secret konfiguriert |
Einrichtung im Codeberg-Repo unter **Einstellungen → Webhooks → Forgejo**:
- **Ziel-URL:** `https://<oeffentliche-api-url>/api/v1/webhooks/codeberg`
- **HTTP-Methode:** `POST`, Content-Type `application/json`
- **Secret:** derselbe Wert wie `CODEBERG_WEBHOOK_SECRET` (z.B. `openssl rand -hex 32`)
- **Ereignisse:** *Benutzerdefiniert* → `Issues` und `Issue-Label` (Issue-Kommentare sind
fuer das Board irrelevant und wuerden nur ueberfluessige Syncs ausloesen)
Anders als der `sync`-Service braucht **dieser** Endpunkt eine eingehende Freigabe: Proxy und
Firewall muessen `POST /api/v1/webhooks/codeberg` aus dem Internet an den `api`-Container
durchlassen. Ob eine Zustellung geklappt hat, zeigt Codeberg direkt beim Webhook unter
„Letzte Zustellungen" (inkl. Antwort-Body). Der periodische Sync laeuft unabhaengig weiter
und ist das Sicherheitsnetz fuer verpasste Zustellungen (Container-Neustart, Stoerung).
### `GET /api/v1/webhooks/stats` — Empfangene Webhooks zaehlen (Hooks-KPI)
Auth: keine (oeffentlich wie die anderen `GET`-Endpunkte).
```json
{ "count": 12 }
```
`count` zaehlt jede angenommene Zustellung an `POST /api/v1/webhooks/codeberg` (auch
`ping`) **seit dem letzten Start des `api`-Containers** - nicht seit Projektbeginn: die
Zaehldatei wird bei jedem Start dieses Containers zurueckgesetzt (`docker/entrypoint.sh`,
`MONITOR_HOOKS_COUNTER_FILE`). Ein Neustart des `sync`-Containers alleine setzt den Zaehler
nicht zurueck, da nur der `api`-Container Webhooks empfaengt. Das Frontend zeigt den Wert als
eigene, immer sichtbare Kachel ganz rechts im KPI-Monitor (Issue #38).
## Fehlerformat
Alle Fehlerantworten haben die Form `{ "error": "‹Meldung›" }` mit passendem HTTP-Status:
| Status | Bedeutung |
|---|---|
| `400` | Ungueltige Anfrage (z. B. unbekannter Endpunkt-Parameter) |
| `401` | Fehlender/ungueltiger `X-Api-Key`/`X-Admin-Key` |
| `403` | Aktion nicht erlaubt (z. B. fremde Agent-Aktivitaet melden) |
| `404` | Ressource nicht gefunden |
| `405` | HTTP-Methode fuer diesen Pfad nicht erlaubt |
| `422` | Validierungsfehler (Pflichtfeld fehlt, ungueltiger Enum-Wert) |