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
@@ -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);
}
};