feat(mcp): OAuth so claude.ai and ChatGPT can connect as connectors
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m49s

MindSift becomes the OAuth authorization server for its own MCP
endpoint: discovery (RFC 9728/8414), dynamic client registration,
authorization code with PKCE (S256), rotating refresh tokens with reuse
detection. The consent page lives in the app at /oauth/authorize. A
grant is an api_keys entry (kind 'oauth'), so scopes, origin badges,
renaming and revoking work as for keys; tokens are valid for /mcp only.
Unused connections expire after OAUTH_IDLE_DAYS (default 90). Requires
BASE_URL; off in demo mode. Release 0.51.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-11-01 20:01:32 +01:00
1 parent 994f6dc554
commit 75aea4a7d4
31 files changed
+1581 -49

No files matched your search

+7
View File
@@ -58,6 +58,7 @@ import { TasksView } from './features/triage/TasksView';
import { WorkspaceView } from './features/workspace/WorkspaceView';
import { SharedListsView } from './features/lists/SharedListsView';
import { SetupAndAuthView } from './features/auth/SetupAndAuthView';
import { OAuthConsentView } from './features/auth/OAuthConsentView';
import { OfflineBanner } from './components/OfflineBanner';
import { useServerOnline } from './lib/useServerOnline';
import { SharedImportToast } from './components/SharedImportToast';
@@ -869,6 +870,12 @@ export const App: React.FC = () => {
);
}
// Consent of the OAuth flow (claude.ai, ChatGPT connect over MCP) instead of the app; after logging in
// the address is still /oauth/authorize, so the consent follows the login
if (window.location.pathname === '/oauth/authorize') {
return <OAuthConsentView user={user} instanceName={authStatus.instanceName || 'MindSift'} onLogout={handleLogout} />;
}
const isServerAdmin = user.role === 'admin';
const roleLabel = isServerAdmin ? 'Server-Admin' : 'Mitglied';
@@ -0,0 +1,146 @@
import React, { useEffect, useState } from 'react';
import { Brain, Bot, AlertCircle, ExternalLink } from 'lucide-react';
import { ApiKeyScope, OAuthAuthorizeInfo, User } from '../../lib/types';
import { SCOPES, SCOPE_GROUPS } from '../../lib/apiScopes';
import { api, triggerHaptic } from '../../lib/api';
import { ToggleSwitch } from '../../components/ToggleSwitch';
interface OAuthConsentViewProps {
user: User;
instanceName: string;
onLogout: () => void;
}
// Consent page of the OAuth flow (/oauth/authorize): an AI client such as claude.ai wants to use
// MindSift over MCP. The person picks the rights; the server answers with the URL to return to the
// client (with a code or access_denied). Shown instead of the app while the address is /oauth/authorize.
export const OAuthConsentView: React.FC<OAuthConsentViewProps> = ({ user, instanceName, onLogout }) => {
const query = window.location.search;
const [info, setInfo] = useState<OAuthAuthorizeInfo | null>(null);
const [scopes, setScopes] = useState<ApiKeyScope[]>([]);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
useEffect(() => {
api
.getOAuthAuthorizeInfo(query)
.then((res) => {
// Invalid request the client must hear about: straight back
if (res.redirect) {
window.location.replace(res.redirect);
return;
}
setInfo(res);
setScopes(res.scopes || []);
})
.catch((err) => setError(err?.message || 'Die Anfrage ist ungültig.'));
}, [query]);
const toggle = (id: ApiKeyScope) =>
setScopes((prev) => (prev.includes(id) ? prev.filter((s) => s !== id) : [...prev, id]));
const answer = async (approve: boolean) => {
triggerHaptic(15);
setBusy(true);
setError(null);
try {
const res = await api.answerOAuthAuthorize(query, approve, scopes);
window.location.replace(res.redirect);
} catch (err: any) {
setError(err?.message || 'Das hat nicht geklappt.');
setBusy(false);
}
};
const offered = info?.offeredScopes || [];
const groups = SCOPE_GROUPS.filter(([group]) => SCOPES.some((sc) => sc.group === group && offered.includes(sc.id)));
return (
<div className="min-h-screen bg-canvas text-main flex flex-col items-center justify-center p-4 font-sans">
<div className="w-full max-w-md panel-surface shadow-panel overflow-hidden">
<div className="p-4 bg-canvas border-b border-border flex items-center gap-2.5">
<div className="w-7 h-7 bg-accent text-on-accent rounded-btn flex items-center justify-center">
<Brain className="w-4 h-4" />
</div>
<h1 className="text-sm font-bold text-main tracking-tight leading-none">{instanceName}</h1>
</div>
<div className="p-4 space-y-4">
{!info && !error && <p className="text-xs text-muted">Anfrage wird geprüft…</p>}
{error && (
<div className="flex items-start gap-2 text-xs text-status-err">
<AlertCircle className="w-4 h-4 flex-shrink-0 mt-px" />
<span>{error}</span>
</div>
)}
{info && (
<>
<div className="flex items-start gap-3">
<div className="w-9 h-9 bg-accent/15 text-accent rounded-btn flex items-center justify-center flex-shrink-0">
<Bot className="w-5 h-5" />
</div>
<div className="min-w-0">
<h2 className="text-sm font-bold text-main">{info.clientName} möchte auf dein MindSift zugreifen</h2>
<p className="text-xs text-muted mt-0.5">
Angemeldet als <span className="text-main">{user.displayName || user.email}</span>. Freigaben gelten wie
für dich selbst; Notizen und Einstellungen bleiben gesperrt.
</p>
</div>
</div>
<div className="space-y-2" role="group" aria-label="Rechte">
{groups.map(([group, heading]) => (
<div key={group} className="border border-border rounded-card bg-panel px-3 py-1.5">
<p className="text-[11px] text-muted pt-1 pb-0.5 font-semibold">{heading}</p>
{SCOPES.filter((sc) => sc.group === group && offered.includes(sc.id)).map((sc) => (
<div key={sc.id} className="flex items-center justify-between gap-3 min-h-[44px] sm:min-h-[36px]">
<div className="min-w-0">
<span className="block text-xs text-main">{sc.label}</span>
<span className="block text-[11px] text-muted">{sc.hint}</span>
</div>
<ToggleSwitch checked={scopes.includes(sc.id)} onChange={() => toggle(sc.id)} title={sc.chip} />
</div>
))}
</div>
))}
</div>
<p className="text-[11px] text-faint flex items-center gap-1.5">
<ExternalLink className="w-3.5 h-3.5 flex-shrink-0" />
<span>
Danach geht es zurück zu <span className="font-medium text-main">{info.redirectHost}</span>. Die Verbindung
steht unter Einstellungen → API-Schlüssel und lässt sich dort jederzeit widerrufen.
</span>
</p>
<div className="flex gap-2">
<button
type="button"
disabled={busy}
onClick={() => answer(false)}
className="btn-action h-11 sm:h-9 px-4 text-sm flex-1 disabled:opacity-40"
>
Ablehnen
</button>
<button
type="button"
disabled={busy || scopes.length === 0}
onClick={() => answer(true)}
className="btn-primary h-11 sm:h-9 px-4 text-sm flex-1 disabled:opacity-40"
>
Erlauben
</button>
</div>
</>
)}
<button type="button" onClick={onLogout} className="text-[11px] text-muted hover:text-main underline h-11 sm:h-auto">
Mit einem anderen Konto anmelden
</button>
</div>
</div>
</div>
);
};
@@ -0,0 +1,75 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, act } from '@testing-library/react';
const getOAuthAuthorizeInfo = vi.fn();
const answerOAuthAuthorize = vi.fn(async () => ({ redirect: 'https://claude.ai/cb?code=c' }));
vi.mock('../../../lib/api', () => ({
api: {
getOAuthAuthorizeInfo: (...args: unknown[]) => getOAuthAuthorizeInfo(...(args as [])),
answerOAuthAuthorize: (...args: unknown[]) => answerOAuthAuthorize(...(args as [])),
},
triggerHaptic: vi.fn(),
}));
import { OAuthConsentView } from '../OAuthConsentView';
const user = { id: 'u', email: 'a@b.de', displayName: 'Anna', role: 'member', isActive: true } as never;
const replace = vi.fn();
describe('OAuthConsentView', () => {
beforeEach(() => {
replace.mockClear();
answerOAuthAuthorize.mockClear();
getOAuthAuthorizeInfo.mockReset();
Object.defineProperty(window, 'location', {
configurable: true,
value: { search: '?client_id=mcl_1&state=s', replace },
});
});
it('preselects the requested rights and sends the chosen ones', async () => {
getOAuthAuthorizeInfo.mockResolvedValue({
clientName: 'Claude',
redirectHost: 'claude.ai',
scopes: ['tasks:read', 'tasks:write', 'lists:read', 'lists:write'],
offeredScopes: ['tasks:read', 'tasks:write', 'lists:read', 'lists:write', 'webhook:triage'],
});
render(<OAuthConsentView user={user} instanceName="MindSift" onLogout={() => {}} />);
expect(await screen.findByText('Claude möchte auf dein MindSift zugreifen')).toBeTruthy();
expect(screen.getByText('claude.ai')).toBeTruthy();
expect(screen.getByRole('switch', { name: 'Inbox' }).getAttribute('aria-checked')).toBe('false');
// Narrow "create only" rights are not offered here
expect(screen.queryByRole('switch', { name: 'Aufgaben anlegen' })).toBeNull();
fireEvent.click(screen.getByRole('switch', { name: 'Listen ändern' }));
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Erlauben' }));
});
expect(answerOAuthAuthorize).toHaveBeenCalledWith('?client_id=mcl_1&state=s', true, ['tasks:read', 'tasks:write', 'lists:read']);
expect(replace).toHaveBeenCalledWith('https://claude.ai/cb?code=c');
});
it('refuses, and cannot allow without any right', async () => {
getOAuthAuthorizeInfo.mockResolvedValue({ clientName: 'X', redirectHost: 'x.example', scopes: ['tasks:read'], offeredScopes: ['tasks:read'] });
render(<OAuthConsentView user={user} instanceName="MindSift" onLogout={() => {}} />);
fireEvent.click(await screen.findByRole('switch', { name: 'Aufgaben lesen' }));
expect((screen.getByRole('button', { name: 'Erlauben' }) as HTMLButtonElement).disabled).toBe(true);
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Ablehnen' }));
});
expect(answerOAuthAuthorize).toHaveBeenCalledWith('?client_id=mcl_1&state=s', false, []);
});
it('goes straight back when the server says the request is invalid for the client', async () => {
getOAuthAuthorizeInfo.mockResolvedValue({ redirect: 'https://claude.ai/cb?error=invalid_request' });
render(<OAuthConsentView user={user} instanceName="MindSift" onLogout={() => {}} />);
await act(async () => {});
expect(replace).toHaveBeenCalledWith('https://claude.ai/cb?error=invalid_request');
});
it('shows the error when there is nowhere safe to return to', async () => {
getOAuthAuthorizeInfo.mockRejectedValue(new Error('Die Anwendung ist MindSift nicht bekannt.'));
render(<OAuthConsentView user={user} instanceName="MindSift" onLogout={() => {}} />);
expect(await screen.findByText('Die Anwendung ist MindSift nicht bekannt.')).toBeTruthy();
expect(screen.queryByRole('button', { name: 'Erlauben' })).toBeNull();
});
});
+63 -32
View File
@@ -12,6 +12,7 @@ import {
Bot,
} from 'lucide-react';
import { APIKey, ApiKeyScope, PriorityLevel, SharedList, TaskList } from '../../lib/types';
import { SCOPES, SCOPE_GROUPS, scopeChip } from '../../lib/apiScopes';
import { api, triggerHaptic } from '../../lib/api';
import { ToggleSwitch } from '../../components/ToggleSwitch';
@@ -19,27 +20,8 @@ interface ApiKeysPanelProps {
onIngested?: () => void;
}
// What a key may do. The rights hold for every access alike: curl/REST and MCP for AI agents. The ids of
// the delivering rights (webhook:…) stay from the time keys could only deliver data.
type ScopeGroup = 'tasks' | 'lists' | 'inbox';
const SCOPES: { id: ApiKeyScope; label: string; chip: string; hint: string; group: ScopeGroup }[] = [
{ id: 'tasks:read', label: 'Lesen', chip: 'Aufgaben lesen', hint: 'Aufgabenlisten und Aufgaben lesen, suchen', group: 'tasks' },
{ id: 'webhook:task', label: 'Anlegen', chip: 'Aufgaben anlegen', hint: 'Nur neue Aufgaben anlegen', group: 'tasks' },
{ id: 'tasks:write', label: 'Ändern', chip: 'Aufgaben ändern', hint: 'Anlegen, ändern, abhaken und löschen', group: 'tasks' },
{ id: 'lists:read', label: 'Lesen', chip: 'Listen lesen', hint: 'Listen und ihre Einträge lesen', group: 'lists' },
{ id: 'webhook:list', label: 'Einträge hinzufügen', chip: 'Listeneinträge hinzufügen', hint: 'Nur neue Einträge hinzufügen', group: 'lists' },
{ id: 'lists:write', label: 'Ändern', chip: 'Listen ändern', hint: 'Einträge hinzufügen, ändern und abhaken', group: 'lists' },
{ id: 'webhook:triage', label: 'Inbox füllen', chip: 'Inbox', hint: 'Gedanken in die Inbox einliefern', group: 'inbox' },
];
const SCOPE_GROUPS: [ScopeGroup, string][] = [
['tasks', '📋 Aufgaben'],
['lists', '🛒 Listen'],
['inbox', '📥 Inbox'],
];
const scopeChip = (id: string) => SCOPES.find((s) => s.id === id)?.chip || id;
type SimTarget = 'triage' | 'task' | 'list_item';
type McpClient = 'claude' | 'codex' | 'json';
type McpClient = 'connector' | 'claude' | 'codex' | 'json';
type Access = 'rest' | 'mcp';
// Personal API keys with rights (switches) and how to connect: curl / smart home (read examples,
@@ -50,7 +32,10 @@ export const ApiKeysPanel: React.FC<ApiKeysPanelProps> = ({ onIngested }) => {
const [newKeyScopes, setNewKeyScopes] = useState<ApiKeyScope[]>(['webhook:triage']);
const [newlyCreatedKey, setNewlyCreatedKey] = useState<string | null>(null);
const [copiedKey, setCopiedKey] = useState(false);
const [mcpClient, setMcpClient] = useState<McpClient>('claude');
const [mcpClient, setMcpClient] = useState<McpClient>('connector');
// Address for claude.ai / ChatGPT connectors; empty while the server has no OAuth (no BASE_URL)
const [connectorUrl, setConnectorUrl] = useState('');
const [copiedConnector, setCopiedConnector] = useState(false);
const [access, setAccess] = useState<Access>('rest');
// Live Webhook Simulator State
@@ -72,6 +57,10 @@ export const ApiKeysPanel: React.FC<ApiKeysPanelProps> = ({ onIngested }) => {
loadKeys();
api.getAllLists().then(setLists).catch(() => {});
api.getTaskLists().then(setTaskLists).catch(() => {});
api
.getAuthStatus()
.then((st) => setConnectorUrl(st.mcpConnectorUrl || ''))
.catch(() => {});
}, []);
// Editing an existing key: name and rights (the secret stays the same)
@@ -145,10 +134,10 @@ export const ApiKeysPanel: React.FC<ApiKeysPanelProps> = ({ onIngested }) => {
await loadKeys();
};
const handleDeleteKey = async (id: string) => {
if (!window.confirm('Diesen API-Schlüssel wirklich widerrufen?')) return;
const handleDeleteKey = async (k: APIKey) => {
if (!window.confirm(k.kind === 'oauth' ? `Die Verbindung „${k.name}“ wirklich trennen?` : 'Diesen API-Schlüssel wirklich widerrufen?')) return;
triggerHaptic(20);
await api.deleteAPIKey(id);
await api.deleteAPIKey(k.id);
await loadKeys();
};
@@ -312,8 +301,9 @@ http_headers = { "X-API-Key" = "${exampleToken}" }`
</div>
{renderScopeSwitches(editScopes, toggleEditScope, `Berechtigungen von ${k.name}`)}
<p className="text-[11px] text-faint">
Der Schlüssel selbst bleibt gleich; Geräte und Agenten, die ihn nutzen, müssen nichts ändern. Die neuen
Rechte gelten ab sofort.
{k.kind === 'oauth'
? 'Die Verbindung bleibt bestehen; die neuen Rechte gelten ab sofort.'
: 'Der Schlüssel selbst bleibt gleich; Geräte und Agenten, die ihn nutzen, müssen nichts ändern. Die neuen Rechte gelten ab sofort.'}
</p>
{editError && <p className="text-[11px] text-status-err">{editError}</p>}
</div>
@@ -321,7 +311,13 @@ http_headers = { "X-API-Key" = "${exampleToken}" }`
<div key={k.id} className="px-3 py-2 bg-canvas flex items-center justify-between gap-3">
<div className="min-w-0 flex flex-wrap items-center gap-x-3 gap-y-1">
<span className="text-sm font-medium text-main">{k.name}</span>
<span className="text-xs font-mono text-accent">{k.keyPrefix}</span>
{k.kind === 'oauth' ? (
<span className="px-1.5 py-0.5 rounded-btn bg-accent/15 text-accent text-[10px] font-semibold" title="Verbunden über OAuth (claude.ai, ChatGPT …)">
OAuth
</span>
) : (
<span className="text-xs font-mono text-accent">{k.keyPrefix}</span>
)}
{k.scopes
.split(',')
.filter(Boolean)
@@ -346,7 +342,7 @@ http_headers = { "X-API-Key" = "${exampleToken}" }`
<Pencil className="w-3.5 h-3.5" />
</button>
<button
onClick={() => handleDeleteKey(k.id)}
onClick={() => handleDeleteKey(k)}
className="btn-icon btn-icon-danger w-7 h-7"
aria-label={`API-Key ${k.name} löschen`}
>
@@ -561,7 +557,8 @@ http_headers = { "X-API-Key" = "${exampleToken}" }`
<h3 className="text-sm font-bold text-main">KI-Agenten anbinden (MCP)</h3>
<p className="text-xs text-muted">
MindSift ist ein MCP-Server: Claude Code, Codex, Claude Desktop, Cursor und eigene Agenten nutzen dieselben
Schlüssel und Rechte wie curl, nur in der Form, die Agenten erwarten.
Schlüssel und Rechte wie curl, nur in der Form, die Agenten erwarten. claude.ai und ChatGPT verbinden sich ohne
Schlüssel: MindSift fragt beim Verbinden, was der Agent darf.
</p>
</div>
</div>
@@ -569,6 +566,7 @@ http_headers = { "X-API-Key" = "${exampleToken}" }`
<div className="flex flex-wrap gap-1.5 mb-2" role="radiogroup" aria-label="Agent">
{(
[
['connector', 'claude.ai / ChatGPT'],
['claude', 'Claude Code'],
['codex', 'OpenAI Codex'],
['json', 'Claude Desktop / Cursor'],
@@ -588,7 +586,41 @@ http_headers = { "X-API-Key" = "${exampleToken}" }`
</button>
))}
</div>
<pre className="p-3 bg-canvas border border-border font-mono text-xs text-main overflow-x-auto">{mcpSnippet}</pre>
{mcpClient === 'connector' ? (
connectorUrl ? (
<div className="space-y-2">
<div className="flex items-center gap-2">
<code className="flex-1 min-w-0 p-3 bg-canvas border border-border font-mono text-xs text-main break-all">{connectorUrl}</code>
<button
type="button"
onClick={() => {
navigator.clipboard.writeText(connectorUrl);
setCopiedConnector(true);
setTimeout(() => setCopiedConnector(false), 2000);
}}
className="btn-icon w-11 h-11 sm:w-9 sm:h-9 flex-shrink-0"
aria-label="Adresse kopieren"
title="Adresse kopieren"
>
{copiedConnector ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
</button>
</div>
<p className="text-xs text-muted">
In claude.ai unter <span className="text-main">Einstellungen → Connectors → Eigenen Connector hinzufügen</span>{' '}
diese Adresse eintragen (in ChatGPT entsprechend als Connector bzw. App). Beim Verbinden öffnet sich MindSift:
anmelden, Rechte wählen, erlauben. Die Verbindung erscheint oben in der Liste und lässt sich dort ändern oder
trennen; unbenutzt endet sie nach einiger Zeit von selbst.
</p>
</div>
) : (
<p className="p-3 bg-canvas border border-border text-xs text-muted">
Verbinden über claude.ai und ChatGPT braucht OAuth, und das setzt <code className="font-mono">BASE_URL</code> in
der Server-Konfiguration voraus. Bitte die Person, die MindSift betreibt, sie zu setzen.
</p>
)
) : (
<pre className="p-3 bg-canvas border border-border font-mono text-xs text-main overflow-x-auto">{mcpSnippet}</pre>
)}
<p className="mt-2 text-[11px] text-faint">
Werkzeuge: <code className="font-mono">list_task_lists</code>, <code className="font-mono">create_task_list</code>,{' '}
<code className="font-mono">list_tasks</code>, <code className="font-mono">get_task</code>,{' '}
@@ -598,8 +630,7 @@ http_headers = { "X-API-Key" = "${exampleToken}" }`
<code className="font-mono">get_list</code>, <code className="font-mono">add_list_item</code>,{' '}
<code className="font-mono">check_list_item</code>, <code className="font-mono">update_list_item</code>,{' '}
<code className="font-mono">search</code>, <code className="font-mono">capture_to_inbox</code>. Der Agent sieht
nur die Werkzeuge, für die der Schlüssel berechtigt ist; Freigaben gelten wie für dich selbst. ChatGPT im Browser und claude.ai brauchen OAuth und
werden noch nicht unterstützt.
nur die Werkzeuge, für die der Schlüssel bzw. die Verbindung berechtigt ist; Freigaben gelten wie für dich selbst.
</p>
</div>
)}
@@ -7,7 +7,9 @@ vi.mock('../../../lib/api', () => ({
api: {
getAPIKeys: vi.fn(async () => [
{ id: 'k0', userId: 'u', name: 'Rauchmelder', keyPrefix: 'mindsift_sec_xy', scopes: 'webhook:triage,webhook:task', createdAt: '' },
{ id: 'k9', userId: 'u', name: 'Claude (OAuth)', keyPrefix: 'OAuth', scopes: 'tasks:read', createdAt: '', kind: 'oauth' },
]),
getAuthStatus: vi.fn(async () => ({ mcpConnectorUrl: 'https://ms.example/api/v1/mcp' })),
getAllLists: vi.fn(async () => []),
getTaskLists: vi.fn(async () => []),
createAPIKey: (...args: unknown[]) => createAPIKey(...(args as [])),
@@ -49,11 +51,21 @@ describe('ApiKeysPanel', () => {
expect(screen.getAllByText(/tasks\?due=today&status=open/).length).toBeGreaterThan(0);
expect(screen.queryByText(/claude mcp add/)).toBeNull();
fireEvent.click(screen.getByRole('tab', { name: /KI-Agenten/ }));
// claude.ai / ChatGPT first: the connector address, no key
expect(await screen.findByText('https://ms.example/api/v1/mcp')).toBeTruthy();
fireEvent.click(screen.getByRole('radio', { name: 'Claude Code' }));
expect(screen.getByText(/claude mcp add --transport http mindsift/)).toBeTruthy();
fireEvent.click(screen.getByRole('radio', { name: 'OpenAI Codex' }));
expect(screen.getByText(/\[mcp_servers\.mindsift\]/)).toBeTruthy();
});
it('marks OAuth connections instead of showing a key prefix', async () => {
render(<ApiKeysPanel />);
expect(await screen.findByText('Claude (OAuth)')).toBeTruthy();
expect(screen.getByTitle(/Verbunden über OAuth/)).toBeTruthy();
expect(screen.queryByText('OAuth', { selector: '.font-mono' })).toBeNull();
});
it('renames a key and changes its rights', async () => {
render(<ApiKeysPanel />);
fireEvent.click(await screen.findByRole('button', { name: 'API-Key Rauchmelder bearbeiten' }));
+27
View File
@@ -1,5 +1,6 @@
import {
APIKey,
OAuthAuthorizeInfo,
AuthStatus,
DashboardStats,
Document,
@@ -1322,6 +1323,32 @@ export const api = {
await request(`/api/v1/keys/${id}`, { method: 'DELETE' });
},
// --- OAUTH CONSENT (claude.ai, ChatGPT connect to MCP) ---
/** query: the search string of /oauth/authorize as the client sent it */
async getOAuthAuthorizeInfo(query: string): Promise<OAuthAuthorizeInfo> {
return request<OAuthAuthorizeInfo>(`/api/v1/oauth/authorize${query}`);
},
/** Allow (with the chosen rights) or refuse; returns where to send the browser */
async answerOAuthAuthorize(query: string, approve: boolean, scopes: ApiKeyScope[]): Promise<{ redirect: string }> {
const q = new URLSearchParams(query);
return request<{ redirect: string }>('/api/v1/oauth/authorize', {
method: 'POST',
body: JSON.stringify({
responseType: q.get('response_type') || '',
clientId: q.get('client_id') || '',
redirectUri: q.get('redirect_uri') || '',
codeChallenge: q.get('code_challenge') || '',
codeChallengeMethod: q.get('code_challenge_method') || '',
state: q.get('state') || '',
scope: q.get('scope') || '',
resource: q.get('resource') || '',
approve,
scopes,
}),
});
},
/** Simulator: sends like an external system would, with the given key or else the own session. */
async testWebhookIngest(
apiKey: string | null,
+20
View File
@@ -0,0 +1,20 @@
import { ApiKeyScope } from './types';
// What a key may do. The rights hold for every access alike: curl/REST and MCP for AI agents. The ids of
// the delivering rights (webhook:…) stay from the time keys could only deliver data.
export type ScopeGroup = 'tasks' | 'lists' | 'inbox';
export const SCOPES: { id: ApiKeyScope; label: string; chip: string; hint: string; group: ScopeGroup }[] = [
{ id: 'tasks:read', label: 'Lesen', chip: 'Aufgaben lesen', hint: 'Aufgabenlisten und Aufgaben lesen, suchen', group: 'tasks' },
{ id: 'webhook:task', label: 'Anlegen', chip: 'Aufgaben anlegen', hint: 'Nur neue Aufgaben anlegen', group: 'tasks' },
{ id: 'tasks:write', label: 'Ändern', chip: 'Aufgaben ändern', hint: 'Anlegen, ändern, abhaken und löschen', group: 'tasks' },
{ id: 'lists:read', label: 'Lesen', chip: 'Listen lesen', hint: 'Listen und ihre Einträge lesen', group: 'lists' },
{ id: 'webhook:list', label: 'Einträge hinzufügen', chip: 'Listeneinträge hinzufügen', hint: 'Nur neue Einträge hinzufügen', group: 'lists' },
{ id: 'lists:write', label: 'Ändern', chip: 'Listen ändern', hint: 'Einträge hinzufügen, ändern und abhaken', group: 'lists' },
{ id: 'webhook:triage', label: 'Inbox füllen', chip: 'Inbox', hint: 'Gedanken in die Inbox einliefern', group: 'inbox' },
];
export const SCOPE_GROUPS: [ScopeGroup, string][] = [
['tasks', '📋 Aufgaben'],
['lists', '🛒 Listen'],
['inbox', '📥 Inbox'],
];
export const scopeChip = (id: string) => SCOPES.find((s) => s.id === id)?.chip || id;
+3 -2
View File
@@ -20,8 +20,9 @@ export const isOffline = () => typeof navigator !== 'undefined' && navigator.onL
const DB_NAME = 'mindsift-offline';
const STORE = 'responses';
// Endpoints whose answers are worth keeping; streams, long polls, note sync and downloads are not
const NOT_CACHED = [/^\/api\/v1\/(events|ws)/, /\/changes/, /\/sync/, /^\/api\/v1\/export/, /^\/api\/v1\/search/];
// Endpoints whose answers are worth keeping; streams, long polls, note sync, downloads and the OAuth
// consent are not
const NOT_CACHED = [/^\/api\/v1\/(events|ws)/, /\/changes/, /\/sync/, /^\/api\/v1\/export/, /^\/api\/v1\/search/, /^\/api\/v1\/oauth/];
export const isCacheablePath = (path: string) => path.startsWith('/api/v1/') && !NOT_CACHED.some((re) => re.test(path));
+13
View File
@@ -17,6 +17,8 @@ export interface AuthStatus {
sharingMode?: SharingMode;
// "Passwort vergessen?" works (SMTP and BASE_URL configured)
passwordReset?: boolean;
// MCP address for claude.ai / ChatGPT connectors (OAuth); empty when the server has no BASE_URL
mcpConnectorUrl?: string;
// Demo mode with periodic reset: next reset (ISO) and seconds until then
demoNextReset?: string;
demoResetInSeconds?: number;
@@ -674,6 +676,17 @@ export interface APIKey {
scopes: string; // comma separated ApiKeyScope values
lastUsedAt?: string;
createdAt: string;
// 'oauth': a connection agreed to on the consent page (claude.ai, ChatGPT), no secret to show
kind?: 'key' | 'oauth';
}
// What the consent page (/oauth/authorize) shows; redirect alone means "go back to the client"
export interface OAuthAuthorizeInfo {
redirect?: string;
clientName?: string;
redirectHost?: string;
scopes?: ApiKeyScope[];
offeredScopes?: ApiKeyScope[];
}
export interface DashboardStats {