Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 24 additions & 2 deletions cmd/mesa/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,17 +58,19 @@ func main() {
verbosity := 0

dashboardAuth := false
externalAPIKey := ""

// CLI: mesa [-t <template>] [-m <model>] [-v|-vv|-vvv] [--auth] [doctor|wiki-search] [port]
// CLI: mesa [-t <template>] [-m <model>] [-v|-vv|-vvv] [--auth] [--api-key <key>] [doctor|wiki-search] [port]
args := os.Args[1:]
for i := 0; i < len(args); i++ {
arg := args[i]
if arg == "-h" || arg == "--help" {
fmt.Println("Usage: mesa [-t <template>] [-m <model>] [-v|-vv|-vvv] [--auth] [doctor|wiki-search] [port]")
fmt.Println("Usage: mesa [-t <template>] [-m <model>] [-v|-vv|-vvv] [--auth] [--api-key <key>] [doctor|wiki-search] [port]")
fmt.Println(" -t, --template Team template: startup, dev-team, enterprise, saas, agency (default: startup)")
fmt.Println(" -m, --model Default agent runner: claude, gemini, codex, opencode (default: claude)")
fmt.Println(" -v Verbosity: -v info, -vv debug, -vvv debug+cmd")
fmt.Println(" --auth Enable dashboard authentication with auto-generated token")
fmt.Println(" --api-key Static API key for external access (non-expiring, not agent-scoped)")
fmt.Println(" doctor Check that required CLI binaries are available")
fmt.Println(" wiki-search Search wiki pages (usage: wiki-search <query>)")
fmt.Println(" port HTTP port (default: 3001, or PORT env)")
Expand Down Expand Up @@ -104,11 +106,23 @@ func main() {
modelProvided = true
} else if arg == "--auth" {
dashboardAuth = true
} else if arg == "--api-key" {
if i+1 >= len(args) {
fmt.Fprintln(os.Stderr, "error: --api-key requires a value")
os.Exit(1)
}
i++
externalAPIKey = args[i]
} else {
port = arg
}
}

// Also allow env override for external API key
if k := os.Getenv("MESA_API_KEY_EXTERNAL"); k != "" {
externalAPIKey = k
}

switch {
case verbosity >= 3:
logLevel.Set(slog.Level(-8)) // trace: per-line agent output
Expand Down Expand Up @@ -250,6 +264,9 @@ func main() {

// Handlers
api := handlers.NewAPI(database, sse, tmpl, wake, tg, dc)
if externalAPIKey != "" {
api.SetExternalKey(externalAPIKey)
}
ui := handlers.NewUI(database, sse, tmpl, wake, sched)
// Routes
mux := http.NewServeMux()
Expand Down Expand Up @@ -320,6 +337,7 @@ func main() {

// API routes (auth-wrapped)
mux.HandleFunc("GET /api/v1/inbox", api.Auth(api.Inbox))
mux.HandleFunc("GET /api/v1/issues", api.Auth(api.ListIssues))
mux.HandleFunc("GET /api/v1/issues/{key}", api.Auth(api.GetIssue))
mux.HandleFunc("POST /api/v1/issues/{key}/checkout", api.Auth(api.CheckoutIssue))
mux.HandleFunc("PATCH /api/v1/issues/{key}", api.Auth(api.UpdateIssue))
Expand Down Expand Up @@ -396,6 +414,10 @@ func main() {
fmt.Fprintf(os.Stderr, "\n Dashboard auth enabled\n")
fmt.Fprintf(os.Stderr, " Open: http://localhost:%s/dashboard?token=%s\n\n", port, dashToken)
}
if externalAPIKey != "" {
fmt.Fprintf(os.Stderr, " External API key enabled\n")
fmt.Fprintf(os.Stderr, " Usage: curl -H 'Authorization: Bearer %s' http://localhost:%s/api/v1/issues\n\n", externalAPIKey, port)
}
slog.Info("mesa running", "url", "http://localhost:"+port)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
slog.Error("http server error", "error", err)
Expand Down
64 changes: 57 additions & 7 deletions internal/handlers/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,19 +33,43 @@ type DiscordNotifier interface {
}

type API struct {
db *db.DB
sse *SSEHub
tmpl *template.Template
wake func(agent *models.Agent, issue *models.Issue)
telegram TelegramNotifier
discord DiscordNotifier
db *db.DB
sse *SSEHub
tmpl *template.Template
wake func(agent *models.Agent, issue *models.Issue)
telegram TelegramNotifier
discord DiscordNotifier
externalKeyHash string // SHA-256 hex of the --api-key value; empty = disabled
}

func NewAPI(database *db.DB, sse *SSEHub, tmpl *template.Template, wake func(*models.Agent, *models.Issue), tg TelegramNotifier, dc DiscordNotifier) *API {
return &API{db: database, sse: sse, tmpl: tmpl, wake: wake, telegram: tg, discord: dc}
}

// Auth middleware extracts agent from API key
// SetExternalKey configures a static API key for external access.
// The key is hashed and compared against Bearer tokens in the Auth middleware.
func (a *API) SetExternalKey(rawKey string) {
if rawKey == "" {
return
}
hash := sha256.Sum256([]byte(rawKey))
a.externalKeyHash = hex.EncodeToString(hash[:])
}

// externalAgent returns a synthetic agent used for external API key access.
// It has a fixed ID and name so that endpoints requiring agent context work correctly.
func externalAgent() *models.Agent {
return &models.Agent{
ID: "external",
Name: "External",
Slug: "external",
ArchetypeSlug: "external",
Active: true,
}
}

// Auth middleware extracts agent from API key.
// If an external API key is configured (via --api-key), it is checked first.
func (a *API) Auth(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
token := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
Expand All @@ -55,6 +79,14 @@ func (a *API) Auth(next http.HandlerFunc) http.HandlerFunc {
}
hash := sha256.Sum256([]byte(token))
keyHash := hex.EncodeToString(hash[:])

// Check external key first (non-expiring, not agent-scoped)
if a.externalKeyHash != "" && keyHash == a.externalKeyHash {
r = r.WithContext(withAgent(r.Context(), externalAgent()))
next(w, r)
return
}

agent, err := a.db.GetAgentByAPIKey(keyHash)
if err != nil {
http.Error(w, `{"error":"invalid api key"}`, http.StatusUnauthorized)
Expand All @@ -75,6 +107,24 @@ func (a *API) Inbox(w http.ResponseWriter, r *http.Request) {
jsonOK(w, issues)
}

// ListIssues returns all issues, optionally filtered by ?status= and ?limit=.
// Available to any authenticated caller (including external API keys).
func (a *API) ListIssues(w http.ResponseWriter, r *http.Request) {
status := r.URL.Query().Get("status")
limit := 100
if l := r.URL.Query().Get("limit"); l != "" {
if n, err := strconv.Atoi(l); err == nil && n > 0 {
limit = n
}
}
issues, err := a.db.ListIssues(status, limit)
if err != nil {
jsonError(w, err.Error(), http.StatusInternalServerError)
return
}
jsonOK(w, issues)
}

func (a *API) GetIssue(w http.ResponseWriter, r *http.Request) {
key := r.PathValue("key")
issue, err := a.db.GetIssue(key)
Expand Down
75 changes: 75 additions & 0 deletions internal/handlers/handlers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,81 @@ func TestAuthValidKey(t *testing.T) {
}
}

func TestAuthExternalKey(t *testing.T) {
d := testDB(t)
hub := NewSSEHub()
defer hub.Close()
api := NewAPI(d, hub, nil, nil, &stubTelegram{}, nil)
api.SetExternalKey("my-external-key")

var gotAgent *models.Agent
handler := api.Auth(func(w http.ResponseWriter, r *http.Request) {
gotAgent = agentFromContext(r.Context())
w.WriteHeader(http.StatusOK)
})

req := httptest.NewRequest("GET", "/", nil)
req.Header.Set("Authorization", "Bearer my-external-key")
w := httptest.NewRecorder()
handler(w, req)

if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200", w.Code)
}
if gotAgent == nil {
t.Fatal("expected agent in context")
}
if gotAgent.ID != "external" {
t.Errorf("agent ID = %q, want external", gotAgent.ID)
}
if gotAgent.Slug != "external" {
t.Errorf("agent slug = %q, want external", gotAgent.Slug)
}
}

func TestAuthExternalKeyWrongValue(t *testing.T) {
d := testDB(t)
hub := NewSSEHub()
defer hub.Close()
api := NewAPI(d, hub, nil, nil, &stubTelegram{}, nil)
api.SetExternalKey("my-external-key")

handler := api.Auth(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})

req := httptest.NewRequest("GET", "/", nil)
req.Header.Set("Authorization", "Bearer wrong-key")
w := httptest.NewRecorder()
handler(w, req)

if w.Code != http.StatusUnauthorized {
t.Errorf("status = %d, want 401", w.Code)
}
}

func TestAuthExternalKeyNotSet(t *testing.T) {
d := testDB(t)
hub := NewSSEHub()
defer hub.Close()
api := NewAPI(d, hub, nil, nil, &stubTelegram{}, nil)
// No SetExternalKey called

handler := api.Auth(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})

req := httptest.NewRequest("GET", "/", nil)
req.Header.Set("Authorization", "Bearer some-key")
w := httptest.NewRecorder()
handler(w, req)

// Should fail because no external key is set and "some-key" isn't a valid DB key
if w.Code != http.StatusUnauthorized {
t.Errorf("status = %d, want 401", w.Code)
}
}

// --- SSE ServeHTTP ---

func TestSSEServeHTTP(t *testing.T) {
Expand Down
Loading