MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m19s
MindSift now speaks MCP (Streamable HTTP, stateless JSON-RPC) at POST /api/v1/mcp so Claude Code, Codex, Claude Desktop, Cursor and custom agents can read, create and complete tasks and keep shared lists. API keys get the scopes tasks:read/write and lists:read/write; tools/list only shows what the key may call and every tool checks its scope. Task and list write paths are shared helpers (updateTask, createTask, addListItem, setListItemDone) used by REST and MCP alike. Settings: scope selection and the SectionPicker (import, restore) use ToggleSwitch rows instead of checkbox-like buttons; new MCP section with connection snippets for Claude Code, Codex and JSON configs. Docs, README, help and changelog updated; v0.28.0. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
118 lines
3.6 KiB
Go
118 lines
3.6 KiB
Go
package storage
|
|
|
|
import (
|
|
"errors"
|
|
"strings"
|
|
|
|
"github.com/sandrodach/mindsift/internal/auth"
|
|
"github.com/sandrodach/mindsift/internal/domain"
|
|
)
|
|
|
|
// --- API KEYS & WEBHOOKS ---
|
|
|
|
// Scopes of an API key. The webhook scopes deliver data (inbox, /webhooks/ingest); the agent scopes
|
|
// let AI agents read and change tasks and lists over the MCP endpoint. Notes are never reachable with
|
|
// a key.
|
|
const (
|
|
ScopeTriage = "webhook:triage" // thoughts into the inbox
|
|
ScopeTask = "webhook:task" // tasks directly
|
|
ScopeList = "webhook:list" // items into lists
|
|
|
|
ScopeTasksRead = "tasks:read" // MCP: task lists and tasks
|
|
ScopeTasksWrite = "tasks:write" // MCP: create, change and complete tasks
|
|
ScopeListsRead = "lists:read" // MCP: lists and their items
|
|
ScopeListsWrite = "lists:write" // MCP: add, change and check off items
|
|
)
|
|
|
|
// AllScopes in display order.
|
|
var AllScopes = []string{ScopeTriage, ScopeTask, ScopeList, ScopeTasksRead, ScopeTasksWrite, ScopeListsRead, ScopeListsWrite}
|
|
|
|
// NormalizeScopes keeps the known scopes in a fixed order; none at all means triage only.
|
|
func NormalizeScopes(scopes []string) []string {
|
|
want := map[string]bool{}
|
|
for _, sc := range scopes {
|
|
want[strings.ToLower(strings.TrimSpace(sc))] = true
|
|
}
|
|
out := []string{}
|
|
for _, sc := range AllScopes {
|
|
if want[sc] {
|
|
out = append(out, sc)
|
|
}
|
|
}
|
|
if len(out) == 0 {
|
|
out = []string{ScopeTriage}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (s *Store) CreateAPIKey(userID, name string, scopes []string) (*domain.APIKey, error) {
|
|
if strings.TrimSpace(name) == "" {
|
|
name = "Smarthome / Webhook Key"
|
|
}
|
|
rawKey, prefix, hash := auth.GenerateAPIKey()
|
|
k := &domain.APIKey{
|
|
ID: domain.NewID("key"),
|
|
UserID: userID,
|
|
Name: strings.TrimSpace(name),
|
|
KeyPrefix: prefix,
|
|
KeyHash: hash,
|
|
RawKey: rawKey,
|
|
Scopes: strings.Join(NormalizeScopes(scopes), ","),
|
|
CreatedAt: domain.NowISO(),
|
|
}
|
|
|
|
_, err := s.db.Exec(
|
|
`INSERT INTO api_keys (id, user_id, name, key_prefix, key_hash, scopes, created_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|
k.ID, k.UserID, k.Name, k.KeyPrefix, k.KeyHash, k.Scopes, k.CreatedAt,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return k, nil
|
|
}
|
|
|
|
func (s *Store) GetAPIKeys(userID string) ([]domain.APIKey, error) {
|
|
rows, err := s.db.Query(
|
|
`SELECT id, user_id, name, key_prefix, scopes, last_used_at, created_at
|
|
FROM api_keys WHERE user_id = ? ORDER BY created_at DESC`,
|
|
userID,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
keys := make([]domain.APIKey, 0)
|
|
for rows.Next() {
|
|
var k domain.APIKey
|
|
if err := rows.Scan(&k.ID, &k.UserID, &k.Name, &k.KeyPrefix, &k.Scopes, &k.LastUsedAt, &k.CreatedAt); err == nil {
|
|
keys = append(keys, k)
|
|
}
|
|
}
|
|
return keys, nil
|
|
}
|
|
|
|
func (s *Store) DeleteAPIKey(userID, keyID string) error {
|
|
_, err := s.db.Exec(`DELETE FROM api_keys WHERE id = ? AND user_id = ?`, keyID, userID)
|
|
return err
|
|
}
|
|
|
|
func (s *Store) ValidateAPIKeyAndGetUser(rawKey string) (*domain.User, error) {
|
|
u, _, err := s.ValidateAPIKey(rawKey)
|
|
return u, err
|
|
}
|
|
|
|
// ValidateAPIKey returns the owner of a key and the key's scopes.
|
|
func (s *Store) ValidateAPIKey(rawKey string) (*domain.User, []string, error) {
|
|
hash := auth.HashAPIKey(strings.TrimSpace(rawKey))
|
|
var keyID, userID, scopes string
|
|
err := s.db.QueryRow(`SELECT id, user_id, scopes FROM api_keys WHERE key_hash = ?`, hash).Scan(&keyID, &userID, &scopes)
|
|
if err != nil {
|
|
return nil, nil, errors.New("invalid API key")
|
|
}
|
|
_, _ = s.db.Exec(`UPDATE api_keys SET last_used_at = ? WHERE id = ?`, domain.NowISO(), keyID)
|
|
u, err := s.GetUserByID(userID)
|
|
return u, NormalizeScopes(strings.Split(scopes, ",")), err
|
|
}
|