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
@@ -0,0 +1,142 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"regexp"
|
||||
|
||||
"github.com/sandrodach/mindsift/internal/domain"
|
||||
"github.com/sandrodach/mindsift/internal/storage"
|
||||
)
|
||||
|
||||
// What an API key may do. The rights (scopes) hold the same over every access: the REST endpoints
|
||||
// below (curl, smart home) and the MCP endpoint for AI agents (mcpToolAllowed). Notes, settings and
|
||||
// everything else stay reserved for a login.
|
||||
|
||||
// apiKeyAuth: the key a request was made with
|
||||
type apiKeyAuth struct {
|
||||
ID string
|
||||
Scopes []string
|
||||
}
|
||||
|
||||
// apiActor: an API key acting on entries; via is storage.OriginAPI or storage.OriginMCP. Recorded as
|
||||
// the entry's origin (storage.RecordOrigin).
|
||||
type apiActor struct {
|
||||
Via string
|
||||
KeyID string
|
||||
}
|
||||
|
||||
type apiKeyCtxKey struct{}
|
||||
|
||||
type apiKeyCtx struct {
|
||||
scopes []string
|
||||
actor *apiActor
|
||||
}
|
||||
|
||||
func withAPIKey(ctx context.Context, key *apiKeyAuth, via string) context.Context {
|
||||
return context.WithValue(ctx, apiKeyCtxKey{}, apiKeyCtx{scopes: key.Scopes, actor: &apiActor{Via: via, KeyID: key.ID}})
|
||||
}
|
||||
|
||||
// keyScopes returns the scopes of the request's API key; isKey is false for login sessions.
|
||||
func keyScopes(ctx context.Context) (scopes []string, isKey bool) {
|
||||
k, ok := ctx.Value(apiKeyCtxKey{}).(apiKeyCtx)
|
||||
return k.scopes, ok
|
||||
}
|
||||
|
||||
// actorFrom: the API key behind the request, nil for login sessions.
|
||||
func actorFrom(ctx context.Context) *apiActor {
|
||||
if k, ok := ctx.Value(apiKeyCtxKey{}).(apiKeyCtx); ok {
|
||||
return k.actor
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func hasAnyScope(scopes []string, want ...string) bool {
|
||||
for _, have := range scopes {
|
||||
for _, w := range want {
|
||||
if have == w {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// keyRoute: one REST endpoint an API key may call, with the scopes of which it needs one. nil scopes:
|
||||
// the handler (or MCP tool) checks the exact right itself.
|
||||
type keyRoute struct {
|
||||
method string
|
||||
path *regexp.Regexp
|
||||
scopes []string
|
||||
}
|
||||
|
||||
var keyRoutes = []keyRoute{
|
||||
{http.MethodPost, regexp.MustCompile(`^/api/v1/inbox$`), nil},
|
||||
{http.MethodPost, regexp.MustCompile(`^` + regexp.QuoteMeta(mcpPath) + `$`), nil},
|
||||
{http.MethodGet, regexp.MustCompile(`^/api/v1/task-lists$`), []string{storage.ScopeTasksRead}},
|
||||
{http.MethodGet, regexp.MustCompile(`^/api/v1/tasks$`), []string{storage.ScopeTasksRead}},
|
||||
{http.MethodPatch, regexp.MustCompile(`^/api/v1/thoughts/[^/]+$`), []string{storage.ScopeTasksWrite}},
|
||||
{http.MethodDelete, regexp.MustCompile(`^/api/v1/thoughts/[^/]+$`), []string{storage.ScopeTasksWrite}},
|
||||
{http.MethodGet, regexp.MustCompile(`^/api/v1/lists$`), []string{storage.ScopeListsRead}},
|
||||
{http.MethodGet, regexp.MustCompile(`^/api/v1/lists/[^/]+$`), []string{storage.ScopeListsRead}},
|
||||
{http.MethodPost, regexp.MustCompile(`^/api/v1/lists/[^/]+/items$`), []string{storage.ScopeListsWrite, storage.ScopeList}},
|
||||
{http.MethodPatch, regexp.MustCompile(`^/api/v1/lists/[^/]+/items/[^/]+$`), []string{storage.ScopeListsWrite}},
|
||||
{http.MethodPut, regexp.MustCompile(`^/api/v1/lists/[^/]+/items/[^/]+$`), []string{storage.ScopeListsWrite}},
|
||||
}
|
||||
|
||||
// apiKeyRouteDenied returns why an API key may not make this request, or "" if it may.
|
||||
// /webhooks/ingest is not listed: it authenticates on its own and checks requireDeliveryScope.
|
||||
func apiKeyRouteDenied(r *http.Request, scopes []string) string {
|
||||
for _, kr := range keyRoutes {
|
||||
if r.Method != kr.method || !kr.path.MatchString(r.URL.Path) {
|
||||
continue
|
||||
}
|
||||
if kr.scopes == nil || hasAnyScope(scopes, kr.scopes...) {
|
||||
return ""
|
||||
}
|
||||
return "Dem API-Schlüssel fehlt die Berechtigung für diese Aktion"
|
||||
}
|
||||
return "Mit einem API-Schlüssel nicht erreichbar (nur Aufgaben, Listen, Inbox und MCP; Notizen und Einstellungen brauchen eine Anmeldung)"
|
||||
}
|
||||
|
||||
// requireDeliveryScope: delivering into the inbox, a task list or a list (POST /inbox,
|
||||
// /webhooks/ingest) needs the matching right; sessions may do all. target: "inbox"/"triage", "task",
|
||||
// "list_item"; notes ("document") cannot be created with a key.
|
||||
func requireDeliveryScope(w http.ResponseWriter, r *http.Request, target string) bool {
|
||||
scopes, isKey := keyScopes(r.Context())
|
||||
if !isKey {
|
||||
return true
|
||||
}
|
||||
var ok bool
|
||||
switch target {
|
||||
case "task":
|
||||
ok = hasAnyScope(scopes, storage.ScopeTask, storage.ScopeTasksWrite)
|
||||
case "list_item":
|
||||
ok = hasAnyScope(scopes, storage.ScopeList, storage.ScopeListsWrite)
|
||||
case "document":
|
||||
ok = false
|
||||
default:
|
||||
ok = hasAnyScope(scopes, storage.ScopeTriage)
|
||||
}
|
||||
if !ok {
|
||||
writeError(w, http.StatusForbidden, "API-Key besitzt keine Berechtigung für diese Aktion")
|
||||
}
|
||||
return ok
|
||||
}
|
||||
|
||||
// recordThoughtOrigin stores that an API key created or changed a task (nothing for the app itself)
|
||||
// and puts the result on t, so the broadcast and the response carry it.
|
||||
func (s *Server) recordThoughtOrigin(t *domain.Thought, actor *apiActor, created bool) {
|
||||
if actor == nil || t == nil {
|
||||
return
|
||||
}
|
||||
t.Origin = s.store.RecordOrigin(storage.OriginTableThoughts, t.ID, actor.Via, actor.KeyID, created)
|
||||
}
|
||||
|
||||
// recordListItemOrigin is recordThoughtOrigin for list items.
|
||||
func (s *Server) recordListItemOrigin(it *domain.ListItem, actor *apiActor, created bool) {
|
||||
if actor == nil || it == nil {
|
||||
return
|
||||
}
|
||||
it.Origin = s.store.RecordOrigin(storage.OriginTableListItems, it.ID, actor.Via, actor.KeyID, created)
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/sandrodach/mindsift/internal/auth"
|
||||
"github.com/sandrodach/mindsift/internal/domain"
|
||||
)
|
||||
|
||||
func keyRequest(t *testing.T, router http.Handler, key, method, path, body string) (int, []byte) {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(method, path, strings.NewReader(body))
|
||||
req.Header.Set("X-API-Key", key)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
return rec.Code, rec.Body.Bytes()
|
||||
}
|
||||
|
||||
// API keys reach tasks and lists over plain REST with the right scope, and nothing else.
|
||||
func TestAPIKeyRESTAccess(t *testing.T) {
|
||||
srv, store, token := setupTestServer(t)
|
||||
router := srv.Router()
|
||||
claims, _ := auth.ValidateToken(testJWTSecret, token)
|
||||
reader, _ := store.CreateAPIKey(claims.UserID, "Home Assistant", []string{"tasks:read", "lists:read"})
|
||||
writer, _ := store.CreateAPIKey(claims.UserID, "Skript", []string{"tasks:write", "lists:write"})
|
||||
inbox, _ := store.CreateAPIKey(claims.UserID, "Nur Inbox", nil)
|
||||
|
||||
task, _ := srv.createTask(claims.UserID, "Müll rausbringen", "", "manual", time.Now().Format("2006-01-02"), "", "", nil)
|
||||
list, _ := store.CreateList(claims.UserID, "Einkauf", "", "shopping")
|
||||
|
||||
if code, body := keyRequest(t, router, reader.RawKey, http.MethodGet, "/api/v1/tasks?due=today&status=open", ""); code != http.StatusOK || !strings.Contains(string(body), "Müll rausbringen") {
|
||||
t.Fatalf("reader GET /tasks: %d %s", code, body)
|
||||
}
|
||||
if code, _ := keyRequest(t, router, reader.RawKey, http.MethodGet, "/api/v1/lists/"+list.ID, ""); code != http.StatusOK {
|
||||
t.Fatalf("reader GET list: %d", code)
|
||||
}
|
||||
// Reading does not allow writing, and notes stay closed
|
||||
if code, _ := keyRequest(t, router, reader.RawKey, http.MethodPatch, "/api/v1/thoughts/"+task.ID, `{"isCompleted":true}`); code != http.StatusForbidden {
|
||||
t.Fatalf("reader must not change tasks: %d", code)
|
||||
}
|
||||
if code, _ := keyRequest(t, router, reader.RawKey, http.MethodGet, "/api/v1/documents", ""); code != http.StatusForbidden {
|
||||
t.Fatalf("keys must not reach notes: %d", code)
|
||||
}
|
||||
if code, _ := keyRequest(t, router, inbox.RawKey, http.MethodGet, "/api/v1/tasks", ""); code != http.StatusForbidden {
|
||||
t.Fatalf("inbox key must not read tasks: %d", code)
|
||||
}
|
||||
if code, _ := keyRequest(t, router, reader.RawKey, http.MethodGet, "/api/v1/tasks?due=morgen", ""); code != http.StatusBadRequest {
|
||||
t.Fatalf("bad due filter should be 400: %d", code)
|
||||
}
|
||||
|
||||
// Writing over REST records the key as origin
|
||||
if code, body := keyRequest(t, router, writer.RawKey, http.MethodPatch, "/api/v1/thoughts/"+task.ID, `{"isCompleted":true}`); code != http.StatusOK {
|
||||
t.Fatalf("writer PATCH: %d %s", code, body)
|
||||
}
|
||||
th, _ := store.GetThoughtByID(claims.UserID, task.ID)
|
||||
if !th.IsCompleted || th.Origin == nil || th.Origin.ChangedVia != "api" || th.Origin.ChangedKey != "Skript" {
|
||||
t.Fatalf("REST change by key: %+v %+v", th.IsCompleted, th.Origin)
|
||||
}
|
||||
code, body := keyRequest(t, router, writer.RawKey, http.MethodPost, "/api/v1/lists/"+list.ID+"/items", `{"content":"Milch"}`)
|
||||
var item domain.ListItem
|
||||
_ = json.Unmarshal(body, &item)
|
||||
if code != http.StatusCreated || item.Origin == nil || item.Origin.CreatedKey != "Skript" {
|
||||
t.Fatalf("writer adds item: %d %s", code, body)
|
||||
}
|
||||
|
||||
// Webhook-delivered thoughts keep their origin when the triage turns them into a list item
|
||||
code, body = keyRequest(t, router, inbox.RawKey, http.MethodPost, "/api/v1/webhooks/ingest", `{"title":"Kaffee kaufen"}`)
|
||||
var ingested struct {
|
||||
Thought domain.Thought `json:"thought"`
|
||||
}
|
||||
_ = json.Unmarshal(body, &ingested)
|
||||
if code != http.StatusCreated || ingested.Thought.Origin == nil || ingested.Thought.Origin.CreatedKey != "Nur Inbox" {
|
||||
t.Fatalf("ingest origin: %d %s", code, body)
|
||||
}
|
||||
if _, err := store.UpdateThoughtTriage(claims.UserID, ingested.Thought.ID, "keep", "list_item", list.ID, "", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
items, _ := store.GetListItems(list.ID)
|
||||
found := false
|
||||
for _, it := range items {
|
||||
if it.Content == "Kaffee kaufen" {
|
||||
found = it.Origin != nil && it.Origin.CreatedVia == "api" && it.Origin.CreatedKey == "Nur Inbox"
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("triaged list item lost its origin: %+v", items)
|
||||
}
|
||||
}
|
||||
|
||||
// The day filters count in the server's time zone, whatever the time of day.
|
||||
func TestFilterTasks(t *testing.T) {
|
||||
now := time.Date(2026, 11, 1, 23, 30, 0, 0, time.Local)
|
||||
tasks := []domain.Thought{
|
||||
{ID: "a", DueDate: "2026-11-01T09:00"},
|
||||
{ID: "b", DueDate: "2026-10-30"},
|
||||
{ID: "c", DueDate: "2026-10-29", IsCompleted: true},
|
||||
{ID: "d", DueDate: "2026-11-02"},
|
||||
{ID: "e"},
|
||||
}
|
||||
ids := func(ts []domain.Thought) string {
|
||||
out := []string{}
|
||||
for _, x := range ts {
|
||||
out = append(out, x.ID)
|
||||
}
|
||||
return strings.Join(out, ",")
|
||||
}
|
||||
for _, tc := range []struct{ status, due, want string }{
|
||||
{"", "", "a,b,c,d,e"},
|
||||
{"", "today", "a"},
|
||||
{"", "overdue", "b"},
|
||||
{"open", "2026-11-01", "a,b"},
|
||||
{"done", "", "c"},
|
||||
} {
|
||||
got, err := filterTasks(tasks, tc.status, tc.due, now)
|
||||
if err != nil || ids(got) != tc.want {
|
||||
t.Errorf("status=%q due=%q: got %s (%v), want %s", tc.status, tc.due, ids(got), err, tc.want)
|
||||
}
|
||||
}
|
||||
if _, err := filterTasks(tasks, "offen", "", now); err == nil {
|
||||
t.Error("unknown status should fail")
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -70,14 +69,15 @@ func (s *Server) handleWebhookIngest(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// Same checks as every other request (revoked/outdated sessions, inactive accounts)
|
||||
user, keyScopes := s.authenticateToken(token)
|
||||
user, key := s.authenticateToken(token)
|
||||
if user == nil {
|
||||
writeError(w, http.StatusUnauthorized, "Ungültiger Webhook-Token oder API-Key")
|
||||
return
|
||||
}
|
||||
if keyScopes != nil {
|
||||
r = r.WithContext(context.WithValue(r.Context(), apiKeyScopesKey{}, keyScopes))
|
||||
if key != nil {
|
||||
r = r.WithContext(withAPIKey(r.Context(), key, storage.OriginAPI))
|
||||
}
|
||||
actor := actorFrom(r.Context())
|
||||
|
||||
var req struct {
|
||||
Title string `json:"title"`
|
||||
@@ -163,11 +163,11 @@ func (s *Server) handleWebhookIngest(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
if target == "list_item" {
|
||||
s.ingestListItem(w, user.ID, req.ListID, bodyText, req.Category, req.Quantity)
|
||||
s.ingestListItem(w, user.ID, req.ListID, bodyText, req.Category, req.Quantity, actor)
|
||||
return
|
||||
}
|
||||
if target == "task" {
|
||||
s.ingestTask(w, user.ID, bodyText, req.Priority, source, dueDate, req.TaskListID)
|
||||
s.ingestTask(w, user.ID, bodyText, req.Priority, source, dueDate, req.TaskListID, actor)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -179,6 +179,7 @@ func (s *Server) handleWebhookIngest(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
s.recordThoughtOrigin(thought, actor, true)
|
||||
|
||||
s.wsHub.BroadcastToUsers(s.taskAudience(thought), "TASK_CREATED", thought)
|
||||
|
||||
@@ -188,9 +189,9 @@ func (s *Server) handleWebhookIngest(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
// createTask creates a task directly (webhook scope webhook:task, MCP tool create_task), optionally in
|
||||
// a task list the user may write to (otherwise storage.ErrTaskListReadOnly).
|
||||
func (s *Server) createTask(userID, content, priority, source, dueDate, taskListID, notes string) (*domain.Thought, error) {
|
||||
// createTask creates a task directly (POST /webhooks/ingest, MCP tool create_task), optionally in a
|
||||
// task list the user may write to (otherwise storage.ErrTaskListReadOnly). actor: the API key, if any.
|
||||
func (s *Server) createTask(userID, content, priority, source, dueDate, taskListID, notes string, actor *apiActor) (*domain.Thought, error) {
|
||||
if priority == "" {
|
||||
priority = "normal"
|
||||
}
|
||||
@@ -205,12 +206,13 @@ func (s *Server) createTask(userID, content, priority, source, dueDate, taskList
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.recordThoughtOrigin(task, actor, true)
|
||||
s.wsHub.BroadcastToUsers(s.taskAudience(task), "TASK_CREATED", task)
|
||||
return task, nil
|
||||
}
|
||||
|
||||
func (s *Server) ingestTask(w http.ResponseWriter, userID, content, priority, source, dueDate, taskListID string) {
|
||||
task, err := s.createTask(userID, content, priority, source, dueDate, taskListID, "")
|
||||
func (s *Server) ingestTask(w http.ResponseWriter, userID, content, priority, source, dueDate, taskListID string, actor *apiActor) {
|
||||
task, err := s.createTask(userID, content, priority, source, dueDate, taskListID, "", actor)
|
||||
if errors.Is(err, storage.ErrTaskListReadOnly) {
|
||||
writeError(w, http.StatusForbidden, err.Error())
|
||||
return
|
||||
@@ -226,7 +228,7 @@ var errListIDRequired = errors.New("Feld 'listId' ist für Listeneinträge erfor
|
||||
|
||||
// addListItem appends an item to a list the user may write to; the error is storage.ErrListReadOnly for
|
||||
// viewers and "not found" for lists the user cannot see.
|
||||
func (s *Server) addListItem(userID, listID, content, category, quantity string, byAgent bool) (*domain.ListItem, error) {
|
||||
func (s *Server) addListItem(userID, listID, content, category, quantity string, actor *apiActor) (*domain.ListItem, error) {
|
||||
if strings.TrimSpace(listID) == "" {
|
||||
return nil, errListIDRequired
|
||||
}
|
||||
@@ -237,16 +239,14 @@ func (s *Server) addListItem(userID, listID, content, category, quantity string,
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if byAgent {
|
||||
item.AgentMark = s.store.MarkListItemAgentCreated(item.ID)
|
||||
}
|
||||
s.recordListItemOrigin(item, actor, true)
|
||||
s.wsHub.BroadcastListEvent(listID, "ITEM_CREATED", item)
|
||||
return item, nil
|
||||
}
|
||||
|
||||
// ingestListItem adds an item to a list (scope webhook:list) the owner of the key may write to.
|
||||
func (s *Server) ingestListItem(w http.ResponseWriter, userID, listID, content, category, quantity string) {
|
||||
item, err := s.addListItem(userID, listID, content, category, quantity, false)
|
||||
// ingestListItem adds an item to a list the owner of the key may write to.
|
||||
func (s *Server) ingestListItem(w http.ResponseWriter, userID, listID, content, category, quantity string, actor *apiActor) {
|
||||
item, err := s.addListItem(userID, listID, content, category, quantity, actor)
|
||||
if err != nil {
|
||||
status := http.StatusNotFound
|
||||
switch {
|
||||
|
||||
@@ -161,14 +161,15 @@ func (s *Server) handleCreateListItem(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
s.recordListItemOrigin(item, actorFrom(r.Context()), true)
|
||||
|
||||
s.wsHub.BroadcastListEvent(listID, "ITEM_CREATED", item)
|
||||
writeJSON(w, http.StatusCreated, item)
|
||||
}
|
||||
|
||||
// setListItemDone checks an item off (or back on) and tells the list's viewers. Shared by
|
||||
// PATCH /lists/{id}/items/{itemId} and the MCP tool check_list_item (byAgent); access is checked first.
|
||||
func (s *Server) setListItemDone(userID, listID, itemID string, done, byAgent bool) (*domain.ListItem, error) {
|
||||
// PATCH /lists/{id}/items/{itemId} and the MCP tool check_list_item; access is checked first.
|
||||
func (s *Server) setListItemDone(userID, listID, itemID string, done bool, actor *apiActor) (*domain.ListItem, error) {
|
||||
if err := s.store.EnsureListItemsWritable(userID, listID, itemID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -181,7 +182,7 @@ func (s *Server) setListItemDone(userID, listID, itemID string, done, byAgent bo
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item.AgentMark = s.store.SetListItemAgentMark(itemID, byAgent)
|
||||
s.recordListItemOrigin(item, actor, false)
|
||||
s.wsHub.BroadcastListEvent(listID, "ITEM_TOGGLED", item)
|
||||
return item, nil
|
||||
}
|
||||
@@ -202,7 +203,7 @@ func (s *Server) handleToggleListItem(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
item, err := s.setListItemDone(userID, listID, itemID, req.IsCompleted, false)
|
||||
item, err := s.setListItemDone(userID, listID, itemID, req.IsCompleted, actorFrom(r.Context()))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
@@ -232,7 +233,7 @@ func (s *Server) handleUpdateListItem(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
item.AgentMark = s.store.SetListItemAgentMark(itemID, false)
|
||||
s.recordListItemOrigin(item, actorFrom(r.Context()), false)
|
||||
|
||||
s.wsHub.BroadcastListEvent(listID, "ITEM_UPDATED", item)
|
||||
writeJSON(w, http.StatusOK, item)
|
||||
|
||||
@@ -2,8 +2,10 @@ package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/sandrodach/mindsift/internal/auth"
|
||||
@@ -69,6 +71,8 @@ func (s *Server) handleCaptureInbox(w http.ResponseWriter, r *http.Request) {
|
||||
writeStoreError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
// Before a conversion, so a list item made from it takes the origin along
|
||||
s.recordThoughtOrigin(thought, actorFrom(r.Context()), true)
|
||||
|
||||
target := strings.TrimSpace(req.Target)
|
||||
if target == "task" || target == "document" || (target == "list_item" && targetListID != "") {
|
||||
@@ -175,15 +179,60 @@ func (s *Server) handleTriageAction(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) handleGetTasks(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
taskListID := r.URL.Query().Get("taskListId")
|
||||
tasks, err := s.store.GetTasks(userID, taskListID)
|
||||
q := r.URL.Query()
|
||||
tasks, err := s.store.GetTasks(userID, q.Get("taskListId"))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
tasks, err = filterTasks(tasks, q.Get("status"), q.Get("due"), time.Now())
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, tasks)
|
||||
}
|
||||
|
||||
// filterTasks narrows GET /tasks for scripts and smart homes; without parameters nothing changes.
|
||||
// status: open | done | all (default). due: today | overdue | YYYY-MM-DD (due on or before that day).
|
||||
// Days count in the server's time zone like due dates themselves (now is passed in for tests).
|
||||
func filterTasks(tasks []domain.Thought, status, due string, now time.Time) ([]domain.Thought, error) {
|
||||
switch status {
|
||||
case "", "all", "open", "done":
|
||||
default:
|
||||
return nil, errors.New("status: open, done oder all")
|
||||
}
|
||||
today := now.In(time.Local).Format("2006-01-02")
|
||||
var match func(day string) bool
|
||||
switch {
|
||||
case due == "":
|
||||
case due == "today":
|
||||
match = func(day string) bool { return day == today }
|
||||
case due == "overdue":
|
||||
match = func(day string) bool { return day < today }
|
||||
default:
|
||||
if _, err := time.Parse("2006-01-02", due); err != nil {
|
||||
return nil, errors.New("due: today, overdue oder ein Datum JJJJ-MM-TT")
|
||||
}
|
||||
match = func(day string) bool { return day <= due }
|
||||
}
|
||||
out := make([]domain.Thought, 0, len(tasks))
|
||||
for _, t := range tasks {
|
||||
if (status == "open" && t.IsCompleted) || (status == "done" && !t.IsCompleted) {
|
||||
continue
|
||||
}
|
||||
// Overdue only makes sense for open tasks
|
||||
if due == "overdue" && t.IsCompleted {
|
||||
continue
|
||||
}
|
||||
if match != nil && (t.DueDate == "" || !match(t.DueDate[:min(10, len(t.DueDate))])) {
|
||||
continue
|
||||
}
|
||||
out = append(out, t)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Server) handleReorderTasks(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
taskListID := chi.URLParam(r, "id")
|
||||
@@ -245,8 +294,8 @@ type taskPatch struct {
|
||||
Tags *[]string `json:"tags"`
|
||||
Assignees *[]string `json:"assignees"`
|
||||
Recurrence *string `json:"recurrence"`
|
||||
// byAgent: the change comes from an AI agent (MCP) and marks the task; otherwise the mark goes
|
||||
byAgent bool
|
||||
// actor: the API key making the change (recorded as the task's origin); nil for the app
|
||||
actor *apiActor
|
||||
}
|
||||
|
||||
// updateTask applies a patch, keeps a linked note block in step, tells everyone who sees the task
|
||||
@@ -263,7 +312,7 @@ func (s *Server) updateTask(userID, thoughtID string, req taskPatch) (*domain.Th
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
updated.AgentMark = s.store.SetThoughtAgentMark(thoughtID, updated.AgentMark, req.byAgent)
|
||||
s.recordThoughtOrigin(updated, req.actor, false)
|
||||
if req.Assignees != nil {
|
||||
assignees, err := s.store.SetTaskAssignees(userID, thoughtID, *req.Assignees)
|
||||
if err != nil {
|
||||
@@ -290,6 +339,7 @@ func (s *Server) handleUpdateThought(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||||
return
|
||||
}
|
||||
req.actor = actorFrom(r.Context())
|
||||
updated, err := s.updateTask(userID, thoughtID, req)
|
||||
if err != nil {
|
||||
writeStoreError(w, http.StatusBadRequest, err)
|
||||
|
||||
+40
-29
@@ -58,8 +58,10 @@ type mcpTool struct {
|
||||
Name string
|
||||
Description string
|
||||
Scope string
|
||||
Schema map[string]interface{}
|
||||
run func(s *Server, userID string, args json.RawMessage) (interface{}, error)
|
||||
// AltScope: a narrower right that allows this tool too (e.g. "create tasks" for create_task)
|
||||
AltScope string
|
||||
Schema map[string]interface{}
|
||||
run func(s *Server, userID string, actor *apiActor, args json.RawMessage) (interface{}, error)
|
||||
}
|
||||
|
||||
// mcpToolAllowed: sessions may use every tool, API keys only those of their scopes. search needs any
|
||||
@@ -82,12 +84,17 @@ func mcpToolAllowed(scopes []string, isKey bool, tool mcpTool) bool {
|
||||
if tool.Name == "search" {
|
||||
return has(storage.ScopeTasksRead) || has(storage.ScopeListsRead)
|
||||
}
|
||||
return has(tool.Scope)
|
||||
return has(tool.Scope) || (tool.AltScope != "" && has(tool.AltScope))
|
||||
}
|
||||
|
||||
func (s *Server) handleMCP(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
scopes, isKey := r.Context().Value(apiKeyScopesKey{}).([]string)
|
||||
scopes, isKey := keyScopes(r.Context())
|
||||
// Changes are recorded as made via MCP, with the key if there is one (a login session has none)
|
||||
actor := actorFrom(r.Context())
|
||||
if actor == nil {
|
||||
actor = &apiActor{Via: storage.OriginMCP}
|
||||
}
|
||||
|
||||
var raw json.RawMessage
|
||||
if err := json.NewDecoder(r.Body).Decode(&raw); err != nil {
|
||||
@@ -129,7 +136,7 @@ func (s *Server) handleMCP(w http.ResponseWriter, r *http.Request) {
|
||||
"instructions": "MindSift is a personal notes, tasks and lists app. Use list_task_lists and list_tasks " +
|
||||
"to see open tasks, complete_task to check one off, list_lists/get_list for shopping and other " +
|
||||
"lists and check_list_item to tick items. IDs come from the list tools; never guess them. " +
|
||||
"Entries you create or change are marked for the user until they touch them.",
|
||||
"Entries you create or change show the user a badge with the name of your API key.",
|
||||
}, nil)
|
||||
case "ping":
|
||||
writeRPC(w, req.ID, map[string]interface{}{}, nil)
|
||||
@@ -167,7 +174,7 @@ func (s *Server) handleMCP(w http.ResponseWriter, r *http.Request) {
|
||||
if len(p.Arguments) == 0 {
|
||||
p.Arguments = json.RawMessage("{}")
|
||||
}
|
||||
result, err := tool.run(s, userID, p.Arguments)
|
||||
result, err := tool.run(s, userID, actor, p.Arguments)
|
||||
if err != nil {
|
||||
writeRPC(w, req.ID, toolError(err.Error()), nil)
|
||||
return
|
||||
@@ -265,13 +272,14 @@ type taskView struct {
|
||||
Subtasks []domain.SubTask `json:"subtasks,omitempty"`
|
||||
Recurrence string `json:"recurrence,omitempty"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
Origin *domain.Origin `json:"origin,omitempty"`
|
||||
}
|
||||
|
||||
func toTaskView(t *domain.Thought) taskView {
|
||||
return taskView{
|
||||
ID: t.ID, Content: t.Content, IsCompleted: t.IsCompleted, Priority: t.Priority, DueDate: t.DueDate,
|
||||
TaskListID: t.TaskListID, KanbanStatus: t.KanbanStatus, Notes: t.Notes, Tags: t.Tags, Subtasks: t.Subtasks,
|
||||
Recurrence: t.Recurrence, CreatedAt: t.CreatedAt,
|
||||
Recurrence: t.Recurrence, CreatedAt: t.CreatedAt, Origin: t.Origin,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -333,7 +341,7 @@ var mcpTools = []mcpTool{
|
||||
Description: "All task lists the user can see, with permission (owner/editor/viewer), the number of open tasks and the board columns (valid kanbanStatus values). Tasks without a list live in the personal inbox list (taskListId empty).",
|
||||
Scope: storage.ScopeTasksRead,
|
||||
Schema: schema(nil, map[string]interface{}{}),
|
||||
run: func(s *Server, userID string, _ json.RawMessage) (interface{}, error) {
|
||||
run: func(s *Server, userID string, _ *apiActor, _ json.RawMessage) (interface{}, error) {
|
||||
lists, err := s.store.GetTaskLists(userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -353,7 +361,7 @@ var mcpTools = []mcpTool{
|
||||
"title": prop("string", "Name of the task list"),
|
||||
"icon": prop("string", "One emoji, optional (default 📋)"),
|
||||
}),
|
||||
run: func(s *Server, userID string, args json.RawMessage) (interface{}, error) {
|
||||
run: func(s *Server, userID string, actor *apiActor, args json.RawMessage) (interface{}, error) {
|
||||
var a struct {
|
||||
Title string `json:"title"`
|
||||
Icon string `json:"icon"`
|
||||
@@ -383,7 +391,7 @@ var mcpTools = []mcpTool{
|
||||
"dueBefore": prop("string", "Only tasks due on or before this date (YYYY-MM-DD); tasks without due date are left out"),
|
||||
"limit": prop("integer", "Maximum number of tasks (default 100)"),
|
||||
}),
|
||||
run: func(s *Server, userID string, args json.RawMessage) (interface{}, error) {
|
||||
run: func(s *Server, userID string, actor *apiActor, args json.RawMessage) (interface{}, error) {
|
||||
var a struct {
|
||||
TaskListID string `json:"taskListId"`
|
||||
Status string `json:"status"`
|
||||
@@ -437,7 +445,7 @@ var mcpTools = []mcpTool{
|
||||
Description: "One task with all details.",
|
||||
Scope: storage.ScopeTasksRead,
|
||||
Schema: schema([]string{"id"}, map[string]interface{}{"id": prop("string", "Task id")}),
|
||||
run: func(s *Server, userID string, args json.RawMessage) (interface{}, error) {
|
||||
run: func(s *Server, userID string, actor *apiActor, args json.RawMessage) (interface{}, error) {
|
||||
var a struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
@@ -455,6 +463,7 @@ var mcpTools = []mcpTool{
|
||||
Name: "create_task",
|
||||
Description: "Create a task, optionally in a task list the user may edit. dueDate as YYYY-MM-DD or YYYY-MM-DDTHH:MM.",
|
||||
Scope: storage.ScopeTasksWrite,
|
||||
AltScope: storage.ScopeTask,
|
||||
Schema: schema([]string{"content"}, map[string]interface{}{
|
||||
"content": prop("string", "Task text; #hashtags become tags"),
|
||||
"taskListId": prop("string", "Task list id (from list_task_lists); empty = personal tasks"),
|
||||
@@ -462,7 +471,7 @@ var mcpTools = []mcpTool{
|
||||
"dueDate": prop("string", "YYYY-MM-DD or YYYY-MM-DDTHH:MM"),
|
||||
"notes": prop("string", "Longer description"),
|
||||
}),
|
||||
run: func(s *Server, userID string, args json.RawMessage) (interface{}, error) {
|
||||
run: func(s *Server, userID string, actor *apiActor, args json.RawMessage) (interface{}, error) {
|
||||
var a struct {
|
||||
Content string `json:"content"`
|
||||
TaskListID string `json:"taskListId"`
|
||||
@@ -476,7 +485,7 @@ var mcpTools = []mcpTool{
|
||||
if strings.TrimSpace(a.Content) == "" {
|
||||
return nil, errors.New("'content' darf nicht leer sein")
|
||||
}
|
||||
t, err := s.createTask(userID, a.Content, a.Priority, "agent", a.DueDate, a.TaskListID, a.Notes)
|
||||
t, err := s.createTask(userID, a.Content, a.Priority, "agent", a.DueDate, a.TaskListID, a.Notes, actor)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -498,7 +507,7 @@ var mcpTools = []mcpTool{
|
||||
"taskListId": prop("string", "Move into another task list"),
|
||||
"tags": map[string]interface{}{"type": "array", "items": map[string]string{"type": "string"}},
|
||||
}),
|
||||
run: func(s *Server, userID string, args json.RawMessage) (interface{}, error) {
|
||||
run: func(s *Server, userID string, actor *apiActor, args json.RawMessage) (interface{}, error) {
|
||||
var a struct {
|
||||
ID string `json:"id"`
|
||||
taskPatch
|
||||
@@ -507,7 +516,7 @@ var mcpTools = []mcpTool{
|
||||
return nil, err
|
||||
}
|
||||
a.taskPatch.Assignees, a.taskPatch.Recurrence, a.taskPatch.Subtasks = nil, nil, nil
|
||||
a.taskPatch.byAgent = true
|
||||
a.taskPatch.actor = actor
|
||||
t, err := s.updateTask(userID, a.ID, a.taskPatch)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -523,7 +532,7 @@ var mcpTools = []mcpTool{
|
||||
"id": prop("string", "Task id"),
|
||||
"completed": prop("boolean", "Default true"),
|
||||
}),
|
||||
run: func(s *Server, userID string, args json.RawMessage) (interface{}, error) {
|
||||
run: func(s *Server, userID string, actor *apiActor, args json.RawMessage) (interface{}, error) {
|
||||
var a struct {
|
||||
ID string `json:"id"`
|
||||
Completed *bool `json:"completed"`
|
||||
@@ -532,7 +541,7 @@ var mcpTools = []mcpTool{
|
||||
return nil, err
|
||||
}
|
||||
done := a.Completed == nil || *a.Completed
|
||||
t, err := s.updateTask(userID, a.ID, taskPatch{IsCompleted: &done, byAgent: true})
|
||||
t, err := s.updateTask(userID, a.ID, taskPatch{IsCompleted: &done, actor: actor})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -544,7 +553,7 @@ var mcpTools = []mcpTool{
|
||||
Description: "Delete a task for good, e.g. one created by mistake. Prefer complete_task for work that is done.",
|
||||
Scope: storage.ScopeTasksWrite,
|
||||
Schema: schema([]string{"id"}, map[string]interface{}{"id": prop("string", "Task id")}),
|
||||
run: func(s *Server, userID string, args json.RawMessage) (interface{}, error) {
|
||||
run: func(s *Server, userID string, actor *apiActor, args json.RawMessage) (interface{}, error) {
|
||||
var a struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
@@ -565,7 +574,7 @@ var mcpTools = []mcpTool{
|
||||
Description: "Shopping lists, checklists and other shared lists the user can see, with open/total item counts.",
|
||||
Scope: storage.ScopeListsRead,
|
||||
Schema: schema(nil, map[string]interface{}{}),
|
||||
run: func(s *Server, userID string, _ json.RawMessage) (interface{}, error) {
|
||||
run: func(s *Server, userID string, _ *apiActor, _ json.RawMessage) (interface{}, error) {
|
||||
lists, err := s.store.GetAllListsForUser(userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -586,7 +595,7 @@ var mcpTools = []mcpTool{
|
||||
"icon": prop("string", "One emoji, optional"),
|
||||
"listType": map[string]interface{}{"type": "string", "enum": []string{"shopping", "checklist", "vacation"}, "description": "Default shopping"},
|
||||
}),
|
||||
run: func(s *Server, userID string, args json.RawMessage) (interface{}, error) {
|
||||
run: func(s *Server, userID string, actor *apiActor, args json.RawMessage) (interface{}, error) {
|
||||
var a struct {
|
||||
Title string `json:"title"`
|
||||
Icon string `json:"icon"`
|
||||
@@ -611,7 +620,7 @@ var mcpTools = []mcpTool{
|
||||
Description: "One list with all its items (id, content, category, quantity, isCompleted).",
|
||||
Scope: storage.ScopeListsRead,
|
||||
Schema: schema([]string{"listId"}, map[string]interface{}{"listId": prop("string", "List id from list_lists")}),
|
||||
run: func(s *Server, userID string, args json.RawMessage) (interface{}, error) {
|
||||
run: func(s *Server, userID string, actor *apiActor, args json.RawMessage) (interface{}, error) {
|
||||
var a struct {
|
||||
ListID string `json:"listId"`
|
||||
}
|
||||
@@ -629,13 +638,14 @@ var mcpTools = []mcpTool{
|
||||
Name: "add_list_item",
|
||||
Description: "Add an item to a list the user may edit.",
|
||||
Scope: storage.ScopeListsWrite,
|
||||
AltScope: storage.ScopeList,
|
||||
Schema: schema([]string{"listId", "content"}, map[string]interface{}{
|
||||
"listId": prop("string", "List id"),
|
||||
"content": prop("string", "Item text"),
|
||||
"category": prop("string", "Category/aisle, e.g. Obst"),
|
||||
"quantity": prop("string", "Amount, e.g. 2 or 500 g"),
|
||||
}),
|
||||
run: func(s *Server, userID string, args json.RawMessage) (interface{}, error) {
|
||||
run: func(s *Server, userID string, actor *apiActor, args json.RawMessage) (interface{}, error) {
|
||||
var a struct {
|
||||
ListID, Content, Category, Quantity string
|
||||
}
|
||||
@@ -645,7 +655,7 @@ var mcpTools = []mcpTool{
|
||||
if strings.TrimSpace(a.Content) == "" {
|
||||
return nil, errors.New("'content' darf nicht leer sein")
|
||||
}
|
||||
return s.addListItem(userID, a.ListID, a.Content, a.Category, a.Quantity, true)
|
||||
return s.addListItem(userID, a.ListID, a.Content, a.Category, a.Quantity, actor)
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -657,7 +667,7 @@ var mcpTools = []mcpTool{
|
||||
"itemId": prop("string", "Item id from get_list"),
|
||||
"completed": prop("boolean", "Default true"),
|
||||
}),
|
||||
run: func(s *Server, userID string, args json.RawMessage) (interface{}, error) {
|
||||
run: func(s *Server, userID string, actor *apiActor, args json.RawMessage) (interface{}, error) {
|
||||
var a struct {
|
||||
ListID string `json:"listId"`
|
||||
ItemID string `json:"itemId"`
|
||||
@@ -666,7 +676,7 @@ var mcpTools = []mcpTool{
|
||||
if err := decodeArgs(args, &a); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.setListItemDone(userID, a.ListID, a.ItemID, a.Completed == nil || *a.Completed, true)
|
||||
return s.setListItemDone(userID, a.ListID, a.ItemID, a.Completed == nil || *a.Completed, actor)
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -680,7 +690,7 @@ var mcpTools = []mcpTool{
|
||||
"category": prop("string", "New category"),
|
||||
"quantity": prop("string", "New amount"),
|
||||
}),
|
||||
run: func(s *Server, userID string, args json.RawMessage) (interface{}, error) {
|
||||
run: func(s *Server, userID string, actor *apiActor, args json.RawMessage) (interface{}, error) {
|
||||
var a struct {
|
||||
ListID string `json:"listId"`
|
||||
ItemID string `json:"itemId"`
|
||||
@@ -698,7 +708,7 @@ var mcpTools = []mcpTool{
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item.AgentMark = s.store.SetListItemAgentMark(a.ItemID, true)
|
||||
s.recordListItemOrigin(item, actor, false)
|
||||
s.wsHub.BroadcastListEvent(a.ListID, "ITEM_UPDATED", item)
|
||||
return item, nil
|
||||
},
|
||||
@@ -708,7 +718,7 @@ var mcpTools = []mcpTool{
|
||||
Description: "Full-text search over tasks and list items the user can see (notes are not included for API keys).",
|
||||
Scope: storage.ScopeTasksRead,
|
||||
Schema: schema([]string{"query"}, map[string]interface{}{"query": prop("string", "Words to search for")}),
|
||||
run: func(s *Server, userID string, args json.RawMessage) (interface{}, error) {
|
||||
run: func(s *Server, userID string, actor *apiActor, args json.RawMessage) (interface{}, error) {
|
||||
var a struct {
|
||||
Query string `json:"query"`
|
||||
}
|
||||
@@ -735,7 +745,7 @@ var mcpTools = []mcpTool{
|
||||
"priority": map[string]interface{}{"type": "string", "enum": []string{"low", "normal", "high", "urgent"}},
|
||||
"dueDate": prop("string", "YYYY-MM-DD, optional"),
|
||||
}),
|
||||
run: func(s *Server, userID string, args json.RawMessage) (interface{}, error) {
|
||||
run: func(s *Server, userID string, actor *apiActor, args json.RawMessage) (interface{}, error) {
|
||||
var a struct {
|
||||
Content, Priority, DueDate string
|
||||
}
|
||||
@@ -752,6 +762,7 @@ var mcpTools = []mcpTool{
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.recordThoughtOrigin(t, actor, true)
|
||||
s.wsHub.BroadcastToUsers(s.taskAudience(t), "TASK_CREATED", t)
|
||||
return map[string]interface{}{"id": t.ID, "content": t.Content, "status": fmt.Sprint(t.Status)}, nil
|
||||
},
|
||||
|
||||
+37
-24
@@ -197,13 +197,13 @@ func TestMCPTasksAndLists(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Agents can create task lists and lists and delete tasks; their entries carry a mark until the user
|
||||
// changes them.
|
||||
func TestMCPCreateDeleteAndAgentMark(t *testing.T) {
|
||||
// Agents can create task lists and lists and delete tasks; what a key creates or changes keeps its
|
||||
// origin for good, also after the user's own edits.
|
||||
func TestMCPCreateDeleteAndOrigin(t *testing.T) {
|
||||
srv, store, token := setupTestServer(t)
|
||||
router := srv.Router()
|
||||
claims, _ := auth.ValidateToken(testJWTSecret, token)
|
||||
key, _ := store.CreateAPIKey(claims.UserID, "Agent", []string{"tasks:read", "tasks:write", "lists:read", "lists:write"})
|
||||
key, _ := store.CreateAPIKey(claims.UserID, "Claude Code", []string{"tasks:read", "tasks:write", "lists:read", "lists:write"})
|
||||
call := func(id int, name, args string) map[string]interface{} {
|
||||
t.Helper()
|
||||
_, res := rpc(t, router, key.RawKey, fmt.Sprintf(`{"jsonrpc":"2.0","id":%d,"method":"tools/call","params":{"name":%q,"arguments":%s}}`, id, name, args))
|
||||
@@ -222,25 +222,34 @@ func TestMCPCreateDeleteAndAgentMark(t *testing.T) {
|
||||
task := call(2, "create_task", `{"content":"Von Claude","taskListId":"`+tlID+`"}`)
|
||||
taskID := task["id"].(string)
|
||||
th, _ := store.GetThoughtByID(claims.UserID, taskID)
|
||||
if th.AgentMark != "created" || th.TaskListID != tlID {
|
||||
t.Fatalf("agent task should be marked created: %+v", th)
|
||||
}
|
||||
// An agent's change keeps "created"; the user's own change removes the mark
|
||||
call(3, "update_task", `{"id":"`+taskID+`","priority":"high"}`)
|
||||
if th, _ = store.GetThoughtByID(claims.UserID, taskID); th.AgentMark != "created" {
|
||||
t.Fatalf("mark after agent edit: %q", th.AgentMark)
|
||||
if th.Origin == nil || th.Origin.CreatedVia != "mcp" || th.Origin.CreatedKey != "Claude Code" || th.TaskListID != tlID {
|
||||
t.Fatalf("agent task should carry its origin: %+v", th.Origin)
|
||||
}
|
||||
// The user's own edit keeps the origin
|
||||
notes := "selbst ergänzt"
|
||||
if _, err := srv.updateTask(claims.UserID, taskID, taskPatch{Notes: ¬es}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if th, _ = store.GetThoughtByID(claims.UserID, taskID); th.AgentMark != "" {
|
||||
t.Fatalf("user edit should clear the mark, got %q", th.AgentMark)
|
||||
if th, _ = store.GetThoughtByID(claims.UserID, taskID); th.Origin == nil || th.Origin.CreatedKey != "Claude Code" || th.Origin.ChangedVia != "" {
|
||||
t.Fatalf("user edit must keep the origin and not count as a key change: %+v", th.Origin)
|
||||
}
|
||||
call(4, "complete_task", `{"id":"`+taskID+`"}`)
|
||||
if th, _ = store.GetThoughtByID(claims.UserID, taskID); th.AgentMark != "edited" {
|
||||
t.Fatalf("agent edit of a user task should mark edited, got %q", th.AgentMark)
|
||||
|
||||
// A task of the user changed by the agent: changed via MCP, with key and time
|
||||
own, _ := srv.createTask(claims.UserID, "Von mir", "", "manual", "", "", "", nil)
|
||||
if own.Origin != nil {
|
||||
t.Fatalf("own task must not have an origin: %+v", own.Origin)
|
||||
}
|
||||
call(3, "complete_task", `{"id":"`+own.ID+`"}`)
|
||||
if th, _ = store.GetThoughtByID(claims.UserID, own.ID); th.Origin == nil || th.Origin.CreatedVia != "" ||
|
||||
th.Origin.ChangedVia != "mcp" || th.Origin.ChangedKey != "Claude Code" || th.Origin.ChangedAt == "" {
|
||||
t.Fatalf("agent change of a user task: %+v", th.Origin)
|
||||
}
|
||||
// Renamed or deleted keys show up as such
|
||||
_ = store.DeleteAPIKey(claims.UserID, key.ID)
|
||||
if th, _ = store.GetThoughtByID(claims.UserID, own.ID); th.Origin.ChangedKey != "Gelöschter Schlüssel" {
|
||||
t.Fatalf("deleted key name: %q", th.Origin.ChangedKey)
|
||||
}
|
||||
key, _ = store.CreateAPIKey(claims.UserID, "Claude Code", []string{"tasks:read", "tasks:write", "lists:read", "lists:write"})
|
||||
|
||||
call(5, "delete_task", `{"id":"`+taskID+`"}`)
|
||||
if th, err := store.GetThoughtByID(claims.UserID, taskID); err == nil && th != nil {
|
||||
@@ -253,18 +262,15 @@ func TestMCPCreateDeleteAndAgentMark(t *testing.T) {
|
||||
t.Fatalf("create_list: %v", l)
|
||||
}
|
||||
item := call(7, "add_list_item", `{"listId":"`+listID+`","content":"Zahnbürste"}`)
|
||||
if item["agentMark"] != "created" {
|
||||
t.Fatalf("agent item should be marked created: %v", item)
|
||||
if o, _ := item["origin"].(map[string]interface{}); o == nil || o["createdVia"] != "mcp" {
|
||||
t.Fatalf("agent item should carry its origin: %v", item)
|
||||
}
|
||||
items, _ := store.GetListItems(listID)
|
||||
if len(items) != 1 || items[0].AgentMark != "created" {
|
||||
t.Fatalf("stored item mark: %+v", items)
|
||||
}
|
||||
if _, err := srv.setListItemDone(claims.UserID, listID, items[0].ID, true, false); err != nil {
|
||||
if _, err := srv.setListItemDone(claims.UserID, listID, items[0].ID, true, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if items, _ = store.GetListItems(listID); items[0].AgentMark != "" {
|
||||
t.Fatalf("ticking by the user should clear the mark: %q", items[0].AgentMark)
|
||||
if items, _ = store.GetListItems(listID); items[0].Origin == nil || items[0].Origin.CreatedKey != "Claude Code" {
|
||||
t.Fatalf("ticking by the user keeps the origin: %+v", items[0].Origin)
|
||||
}
|
||||
|
||||
// Other people's tasks cannot be deleted
|
||||
@@ -274,4 +280,11 @@ func TestMCPCreateDeleteAndAgentMark(t *testing.T) {
|
||||
if _, isErr := callResult(t, res); !isErr {
|
||||
t.Fatalf("key must not delete foreign tasks")
|
||||
}
|
||||
|
||||
// The narrow right "create tasks" also allows create_task over MCP, but not update_task
|
||||
creator, _ := store.CreateAPIKey(claims.UserID, "Nur anlegen", []string{"webhook:task"})
|
||||
_, res = rpc(t, router, creator.RawKey, `{"jsonrpc":"2.0","id":9,"method":"tools/list"}`)
|
||||
if names := strings.Join(toolNames(res), ","); !strings.Contains(names, "create_task") || strings.Contains(names, "update_task") {
|
||||
t.Fatalf("webhook:task key tools: %s", names)
|
||||
}
|
||||
}
|
||||
@@ -28,7 +28,7 @@ func (s *Server) authenticateUserFromReq(r *http.Request) string {
|
||||
if authHdr := r.Header.Get("Authorization"); strings.HasPrefix(authHdr, "Bearer ") {
|
||||
token = strings.TrimPrefix(authHdr, "Bearer ")
|
||||
}
|
||||
if user, keyScopes := s.authenticateToken(token); user != nil && keyScopes == nil {
|
||||
if user, key := s.authenticateToken(token); user != nil && key == nil {
|
||||
return user.ID
|
||||
}
|
||||
return ""
|
||||
|
||||
+15
-48
@@ -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))
|
||||
})
|
||||
|
||||
+25
-15
@@ -268,7 +268,17 @@ type Thought struct {
|
||||
SortOrder int `json:"sortOrder"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
TriagedAt string `json:"triagedAt,omitempty"`
|
||||
AgentMark string `json:"agentMark,omitempty"` // created | edited: last change via MCP, untouched by the user since
|
||||
Origin *Origin `json:"origin,omitempty"` // API key that created / last changed it
|
||||
}
|
||||
|
||||
// Origin: which API key created an entry and which one changed it last, over which access ("api" for
|
||||
// REST, "mcp"). Key names are resolved when reading; it stays when the user edits the entry.
|
||||
type Origin struct {
|
||||
CreatedVia string `json:"createdVia,omitempty"`
|
||||
CreatedKey string `json:"createdKey,omitempty"`
|
||||
ChangedVia string `json:"changedVia,omitempty"`
|
||||
ChangedKey string `json:"changedKey,omitempty"`
|
||||
ChangedAt string `json:"changedAt,omitempty"`
|
||||
}
|
||||
|
||||
type ThoughtReorderItem struct {
|
||||
@@ -558,20 +568,20 @@ type ListShare struct {
|
||||
}
|
||||
|
||||
type ListItem struct {
|
||||
ID string `json:"id"`
|
||||
ListID string `json:"listId"`
|
||||
Content string `json:"content"`
|
||||
Category string `json:"category,omitempty"`
|
||||
Quantity string `json:"quantity,omitempty"`
|
||||
IsCompleted bool `json:"isCompleted"`
|
||||
CompletedBy string `json:"completedBy,omitempty"`
|
||||
CompletedByName string `json:"completedByName,omitempty"`
|
||||
CreatedBy string `json:"createdBy,omitempty"`
|
||||
CreatedByName string `json:"createdByName,omitempty"`
|
||||
SortOrder int `json:"sortOrder"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
CompletedAt string `json:"completedAt,omitempty"`
|
||||
AgentMark string `json:"agentMark,omitempty"` // created | edited: last change via MCP, untouched by the user since
|
||||
ID string `json:"id"`
|
||||
ListID string `json:"listId"`
|
||||
Content string `json:"content"`
|
||||
Category string `json:"category,omitempty"`
|
||||
Quantity string `json:"quantity,omitempty"`
|
||||
IsCompleted bool `json:"isCompleted"`
|
||||
CompletedBy string `json:"completedBy,omitempty"`
|
||||
CompletedByName string `json:"completedByName,omitempty"`
|
||||
CreatedBy string `json:"createdBy,omitempty"`
|
||||
CreatedByName string `json:"createdByName,omitempty"`
|
||||
SortOrder int `json:"sortOrder"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
CompletedAt string `json:"completedAt,omitempty"`
|
||||
Origin *Origin `json:"origin,omitempty"` // API key that created / last changed it
|
||||
}
|
||||
|
||||
type SharedList struct {
|
||||
|
||||
+14
-14
@@ -10,18 +10,18 @@ import (
|
||||
|
||||
// --- API KEYS & WEBHOOKS ---
|
||||
|
||||
// Scopes of an API key. The webhook scopes deliver data (inbox, /webhooks/ingest); the agent scopes
|
||||
// let AI agents read and change tasks and lists over the MCP endpoint. Notes are never reachable with
|
||||
// a key.
|
||||
// Rights of an API key. They hold the same over every access: REST (curl, smart home) and the MCP
|
||||
// endpoint for AI agents. The ids of the first three come from the time they only delivered data
|
||||
// (webhooks) and stay for existing keys. Notes are never reachable with a key.
|
||||
const (
|
||||
ScopeTriage = "webhook:triage" // thoughts into the inbox
|
||||
ScopeTask = "webhook:task" // tasks directly
|
||||
ScopeList = "webhook:list" // items into lists
|
||||
ScopeTask = "webhook:task" // create tasks
|
||||
ScopeList = "webhook:list" // add items to lists
|
||||
|
||||
ScopeTasksRead = "tasks:read" // MCP: task lists and tasks
|
||||
ScopeTasksWrite = "tasks:write" // MCP: create, change and complete tasks
|
||||
ScopeListsRead = "lists:read" // MCP: lists and their items
|
||||
ScopeListsWrite = "lists:write" // MCP: add, change and check off items
|
||||
ScopeTasksRead = "tasks:read" // task lists and tasks
|
||||
ScopeTasksWrite = "tasks:write" // create, change, complete and delete tasks
|
||||
ScopeListsRead = "lists:read" // lists and their items
|
||||
ScopeListsWrite = "lists:write" // add, change and check off items
|
||||
)
|
||||
|
||||
// AllScopes in display order.
|
||||
@@ -99,19 +99,19 @@ func (s *Store) DeleteAPIKey(userID, keyID string) error {
|
||||
}
|
||||
|
||||
func (s *Store) ValidateAPIKeyAndGetUser(rawKey string) (*domain.User, error) {
|
||||
u, _, err := s.ValidateAPIKey(rawKey)
|
||||
u, _, _, err := s.ValidateAPIKey(rawKey)
|
||||
return u, err
|
||||
}
|
||||
|
||||
// ValidateAPIKey returns the owner of a key and the key's scopes.
|
||||
func (s *Store) ValidateAPIKey(rawKey string) (*domain.User, []string, error) {
|
||||
// ValidateAPIKey returns the owner of a key, the key's scopes and its id.
|
||||
func (s *Store) ValidateAPIKey(rawKey string) (*domain.User, []string, string, error) {
|
||||
hash := auth.HashAPIKey(strings.TrimSpace(rawKey))
|
||||
var keyID, userID, scopes string
|
||||
err := s.db.QueryRow(`SELECT id, user_id, scopes FROM api_keys WHERE key_hash = ?`, hash).Scan(&keyID, &userID, &scopes)
|
||||
if err != nil {
|
||||
return nil, nil, errors.New("invalid API key")
|
||||
return nil, nil, "", errors.New("invalid API key")
|
||||
}
|
||||
_, _ = s.db.Exec(`UPDATE api_keys SET last_used_at = ? WHERE id = ?`, domain.NowISO(), keyID)
|
||||
u, err := s.GetUserByID(userID)
|
||||
return u, NormalizeScopes(strings.Split(scopes, ",")), err
|
||||
return u, NormalizeScopes(strings.Split(scopes, ",")), keyID, err
|
||||
}
|
||||
@@ -483,7 +483,7 @@ func (s *Store) ReconcileDocTaskLinks(docID string) []string {
|
||||
// GetThoughtByIDUnchecked loads a task without access check, for server-internal notifications.
|
||||
func (s *Store) GetThoughtByIDUnchecked(thoughtID string) (*domain.Thought, error) {
|
||||
return scanThought(s.db.QueryRow(
|
||||
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order, recurrence, agent_mark
|
||||
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order, recurrence, `+originColumns("")+`
|
||||
FROM thoughts WHERE id = ?`, thoughtID))
|
||||
}
|
||||
|
||||
|
||||
@@ -303,7 +303,7 @@ func (s *Store) DeleteList(userID, listID string) error {
|
||||
func (s *Store) GetListItems(listID string) ([]domain.ListItem, error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT li.id, li.list_id, li.content, li.category, li.quantity, li.is_completed, li.completed_by, li.completed_by_name,
|
||||
li.created_by, COALESCE(u.display_name, ''), li.sort_order, li.created_at, li.completed_at, li.agent_mark
|
||||
li.created_by, COALESCE(u.display_name, ''), li.sort_order, li.created_at, li.completed_at, `+originColumns("li.")+`
|
||||
FROM list_items li LEFT JOIN users u ON u.id = li.created_by AND li.created_by != ''
|
||||
WHERE li.list_id = ?
|
||||
ORDER BY li.is_completed ASC, li.sort_order ASC, li.created_at DESC`,
|
||||
@@ -318,8 +318,10 @@ func (s *Store) GetListItems(listID string) ([]domain.ListItem, error) {
|
||||
for rows.Next() {
|
||||
var it domain.ListItem
|
||||
var doneInt int
|
||||
if err := rows.Scan(&it.ID, &it.ListID, &it.Content, &it.Category, &it.Quantity, &doneInt, &it.CompletedBy, &it.CompletedByName, &it.CreatedBy, &it.CreatedByName, &it.SortOrder, &it.CreatedAt, &it.CompletedAt, &it.AgentMark); err == nil {
|
||||
var o originDest
|
||||
if err := rows.Scan(&it.ID, &it.ListID, &it.Content, &it.Category, &it.Quantity, &doneInt, &it.CompletedBy, &it.CompletedByName, &it.CreatedBy, &it.CreatedByName, &it.SortOrder, &it.CreatedAt, &it.CompletedAt, &o.createdVia, &o.createdKey, &o.changedVia, &o.changedKey, &o.changedAt); err == nil {
|
||||
it.IsCompleted = doneInt != 0
|
||||
it.Origin = o.result()
|
||||
items = append(items, it)
|
||||
}
|
||||
}
|
||||
@@ -399,15 +401,18 @@ func (s *Store) ToggleListItem(itemID string, isCompleted bool, userID, userName
|
||||
|
||||
var it domain.ListItem
|
||||
var dInt int
|
||||
var o originDest
|
||||
err = s.db.QueryRow(
|
||||
`SELECT id, list_id, content, category, quantity, is_completed, completed_by, completed_by_name, sort_order, created_at, completed_at
|
||||
`SELECT id, list_id, content, category, quantity, is_completed, completed_by, completed_by_name, sort_order, created_at, completed_at, `+originColumns("")+`
|
||||
FROM list_items WHERE id = ?`,
|
||||
itemID,
|
||||
).Scan(&it.ID, &it.ListID, &it.Content, &it.Category, &it.Quantity, &dInt, &it.CompletedBy, &it.CompletedByName, &it.SortOrder, &it.CreatedAt, &it.CompletedAt)
|
||||
).Scan(&it.ID, &it.ListID, &it.Content, &it.Category, &it.Quantity, &dInt, &it.CompletedBy, &it.CompletedByName, &it.SortOrder, &it.CreatedAt, &it.CompletedAt,
|
||||
&o.createdVia, &o.createdKey, &o.changedVia, &o.changedKey, &o.changedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
it.IsCompleted = dInt != 0
|
||||
it.Origin = o.result()
|
||||
_, _ = s.db.Exec(`UPDATE lists SET updated_at = ? WHERE id = ?`, now, it.ListID)
|
||||
return &it, nil
|
||||
}
|
||||
@@ -416,15 +421,18 @@ func (s *Store) UpdateListItem(itemID string, content *string, category *string,
|
||||
now := domain.NowISO()
|
||||
var it domain.ListItem
|
||||
var dInt int
|
||||
var o originDest
|
||||
err := s.db.QueryRow(
|
||||
`SELECT id, list_id, content, category, quantity, is_completed, completed_by, completed_by_name, sort_order, created_at, completed_at
|
||||
`SELECT id, list_id, content, category, quantity, is_completed, completed_by, completed_by_name, sort_order, created_at, completed_at, `+originColumns("")+`
|
||||
FROM list_items WHERE id = ?`,
|
||||
itemID,
|
||||
).Scan(&it.ID, &it.ListID, &it.Content, &it.Category, &it.Quantity, &dInt, &it.CompletedBy, &it.CompletedByName, &it.SortOrder, &it.CreatedAt, &it.CompletedAt)
|
||||
).Scan(&it.ID, &it.ListID, &it.Content, &it.Category, &it.Quantity, &dInt, &it.CompletedBy, &it.CompletedByName, &it.SortOrder, &it.CreatedAt, &it.CompletedAt,
|
||||
&o.createdVia, &o.createdKey, &o.changedVia, &o.changedKey, &o.changedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
it.IsCompleted = dInt != 0
|
||||
it.Origin = o.result()
|
||||
|
||||
if content != nil {
|
||||
it.Content = *content
|
||||
@@ -459,17 +467,19 @@ func (s *Store) MoveListItem(itemID, targetListID string) (*domain.ListItem, err
|
||||
|
||||
var it domain.ListItem
|
||||
var dInt int
|
||||
var o originDest
|
||||
err := s.db.QueryRow(
|
||||
`SELECT li.id, li.list_id, li.content, li.category, li.quantity, li.is_completed, li.completed_by, li.completed_by_name,
|
||||
li.created_by, COALESCE(u.display_name, ''), li.sort_order, li.created_at, li.completed_at, li.agent_mark
|
||||
li.created_by, COALESCE(u.display_name, ''), li.sort_order, li.created_at, li.completed_at, `+originColumns("li.")+`
|
||||
FROM list_items li LEFT JOIN users u ON u.id = li.created_by AND li.created_by != ''
|
||||
WHERE li.id = ?`,
|
||||
itemID,
|
||||
).Scan(&it.ID, &it.ListID, &it.Content, &it.Category, &it.Quantity, &dInt, &it.CompletedBy, &it.CompletedByName, &it.CreatedBy, &it.CreatedByName, &it.SortOrder, &it.CreatedAt, &it.CompletedAt, &it.AgentMark)
|
||||
).Scan(&it.ID, &it.ListID, &it.Content, &it.Category, &it.Quantity, &dInt, &it.CompletedBy, &it.CompletedByName, &it.CreatedBy, &it.CreatedByName, &it.SortOrder, &it.CreatedAt, &it.CompletedAt, &o.createdVia, &o.createdKey, &o.changedVia, &o.changedKey, &o.changedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
it.IsCompleted = dInt != 0
|
||||
it.Origin = o.result()
|
||||
return &it, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ var migrations = []migration{
|
||||
{10, "Registrierung und Einladungslinks", (*Store).migrateRegistration},
|
||||
{11, "E-Mail-Bestätigung", (*Store).migrateEmailVerification},
|
||||
{12, "Markierung für Einträge von KI-Agenten", (*Store).migrateAgentMark},
|
||||
{13, "Herkunft von Einträgen (API-Schlüssel)", (*Store).migrateOrigin},
|
||||
}
|
||||
|
||||
func (s *Store) migrate() error {
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/sandrodach/mindsift/internal/domain"
|
||||
)
|
||||
|
||||
// Origin of tasks, thoughts and list items: which API key created an entry and which one changed it
|
||||
// last, and over which access (REST "api" or "mcp"). Both stay for good, also after the user edits the
|
||||
// entry. Key names are resolved when reading, so renaming a key shows everywhere at once.
|
||||
|
||||
const (
|
||||
OriginAPI = "api" // REST: /inbox, /webhooks/ingest and the other endpoints a key may use
|
||||
OriginMCP = "mcp"
|
||||
)
|
||||
|
||||
// Tables with origin columns; RecordOrigin only takes these
|
||||
const (
|
||||
OriginTableThoughts = "thoughts"
|
||||
OriginTableListItems = "list_items"
|
||||
)
|
||||
|
||||
// migrateOrigin replaces agent_mark (migration 12, which the user's own edit cleared) with the
|
||||
// permanent origin. Old marks and the sources webhook/agent are carried over without a key.
|
||||
func (s *Store) migrateOrigin() error {
|
||||
for _, table := range []string{OriginTableThoughts, OriginTableListItems} {
|
||||
for _, col := range []string{"created_via", "created_key_id", "changed_via", "changed_key_id", "changed_at"} {
|
||||
if err := s.addColumnIfMissing(table, col, "TEXT NOT NULL DEFAULT ''"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return s.execInTx(
|
||||
stmt(`UPDATE thoughts SET created_via = 'api' WHERE created_via = '' AND source = 'webhook'`),
|
||||
stmt(`UPDATE thoughts SET created_via = 'mcp' WHERE created_via = '' AND source = 'agent'`),
|
||||
stmt(`UPDATE thoughts SET changed_via = 'mcp' WHERE changed_via = '' AND agent_mark = 'edited'`),
|
||||
stmt(`UPDATE list_items SET created_via = 'mcp' WHERE created_via = '' AND agent_mark = 'created'`),
|
||||
stmt(`UPDATE list_items SET changed_via = 'mcp' WHERE changed_via = '' AND agent_mark = 'edited'`),
|
||||
)
|
||||
}
|
||||
|
||||
// originColumns: the five origin values of a SELECT, key ids resolved to names (prefix e.g. "li." for
|
||||
// an aliased table). Scan them with scanOrigin.
|
||||
func originColumns(prefix string) string {
|
||||
keyName := func(col string) string {
|
||||
return `CASE WHEN ` + prefix + col + ` = '' THEN '' ELSE COALESCE((SELECT k.name FROM api_keys k WHERE k.id = ` +
|
||||
prefix + col + `), 'Gelöschter Schlüssel') END`
|
||||
}
|
||||
return prefix + `created_via, ` + keyName("created_key_id") + `, ` + prefix + `changed_via, ` + keyName("changed_key_id") + `, ` + prefix + `changed_at`
|
||||
}
|
||||
|
||||
// originDest holds the scanned origin columns; result is nil for entries no key ever touched.
|
||||
type originDest struct{ createdVia, createdKey, changedVia, changedKey, changedAt string }
|
||||
|
||||
func (o *originDest) targets() []any {
|
||||
return []any{&o.createdVia, &o.createdKey, &o.changedVia, &o.changedKey, &o.changedAt}
|
||||
}
|
||||
|
||||
func (o *originDest) result() *domain.Origin {
|
||||
if o.createdVia == "" && o.changedVia == "" {
|
||||
return nil
|
||||
}
|
||||
return &domain.Origin{CreatedVia: o.createdVia, CreatedKey: o.createdKey, ChangedVia: o.changedVia, ChangedKey: o.changedKey, ChangedAt: o.changedAt}
|
||||
}
|
||||
|
||||
// RecordOrigin notes that an API key created (created) or changed an entry and returns the entry's
|
||||
// origin as it is now. via is OriginAPI or OriginMCP; keyID may be empty (MCP from a login session).
|
||||
func (s *Store) RecordOrigin(table, id, via, keyID string, created bool) *domain.Origin {
|
||||
if table != OriginTableThoughts && table != OriginTableListItems {
|
||||
return nil
|
||||
}
|
||||
if created {
|
||||
_, _ = s.db.Exec(`UPDATE `+table+` SET created_via = ?, created_key_id = ? WHERE id = ?`, via, keyID, id)
|
||||
} else {
|
||||
_, _ = s.db.Exec(`UPDATE `+table+` SET changed_via = ?, changed_key_id = ?, changed_at = ? WHERE id = ?`, via, keyID, domain.NowISO(), id)
|
||||
}
|
||||
return s.Origin(table, id)
|
||||
}
|
||||
|
||||
// Origin reads the origin of one entry (nil if no key touched it).
|
||||
func (s *Store) Origin(table, id string) *domain.Origin {
|
||||
if table != OriginTableThoughts && table != OriginTableListItems {
|
||||
return nil
|
||||
}
|
||||
var o originDest
|
||||
if err := s.db.QueryRow(`SELECT `+originColumns("")+` FROM `+table+` WHERE id = ?`, id).Scan(o.targets()...); err != nil {
|
||||
return nil
|
||||
}
|
||||
return o.result()
|
||||
}
|
||||
|
||||
// copyOriginToListItem: a thought turned into a list item in the triage keeps where it came from.
|
||||
func (s *Store) copyOriginToListItem(thoughtID, itemID string) {
|
||||
cols := []string{"created_via", "created_key_id", "changed_via", "changed_key_id", "changed_at"}
|
||||
set := make([]string, len(cols))
|
||||
for i, c := range cols {
|
||||
set[i] = c + ` = (SELECT ` + c + ` FROM thoughts WHERE id = ?)`
|
||||
}
|
||||
args := []any{}
|
||||
for range cols {
|
||||
args = append(args, thoughtID)
|
||||
}
|
||||
args = append(args, itemID)
|
||||
_, _ = s.db.Exec(`UPDATE list_items SET `+strings.Join(set, ", ")+` WHERE id = ?`, args...)
|
||||
}
|
||||
@@ -72,7 +72,7 @@ func (s *Store) SetTaskRecurrence(userID, taskID, rule string) error {
|
||||
|
||||
func (s *Store) getThoughtRaw(id string) (*domain.Thought, error) {
|
||||
t, err := scanThought(s.db.QueryRow(
|
||||
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order, recurrence, agent_mark
|
||||
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order, recurrence, `+originColumns("")+`
|
||||
FROM thoughts WHERE id = ?`, id))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -76,14 +76,11 @@ func (s *Store) CreateThoughtWithDetails(userID, content, priority, source, dueD
|
||||
Notes: notes,
|
||||
CreatedAt: domain.NowISO(),
|
||||
}
|
||||
if source == "agent" {
|
||||
t.AgentMark = AgentMarkCreated
|
||||
}
|
||||
|
||||
_, err := s.db.Exec(
|
||||
`INSERT INTO thoughts (id, user_id, content, tags, priority, source, status, is_completed, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, subtasks, agent_mark)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, 0, ?, '[]', ?)`,
|
||||
t.ID, t.UserID, t.Content, string(tagsJSON), t.Priority, t.Source, string(t.Status), t.DueDate, t.TaskListID, t.KanbanStatus, t.Notes, t.CreatedAt, t.AgentMark,
|
||||
`INSERT INTO thoughts (id, user_id, content, tags, priority, source, status, is_completed, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, subtasks)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, 0, ?, '[]')`,
|
||||
t.ID, t.UserID, t.Content, string(tagsJSON), t.Priority, t.Source, string(t.Status), t.DueDate, t.TaskListID, t.KanbanStatus, t.Notes, t.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -98,16 +95,19 @@ func scanThought(rows interface {
|
||||
t := &domain.Thought{}
|
||||
var tagsStr, subtasksStr, statusStr string
|
||||
var isCompleted, alarmSent, sortOrder int
|
||||
var origin originDest
|
||||
err := rows.Scan(
|
||||
&t.ID, &t.UserID, &t.Content, &tagsStr, &t.Priority, &t.Source,
|
||||
&statusStr, &isCompleted, &t.ConvertedType, &t.ConvertedRefID,
|
||||
&t.DueDate, &t.TaskListID, &t.KanbanStatus, &t.Notes, &alarmSent,
|
||||
&t.CreatedAt, &t.TriagedAt, &subtasksStr, &sortOrder, &t.Recurrence, &t.AgentMark,
|
||||
&t.CreatedAt, &t.TriagedAt, &subtasksStr, &sortOrder, &t.Recurrence,
|
||||
&origin.createdVia, &origin.createdKey, &origin.changedVia, &origin.changedKey, &origin.changedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
t.SortOrder = sortOrder
|
||||
t.Origin = origin.result()
|
||||
t.Status = domain.ThoughtStatus(statusStr)
|
||||
t.IsCompleted = isCompleted != 0
|
||||
t.AlarmSent = alarmSent != 0
|
||||
@@ -139,7 +139,7 @@ func (s *Store) accessibleTaskListIDsQuery() string {
|
||||
|
||||
func (s *Store) GetThoughtByID(userID, thoughtID string) (*domain.Thought, error) {
|
||||
row := s.db.QueryRow(
|
||||
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order, recurrence, agent_mark
|
||||
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order, recurrence, `+originColumns("")+`
|
||||
FROM thoughts WHERE id = ? AND (user_id = ? OR task_list_id IN (`+s.accessibleTaskListIDsQuery()+`))`,
|
||||
thoughtID, userID, userID, userID, userID,
|
||||
)
|
||||
@@ -160,14 +160,14 @@ func (s *Store) GetThoughts(userID, statusFilter string, limit int) ([]domain.Th
|
||||
|
||||
if statusFilter != "" && statusFilter != "all" {
|
||||
rows, err = s.db.Query(
|
||||
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order, recurrence, agent_mark
|
||||
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order, recurrence, `+originColumns("")+`
|
||||
FROM thoughts WHERE user_id = ? AND status = ?
|
||||
ORDER BY CASE priority WHEN 'urgent' THEN 1 WHEN 'high' THEN 2 WHEN 'normal' THEN 3 ELSE 4 END, sort_order ASC, created_at DESC LIMIT ?`,
|
||||
userID, statusFilter, limit,
|
||||
)
|
||||
} else {
|
||||
rows, err = s.db.Query(
|
||||
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order, recurrence, agent_mark
|
||||
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order, recurrence, `+originColumns("")+`
|
||||
FROM thoughts WHERE user_id = ?
|
||||
ORDER BY sort_order ASC, created_at DESC LIMIT ?`,
|
||||
userID, limit,
|
||||
@@ -193,7 +193,7 @@ func (s *Store) GetTasks(userID string, taskListID string) ([]domain.Thought, er
|
||||
var err error
|
||||
if taskListID != "" {
|
||||
rows, err = s.db.Query(
|
||||
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order, recurrence, agent_mark
|
||||
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order, recurrence, `+originColumns("")+`
|
||||
FROM thoughts
|
||||
WHERE (user_id = ? OR task_list_id IN (`+s.accessibleTaskListIDsQuery()+`))
|
||||
AND task_list_id = ?
|
||||
@@ -203,7 +203,7 @@ func (s *Store) GetTasks(userID string, taskListID string) ([]domain.Thought, er
|
||||
)
|
||||
} else {
|
||||
rows, err = s.db.Query(
|
||||
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order, recurrence, agent_mark
|
||||
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order, recurrence, `+originColumns("")+`
|
||||
FROM thoughts
|
||||
WHERE (user_id = ? OR task_list_id IN (`+s.accessibleTaskListIDsQuery()+`))
|
||||
AND (status = 'kept' OR (status = 'converted' AND converted_type = 'task'))
|
||||
@@ -384,6 +384,7 @@ func (s *Store) UpdateThoughtTriage(userID, thoughtID, action, convertTo, target
|
||||
item, err := s.CreateListItem(targetListID, thought.Content, "", "", 0, userID)
|
||||
if err == nil && item != nil {
|
||||
convertedRefID = item.ID
|
||||
s.copyOriginToListItem(thought.ID, item.ID)
|
||||
}
|
||||
}
|
||||
default:
|
||||
@@ -512,7 +513,8 @@ func (s *Store) DeleteThought(userID, thoughtID string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// migrateAgentMark: entries an AI agent created or changed via MCP and the user has not touched since
|
||||
// migrateAgentMark: released in v0.29.0; agent_mark is superseded by the origin columns (migrateOrigin)
|
||||
// and only read once to carry old marks over
|
||||
func (s *Store) migrateAgentMark() error {
|
||||
if err := s.addColumnIfMissing("thoughts", "agent_mark", "TEXT NOT NULL DEFAULT ''"); err != nil {
|
||||
return err
|
||||
@@ -520,51 +522,6 @@ func (s *Store) migrateAgentMark() error {
|
||||
return s.addColumnIfMissing("list_items", "agent_mark", "TEXT NOT NULL DEFAULT ''")
|
||||
}
|
||||
|
||||
// Values of agent_mark on thoughts and list items
|
||||
const (
|
||||
AgentMarkCreated = "created"
|
||||
AgentMarkEdited = "edited"
|
||||
)
|
||||
|
||||
// nextAgentMark: an agent's change marks the entry (an entry it created stays "created"), a change by
|
||||
// the user removes the mark.
|
||||
func nextAgentMark(current string, byAgent bool) string {
|
||||
if !byAgent {
|
||||
return ""
|
||||
}
|
||||
if current == AgentMarkCreated {
|
||||
return AgentMarkCreated
|
||||
}
|
||||
return AgentMarkEdited
|
||||
}
|
||||
|
||||
// SetThoughtAgentMark records who changed a task last and returns the new mark.
|
||||
func (s *Store) SetThoughtAgentMark(thoughtID, current string, byAgent bool) string {
|
||||
mark := nextAgentMark(current, byAgent)
|
||||
if mark != current {
|
||||
_, _ = s.db.Exec(`UPDATE thoughts SET agent_mark = ? WHERE id = ?`, mark, thoughtID)
|
||||
}
|
||||
return mark
|
||||
}
|
||||
|
||||
// SetListItemAgentMark is SetThoughtAgentMark for list items; the current mark is read here because
|
||||
// the item queries after a change do not carry it.
|
||||
func (s *Store) SetListItemAgentMark(itemID string, byAgent bool) string {
|
||||
var current string
|
||||
_ = s.db.QueryRow(`SELECT agent_mark FROM list_items WHERE id = ?`, itemID).Scan(¤t)
|
||||
mark := nextAgentMark(current, byAgent)
|
||||
if mark != current {
|
||||
_, _ = s.db.Exec(`UPDATE list_items SET agent_mark = ? WHERE id = ?`, mark, itemID)
|
||||
}
|
||||
return mark
|
||||
}
|
||||
|
||||
// MarkListItemAgentCreated marks an item an agent just added.
|
||||
func (s *Store) MarkListItemAgentCreated(itemID string) string {
|
||||
_, _ = s.db.Exec(`UPDATE list_items SET agent_mark = ? WHERE id = ?`, AgentMarkCreated, itemID)
|
||||
return AgentMarkCreated
|
||||
}
|
||||
|
||||
func (s *Store) PurgeDiscardedThoughts(userID string) (int64, error) {
|
||||
res, err := s.db.Exec(`DELETE FROM thoughts WHERE user_id = ? AND status = 'discarded'`, userID)
|
||||
if err != nil {
|
||||
|
||||
Reference in new issue
Block a user