Add local JSON daemon and route TUI/CLI through it.
A per-DataDir background process owns ticket JSON, history, and sync so multiple TUI instances stay consistent; MCP token APIs are ready for phase 2. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,115 @@
|
||||
package api_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"task/internal/command"
|
||||
"task/internal/daemon/api"
|
||||
"task/internal/project"
|
||||
"task/internal/ticket"
|
||||
)
|
||||
|
||||
func setup(t *testing.T) (*api.Server, string) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
store := ticket.NewJSONStore(dir)
|
||||
timeStore := ticket.NewJSONTimeStore(dir)
|
||||
projects := project.NewDirStore(dir)
|
||||
_ = projects.Ensure("inbox")
|
||||
hist, err := command.OpenHistory(filepath.Join(dir, "history.json"), store, timeStore)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return api.New(store, timeStore, projects, hist), dir
|
||||
}
|
||||
|
||||
func TestHealthAndCRUD(t *testing.T) {
|
||||
srv, _ := setup(t)
|
||||
h := srv.Handler()
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/v1/health", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("health: %d", rec.Code)
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(ticket.Ticket{Title: "Hi", ProjectID: "inbox", Status: ticket.StatusTodo})
|
||||
req = httptest.NewRequest(http.MethodPost, "/v1/tickets", bytes.NewReader(body))
|
||||
req.Header.Set(api.HeaderClient, api.ClientTUI)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("create: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var created ticket.Ticket
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &created)
|
||||
if created.ID == "" {
|
||||
t.Fatal("missing id")
|
||||
}
|
||||
|
||||
req = httptest.NewRequest(http.MethodGet, "/v1/tickets?projectId=inbox", nil)
|
||||
req.Header.Set(api.HeaderClient, api.ClientTUI)
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("list: %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPAuthRequired(t *testing.T) {
|
||||
srv, _ := setup(t)
|
||||
h := srv.Handler()
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/v1/tickets?projectId=inbox", nil)
|
||||
req.Header.Set(api.HeaderClient, api.ClientMCP)
|
||||
req.Header.Set("X-TM-Project", "inbox")
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusUnauthorized && rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected auth failure, got %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// Create token via TUI client
|
||||
req = httptest.NewRequest(http.MethodPost, "/v1/projects/inbox/mcp-token", bytes.NewReader([]byte("{}")))
|
||||
req.Header.Set(api.HeaderClient, api.ClientTUI)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("token create: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var tok struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &tok)
|
||||
if tok.Token == "" {
|
||||
t.Fatal("empty token")
|
||||
}
|
||||
|
||||
req = httptest.NewRequest(http.MethodGet, "/v1/tickets?projectId=inbox", nil)
|
||||
req.Header.Set(api.HeaderClient, api.ClientMCP)
|
||||
req.Header.Set("Authorization", "Bearer "+tok.Token)
|
||||
req.Header.Set("X-TM-Project", "inbox")
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("mcp list: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
req = httptest.NewRequest(http.MethodGet, "/v1/tickets?projectId=inbox", nil)
|
||||
req.Header.Set(api.HeaderClient, api.ClientMCP)
|
||||
req.Header.Set("Authorization", "Bearer wrong")
|
||||
req.Header.Set("X-TM-Project", "inbox")
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code == 200 {
|
||||
t.Fatal("wrong token should fail")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type ctxKey int
|
||||
|
||||
const (
|
||||
ctxClientKind ctxKey = iota
|
||||
ctxMCPProject
|
||||
)
|
||||
|
||||
const (
|
||||
HeaderClient = "X-TM-Client"
|
||||
ClientTUI = "tui"
|
||||
ClientCLI = "cli"
|
||||
ClientMCP = "mcp"
|
||||
)
|
||||
|
||||
// ClientKind returns the authenticated client kind from context.
|
||||
func ClientKind(ctx context.Context) string {
|
||||
v, _ := ctx.Value(ctxClientKind).(string)
|
||||
return v
|
||||
}
|
||||
|
||||
// MCPProjectID returns the project bound to a validated MCP token, if any.
|
||||
func MCPProjectID(ctx context.Context) string {
|
||||
v, _ := ctx.Value(ctxMCPProject).(string)
|
||||
return v
|
||||
}
|
||||
|
||||
func hashToken(token string) string {
|
||||
sum := sha256.Sum256([]byte(token))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func (s *Server) withAuth(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/v1/health" {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
kind := strings.ToLower(strings.TrimSpace(r.Header.Get(HeaderClient)))
|
||||
if kind == "" {
|
||||
kind = ClientTUI
|
||||
}
|
||||
ctx := context.WithValue(r.Context(), ctxClientKind, kind)
|
||||
|
||||
if kind == ClientMCP {
|
||||
auth := r.Header.Get("Authorization")
|
||||
const prefix = "Bearer "
|
||||
if !strings.HasPrefix(auth, prefix) {
|
||||
writeErr(w, http.StatusUnauthorized, "mcp requires Authorization: Bearer <token>")
|
||||
return
|
||||
}
|
||||
token := strings.TrimSpace(strings.TrimPrefix(auth, prefix))
|
||||
if token == "" {
|
||||
writeErr(w, http.StatusUnauthorized, "empty bearer token")
|
||||
return
|
||||
}
|
||||
projectID := strings.TrimSpace(r.Header.Get("X-TM-Project"))
|
||||
if projectID == "" {
|
||||
projectID = extractProjectID(r)
|
||||
}
|
||||
if projectID == "" {
|
||||
writeErr(w, http.StatusUnauthorized, "mcp requires project id (X-TM-Project or path/body)")
|
||||
return
|
||||
}
|
||||
meta, err := s.projects.LoadMeta(projectID)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if meta.McpAuth == nil || meta.McpAuth.TokenHash == "" {
|
||||
writeErr(w, http.StatusForbidden, "no mcp token configured for project")
|
||||
return
|
||||
}
|
||||
got := hashToken(token)
|
||||
if subtle.ConstantTimeCompare([]byte(got), []byte(meta.McpAuth.TokenHash)) != 1 {
|
||||
writeErr(w, http.StatusForbidden, "invalid mcp token")
|
||||
return
|
||||
}
|
||||
ctx = context.WithValue(ctx, ctxMCPProject, projectID)
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
// extractProjectID best-effort for auth before body is consumed.
|
||||
// Path patterns: /v1/projects/{id}/...
|
||||
func extractProjectID(r *http.Request) string {
|
||||
if q := strings.TrimSpace(r.URL.Query().Get("projectId")); q != "" {
|
||||
return q
|
||||
}
|
||||
parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/")
|
||||
// v1 projects {id} ...
|
||||
if len(parts) >= 3 && parts[0] == "v1" && parts[1] == "projects" {
|
||||
return parts[2]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// requireMCPProject ensures MCP calls stay on the token's project.
|
||||
func requireMCPProject(r *http.Request, projectID string) bool {
|
||||
if ClientKind(r.Context()) != ClientMCP {
|
||||
return true
|
||||
}
|
||||
bound := MCPProjectID(r.Context())
|
||||
return bound != "" && bound == projectID
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func (s *Server) handleHistoryStatus(w http.ResponseWriter, r *http.Request) {
|
||||
_ = r
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"canUndo": s.history.CanUndo(),
|
||||
"canRedo": s.history.CanRedo(),
|
||||
"lastUndoName": s.history.LastUndoName(),
|
||||
"lastRedoName": s.history.LastRedoName(),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleUndo(w http.ResponseWriter, r *http.Request) {
|
||||
_ = r
|
||||
name := s.history.LastUndoName()
|
||||
if err := s.history.Undo(); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
s.notify("")
|
||||
writeJSON(w, http.StatusOK, map[string]string{"name": name})
|
||||
}
|
||||
|
||||
func (s *Server) handleRedo(w http.ResponseWriter, r *http.Request) {
|
||||
_ = r
|
||||
name := s.history.LastRedoName()
|
||||
if err := s.history.Redo(); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
s.notify("")
|
||||
writeJSON(w, http.StatusOK, map[string]string{"name": name})
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Event is a change notification pushed to SSE clients.
|
||||
type Event struct {
|
||||
Type string `json:"type"`
|
||||
ProjectID string `json:"projectId,omitempty"`
|
||||
}
|
||||
|
||||
type hub struct {
|
||||
mu sync.Mutex
|
||||
clients map[chan Event]struct{}
|
||||
}
|
||||
|
||||
func newHub() *hub {
|
||||
return &hub{clients: map[chan Event]struct{}{}}
|
||||
}
|
||||
|
||||
func (h *hub) subscribe() chan Event {
|
||||
ch := make(chan Event, 16)
|
||||
h.mu.Lock()
|
||||
h.clients[ch] = struct{}{}
|
||||
h.mu.Unlock()
|
||||
return ch
|
||||
}
|
||||
|
||||
func (h *hub) unsubscribe(ch chan Event) {
|
||||
h.mu.Lock()
|
||||
delete(h.clients, ch)
|
||||
h.mu.Unlock()
|
||||
close(ch)
|
||||
}
|
||||
|
||||
func (h *hub) publish(ev Event) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
for ch := range h.clients {
|
||||
select {
|
||||
case ch <- ev:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleEvents(w http.ResponseWriter, r *http.Request) {
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
http.Error(w, "streaming unsupported", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
|
||||
ch := s.hub.subscribe()
|
||||
defer s.hub.unsubscribe(ch)
|
||||
|
||||
fmt.Fprintf(w, ": ok\n\n")
|
||||
flusher.Flush()
|
||||
|
||||
ctx := r.Context()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case ev, ok := <-ch:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
data, _ := json.Marshal(ev)
|
||||
fmt.Fprintf(w, "data: %s\n\n", data)
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func writeErr(w http.ResponseWriter, status int, msg string) {
|
||||
writeJSON(w, status, map[string]string{"error": msg})
|
||||
}
|
||||
|
||||
func readJSON(r *http.Request, dst any) error {
|
||||
defer r.Body.Close()
|
||||
dec := json.NewDecoder(r.Body)
|
||||
dec.DisallowUnknownFields()
|
||||
return dec.Decode(dst)
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"task/internal/project"
|
||||
)
|
||||
|
||||
func (s *Server) handleListProjects(w http.ResponseWriter, r *http.Request) {
|
||||
list, err := s.projects.List()
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if ClientKind(r.Context()) == ClientMCP {
|
||||
pid := MCPProjectID(r.Context())
|
||||
filtered := make([]project.Project, 0, 1)
|
||||
for _, p := range list {
|
||||
if p.ID == pid {
|
||||
filtered = append(filtered, p)
|
||||
}
|
||||
}
|
||||
list = filtered
|
||||
}
|
||||
writeJSON(w, http.StatusOK, list)
|
||||
}
|
||||
|
||||
func (s *Server) handleEnsureProject(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if err := readJSON(r, &body); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if !requireMCPProject(r, body.ID) {
|
||||
writeErr(w, http.StatusForbidden, "mcp token project mismatch")
|
||||
return
|
||||
}
|
||||
if err := s.projects.Ensure(body.ID); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
s.notify(body.ID)
|
||||
writeJSON(w, http.StatusOK, map[string]string{"id": body.ID})
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteProject(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if !requireMCPProject(r, id) {
|
||||
writeErr(w, http.StatusForbidden, "mcp token project mismatch")
|
||||
return
|
||||
}
|
||||
if err := s.timeStore.DeleteByProject(id); err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if t, err := s.timeStore.LoadActiveTimer(); err == nil && t != nil && t.ProjectID == id {
|
||||
_ = s.timeStore.SaveActiveTimer(nil)
|
||||
}
|
||||
if err := s.projects.Delete(id); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
s.notify(id)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (s *Server) handleGetMeta(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if !requireMCPProject(r, id) {
|
||||
writeErr(w, http.StatusForbidden, "mcp token project mismatch")
|
||||
return
|
||||
}
|
||||
meta, err := s.projects.LoadMeta(id)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, meta)
|
||||
}
|
||||
|
||||
func (s *Server) handleSaveMeta(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if !requireMCPProject(r, id) {
|
||||
writeErr(w, http.StatusForbidden, "mcp token project mismatch")
|
||||
return
|
||||
}
|
||||
var meta project.Meta
|
||||
if err := readJSON(r, &meta); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
// Preserve existing mcpAuth if client omitted it (TUI sync edit).
|
||||
prev, _ := s.projects.LoadMeta(id)
|
||||
if meta.McpAuth == nil && prev.McpAuth != nil {
|
||||
meta.McpAuth = prev.McpAuth
|
||||
}
|
||||
if err := s.projects.SaveMeta(id, meta); err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
s.notify(id)
|
||||
writeJSON(w, http.StatusOK, meta)
|
||||
}
|
||||
|
||||
func (s *Server) handleMCPTokenStatus(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if ClientKind(r.Context()) == ClientMCP {
|
||||
writeErr(w, http.StatusForbidden, "mcp cannot inspect token status")
|
||||
return
|
||||
}
|
||||
meta, err := s.projects.LoadMeta(id)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
configured := meta.McpAuth != nil && meta.McpAuth.TokenHash != ""
|
||||
created := ""
|
||||
if meta.McpAuth != nil {
|
||||
created = meta.McpAuth.CreatedAt
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"configured": configured,
|
||||
"createdAt": created,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleMCPTokenCreate(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if ClientKind(r.Context()) == ClientMCP {
|
||||
writeErr(w, http.StatusForbidden, "mcp cannot create tokens")
|
||||
return
|
||||
}
|
||||
if err := s.projects.Ensure(id); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
raw := make([]byte, 24)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
token := "tm_mcp_" + hex.EncodeToString(raw)
|
||||
meta, err := s.projects.LoadMeta(id)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
created := time.Now().UTC().Format(time.RFC3339)
|
||||
meta.McpAuth = &project.McpAuth{TokenHash: hashToken(token), CreatedAt: created}
|
||||
if err := s.projects.SaveMeta(id, meta); err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{
|
||||
"token": token,
|
||||
"project": id,
|
||||
"createdAt": created,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleMCPTokenRevoke(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if ClientKind(r.Context()) == ClientMCP {
|
||||
writeErr(w, http.StatusForbidden, "mcp cannot revoke tokens")
|
||||
return
|
||||
}
|
||||
meta, err := s.projects.LoadMeta(id)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
meta.McpAuth = nil
|
||||
if err := s.projects.SaveMeta(id, meta); err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"task/internal/command"
|
||||
"task/internal/project"
|
||||
"task/internal/ticket"
|
||||
)
|
||||
|
||||
// Server is the daemon HTTP API.
|
||||
type Server struct {
|
||||
store ticket.Store
|
||||
timeStore ticket.TimeStore
|
||||
projects project.Store
|
||||
history command.Executor
|
||||
hub *hub
|
||||
mux *http.ServeMux
|
||||
}
|
||||
|
||||
// New constructs the API server.
|
||||
func New(store ticket.Store, timeStore ticket.TimeStore, projects project.Store, history command.Executor) *Server {
|
||||
s := &Server{
|
||||
store: store,
|
||||
timeStore: timeStore,
|
||||
projects: projects,
|
||||
history: history,
|
||||
hub: newHub(),
|
||||
mux: http.NewServeMux(),
|
||||
}
|
||||
s.routes()
|
||||
return s
|
||||
}
|
||||
|
||||
// Handler returns the root HTTP handler with auth middleware.
|
||||
func (s *Server) Handler() http.Handler {
|
||||
return s.withAuth(s.mux)
|
||||
}
|
||||
|
||||
func (s *Server) routes() {
|
||||
s.mux.HandleFunc("GET /v1/health", s.handleHealth)
|
||||
|
||||
s.mux.HandleFunc("GET /v1/projects", s.handleListProjects)
|
||||
s.mux.HandleFunc("POST /v1/projects", s.handleEnsureProject)
|
||||
s.mux.HandleFunc("DELETE /v1/projects/{id}", s.handleDeleteProject)
|
||||
s.mux.HandleFunc("GET /v1/projects/{id}/meta", s.handleGetMeta)
|
||||
s.mux.HandleFunc("PUT /v1/projects/{id}/meta", s.handleSaveMeta)
|
||||
s.mux.HandleFunc("GET /v1/projects/{id}/mcp-token", s.handleMCPTokenStatus)
|
||||
s.mux.HandleFunc("POST /v1/projects/{id}/mcp-token", s.handleMCPTokenCreate)
|
||||
s.mux.HandleFunc("DELETE /v1/projects/{id}/mcp-token", s.handleMCPTokenRevoke)
|
||||
|
||||
s.mux.HandleFunc("GET /v1/tickets", s.handleListTickets)
|
||||
s.mux.HandleFunc("GET /v1/tickets/{id}", s.handleGetTicket)
|
||||
s.mux.HandleFunc("POST /v1/tickets", s.handleCreateTicket)
|
||||
s.mux.HandleFunc("PATCH /v1/tickets/{id}", s.handleUpdateTicket)
|
||||
s.mux.HandleFunc("DELETE /v1/tickets/{id}", s.handleDeleteTicket)
|
||||
s.mux.HandleFunc("POST /v1/tickets/{id}/move", s.handleMoveTicket)
|
||||
|
||||
s.mux.HandleFunc("GET /v1/time", s.handleListTime)
|
||||
s.mux.HandleFunc("GET /v1/time/by-ticket/{ticketId}", s.handleListTimeByTicket)
|
||||
s.mux.HandleFunc("GET /v1/time/sum/{ticketId}", s.handleSumTime)
|
||||
s.mux.HandleFunc("POST /v1/time", s.handleAddTime)
|
||||
s.mux.HandleFunc("PATCH /v1/time/{id}", s.handleUpdateTime)
|
||||
s.mux.HandleFunc("DELETE /v1/time/{id}", s.handleRemoveTime)
|
||||
s.mux.HandleFunc("GET /v1/timer", s.handleGetTimer)
|
||||
s.mux.HandleFunc("PUT /v1/timer", s.handleSetTimer)
|
||||
|
||||
s.mux.HandleFunc("GET /v1/history", s.handleHistoryStatus)
|
||||
s.mux.HandleFunc("POST /v1/undo", s.handleUndo)
|
||||
s.mux.HandleFunc("POST /v1/redo", s.handleRedo)
|
||||
|
||||
s.mux.HandleFunc("POST /v1/sync/pull", s.handleSyncPull)
|
||||
s.mux.HandleFunc("POST /v1/sync/push", s.handleSyncPush)
|
||||
|
||||
s.mux.HandleFunc("GET /v1/events", s.handleEvents)
|
||||
}
|
||||
|
||||
func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (s *Server) notify(projectID string) {
|
||||
s.hub.publish(Event{Type: "changed", ProjectID: projectID})
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"task/internal/project"
|
||||
"task/internal/tmsync"
|
||||
"task/internal/ytsync"
|
||||
)
|
||||
|
||||
func (s *Server) handleSyncPull(w http.ResponseWriter, r *http.Request) {
|
||||
s.runSync(w, r, false)
|
||||
}
|
||||
|
||||
func (s *Server) handleSyncPush(w http.ResponseWriter, r *http.Request) {
|
||||
s.runSync(w, r, true)
|
||||
}
|
||||
|
||||
func (s *Server) runSync(w http.ResponseWriter, r *http.Request, push bool) {
|
||||
var body struct {
|
||||
ProjectID string `json:"projectId"`
|
||||
}
|
||||
if err := readJSON(r, &body); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if body.ProjectID == "" {
|
||||
writeErr(w, http.StatusBadRequest, "projectId required")
|
||||
return
|
||||
}
|
||||
if !requireMCPProject(r, body.ProjectID) {
|
||||
writeErr(w, http.StatusForbidden, "mcp token project mismatch")
|
||||
return
|
||||
}
|
||||
meta, err := s.projects.LoadMeta(body.ProjectID)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
var res ytsync.Result
|
||||
ctx := context.Background()
|
||||
switch meta.EffectiveBackend() {
|
||||
case project.BackendTaskMaster:
|
||||
if meta.TaskMaster == nil || !meta.TaskMaster.Configured() {
|
||||
writeErr(w, http.StatusBadRequest, "taskmaster not configured")
|
||||
return
|
||||
}
|
||||
syncer := &tmsync.Syncer{Store: s.store, TimeStore: s.timeStore}
|
||||
if push {
|
||||
res, err = syncer.Push(ctx, body.ProjectID, *meta.TaskMaster)
|
||||
} else {
|
||||
res, err = syncer.Pull(ctx, body.ProjectID, *meta.TaskMaster)
|
||||
}
|
||||
default:
|
||||
if push {
|
||||
res, err = ytsync.PushProject(ctx, s.projects, s.store, s.timeStore, body.ProjectID)
|
||||
} else {
|
||||
res, err = ytsync.PullProject(ctx, s.projects, s.store, s.timeStore, body.ProjectID)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
s.notify(body.ProjectID)
|
||||
writeJSON(w, http.StatusOK, res)
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"task/internal/command"
|
||||
"task/internal/ticket"
|
||||
)
|
||||
|
||||
func (s *Server) handleListTickets(w http.ResponseWriter, r *http.Request) {
|
||||
projectID := r.URL.Query().Get("projectId")
|
||||
if projectID == "" {
|
||||
writeErr(w, http.StatusBadRequest, "projectId query required")
|
||||
return
|
||||
}
|
||||
if !requireMCPProject(r, projectID) {
|
||||
writeErr(w, http.StatusForbidden, "mcp token project mismatch")
|
||||
return
|
||||
}
|
||||
list, err := s.store.List(projectID)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, list)
|
||||
}
|
||||
|
||||
func (s *Server) handleGetTicket(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
t, err := s.store.Get(id)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
if !requireMCPProject(r, t.ProjectID) {
|
||||
writeErr(w, http.StatusForbidden, "mcp token project mismatch")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, t)
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateTicket(w http.ResponseWriter, r *http.Request) {
|
||||
var body ticket.Ticket
|
||||
if err := readJSON(r, &body); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if !requireMCPProject(r, body.ProjectID) {
|
||||
writeErr(w, http.StatusForbidden, "mcp token project mismatch")
|
||||
return
|
||||
}
|
||||
cmd := &command.CreateTicket{Store: s.store, Ticket: body}
|
||||
if err := s.history.Do(cmd); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
s.notify(cmd.Ticket.ProjectID)
|
||||
writeJSON(w, http.StatusCreated, cmd.Ticket)
|
||||
}
|
||||
|
||||
func (s *Server) handleUpdateTicket(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
before, err := s.store.Get(id)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
if !requireMCPProject(r, before.ProjectID) {
|
||||
writeErr(w, http.StatusForbidden, "mcp token project mismatch")
|
||||
return
|
||||
}
|
||||
var after ticket.Ticket
|
||||
if err := readJSON(r, &after); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
after.ID = id
|
||||
if after.ProjectID == "" {
|
||||
after.ProjectID = before.ProjectID
|
||||
}
|
||||
if !requireMCPProject(r, after.ProjectID) {
|
||||
writeErr(w, http.StatusForbidden, "mcp token project mismatch")
|
||||
return
|
||||
}
|
||||
cmd := &command.UpdateTicket{Store: s.store, Before: before, After: after}
|
||||
if err := s.history.Do(cmd); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
s.notify(after.ProjectID)
|
||||
writeJSON(w, http.StatusOK, after)
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteTicket(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
before, err := s.store.Get(id)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
if !requireMCPProject(r, before.ProjectID) {
|
||||
writeErr(w, http.StatusForbidden, "mcp token project mismatch")
|
||||
return
|
||||
}
|
||||
cmd := &command.DeleteTicket{Store: s.store, Snapshot: before}
|
||||
if err := s.history.Do(cmd); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
s.notify(before.ProjectID)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (s *Server) handleMoveTicket(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
before, err := s.store.Get(id)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
if !requireMCPProject(r, before.ProjectID) {
|
||||
writeErr(w, http.StatusForbidden, "mcp token project mismatch")
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
ToProjectID string `json:"toProjectId"`
|
||||
}
|
||||
if err := readJSON(r, &body); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
// MCP may only move within its project (no cross-project).
|
||||
if ClientKind(r.Context()) == ClientMCP && body.ToProjectID != MCPProjectID(r.Context()) {
|
||||
writeErr(w, http.StatusForbidden, "mcp cannot move tickets to another project")
|
||||
return
|
||||
}
|
||||
after := before
|
||||
after.ProjectID = body.ToProjectID
|
||||
cmd := &command.MoveTicket{Store: s.store, TimeStore: s.timeStore, Before: before, After: after}
|
||||
if err := s.history.Do(cmd); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
s.notify(before.ProjectID)
|
||||
s.notify(body.ToProjectID)
|
||||
writeJSON(w, http.StatusOK, cmd.After)
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"task/internal/command"
|
||||
"task/internal/ticket"
|
||||
)
|
||||
|
||||
func (s *Server) handleListTime(w http.ResponseWriter, r *http.Request) {
|
||||
list, err := s.timeStore.List()
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if ClientKind(r.Context()) == ClientMCP {
|
||||
pid := MCPProjectID(r.Context())
|
||||
filtered := make([]ticket.TimeEntry, 0)
|
||||
for _, e := range list {
|
||||
if e.ProjectID == pid {
|
||||
filtered = append(filtered, e)
|
||||
}
|
||||
}
|
||||
list = filtered
|
||||
}
|
||||
writeJSON(w, http.StatusOK, list)
|
||||
}
|
||||
|
||||
func (s *Server) handleListTimeByTicket(w http.ResponseWriter, r *http.Request) {
|
||||
ticketID := r.PathValue("ticketId")
|
||||
t, err := s.store.Get(ticketID)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
if !requireMCPProject(r, t.ProjectID) {
|
||||
writeErr(w, http.StatusForbidden, "mcp token project mismatch")
|
||||
return
|
||||
}
|
||||
list, err := s.timeStore.ListByTicket(ticketID)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, list)
|
||||
}
|
||||
|
||||
func (s *Server) handleSumTime(w http.ResponseWriter, r *http.Request) {
|
||||
ticketID := r.PathValue("ticketId")
|
||||
t, err := s.store.Get(ticketID)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
if !requireMCPProject(r, t.ProjectID) {
|
||||
writeErr(w, http.StatusForbidden, "mcp token project mismatch")
|
||||
return
|
||||
}
|
||||
d, err := s.timeStore.SumByTicket(ticketID)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]int64{"duration": int64(d)})
|
||||
}
|
||||
|
||||
func (s *Server) handleAddTime(w http.ResponseWriter, r *http.Request) {
|
||||
var body ticket.TimeEntry
|
||||
if err := readJSON(r, &body); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if !requireMCPProject(r, body.ProjectID) {
|
||||
writeErr(w, http.StatusForbidden, "mcp token project mismatch")
|
||||
return
|
||||
}
|
||||
cmd := &command.AddTimeEntry{Store: s.timeStore, Entry: body}
|
||||
if err := s.history.Do(cmd); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
s.notify(cmd.Entry.ProjectID)
|
||||
writeJSON(w, http.StatusCreated, cmd.Entry)
|
||||
}
|
||||
|
||||
func (s *Server) handleUpdateTime(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
entries, err := s.timeStore.List()
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
var before *ticket.TimeEntry
|
||||
for i := range entries {
|
||||
if entries[i].ID == id {
|
||||
before = &entries[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if before == nil {
|
||||
writeErr(w, http.StatusNotFound, "time entry not found")
|
||||
return
|
||||
}
|
||||
if !requireMCPProject(r, before.ProjectID) {
|
||||
writeErr(w, http.StatusForbidden, "mcp token project mismatch")
|
||||
return
|
||||
}
|
||||
var after ticket.TimeEntry
|
||||
if err := readJSON(r, &after); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
after.ID = id
|
||||
if after.ProjectID == "" {
|
||||
after.ProjectID = before.ProjectID
|
||||
}
|
||||
cmd := &command.UpdateTimeEntry{Store: s.timeStore, Before: *before, After: after}
|
||||
if err := s.history.Do(cmd); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
s.notify(after.ProjectID)
|
||||
writeJSON(w, http.StatusOK, after)
|
||||
}
|
||||
|
||||
func (s *Server) handleRemoveTime(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
entries, err := s.timeStore.List()
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
var snap *ticket.TimeEntry
|
||||
for i := range entries {
|
||||
if entries[i].ID == id {
|
||||
snap = &entries[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if snap == nil {
|
||||
writeErr(w, http.StatusNotFound, "time entry not found")
|
||||
return
|
||||
}
|
||||
if !requireMCPProject(r, snap.ProjectID) {
|
||||
writeErr(w, http.StatusForbidden, "mcp token project mismatch")
|
||||
return
|
||||
}
|
||||
cmd := &command.RemoveTimeEntry{Store: s.timeStore, Snapshot: *snap}
|
||||
if err := s.history.Do(cmd); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
s.notify(snap.ProjectID)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (s *Server) handleGetTimer(w http.ResponseWriter, r *http.Request) {
|
||||
t, err := s.timeStore.LoadActiveTimer()
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if t != nil && ClientKind(r.Context()) == ClientMCP && t.ProjectID != MCPProjectID(r.Context()) {
|
||||
writeJSON(w, http.StatusOK, nil)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, t)
|
||||
}
|
||||
|
||||
func (s *Server) handleSetTimer(w http.ResponseWriter, r *http.Request) {
|
||||
defer r.Body.Close()
|
||||
var raw map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&raw); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
var body *ticket.ActiveTimer
|
||||
if raw != nil {
|
||||
b, err := json.Marshal(raw)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
var t ticket.ActiveTimer
|
||||
if err := json.Unmarshal(b, &t); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if t.TicketID == "" && t.ProjectID == "" {
|
||||
body = nil
|
||||
} else {
|
||||
if !requireMCPProject(r, t.ProjectID) {
|
||||
writeErr(w, http.StatusForbidden, "mcp token project mismatch")
|
||||
return
|
||||
}
|
||||
if t.StartedAt.IsZero() {
|
||||
t.StartedAt = time.Now()
|
||||
}
|
||||
body = &t
|
||||
}
|
||||
}
|
||||
if err := s.timeStore.SaveActiveTimer(body); err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if body != nil {
|
||||
s.notify(body.ProjectID)
|
||||
} else {
|
||||
s.notify("")
|
||||
}
|
||||
writeJSON(w, http.StatusOK, body)
|
||||
}
|
||||
Reference in New Issue
Block a user