File size: 7,017 Bytes
857a91b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
// 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})
}