feat(api): one API for curl and MCP, REST reads with keys, origin badge with key name
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m16s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m16s
API keys reach tasks and lists over plain REST too (GET /task-lists, GET /tasks with
status/due filters, PATCH/DELETE /thoughts/{id}, GET /lists, list items), guarded by a route
table; notes and settings stay closed. Rights hold for every access alike (webhook:task also
allows create_task over MCP, tasks:write also task delivery).
Tasks, thoughts and list items record which key created them and which one changed them last
(migration 13 replaces agent_mark; origin stays after the user's own edits and survives the
triage into a list item). OriginBadge shows the key name with a webhook or robot icon, details
on hover and tap, and replaces the Webhook and KI badges everywhere, now also on list items.
Settings: category "API" with rights grouped by tasks, lists and inbox and two tabs
(curl / smart home with a Home Assistant sensor, AI agents over MCP). Docs, help, README and
changelog updated; v0.30.0.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0ca715ba64
commit
b883315914
36 files changed
+985
-429
No files matched your search
+13
-1
@@ -6,9 +6,20 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.30.0] - 2026-11-01
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
- Aufgaben und Listen per curl und Smart Home: Ein API-Schlüssel mit Leserecht ruft z. B. mit `GET /api/v1/tasks?due=today&status=open` ab, was heute ansteht (Filter auch `overdue`, ein Datum, `status`, `taskListId`), und liest Listen. Mit Änderungsrecht hakt er Aufgaben ab, ändert oder löscht sie und pflegt Listeneinträge. Die Einstellungen zeigen dafür einen fertigen Home-Assistant-Sensor „Heute fällig“.
|
||||||
|
- Herkunfts-Badge: Was ein API-Schlüssel anlegt oder ändert, trägt dauerhaft eine Badge mit seinem Namen, vorne ein Webhook-Symbol (API) oder ein Roboter (MCP). Mouseover oder Tippen zeigt, wer wann angelegt und zuletzt geändert hat; in der Aufgaben-Detailansicht steht es unten. Die Badge gibt es jetzt auch an Listeneinträgen.
|
||||||
- Aufgaben in der Listenansicht zeigen ihren Status mit einem farbigen Punkt in der Farbe der Kanban-Spalte: am PC vor der Status-Auswahl, am Handy als Punkt mit Spaltenname (reine Anzeige, ändern weiter in der Detailansicht).
|
- Aufgaben in der Listenansicht zeigen ihren Status mit einem farbigen Punkt in der Farbe der Kanban-Spalte: am PC vor der Status-Auswahl, am Handy als Punkt mit Spaltenname (reine Anzeige, ändern weiter in der Detailansicht).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Webhooks und MCP heißen jetzt zusammen „API“: Die Einstellungs-Kategorie „Webhooks & KI-Agenten“ heißt „API“, die Schalter eines Schlüssels sind nach Aufgaben, Listen und Inbox geordnet (lesen, anlegen, ändern), und die Beispiele stehen in zwei Reitern „curl / Smart Home“ und „KI-Agenten (MCP)“. Die Rechte gelten für beide Zugänge gleich: „Aufgaben anlegen“ erlaubt jetzt auch `create_task` per MCP, „Aufgaben ändern“ auch das Einliefern von Aufgaben per Webhook. Bestehende Schlüssel behalten ihre Rechte.
|
||||||
|
- Die Badges „Webhook“ und „KI“ / „KI geändert“ sind durch die Herkunfts-Badge ersetzt. Sie verschwindet nicht mehr, wenn du einen Eintrag selbst bearbeitest.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Ein per Webhook oder MCP eingelieferter Gedanke verlor seine Herkunft, wenn er in der Triage zum Listeneintrag wurde.
|
||||||
|
|
||||||
## [0.29.0] - 2026-11-01
|
## [0.29.0] - 2026-11-01
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -566,7 +577,8 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V
|
|||||||
- Geteilte Listen (Einkaufs-, Pack- und Checklisten) mit `#Kategorie`-Gruppierung, Mengenangaben und Echtzeit-Abhaken.
|
- Geteilte Listen (Einkaufs-, Pack- und Checklisten) mit `#Kategorie`-Gruppierung, Mengenangaben und Echtzeit-Abhaken.
|
||||||
- Benutzer-, Gruppen- und Admin-Verwaltung (Ersteinrichtungs-Assistent, Einladungscodes, Profilbilder, persönliche API-Keys & Webhook-Ingest `/api/v1/webhooks/ingest`, JSON-Datenexport) sowie CSS-Theme-Engine mit 4 Themes (`Dark`, `Light`, `High Contrast`, `Soft`) und PWA-Unterstützung.
|
- Benutzer-, Gruppen- und Admin-Verwaltung (Ersteinrichtungs-Assistent, Einladungscodes, Profilbilder, persönliche API-Keys & Webhook-Ingest `/api/v1/webhooks/ingest`, JSON-Datenexport) sowie CSS-Theme-Engine mit 4 Themes (`Dark`, `Light`, `High Contrast`, `Soft`) und PWA-Unterstützung.
|
||||||
|
|
||||||
[Unreleased]: https://git.dsit.me/sandrodach/MindSift/compare/v0.29.0...dev
|
[Unreleased]: https://git.dsit.me/sandrodach/MindSift/compare/v0.30.0...dev
|
||||||
|
[0.30.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.29.0...v0.30.0
|
||||||
[0.29.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.28.0...v0.29.0
|
[0.29.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.28.0...v0.29.0
|
||||||
[0.28.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.27.1...v0.28.0
|
[0.28.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.27.1...v0.28.0
|
||||||
[0.27.1]: https://git.dsit.me/sandrodach/MindSift/compare/v0.27.0...v0.27.1
|
[0.27.1]: https://git.dsit.me/sandrodach/MindSift/compare/v0.27.0...v0.27.1
|
||||||
|
|||||||
+31
-4
@@ -1,6 +1,6 @@
|
|||||||
# REST-API
|
# REST-API
|
||||||
|
|
||||||
Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}`. Authentifizierung über `Authorization: Bearer <JWT>`. **API-Keys** (`Bearer mindsift_sec_…` bzw. Header `X-API-Key`) liefern 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). Mit den Agenten-Scopes `tasks:read`, `tasks:write`, `lists:read`, `lists:write` nutzen sie außerdem den [MCP-Endpunkt](#mcp-für-ki-agenten-post-apiv1mcp). Alles andere, auch Live-Streams, antwortet mit `403` bzw. `401`. Zeitstempel sind ISO-8601 (UTC). Anfragekörper sind auf 2 MB begrenzt (Notiz-Sync 8 MB, Importe 101 MB). Im Demo-Modus antworten Endpunkte, die etwas verschicken, Server-Einstellungen ändern oder die gemeinsamen Demo-Konten verändern würden, mit `403` „In der Demo deaktiviert“. 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 <JWT>`. **API-Schlüssel** (`Bearer mindsift_sec_…` bzw. Header `X-API-Key`) haben Rechte (Scopes), die für REST und [MCP](#mcp-für-ki-agenten-post-apiv1mcp) gleich gelten, siehe [API-Schlüssel](#api-schlüssel-rechte-und-herkunft). Alles andere, auch Notizen, Einstellungen und Live-Streams, antwortet einem Schlüssel mit `403`. Zeitstempel sind ISO-8601 (UTC). Anfragekörper sind auf 2 MB begrenzt (Notiz-Sync 8 MB, Importe 101 MB). Im Demo-Modus antworten Endpunkte, die etwas verschicken, Server-Einstellungen ändern oder die gemeinsamen Demo-Konten verändern würden, mit `403` „In der Demo deaktiviert“. Quelle der Wahrheit sind die Routen in [server.go](../../internal/api/server.go).
|
||||||
|
|
||||||
## Öffentlich
|
## Öffentlich
|
||||||
|
|
||||||
@@ -101,10 +101,30 @@ MindSift ist ein [MCP](https://modelcontextprotocol.io)-Server (Streamable HTTP,
|
|||||||
| `search` | ein Lese-Scope | Volltextsuche in Aufgaben und Listeneinträgen (nur die Teile der eigenen Scopes, keine Notizen) |
|
| `search` | ein Lese-Scope | Volltextsuche in Aufgaben und Listeneinträgen (nur die Teile der eigenen Scopes, keine Notizen) |
|
||||||
| `capture_to_inbox` | `webhook:triage` | Gedanken in die Inbox |
|
| `capture_to_inbox` | `webhook:triage` | Gedanken in die Inbox |
|
||||||
|
|
||||||
Was ein Agent anlegt oder ändert, bekommt das Feld `agentMark` (`created` bzw. `edited`) an Aufgabe oder Listeneintrag; die App zeigt dafür das Zeichen „KI“ bzw. „KI geändert“. Ändert die Person den Eintrag selbst (REST, App), fällt die Markierung weg. Ein Agent, der einen eigenen Eintrag ändert, lässt `created` stehen.
|
Was ein Agent anlegt oder ändert, bekommt eine dauerhafte Herkunft (`origin`, siehe [API-Schlüssel](#api-schlüssel-rechte-und-herkunft)). `create_task` geht auch mit `webhook:task`, `add_list_item` auch mit `webhook:list`.
|
||||||
|
|
||||||
Freigaben gelten wie für die Person selbst: Leser-Listen lassen sich nicht ändern, fremde Listen sind unsichtbar. Einrichtung z. B. in Claude Code: `claude mcp add --transport http mindsift https://mindsift.example.com/api/v1/mcp --header "X-API-Key: mindsift_sec_…"`; die Einstellungen zeigen die Varianten für Codex (`config.toml`) und JSON-Konfigurationen (Claude Desktop, Cursor). claude.ai und ChatGPT im Browser verlangen OAuth und werden noch nicht unterstützt.
|
Freigaben gelten wie für die Person selbst: Leser-Listen lassen sich nicht ändern, fremde Listen sind unsichtbar. Einrichtung z. B. in Claude Code: `claude mcp add --transport http mindsift https://mindsift.example.com/api/v1/mcp --header "X-API-Key: mindsift_sec_…"`; die Einstellungen zeigen die Varianten für Codex (`config.toml`) und JSON-Konfigurationen (Claude Desktop, Cursor). claude.ai und ChatGPT im Browser verlangen OAuth und werden noch nicht unterstützt.
|
||||||
|
|
||||||
|
### API-Schlüssel: Rechte und Herkunft
|
||||||
|
|
||||||
|
Ein Schlüssel darf nur diese Endpunkte und braucht dafür eines der genannten Rechte:
|
||||||
|
|
||||||
|
| Recht (Scope) | Bedeutung | REST | MCP-Werkzeuge |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `webhook:triage` | Inbox füllen | `POST /inbox`, `POST /webhooks/ingest` (Ziel `triage`) | `capture_to_inbox` |
|
||||||
|
| `tasks:read` | Aufgaben lesen | `GET /task-lists`, `GET /tasks` | `list_task_lists`, `list_tasks`, `get_task`, `search` |
|
||||||
|
| `webhook:task` | Aufgaben anlegen | `POST /inbox` bzw. `/webhooks/ingest` mit Ziel `task` | `create_task` |
|
||||||
|
| `tasks:write` | Aufgaben ändern (schließt Anlegen ein) | dazu `PATCH`/`DELETE /thoughts/{id}` | dazu `update_task`, `complete_task`, `delete_task`, `create_task_list` |
|
||||||
|
| `lists:read` | Listen lesen | `GET /lists`, `GET /lists/{id}` | `list_lists`, `get_list`, `search` |
|
||||||
|
| `webhook:list` | Listeneinträge hinzufügen | `POST /lists/{id}/items`, `/webhooks/ingest` mit Ziel `list_item` | `add_list_item` |
|
||||||
|
| `lists:write` | Listen ändern (schließt Hinzufügen ein) | dazu `PATCH`/`PUT /lists/{id}/items/{itemId}` | dazu `check_list_item`, `update_list_item`, `create_list` |
|
||||||
|
|
||||||
|
Die Ids `webhook:…` stammen aus der Zeit, als Schlüssel nur einliefern konnten, und bleiben für bestehende Schlüssel gleich.
|
||||||
|
|
||||||
|
`GET /tasks` filtert für Skripte und Smart Home: `status=open|done|all` (Standard alle), `due=today`, `due=overdue` (nur offene) oder `due=JJJJ-MM-TT` (fällig bis einschließlich); Tage zählen in der Zeitzone des Servers (`TZ`). Ohne Parameter bleibt die Antwort wie bisher.
|
||||||
|
|
||||||
|
**Herkunft:** Was ein Schlüssel anlegt oder ändert, trägt an Aufgabe, Gedanke oder Listeneintrag das Feld `origin`: `createdVia`/`createdKey` (angelegt) und `changedVia`/`changedKey`/`changedAt` (zuletzt per Schlüssel geändert), mit `api` oder `mcp` als Zugang und dem Namen des Schlüssels (gelöscht: „Gelöschter Schlüssel“). Die Herkunft bleibt dauerhaft, auch wenn die Person den Eintrag danach selbst bearbeitet, und geht beim Umwandeln in der Triage mit (auch in einen Listeneintrag). Eine MCP-Anfrage aus einer Anmeldung heraus zählt als `mcp` ohne Schlüssel.
|
||||||
|
|
||||||
## Gedanken, Triage, Aufgaben
|
## Gedanken, Triage, Aufgaben
|
||||||
|
|
||||||
| Methode | Pfad | Zweck |
|
| Methode | Pfad | Zweck |
|
||||||
@@ -115,7 +135,7 @@ Freigaben gelten wie für die Person selbst: Leser-Listen lassen sich nicht änd
|
|||||||
| DELETE | `/thoughts/discarded/purge` | Papierkorb leeren |
|
| DELETE | `/thoughts/discarded/purge` | Papierkorb leeren |
|
||||||
| GET | `/triage/deck` | Posteingang (max. 100) |
|
| GET | `/triage/deck` | Posteingang (max. 100) |
|
||||||
| PATCH | `/triage/{id}` | `action`: `keep`/`discard`/`restore`; mit `convertTo`: `task`/`document`/`list_item` |
|
| PATCH | `/triage/{id}` | `action`: `keep`/`discard`/`restore`; mit `convertTo`: `task`/`document`/`list_item` |
|
||||||
| GET | `/tasks?taskListId=` | Aufgaben (optional einer Liste) |
|
| GET | `/tasks?taskListId=&status=&due=` | Aufgaben (optional einer Liste; Filter siehe [API-Schlüssel](#api-schlüssel-rechte-und-herkunft)) |
|
||||||
| POST | `/tasks/reorder`, `/task-lists/{id}/tasks/reorder` | Reihenfolge/Spalte ändern (`items[{id, sortOrder, kanbanStatus}]`) |
|
| POST | `/tasks/reorder`, `/task-lists/{id}/tasks/reorder` | Reihenfolge/Spalte ändern (`items[{id, sortOrder, kanbanStatus}]`) |
|
||||||
| POST | `/tasks/{id}/unlink` | Verknüpfung zu Notiz-Block lösen |
|
| POST | `/tasks/{id}/unlink` | Verknüpfung zu Notiz-Block lösen |
|
||||||
| POST/DELETE | `/tasks/{id}/watch` | Aufgabe beobachten bzw. nicht mehr → `{watching}`; `404` ohne Zugriff. Aufgaben-Antworten tragen `isWatching` |
|
| POST/DELETE | `/tasks/{id}/watch` | Aufgabe beobachten bzw. nicht mehr → `{watching}`; `404` ohne Zugriff. Aufgaben-Antworten tragen `isWatching` |
|
||||||
@@ -263,7 +283,14 @@ curl -X POST https://mindsift.example.com/api/v1/webhooks/ingest \
|
|||||||
-H "X-API-Key: mindsift_sec_…" -H "Content-Type: application/json" \
|
-H "X-API-Key: mindsift_sec_…" -H "Content-Type: application/json" \
|
||||||
-d '{"title":"Milch kaufen #einkauf","priority":"high","dueDate":"2026-11-01"}'
|
-d '{"title":"Milch kaufen #einkauf","priority":"high","dueDate":"2026-11-01"}'
|
||||||
|
|
||||||
# Einkaufsliste per API-Key ergänzen (Scope webhook:list)
|
# Was heute ansteht (Recht tasks:read), z. B. für einen Home-Assistant-Sensor
|
||||||
|
curl "https://mindsift.example.com/api/v1/tasks?due=today&status=open" -H "X-API-Key: mindsift_sec_…"
|
||||||
|
|
||||||
|
# Aufgabe abhaken (Recht tasks:write)
|
||||||
|
curl -X PATCH https://mindsift.example.com/api/v1/thoughts/th_… \
|
||||||
|
-H "X-API-Key: mindsift_sec_…" -H "Content-Type: application/json" -d '{"isCompleted":true}'
|
||||||
|
|
||||||
|
# Einkaufsliste per API-Key ergänzen (Recht webhook:list oder lists:write)
|
||||||
curl -X POST https://mindsift.example.com/api/v1/webhooks/ingest \
|
curl -X POST https://mindsift.example.com/api/v1/webhooks/ingest \
|
||||||
-H "X-API-Key: mindsift_sec_…" -H "Content-Type: application/json" \
|
-H "X-API-Key: mindsift_sec_…" -H "Content-Type: application/json" \
|
||||||
-d '{"target":"list_item","listId":"lst_…","content":"Hafermilch","quantity":"2"}'
|
-d '{"target":"list_item","listId":"lst_…","content":"Hafermilch","quantity":"2"}'
|
||||||
|
|||||||
+6
-5
@@ -63,13 +63,14 @@ Komponente: [CountBadge.tsx](../frontend/src/components/CountBadge.tsx), CSS-Kla
|
|||||||
- Zahlen im mobilen Menü (`MobileActionContext`, `badge: number`) werden automatisch als `CountBadge` gezeigt; Text-Badges wie „2 aktiv“ bleiben Textmarken.
|
- Zahlen im mobilen Menü (`MobileActionContext`, `badge: number`) werden automatisch als `CountBadge` gezeigt; Text-Badges wie „2 aktiv“ bleiben Textmarken.
|
||||||
- **Wichtig für neue Klassen:** Tailwind entfernt Klassen aus `@layer components`, die nicht wörtlich im Code stehen. Klassennamen deshalb nie zusammensetzen (`count-badge-${variant}`), sondern ausschreiben.
|
- **Wichtig für neue Klassen:** Tailwind entfernt Klassen aus `@layer components`, die nicht wörtlich im Code stehen. Klassennamen deshalb nie zusammensetzen (`count-badge-${variant}`), sondern ausschreiben.
|
||||||
|
|
||||||
## Zeichen für Einträge von KI-Agenten
|
## Herkunfts-Badge (API-Schlüssel)
|
||||||
|
|
||||||
Komponente: [AgentBadge.tsx](../frontend/src/components/AgentBadge.tsx)
|
Komponente: [OriginBadge.tsx](../frontend/src/components/OriginBadge.tsx)
|
||||||
|
|
||||||
- Zeigt an Aufgaben (Zeile, Kanban-Karte, Detailansicht, Inbox) und Listeneinträgen, dass ein KI-Agent sie per MCP angelegt („KI“) oder zuletzt geändert hat („KI geändert“). Grundlage ist das Feld `agentMark` vom Server.
|
- Eine Badge für alles, was ein API-Schlüssel angelegt oder geändert hat (Feld `origin`): neutraler Rahmen, vorne ein Symbol für den Zugang (Webhook = REST/curl, Roboter = MCP), dahinter der Name des Schlüssels. Ältere Einträge ohne Schlüssel zeigen „API“ bzw. „MCP“.
|
||||||
- Ändert oder hakt die Person den Eintrag selbst ab, löscht der Server die Markierung; das Zeichen verschwindet dann ohne eigenes Zutun des Frontends.
|
- Höchstens eine Badge pro Eintrag: der Schlüssel, der angelegt hat, sonst der, der zuletzt geändert hat. Sie bleibt dauerhaft, auch nach eigenem Bearbeiten und nach der Triage.
|
||||||
- Größen: `sm` in Aufgabenzeilen und der Detailansicht, `xs` auf Kanban-Karten und in Listen. Kein Hashtag als Ersatz.
|
- Details (angelegt / zuletzt geändert, mit Schlüssel und Zeit) per Mouseover und per Tippen (kleines Info-Fenster, am Handy mit vergrößerter Trefferfläche); in der Aufgaben-Detailansicht stehen sie zusätzlich unten (`originDetails`). Ein Tipp auf die Badge öffnet nie den Eintrag.
|
||||||
|
- Größen: `sm` in Aufgabenzeilen, Inbox-Liste und Detailansicht, `xs` auf Kanban-Karten und in Listen, `md` auf den Triage-Karten.
|
||||||
|
|
||||||
## Ladebildschirm & Statusleiste
|
## Ladebildschirm & Statusleiste
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
[](#)
|
[](#)
|
||||||
[](#-schnellstart)
|
[](#-schnellstart)
|
||||||
[](#-deine-daten-gehören-dir)
|
[](#-deine-daten-gehören-dir)
|
||||||
[](CHANGELOG.md)
|
[](CHANGELOG.md)
|
||||||
|
|
||||||
**MindSift** ist eine selbst gehostete Schaltstelle für spontane Gedanken, Aufgaben mit Kanban-Board, hierarchische Outliner-Notizen, die mehrere Personen gleichzeitig bearbeiten können, und gemeinsame Listen. Alles läuft als ein einzelner Container mit eingebetteter SQLite-Datenbank, auf deinem eigenen Server.
|
**MindSift** ist eine selbst gehostete Schaltstelle für spontane Gedanken, Aufgaben mit Kanban-Board, hierarchische Outliner-Notizen, die mehrere Personen gleichzeitig bearbeiten können, und gemeinsame Listen. Alles läuft als ein einzelner Container mit eingebetteter SQLite-Datenbank, auf deinem eigenen Server.
|
||||||
|
|
||||||
@@ -128,8 +128,10 @@ Technik dahinter: [Doku/modules/collaboration.md](Doku/modules/collaboration.md)
|
|||||||
|
|
||||||
### 🔌 Integrationen
|
### 🔌 Integrationen
|
||||||
|
|
||||||
* **Eingehend (API-Key):** `POST /api/v1/webhooks/ingest` legt Gedanken, Aufgaben oder Listeneinträge aus Smarthome, Skripten oder Automationen an.
|
* **API mit Schlüsseln:** Pro Schlüssel schaltbare Rechte (Aufgaben und Listen lesen, anlegen, ändern; Inbox füllen), gleich für beide Zugänge. Notizen und Einstellungen bleiben außen vor.
|
||||||
* **KI-Agenten (MCP):** MindSift ist ein MCP-Server (`POST /api/v1/mcp`, Streamable HTTP). Claude Code, OpenAI Codex, Claude Desktop, Cursor und eigene Agenten lesen offene Aufgaben, haken sie ab, legen neue an oder löschen sie, legen Aufgabenlisten und Listen an und führen Einkaufs- und Checklisten. Was ein Agent angelegt oder geändert hat, trägt das Zeichen „KI“, bis du es selbst änderst. Pro API-Key schaltbar (lesen/ändern, Aufgaben/Listen); Notizen bleiben außen vor. `claude mcp add --transport http mindsift https://…/api/v1/mcp --header "X-API-Key: mindsift_sec_…"`
|
* **curl / Smart Home:** z. B. `GET /api/v1/tasks?due=today&status=open` für einen Home-Assistant-Sensor „Heute fällig“, abhaken per `PATCH`, Gedanken, Aufgaben und Listeneinträge einliefern per `POST /api/v1/webhooks/ingest`.
|
||||||
|
* **KI-Agenten (MCP):** MindSift ist ein MCP-Server (`POST /api/v1/mcp`, Streamable HTTP). Claude Code, OpenAI Codex, Claude Desktop, Cursor und eigene Agenten lesen, legen an, ändern, haken ab und löschen Aufgaben, legen Aufgabenlisten und Listen an und führen Einkaufs- und Checklisten. `claude mcp add --transport http mindsift https://…/api/v1/mcp --header "X-API-Key: mindsift_sec_…"`
|
||||||
|
* **Herkunft:** Was ein Schlüssel anlegt oder ändert, trägt dauerhaft eine Badge mit seinem Namen; Mouseover oder Tippen zeigt, wer wann angelegt und geändert hat.
|
||||||
* **Ausgehend:** eigener Webhook als Benachrichtigungskanal (JSON per POST), Discord, E-Mail, Push.
|
* **Ausgehend:** eigener Webhook als Benachrichtigungskanal (JSON per POST), Discord, E-Mail, Push.
|
||||||
* Persönliche API-Keys, Datenexport als JSON, Markdown-Archiv (Obsidian-kompatibel).
|
* Persönliche API-Keys, Datenexport als JSON, Markdown-Archiv (Obsidian-kompatibel).
|
||||||
|
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "mindsift-frontend",
|
"name": "mindsift-frontend",
|
||||||
"version": "0.29.0",
|
"version": "0.30.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "mindsift-frontend",
|
"name": "mindsift-frontend",
|
||||||
"version": "0.29.0",
|
"version": "0.30.0",
|
||||||
"license": "Unlicense",
|
"license": "Unlicense",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource-variable/inter": "5.3.0",
|
"@fontsource-variable/inter": "5.3.0",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "mindsift-frontend",
|
"name": "mindsift-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.29.0",
|
"version": "0.30.0",
|
||||||
"license": "Unlicense",
|
"license": "Unlicense",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { Bot } from 'lucide-react';
|
|
||||||
import { AgentMark } from '../lib/types';
|
|
||||||
|
|
||||||
interface AgentBadgeProps {
|
|
||||||
mark: AgentMark | undefined;
|
|
||||||
// sm: task rows and the task detail; xs: Kanban cards and list items
|
|
||||||
size?: 'sm' | 'xs';
|
|
||||||
}
|
|
||||||
|
|
||||||
const TITLES: Record<AgentMark, string> = {
|
|
||||||
created: 'Von einem KI-Agenten per MCP angelegt. Das Zeichen verschwindet, sobald du den Eintrag selbst änderst.',
|
|
||||||
edited: 'Zuletzt von einem KI-Agenten per MCP geändert. Das Zeichen verschwindet, sobald du den Eintrag selbst änderst.',
|
|
||||||
};
|
|
||||||
|
|
||||||
// Marks entries an AI agent created or changed via MCP that the user has not touched since
|
|
||||||
// (Doku/ui-patterns.md)
|
|
||||||
export const AgentBadge: React.FC<AgentBadgeProps> = ({ mark, size = 'sm' }) => {
|
|
||||||
if (mark !== 'created' && mark !== 'edited') return null;
|
|
||||||
const label = mark === 'created' ? 'KI' : 'KI geändert';
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
className={`px-1.5 border border-accent/40 bg-accent/10 text-accent rounded-btn font-medium inline-flex items-center gap-1 flex-shrink-0 ${
|
|
||||||
size === 'xs' ? 'py-0.2 text-[9px]' : 'py-0.5 text-[10px]'
|
|
||||||
}`}
|
|
||||||
title={TITLES[mark]}
|
|
||||||
aria-label={TITLES[mark]}
|
|
||||||
>
|
|
||||||
<Bot className="w-2.5 h-2.5" />
|
|
||||||
<span>{label}</span>
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
|
import { Bot, Webhook } from 'lucide-react';
|
||||||
|
import { Origin } from '../lib/types';
|
||||||
|
|
||||||
|
interface OriginBadgeProps {
|
||||||
|
origin: Origin | undefined;
|
||||||
|
// When the entry was created, for the details
|
||||||
|
createdAt?: string;
|
||||||
|
// xs: Kanban cards and list items; sm: task rows, inbox, detail view; md: triage cards
|
||||||
|
size?: 'xs' | 'sm' | 'md';
|
||||||
|
}
|
||||||
|
|
||||||
|
const VIA_LABEL = { api: 'API', mcp: 'MCP' } as const;
|
||||||
|
|
||||||
|
const formatWhen = (iso?: string) => {
|
||||||
|
if (!iso) return '';
|
||||||
|
const d = new Date(iso);
|
||||||
|
if (Number.isNaN(d.getTime())) return '';
|
||||||
|
return d.toLocaleString('de-DE', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' });
|
||||||
|
};
|
||||||
|
|
||||||
|
// originDetails: who created and who last changed the entry, as one line for tooltip and detail view
|
||||||
|
export const originDetails = (origin: Origin | undefined, createdAt?: string): string => {
|
||||||
|
if (!origin || (!origin.createdVia && !origin.changedVia)) return '';
|
||||||
|
const parts: string[] = [];
|
||||||
|
const created = formatWhen(createdAt);
|
||||||
|
if (origin.createdVia) {
|
||||||
|
const key = origin.createdKey ? ` (${origin.createdKey})` : '';
|
||||||
|
parts.push(`Angelegt per ${VIA_LABEL[origin.createdVia]}${key}${created ? ` am ${created}` : ''}`);
|
||||||
|
} else {
|
||||||
|
parts.push(`In der App angelegt${created ? ` am ${created}` : ''}`);
|
||||||
|
}
|
||||||
|
if (origin.changedVia) {
|
||||||
|
const key = origin.changedKey ? ` (${origin.changedKey})` : '';
|
||||||
|
const when = formatWhen(origin.changedAt);
|
||||||
|
parts.push(`zuletzt geändert per ${VIA_LABEL[origin.changedVia]}${key}${when ? ` am ${when}` : ''}`);
|
||||||
|
}
|
||||||
|
return parts.join(' · ');
|
||||||
|
};
|
||||||
|
|
||||||
|
const SIZE_CLASS = {
|
||||||
|
xs: 'px-1.5 py-0.2 text-[9px]',
|
||||||
|
sm: 'px-1.5 py-0.5 text-[10px]',
|
||||||
|
md: 'px-2 py-0.5 text-[11px]',
|
||||||
|
};
|
||||||
|
|
||||||
|
// One badge for entries an API key created or changed (Doku/ui-patterns.md): icon for the access
|
||||||
|
// (webhook = REST, robot = MCP) and the key's name. Hover shows the details; a tap opens them, so it
|
||||||
|
// works on phones too. It stays for good.
|
||||||
|
export const OriginBadge: React.FC<OriginBadgeProps> = ({ origin, createdAt, size = 'sm' }) => {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const ref = useRef<HTMLSpanElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const close = (e: Event) => {
|
||||||
|
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||||
|
};
|
||||||
|
document.addEventListener('pointerdown', close);
|
||||||
|
return () => document.removeEventListener('pointerdown', close);
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const via = origin?.createdVia || origin?.changedVia;
|
||||||
|
if (!origin || !via) return null;
|
||||||
|
const key = origin.createdVia ? origin.createdKey : origin.changedKey;
|
||||||
|
const label = key || VIA_LABEL[via];
|
||||||
|
const details = originDetails(origin, createdAt);
|
||||||
|
const Icon = via === 'mcp' ? Bot : Webhook;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span ref={ref} className="relative inline-flex flex-shrink-0 max-w-full">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
draggable={false}
|
||||||
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setOpen((o) => !o);
|
||||||
|
}}
|
||||||
|
title={details}
|
||||||
|
aria-label={details}
|
||||||
|
aria-expanded={open}
|
||||||
|
className={`relative border border-border bg-canvas text-muted rounded-btn font-medium inline-flex items-center gap-1 max-w-[12rem] cursor-pointer hover:text-main before:absolute before:content-[''] before:inset-x-0 before:-inset-y-3 sm:before:hidden ${SIZE_CLASS[size]}`}
|
||||||
|
>
|
||||||
|
<Icon className="w-2.5 h-2.5 text-accent flex-shrink-0" />
|
||||||
|
<span className="truncate">{label}</span>
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<span
|
||||||
|
role="tooltip"
|
||||||
|
className="absolute left-0 top-full mt-1 z-40 w-64 max-w-[80vw] p-2 bg-panel border border-border rounded-card shadow-panel text-[11px] leading-snug text-main whitespace-normal"
|
||||||
|
>
|
||||||
|
{details}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
|
||||||
import { render } from '@testing-library/react';
|
|
||||||
import { AgentBadge } from '../AgentBadge';
|
|
||||||
|
|
||||||
describe('AgentBadge', () => {
|
|
||||||
it('shows nothing for entries of the user', () => {
|
|
||||||
expect(render(<AgentBadge mark={undefined} />).container.firstChild).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('tells created and edited entries apart', () => {
|
|
||||||
const created = render(<AgentBadge mark="created" />).container.firstChild as HTMLElement;
|
|
||||||
expect(created.textContent).toBe('KI');
|
|
||||||
expect(created.getAttribute('title')).toContain('angelegt');
|
|
||||||
const edited = render(<AgentBadge mark="edited" />).container.firstChild as HTMLElement;
|
|
||||||
expect(edited.textContent).toBe('KI geändert');
|
|
||||||
expect(edited.getAttribute('title')).toContain('geändert');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { render, fireEvent } from '@testing-library/react';
|
||||||
|
import { OriginBadge, originDetails } from '../OriginBadge';
|
||||||
|
|
||||||
|
describe('OriginBadge', () => {
|
||||||
|
it('shows nothing for entries no key touched', () => {
|
||||||
|
expect(render(<OriginBadge origin={undefined} />).container.firstChild).toBeNull();
|
||||||
|
expect(render(<OriginBadge origin={{}} />).container.firstChild).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('names the key that created the entry, else the one that changed it', () => {
|
||||||
|
const created = render(<OriginBadge origin={{ createdVia: 'api', createdKey: 'Home Assistant', changedVia: 'mcp', changedKey: 'Claude Code' }} />);
|
||||||
|
expect(created.container.textContent).toBe('Home Assistant');
|
||||||
|
const changed = render(<OriginBadge origin={{ changedVia: 'mcp', changedKey: 'Claude Code' }} />);
|
||||||
|
expect(changed.container.textContent).toBe('Claude Code');
|
||||||
|
const old = render(<OriginBadge origin={{ createdVia: 'mcp' }} />);
|
||||||
|
expect(old.container.textContent).toBe('MCP');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens the details on tap', () => {
|
||||||
|
const { getByRole, queryByRole } = render(
|
||||||
|
<OriginBadge origin={{ changedVia: 'mcp', changedKey: 'Claude Code', changedAt: '2026-11-01T09:40:00Z' }} createdAt="2026-10-31T08:00:00Z" />
|
||||||
|
);
|
||||||
|
expect(queryByRole('tooltip')).toBeNull();
|
||||||
|
fireEvent.click(getByRole('button'));
|
||||||
|
const tip = getByRole('tooltip').textContent || '';
|
||||||
|
expect(tip).toContain('In der App angelegt');
|
||||||
|
expect(tip).toContain('zuletzt geändert per MCP (Claude Code)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('describes both created and changed', () => {
|
||||||
|
const text = originDetails({ createdVia: 'api', createdKey: 'HA', changedVia: 'mcp', changedKey: 'Claude Code' }, '2026-11-01T08:12:00Z');
|
||||||
|
expect(text).toMatch(/^Angelegt per API \(HA\) am .* · zuletzt geändert per MCP \(Claude Code\)$/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -492,8 +492,8 @@ export const HelpView: React.FC<HelpViewProps> = ({ onBack }) => {
|
|||||||
{
|
{
|
||||||
id: 'triage-webhooks',
|
id: 'triage-webhooks',
|
||||||
category: 'triage',
|
category: 'triage',
|
||||||
title: 'Webhooks, API & KI-Agenten (MCP)',
|
title: 'API: curl, Smart Home & KI-Agenten (MCP)',
|
||||||
subtitle: 'Gedanken von Siri, Home Assistant, n8n oder curl einspeisen; Aufgaben und Listen für Claude Code, Codex & Co.',
|
subtitle: 'Aufgaben und Listen per curl oder Home Assistant lesen und ändern, Gedanken einliefern, KI-Agenten wie Claude Code anbinden.',
|
||||||
icon: Bell,
|
icon: Bell,
|
||||||
keywords: [
|
keywords: [
|
||||||
'webhooks',
|
'webhooks',
|
||||||
@@ -513,16 +513,19 @@ export const HelpView: React.FC<HelpViewProps> = ({ onBack }) => {
|
|||||||
content: (
|
content: (
|
||||||
<div className="space-y-2 text-muted leading-relaxed">
|
<div className="space-y-2 text-muted leading-relaxed">
|
||||||
<p>
|
<p>
|
||||||
Unter <em className="text-main">Kontoeinstellungen → Webhooks & KI-Agenten</em> kannst du persönliche API-Schlüssel erzeugen. Pro Schlüssel legst du mit Schaltern fest, was er darf: Gedanken in die Inbox, Aufgaben oder Listeneinträge einliefern (Webhooks) oder für KI-Agenten Aufgaben und Listen lesen und ändern. Notizen erreicht ein Schlüssel nie.
|
MindSift hat eine API. Unter <em className="text-main">Kontoeinstellungen → API</em> erzeugst du persönliche Schlüssel und legst mit Schaltern fest, was jeder darf: Aufgaben lesen, anlegen oder ändern, Listen lesen, ergänzen oder ändern und Gedanken in die Inbox liefern. Diese Rechte gelten für beide Zugänge gleich. Notizen und Einstellungen erreicht ein Schlüssel nie.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<strong className="text-main">KI-Agenten:</strong> MindSift ist ein MCP-Server (<code className="text-accent bg-panel px-1 py-0.5 rounded font-mono">POST /api/v1/mcp</code>). Claude Code, OpenAI Codex, Claude Desktop, Cursor und eigene Agenten können damit offene Aufgaben auslesen, anlegen, abhaken und löschen, neue Aufgabenlisten und Listen anlegen sowie Einkaufs- und Checklisten führen. Die Einstellungen zeigen den fertigen Befehl bzw. die Konfiguration für den jeweiligen Agenten; der Agent sieht nur die Werkzeuge, für die der Schlüssel freigegeben ist. Was ein Agent angelegt oder geändert hat, trägt das Zeichen <strong className="text-main">KI</strong> bzw. <strong className="text-main">KI geändert</strong>, bis du den Eintrag selbst änderst.
|
<strong className="text-main">curl und Smart Home:</strong> Mit Leserecht liefert z. B. <code className="text-accent bg-panel px-1 py-0.5 rounded font-mono">GET /api/v1/tasks?due=today&status=open</code> alles, was heute ansteht, etwa für einen Home-Assistant-Sensor. Weitere Filter: <code className="font-mono">due=overdue</code>, ein Datum, <code className="font-mono">taskListId</code>. Listen kommen über <code className="font-mono">GET /api/v1/lists</code>. Mit Änderungsrecht hakst du per <code className="font-mono">PATCH /api/v1/thoughts/ID</code> ab. Die Einstellungen zeigen fertige Beispiele.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
Mit dem Feld <code className="text-accent bg-panel px-1 py-0.5 rounded font-mono">"target"</code> wählst du das Ziel: <code className="font-mono">triage</code> (Standard), <code className="font-mono">task</code> (optional mit <code className="font-mono">taskListId</code>) oder <code className="font-mono">list_item</code> (mit <code className="font-mono">listId</code>). Der Simulator in den Einstellungen zeigt passende Beispiele.
|
<strong className="text-main">Einliefern:</strong> <code className="text-accent bg-panel px-1 py-0.5 rounded font-mono">POST /api/v1/webhooks/ingest</code> nimmt Gedanken aus iOS-Kurzbefehlen, Home Assistant oder n8n an. Mit dem Feld <code className="font-mono">"target"</code> wählst du das Ziel: <code className="font-mono">triage</code> (Standard), <code className="font-mono">task</code> (optional mit <code className="font-mono">taskListId</code>) oder <code className="font-mono">list_item</code> (mit <code className="font-mono">listId</code>).
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
Über den Endpunkt <code className="text-accent bg-panel px-1 py-0.5 rounded font-mono">POST /api/v1/webhooks/ingest</code> kannst du Gedanken aus iOS-Kurzbefehlen, Smart-Home-Systemen oder Drittanwendungen einspeisen. Sie werden automatisch mit dem Tag <code className="text-accent font-mono">#webhook</code> markiert.
|
<strong className="text-main">KI-Agenten:</strong> Über MCP (<code className="text-accent bg-panel px-1 py-0.5 rounded font-mono">POST /api/v1/mcp</code>) lesen, anlegen, ändern, abhaken und löschen Claude Code, OpenAI Codex, Claude Desktop, Cursor und eigene Agenten deine Aufgaben und Listen. Die Einstellungen zeigen den fertigen Befehl für jeden Agenten; ein Agent sieht nur die Werkzeuge, die der Schlüssel erlaubt.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong className="text-main">Herkunft:</strong> Was ein Schlüssel anlegt oder ändert, trägt dauerhaft eine Badge mit seinem Namen, vorne ein Webhook-Symbol (API) oder ein Roboter (MCP). Fährst du darüber oder tippst darauf, siehst du, wer wann angelegt und zuletzt geändert hat.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import { OFFLINE_ITEM_PREFIX, pendingListItems } from '../../lib/offline';
|
|||||||
import { UnifiedShareModal } from '../../components/UnifiedShareModal';
|
import { UnifiedShareModal } from '../../components/UnifiedShareModal';
|
||||||
import { AvatarStack, AvatarPerson } from '../../components/AvatarStack';
|
import { AvatarStack, AvatarPerson } from '../../components/AvatarStack';
|
||||||
import { CountBadge } from '../../components/CountBadge';
|
import { CountBadge } from '../../components/CountBadge';
|
||||||
import { AgentBadge } from '../../components/AgentBadge';
|
import { OriginBadge } from '../../components/OriginBadge';
|
||||||
import { FormSheet } from '../../components/FormSheet';
|
import { FormSheet } from '../../components/FormSheet';
|
||||||
import { useMobileActions } from '../../context/MobileActionContext';
|
import { useMobileActions } from '../../context/MobileActionContext';
|
||||||
|
|
||||||
@@ -1391,7 +1391,7 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AgentBadge mark={item.agentMark} size="xs" />
|
<OriginBadge origin={item.origin} createdAt={item.createdAt} size="xs" />
|
||||||
{renderItemAuthor(item)}
|
{renderItemAuthor(item)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -1501,7 +1501,7 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<AgentBadge mark={item.agentMark} size="xs" />
|
<OriginBadge origin={item.origin} createdAt={item.createdAt} size="xs" />
|
||||||
{renderItemAuthor(item)}
|
{renderItemAuthor(item)}
|
||||||
{item.completedByName && (
|
{item.completedByName && (
|
||||||
<span className="text-xs text-faint">✓ {item.completedByName}</span>
|
<span className="text-xs text-faint">✓ {item.completedByName}</span>
|
||||||
|
|||||||
@@ -18,24 +18,31 @@ interface ApiKeysPanelProps {
|
|||||||
onIngested?: () => void;
|
onIngested?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// What a key may do: deliver data (webhooks) or read and change tasks and lists for AI agents (MCP)
|
// What a key may do. The rights hold for every access alike: curl/REST and MCP for AI agents. The ids of
|
||||||
const SCOPES: { id: ApiKeyScope; label: string; hint: string; group: 'webhook' | 'agent' }[] = [
|
// the delivering rights (webhook:…) stay from the time keys could only deliver data.
|
||||||
{ id: 'webhook:triage', label: '📥 Triage / Inbox', hint: 'Gedanken in die Inbox einliefern', group: 'webhook' },
|
type ScopeGroup = 'tasks' | 'lists' | 'inbox';
|
||||||
{ id: 'webhook:task', label: '📋 Aufgaben anlegen', hint: 'Aufgaben direkt anlegen', group: 'webhook' },
|
const SCOPES: { id: ApiKeyScope; label: string; chip: string; hint: string; group: ScopeGroup }[] = [
|
||||||
{ id: 'webhook:list', label: '📝 Listeneinträge anlegen', hint: 'Einträge zu Listen hinzufügen', group: 'webhook' },
|
{ id: 'tasks:read', label: 'Lesen', chip: 'Aufgaben lesen', hint: 'Aufgabenlisten und Aufgaben lesen, suchen', group: 'tasks' },
|
||||||
{ id: 'tasks:read', label: '📋 Aufgaben lesen', hint: 'Aufgabenlisten und Aufgaben lesen, suchen', group: 'agent' },
|
{ id: 'webhook:task', label: 'Anlegen', chip: 'Aufgaben anlegen', hint: 'Nur neue Aufgaben anlegen', group: 'tasks' },
|
||||||
{ id: 'tasks:write', label: '✅ Aufgaben ändern', hint: 'Aufgaben anlegen, ändern und abhaken', group: 'agent' },
|
{ id: 'tasks:write', label: 'Ändern', chip: 'Aufgaben ändern', hint: 'Anlegen, ändern, abhaken und löschen', group: 'tasks' },
|
||||||
{ id: 'lists:read', label: '📝 Listen lesen', hint: 'Listen und ihre Einträge lesen', group: 'agent' },
|
{ id: 'lists:read', label: 'Lesen', chip: 'Listen lesen', hint: 'Listen und ihre Einträge lesen', group: 'lists' },
|
||||||
{ id: 'lists:write', label: '🛒 Listen ändern', hint: 'Einträge hinzufügen, ändern und abhaken', group: 'agent' },
|
{ id: 'webhook:list', label: 'Einträge hinzufügen', chip: 'Listeneinträge hinzufügen', hint: 'Nur neue Einträge hinzufügen', group: 'lists' },
|
||||||
|
{ id: 'lists:write', label: 'Ändern', chip: 'Listen ändern', hint: 'Einträge hinzufügen, ändern und abhaken', group: 'lists' },
|
||||||
|
{ id: 'webhook:triage', label: 'Inbox füllen', chip: 'Inbox', hint: 'Gedanken in die Inbox einliefern', group: 'inbox' },
|
||||||
];
|
];
|
||||||
const scopeLabel = (id: string) => SCOPES.find((s) => s.id === id)?.label || id;
|
const SCOPE_GROUPS: [ScopeGroup, string][] = [
|
||||||
const isAgentScope = (sc: string) => SCOPES.find((s) => s.id === sc)?.group === 'agent';
|
['tasks', '📋 Aufgaben'],
|
||||||
|
['lists', '🛒 Listen'],
|
||||||
|
['inbox', '📥 Inbox'],
|
||||||
|
];
|
||||||
|
const scopeChip = (id: string) => SCOPES.find((s) => s.id === id)?.chip || id;
|
||||||
|
|
||||||
type SimTarget = 'triage' | 'task' | 'list_item';
|
type SimTarget = 'triage' | 'task' | 'list_item';
|
||||||
type McpClient = 'claude' | 'codex' | 'json';
|
type McpClient = 'claude' | 'codex' | 'json';
|
||||||
|
type Access = 'rest' | 'mcp';
|
||||||
|
|
||||||
// Personal API keys with scopes (switches), MCP connection snippets, a webhook simulator and copyable
|
// Personal API keys with rights (switches) and how to connect: curl / smart home (read examples,
|
||||||
// examples (settings → Webhooks & KI-Agenten)
|
// delivery simulator) or AI agents over MCP (settings → API)
|
||||||
export const ApiKeysPanel: React.FC<ApiKeysPanelProps> = ({ onIngested }) => {
|
export const ApiKeysPanel: React.FC<ApiKeysPanelProps> = ({ onIngested }) => {
|
||||||
const [keys, setKeys] = useState<APIKey[]>([]);
|
const [keys, setKeys] = useState<APIKey[]>([]);
|
||||||
const [newKeyName, setNewKeyName] = useState('');
|
const [newKeyName, setNewKeyName] = useState('');
|
||||||
@@ -43,6 +50,7 @@ export const ApiKeysPanel: React.FC<ApiKeysPanelProps> = ({ onIngested }) => {
|
|||||||
const [newlyCreatedKey, setNewlyCreatedKey] = useState<string | null>(null);
|
const [newlyCreatedKey, setNewlyCreatedKey] = useState<string | null>(null);
|
||||||
const [copiedKey, setCopiedKey] = useState(false);
|
const [copiedKey, setCopiedKey] = useState(false);
|
||||||
const [mcpClient, setMcpClient] = useState<McpClient>('claude');
|
const [mcpClient, setMcpClient] = useState<McpClient>('claude');
|
||||||
|
const [access, setAccess] = useState<Access>('rest');
|
||||||
|
|
||||||
// Live Webhook Simulator State
|
// Live Webhook Simulator State
|
||||||
const [simTitle, setSimTitle] = useState('Waschmaschine im Keller ist fertig!');
|
const [simTitle, setSimTitle] = useState('Waschmaschine im Keller ist fertig!');
|
||||||
@@ -179,10 +187,12 @@ http_headers = { "X-API-Key" = "${exampleToken}" }`
|
|||||||
<Key className="w-4 h-4" />
|
<Key className="w-4 h-4" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-bold text-main">API-Schlüssel für Webhooks und KI-Agenten</h3>
|
<h3 className="text-sm font-bold text-main">API-Schlüssel</h3>
|
||||||
<p className="text-xs text-muted">
|
<p className="text-xs text-muted">
|
||||||
Verbinde Home Assistant, n8n, eigene Skripte oder KI-Agenten (Claude Code, Codex …) mit MindSift. Was ein
|
Ein Schlüssel verbindet Home Assistant, n8n, eigene Skripte oder KI-Agenten (Claude Code, Codex …) mit
|
||||||
Key darf, legst du pro Key fest; Notizen bleiben für Keys immer unerreichbar.
|
MindSift. Was er darf, legst du mit den Schaltern fest; das gilt per curl und per MCP gleich. Notizen und
|
||||||
|
Einstellungen bleiben für Schlüssel gesperrt. Was ein Schlüssel anlegt oder ändert, trägt seinen Namen als
|
||||||
|
Badge.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -201,18 +211,10 @@ http_headers = { "X-API-Key" = "${exampleToken}" }`
|
|||||||
<span>API-Key erzeugen</span>
|
<span>API-Key erzeugen</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3" role="group" aria-label="Berechtigungen des Keys">
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-3" role="group" aria-label="Berechtigungen des Schlüssels">
|
||||||
{(
|
{SCOPE_GROUPS.map(([group, heading]) => (
|
||||||
[
|
|
||||||
['webhook', 'Einliefern (Webhooks)', 'POST /api/v1/webhooks/ingest'],
|
|
||||||
['agent', 'KI-Agenten (MCP)', 'POST /api/v1/mcp'],
|
|
||||||
] as const
|
|
||||||
).map(([group, heading, path]) => (
|
|
||||||
<div key={group} className="border border-border rounded-card bg-panel px-3 py-1.5">
|
<div key={group} className="border border-border rounded-card bg-panel px-3 py-1.5">
|
||||||
<p className="text-[11px] text-muted pt-1 pb-0.5 flex flex-wrap justify-between gap-x-2">
|
<p className="text-[11px] text-muted pt-1 pb-0.5 font-semibold">{heading}</p>
|
||||||
<span className="font-semibold">{heading}</span>
|
|
||||||
<code className="font-mono">{path}</code>
|
|
||||||
</p>
|
|
||||||
{SCOPES.filter((sc) => sc.group === group).map((sc) => {
|
{SCOPES.filter((sc) => sc.group === group).map((sc) => {
|
||||||
const on = newKeyScopes.includes(sc.id);
|
const on = newKeyScopes.includes(sc.id);
|
||||||
return (
|
return (
|
||||||
@@ -221,7 +223,7 @@ http_headers = { "X-API-Key" = "${exampleToken}" }`
|
|||||||
<span className="block text-xs text-main">{sc.label}</span>
|
<span className="block text-xs text-main">{sc.label}</span>
|
||||||
<span className="block text-[11px] text-muted">{sc.hint}</span>
|
<span className="block text-[11px] text-muted">{sc.hint}</span>
|
||||||
</div>
|
</div>
|
||||||
<ToggleSwitch checked={on} onChange={() => toggleScope(sc.id)} title={sc.label} />
|
<ToggleSwitch checked={on} onChange={() => toggleScope(sc.id)} title={sc.chip} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -257,13 +259,8 @@ http_headers = { "X-API-Key" = "${exampleToken}" }`
|
|||||||
.split(',')
|
.split(',')
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.map((sc) => (
|
.map((sc) => (
|
||||||
<span
|
<span key={sc} className="px-1.5 py-0.5 rounded-btn border border-border text-muted text-[10px]">
|
||||||
key={sc}
|
{scopeChip(sc)}
|
||||||
className={`px-1.5 py-0.5 rounded-btn border text-[10px] ${
|
|
||||||
isAgentScope(sc) ? 'border-accent/40 text-accent' : 'border-border text-muted'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{scopeLabel(sc)}
|
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
{k.lastUsedAt && (
|
{k.lastUsedAt && (
|
||||||
@@ -285,66 +282,75 @@ http_headers = { "X-API-Key" = "${exampleToken}" }`
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* AI agents (MCP) */}
|
{/* How to connect: one API, two accesses */}
|
||||||
<div className="pt-4 border-t border-border">
|
<div className="pt-4 border-t border-border space-y-4">
|
||||||
<div className="flex items-center gap-3 mb-3">
|
<div className="flex flex-wrap gap-1.5" role="tablist" aria-label="Zugang">
|
||||||
<div className="w-7 h-7 bg-accent text-on-accent flex items-center justify-center flex-shrink-0">
|
|
||||||
<Bot className="w-4 h-4" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 className="text-sm font-bold text-main">KI-Agenten anbinden (MCP)</h3>
|
|
||||||
<p className="text-xs text-muted">
|
|
||||||
MindSift ist ein MCP-Server: Claude Code, Codex, Claude Desktop, Cursor und eigene Agenten können offene
|
|
||||||
Aufgaben lesen, abhaken und anlegen sowie Listen führen. Dafür braucht der Key die Agenten-Berechtigungen.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-1.5 mb-2" role="radiogroup" aria-label="Agent">
|
|
||||||
{(
|
{(
|
||||||
[
|
[
|
||||||
['claude', 'Claude Code'],
|
['rest', 'curl / Smart Home', Terminal],
|
||||||
['codex', 'OpenAI Codex'],
|
['mcp', 'KI-Agenten (MCP)', Bot],
|
||||||
['json', 'Claude Desktop / Cursor'],
|
|
||||||
] as const
|
] as const
|
||||||
).map(([value, label]) => (
|
).map(([value, label, Icon]) => (
|
||||||
<button
|
<button
|
||||||
key={value}
|
key={value}
|
||||||
type="button"
|
type="button"
|
||||||
role="radio"
|
role="tab"
|
||||||
aria-checked={mcpClient === value}
|
aria-selected={access === value}
|
||||||
onClick={() => setMcpClient(value)}
|
onClick={() => setAccess(value)}
|
||||||
className={`h-11 sm:h-8 px-3 rounded-btn border text-xs transition-colors ${
|
className={`h-11 sm:h-8 px-3 rounded-btn border text-xs flex items-center gap-1.5 transition-colors ${
|
||||||
mcpClient === value ? 'border-accent bg-accent/10 text-main' : 'border-border text-muted hover:text-main'
|
access === value ? 'border-accent bg-accent/10 text-main' : 'border-border text-muted hover:text-main'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
<Icon className="w-3.5 h-3.5 text-accent" />
|
||||||
{label}
|
{label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<pre className="p-3 bg-canvas border border-border font-mono text-xs text-main overflow-x-auto">{mcpSnippet}</pre>
|
|
||||||
|
{access === 'rest' && (
|
||||||
|
<div className="space-y-5">
|
||||||
|
{/* Reading: e.g. a smart home sensor for what is due today */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 text-xs font-medium text-muted mb-2">
|
||||||
|
<Terminal className="w-3.5 h-3.5 text-accent" />
|
||||||
|
<span>Lesen, z. B. was heute fällig ist (Recht „Aufgaben lesen“):</span>
|
||||||
|
</div>
|
||||||
|
<pre className="p-3 bg-canvas border border-border font-mono text-xs text-main overflow-x-auto">
|
||||||
|
{`curl "${baseUrl}/api/v1/tasks?due=today&status=open" \\
|
||||||
|
-H "X-API-Key: ${exampleToken}"`}
|
||||||
|
</pre>
|
||||||
<p className="mt-2 text-[11px] text-faint">
|
<p className="mt-2 text-[11px] text-faint">
|
||||||
Werkzeuge: <code className="font-mono">list_task_lists</code>, <code className="font-mono">list_tasks</code>,{' '}
|
Filter: <code className="font-mono">due=today</code>, <code className="font-mono">due=overdue</code> oder ein
|
||||||
<code className="font-mono">complete_task</code>, <code className="font-mono">create_task</code>,{' '}
|
Datum (<code className="font-mono">JJJJ-MM-TT</code>, fällig bis einschließlich), <code className="font-mono">status=open|done</code>,{' '}
|
||||||
<code className="font-mono">update_task</code>, <code className="font-mono">list_lists</code>,{' '}
|
<code className="font-mono">taskListId</code>. Listen: <code className="font-mono">GET /api/v1/lists</code> und{' '}
|
||||||
<code className="font-mono">get_list</code>, <code className="font-mono">add_list_item</code>,{' '}
|
<code className="font-mono">/lists/ID</code>. Mit „Ändern“ auch <code className="font-mono">PATCH /api/v1/thoughts/ID</code>{' '}
|
||||||
<code className="font-mono">check_list_item</code>, <code className="font-mono">search</code>,{' '}
|
(z. B. <code className="font-mono">{'{"isCompleted": true}'}</code>).
|
||||||
<code className="font-mono">capture_to_inbox</code>. Der Agent sieht nur die Werkzeuge, für die der Key
|
|
||||||
berechtigt ist; Freigaben gelten wie für dich selbst. ChatGPT im Browser und claude.ai brauchen OAuth und
|
|
||||||
werden noch nicht unterstützt.
|
|
||||||
</p>
|
</p>
|
||||||
|
<div className="flex items-center gap-2 text-xs font-medium text-muted mt-3 mb-2">
|
||||||
|
<Terminal className="w-3.5 h-3.5 text-accent" />
|
||||||
|
<span>Home Assistant: Sensor „Heute fällig“ (configuration.yaml)</span>
|
||||||
|
</div>
|
||||||
|
<pre className="p-3 bg-canvas border border-border font-mono text-xs text-main overflow-x-auto">
|
||||||
|
{`rest:
|
||||||
|
- resource: "${baseUrl}/api/v1/tasks?due=today&status=open"
|
||||||
|
headers:
|
||||||
|
X-API-Key: "${exampleToken}"
|
||||||
|
scan_interval: 900
|
||||||
|
sensor:
|
||||||
|
- name: "MindSift heute fällig"
|
||||||
|
value_template: "{{ value_json | length }}"`}
|
||||||
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Simulator */}
|
|
||||||
<div className="pt-4 border-t border-border">
|
<div className="pt-4 border-t border-border">
|
||||||
<div className="flex items-center gap-3 mb-3">
|
<div className="flex items-center gap-3 mb-3">
|
||||||
<div className="w-7 h-7 bg-accent text-on-accent flex items-center justify-center flex-shrink-0">
|
<div className="w-7 h-7 bg-accent text-on-accent flex items-center justify-center flex-shrink-0">
|
||||||
<Webhook className="w-4 h-4" />
|
<Webhook className="w-4 h-4" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-bold text-main">Live Smarthome Webhook-Simulator</h3>
|
<h3 className="text-sm font-bold text-main">Einliefern testen (Webhook)</h3>
|
||||||
<p className="text-xs text-muted">
|
<p className="text-xs text-muted">
|
||||||
Teste den Webhook-Endpunkt direkt hier
|
Teste das Einliefern per <code className="font-mono">POST /api/v1/webhooks/ingest</code> direkt hier
|
||||||
{newlyCreatedKey ? ' – mit dem eben erzeugten Key und seinen Berechtigungen.' : ' – mit deiner Anmeldung.'}
|
{newlyCreatedKey ? ' – mit dem eben erzeugten Key und seinen Berechtigungen.' : ' – mit deiner Anmeldung.'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -464,7 +470,62 @@ http_headers = { "X-API-Key" = "${exampleToken}" }`
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{access === 'mcp' && (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-3 mb-3">
|
||||||
|
<div className="w-7 h-7 bg-accent text-on-accent flex items-center justify-center flex-shrink-0">
|
||||||
|
<Bot className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-bold text-main">KI-Agenten anbinden (MCP)</h3>
|
||||||
|
<p className="text-xs text-muted">
|
||||||
|
MindSift ist ein MCP-Server: Claude Code, Codex, Claude Desktop, Cursor und eigene Agenten nutzen dieselben
|
||||||
|
Schlüssel und Rechte wie curl, nur in der Form, die Agenten erwarten.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-1.5 mb-2" role="radiogroup" aria-label="Agent">
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
['claude', 'Claude Code'],
|
||||||
|
['codex', 'OpenAI Codex'],
|
||||||
|
['json', 'Claude Desktop / Cursor'],
|
||||||
|
] as const
|
||||||
|
).map(([value, label]) => (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={mcpClient === value}
|
||||||
|
onClick={() => setMcpClient(value)}
|
||||||
|
className={`h-11 sm:h-8 px-3 rounded-btn border text-xs transition-colors ${
|
||||||
|
mcpClient === value ? 'border-accent bg-accent/10 text-main' : 'border-border text-muted hover:text-main'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<pre className="p-3 bg-canvas border border-border font-mono text-xs text-main overflow-x-auto">{mcpSnippet}</pre>
|
||||||
|
<p className="mt-2 text-[11px] text-faint">
|
||||||
|
Werkzeuge: <code className="font-mono">list_task_lists</code>, <code className="font-mono">create_task_list</code>,{' '}
|
||||||
|
<code className="font-mono">list_tasks</code>, <code className="font-mono">get_task</code>,{' '}
|
||||||
|
<code className="font-mono">create_task</code>, <code className="font-mono">update_task</code>,{' '}
|
||||||
|
<code className="font-mono">complete_task</code>, <code className="font-mono">delete_task</code>,{' '}
|
||||||
|
<code className="font-mono">list_lists</code>, <code className="font-mono">create_list</code>,{' '}
|
||||||
|
<code className="font-mono">get_list</code>, <code className="font-mono">add_list_item</code>,{' '}
|
||||||
|
<code className="font-mono">check_list_item</code>, <code className="font-mono">update_list_item</code>,{' '}
|
||||||
|
<code className="font-mono">search</code>, <code className="font-mono">capture_to_inbox</code>. Der Agent sieht
|
||||||
|
nur die Werkzeuge, für die der Schlüssel berechtigt ist; Freigaben gelten wie für dich selbst. ChatGPT im Browser und claude.ai brauchen OAuth und
|
||||||
|
werden noch nicht unterstützt.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -583,7 +583,7 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
|||||||
<div>
|
<div>
|
||||||
<h1 className="text-base font-bold text-main tracking-tight">Kontoeinstellungen</h1>
|
<h1 className="text-base font-bold text-main tracking-tight">Kontoeinstellungen</h1>
|
||||||
<p className="text-xs text-muted">
|
<p className="text-xs text-muted">
|
||||||
Persönliches Profil, Design-Themes, Aufgaben-Alarme, Webhooks, KI-Agenten, Export & Import
|
Persönliches Profil, Design-Themes, Aufgaben-Alarme, API, Export & Import
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -652,8 +652,8 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
|||||||
>
|
>
|
||||||
<Key className="w-4 h-4 text-accent flex-shrink-0" />
|
<Key className="w-4 h-4 text-accent flex-shrink-0" />
|
||||||
<div>
|
<div>
|
||||||
<p className="font-semibold">Webhooks & KI-Agenten</p>
|
<p className="font-semibold">API</p>
|
||||||
<p className="text-[10px] text-faint">API-Keys, MCP & Curl</p>
|
<p className="text-[10px] text-faint">Schlüssel, curl, Smart Home & KI-Agenten</p>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -1865,15 +1865,15 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ================================================================= */}
|
{/* ================================================================= */}
|
||||||
{/* SECTION 4: PRIVATE WEBHOOKS & AI AGENTS */}
|
{/* SECTION 4: API (KEYS, CURL / SMART HOME, AI AGENTS) */}
|
||||||
{/* ================================================================= */}
|
{/* ================================================================= */}
|
||||||
{section === 'webhooks' && (
|
{section === 'webhooks' && (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<div className="border-b border-border pb-3">
|
<div className="border-b border-border pb-3">
|
||||||
<h2 className="text-sm font-bold text-main">Webhooks & KI-Agenten</h2>
|
<h2 className="text-sm font-bold text-main">API</h2>
|
||||||
<p className="text-muted mt-0.5">
|
<p className="text-muted mt-0.5">
|
||||||
Erstelle persönliche API-Schlüssel, um aus Skripten, Smarthome oder Bots Aufgaben und Gedanken
|
Mit persönlichen API-Schlüsseln lesen und ändern Skripte, dein Smart Home oder KI-Agenten wie Claude Code
|
||||||
einzuspeisen oder KI-Agenten wie Claude Code und Codex an deine Aufgaben und Listen zu lassen.
|
und Codex deine Aufgaben, Listen und die Inbox: per curl oder über MCP, mit denselben Rechten.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -21,28 +21,32 @@ import { ApiKeysPanel } from '../ApiKeysPanel';
|
|||||||
describe('ApiKeysPanel', () => {
|
describe('ApiKeysPanel', () => {
|
||||||
beforeEach(() => createAPIKey.mockClear());
|
beforeEach(() => createAPIKey.mockClear());
|
||||||
|
|
||||||
it('shows the scopes of existing keys as badges', async () => {
|
it('shows the rights of existing keys as badges', async () => {
|
||||||
render(<ApiKeysPanel />);
|
render(<ApiKeysPanel />);
|
||||||
expect(await screen.findByText('Rauchmelder')).toBeTruthy();
|
expect(await screen.findByText('Rauchmelder')).toBeTruthy();
|
||||||
expect(screen.getAllByText('📋 Aufgaben anlegen').length).toBeGreaterThan(1); // switch label + badge
|
expect(screen.getByText('Aufgaben anlegen')).toBeTruthy();
|
||||||
|
expect(screen.getByText('Inbox')).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('creates a key with the chosen scopes and never without one', async () => {
|
it('creates a key with the chosen scopes and never without one', async () => {
|
||||||
render(<ApiKeysPanel />);
|
render(<ApiKeysPanel />);
|
||||||
const triage = screen.getByRole('switch', { name: /Triage/ });
|
const triage = screen.getByRole('switch', { name: 'Inbox' });
|
||||||
fireEvent.click(triage); // last remaining scope cannot be switched off
|
fireEvent.click(triage); // last remaining scope cannot be switched off
|
||||||
expect(triage.getAttribute('aria-checked')).toBe('true');
|
expect(triage.getAttribute('aria-checked')).toBe('true');
|
||||||
fireEvent.click(screen.getByRole('switch', { name: /Listeneinträge anlegen/ }));
|
fireEvent.click(screen.getByRole('switch', { name: 'Listeneinträge hinzufügen' }));
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
fireEvent.click(screen.getByRole('button', { name: /API-Key erzeugen/ }));
|
fireEvent.click(screen.getByRole('button', { name: /API-Key erzeugen/ }));
|
||||||
});
|
});
|
||||||
expect(createAPIKey).toHaveBeenCalledWith('Home Assistant', ['webhook:triage', 'webhook:list']);
|
expect(createAPIKey).toHaveBeenCalledWith('Home Assistant', ['webhook:triage', 'webhook:list']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('offers agent scopes and shows how to connect an MCP client', async () => {
|
it('shows curl for scripts and, in the second tab, how to connect an MCP client', async () => {
|
||||||
render(<ApiKeysPanel />);
|
render(<ApiKeysPanel />);
|
||||||
fireEvent.click(screen.getByRole('switch', { name: /Aufgaben ändern/ }));
|
fireEvent.click(screen.getByRole('switch', { name: 'Aufgaben ändern' }));
|
||||||
expect(screen.getByRole('switch', { name: /Aufgaben ändern/ }).getAttribute('aria-checked')).toBe('true');
|
expect(screen.getByRole('switch', { name: 'Aufgaben ändern' }).getAttribute('aria-checked')).toBe('true');
|
||||||
|
expect(screen.getAllByText(/tasks\?due=today&status=open/).length).toBeGreaterThan(0);
|
||||||
|
expect(screen.queryByText(/claude mcp add/)).toBeNull();
|
||||||
|
fireEvent.click(screen.getByRole('tab', { name: /KI-Agenten/ }));
|
||||||
expect(screen.getByText(/claude mcp add --transport http mindsift/)).toBeTruthy();
|
expect(screen.getByText(/claude mcp add --transport http mindsift/)).toBeTruthy();
|
||||||
fireEvent.click(screen.getByRole('radio', { name: 'OpenAI Codex' }));
|
fireEvent.click(screen.getByRole('radio', { name: 'OpenAI Codex' }));
|
||||||
expect(screen.getByText(/\[mcp_servers\.mindsift\]/)).toBeTruthy();
|
expect(screen.getByText(/\[mcp_servers\.mindsift\]/)).toBeTruthy();
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import {
|
|||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
Flame,
|
Flame,
|
||||||
Folder,
|
Folder,
|
||||||
Webhook,
|
|
||||||
Lock,
|
Lock,
|
||||||
UserPlus,
|
UserPlus,
|
||||||
Repeat,
|
Repeat,
|
||||||
@@ -20,7 +19,7 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Thought, TaskList, TaskListMember, KanbanColumn, User, Recurrence } from '../../lib/types';
|
import { Thought, TaskList, TaskListMember, KanbanColumn, User, Recurrence } from '../../lib/types';
|
||||||
import { UserAvatar } from '../../components/UserAvatar';
|
import { UserAvatar } from '../../components/UserAvatar';
|
||||||
import { AgentBadge } from '../../components/AgentBadge';
|
import { OriginBadge, originDetails } from '../../components/OriginBadge';
|
||||||
import { UserAutocomplete } from '../UserAutocomplete';
|
import { UserAutocomplete } from '../UserAutocomplete';
|
||||||
import { api, triggerHaptic } from '../../lib/api';
|
import { api, triggerHaptic } from '../../lib/api';
|
||||||
import { MarkdownViewer } from '../../lib/markdown';
|
import { MarkdownViewer } from '../../lib/markdown';
|
||||||
@@ -239,13 +238,7 @@ const TaskDetailModalContent: React.FC<TaskDetailModalProps & { task: Thought }>
|
|||||||
<span>Nur Lesen</span>
|
<span>Nur Lesen</span>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{task.source?.toLowerCase() === 'webhook' && (
|
<OriginBadge origin={task.origin} createdAt={task.createdAt} />
|
||||||
<span className="px-1.5 py-0.5 border border-border bg-canvas text-muted rounded-btn text-[10px] font-medium flex items-center gap-1 flex-shrink-0">
|
|
||||||
<Webhook className="w-2.5 h-2.5 text-accent" />
|
|
||||||
<span>Webhook</span>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<AgentBadge mark={task.agentMark} />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
@@ -895,8 +888,8 @@ const TaskDetailModalContent: React.FC<TaskDetailModalProps & { task: Thought }>
|
|||||||
|
|
||||||
{/* Modal Footer */}
|
{/* Modal Footer */}
|
||||||
<div className="flex items-center justify-between px-4 py-2.5 border-t border-border bg-panel">
|
<div className="flex items-center justify-between px-4 py-2.5 border-t border-border bg-panel">
|
||||||
<div className="text-[11px] text-faint">
|
<div className="text-[11px] text-faint min-w-0 pr-3">
|
||||||
Erstellt: {new Date(task.createdAt).toLocaleDateString('de-DE')}
|
{originDetails(task.origin, task.createdAt) || `Erstellt: ${new Date(task.createdAt).toLocaleDateString('de-DE')}`}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ import {
|
|||||||
Settings,
|
Settings,
|
||||||
Folder,
|
Folder,
|
||||||
Filter,
|
Filter,
|
||||||
Webhook,
|
|
||||||
Repeat,
|
Repeat,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { TaskList, Thought, SubTask, Team, User, TaskListMember, KanbanColumn, PriorityLevel, Recurrence, RECURRENCE_LABELS } from '../../lib/types';
|
import { TaskList, Thought, SubTask, Team, User, TaskListMember, KanbanColumn, PriorityLevel, Recurrence, RECURRENCE_LABELS } from '../../lib/types';
|
||||||
@@ -35,7 +34,7 @@ import { useKanbanTouchDrag } from './useKanbanTouchDrag';
|
|||||||
import { api, triggerHaptic } from '../../lib/api';
|
import { api, triggerHaptic } from '../../lib/api';
|
||||||
import { UnifiedShareModal } from '../../components/UnifiedShareModal';
|
import { UnifiedShareModal } from '../../components/UnifiedShareModal';
|
||||||
import { TaskDetailModal } from './TaskDetailModal';
|
import { TaskDetailModal } from './TaskDetailModal';
|
||||||
import { AgentBadge } from '../../components/AgentBadge';
|
import { OriginBadge } from '../../components/OriginBadge';
|
||||||
import { ManageColumnsModal, DEFAULT_KANBAN_COLUMNS, columnDotClass } from './ManageColumnsModal';
|
import { ManageColumnsModal, DEFAULT_KANBAN_COLUMNS, columnDotClass } from './ManageColumnsModal';
|
||||||
import { AvatarStack, AvatarPerson } from '../../components/AvatarStack';
|
import { AvatarStack, AvatarPerson } from '../../components/AvatarStack';
|
||||||
import { CountBadge } from '../../components/CountBadge';
|
import { CountBadge } from '../../components/CountBadge';
|
||||||
@@ -1362,16 +1361,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{task.source?.toLowerCase() === 'webhook' && (
|
<OriginBadge origin={task.origin} createdAt={task.createdAt} />
|
||||||
<span
|
|
||||||
className="px-1.5 py-0.5 border border-border bg-canvas text-muted rounded-btn text-[10px] font-medium flex items-center gap-1 flex-shrink-0"
|
|
||||||
title="Erstellt über Webhook"
|
|
||||||
>
|
|
||||||
<Webhook className="w-2.5 h-2.5 text-accent" />
|
|
||||||
<span>Webhook</span>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<AgentBadge mark={task.agentMark} />
|
|
||||||
|
|
||||||
{/* Note indicator */}
|
{/* Note indicator */}
|
||||||
{task.notes && (
|
{task.notes && (
|
||||||
@@ -2436,16 +2426,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
⚠️ Wichtig
|
⚠️ Wichtig
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{task.source?.toLowerCase() === 'webhook' && (
|
<OriginBadge origin={task.origin} createdAt={task.createdAt} size="xs" />
|
||||||
<span
|
|
||||||
className="px-1.5 py-0.2 border border-border bg-canvas text-muted rounded-btn text-[9px] font-medium flex items-center gap-1"
|
|
||||||
title="Erstellt über Webhook"
|
|
||||||
>
|
|
||||||
<Webhook className="w-2.5 h-2.5 text-accent" />
|
|
||||||
<span>Webhook</span>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<AgentBadge mark={task.agentMark} size="xs" />
|
|
||||||
{task.dueDate && (
|
{task.dueDate && (
|
||||||
<span
|
<span
|
||||||
className={`inline-flex items-center gap-0.5 text-[9px] ${!task.isCompleted && isOverdue(task.dueDate) ? 'text-status-err font-semibold' : 'text-muted'}`}
|
className={`inline-flex items-center gap-0.5 text-[9px] ${!task.isCompleted && isOverdue(task.dueDate) ? 'text-status-err font-semibold' : 'text-muted'}`}
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
RotateCcw,
|
RotateCcw,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
Mic,
|
Mic,
|
||||||
Webhook,
|
|
||||||
Keyboard,
|
Keyboard,
|
||||||
Tag,
|
Tag,
|
||||||
Clock,
|
Clock,
|
||||||
@@ -25,7 +24,7 @@ import {
|
|||||||
import { SharedList, Thought } from '../../lib/types';
|
import { SharedList, Thought } from '../../lib/types';
|
||||||
import { addDays, formatDue } from '../../lib/due';
|
import { addDays, formatDue } from '../../lib/due';
|
||||||
import { api, triggerHaptic } from '../../lib/api';
|
import { api, triggerHaptic } from '../../lib/api';
|
||||||
import { AgentBadge } from '../../components/AgentBadge';
|
import { OriginBadge } from '../../components/OriginBadge';
|
||||||
|
|
||||||
interface TriageDeckProps {
|
interface TriageDeckProps {
|
||||||
deck: Thought[];
|
deck: Thought[];
|
||||||
@@ -149,17 +148,12 @@ const SwipeableQueueItem: React.FC<SwipeableQueueItemProps> = ({
|
|||||||
<span className="text-sm sm:text-xs font-medium text-main line-clamp-2 sm:truncate">
|
<span className="text-sm sm:text-xs font-medium text-main line-clamp-2 sm:truncate">
|
||||||
{item.content}
|
{item.content}
|
||||||
</span>
|
</span>
|
||||||
{item.source?.toLowerCase() === 'webhook' && (
|
|
||||||
<span className="px-1.5 py-0.5 text-[10px] bg-canvas text-muted border border-border rounded-btn flex items-center gap-1 flex-shrink-0">
|
|
||||||
<Webhook className="w-2.5 h-2.5 text-accent" /> Webhook
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{item.source?.toLowerCase() === 'voice' && (
|
{item.source?.toLowerCase() === 'voice' && (
|
||||||
<span className="px-1.5 py-0.5 text-[10px] bg-canvas text-accent border border-border rounded-btn flex items-center gap-1 flex-shrink-0">
|
<span className="px-1.5 py-0.5 text-[10px] bg-canvas text-accent border border-border rounded-btn flex items-center gap-1 flex-shrink-0">
|
||||||
<Mic className="w-2.5 h-2.5" /> Diktat
|
<Mic className="w-2.5 h-2.5" /> Diktat
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<AgentBadge mark={item.agentMark} />
|
<OriginBadge origin={item.origin} createdAt={item.createdAt} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Desktop quick buttons (hidden on mobile so queue items are clean & easy to tap/swipe) */}
|
{/* Desktop quick buttons (hidden on mobile so queue items are clean & easy to tap/swipe) */}
|
||||||
@@ -441,13 +435,6 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
|
|||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (s === 'webhook') {
|
|
||||||
return (
|
|
||||||
<span className="px-2 py-0.5 text-[11px] bg-canvas text-muted border border-border rounded-btn flex items-center gap-1">
|
|
||||||
<Webhook className="w-3 h-3 text-accent" /> Webhook
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -523,7 +510,7 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
|
|||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
{priorityBadge(nextCard.priority)}
|
{priorityBadge(nextCard.priority)}
|
||||||
{sourceBadge(nextCard.source)}
|
{sourceBadge(nextCard.source)}
|
||||||
<AgentBadge mark={nextCard.agentMark} />
|
<OriginBadge origin={nextCard.origin} createdAt={nextCard.createdAt} size="md" />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted line-clamp-2">{nextCard.content}</p>
|
<p className="text-sm text-muted line-clamp-2">{nextCard.content}</p>
|
||||||
<div className="text-[11px] text-faint">Nächster Quick Dump…</div>
|
<div className="text-[11px] text-faint">Nächster Quick Dump…</div>
|
||||||
@@ -593,7 +580,7 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
|
|||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
{priorityBadge(activeCard.priority)}
|
{priorityBadge(activeCard.priority)}
|
||||||
{sourceBadge(activeCard.source)}
|
{sourceBadge(activeCard.source)}
|
||||||
<AgentBadge mark={activeCard.agentMark} />
|
<OriginBadge origin={activeCard.origin} createdAt={activeCard.createdAt} size="md" />
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className="flex items-center gap-2"
|
className="flex items-center gap-2"
|
||||||
|
|||||||
@@ -148,8 +148,15 @@ export const RECURRENCE_LABELS: Record<Exclude<Recurrence, ''>, string> = {
|
|||||||
yearly: 'jährlich',
|
yearly: 'jährlich',
|
||||||
};
|
};
|
||||||
|
|
||||||
// Set while the last change came from an AI agent via MCP and the user has not touched the entry since
|
// Which API key created an entry and which one changed it last, over which access (REST "api" or
|
||||||
export type AgentMark = 'created' | 'edited';
|
// "mcp"). Stays for good; key names come resolved from the server.
|
||||||
|
export interface Origin {
|
||||||
|
createdVia?: 'api' | 'mcp';
|
||||||
|
createdKey?: string;
|
||||||
|
changedVia?: 'api' | 'mcp';
|
||||||
|
changedKey?: string;
|
||||||
|
changedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Thought {
|
export interface Thought {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -176,7 +183,7 @@ export interface Thought {
|
|||||||
sortOrder?: number;
|
sortOrder?: number;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
triagedAt?: string;
|
triagedAt?: string;
|
||||||
agentMark?: AgentMark;
|
origin?: Origin;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface KanbanColumn {
|
export interface KanbanColumn {
|
||||||
@@ -542,7 +549,7 @@ export interface ListItem {
|
|||||||
sortOrder: number;
|
sortOrder: number;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
completedAt?: string;
|
completedAt?: string;
|
||||||
agentMark?: AgentMark;
|
origin?: Origin;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ListShare {
|
export interface ListShare {
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"regexp"
|
||||||
|
|
||||||
|
"github.com/sandrodach/mindsift/internal/domain"
|
||||||
|
"github.com/sandrodach/mindsift/internal/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
// What an API key may do. The rights (scopes) hold the same over every access: the REST endpoints
|
||||||
|
// below (curl, smart home) and the MCP endpoint for AI agents (mcpToolAllowed). Notes, settings and
|
||||||
|
// everything else stay reserved for a login.
|
||||||
|
|
||||||
|
// apiKeyAuth: the key a request was made with
|
||||||
|
type apiKeyAuth struct {
|
||||||
|
ID string
|
||||||
|
Scopes []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// apiActor: an API key acting on entries; via is storage.OriginAPI or storage.OriginMCP. Recorded as
|
||||||
|
// the entry's origin (storage.RecordOrigin).
|
||||||
|
type apiActor struct {
|
||||||
|
Via string
|
||||||
|
KeyID string
|
||||||
|
}
|
||||||
|
|
||||||
|
type apiKeyCtxKey struct{}
|
||||||
|
|
||||||
|
type apiKeyCtx struct {
|
||||||
|
scopes []string
|
||||||
|
actor *apiActor
|
||||||
|
}
|
||||||
|
|
||||||
|
func withAPIKey(ctx context.Context, key *apiKeyAuth, via string) context.Context {
|
||||||
|
return context.WithValue(ctx, apiKeyCtxKey{}, apiKeyCtx{scopes: key.Scopes, actor: &apiActor{Via: via, KeyID: key.ID}})
|
||||||
|
}
|
||||||
|
|
||||||
|
// keyScopes returns the scopes of the request's API key; isKey is false for login sessions.
|
||||||
|
func keyScopes(ctx context.Context) (scopes []string, isKey bool) {
|
||||||
|
k, ok := ctx.Value(apiKeyCtxKey{}).(apiKeyCtx)
|
||||||
|
return k.scopes, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// actorFrom: the API key behind the request, nil for login sessions.
|
||||||
|
func actorFrom(ctx context.Context) *apiActor {
|
||||||
|
if k, ok := ctx.Value(apiKeyCtxKey{}).(apiKeyCtx); ok {
|
||||||
|
return k.actor
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasAnyScope(scopes []string, want ...string) bool {
|
||||||
|
for _, have := range scopes {
|
||||||
|
for _, w := range want {
|
||||||
|
if have == w {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// keyRoute: one REST endpoint an API key may call, with the scopes of which it needs one. nil scopes:
|
||||||
|
// the handler (or MCP tool) checks the exact right itself.
|
||||||
|
type keyRoute struct {
|
||||||
|
method string
|
||||||
|
path *regexp.Regexp
|
||||||
|
scopes []string
|
||||||
|
}
|
||||||
|
|
||||||
|
var keyRoutes = []keyRoute{
|
||||||
|
{http.MethodPost, regexp.MustCompile(`^/api/v1/inbox$`), nil},
|
||||||
|
{http.MethodPost, regexp.MustCompile(`^` + regexp.QuoteMeta(mcpPath) + `$`), nil},
|
||||||
|
{http.MethodGet, regexp.MustCompile(`^/api/v1/task-lists$`), []string{storage.ScopeTasksRead}},
|
||||||
|
{http.MethodGet, regexp.MustCompile(`^/api/v1/tasks$`), []string{storage.ScopeTasksRead}},
|
||||||
|
{http.MethodPatch, regexp.MustCompile(`^/api/v1/thoughts/[^/]+$`), []string{storage.ScopeTasksWrite}},
|
||||||
|
{http.MethodDelete, regexp.MustCompile(`^/api/v1/thoughts/[^/]+$`), []string{storage.ScopeTasksWrite}},
|
||||||
|
{http.MethodGet, regexp.MustCompile(`^/api/v1/lists$`), []string{storage.ScopeListsRead}},
|
||||||
|
{http.MethodGet, regexp.MustCompile(`^/api/v1/lists/[^/]+$`), []string{storage.ScopeListsRead}},
|
||||||
|
{http.MethodPost, regexp.MustCompile(`^/api/v1/lists/[^/]+/items$`), []string{storage.ScopeListsWrite, storage.ScopeList}},
|
||||||
|
{http.MethodPatch, regexp.MustCompile(`^/api/v1/lists/[^/]+/items/[^/]+$`), []string{storage.ScopeListsWrite}},
|
||||||
|
{http.MethodPut, regexp.MustCompile(`^/api/v1/lists/[^/]+/items/[^/]+$`), []string{storage.ScopeListsWrite}},
|
||||||
|
}
|
||||||
|
|
||||||
|
// apiKeyRouteDenied returns why an API key may not make this request, or "" if it may.
|
||||||
|
// /webhooks/ingest is not listed: it authenticates on its own and checks requireDeliveryScope.
|
||||||
|
func apiKeyRouteDenied(r *http.Request, scopes []string) string {
|
||||||
|
for _, kr := range keyRoutes {
|
||||||
|
if r.Method != kr.method || !kr.path.MatchString(r.URL.Path) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if kr.scopes == nil || hasAnyScope(scopes, kr.scopes...) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return "Dem API-Schlüssel fehlt die Berechtigung für diese Aktion"
|
||||||
|
}
|
||||||
|
return "Mit einem API-Schlüssel nicht erreichbar (nur Aufgaben, Listen, Inbox und MCP; Notizen und Einstellungen brauchen eine Anmeldung)"
|
||||||
|
}
|
||||||
|
|
||||||
|
// requireDeliveryScope: delivering into the inbox, a task list or a list (POST /inbox,
|
||||||
|
// /webhooks/ingest) needs the matching right; sessions may do all. target: "inbox"/"triage", "task",
|
||||||
|
// "list_item"; notes ("document") cannot be created with a key.
|
||||||
|
func requireDeliveryScope(w http.ResponseWriter, r *http.Request, target string) bool {
|
||||||
|
scopes, isKey := keyScopes(r.Context())
|
||||||
|
if !isKey {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
var ok bool
|
||||||
|
switch target {
|
||||||
|
case "task":
|
||||||
|
ok = hasAnyScope(scopes, storage.ScopeTask, storage.ScopeTasksWrite)
|
||||||
|
case "list_item":
|
||||||
|
ok = hasAnyScope(scopes, storage.ScopeList, storage.ScopeListsWrite)
|
||||||
|
case "document":
|
||||||
|
ok = false
|
||||||
|
default:
|
||||||
|
ok = hasAnyScope(scopes, storage.ScopeTriage)
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
writeError(w, http.StatusForbidden, "API-Key besitzt keine Berechtigung für diese Aktion")
|
||||||
|
}
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// recordThoughtOrigin stores that an API key created or changed a task (nothing for the app itself)
|
||||||
|
// and puts the result on t, so the broadcast and the response carry it.
|
||||||
|
func (s *Server) recordThoughtOrigin(t *domain.Thought, actor *apiActor, created bool) {
|
||||||
|
if actor == nil || t == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.Origin = s.store.RecordOrigin(storage.OriginTableThoughts, t.ID, actor.Via, actor.KeyID, created)
|
||||||
|
}
|
||||||
|
|
||||||
|
// recordListItemOrigin is recordThoughtOrigin for list items.
|
||||||
|
func (s *Server) recordListItemOrigin(it *domain.ListItem, actor *apiActor, created bool) {
|
||||||
|
if actor == nil || it == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
it.Origin = s.store.RecordOrigin(storage.OriginTableListItems, it.ID, actor.Via, actor.KeyID, created)
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/sandrodach/mindsift/internal/auth"
|
||||||
|
"github.com/sandrodach/mindsift/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
func keyRequest(t *testing.T, router http.Handler, key, method, path, body string) (int, []byte) {
|
||||||
|
t.Helper()
|
||||||
|
req := httptest.NewRequest(method, path, strings.NewReader(body))
|
||||||
|
req.Header.Set("X-API-Key", key)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
return rec.Code, rec.Body.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
// API keys reach tasks and lists over plain REST with the right scope, and nothing else.
|
||||||
|
func TestAPIKeyRESTAccess(t *testing.T) {
|
||||||
|
srv, store, token := setupTestServer(t)
|
||||||
|
router := srv.Router()
|
||||||
|
claims, _ := auth.ValidateToken(testJWTSecret, token)
|
||||||
|
reader, _ := store.CreateAPIKey(claims.UserID, "Home Assistant", []string{"tasks:read", "lists:read"})
|
||||||
|
writer, _ := store.CreateAPIKey(claims.UserID, "Skript", []string{"tasks:write", "lists:write"})
|
||||||
|
inbox, _ := store.CreateAPIKey(claims.UserID, "Nur Inbox", nil)
|
||||||
|
|
||||||
|
task, _ := srv.createTask(claims.UserID, "Müll rausbringen", "", "manual", time.Now().Format("2006-01-02"), "", "", nil)
|
||||||
|
list, _ := store.CreateList(claims.UserID, "Einkauf", "", "shopping")
|
||||||
|
|
||||||
|
if code, body := keyRequest(t, router, reader.RawKey, http.MethodGet, "/api/v1/tasks?due=today&status=open", ""); code != http.StatusOK || !strings.Contains(string(body), "Müll rausbringen") {
|
||||||
|
t.Fatalf("reader GET /tasks: %d %s", code, body)
|
||||||
|
}
|
||||||
|
if code, _ := keyRequest(t, router, reader.RawKey, http.MethodGet, "/api/v1/lists/"+list.ID, ""); code != http.StatusOK {
|
||||||
|
t.Fatalf("reader GET list: %d", code)
|
||||||
|
}
|
||||||
|
// Reading does not allow writing, and notes stay closed
|
||||||
|
if code, _ := keyRequest(t, router, reader.RawKey, http.MethodPatch, "/api/v1/thoughts/"+task.ID, `{"isCompleted":true}`); code != http.StatusForbidden {
|
||||||
|
t.Fatalf("reader must not change tasks: %d", code)
|
||||||
|
}
|
||||||
|
if code, _ := keyRequest(t, router, reader.RawKey, http.MethodGet, "/api/v1/documents", ""); code != http.StatusForbidden {
|
||||||
|
t.Fatalf("keys must not reach notes: %d", code)
|
||||||
|
}
|
||||||
|
if code, _ := keyRequest(t, router, inbox.RawKey, http.MethodGet, "/api/v1/tasks", ""); code != http.StatusForbidden {
|
||||||
|
t.Fatalf("inbox key must not read tasks: %d", code)
|
||||||
|
}
|
||||||
|
if code, _ := keyRequest(t, router, reader.RawKey, http.MethodGet, "/api/v1/tasks?due=morgen", ""); code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("bad due filter should be 400: %d", code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Writing over REST records the key as origin
|
||||||
|
if code, body := keyRequest(t, router, writer.RawKey, http.MethodPatch, "/api/v1/thoughts/"+task.ID, `{"isCompleted":true}`); code != http.StatusOK {
|
||||||
|
t.Fatalf("writer PATCH: %d %s", code, body)
|
||||||
|
}
|
||||||
|
th, _ := store.GetThoughtByID(claims.UserID, task.ID)
|
||||||
|
if !th.IsCompleted || th.Origin == nil || th.Origin.ChangedVia != "api" || th.Origin.ChangedKey != "Skript" {
|
||||||
|
t.Fatalf("REST change by key: %+v %+v", th.IsCompleted, th.Origin)
|
||||||
|
}
|
||||||
|
code, body := keyRequest(t, router, writer.RawKey, http.MethodPost, "/api/v1/lists/"+list.ID+"/items", `{"content":"Milch"}`)
|
||||||
|
var item domain.ListItem
|
||||||
|
_ = json.Unmarshal(body, &item)
|
||||||
|
if code != http.StatusCreated || item.Origin == nil || item.Origin.CreatedKey != "Skript" {
|
||||||
|
t.Fatalf("writer adds item: %d %s", code, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Webhook-delivered thoughts keep their origin when the triage turns them into a list item
|
||||||
|
code, body = keyRequest(t, router, inbox.RawKey, http.MethodPost, "/api/v1/webhooks/ingest", `{"title":"Kaffee kaufen"}`)
|
||||||
|
var ingested struct {
|
||||||
|
Thought domain.Thought `json:"thought"`
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal(body, &ingested)
|
||||||
|
if code != http.StatusCreated || ingested.Thought.Origin == nil || ingested.Thought.Origin.CreatedKey != "Nur Inbox" {
|
||||||
|
t.Fatalf("ingest origin: %d %s", code, body)
|
||||||
|
}
|
||||||
|
if _, err := store.UpdateThoughtTriage(claims.UserID, ingested.Thought.ID, "keep", "list_item", list.ID, "", ""); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
items, _ := store.GetListItems(list.ID)
|
||||||
|
found := false
|
||||||
|
for _, it := range items {
|
||||||
|
if it.Content == "Kaffee kaufen" {
|
||||||
|
found = it.Origin != nil && it.Origin.CreatedVia == "api" && it.Origin.CreatedKey == "Nur Inbox"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatalf("triaged list item lost its origin: %+v", items)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The day filters count in the server's time zone, whatever the time of day.
|
||||||
|
func TestFilterTasks(t *testing.T) {
|
||||||
|
now := time.Date(2026, 11, 1, 23, 30, 0, 0, time.Local)
|
||||||
|
tasks := []domain.Thought{
|
||||||
|
{ID: "a", DueDate: "2026-11-01T09:00"},
|
||||||
|
{ID: "b", DueDate: "2026-10-30"},
|
||||||
|
{ID: "c", DueDate: "2026-10-29", IsCompleted: true},
|
||||||
|
{ID: "d", DueDate: "2026-11-02"},
|
||||||
|
{ID: "e"},
|
||||||
|
}
|
||||||
|
ids := func(ts []domain.Thought) string {
|
||||||
|
out := []string{}
|
||||||
|
for _, x := range ts {
|
||||||
|
out = append(out, x.ID)
|
||||||
|
}
|
||||||
|
return strings.Join(out, ",")
|
||||||
|
}
|
||||||
|
for _, tc := range []struct{ status, due, want string }{
|
||||||
|
{"", "", "a,b,c,d,e"},
|
||||||
|
{"", "today", "a"},
|
||||||
|
{"", "overdue", "b"},
|
||||||
|
{"open", "2026-11-01", "a,b"},
|
||||||
|
{"done", "", "c"},
|
||||||
|
} {
|
||||||
|
got, err := filterTasks(tasks, tc.status, tc.due, now)
|
||||||
|
if err != nil || ids(got) != tc.want {
|
||||||
|
t.Errorf("status=%q due=%q: got %s (%v), want %s", tc.status, tc.due, ids(got), err, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := filterTasks(tasks, "offen", "", now); err == nil {
|
||||||
|
t.Error("unknown status should fail")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -70,14 +69,15 @@ func (s *Server) handleWebhookIngest(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Same checks as every other request (revoked/outdated sessions, inactive accounts)
|
// Same checks as every other request (revoked/outdated sessions, inactive accounts)
|
||||||
user, keyScopes := s.authenticateToken(token)
|
user, key := s.authenticateToken(token)
|
||||||
if user == nil {
|
if user == nil {
|
||||||
writeError(w, http.StatusUnauthorized, "Ungültiger Webhook-Token oder API-Key")
|
writeError(w, http.StatusUnauthorized, "Ungültiger Webhook-Token oder API-Key")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if keyScopes != nil {
|
if key != nil {
|
||||||
r = r.WithContext(context.WithValue(r.Context(), apiKeyScopesKey{}, keyScopes))
|
r = r.WithContext(withAPIKey(r.Context(), key, storage.OriginAPI))
|
||||||
}
|
}
|
||||||
|
actor := actorFrom(r.Context())
|
||||||
|
|
||||||
var req struct {
|
var req struct {
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
@@ -163,11 +163,11 @@ func (s *Server) handleWebhookIngest(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if target == "list_item" {
|
if target == "list_item" {
|
||||||
s.ingestListItem(w, user.ID, req.ListID, bodyText, req.Category, req.Quantity)
|
s.ingestListItem(w, user.ID, req.ListID, bodyText, req.Category, req.Quantity, actor)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if target == "task" {
|
if target == "task" {
|
||||||
s.ingestTask(w, user.ID, bodyText, req.Priority, source, dueDate, req.TaskListID)
|
s.ingestTask(w, user.ID, bodyText, req.Priority, source, dueDate, req.TaskListID, actor)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,6 +179,7 @@ func (s *Server) handleWebhookIngest(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
s.recordThoughtOrigin(thought, actor, true)
|
||||||
|
|
||||||
s.wsHub.BroadcastToUsers(s.taskAudience(thought), "TASK_CREATED", thought)
|
s.wsHub.BroadcastToUsers(s.taskAudience(thought), "TASK_CREATED", thought)
|
||||||
|
|
||||||
@@ -188,9 +189,9 @@ func (s *Server) handleWebhookIngest(w http.ResponseWriter, r *http.Request) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// createTask creates a task directly (webhook scope webhook:task, MCP tool create_task), optionally in
|
// createTask creates a task directly (POST /webhooks/ingest, MCP tool create_task), optionally in a
|
||||||
// a task list the user may write to (otherwise storage.ErrTaskListReadOnly).
|
// task list the user may write to (otherwise storage.ErrTaskListReadOnly). actor: the API key, if any.
|
||||||
func (s *Server) createTask(userID, content, priority, source, dueDate, taskListID, notes string) (*domain.Thought, error) {
|
func (s *Server) createTask(userID, content, priority, source, dueDate, taskListID, notes string, actor *apiActor) (*domain.Thought, error) {
|
||||||
if priority == "" {
|
if priority == "" {
|
||||||
priority = "normal"
|
priority = "normal"
|
||||||
}
|
}
|
||||||
@@ -205,12 +206,13 @@ func (s *Server) createTask(userID, content, priority, source, dueDate, taskList
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
s.recordThoughtOrigin(task, actor, true)
|
||||||
s.wsHub.BroadcastToUsers(s.taskAudience(task), "TASK_CREATED", task)
|
s.wsHub.BroadcastToUsers(s.taskAudience(task), "TASK_CREATED", task)
|
||||||
return task, nil
|
return task, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) ingestTask(w http.ResponseWriter, userID, content, priority, source, dueDate, taskListID string) {
|
func (s *Server) ingestTask(w http.ResponseWriter, userID, content, priority, source, dueDate, taskListID string, actor *apiActor) {
|
||||||
task, err := s.createTask(userID, content, priority, source, dueDate, taskListID, "")
|
task, err := s.createTask(userID, content, priority, source, dueDate, taskListID, "", actor)
|
||||||
if errors.Is(err, storage.ErrTaskListReadOnly) {
|
if errors.Is(err, storage.ErrTaskListReadOnly) {
|
||||||
writeError(w, http.StatusForbidden, err.Error())
|
writeError(w, http.StatusForbidden, err.Error())
|
||||||
return
|
return
|
||||||
@@ -226,7 +228,7 @@ var errListIDRequired = errors.New("Feld 'listId' ist für Listeneinträge erfor
|
|||||||
|
|
||||||
// addListItem appends an item to a list the user may write to; the error is storage.ErrListReadOnly for
|
// addListItem appends an item to a list the user may write to; the error is storage.ErrListReadOnly for
|
||||||
// viewers and "not found" for lists the user cannot see.
|
// viewers and "not found" for lists the user cannot see.
|
||||||
func (s *Server) addListItem(userID, listID, content, category, quantity string, byAgent bool) (*domain.ListItem, error) {
|
func (s *Server) addListItem(userID, listID, content, category, quantity string, actor *apiActor) (*domain.ListItem, error) {
|
||||||
if strings.TrimSpace(listID) == "" {
|
if strings.TrimSpace(listID) == "" {
|
||||||
return nil, errListIDRequired
|
return nil, errListIDRequired
|
||||||
}
|
}
|
||||||
@@ -237,16 +239,14 @@ func (s *Server) addListItem(userID, listID, content, category, quantity string,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if byAgent {
|
s.recordListItemOrigin(item, actor, true)
|
||||||
item.AgentMark = s.store.MarkListItemAgentCreated(item.ID)
|
|
||||||
}
|
|
||||||
s.wsHub.BroadcastListEvent(listID, "ITEM_CREATED", item)
|
s.wsHub.BroadcastListEvent(listID, "ITEM_CREATED", item)
|
||||||
return item, nil
|
return item, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ingestListItem adds an item to a list (scope webhook:list) the owner of the key may write to.
|
// ingestListItem adds an item to a list the owner of the key may write to.
|
||||||
func (s *Server) ingestListItem(w http.ResponseWriter, userID, listID, content, category, quantity string) {
|
func (s *Server) ingestListItem(w http.ResponseWriter, userID, listID, content, category, quantity string, actor *apiActor) {
|
||||||
item, err := s.addListItem(userID, listID, content, category, quantity, false)
|
item, err := s.addListItem(userID, listID, content, category, quantity, actor)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
status := http.StatusNotFound
|
status := http.StatusNotFound
|
||||||
switch {
|
switch {
|
||||||
|
|||||||
@@ -161,14 +161,15 @@ func (s *Server) handleCreateListItem(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeError(w, http.StatusBadRequest, err.Error())
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
s.recordListItemOrigin(item, actorFrom(r.Context()), true)
|
||||||
|
|
||||||
s.wsHub.BroadcastListEvent(listID, "ITEM_CREATED", item)
|
s.wsHub.BroadcastListEvent(listID, "ITEM_CREATED", item)
|
||||||
writeJSON(w, http.StatusCreated, item)
|
writeJSON(w, http.StatusCreated, item)
|
||||||
}
|
}
|
||||||
|
|
||||||
// setListItemDone checks an item off (or back on) and tells the list's viewers. Shared by
|
// setListItemDone checks an item off (or back on) and tells the list's viewers. Shared by
|
||||||
// PATCH /lists/{id}/items/{itemId} and the MCP tool check_list_item (byAgent); access is checked first.
|
// PATCH /lists/{id}/items/{itemId} and the MCP tool check_list_item; access is checked first.
|
||||||
func (s *Server) setListItemDone(userID, listID, itemID string, done, byAgent bool) (*domain.ListItem, error) {
|
func (s *Server) setListItemDone(userID, listID, itemID string, done bool, actor *apiActor) (*domain.ListItem, error) {
|
||||||
if err := s.store.EnsureListItemsWritable(userID, listID, itemID); err != nil {
|
if err := s.store.EnsureListItemsWritable(userID, listID, itemID); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -181,7 +182,7 @@ func (s *Server) setListItemDone(userID, listID, itemID string, done, byAgent bo
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
item.AgentMark = s.store.SetListItemAgentMark(itemID, byAgent)
|
s.recordListItemOrigin(item, actor, false)
|
||||||
s.wsHub.BroadcastListEvent(listID, "ITEM_TOGGLED", item)
|
s.wsHub.BroadcastListEvent(listID, "ITEM_TOGGLED", item)
|
||||||
return item, nil
|
return item, nil
|
||||||
}
|
}
|
||||||
@@ -202,7 +203,7 @@ func (s *Server) handleToggleListItem(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
item, err := s.setListItemDone(userID, listID, itemID, req.IsCompleted, false)
|
item, err := s.setListItemDone(userID, listID, itemID, req.IsCompleted, actorFrom(r.Context()))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusBadRequest, err.Error())
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
return
|
return
|
||||||
@@ -232,7 +233,7 @@ func (s *Server) handleUpdateListItem(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeError(w, http.StatusBadRequest, err.Error())
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
item.AgentMark = s.store.SetListItemAgentMark(itemID, false)
|
s.recordListItemOrigin(item, actorFrom(r.Context()), false)
|
||||||
|
|
||||||
s.wsHub.BroadcastListEvent(listID, "ITEM_UPDATED", item)
|
s.wsHub.BroadcastListEvent(listID, "ITEM_UPDATED", item)
|
||||||
writeJSON(w, http.StatusOK, item)
|
writeJSON(w, http.StatusOK, item)
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/sandrodach/mindsift/internal/auth"
|
"github.com/sandrodach/mindsift/internal/auth"
|
||||||
@@ -69,6 +71,8 @@ func (s *Server) handleCaptureInbox(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeStoreError(w, http.StatusBadRequest, err)
|
writeStoreError(w, http.StatusBadRequest, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Before a conversion, so a list item made from it takes the origin along
|
||||||
|
s.recordThoughtOrigin(thought, actorFrom(r.Context()), true)
|
||||||
|
|
||||||
target := strings.TrimSpace(req.Target)
|
target := strings.TrimSpace(req.Target)
|
||||||
if target == "task" || target == "document" || (target == "list_item" && targetListID != "") {
|
if target == "task" || target == "document" || (target == "list_item" && targetListID != "") {
|
||||||
@@ -175,15 +179,60 @@ func (s *Server) handleTriageAction(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
func (s *Server) handleGetTasks(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleGetTasks(w http.ResponseWriter, r *http.Request) {
|
||||||
userID := auth.GetUserID(r.Context())
|
userID := auth.GetUserID(r.Context())
|
||||||
taskListID := r.URL.Query().Get("taskListId")
|
q := r.URL.Query()
|
||||||
tasks, err := s.store.GetTasks(userID, taskListID)
|
tasks, err := s.store.GetTasks(userID, q.Get("taskListId"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
tasks, err = filterTasks(tasks, q.Get("status"), q.Get("due"), time.Now())
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
writeJSON(w, http.StatusOK, tasks)
|
writeJSON(w, http.StatusOK, tasks)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// filterTasks narrows GET /tasks for scripts and smart homes; without parameters nothing changes.
|
||||||
|
// status: open | done | all (default). due: today | overdue | YYYY-MM-DD (due on or before that day).
|
||||||
|
// Days count in the server's time zone like due dates themselves (now is passed in for tests).
|
||||||
|
func filterTasks(tasks []domain.Thought, status, due string, now time.Time) ([]domain.Thought, error) {
|
||||||
|
switch status {
|
||||||
|
case "", "all", "open", "done":
|
||||||
|
default:
|
||||||
|
return nil, errors.New("status: open, done oder all")
|
||||||
|
}
|
||||||
|
today := now.In(time.Local).Format("2006-01-02")
|
||||||
|
var match func(day string) bool
|
||||||
|
switch {
|
||||||
|
case due == "":
|
||||||
|
case due == "today":
|
||||||
|
match = func(day string) bool { return day == today }
|
||||||
|
case due == "overdue":
|
||||||
|
match = func(day string) bool { return day < today }
|
||||||
|
default:
|
||||||
|
if _, err := time.Parse("2006-01-02", due); err != nil {
|
||||||
|
return nil, errors.New("due: today, overdue oder ein Datum JJJJ-MM-TT")
|
||||||
|
}
|
||||||
|
match = func(day string) bool { return day <= due }
|
||||||
|
}
|
||||||
|
out := make([]domain.Thought, 0, len(tasks))
|
||||||
|
for _, t := range tasks {
|
||||||
|
if (status == "open" && t.IsCompleted) || (status == "done" && !t.IsCompleted) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Overdue only makes sense for open tasks
|
||||||
|
if due == "overdue" && t.IsCompleted {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if match != nil && (t.DueDate == "" || !match(t.DueDate[:min(10, len(t.DueDate))])) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, t)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) handleReorderTasks(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleReorderTasks(w http.ResponseWriter, r *http.Request) {
|
||||||
userID := auth.GetUserID(r.Context())
|
userID := auth.GetUserID(r.Context())
|
||||||
taskListID := chi.URLParam(r, "id")
|
taskListID := chi.URLParam(r, "id")
|
||||||
@@ -245,8 +294,8 @@ type taskPatch struct {
|
|||||||
Tags *[]string `json:"tags"`
|
Tags *[]string `json:"tags"`
|
||||||
Assignees *[]string `json:"assignees"`
|
Assignees *[]string `json:"assignees"`
|
||||||
Recurrence *string `json:"recurrence"`
|
Recurrence *string `json:"recurrence"`
|
||||||
// byAgent: the change comes from an AI agent (MCP) and marks the task; otherwise the mark goes
|
// actor: the API key making the change (recorded as the task's origin); nil for the app
|
||||||
byAgent bool
|
actor *apiActor
|
||||||
}
|
}
|
||||||
|
|
||||||
// updateTask applies a patch, keeps a linked note block in step, tells everyone who sees the task
|
// updateTask applies a patch, keeps a linked note block in step, tells everyone who sees the task
|
||||||
@@ -263,7 +312,7 @@ func (s *Server) updateTask(userID, thoughtID string, req taskPatch) (*domain.Th
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
updated.AgentMark = s.store.SetThoughtAgentMark(thoughtID, updated.AgentMark, req.byAgent)
|
s.recordThoughtOrigin(updated, req.actor, false)
|
||||||
if req.Assignees != nil {
|
if req.Assignees != nil {
|
||||||
assignees, err := s.store.SetTaskAssignees(userID, thoughtID, *req.Assignees)
|
assignees, err := s.store.SetTaskAssignees(userID, thoughtID, *req.Assignees)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -290,6 +339,7 @@ func (s *Server) handleUpdateThought(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
req.actor = actorFrom(r.Context())
|
||||||
updated, err := s.updateTask(userID, thoughtID, req)
|
updated, err := s.updateTask(userID, thoughtID, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeStoreError(w, http.StatusBadRequest, err)
|
writeStoreError(w, http.StatusBadRequest, err)
|
||||||
|
|||||||
+39
-28
@@ -58,8 +58,10 @@ type mcpTool struct {
|
|||||||
Name string
|
Name string
|
||||||
Description string
|
Description string
|
||||||
Scope string
|
Scope string
|
||||||
|
// AltScope: a narrower right that allows this tool too (e.g. "create tasks" for create_task)
|
||||||
|
AltScope string
|
||||||
Schema map[string]interface{}
|
Schema map[string]interface{}
|
||||||
run func(s *Server, userID string, args json.RawMessage) (interface{}, error)
|
run func(s *Server, userID string, actor *apiActor, args json.RawMessage) (interface{}, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// mcpToolAllowed: sessions may use every tool, API keys only those of their scopes. search needs any
|
// mcpToolAllowed: sessions may use every tool, API keys only those of their scopes. search needs any
|
||||||
@@ -82,12 +84,17 @@ func mcpToolAllowed(scopes []string, isKey bool, tool mcpTool) bool {
|
|||||||
if tool.Name == "search" {
|
if tool.Name == "search" {
|
||||||
return has(storage.ScopeTasksRead) || has(storage.ScopeListsRead)
|
return has(storage.ScopeTasksRead) || has(storage.ScopeListsRead)
|
||||||
}
|
}
|
||||||
return has(tool.Scope)
|
return has(tool.Scope) || (tool.AltScope != "" && has(tool.AltScope))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleMCP(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleMCP(w http.ResponseWriter, r *http.Request) {
|
||||||
userID := auth.GetUserID(r.Context())
|
userID := auth.GetUserID(r.Context())
|
||||||
scopes, isKey := r.Context().Value(apiKeyScopesKey{}).([]string)
|
scopes, isKey := keyScopes(r.Context())
|
||||||
|
// Changes are recorded as made via MCP, with the key if there is one (a login session has none)
|
||||||
|
actor := actorFrom(r.Context())
|
||||||
|
if actor == nil {
|
||||||
|
actor = &apiActor{Via: storage.OriginMCP}
|
||||||
|
}
|
||||||
|
|
||||||
var raw json.RawMessage
|
var raw json.RawMessage
|
||||||
if err := json.NewDecoder(r.Body).Decode(&raw); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&raw); err != nil {
|
||||||
@@ -129,7 +136,7 @@ func (s *Server) handleMCP(w http.ResponseWriter, r *http.Request) {
|
|||||||
"instructions": "MindSift is a personal notes, tasks and lists app. Use list_task_lists and list_tasks " +
|
"instructions": "MindSift is a personal notes, tasks and lists app. Use list_task_lists and list_tasks " +
|
||||||
"to see open tasks, complete_task to check one off, list_lists/get_list for shopping and other " +
|
"to see open tasks, complete_task to check one off, list_lists/get_list for shopping and other " +
|
||||||
"lists and check_list_item to tick items. IDs come from the list tools; never guess them. " +
|
"lists and check_list_item to tick items. IDs come from the list tools; never guess them. " +
|
||||||
"Entries you create or change are marked for the user until they touch them.",
|
"Entries you create or change show the user a badge with the name of your API key.",
|
||||||
}, nil)
|
}, nil)
|
||||||
case "ping":
|
case "ping":
|
||||||
writeRPC(w, req.ID, map[string]interface{}{}, nil)
|
writeRPC(w, req.ID, map[string]interface{}{}, nil)
|
||||||
@@ -167,7 +174,7 @@ func (s *Server) handleMCP(w http.ResponseWriter, r *http.Request) {
|
|||||||
if len(p.Arguments) == 0 {
|
if len(p.Arguments) == 0 {
|
||||||
p.Arguments = json.RawMessage("{}")
|
p.Arguments = json.RawMessage("{}")
|
||||||
}
|
}
|
||||||
result, err := tool.run(s, userID, p.Arguments)
|
result, err := tool.run(s, userID, actor, p.Arguments)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeRPC(w, req.ID, toolError(err.Error()), nil)
|
writeRPC(w, req.ID, toolError(err.Error()), nil)
|
||||||
return
|
return
|
||||||
@@ -265,13 +272,14 @@ type taskView struct {
|
|||||||
Subtasks []domain.SubTask `json:"subtasks,omitempty"`
|
Subtasks []domain.SubTask `json:"subtasks,omitempty"`
|
||||||
Recurrence string `json:"recurrence,omitempty"`
|
Recurrence string `json:"recurrence,omitempty"`
|
||||||
CreatedAt string `json:"createdAt"`
|
CreatedAt string `json:"createdAt"`
|
||||||
|
Origin *domain.Origin `json:"origin,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func toTaskView(t *domain.Thought) taskView {
|
func toTaskView(t *domain.Thought) taskView {
|
||||||
return taskView{
|
return taskView{
|
||||||
ID: t.ID, Content: t.Content, IsCompleted: t.IsCompleted, Priority: t.Priority, DueDate: t.DueDate,
|
ID: t.ID, Content: t.Content, IsCompleted: t.IsCompleted, Priority: t.Priority, DueDate: t.DueDate,
|
||||||
TaskListID: t.TaskListID, KanbanStatus: t.KanbanStatus, Notes: t.Notes, Tags: t.Tags, Subtasks: t.Subtasks,
|
TaskListID: t.TaskListID, KanbanStatus: t.KanbanStatus, Notes: t.Notes, Tags: t.Tags, Subtasks: t.Subtasks,
|
||||||
Recurrence: t.Recurrence, CreatedAt: t.CreatedAt,
|
Recurrence: t.Recurrence, CreatedAt: t.CreatedAt, Origin: t.Origin,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -333,7 +341,7 @@ var mcpTools = []mcpTool{
|
|||||||
Description: "All task lists the user can see, with permission (owner/editor/viewer), the number of open tasks and the board columns (valid kanbanStatus values). Tasks without a list live in the personal inbox list (taskListId empty).",
|
Description: "All task lists the user can see, with permission (owner/editor/viewer), the number of open tasks and the board columns (valid kanbanStatus values). Tasks without a list live in the personal inbox list (taskListId empty).",
|
||||||
Scope: storage.ScopeTasksRead,
|
Scope: storage.ScopeTasksRead,
|
||||||
Schema: schema(nil, map[string]interface{}{}),
|
Schema: schema(nil, map[string]interface{}{}),
|
||||||
run: func(s *Server, userID string, _ json.RawMessage) (interface{}, error) {
|
run: func(s *Server, userID string, _ *apiActor, _ json.RawMessage) (interface{}, error) {
|
||||||
lists, err := s.store.GetTaskLists(userID)
|
lists, err := s.store.GetTaskLists(userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -353,7 +361,7 @@ var mcpTools = []mcpTool{
|
|||||||
"title": prop("string", "Name of the task list"),
|
"title": prop("string", "Name of the task list"),
|
||||||
"icon": prop("string", "One emoji, optional (default 📋)"),
|
"icon": prop("string", "One emoji, optional (default 📋)"),
|
||||||
}),
|
}),
|
||||||
run: func(s *Server, userID string, args json.RawMessage) (interface{}, error) {
|
run: func(s *Server, userID string, actor *apiActor, args json.RawMessage) (interface{}, error) {
|
||||||
var a struct {
|
var a struct {
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Icon string `json:"icon"`
|
Icon string `json:"icon"`
|
||||||
@@ -383,7 +391,7 @@ var mcpTools = []mcpTool{
|
|||||||
"dueBefore": prop("string", "Only tasks due on or before this date (YYYY-MM-DD); tasks without due date are left out"),
|
"dueBefore": prop("string", "Only tasks due on or before this date (YYYY-MM-DD); tasks without due date are left out"),
|
||||||
"limit": prop("integer", "Maximum number of tasks (default 100)"),
|
"limit": prop("integer", "Maximum number of tasks (default 100)"),
|
||||||
}),
|
}),
|
||||||
run: func(s *Server, userID string, args json.RawMessage) (interface{}, error) {
|
run: func(s *Server, userID string, actor *apiActor, args json.RawMessage) (interface{}, error) {
|
||||||
var a struct {
|
var a struct {
|
||||||
TaskListID string `json:"taskListId"`
|
TaskListID string `json:"taskListId"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
@@ -437,7 +445,7 @@ var mcpTools = []mcpTool{
|
|||||||
Description: "One task with all details.",
|
Description: "One task with all details.",
|
||||||
Scope: storage.ScopeTasksRead,
|
Scope: storage.ScopeTasksRead,
|
||||||
Schema: schema([]string{"id"}, map[string]interface{}{"id": prop("string", "Task id")}),
|
Schema: schema([]string{"id"}, map[string]interface{}{"id": prop("string", "Task id")}),
|
||||||
run: func(s *Server, userID string, args json.RawMessage) (interface{}, error) {
|
run: func(s *Server, userID string, actor *apiActor, args json.RawMessage) (interface{}, error) {
|
||||||
var a struct {
|
var a struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
}
|
}
|
||||||
@@ -455,6 +463,7 @@ var mcpTools = []mcpTool{
|
|||||||
Name: "create_task",
|
Name: "create_task",
|
||||||
Description: "Create a task, optionally in a task list the user may edit. dueDate as YYYY-MM-DD or YYYY-MM-DDTHH:MM.",
|
Description: "Create a task, optionally in a task list the user may edit. dueDate as YYYY-MM-DD or YYYY-MM-DDTHH:MM.",
|
||||||
Scope: storage.ScopeTasksWrite,
|
Scope: storage.ScopeTasksWrite,
|
||||||
|
AltScope: storage.ScopeTask,
|
||||||
Schema: schema([]string{"content"}, map[string]interface{}{
|
Schema: schema([]string{"content"}, map[string]interface{}{
|
||||||
"content": prop("string", "Task text; #hashtags become tags"),
|
"content": prop("string", "Task text; #hashtags become tags"),
|
||||||
"taskListId": prop("string", "Task list id (from list_task_lists); empty = personal tasks"),
|
"taskListId": prop("string", "Task list id (from list_task_lists); empty = personal tasks"),
|
||||||
@@ -462,7 +471,7 @@ var mcpTools = []mcpTool{
|
|||||||
"dueDate": prop("string", "YYYY-MM-DD or YYYY-MM-DDTHH:MM"),
|
"dueDate": prop("string", "YYYY-MM-DD or YYYY-MM-DDTHH:MM"),
|
||||||
"notes": prop("string", "Longer description"),
|
"notes": prop("string", "Longer description"),
|
||||||
}),
|
}),
|
||||||
run: func(s *Server, userID string, args json.RawMessage) (interface{}, error) {
|
run: func(s *Server, userID string, actor *apiActor, args json.RawMessage) (interface{}, error) {
|
||||||
var a struct {
|
var a struct {
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
TaskListID string `json:"taskListId"`
|
TaskListID string `json:"taskListId"`
|
||||||
@@ -476,7 +485,7 @@ var mcpTools = []mcpTool{
|
|||||||
if strings.TrimSpace(a.Content) == "" {
|
if strings.TrimSpace(a.Content) == "" {
|
||||||
return nil, errors.New("'content' darf nicht leer sein")
|
return nil, errors.New("'content' darf nicht leer sein")
|
||||||
}
|
}
|
||||||
t, err := s.createTask(userID, a.Content, a.Priority, "agent", a.DueDate, a.TaskListID, a.Notes)
|
t, err := s.createTask(userID, a.Content, a.Priority, "agent", a.DueDate, a.TaskListID, a.Notes, actor)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -498,7 +507,7 @@ var mcpTools = []mcpTool{
|
|||||||
"taskListId": prop("string", "Move into another task list"),
|
"taskListId": prop("string", "Move into another task list"),
|
||||||
"tags": map[string]interface{}{"type": "array", "items": map[string]string{"type": "string"}},
|
"tags": map[string]interface{}{"type": "array", "items": map[string]string{"type": "string"}},
|
||||||
}),
|
}),
|
||||||
run: func(s *Server, userID string, args json.RawMessage) (interface{}, error) {
|
run: func(s *Server, userID string, actor *apiActor, args json.RawMessage) (interface{}, error) {
|
||||||
var a struct {
|
var a struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
taskPatch
|
taskPatch
|
||||||
@@ -507,7 +516,7 @@ var mcpTools = []mcpTool{
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
a.taskPatch.Assignees, a.taskPatch.Recurrence, a.taskPatch.Subtasks = nil, nil, nil
|
a.taskPatch.Assignees, a.taskPatch.Recurrence, a.taskPatch.Subtasks = nil, nil, nil
|
||||||
a.taskPatch.byAgent = true
|
a.taskPatch.actor = actor
|
||||||
t, err := s.updateTask(userID, a.ID, a.taskPatch)
|
t, err := s.updateTask(userID, a.ID, a.taskPatch)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -523,7 +532,7 @@ var mcpTools = []mcpTool{
|
|||||||
"id": prop("string", "Task id"),
|
"id": prop("string", "Task id"),
|
||||||
"completed": prop("boolean", "Default true"),
|
"completed": prop("boolean", "Default true"),
|
||||||
}),
|
}),
|
||||||
run: func(s *Server, userID string, args json.RawMessage) (interface{}, error) {
|
run: func(s *Server, userID string, actor *apiActor, args json.RawMessage) (interface{}, error) {
|
||||||
var a struct {
|
var a struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Completed *bool `json:"completed"`
|
Completed *bool `json:"completed"`
|
||||||
@@ -532,7 +541,7 @@ var mcpTools = []mcpTool{
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
done := a.Completed == nil || *a.Completed
|
done := a.Completed == nil || *a.Completed
|
||||||
t, err := s.updateTask(userID, a.ID, taskPatch{IsCompleted: &done, byAgent: true})
|
t, err := s.updateTask(userID, a.ID, taskPatch{IsCompleted: &done, actor: actor})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -544,7 +553,7 @@ var mcpTools = []mcpTool{
|
|||||||
Description: "Delete a task for good, e.g. one created by mistake. Prefer complete_task for work that is done.",
|
Description: "Delete a task for good, e.g. one created by mistake. Prefer complete_task for work that is done.",
|
||||||
Scope: storage.ScopeTasksWrite,
|
Scope: storage.ScopeTasksWrite,
|
||||||
Schema: schema([]string{"id"}, map[string]interface{}{"id": prop("string", "Task id")}),
|
Schema: schema([]string{"id"}, map[string]interface{}{"id": prop("string", "Task id")}),
|
||||||
run: func(s *Server, userID string, args json.RawMessage) (interface{}, error) {
|
run: func(s *Server, userID string, actor *apiActor, args json.RawMessage) (interface{}, error) {
|
||||||
var a struct {
|
var a struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
}
|
}
|
||||||
@@ -565,7 +574,7 @@ var mcpTools = []mcpTool{
|
|||||||
Description: "Shopping lists, checklists and other shared lists the user can see, with open/total item counts.",
|
Description: "Shopping lists, checklists and other shared lists the user can see, with open/total item counts.",
|
||||||
Scope: storage.ScopeListsRead,
|
Scope: storage.ScopeListsRead,
|
||||||
Schema: schema(nil, map[string]interface{}{}),
|
Schema: schema(nil, map[string]interface{}{}),
|
||||||
run: func(s *Server, userID string, _ json.RawMessage) (interface{}, error) {
|
run: func(s *Server, userID string, _ *apiActor, _ json.RawMessage) (interface{}, error) {
|
||||||
lists, err := s.store.GetAllListsForUser(userID)
|
lists, err := s.store.GetAllListsForUser(userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -586,7 +595,7 @@ var mcpTools = []mcpTool{
|
|||||||
"icon": prop("string", "One emoji, optional"),
|
"icon": prop("string", "One emoji, optional"),
|
||||||
"listType": map[string]interface{}{"type": "string", "enum": []string{"shopping", "checklist", "vacation"}, "description": "Default shopping"},
|
"listType": map[string]interface{}{"type": "string", "enum": []string{"shopping", "checklist", "vacation"}, "description": "Default shopping"},
|
||||||
}),
|
}),
|
||||||
run: func(s *Server, userID string, args json.RawMessage) (interface{}, error) {
|
run: func(s *Server, userID string, actor *apiActor, args json.RawMessage) (interface{}, error) {
|
||||||
var a struct {
|
var a struct {
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Icon string `json:"icon"`
|
Icon string `json:"icon"`
|
||||||
@@ -611,7 +620,7 @@ var mcpTools = []mcpTool{
|
|||||||
Description: "One list with all its items (id, content, category, quantity, isCompleted).",
|
Description: "One list with all its items (id, content, category, quantity, isCompleted).",
|
||||||
Scope: storage.ScopeListsRead,
|
Scope: storage.ScopeListsRead,
|
||||||
Schema: schema([]string{"listId"}, map[string]interface{}{"listId": prop("string", "List id from list_lists")}),
|
Schema: schema([]string{"listId"}, map[string]interface{}{"listId": prop("string", "List id from list_lists")}),
|
||||||
run: func(s *Server, userID string, args json.RawMessage) (interface{}, error) {
|
run: func(s *Server, userID string, actor *apiActor, args json.RawMessage) (interface{}, error) {
|
||||||
var a struct {
|
var a struct {
|
||||||
ListID string `json:"listId"`
|
ListID string `json:"listId"`
|
||||||
}
|
}
|
||||||
@@ -629,13 +638,14 @@ var mcpTools = []mcpTool{
|
|||||||
Name: "add_list_item",
|
Name: "add_list_item",
|
||||||
Description: "Add an item to a list the user may edit.",
|
Description: "Add an item to a list the user may edit.",
|
||||||
Scope: storage.ScopeListsWrite,
|
Scope: storage.ScopeListsWrite,
|
||||||
|
AltScope: storage.ScopeList,
|
||||||
Schema: schema([]string{"listId", "content"}, map[string]interface{}{
|
Schema: schema([]string{"listId", "content"}, map[string]interface{}{
|
||||||
"listId": prop("string", "List id"),
|
"listId": prop("string", "List id"),
|
||||||
"content": prop("string", "Item text"),
|
"content": prop("string", "Item text"),
|
||||||
"category": prop("string", "Category/aisle, e.g. Obst"),
|
"category": prop("string", "Category/aisle, e.g. Obst"),
|
||||||
"quantity": prop("string", "Amount, e.g. 2 or 500 g"),
|
"quantity": prop("string", "Amount, e.g. 2 or 500 g"),
|
||||||
}),
|
}),
|
||||||
run: func(s *Server, userID string, args json.RawMessage) (interface{}, error) {
|
run: func(s *Server, userID string, actor *apiActor, args json.RawMessage) (interface{}, error) {
|
||||||
var a struct {
|
var a struct {
|
||||||
ListID, Content, Category, Quantity string
|
ListID, Content, Category, Quantity string
|
||||||
}
|
}
|
||||||
@@ -645,7 +655,7 @@ var mcpTools = []mcpTool{
|
|||||||
if strings.TrimSpace(a.Content) == "" {
|
if strings.TrimSpace(a.Content) == "" {
|
||||||
return nil, errors.New("'content' darf nicht leer sein")
|
return nil, errors.New("'content' darf nicht leer sein")
|
||||||
}
|
}
|
||||||
return s.addListItem(userID, a.ListID, a.Content, a.Category, a.Quantity, true)
|
return s.addListItem(userID, a.ListID, a.Content, a.Category, a.Quantity, actor)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -657,7 +667,7 @@ var mcpTools = []mcpTool{
|
|||||||
"itemId": prop("string", "Item id from get_list"),
|
"itemId": prop("string", "Item id from get_list"),
|
||||||
"completed": prop("boolean", "Default true"),
|
"completed": prop("boolean", "Default true"),
|
||||||
}),
|
}),
|
||||||
run: func(s *Server, userID string, args json.RawMessage) (interface{}, error) {
|
run: func(s *Server, userID string, actor *apiActor, args json.RawMessage) (interface{}, error) {
|
||||||
var a struct {
|
var a struct {
|
||||||
ListID string `json:"listId"`
|
ListID string `json:"listId"`
|
||||||
ItemID string `json:"itemId"`
|
ItemID string `json:"itemId"`
|
||||||
@@ -666,7 +676,7 @@ var mcpTools = []mcpTool{
|
|||||||
if err := decodeArgs(args, &a); err != nil {
|
if err := decodeArgs(args, &a); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return s.setListItemDone(userID, a.ListID, a.ItemID, a.Completed == nil || *a.Completed, true)
|
return s.setListItemDone(userID, a.ListID, a.ItemID, a.Completed == nil || *a.Completed, actor)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -680,7 +690,7 @@ var mcpTools = []mcpTool{
|
|||||||
"category": prop("string", "New category"),
|
"category": prop("string", "New category"),
|
||||||
"quantity": prop("string", "New amount"),
|
"quantity": prop("string", "New amount"),
|
||||||
}),
|
}),
|
||||||
run: func(s *Server, userID string, args json.RawMessage) (interface{}, error) {
|
run: func(s *Server, userID string, actor *apiActor, args json.RawMessage) (interface{}, error) {
|
||||||
var a struct {
|
var a struct {
|
||||||
ListID string `json:"listId"`
|
ListID string `json:"listId"`
|
||||||
ItemID string `json:"itemId"`
|
ItemID string `json:"itemId"`
|
||||||
@@ -698,7 +708,7 @@ var mcpTools = []mcpTool{
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
item.AgentMark = s.store.SetListItemAgentMark(a.ItemID, true)
|
s.recordListItemOrigin(item, actor, false)
|
||||||
s.wsHub.BroadcastListEvent(a.ListID, "ITEM_UPDATED", item)
|
s.wsHub.BroadcastListEvent(a.ListID, "ITEM_UPDATED", item)
|
||||||
return item, nil
|
return item, nil
|
||||||
},
|
},
|
||||||
@@ -708,7 +718,7 @@ var mcpTools = []mcpTool{
|
|||||||
Description: "Full-text search over tasks and list items the user can see (notes are not included for API keys).",
|
Description: "Full-text search over tasks and list items the user can see (notes are not included for API keys).",
|
||||||
Scope: storage.ScopeTasksRead,
|
Scope: storage.ScopeTasksRead,
|
||||||
Schema: schema([]string{"query"}, map[string]interface{}{"query": prop("string", "Words to search for")}),
|
Schema: schema([]string{"query"}, map[string]interface{}{"query": prop("string", "Words to search for")}),
|
||||||
run: func(s *Server, userID string, args json.RawMessage) (interface{}, error) {
|
run: func(s *Server, userID string, actor *apiActor, args json.RawMessage) (interface{}, error) {
|
||||||
var a struct {
|
var a struct {
|
||||||
Query string `json:"query"`
|
Query string `json:"query"`
|
||||||
}
|
}
|
||||||
@@ -735,7 +745,7 @@ var mcpTools = []mcpTool{
|
|||||||
"priority": map[string]interface{}{"type": "string", "enum": []string{"low", "normal", "high", "urgent"}},
|
"priority": map[string]interface{}{"type": "string", "enum": []string{"low", "normal", "high", "urgent"}},
|
||||||
"dueDate": prop("string", "YYYY-MM-DD, optional"),
|
"dueDate": prop("string", "YYYY-MM-DD, optional"),
|
||||||
}),
|
}),
|
||||||
run: func(s *Server, userID string, args json.RawMessage) (interface{}, error) {
|
run: func(s *Server, userID string, actor *apiActor, args json.RawMessage) (interface{}, error) {
|
||||||
var a struct {
|
var a struct {
|
||||||
Content, Priority, DueDate string
|
Content, Priority, DueDate string
|
||||||
}
|
}
|
||||||
@@ -752,6 +762,7 @@ var mcpTools = []mcpTool{
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
s.recordThoughtOrigin(t, actor, true)
|
||||||
s.wsHub.BroadcastToUsers(s.taskAudience(t), "TASK_CREATED", t)
|
s.wsHub.BroadcastToUsers(s.taskAudience(t), "TASK_CREATED", t)
|
||||||
return map[string]interface{}{"id": t.ID, "content": t.Content, "status": fmt.Sprint(t.Status)}, nil
|
return map[string]interface{}{"id": t.ID, "content": t.Content, "status": fmt.Sprint(t.Status)}, nil
|
||||||
},
|
},
|
||||||
|
|||||||
+37
-24
@@ -197,13 +197,13 @@ func TestMCPTasksAndLists(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Agents can create task lists and lists and delete tasks; their entries carry a mark until the user
|
// Agents can create task lists and lists and delete tasks; what a key creates or changes keeps its
|
||||||
// changes them.
|
// origin for good, also after the user's own edits.
|
||||||
func TestMCPCreateDeleteAndAgentMark(t *testing.T) {
|
func TestMCPCreateDeleteAndOrigin(t *testing.T) {
|
||||||
srv, store, token := setupTestServer(t)
|
srv, store, token := setupTestServer(t)
|
||||||
router := srv.Router()
|
router := srv.Router()
|
||||||
claims, _ := auth.ValidateToken(testJWTSecret, token)
|
claims, _ := auth.ValidateToken(testJWTSecret, token)
|
||||||
key, _ := store.CreateAPIKey(claims.UserID, "Agent", []string{"tasks:read", "tasks:write", "lists:read", "lists:write"})
|
key, _ := store.CreateAPIKey(claims.UserID, "Claude Code", []string{"tasks:read", "tasks:write", "lists:read", "lists:write"})
|
||||||
call := func(id int, name, args string) map[string]interface{} {
|
call := func(id int, name, args string) map[string]interface{} {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
_, res := rpc(t, router, key.RawKey, fmt.Sprintf(`{"jsonrpc":"2.0","id":%d,"method":"tools/call","params":{"name":%q,"arguments":%s}}`, id, name, args))
|
_, res := rpc(t, router, key.RawKey, fmt.Sprintf(`{"jsonrpc":"2.0","id":%d,"method":"tools/call","params":{"name":%q,"arguments":%s}}`, id, name, args))
|
||||||
@@ -222,25 +222,34 @@ func TestMCPCreateDeleteAndAgentMark(t *testing.T) {
|
|||||||
task := call(2, "create_task", `{"content":"Von Claude","taskListId":"`+tlID+`"}`)
|
task := call(2, "create_task", `{"content":"Von Claude","taskListId":"`+tlID+`"}`)
|
||||||
taskID := task["id"].(string)
|
taskID := task["id"].(string)
|
||||||
th, _ := store.GetThoughtByID(claims.UserID, taskID)
|
th, _ := store.GetThoughtByID(claims.UserID, taskID)
|
||||||
if th.AgentMark != "created" || th.TaskListID != tlID {
|
if th.Origin == nil || th.Origin.CreatedVia != "mcp" || th.Origin.CreatedKey != "Claude Code" || th.TaskListID != tlID {
|
||||||
t.Fatalf("agent task should be marked created: %+v", th)
|
t.Fatalf("agent task should carry its origin: %+v", th.Origin)
|
||||||
}
|
|
||||||
// An agent's change keeps "created"; the user's own change removes the mark
|
|
||||||
call(3, "update_task", `{"id":"`+taskID+`","priority":"high"}`)
|
|
||||||
if th, _ = store.GetThoughtByID(claims.UserID, taskID); th.AgentMark != "created" {
|
|
||||||
t.Fatalf("mark after agent edit: %q", th.AgentMark)
|
|
||||||
}
|
}
|
||||||
|
// The user's own edit keeps the origin
|
||||||
notes := "selbst ergänzt"
|
notes := "selbst ergänzt"
|
||||||
if _, err := srv.updateTask(claims.UserID, taskID, taskPatch{Notes: ¬es}); err != nil {
|
if _, err := srv.updateTask(claims.UserID, taskID, taskPatch{Notes: ¬es}); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if th, _ = store.GetThoughtByID(claims.UserID, taskID); th.AgentMark != "" {
|
if th, _ = store.GetThoughtByID(claims.UserID, taskID); th.Origin == nil || th.Origin.CreatedKey != "Claude Code" || th.Origin.ChangedVia != "" {
|
||||||
t.Fatalf("user edit should clear the mark, got %q", th.AgentMark)
|
t.Fatalf("user edit must keep the origin and not count as a key change: %+v", th.Origin)
|
||||||
}
|
}
|
||||||
call(4, "complete_task", `{"id":"`+taskID+`"}`)
|
|
||||||
if th, _ = store.GetThoughtByID(claims.UserID, taskID); th.AgentMark != "edited" {
|
// A task of the user changed by the agent: changed via MCP, with key and time
|
||||||
t.Fatalf("agent edit of a user task should mark edited, got %q", th.AgentMark)
|
own, _ := srv.createTask(claims.UserID, "Von mir", "", "manual", "", "", "", nil)
|
||||||
|
if own.Origin != nil {
|
||||||
|
t.Fatalf("own task must not have an origin: %+v", own.Origin)
|
||||||
}
|
}
|
||||||
|
call(3, "complete_task", `{"id":"`+own.ID+`"}`)
|
||||||
|
if th, _ = store.GetThoughtByID(claims.UserID, own.ID); th.Origin == nil || th.Origin.CreatedVia != "" ||
|
||||||
|
th.Origin.ChangedVia != "mcp" || th.Origin.ChangedKey != "Claude Code" || th.Origin.ChangedAt == "" {
|
||||||
|
t.Fatalf("agent change of a user task: %+v", th.Origin)
|
||||||
|
}
|
||||||
|
// Renamed or deleted keys show up as such
|
||||||
|
_ = store.DeleteAPIKey(claims.UserID, key.ID)
|
||||||
|
if th, _ = store.GetThoughtByID(claims.UserID, own.ID); th.Origin.ChangedKey != "Gelöschter Schlüssel" {
|
||||||
|
t.Fatalf("deleted key name: %q", th.Origin.ChangedKey)
|
||||||
|
}
|
||||||
|
key, _ = store.CreateAPIKey(claims.UserID, "Claude Code", []string{"tasks:read", "tasks:write", "lists:read", "lists:write"})
|
||||||
|
|
||||||
call(5, "delete_task", `{"id":"`+taskID+`"}`)
|
call(5, "delete_task", `{"id":"`+taskID+`"}`)
|
||||||
if th, err := store.GetThoughtByID(claims.UserID, taskID); err == nil && th != nil {
|
if th, err := store.GetThoughtByID(claims.UserID, taskID); err == nil && th != nil {
|
||||||
@@ -253,18 +262,15 @@ func TestMCPCreateDeleteAndAgentMark(t *testing.T) {
|
|||||||
t.Fatalf("create_list: %v", l)
|
t.Fatalf("create_list: %v", l)
|
||||||
}
|
}
|
||||||
item := call(7, "add_list_item", `{"listId":"`+listID+`","content":"Zahnbürste"}`)
|
item := call(7, "add_list_item", `{"listId":"`+listID+`","content":"Zahnbürste"}`)
|
||||||
if item["agentMark"] != "created" {
|
if o, _ := item["origin"].(map[string]interface{}); o == nil || o["createdVia"] != "mcp" {
|
||||||
t.Fatalf("agent item should be marked created: %v", item)
|
t.Fatalf("agent item should carry its origin: %v", item)
|
||||||
}
|
}
|
||||||
items, _ := store.GetListItems(listID)
|
items, _ := store.GetListItems(listID)
|
||||||
if len(items) != 1 || items[0].AgentMark != "created" {
|
if _, err := srv.setListItemDone(claims.UserID, listID, items[0].ID, true, nil); err != nil {
|
||||||
t.Fatalf("stored item mark: %+v", items)
|
|
||||||
}
|
|
||||||
if _, err := srv.setListItemDone(claims.UserID, listID, items[0].ID, true, false); err != nil {
|
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if items, _ = store.GetListItems(listID); items[0].AgentMark != "" {
|
if items, _ = store.GetListItems(listID); items[0].Origin == nil || items[0].Origin.CreatedKey != "Claude Code" {
|
||||||
t.Fatalf("ticking by the user should clear the mark: %q", items[0].AgentMark)
|
t.Fatalf("ticking by the user keeps the origin: %+v", items[0].Origin)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Other people's tasks cannot be deleted
|
// Other people's tasks cannot be deleted
|
||||||
@@ -274,4 +280,11 @@ func TestMCPCreateDeleteAndAgentMark(t *testing.T) {
|
|||||||
if _, isErr := callResult(t, res); !isErr {
|
if _, isErr := callResult(t, res); !isErr {
|
||||||
t.Fatalf("key must not delete foreign tasks")
|
t.Fatalf("key must not delete foreign tasks")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The narrow right "create tasks" also allows create_task over MCP, but not update_task
|
||||||
|
creator, _ := store.CreateAPIKey(claims.UserID, "Nur anlegen", []string{"webhook:task"})
|
||||||
|
_, res = rpc(t, router, creator.RawKey, `{"jsonrpc":"2.0","id":9,"method":"tools/list"}`)
|
||||||
|
if names := strings.Join(toolNames(res), ","); !strings.Contains(names, "create_task") || strings.Contains(names, "update_task") {
|
||||||
|
t.Fatalf("webhook:task key tools: %s", names)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -28,7 +28,7 @@ func (s *Server) authenticateUserFromReq(r *http.Request) string {
|
|||||||
if authHdr := r.Header.Get("Authorization"); strings.HasPrefix(authHdr, "Bearer ") {
|
if authHdr := r.Header.Get("Authorization"); strings.HasPrefix(authHdr, "Bearer ") {
|
||||||
token = strings.TrimPrefix(authHdr, "Bearer ")
|
token = strings.TrimPrefix(authHdr, "Bearer ")
|
||||||
}
|
}
|
||||||
if user, keyScopes := s.authenticateToken(token); user != nil && keyScopes == nil {
|
if user, key := s.authenticateToken(token); user != nil && key == nil {
|
||||||
return user.ID
|
return user.ID
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
|
|||||||
+15
-48
@@ -1,7 +1,6 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
@@ -454,21 +453,19 @@ 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
|
// authenticateToken checks a session token or an API key. key is nil for sessions and holds the key's
|
||||||
// checked against the revocation list and token_version; viaAPIKey tells the caller which it was.
|
// id and scopes for API keys. JWTs are checked against the revocation list and token_version.
|
||||||
// authenticateToken checks a session token or an API key. keyScopes is nil for sessions and the key's
|
func (s *Server) authenticateToken(token string) (user *domain.User, key *apiKeyAuth) {
|
||||||
// scopes for API keys.
|
|
||||||
func (s *Server) authenticateToken(token string) (user *domain.User, keyScopes []string) {
|
|
||||||
token = strings.TrimSpace(token)
|
token = strings.TrimSpace(token)
|
||||||
if token == "" {
|
if token == "" {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
if strings.HasPrefix(token, "mindsift_sec_") {
|
if strings.HasPrefix(token, "mindsift_sec_") {
|
||||||
u, scopes, err := s.store.ValidateAPIKey(token)
|
u, scopes, keyID, err := s.store.ValidateAPIKey(token)
|
||||||
if err != nil || u == nil || !u.IsActive {
|
if err != nil || u == nil || !u.IsActive {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
return u, scopes
|
return u, &apiKeyAuth{ID: keyID, Scopes: scopes}
|
||||||
}
|
}
|
||||||
claims, err := auth.ValidateToken(s.cfg.JWTSecret, token)
|
claims, err := auth.ValidateToken(s.cfg.JWTSecret, token)
|
||||||
if err != nil || claims == nil {
|
if err != nil || claims == nil {
|
||||||
@@ -488,40 +485,6 @@ func (s *Server) authenticateToken(token string) (user *domain.User, keyScopes [
|
|||||||
return u, nil
|
return u, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
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 delivers data into MindSift (POST /api/v1/inbox; /api/v1/webhooks/ingest
|
|
||||||
// authenticates on its own) or talks to the MCP endpoint for AI agents; what it may do there is decided
|
|
||||||
// by its scopes (requireDeliveryScope, mcpToolAllowed). Everything else needs a login.
|
|
||||||
func apiKeyAllowed(r *http.Request) bool {
|
|
||||||
return r.Method == http.MethodPost && (r.URL.Path == "/api/v1/inbox" || r.URL.Path == mcpPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) authMiddleware(next http.Handler) http.Handler {
|
func (s *Server) authMiddleware(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
token := ""
|
token := ""
|
||||||
@@ -530,24 +493,28 @@ func (s *Server) authMiddleware(next http.Handler) http.Handler {
|
|||||||
} else {
|
} else {
|
||||||
token = r.Header.Get("X-API-Key")
|
token = r.Header.Get("X-API-Key")
|
||||||
}
|
}
|
||||||
user, keyScopes := s.authenticateToken(token)
|
user, key := s.authenticateToken(token)
|
||||||
if user == nil {
|
if user == nil {
|
||||||
writeError(w, http.StatusUnauthorized, "Nicht autorisiert – bitte anmelden")
|
writeError(w, http.StatusUnauthorized, "Nicht autorisiert – bitte anmelden")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
ctx := auth.WithUserID(r.Context(), user.ID)
|
ctx := auth.WithUserID(r.Context(), user.ID)
|
||||||
if keyScopes == nil {
|
if key == nil {
|
||||||
if claims, err := auth.ValidateToken(s.cfg.JWTSecret, token); err == nil {
|
if claims, err := auth.ValidateToken(s.cfg.JWTSecret, token); err == nil {
|
||||||
ctx = withSession(ctx, claims.ID)
|
ctx = withSession(ctx, claims.ID)
|
||||||
s.renewSessionToken(w, user, claims, false)
|
s.renewSessionToken(w, user, claims, false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if keyScopes != nil {
|
if key != nil {
|
||||||
if !apiKeyAllowed(r) {
|
if msg := apiKeyRouteDenied(r, key.Scopes); msg != "" {
|
||||||
writeError(w, http.StatusForbidden, "API-Keys dürfen nur Daten einliefern (POST /api/v1/inbox oder /api/v1/webhooks/ingest) oder den MCP-Endpunkt nutzen (POST /api/v1/mcp)")
|
writeError(w, http.StatusForbidden, msg)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
ctx = context.WithValue(ctx, apiKeyScopesKey{}, keyScopes)
|
via := storage.OriginAPI
|
||||||
|
if r.URL.Path == mcpPath {
|
||||||
|
via = storage.OriginMCP
|
||||||
|
}
|
||||||
|
ctx = withAPIKey(ctx, key, via)
|
||||||
}
|
}
|
||||||
next.ServeHTTP(w, r.WithContext(ctx))
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -268,7 +268,17 @@ type Thought struct {
|
|||||||
SortOrder int `json:"sortOrder"`
|
SortOrder int `json:"sortOrder"`
|
||||||
CreatedAt string `json:"createdAt"`
|
CreatedAt string `json:"createdAt"`
|
||||||
TriagedAt string `json:"triagedAt,omitempty"`
|
TriagedAt string `json:"triagedAt,omitempty"`
|
||||||
AgentMark string `json:"agentMark,omitempty"` // created | edited: last change via MCP, untouched by the user since
|
Origin *Origin `json:"origin,omitempty"` // API key that created / last changed it
|
||||||
|
}
|
||||||
|
|
||||||
|
// Origin: which API key created an entry and which one changed it last, over which access ("api" for
|
||||||
|
// REST, "mcp"). Key names are resolved when reading; it stays when the user edits the entry.
|
||||||
|
type Origin struct {
|
||||||
|
CreatedVia string `json:"createdVia,omitempty"`
|
||||||
|
CreatedKey string `json:"createdKey,omitempty"`
|
||||||
|
ChangedVia string `json:"changedVia,omitempty"`
|
||||||
|
ChangedKey string `json:"changedKey,omitempty"`
|
||||||
|
ChangedAt string `json:"changedAt,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ThoughtReorderItem struct {
|
type ThoughtReorderItem struct {
|
||||||
@@ -571,7 +581,7 @@ type ListItem struct {
|
|||||||
SortOrder int `json:"sortOrder"`
|
SortOrder int `json:"sortOrder"`
|
||||||
CreatedAt string `json:"createdAt"`
|
CreatedAt string `json:"createdAt"`
|
||||||
CompletedAt string `json:"completedAt,omitempty"`
|
CompletedAt string `json:"completedAt,omitempty"`
|
||||||
AgentMark string `json:"agentMark,omitempty"` // created | edited: last change via MCP, untouched by the user since
|
Origin *Origin `json:"origin,omitempty"` // API key that created / last changed it
|
||||||
}
|
}
|
||||||
|
|
||||||
type SharedList struct {
|
type SharedList struct {
|
||||||
|
|||||||
+14
-14
@@ -10,18 +10,18 @@ import (
|
|||||||
|
|
||||||
// --- API KEYS & WEBHOOKS ---
|
// --- API KEYS & WEBHOOKS ---
|
||||||
|
|
||||||
// Scopes of an API key. The webhook scopes deliver data (inbox, /webhooks/ingest); the agent scopes
|
// Rights of an API key. They hold the same over every access: REST (curl, smart home) and the MCP
|
||||||
// let AI agents read and change tasks and lists over the MCP endpoint. Notes are never reachable with
|
// endpoint for AI agents. The ids of the first three come from the time they only delivered data
|
||||||
// a key.
|
// (webhooks) and stay for existing keys. Notes are never reachable with a key.
|
||||||
const (
|
const (
|
||||||
ScopeTriage = "webhook:triage" // thoughts into the inbox
|
ScopeTriage = "webhook:triage" // thoughts into the inbox
|
||||||
ScopeTask = "webhook:task" // tasks directly
|
ScopeTask = "webhook:task" // create tasks
|
||||||
ScopeList = "webhook:list" // items into lists
|
ScopeList = "webhook:list" // add items to lists
|
||||||
|
|
||||||
ScopeTasksRead = "tasks:read" // MCP: task lists and tasks
|
ScopeTasksRead = "tasks:read" // task lists and tasks
|
||||||
ScopeTasksWrite = "tasks:write" // MCP: create, change and complete tasks
|
ScopeTasksWrite = "tasks:write" // create, change, complete and delete tasks
|
||||||
ScopeListsRead = "lists:read" // MCP: lists and their items
|
ScopeListsRead = "lists:read" // lists and their items
|
||||||
ScopeListsWrite = "lists:write" // MCP: add, change and check off items
|
ScopeListsWrite = "lists:write" // add, change and check off items
|
||||||
)
|
)
|
||||||
|
|
||||||
// AllScopes in display order.
|
// AllScopes in display order.
|
||||||
@@ -99,19 +99,19 @@ func (s *Store) DeleteAPIKey(userID, keyID string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) ValidateAPIKeyAndGetUser(rawKey string) (*domain.User, error) {
|
func (s *Store) ValidateAPIKeyAndGetUser(rawKey string) (*domain.User, error) {
|
||||||
u, _, err := s.ValidateAPIKey(rawKey)
|
u, _, _, err := s.ValidateAPIKey(rawKey)
|
||||||
return u, err
|
return u, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// ValidateAPIKey returns the owner of a key and the key's scopes.
|
// ValidateAPIKey returns the owner of a key, the key's scopes and its id.
|
||||||
func (s *Store) ValidateAPIKey(rawKey string) (*domain.User, []string, error) {
|
func (s *Store) ValidateAPIKey(rawKey string) (*domain.User, []string, string, error) {
|
||||||
hash := auth.HashAPIKey(strings.TrimSpace(rawKey))
|
hash := auth.HashAPIKey(strings.TrimSpace(rawKey))
|
||||||
var keyID, userID, scopes string
|
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)
|
err := s.db.QueryRow(`SELECT id, user_id, scopes FROM api_keys WHERE key_hash = ?`, hash).Scan(&keyID, &userID, &scopes)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, 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)
|
_, _ = s.db.Exec(`UPDATE api_keys SET last_used_at = ? WHERE id = ?`, domain.NowISO(), keyID)
|
||||||
u, err := s.GetUserByID(userID)
|
u, err := s.GetUserByID(userID)
|
||||||
return u, NormalizeScopes(strings.Split(scopes, ",")), err
|
return u, NormalizeScopes(strings.Split(scopes, ",")), keyID, err
|
||||||
}
|
}
|
||||||
@@ -483,7 +483,7 @@ func (s *Store) ReconcileDocTaskLinks(docID string) []string {
|
|||||||
// GetThoughtByIDUnchecked loads a task without access check, for server-internal notifications.
|
// GetThoughtByIDUnchecked loads a task without access check, for server-internal notifications.
|
||||||
func (s *Store) GetThoughtByIDUnchecked(thoughtID string) (*domain.Thought, error) {
|
func (s *Store) GetThoughtByIDUnchecked(thoughtID string) (*domain.Thought, error) {
|
||||||
return scanThought(s.db.QueryRow(
|
return scanThought(s.db.QueryRow(
|
||||||
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order, recurrence, agent_mark
|
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order, recurrence, `+originColumns("")+`
|
||||||
FROM thoughts WHERE id = ?`, thoughtID))
|
FROM thoughts WHERE id = ?`, thoughtID))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -303,7 +303,7 @@ func (s *Store) DeleteList(userID, listID string) error {
|
|||||||
func (s *Store) GetListItems(listID string) ([]domain.ListItem, error) {
|
func (s *Store) GetListItems(listID string) ([]domain.ListItem, error) {
|
||||||
rows, err := s.db.Query(
|
rows, err := s.db.Query(
|
||||||
`SELECT li.id, li.list_id, li.content, li.category, li.quantity, li.is_completed, li.completed_by, li.completed_by_name,
|
`SELECT li.id, li.list_id, li.content, li.category, li.quantity, li.is_completed, li.completed_by, li.completed_by_name,
|
||||||
li.created_by, COALESCE(u.display_name, ''), li.sort_order, li.created_at, li.completed_at, li.agent_mark
|
li.created_by, COALESCE(u.display_name, ''), li.sort_order, li.created_at, li.completed_at, `+originColumns("li.")+`
|
||||||
FROM list_items li LEFT JOIN users u ON u.id = li.created_by AND li.created_by != ''
|
FROM list_items li LEFT JOIN users u ON u.id = li.created_by AND li.created_by != ''
|
||||||
WHERE li.list_id = ?
|
WHERE li.list_id = ?
|
||||||
ORDER BY li.is_completed ASC, li.sort_order ASC, li.created_at DESC`,
|
ORDER BY li.is_completed ASC, li.sort_order ASC, li.created_at DESC`,
|
||||||
@@ -318,8 +318,10 @@ func (s *Store) GetListItems(listID string) ([]domain.ListItem, error) {
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var it domain.ListItem
|
var it domain.ListItem
|
||||||
var doneInt int
|
var doneInt int
|
||||||
if err := rows.Scan(&it.ID, &it.ListID, &it.Content, &it.Category, &it.Quantity, &doneInt, &it.CompletedBy, &it.CompletedByName, &it.CreatedBy, &it.CreatedByName, &it.SortOrder, &it.CreatedAt, &it.CompletedAt, &it.AgentMark); err == nil {
|
var o originDest
|
||||||
|
if err := rows.Scan(&it.ID, &it.ListID, &it.Content, &it.Category, &it.Quantity, &doneInt, &it.CompletedBy, &it.CompletedByName, &it.CreatedBy, &it.CreatedByName, &it.SortOrder, &it.CreatedAt, &it.CompletedAt, &o.createdVia, &o.createdKey, &o.changedVia, &o.changedKey, &o.changedAt); err == nil {
|
||||||
it.IsCompleted = doneInt != 0
|
it.IsCompleted = doneInt != 0
|
||||||
|
it.Origin = o.result()
|
||||||
items = append(items, it)
|
items = append(items, it)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -399,15 +401,18 @@ func (s *Store) ToggleListItem(itemID string, isCompleted bool, userID, userName
|
|||||||
|
|
||||||
var it domain.ListItem
|
var it domain.ListItem
|
||||||
var dInt int
|
var dInt int
|
||||||
|
var o originDest
|
||||||
err = s.db.QueryRow(
|
err = s.db.QueryRow(
|
||||||
`SELECT id, list_id, content, category, quantity, is_completed, completed_by, completed_by_name, sort_order, created_at, completed_at
|
`SELECT id, list_id, content, category, quantity, is_completed, completed_by, completed_by_name, sort_order, created_at, completed_at, `+originColumns("")+`
|
||||||
FROM list_items WHERE id = ?`,
|
FROM list_items WHERE id = ?`,
|
||||||
itemID,
|
itemID,
|
||||||
).Scan(&it.ID, &it.ListID, &it.Content, &it.Category, &it.Quantity, &dInt, &it.CompletedBy, &it.CompletedByName, &it.SortOrder, &it.CreatedAt, &it.CompletedAt)
|
).Scan(&it.ID, &it.ListID, &it.Content, &it.Category, &it.Quantity, &dInt, &it.CompletedBy, &it.CompletedByName, &it.SortOrder, &it.CreatedAt, &it.CompletedAt,
|
||||||
|
&o.createdVia, &o.createdKey, &o.changedVia, &o.changedKey, &o.changedAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
it.IsCompleted = dInt != 0
|
it.IsCompleted = dInt != 0
|
||||||
|
it.Origin = o.result()
|
||||||
_, _ = s.db.Exec(`UPDATE lists SET updated_at = ? WHERE id = ?`, now, it.ListID)
|
_, _ = s.db.Exec(`UPDATE lists SET updated_at = ? WHERE id = ?`, now, it.ListID)
|
||||||
return &it, nil
|
return &it, nil
|
||||||
}
|
}
|
||||||
@@ -416,15 +421,18 @@ func (s *Store) UpdateListItem(itemID string, content *string, category *string,
|
|||||||
now := domain.NowISO()
|
now := domain.NowISO()
|
||||||
var it domain.ListItem
|
var it domain.ListItem
|
||||||
var dInt int
|
var dInt int
|
||||||
|
var o originDest
|
||||||
err := s.db.QueryRow(
|
err := s.db.QueryRow(
|
||||||
`SELECT id, list_id, content, category, quantity, is_completed, completed_by, completed_by_name, sort_order, created_at, completed_at
|
`SELECT id, list_id, content, category, quantity, is_completed, completed_by, completed_by_name, sort_order, created_at, completed_at, `+originColumns("")+`
|
||||||
FROM list_items WHERE id = ?`,
|
FROM list_items WHERE id = ?`,
|
||||||
itemID,
|
itemID,
|
||||||
).Scan(&it.ID, &it.ListID, &it.Content, &it.Category, &it.Quantity, &dInt, &it.CompletedBy, &it.CompletedByName, &it.SortOrder, &it.CreatedAt, &it.CompletedAt)
|
).Scan(&it.ID, &it.ListID, &it.Content, &it.Category, &it.Quantity, &dInt, &it.CompletedBy, &it.CompletedByName, &it.SortOrder, &it.CreatedAt, &it.CompletedAt,
|
||||||
|
&o.createdVia, &o.createdKey, &o.changedVia, &o.changedKey, &o.changedAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
it.IsCompleted = dInt != 0
|
it.IsCompleted = dInt != 0
|
||||||
|
it.Origin = o.result()
|
||||||
|
|
||||||
if content != nil {
|
if content != nil {
|
||||||
it.Content = *content
|
it.Content = *content
|
||||||
@@ -459,17 +467,19 @@ func (s *Store) MoveListItem(itemID, targetListID string) (*domain.ListItem, err
|
|||||||
|
|
||||||
var it domain.ListItem
|
var it domain.ListItem
|
||||||
var dInt int
|
var dInt int
|
||||||
|
var o originDest
|
||||||
err := s.db.QueryRow(
|
err := s.db.QueryRow(
|
||||||
`SELECT li.id, li.list_id, li.content, li.category, li.quantity, li.is_completed, li.completed_by, li.completed_by_name,
|
`SELECT li.id, li.list_id, li.content, li.category, li.quantity, li.is_completed, li.completed_by, li.completed_by_name,
|
||||||
li.created_by, COALESCE(u.display_name, ''), li.sort_order, li.created_at, li.completed_at, li.agent_mark
|
li.created_by, COALESCE(u.display_name, ''), li.sort_order, li.created_at, li.completed_at, `+originColumns("li.")+`
|
||||||
FROM list_items li LEFT JOIN users u ON u.id = li.created_by AND li.created_by != ''
|
FROM list_items li LEFT JOIN users u ON u.id = li.created_by AND li.created_by != ''
|
||||||
WHERE li.id = ?`,
|
WHERE li.id = ?`,
|
||||||
itemID,
|
itemID,
|
||||||
).Scan(&it.ID, &it.ListID, &it.Content, &it.Category, &it.Quantity, &dInt, &it.CompletedBy, &it.CompletedByName, &it.CreatedBy, &it.CreatedByName, &it.SortOrder, &it.CreatedAt, &it.CompletedAt, &it.AgentMark)
|
).Scan(&it.ID, &it.ListID, &it.Content, &it.Category, &it.Quantity, &dInt, &it.CompletedBy, &it.CompletedByName, &it.CreatedBy, &it.CreatedByName, &it.SortOrder, &it.CreatedAt, &it.CompletedAt, &o.createdVia, &o.createdKey, &o.changedVia, &o.changedKey, &o.changedAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
it.IsCompleted = dInt != 0
|
it.IsCompleted = dInt != 0
|
||||||
|
it.Origin = o.result()
|
||||||
return &it, nil
|
return &it, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ var migrations = []migration{
|
|||||||
{10, "Registrierung und Einladungslinks", (*Store).migrateRegistration},
|
{10, "Registrierung und Einladungslinks", (*Store).migrateRegistration},
|
||||||
{11, "E-Mail-Bestätigung", (*Store).migrateEmailVerification},
|
{11, "E-Mail-Bestätigung", (*Store).migrateEmailVerification},
|
||||||
{12, "Markierung für Einträge von KI-Agenten", (*Store).migrateAgentMark},
|
{12, "Markierung für Einträge von KI-Agenten", (*Store).migrateAgentMark},
|
||||||
|
{13, "Herkunft von Einträgen (API-Schlüssel)", (*Store).migrateOrigin},
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) migrate() error {
|
func (s *Store) migrate() error {
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/sandrodach/mindsift/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Origin of tasks, thoughts and list items: which API key created an entry and which one changed it
|
||||||
|
// last, and over which access (REST "api" or "mcp"). Both stay for good, also after the user edits the
|
||||||
|
// entry. Key names are resolved when reading, so renaming a key shows everywhere at once.
|
||||||
|
|
||||||
|
const (
|
||||||
|
OriginAPI = "api" // REST: /inbox, /webhooks/ingest and the other endpoints a key may use
|
||||||
|
OriginMCP = "mcp"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Tables with origin columns; RecordOrigin only takes these
|
||||||
|
const (
|
||||||
|
OriginTableThoughts = "thoughts"
|
||||||
|
OriginTableListItems = "list_items"
|
||||||
|
)
|
||||||
|
|
||||||
|
// migrateOrigin replaces agent_mark (migration 12, which the user's own edit cleared) with the
|
||||||
|
// permanent origin. Old marks and the sources webhook/agent are carried over without a key.
|
||||||
|
func (s *Store) migrateOrigin() error {
|
||||||
|
for _, table := range []string{OriginTableThoughts, OriginTableListItems} {
|
||||||
|
for _, col := range []string{"created_via", "created_key_id", "changed_via", "changed_key_id", "changed_at"} {
|
||||||
|
if err := s.addColumnIfMissing(table, col, "TEXT NOT NULL DEFAULT ''"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s.execInTx(
|
||||||
|
stmt(`UPDATE thoughts SET created_via = 'api' WHERE created_via = '' AND source = 'webhook'`),
|
||||||
|
stmt(`UPDATE thoughts SET created_via = 'mcp' WHERE created_via = '' AND source = 'agent'`),
|
||||||
|
stmt(`UPDATE thoughts SET changed_via = 'mcp' WHERE changed_via = '' AND agent_mark = 'edited'`),
|
||||||
|
stmt(`UPDATE list_items SET created_via = 'mcp' WHERE created_via = '' AND agent_mark = 'created'`),
|
||||||
|
stmt(`UPDATE list_items SET changed_via = 'mcp' WHERE changed_via = '' AND agent_mark = 'edited'`),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// originColumns: the five origin values of a SELECT, key ids resolved to names (prefix e.g. "li." for
|
||||||
|
// an aliased table). Scan them with scanOrigin.
|
||||||
|
func originColumns(prefix string) string {
|
||||||
|
keyName := func(col string) string {
|
||||||
|
return `CASE WHEN ` + prefix + col + ` = '' THEN '' ELSE COALESCE((SELECT k.name FROM api_keys k WHERE k.id = ` +
|
||||||
|
prefix + col + `), 'Gelöschter Schlüssel') END`
|
||||||
|
}
|
||||||
|
return prefix + `created_via, ` + keyName("created_key_id") + `, ` + prefix + `changed_via, ` + keyName("changed_key_id") + `, ` + prefix + `changed_at`
|
||||||
|
}
|
||||||
|
|
||||||
|
// originDest holds the scanned origin columns; result is nil for entries no key ever touched.
|
||||||
|
type originDest struct{ createdVia, createdKey, changedVia, changedKey, changedAt string }
|
||||||
|
|
||||||
|
func (o *originDest) targets() []any {
|
||||||
|
return []any{&o.createdVia, &o.createdKey, &o.changedVia, &o.changedKey, &o.changedAt}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *originDest) result() *domain.Origin {
|
||||||
|
if o.createdVia == "" && o.changedVia == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &domain.Origin{CreatedVia: o.createdVia, CreatedKey: o.createdKey, ChangedVia: o.changedVia, ChangedKey: o.changedKey, ChangedAt: o.changedAt}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordOrigin notes that an API key created (created) or changed an entry and returns the entry's
|
||||||
|
// origin as it is now. via is OriginAPI or OriginMCP; keyID may be empty (MCP from a login session).
|
||||||
|
func (s *Store) RecordOrigin(table, id, via, keyID string, created bool) *domain.Origin {
|
||||||
|
if table != OriginTableThoughts && table != OriginTableListItems {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if created {
|
||||||
|
_, _ = s.db.Exec(`UPDATE `+table+` SET created_via = ?, created_key_id = ? WHERE id = ?`, via, keyID, id)
|
||||||
|
} else {
|
||||||
|
_, _ = s.db.Exec(`UPDATE `+table+` SET changed_via = ?, changed_key_id = ?, changed_at = ? WHERE id = ?`, via, keyID, domain.NowISO(), id)
|
||||||
|
}
|
||||||
|
return s.Origin(table, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Origin reads the origin of one entry (nil if no key touched it).
|
||||||
|
func (s *Store) Origin(table, id string) *domain.Origin {
|
||||||
|
if table != OriginTableThoughts && table != OriginTableListItems {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var o originDest
|
||||||
|
if err := s.db.QueryRow(`SELECT `+originColumns("")+` FROM `+table+` WHERE id = ?`, id).Scan(o.targets()...); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return o.result()
|
||||||
|
}
|
||||||
|
|
||||||
|
// copyOriginToListItem: a thought turned into a list item in the triage keeps where it came from.
|
||||||
|
func (s *Store) copyOriginToListItem(thoughtID, itemID string) {
|
||||||
|
cols := []string{"created_via", "created_key_id", "changed_via", "changed_key_id", "changed_at"}
|
||||||
|
set := make([]string, len(cols))
|
||||||
|
for i, c := range cols {
|
||||||
|
set[i] = c + ` = (SELECT ` + c + ` FROM thoughts WHERE id = ?)`
|
||||||
|
}
|
||||||
|
args := []any{}
|
||||||
|
for range cols {
|
||||||
|
args = append(args, thoughtID)
|
||||||
|
}
|
||||||
|
args = append(args, itemID)
|
||||||
|
_, _ = s.db.Exec(`UPDATE list_items SET `+strings.Join(set, ", ")+` WHERE id = ?`, args...)
|
||||||
|
}
|
||||||
@@ -72,7 +72,7 @@ func (s *Store) SetTaskRecurrence(userID, taskID, rule string) error {
|
|||||||
|
|
||||||
func (s *Store) getThoughtRaw(id string) (*domain.Thought, error) {
|
func (s *Store) getThoughtRaw(id string) (*domain.Thought, error) {
|
||||||
t, err := scanThought(s.db.QueryRow(
|
t, err := scanThought(s.db.QueryRow(
|
||||||
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order, recurrence, agent_mark
|
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order, recurrence, `+originColumns("")+`
|
||||||
FROM thoughts WHERE id = ?`, id))
|
FROM thoughts WHERE id = ?`, id))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -76,14 +76,11 @@ func (s *Store) CreateThoughtWithDetails(userID, content, priority, source, dueD
|
|||||||
Notes: notes,
|
Notes: notes,
|
||||||
CreatedAt: domain.NowISO(),
|
CreatedAt: domain.NowISO(),
|
||||||
}
|
}
|
||||||
if source == "agent" {
|
|
||||||
t.AgentMark = AgentMarkCreated
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err := s.db.Exec(
|
_, err := s.db.Exec(
|
||||||
`INSERT INTO thoughts (id, user_id, content, tags, priority, source, status, is_completed, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, subtasks, agent_mark)
|
`INSERT INTO thoughts (id, user_id, content, tags, priority, source, status, is_completed, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, subtasks)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, 0, ?, '[]', ?)`,
|
VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, 0, ?, '[]')`,
|
||||||
t.ID, t.UserID, t.Content, string(tagsJSON), t.Priority, t.Source, string(t.Status), t.DueDate, t.TaskListID, t.KanbanStatus, t.Notes, t.CreatedAt, t.AgentMark,
|
t.ID, t.UserID, t.Content, string(tagsJSON), t.Priority, t.Source, string(t.Status), t.DueDate, t.TaskListID, t.KanbanStatus, t.Notes, t.CreatedAt,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -98,16 +95,19 @@ func scanThought(rows interface {
|
|||||||
t := &domain.Thought{}
|
t := &domain.Thought{}
|
||||||
var tagsStr, subtasksStr, statusStr string
|
var tagsStr, subtasksStr, statusStr string
|
||||||
var isCompleted, alarmSent, sortOrder int
|
var isCompleted, alarmSent, sortOrder int
|
||||||
|
var origin originDest
|
||||||
err := rows.Scan(
|
err := rows.Scan(
|
||||||
&t.ID, &t.UserID, &t.Content, &tagsStr, &t.Priority, &t.Source,
|
&t.ID, &t.UserID, &t.Content, &tagsStr, &t.Priority, &t.Source,
|
||||||
&statusStr, &isCompleted, &t.ConvertedType, &t.ConvertedRefID,
|
&statusStr, &isCompleted, &t.ConvertedType, &t.ConvertedRefID,
|
||||||
&t.DueDate, &t.TaskListID, &t.KanbanStatus, &t.Notes, &alarmSent,
|
&t.DueDate, &t.TaskListID, &t.KanbanStatus, &t.Notes, &alarmSent,
|
||||||
&t.CreatedAt, &t.TriagedAt, &subtasksStr, &sortOrder, &t.Recurrence, &t.AgentMark,
|
&t.CreatedAt, &t.TriagedAt, &subtasksStr, &sortOrder, &t.Recurrence,
|
||||||
|
&origin.createdVia, &origin.createdKey, &origin.changedVia, &origin.changedKey, &origin.changedAt,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
t.SortOrder = sortOrder
|
t.SortOrder = sortOrder
|
||||||
|
t.Origin = origin.result()
|
||||||
t.Status = domain.ThoughtStatus(statusStr)
|
t.Status = domain.ThoughtStatus(statusStr)
|
||||||
t.IsCompleted = isCompleted != 0
|
t.IsCompleted = isCompleted != 0
|
||||||
t.AlarmSent = alarmSent != 0
|
t.AlarmSent = alarmSent != 0
|
||||||
@@ -139,7 +139,7 @@ func (s *Store) accessibleTaskListIDsQuery() string {
|
|||||||
|
|
||||||
func (s *Store) GetThoughtByID(userID, thoughtID string) (*domain.Thought, error) {
|
func (s *Store) GetThoughtByID(userID, thoughtID string) (*domain.Thought, error) {
|
||||||
row := s.db.QueryRow(
|
row := s.db.QueryRow(
|
||||||
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order, recurrence, agent_mark
|
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order, recurrence, `+originColumns("")+`
|
||||||
FROM thoughts WHERE id = ? AND (user_id = ? OR task_list_id IN (`+s.accessibleTaskListIDsQuery()+`))`,
|
FROM thoughts WHERE id = ? AND (user_id = ? OR task_list_id IN (`+s.accessibleTaskListIDsQuery()+`))`,
|
||||||
thoughtID, userID, userID, userID, userID,
|
thoughtID, userID, userID, userID, userID,
|
||||||
)
|
)
|
||||||
@@ -160,14 +160,14 @@ func (s *Store) GetThoughts(userID, statusFilter string, limit int) ([]domain.Th
|
|||||||
|
|
||||||
if statusFilter != "" && statusFilter != "all" {
|
if statusFilter != "" && statusFilter != "all" {
|
||||||
rows, err = s.db.Query(
|
rows, err = s.db.Query(
|
||||||
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order, recurrence, agent_mark
|
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order, recurrence, `+originColumns("")+`
|
||||||
FROM thoughts WHERE user_id = ? AND status = ?
|
FROM thoughts WHERE user_id = ? AND status = ?
|
||||||
ORDER BY CASE priority WHEN 'urgent' THEN 1 WHEN 'high' THEN 2 WHEN 'normal' THEN 3 ELSE 4 END, sort_order ASC, created_at DESC LIMIT ?`,
|
ORDER BY CASE priority WHEN 'urgent' THEN 1 WHEN 'high' THEN 2 WHEN 'normal' THEN 3 ELSE 4 END, sort_order ASC, created_at DESC LIMIT ?`,
|
||||||
userID, statusFilter, limit,
|
userID, statusFilter, limit,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
rows, err = s.db.Query(
|
rows, err = s.db.Query(
|
||||||
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order, recurrence, agent_mark
|
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order, recurrence, `+originColumns("")+`
|
||||||
FROM thoughts WHERE user_id = ?
|
FROM thoughts WHERE user_id = ?
|
||||||
ORDER BY sort_order ASC, created_at DESC LIMIT ?`,
|
ORDER BY sort_order ASC, created_at DESC LIMIT ?`,
|
||||||
userID, limit,
|
userID, limit,
|
||||||
@@ -193,7 +193,7 @@ func (s *Store) GetTasks(userID string, taskListID string) ([]domain.Thought, er
|
|||||||
var err error
|
var err error
|
||||||
if taskListID != "" {
|
if taskListID != "" {
|
||||||
rows, err = s.db.Query(
|
rows, err = s.db.Query(
|
||||||
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order, recurrence, agent_mark
|
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order, recurrence, `+originColumns("")+`
|
||||||
FROM thoughts
|
FROM thoughts
|
||||||
WHERE (user_id = ? OR task_list_id IN (`+s.accessibleTaskListIDsQuery()+`))
|
WHERE (user_id = ? OR task_list_id IN (`+s.accessibleTaskListIDsQuery()+`))
|
||||||
AND task_list_id = ?
|
AND task_list_id = ?
|
||||||
@@ -203,7 +203,7 @@ func (s *Store) GetTasks(userID string, taskListID string) ([]domain.Thought, er
|
|||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
rows, err = s.db.Query(
|
rows, err = s.db.Query(
|
||||||
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order, recurrence, agent_mark
|
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order, recurrence, `+originColumns("")+`
|
||||||
FROM thoughts
|
FROM thoughts
|
||||||
WHERE (user_id = ? OR task_list_id IN (`+s.accessibleTaskListIDsQuery()+`))
|
WHERE (user_id = ? OR task_list_id IN (`+s.accessibleTaskListIDsQuery()+`))
|
||||||
AND (status = 'kept' OR (status = 'converted' AND converted_type = 'task'))
|
AND (status = 'kept' OR (status = 'converted' AND converted_type = 'task'))
|
||||||
@@ -384,6 +384,7 @@ func (s *Store) UpdateThoughtTriage(userID, thoughtID, action, convertTo, target
|
|||||||
item, err := s.CreateListItem(targetListID, thought.Content, "", "", 0, userID)
|
item, err := s.CreateListItem(targetListID, thought.Content, "", "", 0, userID)
|
||||||
if err == nil && item != nil {
|
if err == nil && item != nil {
|
||||||
convertedRefID = item.ID
|
convertedRefID = item.ID
|
||||||
|
s.copyOriginToListItem(thought.ID, item.ID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
@@ -512,7 +513,8 @@ func (s *Store) DeleteThought(userID, thoughtID string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// migrateAgentMark: entries an AI agent created or changed via MCP and the user has not touched since
|
// migrateAgentMark: released in v0.29.0; agent_mark is superseded by the origin columns (migrateOrigin)
|
||||||
|
// and only read once to carry old marks over
|
||||||
func (s *Store) migrateAgentMark() error {
|
func (s *Store) migrateAgentMark() error {
|
||||||
if err := s.addColumnIfMissing("thoughts", "agent_mark", "TEXT NOT NULL DEFAULT ''"); err != nil {
|
if err := s.addColumnIfMissing("thoughts", "agent_mark", "TEXT NOT NULL DEFAULT ''"); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -520,51 +522,6 @@ func (s *Store) migrateAgentMark() error {
|
|||||||
return s.addColumnIfMissing("list_items", "agent_mark", "TEXT NOT NULL DEFAULT ''")
|
return s.addColumnIfMissing("list_items", "agent_mark", "TEXT NOT NULL DEFAULT ''")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Values of agent_mark on thoughts and list items
|
|
||||||
const (
|
|
||||||
AgentMarkCreated = "created"
|
|
||||||
AgentMarkEdited = "edited"
|
|
||||||
)
|
|
||||||
|
|
||||||
// nextAgentMark: an agent's change marks the entry (an entry it created stays "created"), a change by
|
|
||||||
// the user removes the mark.
|
|
||||||
func nextAgentMark(current string, byAgent bool) string {
|
|
||||||
if !byAgent {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
if current == AgentMarkCreated {
|
|
||||||
return AgentMarkCreated
|
|
||||||
}
|
|
||||||
return AgentMarkEdited
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetThoughtAgentMark records who changed a task last and returns the new mark.
|
|
||||||
func (s *Store) SetThoughtAgentMark(thoughtID, current string, byAgent bool) string {
|
|
||||||
mark := nextAgentMark(current, byAgent)
|
|
||||||
if mark != current {
|
|
||||||
_, _ = s.db.Exec(`UPDATE thoughts SET agent_mark = ? WHERE id = ?`, mark, thoughtID)
|
|
||||||
}
|
|
||||||
return mark
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetListItemAgentMark is SetThoughtAgentMark for list items; the current mark is read here because
|
|
||||||
// the item queries after a change do not carry it.
|
|
||||||
func (s *Store) SetListItemAgentMark(itemID string, byAgent bool) string {
|
|
||||||
var current string
|
|
||||||
_ = s.db.QueryRow(`SELECT agent_mark FROM list_items WHERE id = ?`, itemID).Scan(¤t)
|
|
||||||
mark := nextAgentMark(current, byAgent)
|
|
||||||
if mark != current {
|
|
||||||
_, _ = s.db.Exec(`UPDATE list_items SET agent_mark = ? WHERE id = ?`, mark, itemID)
|
|
||||||
}
|
|
||||||
return mark
|
|
||||||
}
|
|
||||||
|
|
||||||
// MarkListItemAgentCreated marks an item an agent just added.
|
|
||||||
func (s *Store) MarkListItemAgentCreated(itemID string) string {
|
|
||||||
_, _ = s.db.Exec(`UPDATE list_items SET agent_mark = ? WHERE id = ?`, AgentMarkCreated, itemID)
|
|
||||||
return AgentMarkCreated
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Store) PurgeDiscardedThoughts(userID string) (int64, error) {
|
func (s *Store) PurgeDiscardedThoughts(userID string) (int64, error) {
|
||||||
res, err := s.db.Exec(`DELETE FROM thoughts WHERE user_id = ? AND status = 'discarded'`, userID)
|
res, err := s.db.Exec(`DELETE FROM thoughts WHERE user_id = ? AND status = 'discarded'`, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
Reference in new issue
Block a user