// Package api exposes the matrix-runtime HTTP API: health, capabilities, jobs // and the sandbox compatibility aliases used by MatrixHub. package api import ( "context" "encoding/json" "net" "net/http" "strings" "time" "github.com/agent-matrix/matrix-runtime/internal/config" "github.com/agent-matrix/matrix-runtime/internal/email" "github.com/agent-matrix/matrix-runtime/internal/jobs" "github.com/agent-matrix/matrix-runtime/internal/store" "github.com/agent-matrix/matrix-runtime/web" ) // Server wires the job manager, user store, email sender and config to routes. type Server struct { cfg *config.Config manager *jobs.Manager store *store.Store email *email.Sender limiter *rateLimiter } // NewServer builds a Server. store may be nil if the user database could not be // opened; auth endpoints then return 503. func NewServer(cfg *config.Config, mgr *jobs.Manager, st *store.Store) *Server { s := &Server{cfg: cfg, manager: mgr, store: st, email: email.NewFromEnv()} if cfg.RateLimitRPM > 0 { s.limiter = newRateLimiter(cfg.RateLimitRPM) } return s } // Handler returns the configured HTTP handler with all routes registered. func (s *Server) Handler() http.Handler { mux := http.NewServeMux() mux.HandleFunc("GET /v1/health", s.handleHealth) mux.HandleFunc("GET /v1/ready", s.handleReady) mux.HandleFunc("GET /v1/version", s.handleVersion) mux.HandleFunc("GET /v1/capabilities", s.handleCapabilities) mux.HandleFunc("GET /v1/runtimes", s.handleListRuntimes) mux.HandleFunc("GET /v1/catalog", s.handleCatalog) mux.HandleFunc("GET /v1/policies", s.handlePolicies) // Multitenant auth (users + sessions, SQLite or Postgres/Neon). mux.HandleFunc("POST /v1/auth/signup", s.handleSignup) mux.HandleFunc("POST /v1/auth/login", s.handleLogin) mux.HandleFunc("GET /v1/auth/me", s.handleMe) mux.HandleFunc("POST /v1/auth/logout", s.handleLogout) // Password recovery + email verification (delivered via Resend). mux.HandleFunc("POST /v1/auth/forgot", s.handleForgotPassword) mux.HandleFunc("POST /v1/auth/reset", s.handleResetPassword) mux.HandleFunc("POST /v1/auth/verify", s.handleVerifyEmail) // Hosted control plane: runtimes, join tokens, BYO provider creds, usage. mux.HandleFunc("GET /v1/cloud/runtimes", s.handleCloudListRuntimes) mux.HandleFunc("POST /v1/cloud/runtimes/register", s.handleCloudRegisterRuntime) mux.HandleFunc("POST /v1/cloud/runtimes/heartbeat", s.handleCloudHeartbeat) mux.HandleFunc("GET /v1/cloud/join-tokens", s.handleCloudListJoinTokens) mux.HandleFunc("POST /v1/cloud/join-tokens", s.handleCloudMintJoinToken) mux.HandleFunc("GET /v1/cloud/providers", s.handleCloudListProviders) mux.HandleFunc("POST /v1/cloud/providers", s.handleCloudSetProvider) mux.HandleFunc("GET /v1/cloud/usage", s.handleCloudUsage) mux.HandleFunc("GET /v1/cloud/audit", s.handleCloudAudit) mux.HandleFunc("GET /v1/model-sources/huggingface/search", s.handleHFSearch) mux.HandleFunc("POST /v1/model-sources/resolve", s.handleResolveSource) mux.HandleFunc("GET /v1/model-profiles", s.handleListProfiles) mux.HandleFunc("POST /v1/model-profiles", s.handleImportProfile) mux.HandleFunc("POST /v1/model-profiles/{id}/attach", s.handleAttachProfile) mux.HandleFunc("GET /v1/model-installations", s.handleListInstallations) // MatrixShell — real local Python sandbox (install / status / exec). mux.HandleFunc("GET /v1/matrixshell/status", s.handleMatrixShellStatus) mux.HandleFunc("POST /v1/matrixshell/install", s.handleMatrixShellInstall) mux.HandleFunc("POST /v1/matrixshell/exec", s.handleMatrixShellExec) mux.HandleFunc("POST /v1/jobs", s.handleCreateJob) mux.HandleFunc("GET /v1/jobs", s.handleListJobs) mux.HandleFunc("GET /v1/jobs/{job_id}", s.handleGetJob) mux.HandleFunc("GET /v1/jobs/{job_id}/events", s.handleJobEvents) mux.HandleFunc("DELETE /v1/jobs/{job_id}", s.handleDeleteJob) mux.HandleFunc("POST /v1/sandbox/sessions", s.handleCreateSandbox) mux.HandleFunc("GET /v1/sandbox/sessions/{session_id}", s.handleGetSandbox) mux.HandleFunc("GET /v1/sandbox/sessions/{session_id}/events", s.handleSandboxEvents) mux.HandleFunc("GET /v1/sandbox/sessions/{session_id}/tools", s.handleSandboxTools) mux.HandleFunc("POST /v1/sandbox/sessions/{session_id}/tools/call", s.handleSandboxToolCall) mux.HandleFunc("DELETE /v1/sandbox/sessions/{session_id}", s.handleDeleteSandbox) // API docs (OpenAPI spec + a self-contained viewer), public. mux.HandleFunc("GET /openapi.yaml", s.handleOpenAPISpec) mux.HandleFunc("GET /docs", s.handleDocs) // System: storage usage. mux.HandleFunc("GET /v1/system/storage", s.handleStorage) // Enterprise console (static SPA) served from the embedded web assets. mux.Handle("/", s.consoleHandler()) // Outermost: rate limiting (protects auth + writes); then auth. return s.withRateLimit(s.withAuth(mux)) } // consoleHandler serves the embedded console, falling back to index.html for // client-side routes (single-page app). func (s *Server) consoleHandler() http.Handler { assets := web.Static() fileServer := http.FileServer(http.FS(assets)) return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Never let the SPA shadow the API namespace. if strings.HasPrefix(r.URL.Path, "/v1/") { http.NotFound(w, r) return } // Serve the asset when it exists; otherwise hand back the app shell. p := strings.TrimPrefix(r.URL.Path, "/") if p == "" { p = "index.html" } if f, err := assets.Open(p); err == nil { _ = f.Close() fileServer.ServeHTTP(w, r) return } r2 := r.Clone(r.Context()) r2.URL.Path = "/" fileServer.ServeHTTP(w, r2) }) } // Run starts the HTTP server and blocks until ctx is cancelled, then performs // a graceful shutdown. // Run binds addr and serves until ctx is cancelled. Kept for compatibility; // prefer Serve with a pre-bound listener (e.g. from a port-fallback search). func (s *Server) Run(ctx context.Context, addr string) error { ln, err := net.Listen("tcp", addr) if err != nil { return err } return s.Serve(ctx, ln) } // Serve serves HTTP on the given listener until ctx is cancelled, then performs // a graceful shutdown. func (s *Server) Serve(ctx context.Context, ln net.Listener) error { srv := &http.Server{ Handler: s.Handler(), ReadHeaderTimeout: 10 * time.Second, } errCh := make(chan error, 1) go func() { errCh <- srv.Serve(ln) }() select { case err := <-errCh: return err case <-ctx.Done(): shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() return srv.Shutdown(shutdownCtx) } } // writeJSON serialises v as JSON with the given status code. func writeJSON(w http.ResponseWriter, status int, v any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(v) } // writeError writes a structured JSON error. func writeError(w http.ResponseWriter, status int, msg string) { writeJSON(w, status, map[string]any{"error": msg, "status": status}) }