Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
21c0de7dd9 |
No files matched your search
@@ -6,6 +6,7 @@ BODY_SIZE_LIMIT=6M
|
|||||||
|
|
||||||
# Generate this locally and insert it here as described in README.md. Never commit .env.
|
# Generate this locally and insert it here as described in README.md. Never commit .env.
|
||||||
SENDEWERK_SETUP_TOKEN=
|
SENDEWERK_SETUP_TOKEN=
|
||||||
|
# Public production origins must use HTTPS; loopback HTTP remains available locally.
|
||||||
SENDEWERK_APP_ORIGIN=http://localhost:5173
|
SENDEWERK_APP_ORIGIN=http://localhost:5173
|
||||||
|
|
||||||
# "local" writes messages to the outbox and never contacts a mail server.
|
# "local" writes messages to the outbox and never contacts a mail server.
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"title": "Sendewerk",
|
"title": "Sendewerk",
|
||||||
"description": "Redaktionelle Planung und Durchführung von heinzlive-Sendungen.",
|
"description": "Redaktionelle Planung und Durchführung von heinzlive-Sendungen.",
|
||||||
"tagline": "Vom Fundstück bis zur Livesendung",
|
"tagline": "Vom Fundstück bis zur Livesendung",
|
||||||
"version": "0.7.2",
|
"version": "0.7.3",
|
||||||
"healthCheckPath": "/healthz",
|
"healthCheckPath": "/healthz",
|
||||||
"httpPort": 3000,
|
"httpPort": 3000,
|
||||||
"addons": {
|
"addons": {
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
Sendewerk ist die redaktionelle Webanwendung für den Weg vom vorbereiteten
|
Sendewerk ist die redaktionelle Webanwendung für den Weg vom vorbereiteten
|
||||||
Inhalt bis zur moderierten und archivierten Sendung. Der aktuelle Stand umfasst
|
Inhalt bis zur moderierten und archivierten Sendung. Der aktuelle Stand umfasst
|
||||||
Etappe 1 bis 5: technisches Grundgerüst, Anmeldung und Rollen, einen
|
Etappe 1 bis 7: technisches Grundgerüst, Anmeldung und Rollen, Fundus,
|
||||||
persistenten Fundus, den Sendungsbaukasten sowie Live-Steuerung und eine
|
Sendungsbau, Live-Steuerung, Präsentationsausgabe, Abschluss, Archiv,
|
||||||
abgesicherte Präsentationsausgabe. Abschluss und Archivierung folgen in Etappe 6.
|
Freigaben, RSS sowie technische REST- und MCP-Zugänge.
|
||||||
|
|
||||||
## Voraussetzungen
|
## Voraussetzungen
|
||||||
|
|
||||||
@@ -62,6 +62,9 @@ enthält bewusst kein Konto und kein Standardpasswort.
|
|||||||
Relative Sendewerk-Pfade werden vom Projektverzeichnis aus aufgelöst. In
|
Relative Sendewerk-Pfade werden vom Projektverzeichnis aus aufgelöst. In
|
||||||
Cloudron liegen Datenbank und Uploads unter `/app/data`.
|
Cloudron liegen Datenbank und Uploads unter `/app/data`.
|
||||||
|
|
||||||
|
Eine öffentliche Produktions-Origin muss HTTPS verwenden. Nur für lokale
|
||||||
|
Loopback-Adressen bleibt HTTP zulässig.
|
||||||
|
|
||||||
Der Vorgabemodus `local` baut keine Netzwerkverbindung auf. Einladungen und
|
Der Vorgabemodus `local` baut keine Netzwerkverbindung auf. Einladungen und
|
||||||
Passwort-Resets landen als JSON-Dateien in `.data/mail-outbox`; die enthaltenen
|
Passwort-Resets landen als JSON-Dateien in `.data/mail-outbox`; die enthaltenen
|
||||||
Links sind Geheimnisse und dürfen nicht veröffentlicht werden. Echter Versand
|
Links sind Geheimnisse und dürfen nicht veröffentlicht werden. Echter Versand
|
||||||
@@ -151,6 +154,53 @@ DNS-, Redirect-, Zeit- und Größenlimits abgerufen. Private und lokale
|
|||||||
Netzwerkziele sind gesperrt. Eingebettetes HTML oder Skripte einer Quellseite
|
Netzwerkziele sind gesperrt. Eingebettetes HTML oder Skripte einer Quellseite
|
||||||
werden nicht übernommen.
|
werden nicht übernommen.
|
||||||
|
|
||||||
|
## MCP und ChatGPT
|
||||||
|
|
||||||
|
Der lesende Streamable-HTTP-Endpunkt liegt unter `/mcp`. Seine konkrete
|
||||||
|
Adresse ist die konfigurierte Anwendungs-Origin mit angehängtem Pfad:
|
||||||
|
|
||||||
|
```text
|
||||||
|
https://<Cloudron-App-Domain>/mcp
|
||||||
|
```
|
||||||
|
|
||||||
|
Beispiel: Bei `SENDEWERK_APP_ORIGIN=https://sendewerk.example.org` lautet die
|
||||||
|
MCP-URL `https://sendewerk.example.org/mcp`. Niemals einen technischen
|
||||||
|
REST-Token an die URL hängen oder in ChatGPT eintragen. MCP verwendet einen
|
||||||
|
eigenen OAuth-2.1-Flow mit Authorization Code, PKCE und kurzlebigen Tokens.
|
||||||
|
|
||||||
|
Die Verbindung stellt ausschließlich lesende Werkzeuge bereit:
|
||||||
|
|
||||||
|
- `get_profile` identifiziert das verbundene Sendewerk-Konto ohne E-Mail-Adresse.
|
||||||
|
- `list_pieces` und `get_piece` lesen Fundus-Pieces.
|
||||||
|
- `list_shows` und `get_show_rundown` lesen Sendungen und deren Abläufe.
|
||||||
|
|
||||||
|
Die Detailwerkzeuge können private Recherche, Moderationsnotizen, Quellen,
|
||||||
|
eigene Notizen und Reserveinhalte liefern. Der angemeldete Nutzer muss diesem
|
||||||
|
Umfang ausdrücklich zustimmen. Sperrung oder Passwortwechsel widerrufen
|
||||||
|
bestehende OAuth-Tokens; jede MCP-Anfrage prüft Konto, Scope, Ablauf und
|
||||||
|
Zielressource erneut. MCP kann keine Inhalte schreiben.
|
||||||
|
|
||||||
|
### In ChatGPT einrichten
|
||||||
|
|
||||||
|
1. Zuerst Version `0.7.3` unter der endgültigen öffentlichen HTTPS-Origin
|
||||||
|
bereitstellen. Lokalhost ohne öffentliches HTTPS ist für eine direkte
|
||||||
|
ChatGPT-Verbindung nicht erreichbar.
|
||||||
|
2. In [ChatGPT Plugins](https://chatgpt.com/plugins) das Plus wählen und
|
||||||
|
**Add custom MCP server** öffnen.
|
||||||
|
3. Einen Namen und eine Beschreibung vergeben und unter **Connection** die
|
||||||
|
vollständige URL `https://<Cloudron-App-Domain>/mcp` eintragen.
|
||||||
|
4. OAuth als Authentifizierung verwenden, den Risikohinweis prüfen und
|
||||||
|
**Create as a plugin** wählen. ChatGPT entdeckt die OAuth-Endpunkte und
|
||||||
|
registriert einen öffentlichen PKCE-Client dynamisch.
|
||||||
|
5. Im geöffneten Sendewerk-Fenster mit dem eigenen Konto anmelden, Clientname
|
||||||
|
und Rücksprung-Domain prüfen und den lesenden Zugriff ausdrücklich erlauben.
|
||||||
|
6. Die gefundenen fünf Werkzeuge kontrollieren, das Plugin installieren und in
|
||||||
|
einer neuen Unterhaltung über `@` auswählen.
|
||||||
|
|
||||||
|
Eine erfolgreich lokale OAuth- und MCP-Prüfung beweist noch keine reale
|
||||||
|
ChatGPT-Verbindung. Diese muss nach dem Cloudron-Update separat in der
|
||||||
|
jeweiligen ChatGPT-Workspace-Konfiguration durchgeführt werden.
|
||||||
|
|
||||||
## Befehle
|
## Befehle
|
||||||
|
|
||||||
| Befehl | Zweck |
|
| Befehl | Zweck |
|
||||||
@@ -210,6 +260,12 @@ Umgebungsvariable gesetzt werden. `CLOUDRON_APP_ORIGIN` liefert dort die feste
|
|||||||
HTTPS-Origin. SMTP-Zugangsdaten werden ebenfalls ausschließlich als
|
HTTPS-Origin. SMTP-Zugangsdaten werden ebenfalls ausschließlich als
|
||||||
Laufzeitvariablen gesetzt, nicht in Manifest oder Image geschrieben.
|
Laufzeitvariablen gesetzt, nicht in Manifest oder Image geschrieben.
|
||||||
|
|
||||||
|
Für MCP sind keine zusätzlichen Geheimnisse erforderlich. Nach einem Update
|
||||||
|
auf `0.7.3` führt der bestehende Startprozess Migration `0020_oauth_mcp.sql`
|
||||||
|
aus. Anschließend muss die von Cloudron gelieferte HTTPS-Origin mit der in
|
||||||
|
ChatGPT eingetragenen MCP-Domain übereinstimmen. Reverse Proxy und öffentliche
|
||||||
|
Erreichbarkeit wurden ohne Cloudron-Zugang nicht geprüft.
|
||||||
|
|
||||||
Das ersetzt keinen Test auf einem echten Cloudron. Insbesondere Addon-Backup,
|
Das ersetzt keinen Test auf einem echten Cloudron. Insbesondere Addon-Backup,
|
||||||
Restore, Proxy-Origin und das read-only Laufzeitdateisystem können erst dort
|
Restore, Proxy-Origin und das read-only Laufzeitdateisystem können erst dort
|
||||||
abschließend geprüft werden.
|
abschließend geprüft werden.
|
||||||
|
|||||||
@@ -1,5 +1,28 @@
|
|||||||
# Abnahme
|
# Abnahme
|
||||||
|
|
||||||
|
## Lesender MCP-Zugang 0.7.3
|
||||||
|
|
||||||
|
| Bereich | Nachweis | Stand |
|
||||||
|
| ------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | --------------------- |
|
||||||
|
| OAuth-Discovery und anonymer Schutz | Reale lokale HTTP-Anfrage erhält 401 mit `resource_metadata`; Discovery-Routen sind im Produktionsbuild enthalten | erfüllt am 27.10.2026 |
|
||||||
|
| DCR, Zustimmung und PKCE | Isolierter HTTP-Ablauf registriert Client, lädt Zustimmung, erhält Code mit `state`/`iss` und tauscht per S256 | erfüllt am 27.10.2026 |
|
||||||
|
| Nutzer-, Scope- und Ressourcenbindung | Ausgestellter Token ist gehasht persistiert und wird bei jedem Aufruf gegen Nutzer, Scope, Ablauf und `/mcp` geprüft | erfüllt am 27.10.2026 |
|
||||||
|
| MCP-Kompatibilität | Legacy-Initialisierung `2025-11-25` sowie `server/discover` und `tools/list` mit `2026-07-28` über HTTP | erfüllt am 27.10.2026 |
|
||||||
|
| Tatsächliche Leseanfrage | `tools/call` auf `list_pieces` liefert aus einer temporären Seed-Datenbank reale Pieces | erfüllt am 27.10.2026 |
|
||||||
|
| Schreibschutz und Datenminimierung | Nur fünf Lese-Tools; Positivprojektionen ohne E-Mail, Uploadnamen, Dateipfade und Werkzeug-Arbeitsdaten | erfüllt am 27.10.2026 |
|
||||||
|
| ChatGPT- und Cloudron-Verbindung | Kein Deployment und keine externe Clientumgebung vorhanden | offen |
|
||||||
|
|
||||||
|
- `npm run check`: 0 Fehler und 0 Warnungen.
|
||||||
|
- Gezielter isolierter HTTP-Ablauf: OAuth Code Flow, PKCE, anonymer Challenge,
|
||||||
|
Legacy-Initialisierung, aktuelle Discovery, Toolliste und echte
|
||||||
|
`list_pieces`-Anfrage erfolgreich.
|
||||||
|
- `npm run build`: erfolgreich mit `@sveltejs/adapter-node`.
|
||||||
|
- Keine weitere Verhaltenstest- oder Browsermatrix gemäß Auftrag ausgeführt;
|
||||||
|
die Zustimmungsseite wurde im HTTP-Ablauf geladen, aber nicht visuell geprüft.
|
||||||
|
- Echte ChatGPT-Verbindung, öffentliche HTTPS-Erreichbarkeit und Cloudron-
|
||||||
|
Reverse-Proxy-Verhalten bleiben bis zu einem ausdrücklich freigegebenen
|
||||||
|
Deployment offen.
|
||||||
|
|
||||||
## Wartungsrelease 0.7.2
|
## Wartungsrelease 0.7.2
|
||||||
|
|
||||||
| Bereich | Nachweis | Stand |
|
| Bereich | Nachweis | Stand |
|
||||||
|
|||||||
@@ -481,3 +481,36 @@ Name und Umfang des bestehenden gehashten Tokens und wird auditiert. Die
|
|||||||
Live-Route projiziert zusätzlich nur, ob der feste Ausgabekanal getrennt, mit
|
Live-Route projiziert zusätzlich nur, ob der feste Ausgabekanal getrennt, mit
|
||||||
dieser oder mit einer anderen Sendung verbunden ist. Das Öffnen von Menü und
|
dieser oder mit einer anderen Sendung verbunden ist. Das Öffnen von Menü und
|
||||||
Ausgabedetails führt selbst keine Mutation aus.
|
Ausgabedetails führt selbst keine Mutation aus.
|
||||||
|
|
||||||
|
## Lesender MCP-Zugang 0.7.3
|
||||||
|
|
||||||
|
`/mcp` ist ein stateless Streamable-HTTP-Adapter über `content.ts` und
|
||||||
|
`shows.ts`. Er unterstützt die aktuelle per Request deklarierte MCP-Fassung
|
||||||
|
`2026-07-28` einschließlich `server/discover`, Header-/Body-Abgleich und
|
||||||
|
`resultType`. Für derzeitige OpenAI-Clients bleiben die Initialisierungsfassungen
|
||||||
|
`2025-11-25`, `2025-06-18` und `2025-03-26` verfügbar. Es gibt keine
|
||||||
|
MCP-Sitzung und keinen unabhängigen SSE-Strom; jede Anfrage trägt erneut ihre
|
||||||
|
Authentifizierung.
|
||||||
|
|
||||||
|
Die fünf Werkzeuge sind ausschließlich lesend. Eigene Positivprojektionen
|
||||||
|
lassen Mediennamen, interne Speicherpfade und Werkzeug-Arbeitsdaten aus. Die
|
||||||
|
Detailprojektionen dürfen nach ausdrücklicher Zustimmung redaktionell private
|
||||||
|
Recherche, Moderation, eigene Notizen, Quellen und Reserveinhalte enthalten.
|
||||||
|
Keine MCP-Methode ruft Mutationsdienste auf.
|
||||||
|
|
||||||
|
Sendewerk ist zugleich OAuth-Autorisierungs- und Ressourcenserver. Geschützte
|
||||||
|
Ressourcen- und Authorization-Server-Metadaten verweisen ausschließlich auf die
|
||||||
|
konfigurierte Origin. Öffentliche PKCE-Clients werden per begrenztem DCR
|
||||||
|
registriert; die Autorisierung verlangt exakte Redirect-URI, `S256`, den Scope
|
||||||
|
`sendewerk:read` und den exakten Ressourcenindikator `<Origin>/mcp`. Codes sind
|
||||||
|
fünf Minuten, Zugriffstokens eine Stunde gültig. SQLite speichert nur SHA-256-
|
||||||
|
Hashes. Tokens sind direkt an Nutzer, Client, Scope und Ressource gebunden;
|
||||||
|
Kontosperre und Passwortwechsel widerrufen sie, und jeder MCP-Aufruf verbindet
|
||||||
|
erneut den aktuellen Nutzerzustand.
|
||||||
|
|
||||||
|
DCR ist für die von OpenAI weiterhin unterstützte Kompatibilität vorhanden,
|
||||||
|
aber pro Quelladresse und insgesamt begrenzt. Clientnamen sind nicht verifiziert;
|
||||||
|
der Zustimmungsdialog kennzeichnet dies und zeigt die genaue Rücksprung-Domain.
|
||||||
|
Öffentliche Produktion verlangt HTTPS, während HTTP nur an Loopback-Adressen
|
||||||
|
zulässig bleibt. Der bestehende technische REST-Token wird weder gelesen noch
|
||||||
|
weitergereicht.
|
||||||
@@ -532,3 +532,41 @@ seinen ausschließlich gehasht gespeicherten Token und erzeugt ein Auditereignis
|
|||||||
Das Live-Menü zeigt einen rein gelesenen Verbindungsstatus des Ausgabekanals.
|
Das Live-Menü zeigt einen rein gelesenen Verbindungsstatus des Ausgabekanals.
|
||||||
Nur die bereits vorhandenen expliziten Verbinden-, Rotieren- und Trennenaktionen
|
Nur die bereits vorhandenen expliziten Verbinden-, Rotieren- und Trennenaktionen
|
||||||
verändern öffentliche Ausgabe. Navigation zur Planung bleibt davon getrennt.
|
verändern öffentliche Ausgabe. Navigation zur Planung bleibt davon getrennt.
|
||||||
|
|
||||||
|
## D-034: Nutzergebundener OAuth-Zugang statt Token-Passthrough
|
||||||
|
|
||||||
|
Der bestehende technische REST-Zugang kann und darf nicht für ChatGPT
|
||||||
|
weitergereicht werden: Sein Klartexttoken ist nach der Erstellung nicht mehr
|
||||||
|
verfügbar, und MCP verbietet Token-Passthrough. Sendewerk stellt deshalb einen
|
||||||
|
eigenen OAuth-2.1-Authorization-Code-Flow für öffentliche Clients bereit. PKCE
|
||||||
|
`S256`, exakte Redirect-URIs und der auf `<Origin>/mcp` gebundene
|
||||||
|
Ressourcenindikator sind verpflichtend. Codes und einstündige Zugriffstokens
|
||||||
|
liegen ausschließlich gehasht vor und sind direkt an ein aktives Nutzerkonto
|
||||||
|
gebunden.
|
||||||
|
|
||||||
|
Die Autorisierungsserver-Metadaten bieten das von OpenAI unterstützte Dynamic
|
||||||
|
Client Registration als Kompatibilitätsweg an. Die aktuelle MCP-Fassung stuft
|
||||||
|
DCR zugunsten von Client ID Metadata Documents als veraltet ein. Ein allgemeiner
|
||||||
|
CIMD-Abruf würde jedoch eine neue SSRF- und Cache-Sicherheitsgrenze eröffnen;
|
||||||
|
für diese private Anwendung bleibt DCR deshalb eng mengen- und ratenbegrenzt.
|
||||||
|
Der Zustimmungsdialog behandelt den selbst gemeldeten Clientnamen ausdrücklich
|
||||||
|
als unverifiziert und zeigt dessen Rücksprung-Domain.
|
||||||
|
|
||||||
|
Der MCP-Adapter unterstützt sowohl die aktuelle stateless Fassung `2026-07-28`
|
||||||
|
als auch die von OpenAI weiterhin dokumentierten Initialisierungsfassungen bis
|
||||||
|
`2025-03-26`. Tooldaten werden aus bestehenden Diensten positiv projiziert.
|
||||||
|
Der einzige Scope `sendewerk:read` ist im Dialog ausdrücklich einschließlich
|
||||||
|
privater redaktioneller Recherche beschrieben; E-Mail, Uploadnamen,
|
||||||
|
Speicherpfade und Werkzeug-Arbeitsdaten werden nicht ausgegeben. Alle Werkzeuge
|
||||||
|
sind als lesend markiert und haben keinen Pfad zu Mutationsdiensten.
|
||||||
|
|
||||||
|
Quellen, geprüft am 27. Oktober 2026:
|
||||||
|
|
||||||
|
- <https://developers.openai.com/apps-sdk/build/auth>
|
||||||
|
- <https://developers.openai.com/apps-sdk/deploy/connect-chatgpt>
|
||||||
|
- <https://platform.openai.com/docs/mcp>
|
||||||
|
- <https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization>
|
||||||
|
- <https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http>
|
||||||
|
- <https://modelcontextprotocol.io/specification/2026-07-28/basic/versioning>
|
||||||
|
- <https://modelcontextprotocol.io/specification/2026-07-28/server/discover>
|
||||||
|
- <https://modelcontextprotocol.io/specification/2026-07-28/server/tools>
|
||||||
@@ -1,5 +1,36 @@
|
|||||||
# Release Notes
|
# Release Notes
|
||||||
|
|
||||||
|
## 0.7.3 - Lesender MCP-Zugang mit OAuth 2.1
|
||||||
|
|
||||||
|
Veröffentlicht am 27. Oktober 2026
|
||||||
|
|
||||||
|
- Der öffentliche Streamable-HTTP-Endpunkt `/mcp` bietet fünf ausschließlich
|
||||||
|
lesende Werkzeuge für Kontoidentität, Pieces, Sendungen und vollständige
|
||||||
|
Abläufe. Er verwendet die bestehenden Inhalts- und Sendungsdienste und keine
|
||||||
|
parallele Datenhaltung.
|
||||||
|
- OAuth-2.1-Discovery, begrenzte dynamische Clientregistrierung,
|
||||||
|
Authorization Code, PKCE `S256`, exakte Redirect-URI und Ressourcenbindung
|
||||||
|
ermöglichen die von OpenAI dokumentierte Kontoverknüpfung. Codes und
|
||||||
|
einstündige Tokens werden nur gehasht gespeichert.
|
||||||
|
- Nutzerzustand, Sperren und Rollen werden bei jedem MCP-Aufruf erneut gelesen.
|
||||||
|
Passwortwechsel und Kontosperre widerrufen bestehende OAuth-Tokens. Der
|
||||||
|
vorhandene technische REST-Token wird weder offengelegt noch weitergereicht.
|
||||||
|
- MCP `2026-07-28` und die weiterhin von OpenAI verwendeten älteren
|
||||||
|
Initialisierungsfassungen werden am selben stateless Endpunkt unterstützt.
|
||||||
|
Private redaktionelle Details sind erst nach ausdrücklicher Zustimmung
|
||||||
|
lesbar; E-Mail, Uploadnamen, interne Pfade und Werkzeug-Arbeitsdaten bleiben
|
||||||
|
ausgeschlossen.
|
||||||
|
- Die Produktions-URL lautet `https://<Cloudron-App-Domain>/mcp`. README und
|
||||||
|
Betriebsdokumentation beschreiben die Einrichtung über **Add custom MCP
|
||||||
|
server**. Das Release wurde nicht auf Cloudron ausgerollt und eine reale
|
||||||
|
ChatGPT-Verbindung wird nicht als geprüft ausgegeben.
|
||||||
|
- Paket, Lockdatei und Cloudron-Manifest tragen Version `0.7.3`. Migration
|
||||||
|
`0020_oauth_mcp.sql` ergänzt additive OAuth- und Ratenbegrenzungstabellen.
|
||||||
|
- Typprüfung und Produktionsbuild sind erfolgreich. Ein isolierter lokaler
|
||||||
|
HTTP-Rundlauf hat OAuth, beide MCP-Protokollgenerationen und eine echte
|
||||||
|
`list_pieces`-Anfrage geprüft; eine reale ChatGPT-/Cloudron-Verbindung bleibt
|
||||||
|
ausdrücklich offen.
|
||||||
|
|
||||||
## 0.7.2 - RSS-Auswahl, Live-Menü und Schnittstellenverwaltung
|
## 0.7.2 - RSS-Auswahl, Live-Menü und Schnittstellenverwaltung
|
||||||
|
|
||||||
Veröffentlicht am 26. Oktober 2026
|
Veröffentlicht am 26. Oktober 2026
|
||||||
|
|||||||
@@ -8,6 +8,31 @@ Live-Steuerung, Präsentationsausgabe, unveränderliche Sendungshistorie,
|
|||||||
Archiv, sichere Lesefreigaben, RSS-Import und technische REST-Zugänge sind
|
Archiv, sichere Lesefreigaben, RSS-Import und technische REST-Zugänge sind
|
||||||
dokumentiert.
|
dokumentiert.
|
||||||
|
|
||||||
|
## Lesender MCP-Zugang 0.7.3
|
||||||
|
|
||||||
|
- Der öffentliche Streamable-HTTP-Endpunkt `/mcp` liest Pieces, Sendungen und
|
||||||
|
Abläufe über dieselben Dienste wie Browser und REST-API. Fünf deklarierte
|
||||||
|
Werkzeuge besitzen nur Leseannotationen und keine Mutationspfade.
|
||||||
|
- OAuth 2.1 verwendet Discovery-Metadaten, begrenzte dynamische
|
||||||
|
Clientregistrierung, Authorization Code, PKCE `S256`, exakte Redirect-URIs
|
||||||
|
und Ressourcenbindung. Codes und einstündige Tokens liegen nur gehasht vor.
|
||||||
|
- OAuth-Tokens sind an den zustimmenden Nutzer gebunden. Sperrung und
|
||||||
|
Passwortwechsel widerrufen sie; jeder Zugriff prüft erneut Nutzerstatus,
|
||||||
|
Scope, Ablauf und Zielressource.
|
||||||
|
- Aktuelles MCP `2026-07-28` mit `server/discover` und per-Request-Metadaten
|
||||||
|
läuft neben den OpenAI-kompatiblen Initialisierungsfassungen. Ausgaben sind
|
||||||
|
Positivlisten ohne E-Mail, Uploadnamen, interne Pfade oder Werkzeug-Arbeitsdaten.
|
||||||
|
- Die konkrete Produktionsadresse ist
|
||||||
|
`https://<Cloudron-App-Domain>/mcp`. Ein Cloudron-Deployment und eine reale
|
||||||
|
ChatGPT-Verbindung wurden nicht durchgeführt.
|
||||||
|
|
||||||
|
Geprüft wurden Typen, Produktionsbuild und ein isolierter tatsächlicher
|
||||||
|
HTTP-Ablauf über eine temporäre Datenbank: anonymer OAuth-Challenge, DCR,
|
||||||
|
Zustimmung, PKCE-Tokenaustausch, Legacy-Initialisierung, aktuelle
|
||||||
|
`server/discover`-Abfrage, Toolliste und `list_pieces` mit Seed-Daten waren
|
||||||
|
erfolgreich. Eine weitere Verhaltenstest- oder Browsermatrix wurde gemäß
|
||||||
|
Auftrag nicht ausgeführt.
|
||||||
|
|
||||||
## Bedienkorrekturen 0.7.2
|
## Bedienkorrekturen 0.7.2
|
||||||
|
|
||||||
- Der RSS-Importeingang ist eine kompakte Katalogfläche. Die eigentliche Auswahl
|
- Der RSS-Importeingang ist eine kompakte Katalogfläche. Die eigentliche Auswahl
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
CREATE TABLE oauth_clients (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
redirect_uris_json TEXT NOT NULL CHECK (json_valid(redirect_uris_json)),
|
||||||
|
created_at_utc TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE oauth_authorization_codes (
|
||||||
|
code_hash TEXT PRIMARY KEY,
|
||||||
|
client_id TEXT NOT NULL REFERENCES oauth_clients(id) ON DELETE CASCADE,
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
redirect_uri TEXT NOT NULL,
|
||||||
|
scope TEXT NOT NULL,
|
||||||
|
resource TEXT NOT NULL,
|
||||||
|
code_challenge TEXT NOT NULL,
|
||||||
|
created_at_utc TEXT NOT NULL,
|
||||||
|
expires_at_utc TEXT NOT NULL,
|
||||||
|
consumed_at_utc TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX oauth_authorization_codes_expiry_idx
|
||||||
|
ON oauth_authorization_codes(expires_at_utc);
|
||||||
|
|
||||||
|
CREATE TABLE oauth_access_tokens (
|
||||||
|
token_hash TEXT PRIMARY KEY,
|
||||||
|
client_id TEXT NOT NULL REFERENCES oauth_clients(id) ON DELETE CASCADE,
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
scope TEXT NOT NULL,
|
||||||
|
resource TEXT NOT NULL,
|
||||||
|
created_at_utc TEXT NOT NULL,
|
||||||
|
expires_at_utc TEXT NOT NULL,
|
||||||
|
last_used_at_utc TEXT,
|
||||||
|
request_window_started_at_utc TEXT NOT NULL,
|
||||||
|
request_count INTEGER NOT NULL DEFAULT 0 CHECK (request_count >= 0),
|
||||||
|
revoked_at_utc TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX oauth_access_tokens_user_idx ON oauth_access_tokens(user_id);
|
||||||
|
CREATE INDEX oauth_access_tokens_expiry_idx ON oauth_access_tokens(expires_at_utc);
|
||||||
|
|
||||||
|
CREATE TABLE oauth_rate_limit_attempts (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
kind TEXT NOT NULL CHECK (kind IN ('registration', 'token')),
|
||||||
|
address_hash TEXT NOT NULL,
|
||||||
|
attempted_at_utc TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX oauth_rate_limit_attempts_lookup_idx
|
||||||
|
ON oauth_rate_limit_attempts(kind, address_hash, attempted_at_utc);
|
||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "sendewerk",
|
"name": "sendewerk",
|
||||||
"version": "0.7.2",
|
"version": "0.7.3",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "sendewerk",
|
"name": "sendewerk",
|
||||||
"version": "0.7.2",
|
"version": "0.7.3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@node-rs/argon2": "^2.2.2",
|
"@node-rs/argon2": "^2.2.2",
|
||||||
"better-sqlite3": "^13.0.3",
|
"better-sqlite3": "^13.0.3",
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "sendewerk",
|
"name": "sendewerk",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.7.2",
|
"version": "0.7.3",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=22.17 <25"
|
"node": ">=22.17 <25"
|
||||||
|
|||||||
+26
@@ -3193,6 +3193,32 @@ textarea:focus {
|
|||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.oauth-consent {
|
||||||
|
margin: clamp(1.25rem, 6vw, 4rem) auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.oauth-permission {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.2rem;
|
||||||
|
margin: 1.25rem 0;
|
||||||
|
padding: 0.9rem;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-medium);
|
||||||
|
background: var(--panel-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.oauth-permission span,
|
||||||
|
.oauth-redirect {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.oauth-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
.auth-form,
|
.auth-form,
|
||||||
.compact-form {
|
.compact-form {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|||||||
Vendored
+1
@@ -8,6 +8,7 @@ declare global {
|
|||||||
interface PageData {
|
interface PageData {
|
||||||
user?: AuthUser | null;
|
user?: AuthUser | null;
|
||||||
presentationMode?: boolean;
|
presentationMode?: boolean;
|
||||||
|
oauthMode?: boolean;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-1
@@ -20,6 +20,10 @@ export const handle: Handle = async ({ event, resolve }) => {
|
|||||||
const sharePath = event.url.pathname.startsWith('/freigabe/');
|
const sharePath = event.url.pathname.startsWith('/freigabe/');
|
||||||
const shareMediaPath = /^\/freigabe\/[^/]+\/media\/[^/]+$/.test(event.url.pathname);
|
const shareMediaPath = /^\/freigabe\/[^/]+\/media\/[^/]+$/.test(event.url.pathname);
|
||||||
const apiPath = event.url.pathname.startsWith('/api/v1/');
|
const apiPath = event.url.pathname.startsWith('/api/v1/');
|
||||||
|
const oauthPath =
|
||||||
|
event.url.pathname.startsWith('/oauth/') ||
|
||||||
|
event.url.pathname.startsWith('/.well-known/oauth-');
|
||||||
|
const mcpPath = event.url.pathname === '/mcp';
|
||||||
const presentationTestPath = /^\/fundus\/[^/]+\/praesentation(?:\/|$)/.test(event.url.pathname);
|
const presentationTestPath = /^\/fundus\/[^/]+\/praesentation(?:\/|$)/.test(event.url.pathname);
|
||||||
const token = event.cookies.get(SESSION_COOKIE);
|
const token = event.cookies.get(SESSION_COOKIE);
|
||||||
event.locals.user = getSessionUser(token);
|
event.locals.user = getSessionUser(token);
|
||||||
@@ -30,6 +34,8 @@ export const handle: Handle = async ({ event, resolve }) => {
|
|||||||
!sharePath &&
|
!sharePath &&
|
||||||
!publicPaths.has(event.url.pathname) &&
|
!publicPaths.has(event.url.pathname) &&
|
||||||
!apiPath &&
|
!apiPath &&
|
||||||
|
!oauthPath &&
|
||||||
|
!mcpPath &&
|
||||||
!event.locals.user
|
!event.locals.user
|
||||||
) {
|
) {
|
||||||
redirect(303, hasUsers() ? '/login' : '/setup');
|
redirect(303, hasUsers() ? '/login' : '/setup');
|
||||||
@@ -66,7 +72,12 @@ export const handle: Handle = async ({ event, resolve }) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (event.url.pathname === '/einladung' || event.url.pathname === '/passwort-zuruecksetzen') {
|
if (
|
||||||
|
event.url.pathname === '/einladung' ||
|
||||||
|
event.url.pathname === '/passwort-zuruecksetzen' ||
|
||||||
|
oauthPath ||
|
||||||
|
mcpPath
|
||||||
|
) {
|
||||||
response.headers.set('Cache-Control', 'no-store');
|
response.headers.set('Cache-Control', 'no-store');
|
||||||
}
|
}
|
||||||
return response;
|
return response;
|
||||||
|
|||||||
+15
-1
@@ -564,7 +564,13 @@ export async function resetPassword(token: string, password: string): Promise<vo
|
|||||||
'UPDATE password_reset_tokens SET consumed_at_utc = ? WHERE user_id = ? AND consumed_at_utc IS NULL'
|
'UPDATE password_reset_tokens SET consumed_at_utc = ? WHERE user_id = ? AND consumed_at_utc IS NULL'
|
||||||
)
|
)
|
||||||
.run(timestamp, row.user_id);
|
.run(timestamp, row.user_id);
|
||||||
|
database.prepare('DELETE FROM oauth_authorization_codes WHERE user_id = ?').run(row.user_id);
|
||||||
database.prepare('DELETE FROM sessions WHERE user_id = ?').run(row.user_id);
|
database.prepare('DELETE FROM sessions WHERE user_id = ?').run(row.user_id);
|
||||||
|
database
|
||||||
|
.prepare(
|
||||||
|
'UPDATE oauth_access_tokens SET revoked_at_utc = ? WHERE user_id = ? AND revoked_at_utc IS NULL'
|
||||||
|
)
|
||||||
|
.run(timestamp, row.user_id);
|
||||||
});
|
});
|
||||||
reset();
|
reset();
|
||||||
}
|
}
|
||||||
@@ -592,7 +598,15 @@ export function setUserLocked(actor: AuthUser, userId: string, locked: boolean):
|
|||||||
database
|
database
|
||||||
.prepare('UPDATE users SET locked_at_utc = ?, updated_at_utc = ? WHERE id = ?')
|
.prepare('UPDATE users SET locked_at_utc = ?, updated_at_utc = ? WHERE id = ?')
|
||||||
.run(locked ? nowUtc() : null, nowUtc(), userId);
|
.run(locked ? nowUtc() : null, nowUtc(), userId);
|
||||||
if (locked) database.prepare('DELETE FROM sessions WHERE user_id = ?').run(userId);
|
if (locked) {
|
||||||
|
database.prepare('DELETE FROM sessions WHERE user_id = ?').run(userId);
|
||||||
|
database
|
||||||
|
.prepare(
|
||||||
|
'UPDATE oauth_access_tokens SET revoked_at_utc = ? WHERE user_id = ? AND revoked_at_utc IS NULL'
|
||||||
|
)
|
||||||
|
.run(nowUtc(), userId);
|
||||||
|
database.prepare('DELETE FROM oauth_authorization_codes WHERE user_id = ?').run(userId);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
update();
|
update();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ export function getAuthConfig() {
|
|||||||
throw new Error('SENDEWERK_APP_ORIGIN muss im Produktionsbetrieb gesetzt sein.');
|
throw new Error('SENDEWERK_APP_ORIGIN muss im Produktionsbetrieb gesetzt sein.');
|
||||||
}
|
}
|
||||||
const origin = new URL(configuredOrigin || 'http://localhost:5173');
|
const origin = new URL(configuredOrigin || 'http://localhost:5173');
|
||||||
|
const loopback = ['localhost', '127.0.0.1', '[::1]'].includes(origin.hostname);
|
||||||
|
if (process.env.NODE_ENV === 'production' && origin.protocol !== 'https:' && !loopback) {
|
||||||
|
throw new Error('SENDEWERK_APP_ORIGIN muss öffentlich HTTPS verwenden.');
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
origin,
|
origin,
|
||||||
setupToken: process.env.SENDEWERK_SETUP_TOKEN || '',
|
setupToken: process.env.SENDEWERK_SETUP_TOKEN || '',
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ describe('database setup', () => {
|
|||||||
database.close();
|
database.close();
|
||||||
|
|
||||||
expect(counts).toEqual({
|
expect(counts).toEqual({
|
||||||
migrations: 19,
|
migrations: 20,
|
||||||
pieces: 6,
|
pieces: 6,
|
||||||
shows: 2,
|
shows: 2,
|
||||||
usages: 5,
|
usages: 5,
|
||||||
|
|||||||
@@ -0,0 +1,431 @@
|
|||||||
|
import type { AuthUser } from './auth.js';
|
||||||
|
import { PIECE_STATUSES, getPiece, listPieces, type PieceStatus } from './content.js';
|
||||||
|
import { SHOW_STATUSES, getShow, listShows, type ShowStatus } from './shows.js';
|
||||||
|
import { MCP_SCOPE } from './oauth.js';
|
||||||
|
|
||||||
|
const MAX_PAGE_SIZE = 100;
|
||||||
|
|
||||||
|
type JsonRpcId = string | number | null;
|
||||||
|
type JsonRpcRequest = {
|
||||||
|
jsonrpc: '2.0';
|
||||||
|
id?: JsonRpcId;
|
||||||
|
method: string;
|
||||||
|
params?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const oauthSecurity = [{ type: 'oauth2', scopes: [MCP_SCOPE] }];
|
||||||
|
const readOnlyAnnotations = {
|
||||||
|
readOnlyHint: true,
|
||||||
|
destructiveHint: false,
|
||||||
|
idempotentHint: true,
|
||||||
|
openWorldHint: false
|
||||||
|
};
|
||||||
|
|
||||||
|
const profileOutputSchema = {
|
||||||
|
$schema: 'https://json-schema.org/draft/2020-12/schema',
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
id: { type: 'string', minLength: 1, pattern: '\\S' },
|
||||||
|
name: { type: 'string' },
|
||||||
|
email: { type: 'string' },
|
||||||
|
nickname: { type: 'string' }
|
||||||
|
},
|
||||||
|
required: ['id'],
|
||||||
|
additionalProperties: false
|
||||||
|
};
|
||||||
|
|
||||||
|
const paginationSchema = {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
limit: { type: 'integer' },
|
||||||
|
offset: { type: 'integer' },
|
||||||
|
total: { type: 'integer' }
|
||||||
|
},
|
||||||
|
required: ['limit', 'offset', 'total'],
|
||||||
|
additionalProperties: false
|
||||||
|
};
|
||||||
|
|
||||||
|
const tools = [
|
||||||
|
{
|
||||||
|
name: 'get_profile',
|
||||||
|
title: 'Sendewerk-Profil',
|
||||||
|
description: 'Gibt das mit dieser Verbindung verknüpfte Sendewerk-Konto zurück.',
|
||||||
|
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
|
||||||
|
outputSchema: profileOutputSchema,
|
||||||
|
annotations: readOnlyAnnotations,
|
||||||
|
securitySchemes: oauthSecurity,
|
||||||
|
_meta: { 'openai/profile': true, securitySchemes: oauthSecurity }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'list_pieces',
|
||||||
|
title: 'Pieces durchsuchen',
|
||||||
|
description: 'Listet und durchsucht redaktionelle Pieces im Sendewerk-Fundus.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
query: { type: 'string', maxLength: 200 },
|
||||||
|
statuses: {
|
||||||
|
type: 'array',
|
||||||
|
items: { type: 'string', enum: PIECE_STATUSES },
|
||||||
|
uniqueItems: true
|
||||||
|
},
|
||||||
|
limit: { type: 'integer', minimum: 1, maximum: MAX_PAGE_SIZE, default: 20 },
|
||||||
|
offset: { type: 'integer', minimum: 0, default: 0 }
|
||||||
|
},
|
||||||
|
additionalProperties: false
|
||||||
|
},
|
||||||
|
outputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
pieces: { type: 'array', items: { type: 'object' } },
|
||||||
|
pagination: paginationSchema
|
||||||
|
},
|
||||||
|
required: ['pieces', 'pagination'],
|
||||||
|
additionalProperties: false
|
||||||
|
},
|
||||||
|
annotations: readOnlyAnnotations,
|
||||||
|
securitySchemes: oauthSecurity,
|
||||||
|
_meta: { securitySchemes: oauthSecurity }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'get_piece',
|
||||||
|
title: 'Piece lesen',
|
||||||
|
description: 'Liest die redaktionellen Texte und Quellen eines einzelnen Pieces.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: { id: { type: 'string', minLength: 1, maxLength: 100 } },
|
||||||
|
required: ['id'],
|
||||||
|
additionalProperties: false
|
||||||
|
},
|
||||||
|
outputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: { id: { type: 'string' }, title: { type: 'string' } },
|
||||||
|
required: ['id', 'title']
|
||||||
|
},
|
||||||
|
annotations: readOnlyAnnotations,
|
||||||
|
securitySchemes: oauthSecurity,
|
||||||
|
_meta: { securitySchemes: oauthSecurity }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'list_shows',
|
||||||
|
title: 'Sendungen auflisten',
|
||||||
|
description: 'Listet Sendungen mit Status, Termin, Dauer und Abschnittszahl.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
statuses: {
|
||||||
|
type: 'array',
|
||||||
|
items: { type: 'string', enum: SHOW_STATUSES },
|
||||||
|
uniqueItems: true
|
||||||
|
},
|
||||||
|
limit: { type: 'integer', minimum: 1, maximum: MAX_PAGE_SIZE, default: 20 },
|
||||||
|
offset: { type: 'integer', minimum: 0, default: 0 }
|
||||||
|
},
|
||||||
|
additionalProperties: false
|
||||||
|
},
|
||||||
|
outputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
shows: { type: 'array', items: { type: 'object' } },
|
||||||
|
pagination: paginationSchema
|
||||||
|
},
|
||||||
|
required: ['shows', 'pagination'],
|
||||||
|
additionalProperties: false
|
||||||
|
},
|
||||||
|
annotations: readOnlyAnnotations,
|
||||||
|
securitySchemes: oauthSecurity,
|
||||||
|
_meta: { securitySchemes: oauthSecurity }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'get_show_rundown',
|
||||||
|
title: 'Sendungsablauf lesen',
|
||||||
|
description: 'Liest Metadaten, Abschnitte, eingeplante Pieces und Reserve einer Sendung.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: { id: { type: 'string', minLength: 1, maxLength: 100 } },
|
||||||
|
required: ['id'],
|
||||||
|
additionalProperties: false
|
||||||
|
},
|
||||||
|
outputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
id: { type: 'string' },
|
||||||
|
title: { type: 'string' },
|
||||||
|
sections: { type: 'array', items: { type: 'object' } },
|
||||||
|
reserve: { type: 'array', items: { type: 'object' } }
|
||||||
|
},
|
||||||
|
required: ['id', 'title', 'sections', 'reserve']
|
||||||
|
},
|
||||||
|
annotations: readOnlyAnnotations,
|
||||||
|
securitySchemes: oauthSecurity,
|
||||||
|
_meta: { securitySchemes: oauthSecurity }
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
function isObject(value: unknown): value is Record<string, unknown> {
|
||||||
|
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function integer(
|
||||||
|
value: unknown,
|
||||||
|
fallback: number,
|
||||||
|
minimum: number,
|
||||||
|
maximum = Number.MAX_SAFE_INTEGER
|
||||||
|
) {
|
||||||
|
if (value === undefined) return fallback;
|
||||||
|
if (!Number.isInteger(value) || Number(value) < minimum || Number(value) > maximum) {
|
||||||
|
throw new Error('INVALID_ARGUMENTS');
|
||||||
|
}
|
||||||
|
return Number(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringArgument(value: unknown, maximum: number, required = false): string {
|
||||||
|
if (value === undefined && !required) return '';
|
||||||
|
if (typeof value !== 'string' || (required && !value) || value.length > maximum) {
|
||||||
|
throw new Error('INVALID_ARGUMENTS');
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function enumArray<T extends string>(value: unknown, allowed: readonly T[], fallback: T[]): T[] {
|
||||||
|
if (value === undefined) return fallback;
|
||||||
|
if (!Array.isArray(value) || value.some((item) => !allowed.includes(item as T))) {
|
||||||
|
throw new Error('INVALID_ARGUMENTS');
|
||||||
|
}
|
||||||
|
return [...new Set(value as T[])];
|
||||||
|
}
|
||||||
|
|
||||||
|
function exactKeys(input: Record<string, unknown>, allowed: string[]): void {
|
||||||
|
if (Object.keys(input).some((key) => !allowed.includes(key)))
|
||||||
|
throw new Error('INVALID_ARGUMENTS');
|
||||||
|
}
|
||||||
|
|
||||||
|
function toolResult(value: unknown) {
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: JSON.stringify(value) }],
|
||||||
|
structuredContent: value,
|
||||||
|
isError: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toolError(message: string) {
|
||||||
|
return { content: [{ type: 'text', text: message }], isError: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
function projectPieceUsage(
|
||||||
|
usage: NonNullable<ReturnType<typeof getShow>>['sections'][number]['usages'][number]
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
id: usage.id,
|
||||||
|
originalPieceId: usage.originalPieceId,
|
||||||
|
position: usage.position,
|
||||||
|
status: usage.status,
|
||||||
|
preparationStatus: usage.preparationStatus,
|
||||||
|
preparationEffort: usage.preparationEffort,
|
||||||
|
durationSeconds: usage.durationSeconds,
|
||||||
|
title: usage.title,
|
||||||
|
outputTitle: usage.outputTitle,
|
||||||
|
intro: usage.intro,
|
||||||
|
research: usage.research,
|
||||||
|
moderation: usage.moderation,
|
||||||
|
interaction: usage.interaction,
|
||||||
|
transition: usage.transition,
|
||||||
|
presentation: usage.presentation,
|
||||||
|
ownNotes: usage.ownNotes,
|
||||||
|
sources: usage.sources.map(({ label, url, timecodeSeconds }) => ({
|
||||||
|
label,
|
||||||
|
url,
|
||||||
|
timecodeSeconds
|
||||||
|
})),
|
||||||
|
topics: usage.topics.map(({ name }) => name)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function callTool(name: string, input: unknown, user: AuthUser) {
|
||||||
|
if (!isObject(input)) throw new Error('INVALID_ARGUMENTS');
|
||||||
|
switch (name) {
|
||||||
|
case 'get_profile': {
|
||||||
|
exactKeys(input, []);
|
||||||
|
const profile = { id: user.id, name: user.displayName };
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: JSON.stringify(profile) }],
|
||||||
|
structuredContent: profile,
|
||||||
|
isError: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'list_pieces': {
|
||||||
|
exactKeys(input, ['query', 'statuses', 'limit', 'offset']);
|
||||||
|
const statuses = enumArray<PieceStatus>(input.statuses, PIECE_STATUSES, [
|
||||||
|
'inbox',
|
||||||
|
'preparing',
|
||||||
|
'ready'
|
||||||
|
]);
|
||||||
|
const query = stringArgument(input.query, 200);
|
||||||
|
const limit = integer(input.limit, 20, 1, MAX_PAGE_SIZE);
|
||||||
|
const offset = integer(input.offset, 0, 0);
|
||||||
|
const all = listPieces({ statuses, query });
|
||||||
|
return toolResult({
|
||||||
|
pieces: all
|
||||||
|
.slice(offset, offset + limit)
|
||||||
|
.map(({ mediaId: _mediaId, formatId: _formatId, ...piece }) => piece),
|
||||||
|
pagination: { limit, offset, total: all.length }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
case 'get_piece': {
|
||||||
|
exactKeys(input, ['id']);
|
||||||
|
const id = stringArgument(input.id, 100, true);
|
||||||
|
const piece = getPiece(id);
|
||||||
|
if (!piece) return toolError('Piece nicht gefunden.');
|
||||||
|
const listEntry = listPieces({ statuses: [...PIECE_STATUSES] }).find(
|
||||||
|
(entry) => entry.id === id
|
||||||
|
);
|
||||||
|
return toolResult({
|
||||||
|
id: piece.id,
|
||||||
|
title: piece.title,
|
||||||
|
outputTitle: piece.outputTitle,
|
||||||
|
summary: piece.summary,
|
||||||
|
status: piece.status,
|
||||||
|
preparationEffort: piece.preparationEffort,
|
||||||
|
durationSeconds: piece.durationSeconds,
|
||||||
|
format: listEntry?.format ?? '',
|
||||||
|
categories: listEntry?.categories.map(({ name }) => name) ?? [],
|
||||||
|
topics: listEntry?.topics.map(({ name }) => name) ?? [],
|
||||||
|
intro: piece.intro,
|
||||||
|
research: piece.researchText,
|
||||||
|
moderation: piece.moderationNotes,
|
||||||
|
interaction: piece.interactionText,
|
||||||
|
transition: piece.transitionText,
|
||||||
|
presentation: piece.presentationText,
|
||||||
|
sources: piece.sources,
|
||||||
|
createdAtUtc: piece.createdAtUtc,
|
||||||
|
updatedAtUtc: piece.updatedAtUtc
|
||||||
|
});
|
||||||
|
}
|
||||||
|
case 'list_shows': {
|
||||||
|
exactKeys(input, ['statuses', 'limit', 'offset']);
|
||||||
|
const statuses = enumArray<ShowStatus>(input.statuses, SHOW_STATUSES, [...SHOW_STATUSES]);
|
||||||
|
const limit = integer(input.limit, 20, 1, MAX_PAGE_SIZE);
|
||||||
|
const offset = integer(input.offset, 0, 0);
|
||||||
|
const all = listShows(statuses);
|
||||||
|
return toolResult({
|
||||||
|
shows: all.slice(offset, offset + limit),
|
||||||
|
pagination: { limit, offset, total: all.length }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
case 'get_show_rundown': {
|
||||||
|
exactKeys(input, ['id']);
|
||||||
|
const show = getShow(stringArgument(input.id, 100, true));
|
||||||
|
if (!show) return toolError('Sendung nicht gefunden.');
|
||||||
|
return toolResult({
|
||||||
|
id: show.id,
|
||||||
|
title: show.title,
|
||||||
|
startsAtUtc: show.startsAtUtc,
|
||||||
|
twitchTitle: show.twitchTitle,
|
||||||
|
announcementText: show.announcementText,
|
||||||
|
status: show.status,
|
||||||
|
totalDurationSeconds: show.totalDurationSeconds,
|
||||||
|
sections: show.sections.map((section) => ({
|
||||||
|
id: section.id,
|
||||||
|
name: section.name,
|
||||||
|
category: section.category,
|
||||||
|
position: section.position,
|
||||||
|
explicitDurationSeconds: section.explicitDurationSeconds,
|
||||||
|
durationSeconds: section.durationSeconds,
|
||||||
|
twitchTags: section.twitchTags,
|
||||||
|
pieces: section.usages.map(projectPieceUsage)
|
||||||
|
})),
|
||||||
|
reserve: show.reserve.map(projectPieceUsage),
|
||||||
|
createdAtUtc: show.createdAtUtc,
|
||||||
|
updatedAtUtc: show.updatedAtUtc,
|
||||||
|
completedAtUtc: show.completedAtUtc
|
||||||
|
});
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
throw new Error('UNKNOWN_TOOL');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validJsonRpcRequest(value: unknown): value is JsonRpcRequest {
|
||||||
|
if (!isObject(value) || value.jsonrpc !== '2.0' || typeof value.method !== 'string') return false;
|
||||||
|
if (
|
||||||
|
'id' in value &&
|
||||||
|
value.id !== null &&
|
||||||
|
typeof value.id !== 'string' &&
|
||||||
|
typeof value.id !== 'number'
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return value.params === undefined || isObject(value.params);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handleMcpRequest(request: JsonRpcRequest, user: AuthUser) {
|
||||||
|
const id = request.id ?? null;
|
||||||
|
try {
|
||||||
|
switch (request.method) {
|
||||||
|
case 'server/discover':
|
||||||
|
return {
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id,
|
||||||
|
result: {
|
||||||
|
resultType: 'complete',
|
||||||
|
supportedVersions: ['2026-07-28', '2025-11-25', '2025-06-18', '2025-03-26'],
|
||||||
|
capabilities: { tools: { listChanged: false } },
|
||||||
|
_meta: {
|
||||||
|
'io.modelcontextprotocol/serverInfo': { name: 'Sendewerk', version: '0.7.3' }
|
||||||
|
},
|
||||||
|
instructions: 'Lesezugriff auf Pieces, Sendungen und redaktionelle Abläufe.',
|
||||||
|
ttlMs: 3_600_000,
|
||||||
|
cacheScope: 'private'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
case 'initialize': {
|
||||||
|
const requestedVersion = request.params?.protocolVersion;
|
||||||
|
const supported = ['2025-11-25', '2025-06-18', '2025-03-26'];
|
||||||
|
const protocolVersion =
|
||||||
|
typeof requestedVersion === 'string' && supported.includes(requestedVersion)
|
||||||
|
? requestedVersion
|
||||||
|
: supported[0];
|
||||||
|
return {
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id,
|
||||||
|
result: {
|
||||||
|
protocolVersion,
|
||||||
|
capabilities: { tools: { listChanged: false } },
|
||||||
|
serverInfo: { name: 'Sendewerk', version: '0.7.3' },
|
||||||
|
instructions: 'Lesezugriff auf Pieces, Sendungen und redaktionelle Abläufe.'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'ping':
|
||||||
|
return { jsonrpc: '2.0', id, result: { resultType: 'complete' } };
|
||||||
|
case 'tools/list':
|
||||||
|
return { jsonrpc: '2.0', id, result: { resultType: 'complete', tools } };
|
||||||
|
case 'tools/call': {
|
||||||
|
const name = request.params?.name;
|
||||||
|
if (typeof name !== 'string') throw new Error('INVALID_ARGUMENTS');
|
||||||
|
return {
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id,
|
||||||
|
result: {
|
||||||
|
resultType: 'complete',
|
||||||
|
...callTool(name, request.params?.arguments ?? {}, user)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return { jsonrpc: '2.0', id, error: { code: -32601, message: 'Method not found' } };
|
||||||
|
}
|
||||||
|
} catch (cause) {
|
||||||
|
const message = cause instanceof Error ? cause.message : '';
|
||||||
|
return {
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id,
|
||||||
|
error:
|
||||||
|
message === 'INVALID_ARGUMENTS'
|
||||||
|
? { code: -32602, message: 'Invalid params' }
|
||||||
|
: message === 'UNKNOWN_TOOL'
|
||||||
|
? { code: -32602, message: 'Unknown tool' }
|
||||||
|
: { code: -32603, message: 'Internal error' }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,425 @@
|
|||||||
|
import { createHash, randomBytes, randomUUID } from 'node:crypto';
|
||||||
|
import type { AuthUser } from './auth.js';
|
||||||
|
import { getAuthConfig } from './config.js';
|
||||||
|
import { getDatabase } from './database.js';
|
||||||
|
|
||||||
|
export const MCP_SCOPE = 'sendewerk:read';
|
||||||
|
const AUTHORIZATION_CODE_DURATION_MS = 5 * 60 * 1000;
|
||||||
|
const ACCESS_TOKEN_DURATION_SECONDS = 60 * 60;
|
||||||
|
const MAX_REGISTERED_CLIENTS = 1000;
|
||||||
|
const MCP_REQUEST_LIMIT_PER_HOUR = 600;
|
||||||
|
|
||||||
|
type OAuthClientRow = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
redirect_uris_json: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AuthorizationCodeRow = {
|
||||||
|
client_id: string;
|
||||||
|
user_id: string;
|
||||||
|
redirect_uri: string;
|
||||||
|
scope: string;
|
||||||
|
resource: string;
|
||||||
|
code_challenge: string;
|
||||||
|
expires_at_utc: string;
|
||||||
|
consumed_at_utc: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OAuthClient = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
redirectUris: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OAuthAuthorizationRequest = {
|
||||||
|
client: OAuthClient;
|
||||||
|
redirectUri: string;
|
||||||
|
state: string;
|
||||||
|
scope: typeof MCP_SCOPE;
|
||||||
|
resource: string;
|
||||||
|
codeChallenge: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class OAuthError extends Error {
|
||||||
|
constructor(
|
||||||
|
public code: string,
|
||||||
|
message: string,
|
||||||
|
public status = 400
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function nowUtc(): string {
|
||||||
|
return new Date().toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function futureUtc(durationMs: number): string {
|
||||||
|
return new Date(Date.now() + durationMs).toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function tokenHash(token: string): string {
|
||||||
|
return createHash('sha256').update(token).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
function randomToken(): string {
|
||||||
|
return randomBytes(32).toString('base64url');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function oauthIssuer(): string {
|
||||||
|
return getAuthConfig().origin.origin;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mcpResource(): string {
|
||||||
|
return new URL('/mcp', getAuthConfig().origin).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function protectedResourceMetadataUrl(): string {
|
||||||
|
return new URL('/.well-known/oauth-protected-resource/mcp', getAuthConfig().origin).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function validRedirectUri(value: string): boolean {
|
||||||
|
try {
|
||||||
|
const url = new URL(value);
|
||||||
|
if (url.hash || url.username || url.password) return false;
|
||||||
|
if (url.protocol === 'https:') return true;
|
||||||
|
return (
|
||||||
|
url.protocol === 'http:' &&
|
||||||
|
(url.hostname === 'localhost' || url.hostname === '127.0.0.1' || url.hostname === '[::1]')
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clientFromRow(row: OAuthClientRow): OAuthClient {
|
||||||
|
return { id: row.id, name: row.name, redirectUris: JSON.parse(row.redirect_uris_json) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerOAuthClient(input: unknown): OAuthClient {
|
||||||
|
if (!input || typeof input !== 'object' || Array.isArray(input)) {
|
||||||
|
throw new OAuthError('invalid_client_metadata', 'Client-Metadaten fehlen.');
|
||||||
|
}
|
||||||
|
const metadata = input as Record<string, unknown>;
|
||||||
|
const name = typeof metadata.client_name === 'string' ? metadata.client_name.trim() : '';
|
||||||
|
const redirectUris = Array.isArray(metadata.redirect_uris)
|
||||||
|
? [...new Set(metadata.redirect_uris.filter((uri): uri is string => typeof uri === 'string'))]
|
||||||
|
: [];
|
||||||
|
if (
|
||||||
|
!name ||
|
||||||
|
name.length > 120 ||
|
||||||
|
/[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/u.test(name) ||
|
||||||
|
!redirectUris.length ||
|
||||||
|
redirectUris.length > 10
|
||||||
|
) {
|
||||||
|
throw new OAuthError('invalid_client_metadata', 'Name und Redirect-URIs sind erforderlich.');
|
||||||
|
}
|
||||||
|
if (redirectUris.some((uri) => uri.length > 1000 || !validRedirectUri(uri))) {
|
||||||
|
throw new OAuthError(
|
||||||
|
'invalid_redirect_uri',
|
||||||
|
'Redirect-URIs müssen HTTPS oder lokale HTTP-Adressen sein.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
metadata.token_endpoint_auth_method !== undefined &&
|
||||||
|
metadata.token_endpoint_auth_method !== 'none'
|
||||||
|
) {
|
||||||
|
throw new OAuthError(
|
||||||
|
'invalid_client_metadata',
|
||||||
|
'Nur öffentliche PKCE-Clients werden unterstützt.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
(Array.isArray(metadata.grant_types) && !metadata.grant_types.includes('authorization_code')) ||
|
||||||
|
(Array.isArray(metadata.response_types) && !metadata.response_types.includes('code'))
|
||||||
|
) {
|
||||||
|
throw new OAuthError('invalid_client_metadata', 'Nur Authorization Code wird unterstützt.');
|
||||||
|
}
|
||||||
|
const client: OAuthClient = { id: randomToken(), name, redirectUris };
|
||||||
|
const database = getDatabase();
|
||||||
|
const timestamp = nowUtc();
|
||||||
|
database
|
||||||
|
.prepare('DELETE FROM oauth_authorization_codes WHERE expires_at_utc <= ?')
|
||||||
|
.run(timestamp);
|
||||||
|
database
|
||||||
|
.prepare(
|
||||||
|
'DELETE FROM oauth_access_tokens WHERE expires_at_utc <= ? OR revoked_at_utc IS NOT NULL'
|
||||||
|
)
|
||||||
|
.run(timestamp);
|
||||||
|
database
|
||||||
|
.prepare(
|
||||||
|
`DELETE FROM oauth_clients WHERE created_at_utc < ?
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM oauth_authorization_codes c WHERE c.client_id = oauth_clients.id)
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM oauth_access_tokens t WHERE t.client_id = oauth_clients.id)`
|
||||||
|
)
|
||||||
|
.run(new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString());
|
||||||
|
const count = database.prepare('SELECT count(*) AS count FROM oauth_clients').get() as {
|
||||||
|
count: number;
|
||||||
|
};
|
||||||
|
if (count.count >= MAX_REGISTERED_CLIENTS) {
|
||||||
|
throw new OAuthError(
|
||||||
|
'temporarily_unavailable',
|
||||||
|
'Derzeit sind keine weiteren Registrierungen möglich.',
|
||||||
|
503
|
||||||
|
);
|
||||||
|
}
|
||||||
|
database
|
||||||
|
.prepare(
|
||||||
|
'INSERT INTO oauth_clients (id, name, redirect_uris_json, created_at_utc) VALUES (?, ?, ?, ?)'
|
||||||
|
)
|
||||||
|
.run(client.id, client.name, JSON.stringify(client.redirectUris), timestamp);
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reserveOAuthAttempt(kind: 'registration' | 'token', address: string): boolean {
|
||||||
|
const database = getDatabase();
|
||||||
|
const since = new Date(Date.now() - 60 * 60 * 1000).toISOString();
|
||||||
|
const limit = kind === 'registration' ? 20 : 120;
|
||||||
|
const addressHash = tokenHash(address || 'unknown');
|
||||||
|
return database.transaction(() => {
|
||||||
|
database.prepare('DELETE FROM oauth_rate_limit_attempts WHERE attempted_at_utc < ?').run(since);
|
||||||
|
const row = database
|
||||||
|
.prepare(
|
||||||
|
`SELECT count(*) AS count FROM oauth_rate_limit_attempts
|
||||||
|
WHERE kind = ? AND address_hash = ? AND attempted_at_utc >= ?`
|
||||||
|
)
|
||||||
|
.get(kind, addressHash, since) as { count: number };
|
||||||
|
if (row.count >= limit) return false;
|
||||||
|
database
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO oauth_rate_limit_attempts (id, kind, address_hash, attempted_at_utc)
|
||||||
|
VALUES (?, ?, ?, ?)`
|
||||||
|
)
|
||||||
|
.run(randomUUID(), kind, addressHash, nowUtc());
|
||||||
|
return true;
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getOAuthClient(clientId: string): OAuthClient | null {
|
||||||
|
if (!clientId || clientId.length > 200) return null;
|
||||||
|
const row = getDatabase()
|
||||||
|
.prepare('SELECT id, name, redirect_uris_json FROM oauth_clients WHERE id = ?')
|
||||||
|
.get(clientId) as OAuthClientRow | undefined;
|
||||||
|
return row ? clientFromRow(row) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseAuthorizationRequest(url: URL): OAuthAuthorizationRequest {
|
||||||
|
const client = getOAuthClient(url.searchParams.get('client_id') ?? '');
|
||||||
|
if (!client) throw new OAuthError('invalid_client', 'Der OAuth-Client ist nicht registriert.');
|
||||||
|
const redirectUri = url.searchParams.get('redirect_uri') ?? '';
|
||||||
|
if (!client.redirectUris.includes(redirectUri)) {
|
||||||
|
throw new OAuthError('invalid_request', 'Die Redirect-URI ist nicht registriert.');
|
||||||
|
}
|
||||||
|
if (url.searchParams.get('response_type') !== 'code') {
|
||||||
|
throw new OAuthError('unsupported_response_type', 'Nur response_type=code wird unterstützt.');
|
||||||
|
}
|
||||||
|
const scope = url.searchParams.get('scope') ?? '';
|
||||||
|
if (scope !== MCP_SCOPE)
|
||||||
|
throw new OAuthError('invalid_scope', 'Der angeforderte Scope ist ungültig.');
|
||||||
|
const resource = url.searchParams.get('resource') ?? '';
|
||||||
|
if (resource !== mcpResource()) {
|
||||||
|
throw new OAuthError('invalid_target', 'Die angeforderte MCP-Ressource ist ungültig.');
|
||||||
|
}
|
||||||
|
const codeChallenge = url.searchParams.get('code_challenge') ?? '';
|
||||||
|
if (
|
||||||
|
url.searchParams.get('code_challenge_method') !== 'S256' ||
|
||||||
|
!/^[A-Za-z0-9_-]{43}$/.test(codeChallenge)
|
||||||
|
) {
|
||||||
|
throw new OAuthError('invalid_request', 'PKCE mit S256 ist erforderlich.');
|
||||||
|
}
|
||||||
|
const state = url.searchParams.get('state') ?? '';
|
||||||
|
if (state.length > 1000) throw new OAuthError('invalid_request', 'Der OAuth-State ist zu lang.');
|
||||||
|
return { client, redirectUri, state, scope: MCP_SCOPE, resource, codeChallenge };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function authorizationErrorRedirect(url: URL, problem: OAuthError): string | null {
|
||||||
|
const client = getOAuthClient(url.searchParams.get('client_id') ?? '');
|
||||||
|
const redirectUri = url.searchParams.get('redirect_uri') ?? '';
|
||||||
|
if (!client?.redirectUris.includes(redirectUri)) return null;
|
||||||
|
const target = new URL(redirectUri);
|
||||||
|
target.searchParams.set('error', problem.code);
|
||||||
|
target.searchParams.set('error_description', problem.message);
|
||||||
|
const state = url.searchParams.get('state') ?? '';
|
||||||
|
if (state && state.length <= 1000) target.searchParams.set('state', state);
|
||||||
|
target.searchParams.set('iss', oauthIssuer());
|
||||||
|
return target.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function authorizationRedirect(
|
||||||
|
request: OAuthAuthorizationRequest,
|
||||||
|
values: Record<string, string>
|
||||||
|
): string {
|
||||||
|
const target = new URL(request.redirectUri);
|
||||||
|
for (const [key, value] of Object.entries(values)) target.searchParams.set(key, value);
|
||||||
|
if (request.state) target.searchParams.set('state', request.state);
|
||||||
|
target.searchParams.set('iss', oauthIssuer());
|
||||||
|
return target.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createAuthorizationCode(
|
||||||
|
request: OAuthAuthorizationRequest,
|
||||||
|
user: AuthUser
|
||||||
|
): string {
|
||||||
|
const code = randomToken();
|
||||||
|
const timestamp = nowUtc();
|
||||||
|
const database = getDatabase();
|
||||||
|
const store = database.transaction(() => {
|
||||||
|
database
|
||||||
|
.prepare('DELETE FROM oauth_authorization_codes WHERE expires_at_utc <= ?')
|
||||||
|
.run(timestamp);
|
||||||
|
database
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO oauth_authorization_codes (
|
||||||
|
code_hash, client_id, user_id, redirect_uri, scope, resource, code_challenge,
|
||||||
|
created_at_utc, expires_at_utc
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||||
|
)
|
||||||
|
.run(
|
||||||
|
tokenHash(code),
|
||||||
|
request.client.id,
|
||||||
|
user.id,
|
||||||
|
request.redirectUri,
|
||||||
|
request.scope,
|
||||||
|
request.resource,
|
||||||
|
request.codeChallenge,
|
||||||
|
timestamp,
|
||||||
|
futureUtc(AUTHORIZATION_CODE_DURATION_MS)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
store();
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pkceChallenge(verifier: string): string {
|
||||||
|
return createHash('sha256').update(verifier).digest('base64url');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function exchangeAuthorizationCode(input: {
|
||||||
|
code: string;
|
||||||
|
clientId: string;
|
||||||
|
redirectUri: string;
|
||||||
|
codeVerifier: string;
|
||||||
|
resource: string;
|
||||||
|
}) {
|
||||||
|
if (!/^[A-Za-z0-9._~-]{43,128}$/.test(input.codeVerifier)) {
|
||||||
|
throw new OAuthError('invalid_grant', 'Der PKCE-Verifier ist ungültig.');
|
||||||
|
}
|
||||||
|
const database = getDatabase();
|
||||||
|
const exchange = database.transaction(() => {
|
||||||
|
const timestamp = nowUtc();
|
||||||
|
const row = database
|
||||||
|
.prepare(
|
||||||
|
`SELECT client_id, user_id, redirect_uri, scope, resource, code_challenge,
|
||||||
|
expires_at_utc, consumed_at_utc
|
||||||
|
FROM oauth_authorization_codes WHERE code_hash = ?`
|
||||||
|
)
|
||||||
|
.get(tokenHash(input.code)) as AuthorizationCodeRow | undefined;
|
||||||
|
if (
|
||||||
|
!row ||
|
||||||
|
row.consumed_at_utc ||
|
||||||
|
row.expires_at_utc <= timestamp ||
|
||||||
|
row.client_id !== input.clientId ||
|
||||||
|
row.redirect_uri !== input.redirectUri ||
|
||||||
|
row.resource !== input.resource ||
|
||||||
|
row.code_challenge !== pkceChallenge(input.codeVerifier)
|
||||||
|
) {
|
||||||
|
throw new OAuthError('invalid_grant', 'Der Autorisierungscode ist ungültig oder abgelaufen.');
|
||||||
|
}
|
||||||
|
const user = database
|
||||||
|
.prepare('SELECT 1 FROM users WHERE id = ? AND locked_at_utc IS NULL')
|
||||||
|
.get(row.user_id);
|
||||||
|
if (!user) throw new OAuthError('invalid_grant', 'Das zugehörige Konto ist nicht aktiv.');
|
||||||
|
const token = randomToken();
|
||||||
|
database
|
||||||
|
.prepare('UPDATE oauth_authorization_codes SET consumed_at_utc = ? WHERE code_hash = ?')
|
||||||
|
.run(timestamp, tokenHash(input.code));
|
||||||
|
database
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO oauth_access_tokens (
|
||||||
|
token_hash, client_id, user_id, scope, resource, created_at_utc, expires_at_utc,
|
||||||
|
request_window_started_at_utc
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
|
||||||
|
)
|
||||||
|
.run(
|
||||||
|
tokenHash(token),
|
||||||
|
row.client_id,
|
||||||
|
row.user_id,
|
||||||
|
row.scope,
|
||||||
|
row.resource,
|
||||||
|
timestamp,
|
||||||
|
futureUtc(ACCESS_TOKEN_DURATION_SECONDS * 1000),
|
||||||
|
timestamp
|
||||||
|
);
|
||||||
|
return { accessToken: token, scope: row.scope };
|
||||||
|
});
|
||||||
|
return exchange();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function authenticateOAuth(request: Request): AuthUser {
|
||||||
|
const authorization = request.headers.get('authorization') ?? '';
|
||||||
|
const token = authorization.startsWith('Bearer ') ? authorization.slice(7).trim() : '';
|
||||||
|
if (!token || token.length > 100)
|
||||||
|
throw new OAuthError('invalid_token', 'OAuth-Token fehlt.', 401);
|
||||||
|
const timestamp = nowUtc();
|
||||||
|
const database = getDatabase();
|
||||||
|
const row = database.transaction(() => {
|
||||||
|
const current = database
|
||||||
|
.prepare(
|
||||||
|
`SELECT u.id, u.email, u.display_name AS displayName, u.role,
|
||||||
|
u.locked_at_utc AS lockedAtUtc, t.token_hash,
|
||||||
|
t.request_window_started_at_utc, t.request_count
|
||||||
|
FROM oauth_access_tokens t JOIN users u ON u.id = t.user_id
|
||||||
|
WHERE t.token_hash = ? AND t.revoked_at_utc IS NULL AND t.expires_at_utc > ?
|
||||||
|
AND t.scope = ? AND t.resource = ? AND u.locked_at_utc IS NULL`
|
||||||
|
)
|
||||||
|
.get(tokenHash(token), timestamp, MCP_SCOPE, mcpResource()) as
|
||||||
|
| (AuthUser & {
|
||||||
|
token_hash: string;
|
||||||
|
request_window_started_at_utc: string;
|
||||||
|
request_count: number;
|
||||||
|
})
|
||||||
|
| undefined;
|
||||||
|
if (!current) {
|
||||||
|
throw new OAuthError('invalid_token', 'OAuth-Token ist ungültig oder abgelaufen.', 401);
|
||||||
|
}
|
||||||
|
const windowExpired =
|
||||||
|
current.request_window_started_at_utc <= new Date(Date.now() - 60 * 60 * 1000).toISOString();
|
||||||
|
if (!windowExpired && current.request_count >= MCP_REQUEST_LIMIT_PER_HOUR) {
|
||||||
|
throw new OAuthError('temporarily_unavailable', 'Zu viele MCP-Anfragen.', 429);
|
||||||
|
}
|
||||||
|
database
|
||||||
|
.prepare(
|
||||||
|
`UPDATE oauth_access_tokens SET last_used_at_utc = ?,
|
||||||
|
request_window_started_at_utc = ?, request_count = ? WHERE token_hash = ?`
|
||||||
|
)
|
||||||
|
.run(
|
||||||
|
timestamp,
|
||||||
|
windowExpired ? timestamp : current.request_window_started_at_utc,
|
||||||
|
windowExpired ? 1 : current.request_count + 1,
|
||||||
|
current.token_hash
|
||||||
|
);
|
||||||
|
return current;
|
||||||
|
})();
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
email: row.email,
|
||||||
|
displayName: row.displayName,
|
||||||
|
role: row.role,
|
||||||
|
lockedAtUtc: row.lockedAtUtc
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function revokeOAuthToken(token: string, clientId: string): void {
|
||||||
|
if (!token || token.length > 200 || !clientId || clientId.length > 200) return;
|
||||||
|
getDatabase()
|
||||||
|
.prepare(
|
||||||
|
`UPDATE oauth_access_tokens SET revoked_at_utc = ?
|
||||||
|
WHERE token_hash = ? AND client_id = ? AND revoked_at_utc IS NULL`
|
||||||
|
)
|
||||||
|
.run(nowUtc(), tokenHash(token), clientId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function oauthTokenDurationSeconds(): number {
|
||||||
|
return ACCESS_TOKEN_DURATION_SECONDS;
|
||||||
|
}
|
||||||
@@ -7,5 +7,6 @@ export const load: LayoutServerLoad = ({ locals, url }) => ({
|
|||||||
: locals.user,
|
: locals.user,
|
||||||
presentationMode: url.pathname.startsWith('/heinzlive/'),
|
presentationMode: url.pathname.startsWith('/heinzlive/'),
|
||||||
guestMode: url.pathname.startsWith('/freigabe/'),
|
guestMode: url.pathname.startsWith('/freigabe/'),
|
||||||
|
oauthMode: url.pathname === '/oauth/authorize',
|
||||||
liveControllerMode: url.pathname === '/live'
|
liveControllerMode: url.pathname === '/live'
|
||||||
});
|
});
|
||||||
@@ -34,7 +34,7 @@
|
|||||||
|
|
||||||
{#if data.presentationMode}
|
{#if data.presentationMode}
|
||||||
{@render children()}
|
{@render children()}
|
||||||
{:else if data.guestMode}
|
{:else if data.guestMode || data.oauthMode}
|
||||||
<div class="guest-shell">
|
<div class="guest-shell">
|
||||||
<header class="auth-header">
|
<header class="auth-header">
|
||||||
<span class="brand"
|
<span class="brand"
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { MCP_SCOPE, oauthIssuer } from '#lib/server/oauth.js';
|
||||||
|
|
||||||
|
export const GET: RequestHandler = () => {
|
||||||
|
const issuer = oauthIssuer();
|
||||||
|
return json(
|
||||||
|
{
|
||||||
|
issuer,
|
||||||
|
authorization_endpoint: `${issuer}/oauth/authorize`,
|
||||||
|
token_endpoint: `${issuer}/oauth/token`,
|
||||||
|
registration_endpoint: `${issuer}/oauth/register`,
|
||||||
|
revocation_endpoint: `${issuer}/oauth/revoke`,
|
||||||
|
response_types_supported: ['code'],
|
||||||
|
grant_types_supported: ['authorization_code'],
|
||||||
|
token_endpoint_auth_methods_supported: ['none'],
|
||||||
|
revocation_endpoint_auth_methods_supported: ['none'],
|
||||||
|
code_challenge_methods_supported: ['S256'],
|
||||||
|
scopes_supported: [MCP_SCOPE],
|
||||||
|
authorization_response_iss_parameter_supported: true
|
||||||
|
},
|
||||||
|
{ headers: { 'Cache-Control': 'public, max-age=3600' } }
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { GET } from './mcp/+server.js';
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { MCP_SCOPE, mcpResource, oauthIssuer } from '#lib/server/oauth.js';
|
||||||
|
|
||||||
|
export const GET: RequestHandler = () =>
|
||||||
|
json(
|
||||||
|
{
|
||||||
|
resource: mcpResource(),
|
||||||
|
authorization_servers: [oauthIssuer()],
|
||||||
|
scopes_supported: [MCP_SCOPE],
|
||||||
|
bearer_methods_supported: ['header']
|
||||||
|
},
|
||||||
|
{ headers: { 'Cache-Control': 'public, max-age=3600' } }
|
||||||
|
);
|
||||||
@@ -8,13 +8,26 @@ import {
|
|||||||
validEmail
|
validEmail
|
||||||
} from '#lib/server/auth.js';
|
} from '#lib/server/auth.js';
|
||||||
|
|
||||||
export const load: PageServerLoad = ({ locals }) => {
|
function safeReturnTo(value: string | null): string {
|
||||||
|
if (!value || value.length > 4000) return '/fundus';
|
||||||
|
try {
|
||||||
|
const target = new URL(value, 'http://sendewerk.local');
|
||||||
|
if (target.origin === 'http://sendewerk.local' && target.pathname === '/oauth/authorize') {
|
||||||
|
return `${target.pathname}${target.search}`;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Fall back to the normal authenticated landing page.
|
||||||
|
}
|
||||||
|
return '/fundus';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const load: PageServerLoad = ({ locals, url }) => {
|
||||||
if (!hasUsers()) redirect(303, '/setup');
|
if (!hasUsers()) redirect(303, '/setup');
|
||||||
if (locals.user) redirect(303, '/fundus');
|
if (locals.user) redirect(303, safeReturnTo(url.searchParams.get('returnTo')));
|
||||||
};
|
};
|
||||||
|
|
||||||
export const actions = {
|
export const actions = {
|
||||||
default: async ({ request, cookies, getClientAddress }) => {
|
default: async ({ request, cookies, getClientAddress, url }) => {
|
||||||
const data = await request.formData();
|
const data = await request.formData();
|
||||||
const email = String(data.get('email') || '').trim();
|
const email = String(data.get('email') || '').trim();
|
||||||
const password = String(data.get('password') || '');
|
const password = String(data.get('password') || '');
|
||||||
@@ -31,6 +44,6 @@ export const actions = {
|
|||||||
const user = await authenticate(email, password);
|
const user = await authenticate(email, password);
|
||||||
if (!user) return fail(400, { email, error: 'E-Mail oder Passwort ist nicht korrekt.' });
|
if (!user) return fail(400, { email, error: 'E-Mail oder Passwort ist nicht korrekt.' });
|
||||||
createSession(user.id, cookies);
|
createSession(user.id, cookies);
|
||||||
redirect(303, '/fundus');
|
redirect(303, safeReturnTo(url.searchParams.get('returnTo')));
|
||||||
}
|
}
|
||||||
} satisfies Actions;
|
} satisfies Actions;
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import { json, text } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { handleMcpRequest, validJsonRpcRequest } from '#lib/server/mcp.js';
|
||||||
|
import { getAuthConfig } from '#lib/server/config.js';
|
||||||
|
import {
|
||||||
|
authenticateOAuth,
|
||||||
|
MCP_SCOPE,
|
||||||
|
OAuthError,
|
||||||
|
protectedResourceMetadataUrl
|
||||||
|
} from '#lib/server/oauth.js';
|
||||||
|
|
||||||
|
const MODERN_VERSION = '2026-07-28';
|
||||||
|
const supportedProtocolVersions = [MODERN_VERSION, '2025-11-25', '2025-06-18', '2025-03-26'];
|
||||||
|
|
||||||
|
function challenge(errorCode?: string) {
|
||||||
|
const fields = [`resource_metadata="${protectedResourceMetadataUrl()}"`, `scope="${MCP_SCOPE}"`];
|
||||||
|
if (errorCode) fields.push(`error="${errorCode}"`);
|
||||||
|
return `Bearer ${fields.join(', ')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function authenticate(request: Request) {
|
||||||
|
try {
|
||||||
|
return authenticateOAuth(request);
|
||||||
|
} catch (cause) {
|
||||||
|
if (cause instanceof OAuthError) {
|
||||||
|
const headers: Record<string, string> = { 'Cache-Control': 'no-store' };
|
||||||
|
if (cause.status === 401) headers['WWW-Authenticate'] = challenge(cause.code);
|
||||||
|
return json(
|
||||||
|
{ error: cause.code, error_description: cause.message },
|
||||||
|
{ status: cause.status, headers }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw cause;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function originAllowed(request: Request): boolean {
|
||||||
|
const origin = request.headers.get('origin');
|
||||||
|
return !origin || origin === getAuthConfig().origin.origin;
|
||||||
|
}
|
||||||
|
|
||||||
|
function protocolError(id: string | number | null, code: number, message: string, data?: unknown) {
|
||||||
|
return {
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id,
|
||||||
|
error: { code, message, ...(data === undefined ? {} : { data }) }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function modernValidation(
|
||||||
|
request: Request,
|
||||||
|
body: {
|
||||||
|
id?: string | number | null;
|
||||||
|
method: string;
|
||||||
|
params?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
): Response | null {
|
||||||
|
const id = typeof body.id === 'string' || typeof body.id === 'number' ? body.id : null;
|
||||||
|
const metadata = body.params?._meta;
|
||||||
|
if (!metadata || typeof metadata !== 'object' || Array.isArray(metadata)) {
|
||||||
|
return json(protocolError(id, -32602, 'Required MCP request metadata is missing.'), {
|
||||||
|
status: 400
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const meta = metadata as Record<string, unknown>;
|
||||||
|
const bodyVersion = meta['io.modelcontextprotocol/protocolVersion'];
|
||||||
|
if (typeof bodyVersion !== 'string' || !supportedProtocolVersions.includes(bodyVersion)) {
|
||||||
|
return json(
|
||||||
|
protocolError(id, -32022, 'Unsupported protocol version', {
|
||||||
|
supported: supportedProtocolVersions,
|
||||||
|
requested: bodyVersion ?? null
|
||||||
|
}),
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
request.headers.get('mcp-protocol-version') !== bodyVersion ||
|
||||||
|
request.headers.get('mcp-method') !== body.method ||
|
||||||
|
(body.method === 'tools/call' && request.headers.get('mcp-name') !== body.params?.name)
|
||||||
|
) {
|
||||||
|
return json(protocolError(id, -32020, 'MCP request headers do not match the body.'), {
|
||||||
|
status: 400
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const capabilities = meta['io.modelcontextprotocol/clientCapabilities'];
|
||||||
|
if (
|
||||||
|
body.id === null ||
|
||||||
|
!capabilities ||
|
||||||
|
typeof capabilities !== 'object' ||
|
||||||
|
Array.isArray(capabilities)
|
||||||
|
) {
|
||||||
|
return json(protocolError(id, -32602, 'Required MCP request metadata is invalid.'), {
|
||||||
|
status: 400
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const POST: RequestHandler = async ({ request }) => {
|
||||||
|
if (!originAllowed(request)) return text('Forbidden', { status: 403 });
|
||||||
|
const authenticated = authenticate(request);
|
||||||
|
if (authenticated instanceof Response) return authenticated;
|
||||||
|
if (!request.headers.get('content-type')?.toLowerCase().startsWith('application/json')) {
|
||||||
|
return text('JSON wird erwartet.', { status: 415 });
|
||||||
|
}
|
||||||
|
let body: unknown;
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return json({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } });
|
||||||
|
}
|
||||||
|
if (!validJsonRpcRequest(body)) {
|
||||||
|
return json({ jsonrpc: '2.0', id: null, error: { code: -32600, message: 'Invalid Request' } });
|
||||||
|
}
|
||||||
|
const metadata = body.params?._meta;
|
||||||
|
const bodyVersion =
|
||||||
|
metadata && typeof metadata === 'object' && !Array.isArray(metadata)
|
||||||
|
? (metadata as Record<string, unknown>)['io.modelcontextprotocol/protocolVersion']
|
||||||
|
: undefined;
|
||||||
|
const headerVersion = request.headers.get('mcp-protocol-version');
|
||||||
|
const modern = headerVersion === MODERN_VERSION || bodyVersion === MODERN_VERSION;
|
||||||
|
if (modern) {
|
||||||
|
const validationFailure = modernValidation(request, body);
|
||||||
|
if (validationFailure) return validationFailure;
|
||||||
|
} else if (headerVersion && !supportedProtocolVersions.includes(headerVersion)) {
|
||||||
|
return json(
|
||||||
|
protocolError(body.id ?? null, -32022, 'Unsupported protocol version', {
|
||||||
|
supported: supportedProtocolVersions,
|
||||||
|
requested: headerVersion
|
||||||
|
}),
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (body.id === undefined) return new Response(null, { status: 202 });
|
||||||
|
const response = handleMcpRequest(body, authenticated);
|
||||||
|
const status = modern && 'error' in response && response.error?.code === -32601 ? 404 : 200;
|
||||||
|
return json(response, {
|
||||||
|
status,
|
||||||
|
headers: { 'Cache-Control': 'no-store' }
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const GET: RequestHandler = ({ request }) => {
|
||||||
|
if (!originAllowed(request)) return text('Forbidden', { status: 403 });
|
||||||
|
const authenticated = authenticate(request);
|
||||||
|
if (authenticated instanceof Response) return authenticated;
|
||||||
|
return text('Dieser MCP-Endpunkt bietet keinen serverseitigen Ereignisstrom.', {
|
||||||
|
status: 405,
|
||||||
|
headers: { Allow: 'POST' }
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DELETE: RequestHandler = () =>
|
||||||
|
text('Method Not Allowed', { status: 405, headers: { Allow: 'POST' } });
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { error, redirect } from '@sveltejs/kit';
|
||||||
|
import type { Actions, PageServerLoad } from './$types';
|
||||||
|
import {
|
||||||
|
authorizationErrorRedirect,
|
||||||
|
authorizationRedirect,
|
||||||
|
createAuthorizationCode,
|
||||||
|
OAuthError,
|
||||||
|
parseAuthorizationRequest
|
||||||
|
} from '#lib/server/oauth.js';
|
||||||
|
|
||||||
|
function authorization(url: URL) {
|
||||||
|
try {
|
||||||
|
return parseAuthorizationRequest(url);
|
||||||
|
} catch (cause) {
|
||||||
|
if (cause instanceof OAuthError) {
|
||||||
|
const target = authorizationErrorRedirect(url, cause);
|
||||||
|
if (target) redirect(303, target, { external: true });
|
||||||
|
error(cause.status, cause.message);
|
||||||
|
}
|
||||||
|
throw cause;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const load: PageServerLoad = ({ locals, url }) => {
|
||||||
|
const request = authorization(url);
|
||||||
|
if (!locals.user) {
|
||||||
|
const returnTo = `${url.pathname}${url.search}`;
|
||||||
|
redirect(303, `/login?returnTo=${encodeURIComponent(returnTo)}`);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
clientName: request.client.name,
|
||||||
|
redirectHost: new URL(request.redirectUri).host,
|
||||||
|
scope: request.scope,
|
||||||
|
user: locals.user
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const actions = {
|
||||||
|
default: ({ request: browserRequest, locals, url }) => {
|
||||||
|
if (!locals.user)
|
||||||
|
redirect(303, `/login?returnTo=${encodeURIComponent(`${url.pathname}${url.search}`)}`);
|
||||||
|
const oauthRequest = authorization(url);
|
||||||
|
return browserRequest.formData().then((data) => {
|
||||||
|
if (data.get('decision') !== 'approve') {
|
||||||
|
redirect(303, authorizationRedirect(oauthRequest, { error: 'access_denied' }), {
|
||||||
|
external: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const code = createAuthorizationCode(oauthRequest, locals.user!);
|
||||||
|
redirect(303, authorizationRedirect(oauthRequest, { code }), { external: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} satisfies Actions;
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { PageProps } from './$types';
|
||||||
|
let { data }: PageProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head><title>Zugriff erlauben | Sendewerk</title></svelte:head>
|
||||||
|
|
||||||
|
<section class="auth-card oauth-consent">
|
||||||
|
<p class="eyebrow">Externe Verbindung</p>
|
||||||
|
<h1>{data.clientName} verbinden?</h1>
|
||||||
|
<p>
|
||||||
|
Die Anwendung erhält im Namen von <strong>{data.user.displayName}</strong> lesenden Zugriff auf redaktionelle
|
||||||
|
Pieces, Sendungen und Abläufe. Dazu gehören auch private Recherche, Moderationsnotizen, Quellen und
|
||||||
|
die Reservebank.
|
||||||
|
</p>
|
||||||
|
<div class="oauth-permission">
|
||||||
|
<strong>Nur lesen</strong>
|
||||||
|
<span>Keine Inhalte anlegen, ändern, einplanen oder archivieren.</span>
|
||||||
|
</div>
|
||||||
|
<p class="oauth-redirect">
|
||||||
|
<strong>Nicht verifizierter OAuth-Client.</strong> Nach Zustimmung erfolgt der Rücksprung zu
|
||||||
|
<code>{data.redirectHost}</code>.
|
||||||
|
</p>
|
||||||
|
<form class="oauth-actions" method="POST">
|
||||||
|
<button class="secondary-button" type="submit" name="decision" value="deny">Ablehnen</button>
|
||||||
|
<button class="primary-button" type="submit" name="decision" value="approve">
|
||||||
|
Zugriff erlauben
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { OAuthError, registerOAuthClient, reserveOAuthAttempt } from '#lib/server/oauth.js';
|
||||||
|
|
||||||
|
export const POST: RequestHandler = async ({ request, getClientAddress }) => {
|
||||||
|
try {
|
||||||
|
if (!reserveOAuthAttempt('registration', getClientAddress())) {
|
||||||
|
throw new OAuthError('temporarily_unavailable', 'Zu viele Registrierungsversuche.', 429);
|
||||||
|
}
|
||||||
|
if (!request.headers.get('content-type')?.toLowerCase().startsWith('application/json')) {
|
||||||
|
throw new OAuthError('invalid_client_metadata', 'JSON wird erwartet.');
|
||||||
|
}
|
||||||
|
const client = registerOAuthClient(await request.json());
|
||||||
|
return json(
|
||||||
|
{
|
||||||
|
client_id: client.id,
|
||||||
|
client_id_issued_at: Math.floor(Date.now() / 1000),
|
||||||
|
client_name: client.name,
|
||||||
|
redirect_uris: client.redirectUris,
|
||||||
|
grant_types: ['authorization_code'],
|
||||||
|
response_types: ['code'],
|
||||||
|
token_endpoint_auth_method: 'none'
|
||||||
|
},
|
||||||
|
{ status: 201, headers: { 'Cache-Control': 'no-store' } }
|
||||||
|
);
|
||||||
|
} catch (cause) {
|
||||||
|
const problem =
|
||||||
|
cause instanceof OAuthError
|
||||||
|
? cause
|
||||||
|
: new OAuthError('invalid_client_metadata', 'Ungültige Client-Metadaten.');
|
||||||
|
return json(
|
||||||
|
{ error: problem.code, error_description: problem.message },
|
||||||
|
{ status: problem.status, headers: { 'Cache-Control': 'no-store' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { text } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { revokeOAuthToken } from '#lib/server/oauth.js';
|
||||||
|
|
||||||
|
export const POST: RequestHandler = async ({ request }) => {
|
||||||
|
if (
|
||||||
|
request.headers
|
||||||
|
.get('content-type')
|
||||||
|
?.toLowerCase()
|
||||||
|
.startsWith('application/x-www-form-urlencoded')
|
||||||
|
) {
|
||||||
|
const data = await request.formData();
|
||||||
|
revokeOAuthToken(String(data.get('token') ?? ''), String(data.get('client_id') ?? ''));
|
||||||
|
}
|
||||||
|
return text('', { headers: { 'Cache-Control': 'no-store' } });
|
||||||
|
};
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import {
|
||||||
|
exchangeAuthorizationCode,
|
||||||
|
oauthTokenDurationSeconds,
|
||||||
|
OAuthError,
|
||||||
|
reserveOAuthAttempt
|
||||||
|
} from '#lib/server/oauth.js';
|
||||||
|
|
||||||
|
function failure(cause: unknown) {
|
||||||
|
const problem =
|
||||||
|
cause instanceof OAuthError
|
||||||
|
? cause
|
||||||
|
: new OAuthError('invalid_request', 'Ungültige Token-Anfrage.');
|
||||||
|
return json(
|
||||||
|
{ error: problem.code, error_description: problem.message },
|
||||||
|
{ status: problem.status, headers: { 'Cache-Control': 'no-store', Pragma: 'no-cache' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const POST: RequestHandler = async ({ request, getClientAddress }) => {
|
||||||
|
try {
|
||||||
|
if (!reserveOAuthAttempt('token', getClientAddress())) {
|
||||||
|
throw new OAuthError('temporarily_unavailable', 'Zu viele Token-Anfragen.', 429);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!request.headers
|
||||||
|
.get('content-type')
|
||||||
|
?.toLowerCase()
|
||||||
|
.startsWith('application/x-www-form-urlencoded')
|
||||||
|
) {
|
||||||
|
throw new OAuthError('invalid_request', 'Formulardaten werden erwartet.');
|
||||||
|
}
|
||||||
|
const data = await request.formData();
|
||||||
|
if (data.get('grant_type') !== 'authorization_code') {
|
||||||
|
throw new OAuthError('unsupported_grant_type', 'Nur Authorization Code wird unterstützt.');
|
||||||
|
}
|
||||||
|
const result = exchangeAuthorizationCode({
|
||||||
|
code: String(data.get('code') ?? ''),
|
||||||
|
clientId: String(data.get('client_id') ?? ''),
|
||||||
|
redirectUri: String(data.get('redirect_uri') ?? ''),
|
||||||
|
codeVerifier: String(data.get('code_verifier') ?? ''),
|
||||||
|
resource: String(data.get('resource') ?? '')
|
||||||
|
});
|
||||||
|
return json(
|
||||||
|
{
|
||||||
|
access_token: result.accessToken,
|
||||||
|
token_type: 'Bearer',
|
||||||
|
expires_in: oauthTokenDurationSeconds(),
|
||||||
|
scope: result.scope
|
||||||
|
},
|
||||||
|
{ headers: { 'Cache-Control': 'no-store', Pragma: 'no-cache' } }
|
||||||
|
);
|
||||||
|
} catch (cause) {
|
||||||
|
return failure(cause);
|
||||||
|
}
|
||||||
|
};
|
||||||
Reference in new issue
Block a user