From 5c711c2cf77336850bb004962361957227a2a14c Mon Sep 17 00:00:00 2001 From: Sandro Date: Thu, 29 Oct 2026 14:36:17 +0100 Subject: [PATCH] feat(api-keys): scopes for triage, tasks and list items (FLAW-07) Keys get scopes when created (webhook:triage, webhook:task, webhook:list; default triage, old keys become triage). The webhook ingest learns target task (optional task list) and list_item (needs a list the owner may write to); /inbox checks the scope of its target as well; a missing scope answers 403. Keys can no longer open live streams, which carried all of the owner's events. The visible key UI lived in the account settings while EcosystemView was never rendered; it is replaced by ApiKeysPanel in the settings with scope chips, scope badges per key and a simulator for all three targets. Examples send the key as X-API-Key header, not in the URL. Co-Authored-By: Claude Opus 5.5 --- Doku/api/rest.md | 11 +- Doku/flaws.md | 4 +- frontend/src/features/help/HelpView.tsx | 5 +- .../src/features/settings/ApiKeysPanel.tsx | 380 ++++++++++++++++++ .../src/features/settings/EcosystemView.tsx | 283 ------------- .../features/settings/UserSettingsView.tsx | 170 +------- frontend/src/lib/api.ts | 33 +- frontend/src/lib/types.ts | 5 +- internal/api/handlers_integrations.go | 95 ++++- internal/api/handlers_thoughts.go | 4 + internal/api/realtime.go | 18 +- internal/api/server.go | 67 ++- internal/api/server_test.go | 38 +- internal/storage/apikeys.go | 44 +- internal/storage/sqlite.go | 3 + internal/storage/sqlite_test.go | 2 +- 16 files changed, 644 insertions(+), 518 deletions(-) create mode 100644 frontend/src/features/settings/ApiKeysPanel.tsx delete mode 100644 frontend/src/features/settings/EcosystemView.tsx 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) }