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
+1119 -563

No files matched your search

+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))
})