feat(api): one API for curl and MCP, REST reads with keys, origin badge with key name
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m16s

API keys reach tasks and lists over plain REST too (GET /task-lists, GET /tasks with
status/due filters, PATCH/DELETE /thoughts/{id}, GET /lists, list items), guarded by a route
table; notes and settings stay closed. Rights hold for every access alike (webhook:task also
allows create_task over MCP, tasks:write also task delivery).

Tasks, thoughts and list items record which key created them and which one changed them last
(migration 13 replaces agent_mark; origin stays after the user's own edits and survives the
triage into a list item). OriginBadge shows the key name with a webhook or robot icon, details
on hover and tap, and replaces the Webhook and KI badges everywhere, now also on list items.

Settings: category "API" with rights grouped by tasks, lists and inbox and two tabs
(curl / smart home with a Home Assistant sensor, AI agents over MCP). Docs, help, README and
changelog updated; v0.30.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-11-01 10:26:44 +01:00
1 parent 0ca715ba64
commit b883315914
36 files changed
+985 -429

No files matched your search

+13 -1
View File
@@ -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
+31 -4
View File
@@ -1,6 +1,6 @@
# REST-API
Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}`. Authentifizierung über `Authorization: Bearer <JWT>`. **API-Keys** (`Bearer mindsift_sec_…` bzw. Header `X-API-Key`) liefern Daten ein, über `POST /inbox` und `POST /webhooks/ingest`, und nur für die Ziele ihrer Scopes: `webhook:triage` (Inbox), `webhook:task` (Aufgaben), `webhook:list` (Listeneinträge). Mit den Agenten-Scopes `tasks:read`, `tasks:write`, `lists:read`, `lists:write` nutzen sie außerdem den [MCP-Endpunkt](#mcp-für-ki-agenten-post-apiv1mcp). Alles andere, auch Live-Streams, antwortet mit `403` bzw. `401`. Zeitstempel sind ISO-8601 (UTC). Anfragekörper sind auf 2 MB begrenzt (Notiz-Sync 8 MB, Importe 101 MB). Im Demo-Modus antworten Endpunkte, die etwas verschicken, Server-Einstellungen ändern oder die gemeinsamen Demo-Konten verändern würden, mit `403` „In der Demo deaktiviert“. Quelle der Wahrheit sind die Routen in [server.go](../../internal/api/server.go).
Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}`. Authentifizierung über `Authorization: Bearer <JWT>`. **API-Schlüssel** (`Bearer mindsift_sec_…` bzw. Header `X-API-Key`) haben Rechte (Scopes), die für REST und [MCP](#mcp-für-ki-agenten-post-apiv1mcp) gleich gelten, siehe [API-Schlüssel](#api-schlüssel-rechte-und-herkunft). Alles andere, auch Notizen, Einstellungen und Live-Streams, antwortet einem Schlüssel mit `403`. Zeitstempel sind ISO-8601 (UTC). Anfragekörper sind auf 2 MB begrenzt (Notiz-Sync 8 MB, Importe 101 MB). Im Demo-Modus antworten Endpunkte, die etwas verschicken, Server-Einstellungen ändern oder die gemeinsamen Demo-Konten verändern würden, mit `403` „In der Demo deaktiviert“. Quelle der Wahrheit sind die Routen in [server.go](../../internal/api/server.go).
## Öffentlich
@@ -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"}'
+6 -5
View File
@@ -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
+5 -3
View File
@@ -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).
+2 -2
View File
@@ -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",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "mindsift-frontend",
"private": true,
"version": "0.29.0",
"version": "0.30.0",
"license": "Unlicense",
"type": "module",
"scripts": {
-33
View File
@@ -1,33 +0,0 @@
import React from 'react';
import { Bot } from 'lucide-react';
import { AgentMark } from '../lib/types';
interface AgentBadgeProps {
mark: AgentMark | undefined;
// sm: task rows and the task detail; xs: Kanban cards and list items
size?: 'sm' | 'xs';
}
const TITLES: Record<AgentMark, string> = {
created: 'Von einem KI-Agenten per MCP angelegt. Das Zeichen verschwindet, sobald du den Eintrag selbst änderst.',
edited: 'Zuletzt von einem KI-Agenten per MCP geändert. Das Zeichen verschwindet, sobald du den Eintrag selbst änderst.',
};
// Marks entries an AI agent created or changed via MCP that the user has not touched since
// (Doku/ui-patterns.md)
export const AgentBadge: React.FC<AgentBadgeProps> = ({ mark, size = 'sm' }) => {
if (mark !== 'created' && mark !== 'edited') return null;
const label = mark === 'created' ? 'KI' : 'KI geändert';
return (
<span
className={`px-1.5 border border-accent/40 bg-accent/10 text-accent rounded-btn font-medium inline-flex items-center gap-1 flex-shrink-0 ${
size === 'xs' ? 'py-0.2 text-[9px]' : 'py-0.5 text-[10px]'
}`}
title={TITLES[mark]}
aria-label={TITLES[mark]}
>
<Bot className="w-2.5 h-2.5" />
<span>{label}</span>
</span>
);
};
+98
View File
@@ -0,0 +1,98 @@
import React, { useEffect, useRef, useState } from 'react';
import { Bot, Webhook } from 'lucide-react';
import { Origin } from '../lib/types';
interface OriginBadgeProps {
origin: Origin | undefined;
// When the entry was created, for the details
createdAt?: string;
// xs: Kanban cards and list items; sm: task rows, inbox, detail view; md: triage cards
size?: 'xs' | 'sm' | 'md';
}
const VIA_LABEL = { api: 'API', mcp: 'MCP' } as const;
const formatWhen = (iso?: string) => {
if (!iso) return '';
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return '';
return d.toLocaleString('de-DE', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' });
};
// originDetails: who created and who last changed the entry, as one line for tooltip and detail view
export const originDetails = (origin: Origin | undefined, createdAt?: string): string => {
if (!origin || (!origin.createdVia && !origin.changedVia)) return '';
const parts: string[] = [];
const created = formatWhen(createdAt);
if (origin.createdVia) {
const key = origin.createdKey ? ` (${origin.createdKey})` : '';
parts.push(`Angelegt per ${VIA_LABEL[origin.createdVia]}${key}${created ? ` am ${created}` : ''}`);
} else {
parts.push(`In der App angelegt${created ? ` am ${created}` : ''}`);
}
if (origin.changedVia) {
const key = origin.changedKey ? ` (${origin.changedKey})` : '';
const when = formatWhen(origin.changedAt);
parts.push(`zuletzt geändert per ${VIA_LABEL[origin.changedVia]}${key}${when ? ` am ${when}` : ''}`);
}
return parts.join(' · ');
};
const SIZE_CLASS = {
xs: 'px-1.5 py-0.2 text-[9px]',
sm: 'px-1.5 py-0.5 text-[10px]',
md: 'px-2 py-0.5 text-[11px]',
};
// One badge for entries an API key created or changed (Doku/ui-patterns.md): icon for the access
// (webhook = REST, robot = MCP) and the key's name. Hover shows the details; a tap opens them, so it
// works on phones too. It stays for good.
export const OriginBadge: React.FC<OriginBadgeProps> = ({ origin, createdAt, size = 'sm' }) => {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLSpanElement>(null);
useEffect(() => {
if (!open) return;
const close = (e: Event) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener('pointerdown', close);
return () => document.removeEventListener('pointerdown', close);
}, [open]);
const via = origin?.createdVia || origin?.changedVia;
if (!origin || !via) return null;
const key = origin.createdVia ? origin.createdKey : origin.changedKey;
const label = key || VIA_LABEL[via];
const details = originDetails(origin, createdAt);
const Icon = via === 'mcp' ? Bot : Webhook;
return (
<span ref={ref} className="relative inline-flex flex-shrink-0 max-w-full">
<button
type="button"
draggable={false}
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation();
setOpen((o) => !o);
}}
title={details}
aria-label={details}
aria-expanded={open}
className={`relative border border-border bg-canvas text-muted rounded-btn font-medium inline-flex items-center gap-1 max-w-[12rem] cursor-pointer hover:text-main before:absolute before:content-[''] before:inset-x-0 before:-inset-y-3 sm:before:hidden ${SIZE_CLASS[size]}`}
>
<Icon className="w-2.5 h-2.5 text-accent flex-shrink-0" />
<span className="truncate">{label}</span>
</button>
{open && (
<span
role="tooltip"
className="absolute left-0 top-full mt-1 z-40 w-64 max-w-[80vw] p-2 bg-panel border border-border rounded-card shadow-panel text-[11px] leading-snug text-main whitespace-normal"
>
{details}
</span>
)}
</span>
);
};
@@ -1,18 +0,0 @@
import { describe, it, expect } from 'vitest';
import { render } from '@testing-library/react';
import { AgentBadge } from '../AgentBadge';
describe('AgentBadge', () => {
it('shows nothing for entries of the user', () => {
expect(render(<AgentBadge mark={undefined} />).container.firstChild).toBeNull();
});
it('tells created and edited entries apart', () => {
const created = render(<AgentBadge mark="created" />).container.firstChild as HTMLElement;
expect(created.textContent).toBe('KI');
expect(created.getAttribute('title')).toContain('angelegt');
const edited = render(<AgentBadge mark="edited" />).container.firstChild as HTMLElement;
expect(edited.textContent).toBe('KI geändert');
expect(edited.getAttribute('title')).toContain('geändert');
});
});
@@ -0,0 +1,35 @@
import { describe, it, expect } from 'vitest';
import { render, fireEvent } from '@testing-library/react';
import { OriginBadge, originDetails } from '../OriginBadge';
describe('OriginBadge', () => {
it('shows nothing for entries no key touched', () => {
expect(render(<OriginBadge origin={undefined} />).container.firstChild).toBeNull();
expect(render(<OriginBadge origin={{}} />).container.firstChild).toBeNull();
});
it('names the key that created the entry, else the one that changed it', () => {
const created = render(<OriginBadge origin={{ createdVia: 'api', createdKey: 'Home Assistant', changedVia: 'mcp', changedKey: 'Claude Code' }} />);
expect(created.container.textContent).toBe('Home Assistant');
const changed = render(<OriginBadge origin={{ changedVia: 'mcp', changedKey: 'Claude Code' }} />);
expect(changed.container.textContent).toBe('Claude Code');
const old = render(<OriginBadge origin={{ createdVia: 'mcp' }} />);
expect(old.container.textContent).toBe('MCP');
});
it('opens the details on tap', () => {
const { getByRole, queryByRole } = render(
<OriginBadge origin={{ changedVia: 'mcp', changedKey: 'Claude Code', changedAt: '2026-11-01T09:40:00Z' }} createdAt="2026-10-31T08:00:00Z" />
);
expect(queryByRole('tooltip')).toBeNull();
fireEvent.click(getByRole('button'));
const tip = getByRole('tooltip').textContent || '';
expect(tip).toContain('In der App angelegt');
expect(tip).toContain('zuletzt geändert per MCP (Claude Code)');
});
it('describes both created and changed', () => {
const text = originDetails({ createdVia: 'api', createdKey: 'HA', changedVia: 'mcp', changedKey: 'Claude Code' }, '2026-11-01T08:12:00Z');
expect(text).toMatch(/^Angelegt per API \(HA\) am .* · zuletzt geändert per MCP \(Claude Code\)$/);
});
});
+9 -6
View File
@@ -492,8 +492,8 @@ export const HelpView: React.FC<HelpViewProps> = ({ 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<HelpViewProps> = ({ onBack }) => {
content: (
<div className="space-y-2 text-muted leading-relaxed">
<p>
Unter <em className="text-main">Kontoeinstellungen → Webhooks &amp; KI-Agenten</em> kannst du persönliche API-Schlüssel erzeugen. Pro Schlüssel legst du mit Schaltern fest, was er darf: Gedanken in die Inbox, Aufgaben oder Listeneinträge einliefern (Webhooks) oder für KI-Agenten Aufgaben und Listen lesen und ändern. Notizen erreicht ein Schlüssel nie.
MindSift hat eine API. Unter <em className="text-main">Kontoeinstellungen → API</em> erzeugst du persönliche Schlüssel und legst mit Schaltern fest, was jeder darf: Aufgaben lesen, anlegen oder ändern, Listen lesen, ergänzen oder ändern und Gedanken in die Inbox liefern. Diese Rechte gelten für beide Zugänge gleich. Notizen und Einstellungen erreicht ein Schlüssel nie.
</p>
<p>
<strong className="text-main">KI-Agenten:</strong> MindSift ist ein MCP-Server (<code className="text-accent bg-panel px-1 py-0.5 rounded font-mono">POST /api/v1/mcp</code>). Claude Code, OpenAI Codex, Claude Desktop, Cursor und eigene Agenten können damit offene Aufgaben auslesen, anlegen, abhaken und löschen, neue Aufgabenlisten und Listen anlegen sowie Einkaufs- und Checklisten führen. Die Einstellungen zeigen den fertigen Befehl bzw. die Konfiguration für den jeweiligen Agenten; der Agent sieht nur die Werkzeuge, für die der Schlüssel freigegeben ist. Was ein Agent angelegt oder geändert hat, trägt das Zeichen <strong className="text-main">KI</strong> bzw. <strong className="text-main">KI geändert</strong>, bis du den Eintrag selbst änderst.
<strong className="text-main">curl und Smart Home:</strong> Mit Leserecht liefert z. B. <code className="text-accent bg-panel px-1 py-0.5 rounded font-mono">GET /api/v1/tasks?due=today&amp;status=open</code> alles, was heute ansteht, etwa für einen Home-Assistant-Sensor. Weitere Filter: <code className="font-mono">due=overdue</code>, ein Datum, <code className="font-mono">taskListId</code>. Listen kommen über <code className="font-mono">GET /api/v1/lists</code>. Mit Änderungsrecht hakst du per <code className="font-mono">PATCH /api/v1/thoughts/ID</code> ab. Die Einstellungen zeigen fertige Beispiele.
</p>
<p>
Mit dem Feld <code className="text-accent bg-panel px-1 py-0.5 rounded font-mono">"target"</code> wählst du das Ziel: <code className="font-mono">triage</code> (Standard), <code className="font-mono">task</code> (optional mit <code className="font-mono">taskListId</code>) oder <code className="font-mono">list_item</code> (mit <code className="font-mono">listId</code>). Der Simulator in den Einstellungen zeigt passende Beispiele.
<strong className="text-main">Einliefern:</strong> <code className="text-accent bg-panel px-1 py-0.5 rounded font-mono">POST /api/v1/webhooks/ingest</code> nimmt Gedanken aus iOS-Kurzbefehlen, Home Assistant oder n8n an. Mit dem Feld <code className="font-mono">"target"</code> wählst du das Ziel: <code className="font-mono">triage</code> (Standard), <code className="font-mono">task</code> (optional mit <code className="font-mono">taskListId</code>) oder <code className="font-mono">list_item</code> (mit <code className="font-mono">listId</code>).
</p>
<p>
Über den Endpunkt <code className="text-accent bg-panel px-1 py-0.5 rounded font-mono">POST /api/v1/webhooks/ingest</code> kannst du Gedanken aus iOS-Kurzbefehlen, Smart-Home-Systemen oder Drittanwendungen einspeisen. Sie werden automatisch mit dem Tag <code className="text-accent font-mono">#webhook</code> markiert.
<strong className="text-main">KI-Agenten:</strong> Über MCP (<code className="text-accent bg-panel px-1 py-0.5 rounded font-mono">POST /api/v1/mcp</code>) lesen, anlegen, ändern, abhaken und löschen Claude Code, OpenAI Codex, Claude Desktop, Cursor und eigene Agenten deine Aufgaben und Listen. Die Einstellungen zeigen den fertigen Befehl für jeden Agenten; ein Agent sieht nur die Werkzeuge, die der Schlüssel erlaubt.
</p>
<p>
<strong className="text-main">Herkunft:</strong> Was ein Schlüssel anlegt oder ändert, trägt dauerhaft eine Badge mit seinem Namen, vorne ein Webhook-Symbol (API) oder ein Roboter (MCP). Fährst du darüber oder tippst darauf, siehst du, wer wann angelegt und zuletzt geändert hat.
</p>
</div>
),
@@ -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<SharedListsViewProps> = ({ onRefreshGloba
)}
</div>
<AgentBadge mark={item.agentMark} size="xs" />
<OriginBadge origin={item.origin} createdAt={item.createdAt} size="xs" />
{renderItemAuthor(item)}
<button
type="button"
@@ -1501,7 +1501,7 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
</div>
<div className="flex items-center gap-2">
<AgentBadge mark={item.agentMark} size="xs" />
<OriginBadge origin={item.origin} createdAt={item.createdAt} size="xs" />
{renderItemAuthor(item)}
{item.completedByName && (
<span className="text-xs text-faint">✓ {item.completedByName}</span>
+133 -72
View File
@@ -18,24 +18,31 @@ interface ApiKeysPanelProps {
onIngested?: () => void;
}
// What a key may do: deliver data (webhooks) or read and change tasks and lists for AI agents (MCP)
const SCOPES: { id: ApiKeyScope; label: string; hint: string; group: 'webhook' | 'agent' }[] = [
{ id: 'webhook:triage', label: '📥 Triage / Inbox', hint: 'Gedanken in die Inbox einliefern', group: 'webhook' },
{ id: 'webhook:task', label: '📋 Aufgaben anlegen', hint: 'Aufgaben direkt anlegen', group: 'webhook' },
{ id: 'webhook:list', label: '📝 Listeneinträge anlegen', hint: 'Einträge zu Listen hinzufügen', group: 'webhook' },
{ id: 'tasks:read', label: '📋 Aufgaben lesen', hint: 'Aufgabenlisten und Aufgaben lesen, suchen', group: 'agent' },
{ id: 'tasks:write', label: '✅ Aufgaben ändern', hint: 'Aufgaben anlegen, ändern und abhaken', group: 'agent' },
{ id: 'lists:read', label: '📝 Listen lesen', hint: 'Listen und ihre Einträge lesen', group: 'agent' },
{ id: 'lists:write', label: '🛒 Listen ändern', hint: 'Einträge hinzufügen, ändern und abhaken', group: 'agent' },
// What a key may do. The rights hold for every access alike: curl/REST and MCP for AI agents. The ids of
// the delivering rights (webhook:…) stay from the time keys could only deliver data.
type ScopeGroup = 'tasks' | 'lists' | 'inbox';
const SCOPES: { id: ApiKeyScope; label: string; chip: string; hint: string; group: ScopeGroup }[] = [
{ id: 'tasks:read', label: 'Lesen', chip: 'Aufgaben lesen', hint: 'Aufgabenlisten und Aufgaben lesen, suchen', group: 'tasks' },
{ id: 'webhook:task', label: 'Anlegen', chip: 'Aufgaben anlegen', hint: 'Nur neue Aufgaben anlegen', group: 'tasks' },
{ id: 'tasks:write', label: 'Ändern', chip: 'Aufgaben ändern', hint: 'Anlegen, ändern, abhaken und löschen', group: 'tasks' },
{ id: 'lists:read', label: 'Lesen', chip: 'Listen lesen', hint: 'Listen und ihre Einträge lesen', group: 'lists' },
{ id: 'webhook:list', label: 'Einträge hinzufügen', chip: 'Listeneinträge hinzufügen', hint: 'Nur neue Einträge hinzufügen', group: 'lists' },
{ id: 'lists:write', label: 'Ändern', chip: 'Listen ändern', hint: 'Einträge hinzufügen, ändern und abhaken', group: 'lists' },
{ id: 'webhook:triage', label: 'Inbox füllen', chip: 'Inbox', hint: 'Gedanken in die Inbox einliefern', group: 'inbox' },
];
const scopeLabel = (id: string) => SCOPES.find((s) => s.id === id)?.label || id;
const isAgentScope = (sc: string) => SCOPES.find((s) => s.id === sc)?.group === 'agent';
const SCOPE_GROUPS: [ScopeGroup, string][] = [
['tasks', '📋 Aufgaben'],
['lists', '🛒 Listen'],
['inbox', '📥 Inbox'],
];
const scopeChip = (id: string) => SCOPES.find((s) => s.id === id)?.chip || id;
type SimTarget = 'triage' | 'task' | 'list_item';
type McpClient = 'claude' | 'codex' | 'json';
type Access = 'rest' | 'mcp';
// Personal API keys with scopes (switches), MCP connection snippets, a webhook simulator and copyable
// examples (settings → Webhooks & KI-Agenten)
// Personal API keys with rights (switches) and how to connect: curl / smart home (read examples,
// delivery simulator) or AI agents over MCP (settings → API)
export const ApiKeysPanel: React.FC<ApiKeysPanelProps> = ({ onIngested }) => {
const [keys, setKeys] = useState<APIKey[]>([]);
const [newKeyName, setNewKeyName] = useState('');
@@ -43,6 +50,7 @@ export const ApiKeysPanel: React.FC<ApiKeysPanelProps> = ({ onIngested }) => {
const [newlyCreatedKey, setNewlyCreatedKey] = useState<string | null>(null);
const [copiedKey, setCopiedKey] = useState(false);
const [mcpClient, setMcpClient] = useState<McpClient>('claude');
const [access, setAccess] = useState<Access>('rest');
// Live Webhook Simulator State
const [simTitle, setSimTitle] = useState('Waschmaschine im Keller ist fertig!');
@@ -179,10 +187,12 @@ http_headers = { "X-API-Key" = "${exampleToken}" }`
<Key className="w-4 h-4" />
</div>
<div>
<h3 className="text-sm font-bold text-main">API-Schlüssel für Webhooks und KI-Agenten</h3>
<h3 className="text-sm font-bold text-main">API-Schlüssel</h3>
<p className="text-xs text-muted">
Verbinde Home Assistant, n8n, eigene Skripte oder KI-Agenten (Claude Code, Codex …) mit MindSift. Was ein
Key darf, legst du pro Key fest; Notizen bleiben für Keys immer unerreichbar.
Ein Schlüssel verbindet Home Assistant, n8n, eigene Skripte oder KI-Agenten (Claude Code, Codex …) mit
MindSift. Was er darf, legst du mit den Schaltern fest; das gilt per curl und per MCP gleich. Notizen und
Einstellungen bleiben für Schlüssel gesperrt. Was ein Schlüssel anlegt oder ändert, trägt seinen Namen als
Badge.
</p>
</div>
</div>
@@ -201,18 +211,10 @@ http_headers = { "X-API-Key" = "${exampleToken}" }`
<span>API-Key erzeugen</span>
</button>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3" role="group" aria-label="Berechtigungen des Keys">
{(
[
['webhook', 'Einliefern (Webhooks)', 'POST /api/v1/webhooks/ingest'],
['agent', 'KI-Agenten (MCP)', 'POST /api/v1/mcp'],
] as const
).map(([group, heading, path]) => (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-3" role="group" aria-label="Berechtigungen des Schlüssels">
{SCOPE_GROUPS.map(([group, heading]) => (
<div key={group} className="border border-border rounded-card bg-panel px-3 py-1.5">
<p className="text-[11px] text-muted pt-1 pb-0.5 flex flex-wrap justify-between gap-x-2">
<span className="font-semibold">{heading}</span>
<code className="font-mono">{path}</code>
</p>
<p className="text-[11px] text-muted pt-1 pb-0.5 font-semibold">{heading}</p>
{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}" }`
<span className="block text-xs text-main">{sc.label}</span>
<span className="block text-[11px] text-muted">{sc.hint}</span>
</div>
<ToggleSwitch checked={on} onChange={() => toggleScope(sc.id)} title={sc.label} />
<ToggleSwitch checked={on} onChange={() => toggleScope(sc.id)} title={sc.chip} />
</div>
);
})}
@@ -257,13 +259,8 @@ http_headers = { "X-API-Key" = "${exampleToken}" }`
.split(',')
.filter(Boolean)
.map((sc) => (
<span
key={sc}
className={`px-1.5 py-0.5 rounded-btn border text-[10px] ${
isAgentScope(sc) ? 'border-accent/40 text-accent' : 'border-border text-muted'
}`}
>
{scopeLabel(sc)}
<span key={sc} className="px-1.5 py-0.5 rounded-btn border border-border text-muted text-[10px]">
{scopeChip(sc)}
</span>
))}
{k.lastUsedAt && (
@@ -285,66 +282,75 @@ http_headers = { "X-API-Key" = "${exampleToken}" }`
</div>
</div>
{/* AI agents (MCP) */}
<div className="pt-4 border-t border-border">
<div className="flex items-center gap-3 mb-3">
<div className="w-7 h-7 bg-accent text-on-accent flex items-center justify-center flex-shrink-0">
<Bot className="w-4 h-4" />
</div>
<div>
<h3 className="text-sm font-bold text-main">KI-Agenten anbinden (MCP)</h3>
<p className="text-xs text-muted">
MindSift ist ein MCP-Server: Claude Code, Codex, Claude Desktop, Cursor und eigene Agenten können offene
Aufgaben lesen, abhaken und anlegen sowie Listen führen. Dafür braucht der Key die Agenten-Berechtigungen.
</p>
</div>
</div>
<div className="flex flex-wrap gap-1.5 mb-2" role="radiogroup" aria-label="Agent">
{/* How to connect: one API, two accesses */}
<div className="pt-4 border-t border-border space-y-4">
<div className="flex flex-wrap gap-1.5" role="tablist" aria-label="Zugang">
{(
[
['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]) => (
<button
key={value}
type="button"
role="radio"
aria-checked={mcpClient === value}
onClick={() => setMcpClient(value)}
className={`h-11 sm:h-8 px-3 rounded-btn border text-xs transition-colors ${
mcpClient === value ? 'border-accent bg-accent/10 text-main' : 'border-border text-muted hover:text-main'
role="tab"
aria-selected={access === value}
onClick={() => setAccess(value)}
className={`h-11 sm:h-8 px-3 rounded-btn border text-xs flex items-center gap-1.5 transition-colors ${
access === value ? 'border-accent bg-accent/10 text-main' : 'border-border text-muted hover:text-main'
}`}
>
<Icon className="w-3.5 h-3.5 text-accent" />
{label}
</button>
))}
</div>
<pre className="p-3 bg-canvas border border-border font-mono text-xs text-main overflow-x-auto">{mcpSnippet}</pre>
{access === 'rest' && (
<div className="space-y-5">
{/* Reading: e.g. a smart home sensor for what is due today */}
<div>
<div className="flex items-center gap-2 text-xs font-medium text-muted mb-2">
<Terminal className="w-3.5 h-3.5 text-accent" />
<span>Lesen, z. B. was heute fällig ist (Recht „Aufgaben lesen“):</span>
</div>
<pre className="p-3 bg-canvas border border-border font-mono text-xs text-main overflow-x-auto">
{`curl "${baseUrl}/api/v1/tasks?due=today&status=open" \\
-H "X-API-Key: ${exampleToken}"`}
</pre>
<p className="mt-2 text-[11px] text-faint">
Werkzeuge: <code className="font-mono">list_task_lists</code>, <code className="font-mono">list_tasks</code>,{' '}
<code className="font-mono">complete_task</code>, <code className="font-mono">create_task</code>,{' '}
<code className="font-mono">update_task</code>, <code className="font-mono">list_lists</code>,{' '}
<code className="font-mono">get_list</code>, <code className="font-mono">add_list_item</code>,{' '}
<code className="font-mono">check_list_item</code>, <code className="font-mono">search</code>,{' '}
<code className="font-mono">capture_to_inbox</code>. Der Agent sieht nur die Werkzeuge, für die der Key
berechtigt ist; Freigaben gelten wie für dich selbst. ChatGPT im Browser und claude.ai brauchen OAuth und
werden noch nicht unterstützt.
Filter: <code className="font-mono">due=today</code>, <code className="font-mono">due=overdue</code> oder ein
Datum (<code className="font-mono">JJJJ-MM-TT</code>, fällig bis einschließlich), <code className="font-mono">status=open|done</code>,{' '}
<code className="font-mono">taskListId</code>. Listen: <code className="font-mono">GET /api/v1/lists</code> und{' '}
<code className="font-mono">/lists/ID</code>. Mit „Ändern“ auch <code className="font-mono">PATCH /api/v1/thoughts/ID</code>{' '}
(z. B. <code className="font-mono">{'{"isCompleted": true}'}</code>).
</p>
<div className="flex items-center gap-2 text-xs font-medium text-muted mt-3 mb-2">
<Terminal className="w-3.5 h-3.5 text-accent" />
<span>Home Assistant: Sensor „Heute fällig“ (configuration.yaml)</span>
</div>
<pre className="p-3 bg-canvas border border-border font-mono text-xs text-main overflow-x-auto">
{`rest:
- resource: "${baseUrl}/api/v1/tasks?due=today&status=open"
headers:
X-API-Key: "${exampleToken}"
scan_interval: 900
sensor:
- name: "MindSift heute fällig"
value_template: "{{ value_json | length }}"`}
</pre>
</div>
{/* Simulator */}
<div className="pt-4 border-t border-border">
<div className="flex items-center gap-3 mb-3">
<div className="w-7 h-7 bg-accent text-on-accent flex items-center justify-center flex-shrink-0">
<Webhook className="w-4 h-4" />
</div>
<div>
<h3 className="text-sm font-bold text-main">Live Smarthome Webhook-Simulator</h3>
<h3 className="text-sm font-bold text-main">Einliefern testen (Webhook)</h3>
<p className="text-xs text-muted">
Teste den Webhook-Endpunkt direkt hier
Teste das Einliefern per <code className="font-mono">POST /api/v1/webhooks/ingest</code> direkt hier
{newlyCreatedKey ? ' – mit dem eben erzeugten Key und seinen Berechtigungen.' : ' – mit deiner Anmeldung.'}
</p>
</div>
@@ -464,7 +470,62 @@ http_headers = { "X-API-Key" = "${exampleToken}" }`
</p>
</div>
</div>
</div>
)}
{access === 'mcp' && (
<div>
<div className="flex items-center gap-3 mb-3">
<div className="w-7 h-7 bg-accent text-on-accent flex items-center justify-center flex-shrink-0">
<Bot className="w-4 h-4" />
</div>
<div>
<h3 className="text-sm font-bold text-main">KI-Agenten anbinden (MCP)</h3>
<p className="text-xs text-muted">
MindSift ist ein MCP-Server: Claude Code, Codex, Claude Desktop, Cursor und eigene Agenten nutzen dieselben
Schlüssel und Rechte wie curl, nur in der Form, die Agenten erwarten.
</p>
</div>
</div>
<div className="flex flex-wrap gap-1.5 mb-2" role="radiogroup" aria-label="Agent">
{(
[
['claude', 'Claude Code'],
['codex', 'OpenAI Codex'],
['json', 'Claude Desktop / Cursor'],
] as const
).map(([value, label]) => (
<button
key={value}
type="button"
role="radio"
aria-checked={mcpClient === value}
onClick={() => setMcpClient(value)}
className={`h-11 sm:h-8 px-3 rounded-btn border text-xs transition-colors ${
mcpClient === value ? 'border-accent bg-accent/10 text-main' : 'border-border text-muted hover:text-main'
}`}
>
{label}
</button>
))}
</div>
<pre className="p-3 bg-canvas border border-border font-mono text-xs text-main overflow-x-auto">{mcpSnippet}</pre>
<p className="mt-2 text-[11px] text-faint">
Werkzeuge: <code className="font-mono">list_task_lists</code>, <code className="font-mono">create_task_list</code>,{' '}
<code className="font-mono">list_tasks</code>, <code className="font-mono">get_task</code>,{' '}
<code className="font-mono">create_task</code>, <code className="font-mono">update_task</code>,{' '}
<code className="font-mono">complete_task</code>, <code className="font-mono">delete_task</code>,{' '}
<code className="font-mono">list_lists</code>, <code className="font-mono">create_list</code>,{' '}
<code className="font-mono">get_list</code>, <code className="font-mono">add_list_item</code>,{' '}
<code className="font-mono">check_list_item</code>, <code className="font-mono">update_list_item</code>,{' '}
<code className="font-mono">search</code>, <code className="font-mono">capture_to_inbox</code>. Der Agent sieht
nur die Werkzeuge, für die der Schlüssel berechtigt ist; Freigaben gelten wie für dich selbst. ChatGPT im Browser und claude.ai brauchen OAuth und
werden noch nicht unterstützt.
</p>
</div>
)}
</div>
</div>
);
};
@@ -583,7 +583,7 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
<div>
<h1 className="text-base font-bold text-main tracking-tight">Kontoeinstellungen</h1>
<p className="text-xs text-muted">
Persönliches Profil, Design-Themes, Aufgaben-Alarme, Webhooks, KI-Agenten, Export & Import
Persönliches Profil, Design-Themes, Aufgaben-Alarme, API, Export & Import
</p>
</div>
</div>
@@ -652,8 +652,8 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
>
<Key className="w-4 h-4 text-accent flex-shrink-0" />
<div>
<p className="font-semibold">Webhooks & KI-Agenten</p>
<p className="text-[10px] text-faint">API-Keys, MCP & Curl</p>
<p className="font-semibold">API</p>
<p className="text-[10px] text-faint">Schlüssel, curl, Smart Home & KI-Agenten</p>
</div>
</button>
@@ -1865,15 +1865,15 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
)}
{/* ================================================================= */}
{/* SECTION 4: PRIVATE WEBHOOKS & AI AGENTS */}
{/* SECTION 4: API (KEYS, CURL / SMART HOME, AI AGENTS) */}
{/* ================================================================= */}
{section === 'webhooks' && (
<div className="space-y-5">
<div className="border-b border-border pb-3">
<h2 className="text-sm font-bold text-main">Webhooks & KI-Agenten</h2>
<h2 className="text-sm font-bold text-main">API</h2>
<p className="text-muted mt-0.5">
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.
</p>
</div>
@@ -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(<ApiKeysPanel />);
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(<ApiKeysPanel />);
const triage = screen.getByRole('switch', { name: /Triage/ });
const triage = screen.getByRole('switch', { name: 'Inbox' });
fireEvent.click(triage); // last remaining scope cannot be switched off
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(<ApiKeysPanel />);
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();
@@ -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<TaskDetailModalProps & { task: Thought }>
<span>Nur Lesen</span>
</span>
)}
{task.source?.toLowerCase() === 'webhook' && (
<span className="px-1.5 py-0.5 border border-border bg-canvas text-muted rounded-btn text-[10px] font-medium flex items-center gap-1 flex-shrink-0">
<Webhook className="w-2.5 h-2.5 text-accent" />
<span>Webhook</span>
</span>
)}
<AgentBadge mark={task.agentMark} />
<OriginBadge origin={task.origin} createdAt={task.createdAt} />
</div>
<div className="flex items-center gap-1.5">
@@ -895,8 +888,8 @@ const TaskDetailModalContent: React.FC<TaskDetailModalProps & { task: Thought }>
{/* Modal Footer */}
<div className="flex items-center justify-between px-4 py-2.5 border-t border-border bg-panel">
<div className="text-[11px] text-faint">
Erstellt: {new Date(task.createdAt).toLocaleDateString('de-DE')}
<div className="text-[11px] text-faint min-w-0 pr-3">
{originDetails(task.origin, task.createdAt) || `Erstellt: ${new Date(task.createdAt).toLocaleDateString('de-DE')}`}
</div>
<button
type="button"
+3 -22
View File
@@ -25,7 +25,6 @@ import {
Settings,
Folder,
Filter,
Webhook,
Repeat,
} from 'lucide-react';
import { TaskList, Thought, SubTask, Team, User, TaskListMember, KanbanColumn, PriorityLevel, Recurrence, RECURRENCE_LABELS } from '../../lib/types';
@@ -35,7 +34,7 @@ import { useKanbanTouchDrag } from './useKanbanTouchDrag';
import { api, triggerHaptic } from '../../lib/api';
import { UnifiedShareModal } from '../../components/UnifiedShareModal';
import { TaskDetailModal } from './TaskDetailModal';
import { AgentBadge } from '../../components/AgentBadge';
import { OriginBadge } from '../../components/OriginBadge';
import { ManageColumnsModal, DEFAULT_KANBAN_COLUMNS, columnDotClass } from './ManageColumnsModal';
import { AvatarStack, AvatarPerson } from '../../components/AvatarStack';
import { CountBadge } from '../../components/CountBadge';
@@ -1362,16 +1361,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
</button>
)}
{task.source?.toLowerCase() === 'webhook' && (
<span
className="px-1.5 py-0.5 border border-border bg-canvas text-muted rounded-btn text-[10px] font-medium flex items-center gap-1 flex-shrink-0"
title="Erstellt über Webhook"
>
<Webhook className="w-2.5 h-2.5 text-accent" />
<span>Webhook</span>
</span>
)}
<AgentBadge mark={task.agentMark} />
<OriginBadge origin={task.origin} createdAt={task.createdAt} />
{/* Note indicator */}
{task.notes && (
@@ -2436,16 +2426,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
⚠️ Wichtig
</button>
)}
{task.source?.toLowerCase() === 'webhook' && (
<span
className="px-1.5 py-0.2 border border-border bg-canvas text-muted rounded-btn text-[9px] font-medium flex items-center gap-1"
title="Erstellt über Webhook"
>
<Webhook className="w-2.5 h-2.5 text-accent" />
<span>Webhook</span>
</span>
)}
<AgentBadge mark={task.agentMark} size="xs" />
<OriginBadge origin={task.origin} createdAt={task.createdAt} size="xs" />
{task.dueDate && (
<span
className={`inline-flex items-center gap-0.5 text-[9px] ${!task.isCompleted && isOverdue(task.dueDate) ? 'text-status-err font-semibold' : 'text-muted'}`}
+4 -17
View File
@@ -10,7 +10,6 @@ import {
RotateCcw,
Sparkles,
Mic,
Webhook,
Keyboard,
Tag,
Clock,
@@ -25,7 +24,7 @@ import {
import { SharedList, Thought } from '../../lib/types';
import { addDays, formatDue } from '../../lib/due';
import { api, triggerHaptic } from '../../lib/api';
import { AgentBadge } from '../../components/AgentBadge';
import { OriginBadge } from '../../components/OriginBadge';
interface TriageDeckProps {
deck: Thought[];
@@ -149,17 +148,12 @@ const SwipeableQueueItem: React.FC<SwipeableQueueItemProps> = ({
<span className="text-sm sm:text-xs font-medium text-main line-clamp-2 sm:truncate">
{item.content}
</span>
{item.source?.toLowerCase() === 'webhook' && (
<span className="px-1.5 py-0.5 text-[10px] bg-canvas text-muted border border-border rounded-btn flex items-center gap-1 flex-shrink-0">
<Webhook className="w-2.5 h-2.5 text-accent" /> Webhook
</span>
)}
{item.source?.toLowerCase() === 'voice' && (
<span className="px-1.5 py-0.5 text-[10px] bg-canvas text-accent border border-border rounded-btn flex items-center gap-1 flex-shrink-0">
<Mic className="w-2.5 h-2.5" /> Diktat
</span>
)}
<AgentBadge mark={item.agentMark} />
<OriginBadge origin={item.origin} createdAt={item.createdAt} />
</div>
{/* Desktop quick buttons (hidden on mobile so queue items are clean & easy to tap/swipe) */}
@@ -441,13 +435,6 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
</span>
);
}
if (s === 'webhook') {
return (
<span className="px-2 py-0.5 text-[11px] bg-canvas text-muted border border-border rounded-btn flex items-center gap-1">
<Webhook className="w-3 h-3 text-accent" /> Webhook
</span>
);
}
return null;
};
@@ -523,7 +510,7 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
<div className="flex items-center gap-1.5">
{priorityBadge(nextCard.priority)}
{sourceBadge(nextCard.source)}
<AgentBadge mark={nextCard.agentMark} />
<OriginBadge origin={nextCard.origin} createdAt={nextCard.createdAt} size="md" />
</div>
<p className="text-sm text-muted line-clamp-2">{nextCard.content}</p>
<div className="text-[11px] text-faint">Nächster Quick Dump…</div>
@@ -593,7 +580,7 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
<div className="flex items-center gap-1.5">
{priorityBadge(activeCard.priority)}
{sourceBadge(activeCard.source)}
<AgentBadge mark={activeCard.agentMark} />
<OriginBadge origin={activeCard.origin} createdAt={activeCard.createdAt} size="md" />
</div>
<div
className="flex items-center gap-2"
+11 -4
View File
@@ -148,8 +148,15 @@ export const RECURRENCE_LABELS: Record<Exclude<Recurrence, ''>, 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 {
+142
View File
@@ -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)
}
+128
View File
@@ -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")
}
}
+18 -18
View File
@@ -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 {
+6 -5
View File
@@ -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)
+55 -5
View File
@@ -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)
+39 -28
View File
@@ -58,8 +58,10 @@ type mcpTool struct {
Name string
Description string
Scope string
// AltScope: a narrower right that allows this tool too (e.g. "create tasks" for create_task)
AltScope string
Schema map[string]interface{}
run func(s *Server, userID string, args json.RawMessage) (interface{}, error)
run func(s *Server, userID string, actor *apiActor, args json.RawMessage) (interface{}, error)
}
// mcpToolAllowed: sessions may use every tool, API keys only those of their scopes. search needs any
@@ -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
},
+37 -24
View File
@@ -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: &notes}); 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)
}
}
+1 -1
View File
@@ -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 ""
+15 -48
View File
@@ -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))
})
+12 -2
View File
@@ -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 {
@@ -571,7 +581,7 @@ type ListItem struct {
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
Origin *Origin `json:"origin,omitempty"` // API key that created / last changed it
}
type SharedList struct {
+14 -14
View File
@@ -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
}
+1 -1
View File
@@ -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))
}
+18 -8
View File
@@ -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
}
+1
View File
@@ -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 {
+106
View File
@@ -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...)
}
+1 -1
View File
@@ -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
+15 -58
View File
@@ -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(&current)
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 {