diff --git a/Doku/api/rest.md b/Doku/api/rest.md index 0fd0306..638617d 100644 --- a/Doku/api/rest.md +++ b/Doku/api/rest.md @@ -1,6 +1,6 @@ # REST-API -Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}`. Authentifizierung über `Authorization: Bearer `. **API-Keys** (`Bearer mindsift_sec_…` bzw. Header `X-API-Key`) haben vorerst genau ein Recht: Daten einliefern über `POST /inbox` (Gedanke oder per `target` direkt Aufgabe) und `POST /webhooks/ingest`; alle anderen Endpunkte antworten mit `403`. Feinere Berechtigungen (Scopes) folgen. Zeitstempel sind ISO-8601 (UTC). Quelle der Wahrheit sind die Routen in [server.go](../../internal/api/server.go). +Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}`. Authentifizierung über `Authorization: Bearer `. **API-Keys** (`Bearer mindsift_sec_…` bzw. Header `X-API-Key`) liefern nur Daten ein, über `POST /inbox` und `POST /webhooks/ingest`, und nur für die Ziele ihrer Scopes: `webhook:triage` (Inbox), `webhook:task` (Aufgaben), `webhook:list` (Listeneinträge). Alles andere, auch Live-Streams, antwortet mit `403` bzw. `401`. Zeitstempel sind ISO-8601 (UTC). Quelle der Wahrheit sind die Routen in [server.go](../../internal/api/server.go). ## Öffentlich @@ -15,7 +15,7 @@ Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}` | POST | `/auth/forgot-password` | `{email}` → immer `{ok: true}`; Mail mit Link nur, wenn das Konto existiert. `503`, wenn SMTP oder `BASE_URL` fehlen | | POST | `/auth/reset-password` | `{token, password}` (mind. 8 Zeichen) → neues Passwort, alle Sitzungen beendet; `400` bei ungültigem oder abgelaufenem Link | | POST | `/auth/quickstart` | Immer `403` (Gastzugang deaktiviert) | -| POST | `/webhooks/ingest` | Aufgabe per Webhook anlegen, Token als `?token=`, `X-API-Key` oder Bearer. Body: `title`/`content`/`text`/`message`, `priority`, `source`, `tags`, `dueDate`; Klartext-Body wird als Inhalt genommen | +| POST | `/webhooks/ingest` | Einliefern per Webhook, Key als `X-API-Key` (empfohlen), Bearer oder `?token=`. Body: `title`/`content`/`text`/`message`, `priority`, `source`, `tags`, `dueDate`; `target` (oder `type`): `triage` (Standard, Inbox), `task` (optional `taskListId`), `list_item` (`listId`, `category`, `quantity`). Klartext-Body wird als Inhalt genommen. `403` ohne passenden Scope oder ohne Schreibrecht auf die Liste | | GET | `/ws`, `/ws/spaces/{id}`, `/ws/lists/{id}` | WebSocket, siehe [websocket.md](websocket.md); Token als `?token=` nötig | | GET | `/events` | SSE-Fallback; Token als `?token=` nötig | @@ -40,7 +40,7 @@ Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}` | — | `/import/mindsift?mode=restore` | Wiederherstellen: löscht vor dem Import alle eigenen Notizen, Ordner, Aufgaben, Gedanken und Listen (erst nachdem die Datei vollständig gelesen und geprüft ist) | | DELETE | `/user/data` | Alle eigenen Daten löschen, Konto bleibt (`{"password"}` nötig) | | DELETE | `/user/account` | Eigenes Konto mit allen Daten löschen (`{"password"}` nötig); `403` für den letzten Server-Admin. `/auth/me` liefert dafür `isLastAdmin` | -| GET/POST/DELETE | `/keys`, `/keys/{id}` | API-Keys verwalten (Klartext nur bei Erstellung) | +| GET/POST/DELETE | `/keys`, `/keys/{id}` | API-Keys verwalten (Klartext nur bei Erstellung); `POST` mit `name` und `scopes` (Standard `["webhook:triage"]`) | ### Globale Suche (`GET /api/v1/search?q=`) @@ -197,6 +197,11 @@ curl -X POST https://mindsift.example.com/api/v1/webhooks/ingest \ -H "X-API-Key: mindsift_sec_…" -H "Content-Type: application/json" \ -d '{"title":"Milch kaufen #einkauf","priority":"high","dueDate":"2026-11-01"}' +# Einkaufsliste per API-Key ergänzen (Scope webhook:list) +curl -X POST https://mindsift.example.com/api/v1/webhooks/ingest \ + -H "X-API-Key: mindsift_sec_…" -H "Content-Type: application/json" \ + -d '{"target":"list_item","listId":"lst_…","content":"Hafermilch","quantity":"2"}' + # Aufgabenliste nur lesend freigeben curl -X POST https://mindsift.example.com/api/v1/task-lists/tkl_…/shares \ -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ diff --git a/Doku/flaws.md b/Doku/flaws.md index e8be04d..0992085 100644 --- a/Doku/flaws.md +++ b/Doku/flaws.md @@ -16,7 +16,7 @@ Schweregrade: **Hoch** (Datenabfluss oder Rechteumgehung möglich), **Mittel** ( | FLAW-04 | Auth | ~~Fest eincodiertes Fallback-`JWT_SECRET`~~ | **Behoben** | | FLAW-05 | Benachrichtigungen | ~~Discord-/Webhook-URLs ungeprüft (SSRF)~~ | **Behoben** | | FLAW-06 | Aufgaben | ~~Jeder Editor einer Aufgabenliste kann sie per `teamId` an weitere Gruppen freigeben~~ | **Behoben** | -| FLAW-07 | Auth | ~~Ein API-Key ist ein Vollzugriff~~ – Keys dürfen nur noch einliefern; Scopes fehlen noch | Niedrig | +| FLAW-07 | Auth | ~~Ein API-Key ist ein Vollzugriff~~ | **Behoben** | | FLAW-08 | Auth | ~~Webhook-Ingest mit JWT prüft weder Sperrliste noch `token_version` noch `is_active`~~ | **Behoben** | | FLAW-09 | Auth | ~~Kein Rate-Limit, keine Sperre bei Fehlversuchen, Mindestpasswort 4 Zeichen~~ | **Behoben** | | FLAW-10 | Freigaben | ~~`sharing_mode` wird bei Freigaben nicht durchgesetzt; Benutzerverzeichnis leakt alle Konten~~ | **Behoben** | @@ -79,7 +79,7 @@ Schweregrade: **Hoch** (Datenabfluss oder Rechteumgehung möglich), **Mittel** ( - **Maßnahme:** `teamId`-Änderungen nur für den Eigentümer; das Legacy-Feld `task_lists.team_id` zugunsten von `task_list_shares` abschaffen. ### FLAW-07: API-Key-Scopes ohne Wirkung -- **Teilweise behoben:** API-Keys dürfen nur noch `POST /inbox` und `POST /webhooks/ingest` nutzen (`apiKeyAllowed` in `server.go`), alles andere gibt `403`. Auswählbare Scopes (Triage, Aufgaben, Listen) stehen noch aus. +- **Status: Behoben.** API-Keys dürfen nur `POST /inbox` und `POST /webhooks/ingest` nutzen (`apiKeyAllowed`), und nur für die Ziele ihrer Scopes: `webhook:triage`, `webhook:task`, `webhook:list` (`requireDeliveryScope`, sonst `403`). Live-Streams (WebSocket/SSE) nehmen keine Keys an. Alte Keys bekamen `webhook:triage`. Auswahl und Badges in den Kontoeinstellungen. Test: `TestAPIKeysOnlyDeliverData`. - **Fundstellen:** Tabelle `api_keys.scopes` (Standard `ingest,read`), `authMiddleware`. - **Problem:** Ein Key mit Präfix `mindsift_sec_` authentifiziert als der Benutzer, mit allen Rechten: alle `/api/v1`-Routen, bei Admins auch `/admin/*`, außerdem das Anlegen weiterer Keys. Für Webhooks (nur Aufgaben anlegen) ist das überdimensioniert. - **Maßnahme:** Scopes auswerten; Keys standardmäßig nur auf `/inbox` und `/webhooks/ingest` beschränken. diff --git a/frontend/src/features/help/HelpView.tsx b/frontend/src/features/help/HelpView.tsx index 9c0df9a..5368645 100644 --- a/frontend/src/features/help/HelpView.tsx +++ b/frontend/src/features/help/HelpView.tsx @@ -488,7 +488,10 @@ export const HelpView: React.FC = ({ onBack }) => { content: (

- Unter Benutzereinstellungen → Webhooks & API kannst du persönliche API-Schlüssel generieren. + Unter Kontoeinstellungen → Private Webhooks & Export kannst du persönliche API-Schlüssel erzeugen. Pro Schlüssel legst du fest, was er einliefern darf: Gedanken in die Inbox, Aufgaben direkt oder Einträge in Listen. Lesen kann ein Schlüssel nie. +

+

+ Mit dem Feld "target" wählst du das Ziel: triage (Standard), task (optional mit taskListId) oder list_item (mit listId). Der Simulator in den Einstellungen zeigt passende Beispiele.

Über den Endpunkt POST /api/v1/webhooks/ingest kannst du Gedanken aus iOS-Kurzbefehlen, Smart-Home-Systemen oder Drittanwendungen einspeisen. Sie werden automatisch mit dem Tag #webhook markiert. diff --git a/frontend/src/features/settings/ApiKeysPanel.tsx b/frontend/src/features/settings/ApiKeysPanel.tsx new file mode 100644 index 0000000..94f02d2 --- /dev/null +++ b/frontend/src/features/settings/ApiKeysPanel.tsx @@ -0,0 +1,380 @@ +import React, { useEffect, useState } from 'react'; +import { + Key, + Plus, + Trash2, + Copy, + Check, + Webhook, + Zap, + Terminal, +} from 'lucide-react'; +import { APIKey, ApiKeyScope, PriorityLevel, SharedList, TaskList } from '../../lib/types'; +import { api, triggerHaptic } from '../../lib/api'; + +interface ApiKeysPanelProps { + onIngested?: () => void; +} + +// What a key may deliver; keys never read anything +const SCOPES: { id: ApiKeyScope; label: string; hint: string }[] = [ + { id: 'webhook:triage', label: '📥 Triage / Inbox', hint: 'Gedanken in die Inbox' }, + { id: 'webhook:task', label: '📋 Aufgaben', hint: 'Aufgaben direkt anlegen' }, + { id: 'webhook:list', label: '📝 Listeneinträge', hint: 'Einträge zu Listen hinzufügen' }, +]; +const scopeLabel = (id: string) => SCOPES.find((s) => s.id === id)?.label || id; + +type SimTarget = 'triage' | 'task' | 'list_item'; + +// Personal API keys with scopes, a simulator and copyable examples (settings → Private Webhooks) +export const ApiKeysPanel: React.FC = ({ onIngested }) => { + const [keys, setKeys] = useState([]); + const [newKeyName, setNewKeyName] = useState(''); + const [newKeyScopes, setNewKeyScopes] = useState(['webhook:triage']); + const [newlyCreatedKey, setNewlyCreatedKey] = useState(null); + const [copiedKey, setCopiedKey] = useState(false); + + // Live Webhook Simulator State + const [simTitle, setSimTitle] = useState('Waschmaschine im Keller ist fertig!'); + const [simPriority, setSimPriority] = useState('high'); + const [simTarget, setSimTarget] = useState('triage'); + const [simListId, setSimListId] = useState(''); + const [simTaskListId, setSimTaskListId] = useState(''); + const [simStatus, setSimStatus] = useState(null); + const [lists, setLists] = useState([]); + const [taskLists, setTaskLists] = useState([]); + + const loadKeys = async () => { + const k = await api.getAPIKeys(); + setKeys(k); + }; + + useEffect(() => { + loadKeys(); + api.getAllLists().then(setLists).catch(() => {}); + api.getTaskLists().then(setTaskLists).catch(() => {}); + }, []); + + const toggleScope = (id: ApiKeyScope) => { + setNewKeyScopes((prev) => { + const next = prev.includes(id) ? prev.filter((s) => s !== id) : [...prev, id]; + return next.length > 0 ? next : prev; // at least one + }); + }; + + const handleCreateKey = async (e: React.FormEvent) => { + e.preventDefault(); + triggerHaptic(15); + const created = await api.createAPIKey(newKeyName.trim() || 'Home Assistant', newKeyScopes); + if (created.rawKey) { + setNewlyCreatedKey(created.rawKey); + } + setNewKeyName(''); + setNewKeyScopes(['webhook:triage']); + await loadKeys(); + }; + + const handleDeleteKey = async (id: string) => { + if (!window.confirm('Diesen API-Schlüssel wirklich widerrufen?')) return; + triggerHaptic(20); + await api.deleteAPIKey(id); + await loadKeys(); + }; + + const handleCopy = (text: string) => { + navigator.clipboard.writeText(text); + setCopiedKey(true); + setTimeout(() => setCopiedKey(false), 2000); + }; + + const handleSimulateWebhook = async () => { + triggerHaptic(20); + if (simTarget === 'list_item' && !simListId) { + setSimStatus('❌ Bitte eine Liste wählen.'); + return; + } + try { + // With a freshly created key the simulator tests exactly that key (and its scopes) + await api.testWebhookIngest(newlyCreatedKey, { + title: simTitle, + priority: simPriority, + tags: simTarget === 'list_item' ? [] : ['smarthome', 'automation'], + target: simTarget, + taskListId: simTarget === 'task' ? simTaskListId || undefined : undefined, + listId: simTarget === 'list_item' ? simListId : undefined, + }); + setSimStatus( + simTarget === 'task' + ? '✅ Webhook erfolgreich ausgelöst! Die Aufgabe ist angelegt.' + : simTarget === 'list_item' + ? '✅ Webhook erfolgreich ausgelöst! Der Eintrag steht in der Liste.' + : '✅ Webhook erfolgreich ausgelöst! Neue Karte liegt in deiner Inbox.' + ); + onIngested?.(); + setTimeout(() => setSimStatus(null), 4000); + } catch (err: any) { + setSimStatus(`❌ Fehler: ${err.message}`); + } + }; + + const exampleToken = newlyCreatedKey || 'mindsift_sec_dein_api_key'; + const baseUrl = window.location.origin; + const examplePayload = + simTarget === 'task' + ? `{ + "target": "task", + "title": "Filter der Lüftungsanlage wechseln", + "priority": "high", + "dueDate": "2026-11-15T09:00"${simTaskListId ? `,\n "taskListId": "${simTaskListId}"` : ''} + }` + : simTarget === 'list_item' + ? `{ + "target": "list_item", + "listId": "${simListId || 'ID_DER_LISTE'}", + "content": "Hafermilch", + "quantity": "2" + }` + : `{ + "title": "Kellerfeuchtigkeit über 70%", + "priority": "urgent", + "tags": ["smarthome", "keller"] + }`; + + return ( +

+ {/* API keys */} +
+
+
+ +
+
+

Webhooks & Ingestion API-Schlüssel

+

+ Verbinde Home Assistant, Zigbee2MQTT, n8n oder eigene Cronjobs mit MindSift. Ein Key kann nur Daten + einliefern, nie lesen; was genau, legst du pro Key fest. +

+
+
+ +
+
+ setNewKeyName(e.target.value)} + placeholder="Bezeichnung (z. B. Home Assistant, Proxmox Backup, Rauchmelder)…" + className="w-full sm:flex-1 min-w-0 h-11 sm:h-9 px-3 input-field text-base sm:text-sm" + /> + +
+
+ Darf einliefern: + {SCOPES.map((sc) => { + const on = newKeyScopes.includes(sc.id); + return ( + + ); + })} +
+
+ + {newlyCreatedKey && ( +
+
+

Neuer API-Schlüssel erstellt (bitte jetzt kopieren):

+ {newlyCreatedKey} +
+ +
+ )} + + {/* Active Keys List */} +
+ {keys.length === 0 ? ( +

Noch keine dedizierten API-Schlüssel hinterlegt.

+ ) : ( + keys.map((k) => ( +
+
+ {k.name} + {k.keyPrefix} + {k.scopes + .split(',') + .filter(Boolean) + .map((sc) => ( + + {scopeLabel(sc)} + + ))} + {k.lastUsedAt && ( + + Zuletzt genutzt: {new Date(k.lastUsedAt).toLocaleDateString('de-DE')} + + )} +
+ +
+ )) + )} +
+
+ + {/* Simulator */} +
+
+
+ +
+
+

Live Smarthome Webhook-Simulator

+

+ Teste den Webhook-Endpunkt direkt hier + {newlyCreatedKey ? ' – mit dem eben erzeugten Key und seinen Berechtigungen.' : ' – mit deiner Anmeldung.'} +

+
+
+ +
+ {( + [ + ['triage', '📥 Inbox'], + ['task', '📋 Aufgabe'], + ['list_item', '📝 Listeneintrag'], + ] as const + ).map(([value, label]) => ( + + ))} + {simTarget === 'list_item' && ( + + )} + {simTarget === 'task' && ( + + )} +
+ + {simTarget === 'triage' && ( +
+ {[ + { label: '🧺 Waschmaschine fertig', text: 'Waschmaschine im Keller ist fertig – Wäsche aufhängen!', prio: 'high' as PriorityLevel }, + { label: '🚨 Rauchmelder Batterie', text: 'Rauchmelder Dachgeschoss meldet niedrige Batterie (12%)', prio: 'urgent' as PriorityLevel }, + { label: '💧 Luftfeuchtigkeit Keller', text: 'Kellerfeuchtigkeit über 70% – Lüften oder Entfeuchter starten', prio: 'urgent' as PriorityLevel }, + ].map((preset) => ( + + ))} +
+ )} + +
+ setSimTitle(e.target.value)} + className="flex-1 h-11 sm:h-9 px-3 input-field text-base sm:text-sm" + /> + +
+ + {simStatus && ( +

{simStatus}

+ )} + + {/* Code Example Snippets */} +
+
+ + cURL / Home Assistant REST Command: +
+
+{`curl -X POST "${baseUrl}/api/v1/webhooks/ingest" \\
+  -H "X-API-Key: ${exampleToken}" \\
+  -H "Content-Type: application/json" \\
+  -d '${examplePayload}'`}
+          
+

+ Der Key gehört in den Header X-API-Key, nicht in die URL, damit er nicht in + Proxy-Logs landet. Fehlt dem Key die Berechtigung für das Ziel, antwortet der Server mit 403. +

+
+
+ +
+ ); +}; diff --git a/frontend/src/features/settings/EcosystemView.tsx b/frontend/src/features/settings/EcosystemView.tsx deleted file mode 100644 index bafa526..0000000 --- a/frontend/src/features/settings/EcosystemView.tsx +++ /dev/null @@ -1,283 +0,0 @@ -import React, { useEffect, useState } from 'react'; -import { - Key, - Plus, - Trash2, - Copy, - Check, - Webhook, - Download, - ShieldCheck, - Zap, - Terminal, -} from 'lucide-react'; -import { APIKey, PriorityLevel } from '../../lib/types'; -import { api, getToken, triggerHaptic } from '../../lib/api'; - -interface EcosystemViewProps { - onIngested: () => void; -} - -export const EcosystemView: React.FC = ({ onIngested }) => { - const [keys, setKeys] = useState([]); - const [newKeyName, setNewKeyName] = useState(''); - const [newlyCreatedKey, setNewlyCreatedKey] = useState(null); - const [copiedKey, setCopiedKey] = useState(false); - - // Live Webhook Simulator State - const [simTitle, setSimTitle] = useState('Waschmaschine im Keller ist fertig!'); - const [simPriority, setSimPriority] = useState('high'); - const [simStatus, setSimStatus] = useState(null); - - const loadKeys = async () => { - const k = await api.getAPIKeys(); - setKeys(k); - }; - - useEffect(() => { - loadKeys(); - }, []); - - const handleCreateKey = async (e: React.FormEvent) => { - e.preventDefault(); - triggerHaptic(15); - const created = await api.createAPIKey(newKeyName.trim() || 'Home Assistant'); - if (created.rawKey) { - setNewlyCreatedKey(created.rawKey); - } - setNewKeyName(''); - await loadKeys(); - }; - - const handleDeleteKey = async (id: string) => { - triggerHaptic(20); - await api.deleteAPIKey(id); - await loadKeys(); - }; - - const handleCopy = (text: string) => { - navigator.clipboard.writeText(text); - setCopiedKey(true); - setTimeout(() => setCopiedKey(false), 2000); - }; - - const handleSimulateWebhook = async () => { - triggerHaptic(20); - const tokenToUse = newlyCreatedKey || getToken() || ''; - try { - await api.testWebhookIngest(tokenToUse, simTitle, simPriority, ['smarthome', 'automation']); - setSimStatus('✅ Webhook erfolgreich ausgelöst! Neue Karte liegt in deiner Inbox.'); - onIngested(); - setTimeout(() => setSimStatus(null), 4000); - } catch (err: any) { - setSimStatus(`❌ Fehler: ${err.message}`); - } - }; - - const handleFullExport = async () => { - triggerHaptic(15); - const data = await api.exportAll(); - const blob = new Blob([JSON.stringify(data, null, 2)], { - type: 'application/json;charset=utf-8', - }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `mindsift-backup-${new Date().toISOString().slice(0, 10)}.json`; - a.click(); - URL.revokeObjectURL(url); - }; - - const exampleToken = newlyCreatedKey || 'mindsift_sec_dein_api_key'; - const baseUrl = window.location.origin; - - return ( -
- {/* Section 1: API Keys for Smarthome & Automation */} -
-
-
- -
-
-

- Webhooks & Ingestion API-Schlüssel -

-

- Verbinde Home Assistant, Zigbee2MQTT, n8n oder eigene Cronjobs direkt mit deiner MindSift Inbox. -

-
-
- -
- setNewKeyName(e.target.value)} - placeholder="Bezeichnung (z. B. Home Assistant, Proxmox Backup, Rauchmelder)…" - className="w-full sm:flex-1 min-w-0 h-11 sm:h-9 px-3 input-field text-base sm:text-sm" - /> - -
- - {newlyCreatedKey && ( -
-
-

- Neuer API-Schlüssel erstellt (bitte jetzt kopieren): -

- - {newlyCreatedKey} - -
- -
- )} - - {/* Active Keys List */} -
- {keys.length === 0 ? ( -

- Noch keine dedizierten API-Schlüssel hinterlegt. -

- ) : ( - keys.map((k) => ( -
-
- {k.name} - {k.keyPrefix} - {k.lastUsedAt && ( - - Zuletzt genutzt: {new Date(k.lastUsedAt).toLocaleDateString('de-DE')} - - )} -
- -
- )) - )} -
-
- - {/* Section 2: Interactive Smarthome Webhook Simulator */} -
-
-
- -
-
-

- Live Smarthome Webhook-Simulator -

-

- Teste den Webhook-Endpunkt direkt hier – das Event landet sofort als Karte im Triage-Deck. -

-
-
- -
- {[ - { label: '🧺 Waschmaschine fertig', text: 'Waschmaschine im Keller ist fertig – Wäsche aufhängen!', prio: 'high' as PriorityLevel }, - { label: '🚨 Rauchmelder Batterie', text: 'Rauchmelder Dachgeschoss meldet niedrige Batterie (12%)', prio: 'urgent' as PriorityLevel }, - { label: '💧 Luftfeuchtigkeit Keller', text: 'Kellerfeuchtigkeit über 70% – Lüften oder Entfeuchter starten', prio: 'urgent' as PriorityLevel }, - ].map((preset) => ( - - ))} -
- -
- setSimTitle(e.target.value)} - className="flex-1 h-9 px-3 input-field text-sm" - /> - -
- - {simStatus && ( -

{simStatus}

- )} - - {/* Code Example Snippets */} -
-
- - cURL / Home Assistant REST Command: -
-
-{`curl -X POST "${baseUrl}/api/v1/webhooks/ingest?token=${exampleToken}" \\
-  -H "Content-Type: application/json" \\
-  -d '{
-    "title": "Kellerfeuchtigkeit über 70%",
-    "priority": "urgent",
-    "tags": ["smarthome", "keller"]
-  }'`}
-          
-
-
- - {/* Section 3: Data Sovereignty & Export */} -
-
-
- -
-
-

- 100 % Datensouveränität & Komplett-Export -

-

- Exportiere jederzeit alle Gedanken, Aufgaben, Dokumente und geteilten Listen als portables JSON-Archiv. -

-
-
- - -
-
- ); -}; diff --git a/frontend/src/features/settings/UserSettingsView.tsx b/frontend/src/features/settings/UserSettingsView.tsx index f347c15..b83c188 100644 --- a/frontend/src/features/settings/UserSettingsView.tsx +++ b/frontend/src/features/settings/UserSettingsView.tsx @@ -3,9 +3,7 @@ import { User as UserIcon, Bell, Key, - Plus, Trash2, - Terminal, Download, ArrowLeft, Check, @@ -20,7 +18,8 @@ import { Pencil, Copy, } from 'lucide-react'; -import { APIKey, AuthStatus, User, UserTheme } from '../../lib/types'; +import { AuthStatus, User, UserTheme } from '../../lib/types'; +import { ApiKeysPanel } from './ApiKeysPanel'; import { api, clearToken, triggerHaptic } from '../../lib/api'; import { passwordProblem } from '../../lib/password'; import { customThemeId, ThemeId, ThemeOption } from '../../lib/theme'; @@ -208,12 +207,6 @@ export const UserSettingsView: React.FC = ({ const [savingAlarms, setSavingAlarms] = useState(false); const [testingChannel, setTestingChannel] = useState(null); - // --- Webhooks & API Keys State --- - const [apiKeys, setApiKeys] = useState([]); - const [newKeyName, setNewKeyName] = useState(''); - const [newlyCreatedKey, setNewlyCreatedKey] = useState(null); - const [copiedLabel, setCopiedLabel] = useState(null); - useEffect(() => { setDisplayName(currentUser.displayName || ''); setEmail(currentUser.email || ''); @@ -225,19 +218,6 @@ export const UserSettingsView: React.FC = ({ setNotifyBeforeMinutes(currentUser.notifyBeforeMinutes ?? 30); }, [currentUser]); - useEffect(() => { - loadKeys(); - }, []); - - const loadKeys = async () => { - try { - const keys = await api.getAPIKeys(); - setApiKeys(keys); - } catch { - // ignore - } - }; - const toggleAlarmChannel = (ch: string) => { setAlarmChannels((prev) => prev.includes(ch) ? prev.filter((item) => item !== ch) : [...prev, ch] @@ -382,34 +362,7 @@ export const UserSettingsView: React.FC = ({ } }; - // --- Handlers: Webhooks & Export --- - const handleCreateAPIKey = async (e: React.FormEvent) => { - e.preventDefault(); - triggerHaptic(15); - try { - const created = await api.createAPIKey(newKeyName.trim() || 'Eigenes Skript / Webhook'); - if (created.rawKey) { - setNewlyCreatedKey(created.rawKey); - } - setNewKeyName(''); - loadKeys(); - } catch (err: any) { - alert(err.message); - } - }; - - const handleDeleteAPIKey = async (id: string) => { - if (!window.confirm('Diesen API-Schlüssel wirklich widerrufen?')) return; - await api.deleteAPIKey(id); - loadKeys(); - }; - - const handleCopy = (text: string, label: string) => { - navigator.clipboard.writeText(text); - setCopiedLabel(label); - setTimeout(() => setCopiedLabel(null), 2000); - }; - + // --- Handlers: Export --- // Works on mobile browsers too: the link must be in the document and the URL alive until the click ran const saveBlob = (blob: Blob, filename: string) => { const url = URL.createObjectURL(blob); @@ -448,20 +401,6 @@ export const UserSettingsView: React.FC = ({ } }; - const baseUrl = window.location.origin; - const activeKeyPlaceholder = - newlyCreatedKey || (apiKeys[0]?.keyPrefix ? apiKeys[0].keyPrefix + '••••••••' : 'DEIN_API_KEY'); - - const curlTriageExample = `curl -X POST ${baseUrl}/api/v1/inbox \\ - -H "X-API-Key: ${activeKeyPlaceholder}" \\ - -H "Content-Type: application/json" \\ - -d '{"content": "Kellerfeuchtigkeit über 68% gestiegen – Entfeuchter prüfen #smarthome #alarm", "priority": "urgent"}'`; - - const curlTaskExample = `curl -X POST ${baseUrl}/api/v1/inbox \\ - -H "X-API-Key: ${activeKeyPlaceholder}" \\ - -H "Content-Type: application/json" \\ - -d '{"content": "Server-Alarm prüfen #monitoring", "priority": "urgent", "target": "task"}'`; - const roleBadgeLabel = currentUser.role === 'admin' ? 'Server-Admin' @@ -1226,108 +1165,7 @@ export const UserSettingsView: React.FC = ({

- {/* Create API Key */} -
- setNewKeyName(e.target.value)} - placeholder="Bezeichnung des Schlüssels (z.B. Proxmox Monitoring, Discord-Forwarder)…" - className="w-full sm:flex-1 min-w-0 h-11 sm:h-9 px-3 input-field text-base sm:text-xs" - /> - -
- - {newlyCreatedKey && ( -
-
-

- Neuer API-Schlüssel erstellt (bitte jetzt kopieren, wird nur einmal im - Klartext angezeigt): -

- {newlyCreatedKey} -
- -
- )} - - {/* Existing Keys List */} -
- {apiKeys.length === 0 ? ( -
- Noch keine persönlichen API-Schlüssel vorhanden. -
- ) : ( - apiKeys.map((k) => ( -
-
-

{k.name}

-

- Präfix: {k.keyPrefix}•••••••• · Erstellt:{' '} - {new Date(k.createdAt).toLocaleDateString()} -

-
- -
- )) - )} -
- - {/* Curl Examples */} -
-
-
- - - Curl: In Triage-Deck einspeisen (erhält Webhook-Badge) - - -
-
-                    {curlTriageExample}
-                  
-
- -
-
- - - Curl: Direkt als Aufgabe anlegen - - -
-
-                    {curlTaskExample}
-                  
-
-
+ {/* Full Data Backup */}
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 45bae9c..9609836 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -4,6 +4,7 @@ import { DashboardStats, Document, TrashedDocument, + ApiKeyScope, Backlink, DocumentBlock, DocShare, @@ -1108,10 +1109,10 @@ export const api = { return ensureArray(res); }, - async createAPIKey(name: string): Promise { + async createAPIKey(name: string, scopes: ApiKeyScope[]): Promise { return request('/api/v1/keys', { method: 'POST', - body: JSON.stringify({ name }), + body: JSON.stringify({ name, scopes }), }); }, @@ -1119,19 +1120,23 @@ export const api = { await request(`/api/v1/keys/${id}`, { method: 'DELETE' }); }, + /** Simulator: sends like an external system would, with the given key or else the own session. */ async testWebhookIngest( - token: string, - title: string, - priority: PriorityLevel, - tags: string[] - ): Promise<{ status: string; thought: Thought }> { - return request<{ status: string; thought: Thought }>( - `/api/v1/webhooks/ingest?token=${encodeURIComponent(token)}`, - { - method: 'POST', - body: JSON.stringify({ title, priority, tags }), - } - ); + apiKey: string | null, + payload: { + title: string; + priority?: PriorityLevel; + tags?: string[]; + target?: 'triage' | 'task' | 'list_item'; + taskListId?: string; + listId?: string; + } + ): Promise<{ status: string }> { + return request<{ status: string }>('/api/v1/webhooks/ingest', { + method: 'POST', + headers: apiKey ? { 'X-API-Key': apiKey } : {}, + body: JSON.stringify(payload), + }); }, async searchAll(q: string): Promise { diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 8959c78..37e74c7 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -477,13 +477,16 @@ export interface UserTheme { updatedAt: string; } +/** What an API key may deliver; keys never read data. */ +export type ApiKeyScope = 'webhook:triage' | 'webhook:task' | 'webhook:list'; + export interface APIKey { id: string; userId: string; name: string; keyPrefix: string; rawKey?: string; - scopes: string; + scopes: string; // comma separated ApiKeyScope values lastUsedAt?: string; createdAt: string; } diff --git a/internal/api/handlers_integrations.go b/internal/api/handlers_integrations.go index 487ebbe..c26717c 100644 --- a/internal/api/handlers_integrations.go +++ b/internal/api/handlers_integrations.go @@ -1,7 +1,9 @@ package api import ( + "context" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -9,6 +11,7 @@ import ( "github.com/go-chi/chi/v5" "github.com/sandrodach/mindsift/internal/auth" + "github.com/sandrodach/mindsift/internal/storage" ) // --- API KEYS & WEBHOOK INGESTION --- @@ -26,11 +29,12 @@ func (s *Server) handleGetAPIKeys(w http.ResponseWriter, r *http.Request) { func (s *Server) handleCreateAPIKey(w http.ResponseWriter, r *http.Request) { userID := auth.GetUserID(r.Context()) var req struct { - Name string `json:"name"` + Name string `json:"name"` + Scopes []string `json:"scopes"` } _ = json.NewDecoder(r.Body).Decode(&req) - key, err := s.store.CreateAPIKey(userID, req.Name) + key, err := s.store.CreateAPIKey(userID, req.Name, req.Scopes) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return @@ -59,11 +63,14 @@ func (s *Server) handleWebhookIngest(w http.ResponseWriter, r *http.Request) { } // Same checks as every other request (revoked/outdated sessions, inactive accounts) - user, _ := s.authenticateToken(token) + user, keyScopes := s.authenticateToken(token) if user == nil { writeError(w, http.StatusUnauthorized, "Ungültiger Webhook-Token oder API-Key") return } + if keyScopes != nil { + r = r.WithContext(context.WithValue(r.Context(), apiKeyScopesKey{}, keyScopes)) + } var req struct { Title string `json:"title"` @@ -75,6 +82,13 @@ func (s *Server) handleWebhookIngest(w http.ResponseWriter, r *http.Request) { Tags []string `json:"tags"` DueDate string `json:"dueDate"` DueDate2 string `json:"due_date"` + // Where it goes: "triage" (default, inbox), "task" or "list_item"; "type" is accepted as well + Target string `json:"target"` + Type string `json:"type"` + TaskListID string `json:"taskListId"` + ListID string `json:"listId"` + Category string `json:"category"` + Quantity string `json:"quantity"` } bodyBytes, _ := io.ReadAll(r.Body) @@ -120,8 +134,20 @@ func (s *Server) handleWebhookIngest(w http.ResponseWriter, r *http.Request) { } } - if req.Priority == "" { - req.Priority = "high" + target := strings.ToLower(strings.TrimSpace(req.Target)) + if target == "" { + target = strings.ToLower(strings.TrimSpace(req.Type)) + } + switch target { + case "", "triage", "inbox": + target = "triage" + case "task", "list_item": + default: + writeError(w, http.StatusBadRequest, "Unbekanntes Ziel – erlaubt: triage, task, list_item") + return + } + if !requireDeliveryScope(w, r, target) { + return } source := strings.ToLower(strings.TrimSpace(req.Source)) @@ -129,6 +155,18 @@ func (s *Server) handleWebhookIngest(w http.ResponseWriter, r *http.Request) { source = "webhook" } + if target == "list_item" { + s.ingestListItem(w, user.ID, req.ListID, bodyText, req.Category, req.Quantity) + return + } + if target == "task" { + s.ingestTask(w, user.ID, bodyText, req.Priority, source, dueDate, req.TaskListID) + return + } + + if req.Priority == "" { + req.Priority = "high" + } thought, err := s.store.CreateThought(user.ID, bodyText, req.Priority, source, dueDate) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) @@ -142,3 +180,50 @@ func (s *Server) handleWebhookIngest(w http.ResponseWriter, r *http.Request) { "thought": thought, }) } + +// ingestTask creates a task directly (scope webhook:task), optionally in a task list the owner of +// the key may write to. +func (s *Server) ingestTask(w http.ResponseWriter, userID, content, priority, source, dueDate, taskListID string) { + if priority == "" { + priority = "normal" + } + if taskListID != "" && !s.store.CanEditTaskList(userID, taskListID) { + writeError(w, http.StatusForbidden, storage.ErrTaskListReadOnly.Error()) + return + } + thought, err := s.store.CreateThoughtWithDetails(userID, content, priority, source, dueDate, taskListID, "todo", "") + if err != nil { + writeStoreError(w, http.StatusBadRequest, err) + return + } + task, err := s.store.UpdateThoughtTriage(userID, thought.ID, "keep", "task", "", dueDate, taskListID) + if err != nil { + writeStoreError(w, http.StatusBadRequest, err) + return + } + s.wsHub.BroadcastToUsers(s.taskAudience(task), "TASK_CREATED", task) + writeJSON(w, http.StatusCreated, map[string]interface{}{"status": "ingested", "task": task}) +} + +// ingestListItem adds an item to a list (scope webhook:list) the owner of the key may write to. +func (s *Server) ingestListItem(w http.ResponseWriter, userID, listID, content, category, quantity string) { + if strings.TrimSpace(listID) == "" { + writeError(w, http.StatusBadRequest, "Feld 'listId' ist für Listeneinträge erforderlich") + return + } + if err := s.store.EnsureListItemsWritable(userID, listID, ""); err != nil { + status := http.StatusNotFound + if errors.Is(err, storage.ErrListReadOnly) { + status = http.StatusForbidden + } + writeError(w, status, err.Error()) + return + } + item, err := s.store.CreateListItem(listID, content, category, quantity, 0, userID) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + s.wsHub.BroadcastListEvent(listID, "ITEM_CREATED", item) + writeJSON(w, http.StatusCreated, map[string]interface{}{"status": "ingested", "item": item}) +} diff --git a/internal/api/handlers_thoughts.go b/internal/api/handlers_thoughts.go index 27cfbb2..9bed8a6 100644 --- a/internal/api/handlers_thoughts.go +++ b/internal/api/handlers_thoughts.go @@ -60,6 +60,10 @@ func (s *Server) handleCaptureInbox(w http.ResponseWriter, r *http.Request) { } } + if !requireDeliveryScope(w, r, strings.TrimSpace(req.Target)) { + return + } + thought, err := s.store.CreateThoughtWithDetails(userID, req.Content, req.Priority, source, dueDate, taskListID, "todo", "") if err != nil { writeStoreError(w, http.StatusBadRequest, err) diff --git a/internal/api/realtime.go b/internal/api/realtime.go index 03d17d1..27079db 100644 --- a/internal/api/realtime.go +++ b/internal/api/realtime.go @@ -16,21 +16,9 @@ func (s *Server) authenticateUserFromReq(r *http.Request) string { token = strings.TrimPrefix(authHdr, "Bearer ") } } - if token != "" { - if strings.HasPrefix(token, "mindsift_sec_") { - if user, err := s.store.ValidateAPIKeyAndGetUser(token); err == nil && user != nil && user.IsActive { - return user.ID - } - } - if claims, err := auth.ValidateToken(s.cfg.JWTSecret, token); err == nil && claims != nil { - if !s.store.IsTokenRevoked(token) { - if user, err := s.store.GetUserByID(claims.UserID); err == nil && user != nil && user.IsActive { - if claims.TokenVersion == 0 || claims.TokenVersion == user.TokenVersion { - return claims.UserID - } - } - } - } + // Live streams carry the user's data, so API keys (which only deliver) are not accepted here + if user, keyScopes := s.authenticateToken(token); user != nil && keyScopes == nil { + return user.ID } return "" } diff --git a/internal/api/server.go b/internal/api/server.go index 6d8ba14..bcb17a2 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -1,6 +1,7 @@ package api import ( + "context" "encoding/json" "errors" "io/fs" @@ -382,34 +383,64 @@ func (s *Server) apiLoggingMiddleware(next http.Handler) http.Handler { // authenticateToken resolves a session JWT or an API key (mindsift_sec_…) to an active user. JWTs are // checked against the revocation list and token_version; viaAPIKey tells the caller which it was. -func (s *Server) authenticateToken(token string) (user *domain.User, viaAPIKey bool) { +// authenticateToken checks a session token or an API key. keyScopes is nil for sessions and the key's +// scopes for API keys. +func (s *Server) authenticateToken(token string) (user *domain.User, keyScopes []string) { token = strings.TrimSpace(token) if token == "" { - return nil, false + return nil, nil } if strings.HasPrefix(token, "mindsift_sec_") { - u, err := s.store.ValidateAPIKeyAndGetUser(token) + u, scopes, err := s.store.ValidateAPIKey(token) if err != nil || u == nil || !u.IsActive { - return nil, true + return nil, nil } - return u, true + return u, scopes } claims, err := auth.ValidateToken(s.cfg.JWTSecret, token) if err != nil || claims == nil || s.store.IsTokenRevoked(token) { - return nil, false + return nil, nil } u, err := s.store.GetUserByID(claims.UserID) if err != nil || u == nil || !u.IsActive { - return nil, false + return nil, nil } if claims.TokenVersion != 0 && claims.TokenVersion != u.TokenVersion { - return nil, false + return nil, nil } - return u, false + return u, nil } -// apiKeyAllowed: until API keys get scopes, a key has exactly one right – delivering data into MindSift -// (POST /api/v1/inbox; /api/v1/webhooks/ingest authenticates on its own). Everything else needs a login. +type apiKeyScopesKey struct{} + +// requireDeliveryScope: API keys need the scope for the target they deliver to; sessions may do all. +// target: "inbox"/"triage", "task", "list_item". +func requireDeliveryScope(w http.ResponseWriter, r *http.Request, target string) bool { + scopes, isKey := r.Context().Value(apiKeyScopesKey{}).([]string) + if !isKey { + return true + } + need := storage.ScopeTriage + switch target { + case "task": + need = storage.ScopeTask + case "list_item": + need = storage.ScopeList + case "document": + need = "" // notes cannot be created with a key + } + for _, sc := range scopes { + if need != "" && sc == need { + return true + } + } + writeError(w, http.StatusForbidden, "API-Key besitzt keine Berechtigung für diese Aktion") + return false +} + +// apiKeyAllowed: an API key only delivers data into MindSift (POST /api/v1/inbox; /api/v1/webhooks/ingest +// authenticates on its own); what it may deliver is decided by its scopes (requireDeliveryScope). +// Everything else needs a login. func apiKeyAllowed(r *http.Request) bool { return r.Method == http.MethodPost && r.URL.Path == "/api/v1/inbox" } @@ -422,16 +453,20 @@ func (s *Server) authMiddleware(next http.Handler) http.Handler { } else { token = r.Header.Get("X-API-Key") } - user, viaAPIKey := s.authenticateToken(token) + user, keyScopes := s.authenticateToken(token) if user == nil { writeError(w, http.StatusUnauthorized, "Nicht autorisiert – bitte anmelden") return } - if viaAPIKey && !apiKeyAllowed(r) { - writeError(w, http.StatusForbidden, "API-Keys dürfen nur Daten einliefern (POST /api/v1/inbox oder /api/v1/webhooks/ingest)") - return + ctx := auth.WithUserID(r.Context(), user.ID) + if keyScopes != nil { + if !apiKeyAllowed(r) { + writeError(w, http.StatusForbidden, "API-Keys dürfen nur Daten einliefern (POST /api/v1/inbox oder /api/v1/webhooks/ingest)") + return + } + ctx = context.WithValue(ctx, apiKeyScopesKey{}, keyScopes) } - next.ServeHTTP(w, r.WithContext(auth.WithUserID(r.Context(), user.ID))) + next.ServeHTTP(w, r.WithContext(ctx)) }) } diff --git a/internal/api/server_test.go b/internal/api/server_test.go index c424365..66257b5 100644 --- a/internal/api/server_test.go +++ b/internal/api/server_test.go @@ -796,7 +796,7 @@ func TestWebhookTriageBadgeAndIngestion(t *testing.T) { _ = json.Unmarshal(rec.Body.Bytes(), &authResp) // 2. Create API key for user - apiKey, err := store.CreateAPIKey(authResp.User.ID, "Home Assistant Webhook") + apiKey, err := store.CreateAPIKey(authResp.User.ID, "Home Assistant Webhook", nil) if err != nil || apiKey == nil || apiKey.RawKey == "" { t.Fatalf("failed to create API key: %v", err) } @@ -1416,13 +1416,13 @@ func TestLegalTextsArePublic(t *testing.T) { } } -// API keys have exactly one right until scopes exist: delivering data. Revoked sessions are refused +// API keys only deliver data, and only what their scopes allow (FLAW-07). Revoked sessions are refused // everywhere, the webhook ingest included. func TestAPIKeysOnlyDeliverData(t *testing.T) { srv, store, token := setupTestServer(t) router := srv.Router() claims, _ := auth.ValidateToken(testJWTSecret, token) - key, err := store.CreateAPIKey(claims.UserID, "Home Assistant") + key, err := store.CreateAPIKey(claims.UserID, "Home Assistant", nil) if err != nil { t.Fatalf("CreateAPIKey: %v", err) } @@ -1441,8 +1441,36 @@ func TestAPIKeysOnlyDeliverData(t *testing.T) { if code := do(http.MethodPost, "/api/v1/inbox", key.RawKey, `{"content":"Waschmaschine fertig"}`); code >= 300 { t.Fatalf("API key must be able to post into the inbox, got %d", code) } - if code := do(http.MethodPost, "/api/v1/inbox", key.RawKey, `{"content":"Filter wechseln","target":"task"}`); code >= 300 { - t.Fatalf("API key must be able to create a task via the inbox, got %d", code) + // Default key = triage only + if code := do(http.MethodPost, "/api/v1/inbox", key.RawKey, `{"content":"Filter wechseln","target":"task"}`); code != http.StatusForbidden { + t.Fatalf("triage-only key must not create tasks, got %d", code) + } + if code := do(http.MethodPost, "/api/v1/webhooks/ingest", key.RawKey, `{"content":"Milch","target":"list_item","listId":"x"}`); code != http.StatusForbidden { + t.Fatalf("triage-only key must not add list items, got %d", code) + } + full, _ := store.CreateAPIKey(claims.UserID, "Alles", []string{"webhook:task", "webhook:list"}) + if code := do(http.MethodPost, "/api/v1/webhooks/ingest", full.RawKey, `{"title":"Filter wechseln","target":"task","priority":"high"}`); code != http.StatusCreated { + t.Fatalf("task scope must create a task, got %d", code) + } + if code := do(http.MethodPost, "/api/v1/webhooks/ingest", full.RawKey, `{"content":"Rauchmelder"}`); code != http.StatusForbidden { + t.Fatalf("key without triage scope must not fill the inbox, got %d", code) + } + list, _ := store.CreateList(claims.UserID, "Einkauf", "", "shopping") + if code := do(http.MethodPost, "/api/v1/webhooks/ingest", full.RawKey, `{"content":"Milch","target":"list_item","listId":"`+list.ID+`","quantity":"2"}`); code != http.StatusCreated { + t.Fatalf("list scope must add the item, got %d", code) + } + if items, _ := store.GetListItems(list.ID); len(items) != 1 || items[0].Quantity != "2" { + t.Fatalf("list item not stored: %+v", items) + } + other, _ := store.CreateUser("other@example.com", "hash", "Other", "member") + foreign, _ := store.CreateList(other.ID, "Fremd", "", "shopping") + if code := do(http.MethodPost, "/api/v1/webhooks/ingest", full.RawKey, `{"content":"Spam","target":"list_item","listId":"`+foreign.ID+`"}`); code != http.StatusNotFound { + t.Fatalf("key must not write into lists its owner cannot see, got %d", code) + } + // Keys never open live streams (those carry the user's data) + streamReq := httptest.NewRequest(http.MethodGet, "/api/v1/events?token="+full.RawKey, nil) + if uid := srv.authenticateUserFromReq(streamReq); uid != "" { + t.Fatalf("API key must not authenticate a live stream") } if code := do(http.MethodGet, "/api/v1/documents", key.RawKey, ""); code != http.StatusForbidden { t.Fatalf("API key must not read notes, got %d", code) diff --git a/internal/storage/apikeys.go b/internal/storage/apikeys.go index 80a2c84..435bb59 100644 --- a/internal/storage/apikeys.go +++ b/internal/storage/apikeys.go @@ -10,7 +10,32 @@ import ( // --- API KEYS & WEBHOOKS --- -func (s *Store) CreateAPIKey(userID, name string) (*domain.APIKey, error) { +// Scopes of an API key: what it may deliver. A key never reads data. +const ( + ScopeTriage = "webhook:triage" // thoughts into the inbox + ScopeTask = "webhook:task" // tasks directly + ScopeList = "webhook:list" // items into lists +) + +// NormalizeScopes keeps the known scopes in a fixed order; none at all means triage only. +func NormalizeScopes(scopes []string) []string { + want := map[string]bool{} + for _, sc := range scopes { + want[strings.ToLower(strings.TrimSpace(sc))] = true + } + out := []string{} + for _, sc := range []string{ScopeTriage, ScopeTask, ScopeList} { + if want[sc] { + out = append(out, sc) + } + } + if len(out) == 0 { + out = []string{ScopeTriage} + } + return out +} + +func (s *Store) CreateAPIKey(userID, name string, scopes []string) (*domain.APIKey, error) { if strings.TrimSpace(name) == "" { name = "Smarthome / Webhook Key" } @@ -22,7 +47,7 @@ func (s *Store) CreateAPIKey(userID, name string) (*domain.APIKey, error) { KeyPrefix: prefix, KeyHash: hash, RawKey: rawKey, - Scopes: "ingest,read", + Scopes: strings.Join(NormalizeScopes(scopes), ","), CreatedAt: domain.NowISO(), } @@ -64,12 +89,19 @@ func (s *Store) DeleteAPIKey(userID, keyID string) error { } func (s *Store) ValidateAPIKeyAndGetUser(rawKey string) (*domain.User, error) { + u, _, err := s.ValidateAPIKey(rawKey) + return u, err +} + +// ValidateAPIKey returns the owner of a key and the key's scopes. +func (s *Store) ValidateAPIKey(rawKey string) (*domain.User, []string, error) { hash := auth.HashAPIKey(strings.TrimSpace(rawKey)) - var keyID, userID string - err := s.db.QueryRow(`SELECT id, user_id FROM api_keys WHERE key_hash = ?`, hash).Scan(&keyID, &userID) + var keyID, userID, scopes string + err := s.db.QueryRow(`SELECT id, user_id, scopes FROM api_keys WHERE key_hash = ?`, hash).Scan(&keyID, &userID, &scopes) if err != nil { - return nil, errors.New("invalid API key") + return nil, nil, errors.New("invalid API key") } _, _ = s.db.Exec(`UPDATE api_keys SET last_used_at = ? WHERE id = ?`, domain.NowISO(), keyID) - return s.GetUserByID(userID) + u, err := s.GetUserByID(userID) + return u, NormalizeScopes(strings.Split(scopes, ",")), err } diff --git a/internal/storage/sqlite.go b/internal/storage/sqlite.go index 5c2cd75..00c221c 100644 --- a/internal/storage/sqlite.go +++ b/internal/storage/sqlite.go @@ -497,6 +497,9 @@ func (s *Store) migrate() error { _, _ = s.db.Exec(`UPDATE team_members SET role = 'member' WHERE role NOT IN ('admin', 'member')`) _, _ = s.db.Exec(`UPDATE users SET role = 'member' WHERE role = 'team_admin'`) + // Keys from before scopes existed ("ingest,read") may deliver into the inbox, as before + _, _ = s.db.Exec(`UPDATE api_keys SET scopes = 'webhook:triage' WHERE scopes NOT LIKE '%webhook:%'`) + // Block tree, operation log and idempotency tables for collaborative editing s.migrateDocSync() diff --git a/internal/storage/sqlite_test.go b/internal/storage/sqlite_test.go index a3d957b..2a94316 100644 --- a/internal/storage/sqlite_test.go +++ b/internal/storage/sqlite_test.go @@ -87,7 +87,7 @@ func TestStoreLifecycle(t *testing.T) { } // 4. Test Webhook API Key generation & validation - apiKey, err := store.CreateAPIKey(user.ID, "Home Assistant") + apiKey, err := store.CreateAPIKey(user.ID, "Home Assistant", nil) if err != nil { t.Fatalf("CreateAPIKey failed: %v", err) }