feat(api): one API for curl and MCP, REST reads with keys, origin badge with key name
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m16s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m16s
API keys reach tasks and lists over plain REST too (GET /task-lists, GET /tasks with
status/due filters, PATCH/DELETE /thoughts/{id}, GET /lists, list items), guarded by a route
table; notes and settings stay closed. Rights hold for every access alike (webhook:task also
allows create_task over MCP, tasks:write also task delivery).
Tasks, thoughts and list items record which key created them and which one changed them last
(migration 13 replaces agent_mark; origin stays after the user's own edits and survives the
triage into a list item). OriginBadge shows the key name with a webhook or robot icon, details
on hover and tap, and replaces the Webhook and KI badges everywhere, now also on list items.
Settings: category "API" with rights grouped by tasks, lists and inbox and two tabs
(curl / smart home with a Home Assistant sensor, AI agents over MCP). Docs, help, README and
changelog updated; v0.30.0.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0ca715ba64
commit
b883315914
36 files changed
+1119
-563
No files matched your search
+15
-48
@@ -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))
|
||||
})
|
||||
|
||||
Reference in new issue
Block a user