Release 0.7.3

This commit is contained in:
hnzio committed 2026-10-27 00:59:39 +01:00
1 parent d5b1c58ba2
commit 21c0de7dd9
31 files changed
+1582 -15

No files matched your search

+1
View File
@@ -6,6 +6,7 @@ BODY_SIZE_LIMIT=6M
# Generate this locally and insert it here as described in README.md. Never commit .env.
SENDEWERK_SETUP_TOKEN=
# Public production origins must use HTTPS; loopback HTTP remains available locally.
SENDEWERK_APP_ORIGIN=http://localhost:5173
# "local" writes messages to the outbox and never contacts a mail server.
+1 -1
View File
@@ -3,7 +3,7 @@
"title": "Sendewerk",
"description": "Redaktionelle Planung und Durchführung von heinzlive-Sendungen.",
"tagline": "Vom Fundstück bis zur Livesendung",
"version": "0.7.2",
"version": "0.7.3",
"healthCheckPath": "/healthz",
"httpPort": 3000,
"addons": {
+59 -3
View File
@@ -2,9 +2,9 @@
Sendewerk ist die redaktionelle Webanwendung für den Weg vom vorbereiteten
Inhalt bis zur moderierten und archivierten Sendung. Der aktuelle Stand umfasst
Etappe 1 bis 5: technisches Grundgerüst, Anmeldung und Rollen, einen
persistenten Fundus, den Sendungsbaukasten sowie Live-Steuerung und eine
abgesicherte Präsentationsausgabe. Abschluss und Archivierung folgen in Etappe 6.
Etappe 1 bis 7: technisches Grundgerüst, Anmeldung und Rollen, Fundus,
Sendungsbau, Live-Steuerung, Präsentationsausgabe, Abschluss, Archiv,
Freigaben, RSS sowie technische REST- und MCP-Zugänge.
## Voraussetzungen
@@ -62,6 +62,9 @@ enthält bewusst kein Konto und kein Standardpasswort.
Relative Sendewerk-Pfade werden vom Projektverzeichnis aus aufgelöst. In
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
Passwort-Resets landen als JSON-Dateien in `.data/mail-outbox`; die enthaltenen
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
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
| 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
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,
Restore, Proxy-Origin und das read-only Laufzeitdateisystem können erst dort
abschließend geprüft werden.
+23
View File
@@ -1,5 +1,28 @@
# 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
| Bereich | Nachweis | Stand |
+33
View File
@@ -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
dieser oder mit einer anderen Sendung verbunden ist. Das Öffnen von Menü und
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.
+38
View File
@@ -532,3 +532,41 @@ seinen ausschließlich gehasht gespeicherten Token und erzeugt ein Auditereignis
Das Live-Menü zeigt einen rein gelesenen Verbindungsstatus des Ausgabekanals.
Nur die bereits vorhandenen expliziten Verbinden-, Rotieren- und Trennenaktionen
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>
+31
View File
@@ -1,5 +1,36 @@
# 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
Veröffentlicht am 26. Oktober 2026
+25
View File
@@ -8,6 +8,31 @@ Live-Steuerung, Präsentationsausgabe, unveränderliche Sendungshistorie,
Archiv, sichere Lesefreigaben, RSS-Import und technische REST-Zugänge sind
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
- Der RSS-Importeingang ist eine kompakte Katalogfläche. Die eigentliche Auswahl
+49
View File
@@ -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);
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "sendewerk",
"version": "0.7.2",
"version": "0.7.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sendewerk",
"version": "0.7.2",
"version": "0.7.3",
"dependencies": {
"@node-rs/argon2": "^2.2.2",
"better-sqlite3": "^13.0.3",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "sendewerk",
"private": true,
"version": "0.7.2",
"version": "0.7.3",
"type": "module",
"engines": {
"node": ">=22.17 <25"
+26
View File
@@ -3193,6 +3193,32 @@ textarea:focus {
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,
.compact-form {
display: grid;
+1
View File
@@ -8,6 +8,7 @@ declare global {
interface PageData {
user?: AuthUser | null;
presentationMode?: boolean;
oauthMode?: boolean;
}
}
}
+12 -1
View File
@@ -20,6 +20,10 @@ export const handle: Handle = async ({ event, resolve }) => {
const sharePath = event.url.pathname.startsWith('/freigabe/');
const shareMediaPath = /^\/freigabe\/[^/]+\/media\/[^/]+$/.test(event.url.pathname);
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 token = event.cookies.get(SESSION_COOKIE);
event.locals.user = getSessionUser(token);
@@ -30,6 +34,8 @@ export const handle: Handle = async ({ event, resolve }) => {
!sharePath &&
!publicPaths.has(event.url.pathname) &&
!apiPath &&
!oauthPath &&
!mcpPath &&
!event.locals.user
) {
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');
}
return response;
+15 -1
View File
@@ -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'
)
.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(
'UPDATE oauth_access_tokens SET revoked_at_utc = ? WHERE user_id = ? AND revoked_at_utc IS NULL'
)
.run(timestamp, row.user_id);
});
reset();
}
@@ -592,7 +598,15 @@ export function setUserLocked(actor: AuthUser, userId: string, locked: boolean):
database
.prepare('UPDATE users SET locked_at_utc = ?, updated_at_utc = ? WHERE id = ?')
.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();
}
+4
View File
@@ -18,6 +18,10 @@ export function getAuthConfig() {
throw new Error('SENDEWERK_APP_ORIGIN muss im Produktionsbetrieb gesetzt sein.');
}
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 {
origin,
setupToken: process.env.SENDEWERK_SETUP_TOKEN || '',
+1 -1
View File
@@ -62,7 +62,7 @@ describe('database setup', () => {
database.close();
expect(counts).toEqual({
migrations: 19,
migrations: 20,
pieces: 6,
shows: 2,
usages: 5,
+431
View File
@@ -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' }
};
}
}
+425
View File
@@ -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;
}
+1
View File
@@ -7,5 +7,6 @@ export const load: LayoutServerLoad = ({ locals, url }) => ({
: locals.user,
presentationMode: url.pathname.startsWith('/heinzlive/'),
guestMode: url.pathname.startsWith('/freigabe/'),
oauthMode: url.pathname === '/oauth/authorize',
liveControllerMode: url.pathname === '/live'
});
+1 -1
View File
@@ -34,7 +34,7 @@
{#if data.presentationMode}
{@render children()}
{:else if data.guestMode}
{:else if data.guestMode || data.oauthMode}
<div class="guest-shell">
<header class="auth-header">
<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' } }
);
+17 -4
View File
@@ -8,13 +8,26 @@ import {
validEmail
} 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 (locals.user) redirect(303, '/fundus');
if (locals.user) redirect(303, safeReturnTo(url.searchParams.get('returnTo')));
};
export const actions = {
default: async ({ request, cookies, getClientAddress }) => {
default: async ({ request, cookies, getClientAddress, url }) => {
const data = await request.formData();
const email = String(data.get('email') || '').trim();
const password = String(data.get('password') || '');
@@ -31,6 +44,6 @@ export const actions = {
const user = await authenticate(email, password);
if (!user) return fail(400, { email, error: 'E-Mail oder Passwort ist nicht korrekt.' });
createSession(user.id, cookies);
redirect(303, '/fundus');
redirect(303, safeReturnTo(url.searchParams.get('returnTo')));
}
} satisfies Actions;
+154
View File
@@ -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;
+30
View File
@@ -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>
+36
View File
@@ -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' } }
);
}
};
+16
View File
@@ -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' } });
};
+57
View File
@@ -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);
}
};