diff --git a/CHANGELOG.md b/CHANGELOG.md index aa83080..71436a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,9 +6,20 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V ## [Unreleased] +## [0.30.0] - 2026-11-01 + ### 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). +### 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 ### 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. - 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.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 diff --git a/Doku/api/rest.md b/Doku/api/rest.md index 9785937..cbe44e2 100644 --- a/Doku/api/rest.md +++ b/Doku/api/rest.md @@ -1,6 +1,6 @@ # REST-API -Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}`. Authentifizierung über `Authorization: Bearer `. **API-Keys** (`Bearer mindsift_sec_…` bzw. Header `X-API-Key`) 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 `. **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 @@ -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) | | `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. +### 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 | 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 | | GET | `/triage/deck` | Posteingang (max. 100) | | 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/{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` | @@ -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" \ -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 \ -H "X-API-Key: mindsift_sec_…" -H "Content-Type: application/json" \ -d '{"target":"list_item","listId":"lst_…","content":"Hafermilch","quantity":"2"}' diff --git a/Doku/ui-patterns.md b/Doku/ui-patterns.md index f38666a..4ab835c 100644 --- a/Doku/ui-patterns.md +++ b/Doku/ui-patterns.md @@ -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. - **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. -- Ändert oder hakt die Person den Eintrag selbst ab, löscht der Server die Markierung; das Zeichen verschwindet dann ohne eigenes Zutun des Frontends. -- Größen: `sm` in Aufgabenzeilen und der Detailansicht, `xs` auf Kanban-Karten und in Listen. Kein Hashtag als Ersatz. +- 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“. +- 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. +- 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 diff --git a/README.md b/README.md index e8cc725..45be90a 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ [![Self-Hosted](https://img.shields.io/badge/100%25-Self--Hosted-2563eb?style=flat-square)](#) [![Docker](https://img.shields.io/badge/Deployment-Single%20Container-2496ed?style=flat-square&logo=docker&logoColor=white)](#-schnellstart) [![SQLite](https://img.shields.io/badge/Daten-SQLite-003b57?style=flat-square&logo=sqlite&logoColor=white)](#-deine-daten-gehören-dir) -[![Version](https://img.shields.io/badge/Version-v0.29.0-16a34a?style=flat-square)](CHANGELOG.md) +[![Version](https://img.shields.io/badge/Version-v0.30.0-16a34a?style=flat-square)](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. @@ -128,8 +128,10 @@ Technik dahinter: [Doku/modules/collaboration.md](Doku/modules/collaboration.md) ### 🔌 Integrationen -* **Eingehend (API-Key):** `POST /api/v1/webhooks/ingest` legt Gedanken, Aufgaben oder Listeneinträge aus Smarthome, Skripten oder Automationen an. -* **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_…"` +* **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. + * **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. * Persönliche API-Keys, Datenexport als JSON, Markdown-Archiv (Obsidian-kompatibel). diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 6f2201e..ab18d4b 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "mindsift-frontend", - "version": "0.29.0", + "version": "0.30.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mindsift-frontend", - "version": "0.29.0", + "version": "0.30.0", "license": "Unlicense", "dependencies": { "@fontsource-variable/inter": "5.3.0", diff --git a/frontend/package.json b/frontend/package.json index 05a9b56..aa1b9bc 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "mindsift-frontend", "private": true, - "version": "0.29.0", + "version": "0.30.0", "license": "Unlicense", "type": "module", "scripts": { diff --git a/frontend/src/components/AgentBadge.tsx b/frontend/src/components/AgentBadge.tsx deleted file mode 100644 index 4d1e390..0000000 --- a/frontend/src/components/AgentBadge.tsx +++ /dev/null @@ -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 = { - 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 = ({ mark, size = 'sm' }) => { - if (mark !== 'created' && mark !== 'edited') return null; - const label = mark === 'created' ? 'KI' : 'KI geändert'; - return ( - - - {label} - - ); -}; diff --git a/frontend/src/components/OriginBadge.tsx b/frontend/src/components/OriginBadge.tsx new file mode 100644 index 0000000..b27b3cd --- /dev/null +++ b/frontend/src/components/OriginBadge.tsx @@ -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 = ({ origin, createdAt, size = 'sm' }) => { + const [open, setOpen] = useState(false); + const ref = useRef(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 ( + + + {open && ( + + {details} + + )} + + ); +}; diff --git a/frontend/src/components/__tests__/AgentBadge.test.tsx b/frontend/src/components/__tests__/AgentBadge.test.tsx deleted file mode 100644 index 3ac4b45..0000000 --- a/frontend/src/components/__tests__/AgentBadge.test.tsx +++ /dev/null @@ -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().container.firstChild).toBeNull(); - }); - - it('tells created and edited entries apart', () => { - const created = render().container.firstChild as HTMLElement; - expect(created.textContent).toBe('KI'); - expect(created.getAttribute('title')).toContain('angelegt'); - const edited = render().container.firstChild as HTMLElement; - expect(edited.textContent).toBe('KI geändert'); - expect(edited.getAttribute('title')).toContain('geändert'); - }); -}); diff --git a/frontend/src/components/__tests__/OriginBadge.test.tsx b/frontend/src/components/__tests__/OriginBadge.test.tsx new file mode 100644 index 0000000..5bfc04b --- /dev/null +++ b/frontend/src/components/__tests__/OriginBadge.test.tsx @@ -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().container.firstChild).toBeNull(); + expect(render().container.firstChild).toBeNull(); + }); + + it('names the key that created the entry, else the one that changed it', () => { + const created = render(); + expect(created.container.textContent).toBe('Home Assistant'); + const changed = render(); + expect(changed.container.textContent).toBe('Claude Code'); + const old = render(); + expect(old.container.textContent).toBe('MCP'); + }); + + it('opens the details on tap', () => { + const { getByRole, queryByRole } = render( + + ); + 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\)$/); + }); +}); diff --git a/frontend/src/features/help/HelpView.tsx b/frontend/src/features/help/HelpView.tsx index 82c3b10..d75a807 100644 --- a/frontend/src/features/help/HelpView.tsx +++ b/frontend/src/features/help/HelpView.tsx @@ -492,8 +492,8 @@ export const HelpView: React.FC = ({ onBack }) => { { id: 'triage-webhooks', category: 'triage', - title: 'Webhooks, API & KI-Agenten (MCP)', - subtitle: 'Gedanken von Siri, Home Assistant, n8n oder curl einspeisen; Aufgaben und Listen für Claude Code, Codex & Co.', + title: 'API: curl, Smart Home & KI-Agenten (MCP)', + subtitle: 'Aufgaben und Listen per curl oder Home Assistant lesen und ändern, Gedanken einliefern, KI-Agenten wie Claude Code anbinden.', icon: Bell, keywords: [ 'webhooks', @@ -513,16 +513,19 @@ export const HelpView: React.FC = ({ onBack }) => { content: (

- Unter Kontoeinstellungen → Webhooks & KI-Agenten 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 Kontoeinstellungen → API 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.

- KI-Agenten: MindSift ist ein MCP-Server (POST /api/v1/mcp). 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 KI bzw. KI geändert, bis du den Eintrag selbst änderst. + curl und Smart Home: Mit Leserecht liefert z. B. GET /api/v1/tasks?due=today&status=open alles, was heute ansteht, etwa für einen Home-Assistant-Sensor. Weitere Filter: due=overdue, ein Datum, taskListId. Listen kommen über GET /api/v1/lists. Mit Änderungsrecht hakst du per PATCH /api/v1/thoughts/ID ab. Die Einstellungen zeigen fertige Beispiele.

- Mit dem Feld "target" wählst du das Ziel: triage (Standard), task (optional mit taskListId) oder list_item (mit listId). Der Simulator in den Einstellungen zeigt passende Beispiele. + Einliefern: POST /api/v1/webhooks/ingest nimmt Gedanken aus iOS-Kurzbefehlen, Home Assistant oder n8n an. Mit dem Feld "target" wählst du das Ziel: triage (Standard), task (optional mit taskListId) oder list_item (mit listId).

- Über den Endpunkt POST /api/v1/webhooks/ingest kannst du Gedanken aus iOS-Kurzbefehlen, Smart-Home-Systemen oder Drittanwendungen einspeisen. Sie werden automatisch mit dem Tag #webhook markiert. + KI-Agenten: Über MCP (POST /api/v1/mcp) 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. +

+

+ Herkunft: 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.

), diff --git a/frontend/src/features/lists/SharedListsView.tsx b/frontend/src/features/lists/SharedListsView.tsx index 1d441a9..a90d925 100644 --- a/frontend/src/features/lists/SharedListsView.tsx +++ b/frontend/src/features/lists/SharedListsView.tsx @@ -16,7 +16,7 @@ import { OFFLINE_ITEM_PREFIX, pendingListItems } from '../../lib/offline'; import { UnifiedShareModal } from '../../components/UnifiedShareModal'; import { AvatarStack, AvatarPerson } from '../../components/AvatarStack'; import { CountBadge } from '../../components/CountBadge'; -import { AgentBadge } from '../../components/AgentBadge'; +import { OriginBadge } from '../../components/OriginBadge'; import { FormSheet } from '../../components/FormSheet'; import { useMobileActions } from '../../context/MobileActionContext'; @@ -1391,7 +1391,7 @@ export const SharedListsView: React.FC = ({ onRefreshGloba )} - + {renderItemAuthor(item)} -
- {( - [ - ['webhook', 'Einliefern (Webhooks)', 'POST /api/v1/webhooks/ingest'], - ['agent', 'KI-Agenten (MCP)', 'POST /api/v1/mcp'], - ] as const - ).map(([group, heading, path]) => ( +
+ {SCOPE_GROUPS.map(([group, heading]) => (
-

- {heading} - {path} -

+

{heading}

{SCOPES.filter((sc) => sc.group === group).map((sc) => { const on = newKeyScopes.includes(sc.id); return ( @@ -221,7 +223,7 @@ http_headers = { "X-API-Key" = "${exampleToken}" }` {sc.label} {sc.hint}
- toggleScope(sc.id)} title={sc.label} /> + toggleScope(sc.id)} title={sc.chip} />
); })} @@ -257,13 +259,8 @@ http_headers = { "X-API-Key" = "${exampleToken}" }` .split(',') .filter(Boolean) .map((sc) => ( - - {scopeLabel(sc)} + + {scopeChip(sc)} ))} {k.lastUsedAt && ( @@ -285,186 +282,250 @@ http_headers = { "X-API-Key" = "${exampleToken}" }`
- {/* AI agents (MCP) */} -
-
-
- -
-
-

KI-Agenten anbinden (MCP)

-

- 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. -

-
-
- -
+ {/* How to connect: one API, two accesses */} +
+
{( [ - ['claude', 'Claude Code'], - ['codex', 'OpenAI Codex'], - ['json', 'Claude Desktop / Cursor'], + ['rest', 'curl / Smart Home', Terminal], + ['mcp', 'KI-Agenten (MCP)', Bot], ] as const - ).map(([value, label]) => ( + ).map(([value, label, Icon]) => ( ))}
-
{mcpSnippet}
-

- Werkzeuge: list_task_lists, list_tasks,{' '} - complete_task, create_task,{' '} - update_task, list_lists,{' '} - get_list, add_list_item,{' '} - check_list_item, search,{' '} - capture_to_inbox. 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. -

-
- {/* Simulator */} -
-
-
- -
-
-

Live Smarthome Webhook-Simulator

-

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

-
-
+ {access === 'rest' && ( +
+ {/* Reading: e.g. a smart home sensor for what is due today */} +
+
+ + Lesen, z. B. was heute fällig ist (Recht „Aufgaben lesen“): +
+
+{`curl "${baseUrl}/api/v1/tasks?due=today&status=open" \\
+  -H "X-API-Key: ${exampleToken}"`}
+              
+

+ Filter: due=today, due=overdue oder ein + Datum (JJJJ-MM-TT, fällig bis einschließlich), status=open|done,{' '} + taskListId. Listen: GET /api/v1/lists und{' '} + /lists/ID. Mit „Ändern“ auch PATCH /api/v1/thoughts/ID{' '} + (z. B. {'{"isCompleted": true}'}). +

+
+ + Home Assistant: Sensor „Heute fällig“ (configuration.yaml) +
+
+{`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 }}"`}
+              
+
-
- {( - [ - ['triage', '📥 Inbox'], - ['task', '📋 Aufgabe'], - ['list_item', '📝 Listeneintrag'], - ] as const - ).map(([value, label]) => ( - - ))} - {simTarget === 'list_item' && ( - - )} - {simTarget === 'task' && ( - - )} -
+
+
+
+ +
+
+

Einliefern testen (Webhook)

+

+ Teste das Einliefern per POST /api/v1/webhooks/ingest direkt hier + {newlyCreatedKey ? ' – mit dem eben erzeugten Key und seinen Berechtigungen.' : ' – mit deiner Anmeldung.'} +

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

{simStatus}

- )} +
+ setSimTitle(e.target.value)} + className="flex-1 h-11 sm:h-9 px-3 input-field text-base sm:text-sm" + /> + +
- {/* Code Example Snippets */} -
-
- - cURL / Home Assistant REST Command: -
-
+              {simStatus && (
+                

{simStatus}

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

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

-
-
+ +

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

+
+
+
+ )} + {access === 'mcp' && ( +
+
+
+ +
+
+

KI-Agenten anbinden (MCP)

+

+ 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. +

+
+
+ +
+ {( + [ + ['claude', 'Claude Code'], + ['codex', 'OpenAI Codex'], + ['json', 'Claude Desktop / Cursor'], + ] as const + ).map(([value, label]) => ( + + ))} +
+
{mcpSnippet}
+

+ Werkzeuge: list_task_lists, create_task_list,{' '} + list_tasks, get_task,{' '} + create_task, update_task,{' '} + complete_task, delete_task,{' '} + list_lists, create_list,{' '} + get_list, add_list_item,{' '} + check_list_item, update_list_item,{' '} + search, capture_to_inbox. 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. +

+
+ )} +
); }; diff --git a/frontend/src/features/settings/UserSettingsView.tsx b/frontend/src/features/settings/UserSettingsView.tsx index 4aa2a88..6d8df82 100644 --- a/frontend/src/features/settings/UserSettingsView.tsx +++ b/frontend/src/features/settings/UserSettingsView.tsx @@ -583,7 +583,7 @@ export const UserSettingsView: React.FC = ({

Kontoeinstellungen

- Persönliches Profil, Design-Themes, Aufgaben-Alarme, Webhooks, KI-Agenten, Export & Import + Persönliches Profil, Design-Themes, Aufgaben-Alarme, API, Export & Import

@@ -652,8 +652,8 @@ export const UserSettingsView: React.FC = ({ >
-

Webhooks & KI-Agenten

-

API-Keys, MCP & Curl

+

API

+

Schlüssel, curl, Smart Home & KI-Agenten

@@ -1865,15 +1865,15 @@ export const UserSettingsView: React.FC = ({ )} {/* ================================================================= */} - {/* SECTION 4: PRIVATE WEBHOOKS & AI AGENTS */} + {/* SECTION 4: API (KEYS, CURL / SMART HOME, AI AGENTS) */} {/* ================================================================= */} {section === 'webhooks' && (
-

Webhooks & KI-Agenten

+

API

- Erstelle persönliche API-Schlüssel, um aus Skripten, Smarthome oder Bots Aufgaben und Gedanken - einzuspeisen oder KI-Agenten wie Claude Code und Codex an deine Aufgaben und Listen zu lassen. + Mit persönlichen API-Schlüsseln lesen und ändern Skripte, dein Smart Home oder KI-Agenten wie Claude Code + und Codex deine Aufgaben, Listen und die Inbox: per curl oder über MCP, mit denselben Rechten.

diff --git a/frontend/src/features/settings/__tests__/ApiKeysPanel.test.tsx b/frontend/src/features/settings/__tests__/ApiKeysPanel.test.tsx index ca2daca..3d130c3 100644 --- a/frontend/src/features/settings/__tests__/ApiKeysPanel.test.tsx +++ b/frontend/src/features/settings/__tests__/ApiKeysPanel.test.tsx @@ -21,28 +21,32 @@ import { ApiKeysPanel } from '../ApiKeysPanel'; describe('ApiKeysPanel', () => { beforeEach(() => createAPIKey.mockClear()); - it('shows the scopes of existing keys as badges', async () => { + it('shows the rights of existing keys as badges', async () => { render(); 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 () => { render(); - const triage = screen.getByRole('switch', { name: /Triage/ }); + const triage = screen.getByRole('switch', { name: 'Inbox' }); fireEvent.click(triage); // last remaining scope cannot be switched off 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 () => { fireEvent.click(screen.getByRole('button', { name: /API-Key erzeugen/ })); }); 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(); - fireEvent.click(screen.getByRole('switch', { name: /Aufgaben ändern/ })); - expect(screen.getByRole('switch', { name: /Aufgaben ändern/ }).getAttribute('aria-checked')).toBe('true'); + fireEvent.click(screen.getByRole('switch', { name: 'Aufgaben ändern' })); + 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(); fireEvent.click(screen.getByRole('radio', { name: 'OpenAI Codex' })); expect(screen.getByText(/\[mcp_servers\.mindsift\]/)).toBeTruthy(); diff --git a/frontend/src/features/triage/TaskDetailModal.tsx b/frontend/src/features/triage/TaskDetailModal.tsx index c9f48dc..73ef9cd 100644 --- a/frontend/src/features/triage/TaskDetailModal.tsx +++ b/frontend/src/features/triage/TaskDetailModal.tsx @@ -11,7 +11,6 @@ import { AlertTriangle, Flame, Folder, - Webhook, Lock, UserPlus, Repeat, @@ -20,7 +19,7 @@ import { } from 'lucide-react'; import { Thought, TaskList, TaskListMember, KanbanColumn, User, Recurrence } from '../../lib/types'; import { UserAvatar } from '../../components/UserAvatar'; -import { AgentBadge } from '../../components/AgentBadge'; +import { OriginBadge, originDetails } from '../../components/OriginBadge'; import { UserAutocomplete } from '../UserAutocomplete'; import { api, triggerHaptic } from '../../lib/api'; import { MarkdownViewer } from '../../lib/markdown'; @@ -239,13 +238,7 @@ const TaskDetailModalContent: React.FC Nur Lesen )} - {task.source?.toLowerCase() === 'webhook' && ( - - - Webhook - - )} - +
@@ -895,8 +888,8 @@ const TaskDetailModalContent: React.FC {/* Modal Footer */}
-
- Erstellt: {new Date(task.createdAt).toLocaleDateString('de-DE')} +
+ {originDetails(task.origin, task.createdAt) || `Erstellt: ${new Date(task.createdAt).toLocaleDateString('de-DE')}`}
)} - {task.source?.toLowerCase() === 'webhook' && ( - - - Webhook - - )} - + {/* Note indicator */} {task.notes && ( @@ -2436,16 +2426,7 @@ export const TasksView: React.FC = ({ ⚠️ Wichtig )} - {task.source?.toLowerCase() === 'webhook' && ( - - - Webhook - - )} - + {task.dueDate && ( = ({ {item.content} - {item.source?.toLowerCase() === 'webhook' && ( - - Webhook - - )} {item.source?.toLowerCase() === 'voice' && ( Diktat )} - +
{/* Desktop quick buttons (hidden on mobile so queue items are clean & easy to tap/swipe) */} @@ -441,13 +435,6 @@ export const TriageDeck: React.FC = ({ ); } - if (s === 'webhook') { - return ( - - Webhook - - ); - } return null; }; @@ -523,7 +510,7 @@ export const TriageDeck: React.FC = ({
{priorityBadge(nextCard.priority)} {sourceBadge(nextCard.source)} - +

{nextCard.content}

Nächster Quick Dump…
@@ -593,7 +580,7 @@ export const TriageDeck: React.FC = ({
{priorityBadge(activeCard.priority)} {sourceBadge(activeCard.source)} - +
, string> = { yearly: 'jährlich', }; -// Set while the last change came from an AI agent via MCP and the user has not touched the entry since -export type AgentMark = 'created' | 'edited'; +// Which API key created an entry and which one changed it last, over which access (REST "api" or +// "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 { id: string; @@ -176,7 +183,7 @@ export interface Thought { sortOrder?: number; createdAt: string; triagedAt?: string; - agentMark?: AgentMark; + origin?: Origin; } export interface KanbanColumn { @@ -542,7 +549,7 @@ export interface ListItem { sortOrder: number; createdAt: string; completedAt?: string; - agentMark?: AgentMark; + origin?: Origin; } export interface ListShare { diff --git a/internal/api/apikey_access.go b/internal/api/apikey_access.go new file mode 100644 index 0000000..009f0f3 --- /dev/null +++ b/internal/api/apikey_access.go @@ -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) +} diff --git a/internal/api/apikey_access_test.go b/internal/api/apikey_access_test.go new file mode 100644 index 0000000..b131c8d --- /dev/null +++ b/internal/api/apikey_access_test.go @@ -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") + } +} diff --git a/internal/api/handlers_integrations.go b/internal/api/handlers_integrations.go index 7757a2a..d9d383a 100644 --- a/internal/api/handlers_integrations.go +++ b/internal/api/handlers_integrations.go @@ -1,7 +1,6 @@ package api import ( - "context" "encoding/json" "errors" "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) - user, keyScopes := s.authenticateToken(token) + user, key := s.authenticateToken(token) if user == nil { writeError(w, http.StatusUnauthorized, "Ungültiger Webhook-Token oder API-Key") return } - if keyScopes != nil { - r = r.WithContext(context.WithValue(r.Context(), apiKeyScopesKey{}, keyScopes)) + if key != nil { + r = r.WithContext(withAPIKey(r.Context(), key, storage.OriginAPI)) } + actor := actorFrom(r.Context()) var req struct { Title string `json:"title"` @@ -163,11 +163,11 @@ func (s *Server) handleWebhookIngest(w http.ResponseWriter, r *http.Request) { } 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 } 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 } @@ -179,6 +179,7 @@ func (s *Server) handleWebhookIngest(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusInternalServerError, err.Error()) return } + s.recordThoughtOrigin(thought, actor, true) 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 -// a task list the user may write to (otherwise storage.ErrTaskListReadOnly). -func (s *Server) createTask(userID, content, priority, source, dueDate, taskListID, notes string) (*domain.Thought, error) { +// createTask creates a task directly (POST /webhooks/ingest, MCP tool create_task), optionally in a +// 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, actor *apiActor) (*domain.Thought, error) { if priority == "" { priority = "normal" } @@ -205,12 +206,13 @@ func (s *Server) createTask(userID, content, priority, source, dueDate, taskList if err != nil { return nil, err } + s.recordThoughtOrigin(task, actor, true) s.wsHub.BroadcastToUsers(s.taskAudience(task), "TASK_CREATED", task) return task, nil } -func (s *Server) ingestTask(w http.ResponseWriter, userID, content, priority, source, dueDate, taskListID string) { - task, err := s.createTask(userID, content, priority, source, dueDate, taskListID, "") +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, "", actor) if errors.Is(err, storage.ErrTaskListReadOnly) { writeError(w, http.StatusForbidden, err.Error()) 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 // 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) == "" { return nil, errListIDRequired } @@ -237,16 +239,14 @@ func (s *Server) addListItem(userID, listID, content, category, quantity string, if err != nil { return nil, err } - if byAgent { - item.AgentMark = s.store.MarkListItemAgentCreated(item.ID) - } + s.recordListItemOrigin(item, actor, true) s.wsHub.BroadcastListEvent(listID, "ITEM_CREATED", item) return item, nil } -// ingestListItem adds an item to a list (scope webhook:list) the owner of the key may write to. -func (s *Server) ingestListItem(w http.ResponseWriter, userID, listID, content, category, quantity string) { - item, err := s.addListItem(userID, listID, content, category, quantity, false) +// 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, actor *apiActor) { + item, err := s.addListItem(userID, listID, content, category, quantity, actor) if err != nil { status := http.StatusNotFound switch { diff --git a/internal/api/handlers_lists.go b/internal/api/handlers_lists.go index f372915..dcce2f5 100644 --- a/internal/api/handlers_lists.go +++ b/internal/api/handlers_lists.go @@ -161,14 +161,15 @@ func (s *Server) handleCreateListItem(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, err.Error()) return } + s.recordListItemOrigin(item, actorFrom(r.Context()), true) s.wsHub.BroadcastListEvent(listID, "ITEM_CREATED", item) writeJSON(w, http.StatusCreated, item) } // 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. -func (s *Server) setListItemDone(userID, listID, itemID string, done, byAgent bool) (*domain.ListItem, error) { +// 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 bool, actor *apiActor) (*domain.ListItem, error) { if err := s.store.EnsureListItemsWritable(userID, listID, itemID); err != nil { return nil, err } @@ -181,7 +182,7 @@ func (s *Server) setListItemDone(userID, listID, itemID string, done, byAgent bo if err != nil { return nil, err } - item.AgentMark = s.store.SetListItemAgentMark(itemID, byAgent) + s.recordListItemOrigin(item, actor, false) s.wsHub.BroadcastListEvent(listID, "ITEM_TOGGLED", item) return item, nil } @@ -202,7 +203,7 @@ func (s *Server) handleToggleListItem(w http.ResponseWriter, r *http.Request) { 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 { writeError(w, http.StatusBadRequest, err.Error()) return @@ -232,7 +233,7 @@ func (s *Server) handleUpdateListItem(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, err.Error()) return } - item.AgentMark = s.store.SetListItemAgentMark(itemID, false) + s.recordListItemOrigin(item, actorFrom(r.Context()), false) s.wsHub.BroadcastListEvent(listID, "ITEM_UPDATED", item) writeJSON(w, http.StatusOK, item) diff --git a/internal/api/handlers_thoughts.go b/internal/api/handlers_thoughts.go index 52b4d93..7a87c66 100644 --- a/internal/api/handlers_thoughts.go +++ b/internal/api/handlers_thoughts.go @@ -2,8 +2,10 @@ package api import ( "encoding/json" + "errors" "net/http" "strings" + "time" "github.com/go-chi/chi/v5" "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) 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) 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) { userID := auth.GetUserID(r.Context()) - taskListID := r.URL.Query().Get("taskListId") - tasks, err := s.store.GetTasks(userID, taskListID) + q := r.URL.Query() + tasks, err := s.store.GetTasks(userID, q.Get("taskListId")) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) 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) } +// 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) { userID := auth.GetUserID(r.Context()) taskListID := chi.URLParam(r, "id") @@ -245,8 +294,8 @@ type taskPatch struct { Tags *[]string `json:"tags"` Assignees *[]string `json:"assignees"` Recurrence *string `json:"recurrence"` - // byAgent: the change comes from an AI agent (MCP) and marks the task; otherwise the mark goes - byAgent bool + // actor: the API key making the change (recorded as the task's origin); nil for the app + actor *apiActor } // 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 { return nil, err } - updated.AgentMark = s.store.SetThoughtAgentMark(thoughtID, updated.AgentMark, req.byAgent) + s.recordThoughtOrigin(updated, req.actor, false) if req.Assignees != nil { assignees, err := s.store.SetTaskAssignees(userID, thoughtID, *req.Assignees) if err != nil { @@ -290,6 +339,7 @@ func (s *Server) handleUpdateThought(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, "Ungültiges JSON") return } + req.actor = actorFrom(r.Context()) updated, err := s.updateTask(userID, thoughtID, req) if err != nil { writeStoreError(w, http.StatusBadRequest, err) diff --git a/internal/api/mcp.go b/internal/api/mcp.go index 6994644..f538550 100644 --- a/internal/api/mcp.go +++ b/internal/api/mcp.go @@ -58,8 +58,10 @@ type mcpTool struct { Name string Description string Scope string - Schema map[string]interface{} - run func(s *Server, userID string, args json.RawMessage) (interface{}, error) + // AltScope: a narrower right that allows this tool too (e.g. "create tasks" for create_task) + AltScope string + Schema map[string]interface{} + 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 @@ -82,12 +84,17 @@ func mcpToolAllowed(scopes []string, isKey bool, tool mcpTool) bool { if tool.Name == "search" { 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) { 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 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 " + "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. " + - "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) case "ping": 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 { p.Arguments = json.RawMessage("{}") } - result, err := tool.run(s, userID, p.Arguments) + result, err := tool.run(s, userID, actor, p.Arguments) if err != nil { writeRPC(w, req.ID, toolError(err.Error()), nil) return @@ -265,13 +272,14 @@ type taskView struct { Subtasks []domain.SubTask `json:"subtasks,omitempty"` Recurrence string `json:"recurrence,omitempty"` CreatedAt string `json:"createdAt"` + Origin *domain.Origin `json:"origin,omitempty"` } func toTaskView(t *domain.Thought) taskView { return taskView{ 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, - 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).", Scope: storage.ScopeTasksRead, 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) if err != nil { return nil, err @@ -353,7 +361,7 @@ var mcpTools = []mcpTool{ "title": prop("string", "Name of the task list"), "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 { Title string `json:"title"` 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"), "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 { TaskListID string `json:"taskListId"` Status string `json:"status"` @@ -437,7 +445,7 @@ var mcpTools = []mcpTool{ Description: "One task with all details.", Scope: storage.ScopeTasksRead, 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 { ID string `json:"id"` } @@ -455,6 +463,7 @@ var mcpTools = []mcpTool{ 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.", Scope: storage.ScopeTasksWrite, + AltScope: storage.ScopeTask, Schema: schema([]string{"content"}, map[string]interface{}{ "content": prop("string", "Task text; #hashtags become tags"), "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"), "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 { Content string `json:"content"` TaskListID string `json:"taskListId"` @@ -476,7 +485,7 @@ var mcpTools = []mcpTool{ if strings.TrimSpace(a.Content) == "" { 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 { return nil, err } @@ -498,7 +507,7 @@ var mcpTools = []mcpTool{ "taskListId": prop("string", "Move into another task list"), "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 { ID string `json:"id"` taskPatch @@ -507,7 +516,7 @@ var mcpTools = []mcpTool{ return nil, err } 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) if err != nil { return nil, err @@ -523,7 +532,7 @@ var mcpTools = []mcpTool{ "id": prop("string", "Task id"), "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 { ID string `json:"id"` Completed *bool `json:"completed"` @@ -532,7 +541,7 @@ var mcpTools = []mcpTool{ return nil, err } 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 { 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.", Scope: storage.ScopeTasksWrite, 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 { 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.", Scope: storage.ScopeListsRead, 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) if err != nil { return nil, err @@ -586,7 +595,7 @@ var mcpTools = []mcpTool{ "icon": prop("string", "One emoji, optional"), "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 { Title string `json:"title"` Icon string `json:"icon"` @@ -611,7 +620,7 @@ var mcpTools = []mcpTool{ Description: "One list with all its items (id, content, category, quantity, isCompleted).", Scope: storage.ScopeListsRead, 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 { ListID string `json:"listId"` } @@ -629,13 +638,14 @@ var mcpTools = []mcpTool{ Name: "add_list_item", Description: "Add an item to a list the user may edit.", Scope: storage.ScopeListsWrite, + AltScope: storage.ScopeList, Schema: schema([]string{"listId", "content"}, map[string]interface{}{ "listId": prop("string", "List id"), "content": prop("string", "Item text"), "category": prop("string", "Category/aisle, e.g. Obst"), "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 { ListID, Content, Category, Quantity string } @@ -645,7 +655,7 @@ var mcpTools = []mcpTool{ if strings.TrimSpace(a.Content) == "" { 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"), "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 { ListID string `json:"listId"` ItemID string `json:"itemId"` @@ -666,7 +676,7 @@ var mcpTools = []mcpTool{ if err := decodeArgs(args, &a); err != nil { 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"), "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 { ListID string `json:"listId"` ItemID string `json:"itemId"` @@ -698,7 +708,7 @@ var mcpTools = []mcpTool{ if err != nil { return nil, err } - item.AgentMark = s.store.SetListItemAgentMark(a.ItemID, true) + s.recordListItemOrigin(item, actor, false) s.wsHub.BroadcastListEvent(a.ListID, "ITEM_UPDATED", item) 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).", Scope: storage.ScopeTasksRead, 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 { Query string `json:"query"` } @@ -735,7 +745,7 @@ var mcpTools = []mcpTool{ "priority": map[string]interface{}{"type": "string", "enum": []string{"low", "normal", "high", "urgent"}}, "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 { Content, Priority, DueDate string } @@ -752,6 +762,7 @@ var mcpTools = []mcpTool{ if err != nil { return nil, err } + s.recordThoughtOrigin(t, actor, true) 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 }, diff --git a/internal/api/mcp_test.go b/internal/api/mcp_test.go index 26392b6..09b2e3f 100644 --- a/internal/api/mcp_test.go +++ b/internal/api/mcp_test.go @@ -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 -// changes them. -func TestMCPCreateDeleteAndAgentMark(t *testing.T) { +// Agents can create task lists and lists and delete tasks; what a key creates or changes keeps its +// origin for good, also after the user's own edits. +func TestMCPCreateDeleteAndOrigin(t *testing.T) { srv, store, token := setupTestServer(t) router := srv.Router() 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{} { 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)) @@ -222,25 +222,34 @@ func TestMCPCreateDeleteAndAgentMark(t *testing.T) { task := call(2, "create_task", `{"content":"Von Claude","taskListId":"`+tlID+`"}`) taskID := task["id"].(string) th, _ := store.GetThoughtByID(claims.UserID, taskID) - if th.AgentMark != "created" || th.TaskListID != tlID { - t.Fatalf("agent task should be marked created: %+v", th) - } - // 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) + if th.Origin == nil || th.Origin.CreatedVia != "mcp" || th.Origin.CreatedKey != "Claude Code" || th.TaskListID != tlID { + t.Fatalf("agent task should carry its origin: %+v", th.Origin) } + // The user's own edit keeps the origin notes := "selbst ergänzt" if _, err := srv.updateTask(claims.UserID, taskID, taskPatch{Notes: ¬es}); err != nil { t.Fatal(err) } - if th, _ = store.GetThoughtByID(claims.UserID, taskID); th.AgentMark != "" { - t.Fatalf("user edit should clear the mark, got %q", th.AgentMark) + if th, _ = store.GetThoughtByID(claims.UserID, taskID); th.Origin == nil || th.Origin.CreatedKey != "Claude Code" || th.Origin.ChangedVia != "" { + 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" { - t.Fatalf("agent edit of a user task should mark edited, got %q", th.AgentMark) + + // A task of the user changed by the agent: changed via MCP, with key and time + 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+`"}`) 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) } item := call(7, "add_list_item", `{"listId":"`+listID+`","content":"Zahnbürste"}`) - if item["agentMark"] != "created" { - t.Fatalf("agent item should be marked created: %v", item) + if o, _ := item["origin"].(map[string]interface{}); o == nil || o["createdVia"] != "mcp" { + t.Fatalf("agent item should carry its origin: %v", item) } items, _ := store.GetListItems(listID) - if len(items) != 1 || items[0].AgentMark != "created" { - t.Fatalf("stored item mark: %+v", items) - } - if _, err := srv.setListItemDone(claims.UserID, listID, items[0].ID, true, false); err != nil { + if _, err := srv.setListItemDone(claims.UserID, listID, items[0].ID, true, nil); err != nil { t.Fatal(err) } - if items, _ = store.GetListItems(listID); items[0].AgentMark != "" { - t.Fatalf("ticking by the user should clear the mark: %q", items[0].AgentMark) + if items, _ = store.GetListItems(listID); items[0].Origin == nil || items[0].Origin.CreatedKey != "Claude Code" { + t.Fatalf("ticking by the user keeps the origin: %+v", items[0].Origin) } // Other people's tasks cannot be deleted @@ -274,4 +280,11 @@ func TestMCPCreateDeleteAndAgentMark(t *testing.T) { if _, isErr := callResult(t, res); !isErr { 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) + } } diff --git a/internal/api/realtime.go b/internal/api/realtime.go index 832d5e2..fe44e79 100644 --- a/internal/api/realtime.go +++ b/internal/api/realtime.go @@ -28,7 +28,7 @@ func (s *Server) authenticateUserFromReq(r *http.Request) string { if authHdr := r.Header.Get("Authorization"); strings.HasPrefix(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 "" diff --git a/internal/api/server.go b/internal/api/server.go index 1fbc008..a323a75 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -1,7 +1,6 @@ package api import ( - "context" "encoding/json" "errors" "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 -// checked against the revocation list and token_version; viaAPIKey tells the caller which it was. -// authenticateToken checks a session token or an API key. keyScopes is nil for sessions and the key's -// scopes for API keys. -func (s *Server) authenticateToken(token string) (user *domain.User, keyScopes []string) { +// authenticateToken checks a session token or an API key. key is nil for sessions and holds the key's +// id and scopes for API keys. JWTs are checked against the revocation list and token_version. +func (s *Server) authenticateToken(token string) (user *domain.User, key *apiKeyAuth) { token = strings.TrimSpace(token) if token == "" { return nil, nil } 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 { return nil, nil } - return u, scopes + return u, &apiKeyAuth{ID: keyID, Scopes: scopes} } claims, err := auth.ValidateToken(s.cfg.JWTSecret, token) if err != nil || claims == nil { @@ -488,40 +485,6 @@ func (s *Server) authenticateToken(token string) (user *domain.User, keyScopes [ 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 { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { token := "" @@ -530,24 +493,28 @@ func (s *Server) authMiddleware(next http.Handler) http.Handler { } else { token = r.Header.Get("X-API-Key") } - user, keyScopes := s.authenticateToken(token) + user, key := s.authenticateToken(token) if user == nil { writeError(w, http.StatusUnauthorized, "Nicht autorisiert – bitte anmelden") return } ctx := auth.WithUserID(r.Context(), user.ID) - if keyScopes == nil { + if key == nil { if claims, err := auth.ValidateToken(s.cfg.JWTSecret, token); err == nil { ctx = withSession(ctx, claims.ID) s.renewSessionToken(w, user, claims, false) } } - if keyScopes != nil { - if !apiKeyAllowed(r) { - writeError(w, http.StatusForbidden, "API-Keys dürfen nur Daten einliefern (POST /api/v1/inbox oder /api/v1/webhooks/ingest) oder den MCP-Endpunkt nutzen (POST /api/v1/mcp)") + if key != nil { + if msg := apiKeyRouteDenied(r, key.Scopes); msg != "" { + writeError(w, http.StatusForbidden, msg) 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)) }) diff --git a/internal/domain/models.go b/internal/domain/models.go index d1d7a0b..d017857 100644 --- a/internal/domain/models.go +++ b/internal/domain/models.go @@ -268,7 +268,17 @@ type Thought struct { SortOrder int `json:"sortOrder"` CreatedAt string `json:"createdAt"` 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 { @@ -558,20 +568,20 @@ type ListShare struct { } type ListItem struct { - ID string `json:"id"` - ListID string `json:"listId"` - Content string `json:"content"` - Category string `json:"category,omitempty"` - Quantity string `json:"quantity,omitempty"` - IsCompleted bool `json:"isCompleted"` - CompletedBy string `json:"completedBy,omitempty"` - CompletedByName string `json:"completedByName,omitempty"` - CreatedBy string `json:"createdBy,omitempty"` - CreatedByName string `json:"createdByName,omitempty"` - SortOrder int `json:"sortOrder"` - CreatedAt string `json:"createdAt"` - CompletedAt string `json:"completedAt,omitempty"` - AgentMark string `json:"agentMark,omitempty"` // created | edited: last change via MCP, untouched by the user since + ID string `json:"id"` + ListID string `json:"listId"` + Content string `json:"content"` + Category string `json:"category,omitempty"` + Quantity string `json:"quantity,omitempty"` + IsCompleted bool `json:"isCompleted"` + CompletedBy string `json:"completedBy,omitempty"` + CompletedByName string `json:"completedByName,omitempty"` + CreatedBy string `json:"createdBy,omitempty"` + CreatedByName string `json:"createdByName,omitempty"` + SortOrder int `json:"sortOrder"` + CreatedAt string `json:"createdAt"` + CompletedAt string `json:"completedAt,omitempty"` + Origin *Origin `json:"origin,omitempty"` // API key that created / last changed it } type SharedList struct { diff --git a/internal/storage/apikeys.go b/internal/storage/apikeys.go index d1454d5..1bc2f4e 100644 --- a/internal/storage/apikeys.go +++ b/internal/storage/apikeys.go @@ -10,18 +10,18 @@ import ( // --- API KEYS & WEBHOOKS --- -// Scopes of an API key. The webhook scopes deliver data (inbox, /webhooks/ingest); the agent scopes -// let AI agents read and change tasks and lists over the MCP endpoint. Notes are never reachable with -// a key. +// Rights of an API key. They hold the same over every access: REST (curl, smart home) and the MCP +// endpoint for AI agents. The ids of the first three come from the time they only delivered data +// (webhooks) and stay for existing keys. Notes are never reachable with a key. const ( ScopeTriage = "webhook:triage" // thoughts into the inbox - ScopeTask = "webhook:task" // tasks directly - ScopeList = "webhook:list" // items into lists + ScopeTask = "webhook:task" // create tasks + ScopeList = "webhook:list" // add items to lists - ScopeTasksRead = "tasks:read" // MCP: task lists and tasks - ScopeTasksWrite = "tasks:write" // MCP: create, change and complete tasks - ScopeListsRead = "lists:read" // MCP: lists and their items - ScopeListsWrite = "lists:write" // MCP: add, change and check off items + ScopeTasksRead = "tasks:read" // task lists and tasks + ScopeTasksWrite = "tasks:write" // create, change, complete and delete tasks + ScopeListsRead = "lists:read" // lists and their items + ScopeListsWrite = "lists:write" // add, change and check off items ) // 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) { - u, _, err := s.ValidateAPIKey(rawKey) + u, _, _, err := s.ValidateAPIKey(rawKey) return u, err } -// ValidateAPIKey returns the owner of a key and the key's scopes. -func (s *Store) ValidateAPIKey(rawKey string) (*domain.User, []string, error) { +// ValidateAPIKey returns the owner of a key, the key's scopes and its id. +func (s *Store) ValidateAPIKey(rawKey string) (*domain.User, []string, string, error) { hash := auth.HashAPIKey(strings.TrimSpace(rawKey)) var keyID, userID, scopes string err := s.db.QueryRow(`SELECT id, user_id, scopes FROM api_keys WHERE key_hash = ?`, hash).Scan(&keyID, &userID, &scopes) if err != nil { - return nil, 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) u, err := s.GetUserByID(userID) - return u, NormalizeScopes(strings.Split(scopes, ",")), err + return u, NormalizeScopes(strings.Split(scopes, ",")), keyID, err } diff --git a/internal/storage/docsync.go b/internal/storage/docsync.go index 24007b6..3722500 100644 --- a/internal/storage/docsync.go +++ b/internal/storage/docsync.go @@ -483,7 +483,7 @@ func (s *Store) ReconcileDocTaskLinks(docID string) []string { // GetThoughtByIDUnchecked loads a task without access check, for server-internal notifications. func (s *Store) GetThoughtByIDUnchecked(thoughtID string) (*domain.Thought, error) { 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)) } diff --git a/internal/storage/lists.go b/internal/storage/lists.go index b9ec360..59c0faf 100644 --- a/internal/storage/lists.go +++ b/internal/storage/lists.go @@ -303,7 +303,7 @@ func (s *Store) DeleteList(userID, listID string) error { func (s *Store) GetListItems(listID string) ([]domain.ListItem, error) { 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, - 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 != '' WHERE li.list_id = ? 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() { var it domain.ListItem 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.Origin = o.result() items = append(items, it) } } @@ -399,15 +401,18 @@ func (s *Store) ToggleListItem(itemID string, isCompleted bool, userID, userName var it domain.ListItem var dInt int + var o originDest 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 = ?`, 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 { return nil, err } it.IsCompleted = dInt != 0 + it.Origin = o.result() _, _ = s.db.Exec(`UPDATE lists SET updated_at = ? WHERE id = ?`, now, it.ListID) return &it, nil } @@ -416,15 +421,18 @@ func (s *Store) UpdateListItem(itemID string, content *string, category *string, now := domain.NowISO() var it domain.ListItem var dInt int + var o originDest 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 = ?`, 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 { return nil, err } it.IsCompleted = dInt != 0 + it.Origin = o.result() if content != nil { it.Content = *content @@ -459,17 +467,19 @@ func (s *Store) MoveListItem(itemID, targetListID string) (*domain.ListItem, err var it domain.ListItem var dInt int + var o originDest 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, - 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 != '' WHERE li.id = ?`, 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 { return nil, err } it.IsCompleted = dInt != 0 + it.Origin = o.result() return &it, nil } diff --git a/internal/storage/migrations.go b/internal/storage/migrations.go index 301018f..733ee7b 100644 --- a/internal/storage/migrations.go +++ b/internal/storage/migrations.go @@ -33,6 +33,7 @@ var migrations = []migration{ {10, "Registrierung und Einladungslinks", (*Store).migrateRegistration}, {11, "E-Mail-Bestätigung", (*Store).migrateEmailVerification}, {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 { diff --git a/internal/storage/origin.go b/internal/storage/origin.go new file mode 100644 index 0000000..cc3660b --- /dev/null +++ b/internal/storage/origin.go @@ -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...) +} diff --git a/internal/storage/recurrence.go b/internal/storage/recurrence.go index a2eb7bc..85d6211 100644 --- a/internal/storage/recurrence.go +++ b/internal/storage/recurrence.go @@ -72,7 +72,7 @@ func (s *Store) SetTaskRecurrence(userID, taskID, rule string) error { func (s *Store) getThoughtRaw(id string) (*domain.Thought, error) { 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)) if err != nil { return nil, err diff --git a/internal/storage/thoughts.go b/internal/storage/thoughts.go index b1837f4..ed5b18a 100644 --- a/internal/storage/thoughts.go +++ b/internal/storage/thoughts.go @@ -76,14 +76,11 @@ func (s *Store) CreateThoughtWithDetails(userID, content, priority, source, dueD Notes: notes, CreatedAt: domain.NowISO(), } - if source == "agent" { - t.AgentMark = AgentMarkCreated - } _, 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) - 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, + `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, ?, '[]')`, + 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 { return nil, err @@ -98,16 +95,19 @@ func scanThought(rows interface { t := &domain.Thought{} var tagsStr, subtasksStr, statusStr string var isCompleted, alarmSent, sortOrder int + var origin originDest err := rows.Scan( &t.ID, &t.UserID, &t.Content, &tagsStr, &t.Priority, &t.Source, &statusStr, &isCompleted, &t.ConvertedType, &t.ConvertedRefID, &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 { return nil, err } t.SortOrder = sortOrder + t.Origin = origin.result() t.Status = domain.ThoughtStatus(statusStr) t.IsCompleted = isCompleted != 0 t.AlarmSent = alarmSent != 0 @@ -139,7 +139,7 @@ func (s *Store) accessibleTaskListIDsQuery() string { func (s *Store) GetThoughtByID(userID, thoughtID string) (*domain.Thought, error) { 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()+`))`, thoughtID, userID, userID, userID, userID, ) @@ -160,14 +160,14 @@ func (s *Store) GetThoughts(userID, statusFilter string, limit int) ([]domain.Th if statusFilter != "" && statusFilter != "all" { 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 = ? 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, ) } else { 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 = ? ORDER BY sort_order ASC, created_at DESC LIMIT ?`, userID, limit, @@ -193,7 +193,7 @@ func (s *Store) GetTasks(userID string, taskListID string) ([]domain.Thought, er var err error if taskListID != "" { 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 = ? OR task_list_id IN (`+s.accessibleTaskListIDsQuery()+`)) AND task_list_id = ? @@ -203,7 +203,7 @@ func (s *Store) GetTasks(userID string, taskListID string) ([]domain.Thought, er ) } else { 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 = ? OR task_list_id IN (`+s.accessibleTaskListIDsQuery()+`)) 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) if err == nil && item != nil { convertedRefID = item.ID + s.copyOriginToListItem(thought.ID, item.ID) } } default: @@ -512,7 +513,8 @@ func (s *Store) DeleteThought(userID, thoughtID string) error { 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 { if err := s.addColumnIfMissing("thoughts", "agent_mark", "TEXT NOT NULL DEFAULT ''"); err != nil { return err @@ -520,51 +522,6 @@ func (s *Store) migrateAgentMark() error { 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) { res, err := s.db.Exec(`DELETE FROM thoughts WHERE user_id = ? AND status = 'discarded'`, userID) if err != nil {