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>
118 lines
3.0 KiB
Go
118 lines
3.0 KiB
Go
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
|
|
}
|