Merge dev into main (v0.53.0)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 30s

This commit is contained in:
sandrodach committed 2026-11-01 21:31:02 +01:00
commit 6f1fe67242
33 files changed
+809 -66

No files matched your search

+14 -1
View File
@@ -6,6 +6,17 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V
## [Unreleased]
## [0.53.0] - 2026-11-01
### Added
- Administration → Server & Isolation: Schalter „KI-Agenten (MCP) erlauben“. Ausgeschaltet ist der MCP-Endpunkt abgeschaltet, ebenso das Verbinden über claude.ai und ChatGPT; bestehende Connector-Verbindungen ruhen, API-Schlüssel funktionieren per curl weiter. In den API-Schlüssel-Einstellungen verschwindet dann der Reiter „KI-Agenten (MCP)“. Mit `MCP_ENABLED=false` bzw. `true` legt der Betreiber das fest, der Schalter ist dann gesperrt.
- KI-Agenten können über MCP Notizen anlegen und lesen. Dafür gibt es bei API-Schlüsseln und beim Verbinden über claude.ai/ChatGPT zwei neue Rechte unter „Notizen“: **Lesen** (Notizen auflisten, lesen und in der Suche finden) und **Anlegen** (neue Notizen mit Punkten und Unterpunkten, auf Wunsch in einem Ordner). Bestehende Notizen kann ein Agent nicht ändern. Beide Rechte sind standardmäßig aus und gelten nur über MCP, nicht per curl.
## [0.52.0] - 2026-11-01
### Added
- Notizen: Ein ausgelagerter Punkt lässt sich zurückholen. Am Link, der nach `/extract` stehen bleibt, gibt es den Knopf „Notiz zurückholen“: Die Punkte der Notiz kommen wieder als Unterpunkte an diese Stelle, der Link wird wieder zum Text, und die Notiz wandert in den Papierkorb. Strg+Z oder „Rückgängig“ macht das wieder rückgängig. Geht mit eigenen Notizen, die nirgends sonst verlinkt sind.
## [0.51.0] - 2026-11-01
### Added
@@ -786,7 +797,9 @@ 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.51.0...dev
[Unreleased]: https://git.dsit.me/sandrodach/MindSift/compare/v0.53.0...dev
[0.53.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.52.0...v0.53.0
[0.52.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.51.0...v0.52.0
[0.51.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.50.0...v0.51.0
[0.50.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.49.2...v0.50.0
[0.49.2]: https://git.dsit.me/sandrodach/MindSift/compare/v0.49.1...v0.49.2
+15 -5
View File
@@ -7,7 +7,7 @@ Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}`
| Methode | Pfad | Zweck |
|---|---|---|
| GET | `/health` | Lebenszeichen |
| GET | `/auth/status` | `needsSetup`, `allowRegistration`, `instanceName`, `demoMode`, `sharingMode`, `legal` (`imprintUrl`, `hasImprintContent`, `privacyUrl`, `hasPrivacyContent`, `customLinkLabel`, `customLinkUrl`), `voice` (`mode` = `server`/`browser`/`off`, `maxSeconds`, `browserAllowed`) |
| GET | `/auth/status` | `needsSetup`, `allowRegistration`, `instanceName`, `demoMode`, `sharingMode`, `legal` (`imprintUrl`, `hasImprintContent`, `privacyUrl`, `hasPrivacyContent`, `customLinkLabel`, `customLinkUrl`), `voice` (`mode` = `server`/`browser`/`off`, `maxSeconds`, `browserAllowed`), `mcpEnabled`, `mcpConnectorUrl` |
| GET | `/legal/{imprint\|privacy}` | Hinterlegter Markdown-Text (`content`), ohne Anmeldung; `404`, wenn keiner gesetzt ist |
| POST | `/share-target` | PWA Web Share Target Fallback: Weiterleitung auf `/` (`303 See Other`), falls Service Worker noch inaktiv |
| POST | `/auth/setup` | Erstes Administratorkonto (`email`, `password`, `displayName`, `instanceName`, `allowRegistration`, `sharingMode`, `seedSampleData`); nur solange es keine Konten gibt |
@@ -49,7 +49,7 @@ Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}`
| DELETE | `/user/account` | Eigenes Konto mit allen Daten löschen (`{"password"}` nötig); `403` für den letzten Server-Admin. `/auth/me` liefert dafür `isLastAdmin` |
| GET | `/backups`, `/backups/{name}` | Eigene automatische Server-Backups auflisten bzw. herunterladen |
| POST | `/backups/{name}/restore` | Server-Backup einspielen: `mode=merge` oder `restore` (gewählte Bereiche vorher leeren), `sections=notes,tasks,lists,inbox,themes`, `preview=1` |
| GET/POST | `/keys` | API-Keys auflisten bzw. anlegen (Klartext nur bei Erstellung); `POST` mit `name` und `scopes` (`webhook:triage`, `webhook:task`, `webhook:list`, `tasks:read`, `tasks:write`, `lists:read`, `lists:write`; Standard `["webhook:triage"]`) |
| GET/POST | `/keys` | API-Keys auflisten bzw. anlegen (Klartext nur bei Erstellung); `POST` mit `name` und `scopes` (`webhook:triage`, `webhook:task`, `webhook:list`, `tasks:read`, `tasks:write`, `lists:read`, `lists:write`, `notes:read`, `notes:create`; Standard `["webhook:triage"]`) |
| POST | `/mcp` | MCP-Endpunkt für KI-Agenten, siehe unten |
| PATCH | `/keys/{id}` | Name (`name`) und/oder Rechte (`scopes`) ändern; der Schlüssel selbst bleibt gleich |
| DELETE | `/keys/{id}` | API-Key löschen |
@@ -83,6 +83,8 @@ Führt eine Volltextsuche über SQLite FTS5 mit BM25-Relevanz-Ranking aus.
MindSift ist ein [MCP](https://modelcontextprotocol.io)-Server (Streamable HTTP, zustandslos, nur JSON-RPC 2.0 per `POST`; kein Server-Stream, `GET` antwortet `405`, Batches werden abgelehnt). Anmeldung wie überall per `X-API-Key` oder `Authorization: Bearer`; eine Sitzung darf alles, ein API-Key nur die Werkzeuge seiner Scopes, und `tools/list` zeigt ihm nur diese. Protokollversionen `2025-06-18`, `2025-03-26`, `2024-11-05`. Unterstützte Methoden: `initialize`, `ping`, `tools/list`, `tools/call`; Benachrichtigungen antworten `202`. Fehler eines Werkzeugs kommen als `isError`-Ergebnis zurück, damit der Agent sie lesen kann; Ergebnisse stehen als `structuredContent` (Listen als `{items}`) und als JSON-Text.
Betreiber können MCP abschalten (`MCP_ENABLED=false` oder Schalter in der Administration, Feld `mcpEnabled` in `/admin/settings`). Dann antwortet der Endpunkt mit `404`, OAuth ist ebenfalls aus, und `GET /auth/status` liefert `mcpEnabled: false`.
| Werkzeug | Scope | Zweck |
|---|---|---|
| `list_task_lists` | `tasks:read` | Aufgabenlisten mit `permission`, offenen Aufgaben und Board-Spalten (`columns`: gültige Werte für `kanbanStatus`) |
@@ -99,7 +101,10 @@ MindSift ist ein [MCP](https://modelcontextprotocol.io)-Server (Streamable HTTP,
| `add_list_item` | `lists:write` | Eintrag hinzufügen (`listId`, `content`, `category`, `quantity`) |
| `check_list_item` | `lists:write` | Eintrag abhaken (`completed` false = zurück) |
| `update_list_item` | `lists:write` | Text, Kategorie, Menge ändern |
| `search` | ein Lese-Scope | Volltextsuche in Aufgaben und Listeneinträgen (nur die Teile der eigenen Scopes, keine Notizen) |
| `list_notes` | `notes:read` | Notizen ohne Inhalt, zuletzt geänderte zuerst (`folder` samt Unterordnern, `query` im Titel, `limit`) |
| `get_note` | `notes:read` | Eine Notiz; `content` sind die Punkte als eingerückte Markdown-Liste (Tab je Ebene, `- [ ]` für To-dos, Bilder und Aufnahmen als Platzhalter) |
| `create_note` | `notes:create` | Neue Notiz (`title`, `content` als Markdown wie beim Import, `folderPath`, `icon`); in geteilte Ordner nur mit Schreibrecht. Bestehende Notizen ändert kein Werkzeug |
| `search` | ein Lese-Scope | Volltextsuche in Aufgaben, Listeneinträgen und Notizen (nur die Teile der eigenen Scopes); Notizen als `notes` mit `noteId`, `title`, `snippet` |
| `capture_to_inbox` | `webhook:triage` | Gedanken in die Inbox |
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`.
@@ -108,7 +113,7 @@ Freigaben gelten wie für die Person selbst: Leser-Listen lassen sich nicht änd
### OAuth für claude.ai und ChatGPT
Clients, die keinen `X-API-Key` setzen können, verbinden sich per OAuth 2.1 nach der MCP-Spezifikation (Autorisierung, Stand 2025-06-18). In claude.ai wird dafür nur `<BASE_URL>/api/v1/mcp` als eigener Connector eingetragen; den Rest erledigt der Client. Voraussetzung ist `BASE_URL` (Issuer und Ressource); ohne `BASE_URL` und im Demo-Modus antworten alle Endpunkte mit `404`. `GET /auth/status` liefert die Adresse als `mcpConnectorUrl` (leer, wenn OAuth aus ist).
Clients, die keinen `X-API-Key` setzen können, verbinden sich per OAuth 2.1 nach der MCP-Spezifikation (Autorisierung, Stand 2025-06-18). In claude.ai wird dafür nur `<BASE_URL>/api/v1/mcp` als eigener Connector eingetragen; den Rest erledigt der Client. Voraussetzung ist `BASE_URL` (Issuer und Ressource); ohne `BASE_URL`, im Demo-Modus und bei abgeschaltetem MCP antworten alle Endpunkte mit `404`. `GET /auth/status` liefert die Adresse als `mcpConnectorUrl` (leer, wenn OAuth aus ist).
| Methode | Pfad (ohne `/api/v1`) | Zweck |
|---|---|---|
@@ -121,7 +126,7 @@ Clients, die keinen `X-API-Key` setzen können, verbinden sich per OAuth 2.1 nac
Die Zustimmungsseite nutzt `GET`/`POST /api/v1/oauth/authorize` (nur mit Anmeldung, nicht mit Schlüsseln).
- **Rechte:** dieselben wie bei Schlüsseln, angeboten werden `tasks:read`, `tasks:write`, `lists:read`, `lists:write` und `webhook:triage`. Fragt der Client nichts Bestimmtes an, sind Lesen und Ändern für Aufgaben und Listen vorbelegt.
- **Rechte:** dieselben wie bei Schlüsseln, angeboten werden `tasks:read`, `tasks:write`, `lists:read`, `lists:write`, `notes:read`, `notes:create` und `webhook:triage`. Fragt der Client nichts Bestimmtes an, sind Lesen und Ändern für Aufgaben und Listen vorbelegt.
- **Verbindung = Eintrag unter `/keys`** mit `kind: "oauth"` und dem Namen „<Client> (OAuth)“. Rechte ändern, umbenennen und löschen wie bei jedem Schlüssel; Herkunft (`origin`) mit diesem Namen. Erneutes Zustimmen desselben Clients verwendet den Eintrag weiter.
- **Tokens:** Code 10 Minuten und einmal gültig, Zugangs-Token (`mindsift_oat_…`) 1 Stunde, Refresh-Token (`mindsift_ort_…`) 90 Tage und bei jeder Erneuerung neu. Wird ein bereits ersetzter Refresh-Token noch einmal benutzt, enden alle Tokens der Verbindung. Zugangs-Tokens gelten nur für `POST /mcp`, nicht für REST oder Webhooks.
- **Verfall:** Eine Verbindung, die `OAUTH_IDLE_DAYS` (Standard 90, `0` = nie) nicht benutzt wurde, wird gelöscht.
@@ -140,6 +145,10 @@ Ein Schlüssel darf nur diese Endpunkte und braucht dafür eines der genannten R
| `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` |
| `notes:read` | Notizen lesen | – | `list_notes`, `get_note`, `search` |
| `notes:create` | Notizen anlegen | – | `create_note` |
Notizen gibt es nur über MCP; per REST bleiben sie für Schlüssel gesperrt. Notizen tragen keine Herkunft (`origin`).
Die Ids `webhook:…` stammen aus der Zeit, als Schlüssel nur einliefern konnten, und bleiben für bestehende Schlüssel gleich.
@@ -211,6 +220,7 @@ Schreibzugriffe von Lesern auf Listen oder deren Aufgaben liefern `403` („Kein
| GET | `/journal` | Tage des eigenen Journals, neueste zuerst → `{days: [{date, docId, empty}]}` (`empty`: Notiz noch ohne Text; Tage im Papierkorb fehlen) |
| POST | `/journal/{date}` | Notiz des Tages (`JJJJ-MM-TT`) öffnen; gibt es noch keine, wird sie angelegt (Titel z. B. „Freitag, 30. Oktober 2026“, Icon 📅) → Dokument, `201` bei Neuanlage. Die Journal-Notizen sind normale Notizen (Teilen, Suche, Sync) |
| POST | `/documents/{id}/extract/undo` | Legt die ausgelagerte Notiz `{id}` wieder in den Papierkorb (nur ihr Ersteller) |
| POST | `/documents/{id}/inline` | `{blockId}`: Gegenstück zu `extract` für einen Punkt, der nur aus `[[Notiz]]` (ggf. mit Hashtags) besteht → `{sourceId, content, blocks}`: Text für den Punkt (Titel plus Hashtags) und die Punkte der Notiz mit neuen IDs (`level` 0 = direkt unter dem Punkt). Die verlinkte Notiz kommt in den Papierkorb; rückgängig mit `POST /documents/{sourceId}/restore`. Die Ursprungsnotiz ändert der Editor selbst. `400` kein Link-Punkt, `404` Notiz nicht gefunden, `403` fremde Notiz, `409` noch aus anderen Notizen verlinkt |
| POST | `/documents/{id}/merge` | `{sourceId}`: hängt die (eigene) Quellnotiz als Überschrift mit Unterpunkten an diese Notiz an, Quelle in den Papierkorb → `{targetId, sourceId, sourceTitle, headingId}` |
| POST | `/documents/{id}/merge/undo` | `{sourceId, headingId}`: Abschnitt wieder entfernen, Quelle wiederherstellen |
| POST | `/documents/{id}/media` | Bild oder Audio zu einer Notiz hochladen (`multipart/form-data`, Feld `file`; Schreibrecht nötig) → `{id, kind, mime, size, url}`. `415` falsches Format, `413` zu groß oder Kontingent voll |
+3 -2
View File
@@ -78,6 +78,7 @@ Das Frontend liegt per `go:embed` im Binary ([cmd/server/main.go](../cmd/server/
| `MEDIA_MAX_IMAGE_MB`, `MEDIA_MAX_AUDIO_MB` | `15` / `50` | Größtes Bild bzw. größte Audiodatei in Notizen in MB |
| `MEDIA_QUOTA_MB` | `0` | Speicherplatz für Medien pro Benutzer in MB (`0` = unbegrenzt) |
| `MEDIA_DEMO_MAX_MB` | `3` | Nur im Demo-Modus: maximale Dateigröße für Medien-Uploads in MB |
| `MCP_ENABLED` | leer | `false` schaltet `/api/v1/mcp` ab (antwortet `404`, auch ohne Anmeldung) und damit OAuth; `true` lässt es fest an. Leer: Schalter „KI-Agenten (MCP) erlauben“ in Administration → Server & Isolation (Standard: an) |
| `RETENTION_ALLOW_NEVER` | leer | `false` verbietet „nie“ bei den Aufbewahrungsfristen (Kontoeinstellungen → Aufbewahrung); wer „nie“ gewählt hatte, bekommt die Voreinstellung des Bereichs. `true` erlaubt es fest. Leer: Schalter in Administration → Server & Isolation (Standard: erlaubt) |
| `STT_URL` | leer | Eigene Spracherkennung: Basis-URL eines OpenAI-kompatiblen Whisper-Servers (z. B. `http://whisper:8000`, speaches). Gesetzt = Server-Modus, sonst Spracherkennung des Browsers; in der Demo immer Browser |
| `STT_MODEL`, `STT_LANGUAGE`, `STT_API_KEY` | `deepdml/faster-whisper-large-v3-turbo-ct2` / `de` / leer | Modell, Sprache (`auto` = erkennen), Schlüssel falls der Whisper-Server einen verlangt |
@@ -100,8 +101,8 @@ Hinweis: `DATABASE_URL` mit `postgres…` führt dazu, dass der Standardpfad in
- **Einladungscodes:** `INV-` plus 100 Zufallsbits in Base32. Die Registrierung beansprucht den Code, bevor das Konto entsteht (kein doppeltes Einlösen).
- **JWT und Sitzungen:** HS256, Claims `userId`, `email`, `displayName`, `tokenVersion`, `jti`. Jede Anmeldung legt eine Zeile in `sessions` an (Gerät, IP, letzte Nutzung); `jti` ist deren ID. Gültig ist ein Token nur, wenn die Sitzung aktiv ist (nicht beendet, höchstens 30 Tage unbenutzt), das Konto aktiv ist und `tokenVersion` mit `users.token_version` übereinstimmt. Ist das Token älter als 24 Stunden, schickt der Server im Header `X-Session-Token` ein neues für dieselbe Sitzung; das Frontend übernimmt es (`request()` in `lib/api.ts`). Aktive Geräte bleiben so angemeldet, unbenutzte fallen nach 30 Tagen heraus (`internal/api/sessions.go`). Neue Passwörter brauchen mindestens 8 Zeichen (`auth.ValidatePassword`). `token_version` wird bei Passwortänderung, Rollenwechsel, Deaktivierung und „Alle Sitzungen beenden“ erhöht.
- **Logout** beendet die Sitzung des Geräts; `GET /auth/sessions` listet die Geräte, `DELETE /auth/sessions/{id}` meldet eines ab. Passwort und Login-E-Mail ändern sich nur mit dem aktuellen Passwort (`currentPassword`, falsche Eingaben zählen für die Login-Sperre); ein neues Passwort meldet alle anderen Geräte ab, dieses Gerät erhält per `X-Session-Token` ein neues Token. Ist SMTP eingerichtet, geht eine Hinweismail an die bisherige Adresse.
- **API-Keys:** `mindsift_sec_<hex>`, gespeichert als SHA-256, nur das Präfix bleibt sichtbar. Der Key gilt als Bearer-Token oder in `X-API-Key`. Scopes `webhook:*` liefern Daten an (`POST /inbox`, `POST /webhooks/ingest`), die Agenten-Scopes `tasks:read`, `tasks:write`, `lists:read`, `lists:write` gelten nur am MCP-Endpunkt (`POST /mcp`, `internal/api/mcp.go`); jedes Werkzeug prüft seinen Scope, `tools/list` zeigt nur erlaubte. Alle anderen Endpunkte antworten für Keys mit `403`; Notizen sind mit Keys nie erreichbar.
- **OAuth für MCP** (`internal/api/oauth.go`, `internal/storage/oauth.go`): MindSift ist mit `BASE_URL` sein eigener OAuth-Server für `/api/v1/mcp` (Discovery nach RFC 9728/8414, dynamische Registrierung, Code-Flow mit PKCE S256, rotierende Refresh-Tokens), damit sich claude.ai und ChatGPT verbinden können. Die Zustimmungsseite ist Teil der App (`/oauth/authorize`, `OAuthConsentView`), weil die Anmeldung im `localStorage` liegt. Eine Zustimmung wird ein Eintrag in `api_keys` (`kind = 'oauth'`); Tokens (`mindsift_oat_`/`mindsift_ort_`) hängen daran und gelten nur am MCP-Endpunkt, Rechte und Herkunft laufen wie bei Schlüsseln. Abgelaufene Tokens und unbenutzte Verbindungen (`OAUTH_IDLE_DAYS`) räumt der stündliche Aufräum-Lauf weg. Im Demo-Modus aus.
- **API-Keys:** `mindsift_sec_<hex>`, gespeichert als SHA-256, nur das Präfix bleibt sichtbar. Der Key gilt als Bearer-Token oder in `X-API-Key`. Scopes `webhook:*` liefern Daten an (`POST /inbox`, `POST /webhooks/ingest`), die Agenten-Scopes `tasks:read`, `tasks:write`, `lists:read`, `lists:write` gelten nur am MCP-Endpunkt (`POST /mcp`, `internal/api/mcp.go`); jedes Werkzeug prüft seinen Scope, `tools/list` zeigt nur erlaubte. Alle anderen Endpunkte antworten für Keys mit `403`. Notizen erreichen Keys nur über MCP (`notes:read`: auflisten, lesen, suchen; `notes:create`: neue anlegen, Inhalt als Markdown über `MarkdownToBlocks`), nie per REST.
- **OAuth für MCP** (`internal/api/oauth.go`, `internal/storage/oauth.go`): MindSift ist mit `BASE_URL` sein eigener OAuth-Server für `/api/v1/mcp` (Discovery nach RFC 9728/8414, dynamische Registrierung, Code-Flow mit PKCE S256, rotierende Refresh-Tokens), damit sich claude.ai und ChatGPT verbinden können. Die Zustimmungsseite ist Teil der App (`/oauth/authorize`, `OAuthConsentView`), weil die Anmeldung im `localStorage` liegt. Eine Zustimmung wird ein Eintrag in `api_keys` (`kind = 'oauth'`); Tokens (`mindsift_oat_`/`mindsift_ort_`) hängen daran und gelten nur am MCP-Endpunkt, Rechte und Herkunft laufen wie bei Schlüsseln. Abgelaufene Tokens und unbenutzte Verbindungen (`OAUTH_IDLE_DAYS`) räumt der stündliche Aufräum-Lauf weg. Im Demo-Modus aus, ebenso wenn MCP abgeschaltet ist (`MCP_ENABLED` bzw. Admin-Schalter).
- **Passwort vergessen:** nur wenn SMTP und `BASE_URL` gesetzt sind (nicht im Demo-Modus; `passwordReset` in `/auth/status`). `POST /auth/forgot-password` antwortet immer gleich und verschickt die Mail im Hintergrund, höchstens alle 2 Minuten pro Konto. Der Link `BASE_URL/?resetToken=…` gilt eine Stunde und einmal; gespeichert wird nur der SHA-256-Hash (`password_reset_tokens`). `POST /auth/reset-password` setzt das Passwort, entwertet alle offenen Links des Kontos und erhöht `token_version` (alle Sitzungen enden). Die App entfernt den Token sofort aus der Adresszeile.
- **Registrierung:** nur mit Einladungscode oder wenn `allow_registration` aktiv ist. Das erste Konto entsteht über `/auth/setup` und wird Administrator.
+1
View File
@@ -18,6 +18,7 @@ Code: [WorkspaceView.tsx](../../frontend/src/features/workspace/WorkspaceView.ts
- **Slash-Befehle:** `/todo`, `/link`, `/h1`, `/h2`, `/h3`, `/code` (mit Syntax-Highlighting im VS-Code-Stil), `/callout`, `/divider`, `/extract` (Ast als eigene Notiz auslagern), `/image` (Bilder einfügen/hochladen, Aliase `/bild`, `/foto`), `/audio` (Sprachnotiz/Audiodatei, Alias `/sprachnotiz`), `/diktat` (Spracheingabe mit Live-Mitschreiben, Alias `/dictate`) (Liste in `OutlinerBlock.tsx`). Das Menü öffnet sich nur am Textanfang oder nach einem Leerzeichen und schließt mit `Esc` oder `Backspace`.
- **Link-Vorschau:** Besteht ein Bulletpoint ausschließlich aus einer einzelnen `https`-Adresse (oder wurde über `/link` in einem leeren Punkt eingefügt), zeigt MindSift darüber eine interaktive Vorschaukarte (`LinkPreviewCard`, serverseitig via `/link-preview`). ✕ blendet die Karte aus und wandelt die Adresse synchronisiert in `[Titel](url)` um.
- **Ast auslagern (`/extract`):** Wandelt den aktuellen Punkt samt Unterpunkten in eine eigenständige Notiz im selben Ordner um. Der Server legt die Notiz an und liefert den Link zurück; der Editor ersetzt den Ast lokal über die Sync-Engine als eigene Operation (Strg+Z nimmt die Auslagerung zurück und legt die neue Notiz via `POST /documents/{id}/extract/undo` in den Papierkorb).
- **Notiz zurückholen:** Besteht ein Punkt nur aus einem Link auf eine Notiz (wie nach `/extract`), zeigt seine Knopfleiste „Notiz zurückholen“. `POST /documents/{id}/inline` liefert die Punkte der verlinkten Notiz und legt sie in den Papierkorb; der Editor setzt den Titel (mit den Hashtags des Links) wieder als Text ein und fügt die Punkte als Unterpunkte ein, vor eventuell schon vorhandenen. Strg+Z oder „Rückgängig“ stellt den Link wieder her und holt die Notiz aus dem Papierkorb. Nur mit eigenen Notizen, und nicht, solange andere Notizen sie noch verlinken.
- **Notizen zusammenführen (Merge):** Per Drag & Drop einer Notiz auf eine andere in der Seitenleiste oder über das Drei-Punkte-Menü im Notizkopf („Mit anderer Notiz zusammenführen…“). Die eigene Quellnotiz wird als `h2`-Abschnitt angehängt und in den Papierkorb verschoben; der Hinweis bietet vollständiges Rückgängigmachen (`POST /documents/{id}/merge/undo`).
- **Rückverweise:** Unter jeder Notiz listet „Verlinkt auf diese Notiz (n)“ alle Notizen mit `[[Titel]]` (auch `[[Titel|Text]]`, `[[Titel#Abschnitt]]`, Groß-/Kleinschreibung egal) samt Fundstelle; ein Klick springt direkt zum Bullet. Ohne Rückverweise bleibt der Bereich unsichtbar, eingeklappt merkt er sich im Browser ([BacklinksSection.tsx](../../frontend/src/features/workspace/BacklinksSection.tsx), `GET /documents/{id}/backlinks`).
- **Links:** `[[Notiztitel]]`, Markdown-Links `[Titel](url)` und reine URLs werden als Token dargestellt; `LinkModal` hilft beim Verknüpfen von Notizen.
+2 -1
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.51.0-16a34a?style=flat-square)](CHANGELOG.md)
[![Version](https://img.shields.io/badge/Version-v0.53.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.
@@ -195,6 +195,7 @@ Rufe `http://localhost:8080` auf. Beim ersten Start führt ein Assistent durch d
| `JWT_SECRET` | zufällig erzeugt (`/data/jwt.secret`) | Signaturschlüssel der Anmelde-Tokens; Platzhalter werden ignoriert |
| `BASE_URL` | `http://localhost:8080` | Öffentliche URL. Nur wenn gesetzt (und SMTP eingerichtet ist), gibt es „Passwort vergessen?“ – die Links in den Mails werden ausschließlich daraus gebaut |
| `TRUSTED_PROXIES` | `private` | Wessen `X-Forwarded-For`/`X-Real-IP` geglaubt wird: `private` (Proxy im Docker-/Heimnetz), `all` (immer, früheres Verhalten), `none` oder eigene CIDR-Bereiche ([Doku/reverse-proxy.md](Doku/reverse-proxy.md#client-ip-und-trusted_proxies)) |
| `MCP_ENABLED` | leer (Admin-UI entscheidet) | `false` schaltet den MCP-Endpunkt für KI-Agenten ab, damit auch die Connector-Anmeldung (OAuth) für claude.ai und ChatGPT; `true` lässt ihn fest an. API-Schlüssel funktionieren per curl weiter |
| `OAUTH_IDLE_DAYS` | `90` | Verbindungen von claude.ai, ChatGPT & Co. (OAuth), die so viele Tage nicht benutzt wurden, werden getrennt. `0` = nie. OAuth selbst gibt es nur mit `BASE_URL` |
| `LOGIN_PROTECTION` | `true` | Sperre nach Fehlversuchen: 3 pro Konto und Adresse, 20 pro Konto, 30 pro Adresse, jeweils 15 Minuten. `false` schaltet sie ab |
| `TZ` | `Europe/Berlin` | Zeitzone für Fälligkeiten und Alarme (ganztägige Aufgaben melden sich um 9:00) |
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "mindsift-frontend",
"version": "0.51.0",
"version": "0.53.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mindsift-frontend",
"version": "0.51.0",
"version": "0.53.0",
"license": "AGPL-3.0-only",
"dependencies": {
"@fontsource-variable/inter": "5.3.0",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "mindsift-frontend",
"private": true,
"version": "0.51.0",
"version": "0.53.0",
"license": "AGPL-3.0-only",
"type": "module",
"scripts": {
@@ -85,7 +85,7 @@ export const OAuthConsentView: React.FC<OAuthConsentViewProps> = ({ user, instan
<h2 className="text-sm font-bold text-main">{info.clientName} möchte auf dein MindSift zugreifen</h2>
<p className="text-xs text-muted mt-0.5">
Angemeldet als <span className="text-main">{user.displayName || user.email}</span>. Freigaben gelten wie
für dich selbst; Notizen und Einstellungen bleiben gesperrt.
für dich selbst; Einstellungen bleiben gesperrt.
</p>
</div>
</div>
+1 -1
View File
@@ -207,7 +207,7 @@ export const HelpView: React.FC<HelpViewProps> = ({ onBack }) => {
</div>
<div className="p-1.5 bg-panel rounded border border-border flex items-center gap-1.5 text-main col-span-2">
<span className="text-accent font-bold">📤</span>
<span>/extract oder /notiz (Punkt mit allen Unterpunkten in eine eigene Notiz auslagern, an seiner Stelle bleibt ein [[Link]])</span>
<span>/extract oder /notiz (Punkt mit allen Unterpunkten in eine eigene Notiz auslagern, an seiner Stelle bleibt ein [[Link]]; der Knopf „Notiz zurückholen“ am Link macht das wieder rückgängig)</span>
</div>
<div className="p-1.5 bg-panel rounded border border-border flex items-center gap-1.5 text-main col-span-2">
<span className="text-accent font-bold">🎙️</span>
@@ -160,6 +160,8 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
backupRetentionDays: sys.backupRetentionDays || 3,
retentionAllowNever: sys.retentionAllowNever ?? true,
retentionNeverViaEnv: Boolean(sys.retentionNeverViaEnv),
mcpEnabled: sys.mcpEnabled ?? true,
mcpConfiguredViaEnv: Boolean(sys.mcpConfiguredViaEnv),
});
}
} catch (err: any) {
@@ -1334,6 +1336,24 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
/>
</div>
{/* AI agents over MCP (and claude.ai / ChatGPT over OAuth) */}
<div className="p-4 bg-canvas border border-border rounded-card flex items-center justify-between gap-4">
<div className="pr-4">
<p className="font-bold text-main">KI-Agenten (MCP) erlauben</p>
<p className="text-[11px] text-muted mt-0.5">
Eingeschaltet können Claude Code, Codex & Co. per API-Schlüssel und claude.ai oder ChatGPT als Connector
auf MindSift zugreifen. Ausgeschaltet ist der MCP-Endpunkt samt Connector-Anmeldung abgeschaltet;
bestehende Connector-Verbindungen ruhen, API-Schlüssel funktionieren per curl weiter.
{settings.mcpConfiguredViaEnv && ' Festgelegt per MCP_ENABLED.'}
</p>
</div>
<ToggleSwitch
checked={settings.mcpEnabled !== false}
disabled={Boolean(settings.mcpConfiguredViaEnv)}
onChange={(mcpEnabled) => handleSaveSystemSettings({ mcpEnabled })}
/>
</div>
{/* Who may register without an invite */}
{(() => {
const mode = settings.registrationMode || 'closed';
+41 -32
View File
@@ -37,6 +37,8 @@ export const ApiKeysPanel: React.FC<ApiKeysPanelProps> = ({ onIngested }) => {
const [connectorUrl, setConnectorUrl] = useState('');
const [copiedConnector, setCopiedConnector] = useState(false);
const [access, setAccess] = useState<Access>('rest');
// The operator can switch MCP off; then only the curl / smart home access is shown
const [mcpEnabled, setMcpEnabled] = useState(true);
// Live Webhook Simulator State
const [simTitle, setSimTitle] = useState('Waschmaschine im Keller ist fertig!');
@@ -59,7 +61,10 @@ export const ApiKeysPanel: React.FC<ApiKeysPanelProps> = ({ onIngested }) => {
api.getTaskLists().then(setTaskLists).catch(() => {});
api
.getAuthStatus()
.then((st) => setConnectorUrl(st.mcpConnectorUrl || ''))
.then((st) => {
setConnectorUrl(st.mcpConnectorUrl || '');
setMcpEnabled(st.mcpEnabled !== false);
})
.catch(() => {});
}, []);
@@ -95,10 +100,10 @@ export const ApiKeysPanel: React.FC<ApiKeysPanelProps> = ({ onIngested }) => {
}
};
// Switches grouped by tasks, lists and inbox (same layout for a new and an existing key)
// Switches grouped by tasks, lists, notes and inbox (same layout for a new and an existing key)
const renderScopeSwitches = (selected: ApiKeyScope[], toggle: (id: ApiKeyScope) => void, label: string) => (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-3" role="group" aria-label={label}>
{SCOPE_GROUPS.map(([group, heading]) => (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3" role="group" aria-label={label}>
{SCOPE_GROUPS.filter(([group]) => mcpEnabled || group !== 'notes').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 font-semibold">{heading}</p>
{SCOPES.filter((sc) => sc.group === group).map((sc) => (
@@ -232,9 +237,9 @@ http_headers = { "X-API-Key" = "${exampleToken}" }`
<h3 className="text-sm font-bold text-main">API-Schlüssel</h3>
<p className="text-xs text-muted">
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.
MindSift. Was er darf, legst du mit den Schaltern fest; das gilt per curl und per MCP gleich, nur Notizen
gibt es ausschließlich über MCP. Einstellungen bleiben für Schlüssel gesperrt. Was ein Schlüssel bei Aufgaben
und Listen anlegt oder ändert, trägt seinen Namen als Badge.
</p>
</div>
</div>
@@ -358,30 +363,32 @@ http_headers = { "X-API-Key" = "${exampleToken}" }`
{/* 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">
{(
[
['rest', 'curl / Smart Home', Terminal],
['mcp', 'KI-Agenten (MCP)', Bot],
] as const
).map(([value, label, Icon]) => (
<button
key={value}
type="button"
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>
{mcpEnabled && (
<div className="flex flex-wrap gap-1.5" role="tablist" aria-label="Zugang">
{(
[
['rest', 'curl / Smart Home', Terminal],
['mcp', 'KI-Agenten (MCP)', Bot],
] as const
).map(([value, label, Icon]) => (
<button
key={value}
type="button"
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>
)}
{access === 'rest' && (
{(access === 'rest' || !mcpEnabled) && (
<div className="space-y-5">
{/* Reading: e.g. a smart home sensor for what is due today */}
<div>
@@ -547,7 +554,7 @@ http_headers = { "X-API-Key" = "${exampleToken}" }`
</div>
)}
{access === 'mcp' && (
{access === 'mcp' && mcpEnabled && (
<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">
@@ -629,7 +636,9 @@ http_headers = { "X-API-Key" = "${exampleToken}" }`
<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
<code className="font-mono">list_notes</code>, <code className="font-mono">get_note</code>,{' '}
<code className="font-mono">create_note</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 bzw. die Verbindung berechtigt ist; Freigaben gelten wie für dich selbst.
</p>
</div>
@@ -1956,7 +1956,8 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
<h2 className="text-sm font-bold text-main">API</h2>
<p className="text-muted mt-0.5">
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.
und Codex deine Aufgaben, Listen und die Inbox: per curl oder über MCP, mit denselben Rechten. Notizen
lesen und anlegen geht nur über MCP.
</p>
</div>
@@ -3,13 +3,16 @@ import { render, screen, fireEvent, act, within } from '@testing-library/react';
const createAPIKey = vi.fn(async () => ({ id: 'k1', userId: 'u', name: 'HA', keyPrefix: 'mindsift_sec_ab', scopes: 'webhook:triage,webhook:list', createdAt: '' }));
const updateAPIKey = vi.fn(async () => ({}));
const getAuthStatus = vi.fn(async (): Promise<{ mcpConnectorUrl?: string; mcpEnabled?: boolean }> => ({
mcpConnectorUrl: 'https://ms.example/api/v1/mcp',
}));
vi.mock('../../../lib/api', () => ({
api: {
getAPIKeys: vi.fn(async () => [
{ id: 'k0', userId: 'u', name: 'Rauchmelder', keyPrefix: 'mindsift_sec_xy', scopes: 'webhook:triage,webhook:task', createdAt: '' },
{ id: 'k9', userId: 'u', name: 'Claude (OAuth)', keyPrefix: 'OAuth', scopes: 'tasks:read', createdAt: '', kind: 'oauth' },
]),
getAuthStatus: vi.fn(async () => ({ mcpConnectorUrl: 'https://ms.example/api/v1/mcp' })),
getAuthStatus: (...args: unknown[]) => getAuthStatus(...(args as [])),
getAllLists: vi.fn(async () => []),
getTaskLists: vi.fn(async () => []),
createAPIKey: (...args: unknown[]) => createAPIKey(...(args as [])),
@@ -59,6 +62,15 @@ describe('ApiKeysPanel', () => {
expect(screen.getByText(/\[mcp_servers\.mindsift\]/)).toBeTruthy();
});
it('hides the MCP tab when the operator switched MCP off', async () => {
getAuthStatus.mockResolvedValueOnce({ mcpConnectorUrl: '', mcpEnabled: false });
render(<ApiKeysPanel />);
await act(async () => {});
expect(screen.queryByRole('tab', { name: /KI-Agenten/ })).toBeNull();
expect(screen.queryByRole('switch', { name: 'Notizen lesen' })).toBeNull();
expect(screen.getAllByText(/tasks\?due=today&status=open/).length).toBeGreaterThan(0);
});
it('marks OAuth connections instead of showing a key prefix', async () => {
render(<ApiKeysPanel />);
expect(await screen.findByText('Claude (OAuth)')).toBeTruthy();
@@ -545,7 +545,21 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
// The last extraction, so undoing it (toast or Strg+Z) also puts the new note into the trash
const lastExtractRef = useRef<{ newDocId: string; blockId: string; link: string } | null>(null);
// The last note brought back, so undoing it also takes the note out of the trash again
const lastInlineRef = useRef<{ sourceId: string; blockId: string; content: string } | null>(null);
const finishUndoExtract = useCallback(() => {
const inlined = lastInlineRef.current;
if (inlined) {
const block = currentBlocksRef.current.find((b) => b.id === inlined.blockId);
if (!block || block.content !== inlined.content) {
lastInlineRef.current = null;
api
.restoreDocument(inlined.sourceId)
.then(() => api.getDocument(inlined.sourceId))
.then((restored) => onDocCreated?.(restored))
.catch((err: any) => showNotice(err?.message || 'Notiz konnte nicht wiederhergestellt werden'));
}
}
const info = lastExtractRef.current;
if (!info) return;
const block = currentBlocksRef.current.find((b) => b.id === info.blockId);
@@ -555,7 +569,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
.undoExtract(info.newDocId)
.then(() => onDocRemoved?.(info.newDocId))
.catch((err: any) => showNotice(err?.message || 'Ausgelagerte Notiz konnte nicht entfernt werden'));
}, [onDocRemoved, showNotice]);
}, [onDocCreated, onDocRemoved, showNotice]);
useEffect(() => () => {
if (noticeTimerRef.current !== null) window.clearTimeout(noticeTimerRef.current);
}, []);
@@ -614,6 +628,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
while (end < prev.length && (prev[end].level || 0) > level) end++;
applyLocalBlocks([...prev.slice(0, i), { ...prev[i], content: link }, ...prev.slice(end)]);
}
lastInlineRef.current = null;
lastExtractRef.current = { newDocId: created.id, blockId, link };
onDocCreated?.(created);
showNotice(`In eigene Notiz „${created.title}“ umgewandelt`, [
@@ -627,6 +642,35 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
[docId, onOpenDocBlock, onDocCreated, showNotice, handleUndo, applyLocalBlocks]
);
// The way back: a bullet that only links to a note gets that note's bullets below it again, the note
// goes to the trash. Inserted here as an own edit like extracting, so Strg+Z brings the link back.
const handleInlineBlock = useCallback(
async (blockId: string) => {
const engine = engineRef.current;
if (!engine) return;
try {
await engine.syncNow();
const res = await api.inlineNote(docId, blockId);
(document.activeElement as HTMLElement | null)?.blur?.();
const prev = currentBlocksRef.current;
const i = prev.findIndex((b) => b.id === blockId);
if (i >= 0) {
const level = prev[i].level || 0;
const inserted = res.blocks.map((b) => ({ ...b, level: level + 1 + (b.level || 0) }));
// Above any bullets added below the link meanwhile
applyLocalBlocks([...prev.slice(0, i), { ...prev[i], content: res.content }, ...inserted, ...prev.slice(i + 1)]);
}
lastExtractRef.current = null;
lastInlineRef.current = { sourceId: res.sourceId, blockId, content: res.content };
onDocRemoved?.(res.sourceId);
showNotice('Notiz zurückgeholt, sie liegt jetzt im Papierkorb', [{ label: 'Rückgängig', run: () => handleUndo() }]);
} catch (err: any) {
showNotice(err?.message || 'Zurückholen fehlgeschlagen');
}
},
[docId, onDocRemoved, showNotice, handleUndo, applyLocalBlocks]
);
// Keep parent sidebar metadata in sync when blocks/tags change
useEffect(() => {
if (!docRef.current) return;
@@ -1363,6 +1407,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
levelOffset={levelOffset}
onZoomBlock={setZoomedBlockId}
onExtractBlock={canEdit ? handleExtractBlock : undefined}
onInlineBlock={canEdit ? handleInlineBlock : undefined}
onUpdateBlock={handleUpdateBlock}
onUpdateBlocks={handleUpdateBlocks}
onCreateBlock={handleCreateBlock}
@@ -1513,6 +1558,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
levelOffset={0}
onZoomBlock={setZoomedBlockId}
onExtractBlock={canEdit ? handleExtractBlock : undefined}
onInlineBlock={canEdit ? handleInlineBlock : undefined}
onUpdateBlock={handleUpdateBlock}
onUpdateBlocks={handleUpdateBlocks}
onCreateBlock={handleCreateBlock}
@@ -24,6 +24,7 @@ import {
Mic,
AudioLines,
FileOutput,
FileInput,
Link2,
Type,
} from 'lucide-react';
@@ -96,6 +97,7 @@ export interface OutlinerBlockProps {
onZoomBlock?: (blockId: string) => void;
/** Turns this bullet and its children into a note of its own */
onExtractBlock?: (blockId: string) => void;
onInlineBlock?: (blockId: string) => void;
onUpdateBlock: (updated: DocumentBlock, immediate?: boolean) => void;
onCreateBlockBelow: (afterBlockId: string, newContent?: string) => void;
onDeleteBlock: (blockId: string, deleteSubtree?: boolean) => void;
@@ -151,6 +153,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
levelOffset = 0,
onZoomBlock,
onExtractBlock,
onInlineBlock,
onUpdateBlock,
onCreateBlockBelow,
onDeleteBlock,
@@ -1724,6 +1727,19 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
</button>
)}
{/* The way back: a bullet that only links to a note gets that note's bullets again */}
{canEdit && onInlineBlock && isNoteLinkOnly(localContent) && !mediaPicker && (
<button
type="button"
onClick={() => onInlineBlock(block.id)}
className="p-1 sm:p-0.5 min-w-[28px] min-h-[28px] sm:min-w-0 sm:min-h-0 flex items-center justify-center text-faint hover:text-accent rounded-btn transition-colors cursor-pointer"
title="Notiz zurückholen (ihre Punkte kommen wieder hierher, die Notiz in den Papierkorb)"
aria-label="Notiz zurückholen"
>
<FileInput className="w-3.5 h-3.5" />
</button>
)}
{/* Zoom / Focus button */}
{onZoomBlock && (
<button
@@ -20,6 +20,7 @@ export interface OutlinerTreeProps {
levelOffset?: number;
onZoomBlock?: (blockId: string) => void;
onExtractBlock?: (blockId: string) => void;
onInlineBlock?: (blockId: string) => void;
onUpdateBlock: (updated: DocumentBlock, immediate?: boolean) => void;
onUpdateBlocks?: (updated: DocumentBlock[]) => void;
onCreateBlock: (newBlock: DocumentBlock, afterBlockId?: string) => void;
@@ -54,6 +55,7 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
levelOffset = 0,
onZoomBlock,
onExtractBlock,
onInlineBlock,
onUpdateBlock,
onUpdateBlocks,
onCreateBlock,
@@ -933,6 +935,7 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
levelOffset={levelOffset}
onZoomBlock={onZoomBlock}
onExtractBlock={onExtractBlock}
onInlineBlock={onInlineBlock}
onUpdateBlock={onUpdateBlock}
onCreateBlockBelow={handleCreateBlockBelow}
onPasteMultiLines={handlePasteMultiLines}
+5
View File
@@ -799,6 +799,11 @@ export const api = {
});
},
/** The way back from extractBranch: the linked note's blocks for the bullet; the note goes to the trash. */
async inlineNote(docId: string, blockId: string): Promise<{ sourceId: string; content: string; blocks: DocumentBlock[] }> {
return request(`/api/v1/documents/${docId}/inline`, { method: 'POST', body: JSON.stringify({ blockId }) });
},
/** Puts a note created by extractBranch into the trash again. */
async undoExtract(newDocId: string): Promise<void> {
await request(`/api/v1/documents/${newDocId}/extract/undo`, { method: 'POST' });
+6 -2
View File
@@ -1,8 +1,9 @@
import { ApiKeyScope } from './types';
// 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.
export type ScopeGroup = 'tasks' | 'lists' | 'inbox';
// the delivering rights (webhook:…) stay from the time keys could only deliver data. Notes are reachable
// over MCP only.
export type ScopeGroup = 'tasks' | 'lists' | 'notes' | 'inbox';
export 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' },
@@ -10,11 +11,14 @@ export const SCOPES: { id: ApiKeyScope; label: string; chip: string; hint: strin
{ 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: 'notes:read', label: 'Lesen', chip: 'Notizen lesen', hint: 'Notizen auflisten, lesen, suchen (nur MCP)', group: 'notes' },
{ id: 'notes:create', label: 'Anlegen', chip: 'Notizen anlegen', hint: 'Nur neue Notizen anlegen (nur MCP)', group: 'notes' },
{ id: 'webhook:triage', label: 'Inbox füllen', chip: 'Inbox', hint: 'Gedanken in die Inbox einliefern', group: 'inbox' },
];
export const SCOPE_GROUPS: [ScopeGroup, string][] = [
['tasks', '📋 Aufgaben'],
['lists', '🛒 Listen'],
['notes', '🗒️ Notizen'],
['inbox', '📥 Inbox'],
];
export const scopeChip = (id: string) => SCOPES.find((s) => s.id === id)?.chip || id;
+9 -2
View File
@@ -17,6 +17,8 @@ export interface AuthStatus {
sharingMode?: SharingMode;
// "Passwort vergessen?" works (SMTP and BASE_URL configured)
passwordReset?: boolean;
// AI agents may connect over MCP (the operator can switch it off)
mcpEnabled?: boolean;
// MCP address for claude.ai / ChatGPT connectors (OAuth); empty when the server has no BASE_URL
mcpConnectorUrl?: string;
// Demo mode with periodic reset: next reset (ISO) and seconds until then
@@ -301,6 +303,9 @@ export interface SystemSettings {
// People may keep finished and deleted things for good ("nie"); fixed when set via RETENTION_ALLOW_NEVER
retentionAllowNever?: boolean;
retentionNeverViaEnv?: boolean;
// AI agents may connect over MCP (and claude.ai / ChatGPT over OAuth); fixed when set via MCP_ENABLED
mcpEnabled?: boolean;
mcpConfiguredViaEnv?: boolean;
}
/** One file of a server backup listing. */
@@ -657,7 +662,7 @@ export interface UserTheme {
updatedAt: string;
}
/** What an API key may do: webhook:* delivers data, the agent scopes read and change tasks/lists over MCP. */
/** What an API key may do: webhook:* delivers data, the agent scopes read and change tasks/lists; notes only over MCP. */
export type ApiKeyScope =
| 'webhook:triage'
| 'webhook:task'
@@ -665,7 +670,9 @@ export type ApiKeyScope =
| 'tasks:read'
| 'tasks:write'
| 'lists:read'
| 'lists:write';
| 'lists:write'
| 'notes:read'
| 'notes:create';
export interface APIKey {
id: string;
+9
View File
@@ -345,6 +345,11 @@ func (s *Server) settingsForBrowser(settings *domain.SystemSettings) *domain.Sys
out.RetentionAllowNever = &allow
out.RetentionNeverViaEnv = true
}
if s.cfg.MCPEnabled != nil {
on := *s.cfg.MCPEnabled
out.MCPEnabled = &on
out.MCPViaEnv = true
}
out.EmailLinksAvailable = s.emailLinksAvailable()
return &out
}
@@ -381,6 +386,10 @@ func (s *Server) handleAdminUpdateSettings(w http.ResponseWriter, r *http.Reques
settings.RetentionAllowNever = nil // fixed by RETENTION_ALLOW_NEVER, the stored value stays
}
settings.RetentionNeverViaEnv = false
if s.cfg.MCPEnabled != nil {
settings.MCPEnabled = nil // fixed by MCP_ENABLED, the stored value stays
}
settings.MCPViaEnv = false
previousMode := s.store.SharingMode()
if err := s.store.UpdateSystemSettings(&settings); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
+2
View File
@@ -48,6 +48,8 @@ func (s *Server) handleAuthStatus(w http.ResponseWriter, r *http.Request) {
"demoMode": s.cfg.DemoMode,
"sharingMode": s.store.SharingMode(),
"passwordReset": s.passwordResetAvailable(),
// AI agents may connect over MCP (MCP_ENABLED or the admin's switch)
"mcpEnabled": s.mcpEnabled(),
// Address to enter as connector in claude.ai / ChatGPT (OAuth); empty while OAuth is off
"mcpConnectorUrl": func() string {
if s.oauthEnabled() {
+174 -5
View File
@@ -82,7 +82,7 @@ func mcpToolAllowed(scopes []string, isKey bool, tool mcpTool) bool {
return false
}
if tool.Name == "search" {
return has(storage.ScopeTasksRead) || has(storage.ScopeListsRead)
return has(storage.ScopeTasksRead) || has(storage.ScopeListsRead) || has(storage.ScopeNotesRead)
}
return has(tool.Scope) || (tool.AltScope != "" && has(tool.AltScope))
}
@@ -135,8 +135,9 @@ func (s *Server) handleMCP(w http.ResponseWriter, r *http.Request) {
"serverInfo": map[string]string{"name": "MindSift", "version": mcpServerVersion},
"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 show the user a badge with the name of your API key.",
"lists and check_list_item to tick items, list_notes/get_note to read notes and create_note for a " +
"new one. IDs come from the list tools; never guess them. Tasks and list items 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)
@@ -190,6 +191,14 @@ func (s *Server) handleMCP(w http.ResponseWriter, r *http.Request) {
// handleMCPNoStream: the endpoint is stateless and has no server-initiated stream (GET) or session to
// end (DELETE).
// mcpEnabled: MCP_ENABLED if set, else the admin's switch in the system settings (default: on).
func (s *Server) mcpEnabled() bool {
if s.cfg.MCPEnabled != nil {
return *s.cfg.MCPEnabled
}
return s.store.MCPEnabledSetting()
}
func handleMCPNoStream(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Allow", "POST")
writeError(w, http.StatusMethodNotAllowed, "Der MCP-Endpunkt nimmt nur POST an (kein Server-Stream)")
@@ -311,6 +320,53 @@ func toTaskListView(l domain.TaskList) taskListView {
return taskListView{ID: l.ID, Title: l.Title, Icon: l.Icon, Permission: permission, OpenTasks: l.TasksCount, Columns: cols}
}
// noteView: a note without its bullets (list_notes); get_note adds them as Markdown.
type noteView struct {
ID string `json:"id"`
Title string `json:"title"`
Icon string `json:"icon,omitempty"`
FolderPath string `json:"folderPath,omitempty"`
Permission string `json:"permission"`
Tags []string `json:"tags,omitempty"`
UpdatedAt string `json:"updatedAt"`
Content string `json:"content,omitempty"`
}
func toNoteView(d *domain.Document) noteView {
permission := d.Permission
if d.IsOwner || permission == "" {
permission = "owner"
}
return noteView{ID: d.ID, Title: d.Title, Icon: d.Icon, FolderPath: d.FolderPath, Permission: permission, Tags: d.Tags, UpdatedAt: d.UpdatedAt}
}
// noteMarkdown renders the bullets as an indented Markdown list; images and recordings become a
// placeholder with their caption (the agent cannot open the files).
func noteMarkdown(blocks []domain.DocumentBlock) string {
out := make([]domain.DocumentBlock, len(blocks))
for i, b := range blocks {
if b.Type == "image" || b.Type == "audio" {
label := map[string]string{"image": "Bild", "audio": "Aufnahme"}[b.Type]
if ref, ok := storage.ParseMediaContent(b.Content); ok && ref.Caption != "" {
label += ": " + ref.Caption
}
b.Type, b.Content = "paragraph", "["+label+"]"
}
out[i] = b
}
return storage.BlocksToVaultMarkdown(out)
}
// noteHit: a matching bullet (or title) of a note in the search
type noteHit struct {
NoteID string `json:"noteId"`
Title string `json:"title"`
Snippet string `json:"snippet"`
}
// markTags strips the highlighting of search snippets
var markTags = strings.NewReplacer("<mark>", "", "</mark>", "")
type listView struct {
ID string `json:"id"`
Title string `json:"title"`
@@ -713,9 +769,113 @@ var mcpTools = []mcpTool{
return item, nil
},
},
{
Name: "list_notes",
Description: "Notes the user can see (own and shared), newest change first, without their content. Filter by folder (includes subfolders) or words in the title. Use get_note for the bullets.",
Scope: storage.ScopeNotesRead,
Schema: schema(nil, map[string]interface{}{
"folder": prop("string", "Only notes in this folder or below (e.g. Projekte/MindSift)"),
"query": prop("string", "Only notes whose title contains this text"),
"limit": prop("integer", "Maximum number of notes (default 100)"),
}),
run: func(s *Server, userID string, _ *apiActor, args json.RawMessage) (interface{}, error) {
var a struct {
Folder, Query string
Limit int
}
if err := decodeArgs(args, &a); err != nil {
return nil, err
}
docs, err := s.store.GetDocuments(userID)
if err != nil {
return nil, err
}
folder := strings.Trim(strings.TrimSpace(a.Folder), "/")
query := strings.ToLower(strings.TrimSpace(a.Query))
out := []noteView{}
for i := range docs {
d := &docs[i]
if d.IsArchived {
continue
}
if folder != "" && d.FolderPath != folder && !strings.HasPrefix(d.FolderPath, folder+"/") {
continue
}
if query != "" && !strings.Contains(strings.ToLower(d.Title), query) {
continue
}
out = append(out, toNoteView(d))
}
sort.SliceStable(out, func(i, j int) bool { return out[i].UpdatedAt > out[j].UpdatedAt })
if a.Limit <= 0 {
a.Limit = 100
}
if len(out) > a.Limit {
out = out[:a.Limit]
}
return out, nil
},
},
{
Name: "get_note",
Description: "One note with its bullets as an indented Markdown list (one tab per level; - [ ] / - [x] are to-dos).",
Scope: storage.ScopeNotesRead,
Schema: schema([]string{"id"}, map[string]interface{}{"id": prop("string", "Note id")}),
run: func(s *Server, userID string, _ *apiActor, args json.RawMessage) (interface{}, error) {
var a struct{ ID string }
if err := decodeArgs(args, &a); err != nil {
return nil, err
}
d, err := s.store.GetDocumentByID(userID, a.ID)
if err != nil || d == nil || d.IsArchived {
return nil, errors.New("Notiz nicht gefunden")
}
v := toNoteView(d)
v.Content = noteMarkdown(d.Blocks)
return v, nil
},
},
{
Name: "create_note",
Description: "Create a new note. content is Markdown: a nested list (tab or 2+ spaces per level) becomes nested bullets, - [ ] a to-do, # a heading with the following bullets below it, plain lines become bullets. Existing notes cannot be changed.",
Scope: storage.ScopeNotesCreate,
Schema: schema([]string{"title"}, map[string]interface{}{
"title": prop("string", "Title of the note"),
"content": prop("string", "Bullets as Markdown, optional"),
"folderPath": prop("string", "Folder, e.g. Projekte/MindSift; empty = top level. Missing folders are created."),
"icon": prop("string", "One emoji, optional (default 🗒️)"),
}),
run: func(s *Server, userID string, _ *apiActor, args json.RawMessage) (interface{}, error) {
var a struct {
Title, Content, FolderPath, Icon string
}
if err := decodeArgs(args, &a); err != nil {
return nil, err
}
if strings.TrimSpace(a.Title) == "" {
return nil, errors.New("'title' darf nicht leer sein")
}
folder := strings.Trim(strings.TrimSpace(a.FolderPath), "/")
if err := s.store.CheckCreateInFolder(userID, folder); err != nil {
return nil, err
}
var blocks []domain.DocumentBlock
if strings.TrimSpace(a.Content) != "" {
blocks = storage.MarkdownToBlocks(a.Content)
}
d, err := s.store.CreateDocument(userID, "", a.Title, a.Icon, folder, blocks, nil, false)
if err != nil {
return nil, err
}
s.broadcastDocToAuthorizedUsers(d.ID, "DOC_CREATED")
v := toNoteView(d)
v.Content = noteMarkdown(d.Blocks)
return v, nil
},
},
{
Name: "search",
Description: "Full-text search over tasks and list items the user can see (notes are not included for API keys).",
Description: "Full-text search over tasks, list items and notes the user can see (each only with the matching read right). Notes come as matching bullets with the note id for get_note.",
Scope: storage.ScopeTasksRead,
Schema: schema([]string{"query"}, map[string]interface{}{"query": prop("string", "Words to search for")}),
run: func(s *Server, userID string, actor *apiActor, args json.RawMessage) (interface{}, error) {
@@ -733,7 +893,13 @@ var mcpTools = []mcpTool{
for i := range res.Thoughts {
tasks = append(tasks, toTaskView(&res.Thoughts[i]))
}
return map[string]interface{}{"tasks": tasks, "listItems": res.ListItems}, nil
notes := []noteHit{}
for _, h := range res.Hits {
if h.Type == "document" {
notes = append(notes, noteHit{NoteID: h.DocID, Title: h.Title, Snippet: markTags.Replace(h.Snippet)})
}
}
return map[string]interface{}{"tasks": tasks, "listItems": res.ListItems, "notes": notes}, nil
},
},
{
@@ -785,5 +951,8 @@ func filterSearchForScopes(result interface{}, scopes []string) interface{} {
if !has[storage.ScopeListsRead] {
m["listItems"] = []domain.ListItem{}
}
if !has[storage.ScopeNotesRead] {
m["notes"] = []noteHit{}
}
return m
}
+154
View File
@@ -288,3 +288,157 @@ func TestMCPCreateDeleteAndOrigin(t *testing.T) {
t.Fatalf("webhook:task key tools: %s", names)
}
}
// Operators can switch MCP off: in the admin settings, or fixed by MCP_ENABLED. OAuth goes with it.
func TestMCPSwitchOff(t *testing.T) {
srv, _, session := setupTestServer(t)
srv.cfg.BaseURL = "https://ms.example/"
srv.cfg.BaseURLSet = true
router := srv.Router()
const list = `{"jsonrpc":"2.0","id":1,"method":"tools/list"}`
status := func() map[string]interface{} {
_, _, st := oauthDo(t, router, http.MethodGet, "/api/v1/auth/status", "", "", "")
return st
}
if code, _ := rpc(t, router, session, list); code != http.StatusOK {
t.Fatalf("MCP should be on by default, got %d", code)
}
if st := status(); st["mcpEnabled"] != true || st["mcpConnectorUrl"] == "" {
t.Fatalf("status with MCP on: %v", st)
}
if code, _, _ := oauthDo(t, router, http.MethodPut, "/api/v1/admin/settings", session, "application/json", `{"mcpEnabled":false}`); code != http.StatusOK {
t.Fatalf("switch off: %d", code)
}
if code, _ := rpc(t, router, session, list); code != http.StatusNotFound {
t.Fatalf("MCP switched off must answer 404, got %d", code)
}
code, h, _ := oauthDo(t, router, http.MethodPost, mcpPath, "", "application/json", list)
if code != http.StatusNotFound || h.Get("WWW-Authenticate") != "" {
t.Fatalf("no login hint while MCP is off: %d %q", code, h.Get("WWW-Authenticate"))
}
if code, _, _ := oauthDo(t, router, http.MethodGet, "/.well-known/oauth-authorization-server", "", "", ""); code != http.StatusNotFound {
t.Fatalf("OAuth must be off with MCP, got %d", code)
}
if st := status(); st["mcpEnabled"] != false || st["mcpConnectorUrl"] != "" {
t.Fatalf("status with MCP off: %v", st)
}
// Saving other settings (without the field) leaves the switch as it is
if code, _, _ := oauthDo(t, router, http.MethodPut, "/api/v1/admin/settings", session, "application/json", `{"allowPrivateWebhooks":true}`); code != http.StatusOK {
t.Fatalf("save other settings: %d", code)
}
if code, _ := rpc(t, router, session, list); code != http.StatusNotFound {
t.Fatalf("MCP must stay off, got %d", code)
}
// MCP_ENABLED wins over the stored switch and locks it
on := true
srv.cfg.MCPEnabled = &on
if code, _ := rpc(t, router, session, list); code != http.StatusOK {
t.Fatalf("MCP_ENABLED=true must win, got %d", code)
}
_, _, adm := oauthDo(t, router, http.MethodGet, "/api/v1/admin/settings", session, "", "")
if adm["mcpEnabled"] != true || adm["mcpConfiguredViaEnv"] != true {
t.Fatalf("admin settings must show the environment's value: %v", adm)
}
off := false
srv.cfg.MCPEnabled = &off
if code, _, _ := oauthDo(t, router, http.MethodPut, "/api/v1/admin/settings", session, "application/json", `{"mcpEnabled":true}`); code != http.StatusOK {
t.Fatalf("save: %d", code)
}
if code, _ := rpc(t, router, session, list); code != http.StatusNotFound {
t.Fatalf("MCP_ENABLED=false must win, got %d", code)
}
srv.cfg.MCPEnabled = nil
if code, _ := rpc(t, router, session, list); code != http.StatusNotFound {
t.Fatalf("the locked switch must not have changed the stored value, got %d", code)
}
}
// Notes over MCP: notes:create only creates, notes:read lists, reads and finds them; REST stays closed.
func TestMCPNotes(t *testing.T) {
srv, store, token := setupTestServer(t)
router := srv.Router()
claims, _ := auth.ValidateToken(testJWTSecret, token)
call := func(key, name, args string) (map[string]interface{}, bool) {
t.Helper()
_, res := rpc(t, router, key, `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"`+name+`","arguments":`+args+`}}`)
result, isErr := callResult(t, res)
sc, _ := result["structuredContent"].(map[string]interface{})
return sc, isErr
}
names := func(key string) string {
_, res := rpc(t, router, key, `{"jsonrpc":"2.0","id":1,"method":"tools/list"}`)
return "," + strings.Join(toolNames(res), ",") + ","
}
writer, _ := store.CreateAPIKey(claims.UserID, "Schreiber", []string{"notes:create"})
if n := names(writer.RawKey); n != ",create_note," {
t.Fatalf("notes:create should only offer create_note, got %s", n)
}
note, isErr := call(writer.RawKey, "create_note", `{"title":"Ideen","folderPath":"/Projekte/KI/","content":"- Erster Punkt\n\t- Unterpunkt\n- [ ] Offen"}`)
if isErr || note["folderPath"] != "Projekte/KI" || note["permission"] != "owner" {
t.Fatalf("create_note: %v", note)
}
doc, err := store.GetDocumentByID(claims.UserID, note["id"].(string))
if err != nil || len(doc.Blocks) != 3 || doc.Blocks[1].Level != 1 || doc.Blocks[2].Type != "todo" {
t.Fatalf("bullets not stored as outline: %+v", doc)
}
if _, isErr := call(writer.RawKey, "create_note", `{"title":" "}`); !isErr {
t.Fatal("empty title must be refused")
}
if _, isErr := call(writer.RawKey, "list_notes", `{}`); !isErr {
t.Fatal("notes:create must not read notes")
}
reader, _ := store.CreateAPIKey(claims.UserID, "Leser", []string{"notes:read"})
if n := names(reader.RawKey); !strings.Contains(n, ",list_notes,") || !strings.Contains(n, ",get_note,") ||
!strings.Contains(n, ",search,") || strings.Contains(n, ",create_note,") {
t.Fatalf("notes:read sees the wrong tools: %s", n)
}
_, _ = store.CreateDocument(claims.UserID, "", "Einkauf", "", "", nil, nil, false)
list, _ := call(reader.RawKey, "list_notes", `{"folder":"Projekte/KI"}`)
if items := list["items"].([]interface{}); len(items) != 1 || items[0].(map[string]interface{})["title"] != "Ideen" {
t.Fatalf("folder filter: %v", list)
}
// The folder includes its subfolders; the title filter ignores case
list, _ = call(reader.RawKey, "list_notes", `{"folder":"Projekte","query":"IDEE"}`)
if items := list["items"].([]interface{}); len(items) != 1 {
t.Fatalf("subfolder and title filter: %v", list)
}
got, isErr := call(reader.RawKey, "get_note", `{"id":"`+doc.ID+`"}`)
if isErr || got["content"] != "- Erster Punkt\n\t- Unterpunkt\n- [ ] Offen\n" {
t.Fatalf("get_note: %q", got["content"])
}
found, _ := call(reader.RawKey, "search", `{"query":"Unterpunkt"}`)
if hits := found["notes"].([]interface{}); len(hits) == 0 || hits[0].(map[string]interface{})["noteId"] != doc.ID {
t.Fatalf("search should find the note: %v", found)
}
// Without notes:read the search leaves notes out
taskReader, _ := store.CreateAPIKey(claims.UserID, "Aufgaben", []string{"tasks:read"})
if found, _ := call(taskReader.RawKey, "search", `{"query":"Unterpunkt"}`); len(found["notes"].([]interface{})) != 0 {
t.Fatalf("tasks:read must not find notes: %v", found)
}
// Notes stay closed over REST
if code, _, _ := oauthDo(t, router, http.MethodGet, "/api/v1/documents", reader.RawKey, "", ""); code != http.StatusForbidden {
t.Fatalf("REST must stay closed for notes keys, got %d", code)
}
// A shared folder with read access only takes no new notes
other, _ := store.CreateUser("owner@example.com", "hash", "Owner", "member")
if _, err := store.CreateFolder(other.ID, "Team"); err != nil {
t.Fatal(err)
}
sf, err := store.CreateSharedFolder(other.ID, "Team", "Team")
if err != nil {
t.Fatal(err)
}
if _, err := store.CreateFolderShare(other.ID, sf.ID, "user", claims.UserID, "read"); err != nil {
t.Fatal(err)
}
_, res := rpc(t, router, writer.RawKey, `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"create_note","arguments":{"title":"Rein","folderPath":"Team"}}}`)
if result, isErr := callResult(t, res); !isErr || !strings.Contains(fmt.Sprint(result["content"]), "nur lesen") {
t.Fatalf("read-only shared folder must refuse new notes: %v", result)
}
}
+3 -2
View File
@@ -29,12 +29,13 @@ const (
)
// Rights offered on the consent page; the narrow "create only" ones stay with ordinary keys.
var oauthOfferedScopes = []string{storage.ScopeTasksRead, storage.ScopeTasksWrite, storage.ScopeListsRead, storage.ScopeListsWrite, storage.ScopeTriage}
var oauthOfferedScopes = []string{storage.ScopeTasksRead, storage.ScopeTasksWrite, storage.ScopeListsRead, storage.ScopeListsWrite,
storage.ScopeNotesRead, storage.ScopeNotesCreate, storage.ScopeTriage}
// Preselected when the client asks for nothing in particular (claude.ai usually does not)
var oauthDefaultScopes = []string{storage.ScopeTasksRead, storage.ScopeTasksWrite, storage.ScopeListsRead, storage.ScopeListsWrite}
func (s *Server) oauthEnabled() bool { return s.cfg.BaseURLSet && !s.cfg.DemoMode }
func (s *Server) oauthEnabled() bool { return s.cfg.BaseURLSet && !s.cfg.DemoMode && s.mcpEnabled() }
func (s *Server) oauthIssuer() string { return strings.TrimRight(s.cfg.BaseURL, "/") }
+34 -4
View File
@@ -24,14 +24,16 @@ func (s *Server) restructureActor(r *http.Request, clientID string) collab.Actor
}
func restructureStatus(err error) int {
var stillLinked *storage.StillLinkedError
switch {
case errors.Is(err, storage.ErrBranchLocked):
case errors.Is(err, storage.ErrBranchLocked), errors.As(err, &stillLinked):
return http.StatusConflict
case errors.Is(err, storage.ErrBlockNotFound), errors.Is(err, storage.ErrDocNotFound):
case errors.Is(err, storage.ErrBlockNotFound), errors.Is(err, storage.ErrDocNotFound), errors.Is(err, storage.ErrLinkedMissing):
return http.StatusNotFound
case errors.Is(err, storage.ErrMergeSelf), errors.Is(err, storage.ErrDocInTrash), errors.Is(err, storage.ErrAlreadyNote):
case errors.Is(err, storage.ErrMergeSelf), errors.Is(err, storage.ErrDocInTrash), errors.Is(err, storage.ErrAlreadyNote),
errors.Is(err, storage.ErrNotNoteLink):
return http.StatusBadRequest
case errors.Is(err, storage.ErrMergeNotOwner):
case errors.Is(err, storage.ErrMergeNotOwner), errors.Is(err, storage.ErrInlineOwner):
return http.StatusForbidden
}
return http.StatusInternalServerError
@@ -80,6 +82,34 @@ func (s *Server) handleUndoExtract(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]bool{"undone": true})
}
// handleInlineNote: POST /documents/{id}/inline {blockId} – the way back from extracting: returns the
// linked note's blocks for the bullet and puts that note into the trash (undo: POST /documents/{note}/restore).
func (s *Server) handleInlineNote(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
docID := chi.URLParam(r, "id")
if !storage.CanEditDoc(s.store.DocRole(userID, docID)) {
writeError(w, http.StatusForbidden, "Keine Schreibrechte für diese Notiz")
return
}
var req struct {
BlockID string `json:"blockId"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.BlockID == "" {
writeError(w, http.StatusBadRequest, "blockId fehlt")
return
}
res, err := s.store.InlineNote(userID, docID, req.BlockID)
if err != nil {
writeError(w, restructureStatus(err), err.Error())
return
}
users, _ := s.store.GetAuthorizedDocUsers(res.SourceID)
for _, uid := range users {
s.wsHub.BroadcastToUser(uid, "DOC_DELETED", map[string]string{"id": res.SourceID})
}
writeJSON(w, http.StatusOK, res)
}
// handleMergeDocuments: POST /documents/{id}/merge {sourceId, clientId} appends the source to this note.
func (s *Server) handleMergeDocuments(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
+76
View File
@@ -186,3 +186,79 @@ func TestMergeDocumentsAndUndo(t *testing.T) {
t.Fatalf("foreign source: want 403, got %d %s", rec.Code, rec.Body.String())
}
}
// The way back from extracting: the link bullet gets the note's blocks, the note goes to the trash.
func TestInlineNoteBringsBranchBack(t *testing.T) {
srv, store, token := setupTestServer(t)
router := srv.Router()
claims, _ := auth.ValidateToken(testJWTSecret, token)
uid := claims.UserID
note, _ := store.CreateDocument(uid, "", "Konzept: Garten", "", "", []domain.DocumentBlock{
{ID: "blk_b", Type: "bullet", Content: "Beete", Level: 0, Position: 1},
{ID: "blk_c", Type: "todo", Content: "Erde kaufen", Level: 1, Position: 2, Checked: true},
{ID: "blk_d", Type: "bullet", Content: "Zaun", Level: 0, Position: 3},
}, nil, false)
src, _ := store.CreateDocument(uid, "", "Projekt", "", "", []domain.DocumentBlock{
{ID: "blk_l", Type: "bullet", Content: "[[konzept: garten]] #draußen", Level: 0, Position: 1},
{ID: "blk_t", Type: "bullet", Content: "Siehe [[Konzept: Garten]]", Level: 0, Position: 2},
{ID: "blk_x", Type: "bullet", Content: "[[Gibt es nicht]]", Level: 0, Position: 3},
}, nil, false)
inline := func(docID, blockID, tok string) (int, storage.InlineResult) {
rec := sessionCall(router, "POST", "/api/v1/documents/"+docID+"/inline", tok, `{"blockId":"`+blockID+`"}`, "")
var out storage.InlineResult
_ = json.Unmarshal(rec.Body.Bytes(), &out)
return rec.Code, out
}
if code, _ := inline(src.ID, "blk_t", token); code != http.StatusBadRequest {
t.Fatalf("text with a link is no link bullet: %d", code)
}
if code, _ := inline(src.ID, "blk_x", token); code != http.StatusNotFound {
t.Fatalf("missing note: %d", code)
}
// Linked from another note as well: refused, nothing changes
other, _ := store.CreateDocument(uid, "", "Anderswo", "", "", []domain.DocumentBlock{
{ID: "blk_o", Type: "bullet", Content: "auch [[Konzept: Garten]]", Level: 0, Position: 1},
}, nil, false)
if code, _ := inline(src.ID, "blk_l", token); code != http.StatusConflict {
t.Fatalf("note linked elsewhere must be refused: %d", code)
}
if d, _ := store.GetDocumentByID(uid, note.ID); d.IsArchived {
t.Fatal("refused inline must not trash the note")
}
if rec := sessionCall(router, "DELETE", "/api/v1/documents/"+other.ID, token, "", ""); rec.Code >= 300 {
t.Fatalf("delete other: %d", rec.Code)
}
// Another person's note cannot be pulled in, even when shared with write access
stranger, strangerToken := newTestUser(t, store, "fremd3@example.com")
openSharing(t, store)
if _, err := store.CreateDocShare(uid, src.ID, stranger.Email, "write"); err != nil {
t.Fatal(err)
}
if code, _ := inline(src.ID, "blk_l", strangerToken); code != http.StatusForbidden && code != http.StatusNotFound {
t.Fatalf("stranger must not pull in someone else's note: %d", code)
}
code, res := inline(src.ID, "blk_l", token)
if code != http.StatusOK || res.SourceID != note.ID || res.Content != "Konzept: Garten #draußen" {
t.Fatalf("inline: %d %+v", code, res)
}
var got []string
for _, b := range res.Blocks {
got = append(got, strings.Repeat(">", b.Level)+b.Content)
if b.ID == "blk_b" || b.ID == "blk_c" {
t.Fatal("blocks need fresh ids, the old ones live on in the trashed note")
}
}
if strings.Join(got, "|") != "Beete|>Erde kaufen|Zaun" || !res.Blocks[1].Checked || res.Blocks[1].Type != "todo" {
t.Fatalf("blocks: %v %+v", got, res.Blocks)
}
if d, _ := store.GetDocumentByID(uid, note.ID); d == nil || !d.IsArchived {
t.Fatal("brought-back note must be in the trash")
}
// Undo uses the ordinary restore
if rec := sessionCall(router, "POST", "/api/v1/documents/"+note.ID+"/restore", token, "", ""); rec.Code != http.StatusOK {
t.Fatalf("restore: %d %s", rec.Code, rec.Body.String())
}
}
+6
View File
@@ -173,6 +173,7 @@ func (s *Server) Router() http.Handler {
r.Post("/api/v1/tasks/{id}/watch", s.handleWatchTask)
r.Delete("/api/v1/tasks/{id}/watch", s.handleWatchTask)
r.Post("/api/v1/documents/{id}/extract/undo", s.handleUndoExtract)
r.Post("/api/v1/documents/{id}/inline", s.handleInlineNote)
r.Post("/api/v1/documents/{id}/merge", s.handleMergeDocuments)
r.Post("/api/v1/documents/{id}/merge/undo", s.handleUndoMerge)
r.Get("/api/v1/link-preview", s.notInDemo(s.handleLinkPreview))
@@ -535,6 +536,11 @@ func (s *Server) authMiddleware(next http.Handler) http.Handler {
} else {
token = r.Header.Get("X-API-Key")
}
// MCP switched off: the endpoint answers as if it did not exist, with or without login
if r.URL.Path == mcpPath && !s.mcpEnabled() {
writeError(w, http.StatusNotFound, "MCP ist auf diesem Server ausgeschaltet")
return
}
user, key := s.authenticateToken(token)
if user == nil {
// Tells MCP clients where to start the OAuth flow (RFC 9728)
+4
View File
@@ -60,6 +60,9 @@ type Config struct {
// RetentionAllowNever: RETENTION_ALLOW_NEVER=true/false fixes whether people may keep finished and
// deleted things for good; unset (nil) leaves it to the system settings
RetentionAllowNever *bool
// MCPEnabled: MCP_ENABLED=true/false fixes whether the MCP endpoint (and OAuth for connectors) is on;
// unset (nil) leaves it to the system settings
MCPEnabled *bool
// Speech recognition, configured only by environment (STT_*)
Speech SpeechConfig
@@ -261,6 +264,7 @@ func Load() *Config {
DemoResetMinutes: demoResetMinutes(),
TrustedProxies: getEnv("TRUSTED_PROXIES", "private"),
RetentionAllowNever: envBoolPtr("RETENTION_ALLOW_NEVER"),
MCPEnabled: envBoolPtr("MCP_ENABLED"),
DisableLoginProtection: strings.ToLower(getEnv("LOGIN_PROTECTION", "true")) == "false",
OAuthIdleDays: envInt("OAUTH_IDLE_DAYS", 90, 0, 36500),
SMTPHost: smtpHost,
+4
View File
@@ -418,6 +418,10 @@ type SystemSettings struct {
// update leaves it unchanged. RetentionNeverViaEnv: fixed by RETENTION_ALLOW_NEVER, not editable.
RetentionAllowNever *bool `json:"retentionAllowNever,omitempty"`
RetentionNeverViaEnv bool `json:"retentionNeverViaEnv,omitempty"`
// MCPEnabled: AI agents may connect over MCP (and claude.ai / ChatGPT over OAuth); nil in an update
// leaves it unchanged. MCPViaEnv: fixed by MCP_ENABLED, not editable.
MCPEnabled *bool `json:"mcpEnabled,omitempty"`
MCPViaEnv bool `json:"mcpConfiguredViaEnv,omitempty"`
}
// UnmarshalJSON accepts smtpPort as text or number: older clients sent a number, which made the whole
+6 -2
View File
@@ -12,7 +12,7 @@ import (
// 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.
// (webhooks) and stay for existing keys. Notes are reachable over MCP only, never over REST.
const (
ScopeTriage = "webhook:triage" // thoughts into the inbox
ScopeTask = "webhook:task" // create tasks
@@ -22,10 +22,14 @@ const (
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
ScopeNotesRead = "notes:read" // notes and their bullets (MCP only)
ScopeNotesCreate = "notes:create" // create new notes (MCP only); existing ones stay untouched
)
// AllScopes in display order.
var AllScopes = []string{ScopeTriage, ScopeTask, ScopeList, ScopeTasksRead, ScopeTasksWrite, ScopeListsRead, ScopeListsWrite}
var AllScopes = []string{ScopeTriage, ScopeTask, ScopeList, ScopeTasksRead, ScopeTasksWrite, ScopeListsRead, ScopeListsWrite,
ScopeNotesRead, ScopeNotesCreate}
// NormalizeScopes keeps the known scopes in a fixed order; none at all means triage only.
func NormalizeScopes(scopes []string) []string {
+20
View File
@@ -256,6 +256,26 @@ func (s *Store) getUserFolderMounts(userID string) (map[string]string, map[strin
// matchMountPath checks if folderPath falls within any mounted shared folder.
// Returns (sharedFolderID, relativeSubPath, isMatch).
// CheckCreateInFolder: new notes may go into own folders and into shared folders with write access.
func (s *Store) CheckCreateInFolder(userID, folderPath string) error {
mounts, _, err := s.getUserFolderMounts(userID)
if err != nil {
return err
}
fID, _, ok := matchMountPath(folderPath, mounts)
if !ok {
return nil
}
sf, err := s.GetSharedFolderByID(userID, fID)
if err != nil {
return err
}
if sf.Permission != "owner" && sf.Permission != "write" && !sf.IsOwner {
return fmt.Errorf("im geteilten Ordner „%s“ darfst du nur lesen", sf.Name)
}
return nil
}
func matchMountPath(folderPath string, mounts map[string]string) (string, string, bool) {
clean := filepath.ToSlash(filepath.Clean(strings.TrimSpace(folderPath)))
if clean == "." {
+97
View File
@@ -22,6 +22,9 @@ var (
ErrMergeSelf = errors.New("Eine Notiz kann nicht mit sich selbst zusammengeführt werden")
ErrAlreadyNote = errors.New("Dieser Punkt verweist schon auf eine eigene Notiz")
ErrMergeNotOwner = errors.New("Zusammenführen geht nur mit eigenen Notizen als Quelle")
ErrNotNoteLink = errors.New("Dieser Punkt ist kein Link auf eine Notiz")
ErrLinkedMissing = errors.New("Die verlinkte Notiz gibt es nicht (mehr)")
ErrInlineOwner = errors.New("Zurückholen geht nur mit eigenen Notizen")
)
// readCollabDoc loads the current state of a note outside of a change.
@@ -161,6 +164,100 @@ func (s *Store) UndoExtract(userID, docID string) error {
return s.trashDocument(userID, docID)
}
// StillLinkedError: the note to bring back is linked from other notes as well
type StillLinkedError struct {
Title string
Count int
}
func (e *StillLinkedError) Error() string {
return fmt.Sprintf("„%s“ ist noch in %d weiteren Notiz(en) verlinkt; diese Links würden ins Leere zeigen. Bitte dort zuerst entfernen.", e.Title, e.Count)
}
// InlineResult: what the editor puts back in place of the link bullet
type InlineResult struct {
SourceID string `json:"sourceId"`
// Text of the bullet: the note's title, hashtags of the link kept
Content string `json:"content"`
// The note's blocks with fresh ids; level 0 = directly below the bullet
Blocks []domain.DocumentBlock `json:"blocks"`
}
// InlineNote is the way back from ExtractBranch: for a bullet that is only a link to an own note, it
// returns that note's blocks and puts the note into the trash. Like ExtractBranch it does not change
// the note with the bullet; the editor inserts the blocks itself as an own edit (undo works). Refused
// while other notes link to the note as well, since those links would point to nothing.
func (s *Store) InlineNote(userID, docID, blockID string) (*InlineResult, error) {
state, err := s.readCollabDoc(docID)
if err != nil {
return nil, err
}
var content string
found := false
for _, b := range state.Flatten() {
if b.ID == blockID {
content, found = b.Content, true
break
}
}
if !found {
return nil, ErrBlockNotFound
}
m := wikiLinkRe.FindStringSubmatch(content)
if !noteLinkOnlyRe.MatchString(content) || m == nil {
return nil, ErrNotNoteLink
}
title := strings.TrimSpace(m[1])
docs, err := s.GetDocuments(userID)
if err != nil {
return nil, err
}
var linked *domain.Document
for i := range docs {
d := &docs[i]
if d.ID == docID || !strings.EqualFold(strings.TrimSpace(d.Title), title) {
continue
}
// An own note wins over one shared with the user under the same title
if linked == nil || (d.IsOwner && !linked.IsOwner) {
linked = d
}
}
if linked == nil {
return nil, ErrLinkedMissing
}
if !linked.IsOwner {
return nil, ErrInlineOwner
}
backlinks, err := s.GetBacklinks(userID, linked.ID)
if err != nil {
return nil, err
}
others := 0
for _, bl := range backlinks {
if bl.DocID != docID {
others++
}
}
if others > 0 {
return nil, &StillLinkedError{Title: linked.Title, Count: others}
}
source, err := s.readCollabDoc(linked.ID)
if err != nil {
return nil, err
}
blocks := []domain.DocumentBlock{}
for _, b := range source.Flatten() {
blocks = append(blocks, toDocumentBlock(b, b.Level, len(blocks)))
}
rest := strings.TrimSpace(wikiLinkRe.ReplaceAllString(content, ""))
text := strings.TrimSpace(linked.Title + " " + rest)
if err := s.trashDocument(userID, linked.ID); err != nil {
return nil, err
}
return &InlineResult{SourceID: linked.ID, Content: text, Blocks: blocks}, nil
}
// copyShares gives the new note the direct and team shares of the source; when someone else owns the
// source, that owner may edit the new note as well (the branch was theirs to read before).
func (s *Store) copyShares(userID string, source *domain.Document, newDocID string) {
+18
View File
@@ -179,6 +179,9 @@ func (s *Store) GetSystemSettings() (*domain.SystemSettings, error) {
case "retention_allow_never":
allow := v != "false"
settings.RetentionAllowNever = &allow
case "mcp_enabled":
on := v != "false"
settings.MCPEnabled = &on
}
}
}
@@ -194,6 +197,10 @@ func (s *Store) GetSystemSettings() (*domain.SystemSettings, error) {
allow := true
settings.RetentionAllowNever = &allow
}
if settings.MCPEnabled == nil {
on := true
settings.MCPEnabled = &on
}
return settings, nil
}
@@ -280,5 +287,16 @@ func (s *Store) UpdateSystemSettings(settings *domain.SystemSettings) error {
_, err = s.db.Exec(`INSERT INTO system_settings (key, value) VALUES ('retention_allow_never', ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value`, strconv.FormatBool(*settings.RetentionAllowNever))
}
if err == nil && settings.MCPEnabled != nil {
_, err = s.db.Exec(`INSERT INTO system_settings (key, value) VALUES ('mcp_enabled', ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value`, strconv.FormatBool(*settings.MCPEnabled))
}
return err
}
// MCPEnabledSetting: the admin's switch for the MCP endpoint in the system settings (default: on).
func (s *Store) MCPEnabledSetting() bool {
var v string
_ = s.db.QueryRow(`SELECT value FROM system_settings WHERE key = 'mcp_enabled'`).Scan(&v)
return v != "false"
}