1// Package admin implements the HTTP adapter for the admin API.
2package admin
3
4import (
5 "context"
6 "encoding/json"
7 "errors"
8 "fmt"
9 "io"
10 "net/http"
11 "net/url"
12 "strconv"
13 "strings"
14 "time"
15
16 "github.com/bnema/zerowrap"
17
18 "github.com/bnema/gordon/internal/adapters/dto"
19 "github.com/bnema/gordon/internal/boundaries/in"
20 "github.com/bnema/gordon/internal/boundaries/out"
21 "github.com/bnema/gordon/internal/domain"
22 "github.com/bnema/gordon/internal/usecase/config"
23 "github.com/bnema/gordon/internal/usecase/registry"
24 "github.com/bnema/gordon/pkg/validation"
25)
26
27// maxAdminRequestSize is the maximum allowed size for admin API request bodies.
28const maxAdminRequestSize = 1 << 20 // 1MB
29
30// maxLogLines is the maximum allowed number of log lines that can be requested.
31const maxLogLines = 10000
32
33type registryDeployService interface {
34 in.RegistryService
35 in.DeployCoordinator
36}
37
38// Handler implements the HTTP handler for the admin API.
39type Handler struct {
40 configSvc in.ConfigService
41 authSvc in.AuthService
42 containerSvc in.ContainerService
43 backupSvc in.BackupService
44 imageSvc in.ImageService
45 healthSvc in.HealthService
46 secretSvc in.SecretService
47 logSvc in.LogService
48 volumeSvc in.VolumeService
49 registrySvc registryDeployService
50 previewSvc previewService
51 eventBus out.EventPublisher
52 log zerowrap.Logger
53}
54
55// Type aliases for API responses using shared DTO types.
56type routeInfoResponse = dto.RouteInfo
57type attachmentResponse = dto.Attachment
58type routeResponse = dto.Route
59
60// toAttachmentResponse converts a domain.Attachment to a dto.Attachment.
61func toAttachmentResponse(a domain.Attachment) dto.Attachment {
62 return dto.Attachment{
63 Name: a.Name,
64 Image: a.Image,
65 ContainerID: a.ContainerID,
66 Status: a.Status,
67 Network: a.Network,
68 }
69}
70
71// toRouteInfoResponse converts a domain.RouteInfo to a dto.RouteInfo.
72func toRouteInfoResponse(r domain.RouteInfo) dto.RouteInfo {
73 attachments := make([]dto.Attachment, 0, len(r.Attachments))
74 for _, a := range r.Attachments {
75 attachments = append(attachments, toAttachmentResponse(a))
76 }
77 return dto.RouteInfo{
78 Domain: r.Domain,
79 Image: r.Image,
80 ContainerID: r.ContainerID,
81 ContainerStatus: r.ContainerStatus,
82 Network: r.Network,
83 Attachments: attachments,
84 }
85}
86
87// toRouteResponse converts a domain.Route to a dto.Route.
88func toRouteResponse(r domain.Route) dto.Route {
89 return dto.Route{
90 Domain: r.Domain,
91 Image: r.Image,
92 HTTPS: r.HTTPS,
93 }
94}
95
96func toBackupJobResponse(job domain.BackupJob) dto.BackupJob {
97 var startedAt *time.Time
98 if !job.StartedAt.IsZero() {
99 t := job.StartedAt
100 startedAt = &t
101 }
102 var completedAt *time.Time
103 if !job.CompletedAt.IsZero() {
104 t := job.CompletedAt
105 completedAt = &t
106 }
107
108 return dto.BackupJob{
109 ID: job.ID,
110 Domain: job.Domain,
111 DBName: job.DBName,
112 Schedule: string(job.Schedule),
113 Type: string(job.Type),
114 Status: string(job.Status),
115 StartedAt: startedAt,
116 CompletedAt: completedAt,
117 SizeBytes: job.SizeBytes,
118 Error: job.Error,
119 }
120}
121
122func toDatabaseInfoResponse(db domain.DBInfo) dto.DatabaseInfo {
123 return dto.DatabaseInfo{
124 Type: string(db.Type),
125 Name: db.Name,
126 Version: db.Version,
127 Host: db.Host,
128 Port: db.Port,
129 ContainerID: db.ContainerID,
130 ImageName: db.ImageName,
131 }
132}
133
134// HandlerDeps contains all dependencies for the admin HTTP handler.
135type HandlerDeps struct {
136 ConfigSvc in.ConfigService
137 AuthSvc in.AuthService
138 ContainerSvc in.ContainerService
139 HealthSvc in.HealthService
140 SecretSvc in.SecretService
141 LogSvc in.LogService
142 RegistrySvc registryDeployService
143 EventBus out.EventPublisher
144 Log zerowrap.Logger
145 BackupSvc in.BackupService
146 PreviewSvc previewService
147 ImageSvc in.ImageService
148 VolumeSvc in.VolumeService
149}
150
151// NewHandler creates a new admin HTTP handler.
152func NewHandler(deps HandlerDeps) *Handler {
153 return &Handler{
154 configSvc: deps.ConfigSvc,
155 authSvc: deps.AuthSvc,
156 containerSvc: deps.ContainerSvc,
157 backupSvc: deps.BackupSvc,
158 imageSvc: deps.ImageSvc,
159 healthSvc: deps.HealthSvc,
160 secretSvc: deps.SecretSvc,
161 logSvc: deps.LogSvc,
162 volumeSvc: deps.VolumeSvc,
163 registrySvc: deps.RegistrySvc,
164 previewSvc: deps.PreviewSvc,
165 eventBus: deps.EventBus,
166 log: deps.Log,
167 }
168}
169
170// RegisterRoutes registers the admin routes on the given mux.
171func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
172 mux.HandleFunc("/admin/", h.handleAdminRoutes)
173}
174
175// ServeHTTP implements http.Handler.
176func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
177 h.handleAdminRoutes(w, r)
178}
179
180func (h *Handler) handleAdminRoutes(w http.ResponseWriter, r *http.Request) {
181 ctx := zerowrap.CtxWithFields(r.Context(), map[string]any{
182 zerowrap.FieldLayer: "adapter",
183 zerowrap.FieldAdapter: "http",
184 zerowrap.FieldHandler: "admin",
185 zerowrap.FieldMethod: r.Method,
186 zerowrap.FieldPath: r.URL.Path,
187 })
188 r = r.WithContext(ctx)
189
190 path := strings.TrimPrefix(r.URL.Path, "/admin")
191
192 // Route to appropriate handler
193 if handler, ok := h.matchRoute(path); ok {
194 handler(w, r, path)
195 return
196 }
197 h.sendError(w, http.StatusNotFound, "route not found")
198}
199
200// routeHandler is the signature for path-based route handlers.
201type routeHandler func(w http.ResponseWriter, r *http.Request, path string)
202
203// matchRoute returns the handler for a given path, or false if not found.
204func (h *Handler) matchRoute(path string) (routeHandler, bool) {
205 // Exact match routes
206 exactRoutes := map[string]routeHandler{
207 "/networks": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleNetworks(w, r) },
208 "/status": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleStatus(w, r) },
209 "/health": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleHealth(w, r) },
210 "/bootstrap": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleBootstrap(w, r) },
211 "/reload": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleReload(w, r) },
212 "/config": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleConfig(w, r) },
213 "/auth/verify": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleAuthVerify(w, r) },
214 "/volumes": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleListVolumes(w, r) },
215 "/volumes/prune": func(w http.ResponseWriter, r *http.Request, _ string) { h.handlePruneVolumes(w, r) },
216 }
217 if handler, ok := exactRoutes[path]; ok {
218 return handler, true
219 }
220
221 // Prefix match routes
222 prefixRoutes := []struct {
223 prefix string
224 handler routeHandler
225 }{
226 {"/backups", h.handleBackups},
227 {"/attachments/by-image", h.handleAttachmentsByImage},
228 {"/attachments", h.handleAttachmentsConfig},
229 {"/routes/by-image", h.handleRoutesByImage},
230 {"/routes", h.handleRoutes},
231 {"/secrets", h.handleSecrets},
232 {"/deploy-intent", h.handleDeployIntent},
233 {"/deploy", h.handleDeploy},
234 {"/restart", h.handleRestart},
235 {"/tags", h.handleTags},
236 {"/images", h.handleImages},
237 {"/logs", h.handleLogs},
238 {"/autoroute/allowed-domains", h.handleAutoRouteAllowedDomains},
239 {"/previews", h.handlePreviewList},
240 {"/preview", h.handlePreviewAction},
241 }
242 for _, route := range prefixRoutes {
243 if path == route.prefix || strings.HasPrefix(path, route.prefix+"/") {
244 return route.handler, true
245 }
246 }
247
248 return nil, false
249}
250
251// handleAttachmentsByImage handles GET /admin/attachments/by-image/{image} endpoint.
252// Returns all attachment targets associated with the given image name.
253func (h *Handler) handleAttachmentsByImage(w http.ResponseWriter, r *http.Request, path string) {
254 if r.Method != http.MethodGet {
255 h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
256 return
257 }
258
259 ctx := r.Context()
260
261 if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionRead) {
262 h.sendError(w, http.StatusForbidden, "insufficient permissions for config:read")
263 return
264 }
265
266 imageName := strings.TrimPrefix(path, "/attachments/by-image/")
267 if imageName == "" || imageName == "/attachments/by-image" {
268 h.sendError(w, http.StatusBadRequest, "image name required in path")
269 return
270 }
271
272 imageName, err := url.PathUnescape(imageName)
273 if err != nil {
274 h.sendError(w, http.StatusBadRequest, "invalid image name encoding")
275 return
276 }
277
278 targets := h.configSvc.FindAttachmentTargetsByImage(ctx, imageName)
279
280 h.sendJSON(w, http.StatusOK, dto.AttachmentTargetsByImageResponse{
281 Image: imageName,
282 Targets: targets,
283 })
284}
285
286// handleDeployIntent handles /admin/deploy-intent/:image endpoint.
287// It registers a deploy intent, suppressing event-based deploys for the image.
288func (h *Handler) handleDeployIntent(w http.ResponseWriter, r *http.Request, path string) {
289 if r.Method != http.MethodPost {
290 h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
291 return
292 }
293
294 ctx := r.Context()
295 if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionWrite) {
296 h.sendError(w, http.StatusForbidden, "insufficient permissions for config:write")
297 return
298 }
299
300 if h.registrySvc == nil {
301 h.sendError(w, http.StatusServiceUnavailable, "registry service unavailable")
302 return
303 }
304
305 rawName := strings.TrimPrefix(path, "/deploy-intent/")
306 if rawName == "" || rawName == "/deploy-intent" {
307 h.sendError(w, http.StatusBadRequest, "image name required")
308 return
309 }
310
311 imageName, err := url.PathUnescape(rawName)
312 if err != nil {
313 h.sendError(w, http.StatusBadRequest, "invalid image name encoding")
314 return
315 }
316
317 log := zerowrap.FromCtx(ctx)
318 log.Info().Str("image", imageName).Msg("deploy intent registered, suppressing image.pushed events")
319
320 h.registrySvc.SuppressDeployEvent(imageName)
321
322 h.sendJSON(w, http.StatusOK, map[string]string{
323 "status": "ok",
324 "image": imageName,
325 })
326}
327
328func (h *Handler) handleBootstrap(w http.ResponseWriter, r *http.Request) {
329 if r.Method != http.MethodPost {
330 h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
331 return
332 }
333
334 ctx := r.Context()
335 log := zerowrap.FromCtx(ctx)
336
337 if !HasAccess(ctx, domain.AdminResourceRoutes, domain.AdminActionWrite) {
338 h.sendError(w, http.StatusForbidden, "insufficient permissions for routes:write")
339 return
340 }
341
342 r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestSize)
343
344 var req dto.BootstrapRequest
345 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
346 log.Warn().Err(err).Msg("invalid bootstrap JSON")
347 h.sendError(w, http.StatusBadRequest, "invalid JSON")
348 return
349 }
350
351 if req.Domain == "" {
352 h.sendError(w, http.StatusBadRequest, "domain is required")
353 return
354 }
355
356 if req.Image == "" {
357 h.sendError(w, http.StatusBadRequest, "image is required")
358 return
359 }
360
361 normalizedImage, err := config.NormalizeBootstrapImage(req.Image, h.configSvc.GetRegistryDomain())
362 if err != nil {
363 h.sendError(w, http.StatusBadRequest, err.Error())
364 return
365 }
366
367 if err := h.validateBootstrapPermissions(ctx, req); err != nil {
368 h.sendError(w, http.StatusForbidden, err.Error())
369 return
370 }
371
372 resp := dto.BootstrapResponse{
373 Domain: req.Domain,
374 Image: normalizedImage,
375 Next: fmt.Sprintf("push %s to trigger deployment", normalizedImage),
376 }
377 addStep := func(name, status string) {
378 resp.Steps = append(resp.Steps, dto.BootstrapStep{Name: name, Status: status})
379 }
380
381 err = h.configSvc.AddRoute(ctx, domain.Route{Domain: req.Domain, Image: normalizedImage})
382 switch {
383 case err == nil:
384 addStep("route", "configured")
385 case errors.Is(err, domain.ErrRouteDomainEmpty), errors.Is(err, domain.ErrRouteImageEmpty):
386 addStep("route", "failed")
387 h.sendError(w, http.StatusBadRequest, err.Error())
388 return
389 default:
390 addStep("route", "failed")
391 log.Error().Err(err).Str("domain", req.Domain).Str("image", req.Image).Msg("failed to bootstrap route")
392 h.sendJSON(w, http.StatusInternalServerError, resp)
393 return
394 }
395
396 if err := h.bootstrapAttachments(ctx, h.configSvc, req.Domain, req.Attachments, &resp.Steps); err != nil {
397 log.Error().Err(err).Str("domain", req.Domain).Msg("failed to bootstrap attachments")
398 h.sendJSON(w, http.StatusInternalServerError, resp)
399 return
400 }
401
402 if err := h.bootstrapSecrets(ctx, h.secretSvc, req.Domain, req.Env, &resp.Steps); err != nil {
403 log.Error().Err(err).Str("domain", req.Domain).Msg("failed to bootstrap env")
404 h.sendJSON(w, http.StatusInternalServerError, resp)
405 return
406 }
407
408 if err := h.bootstrapAttachmentSecrets(ctx, h.secretSvc, req.Domain, req.AttachmentEnv, &resp.Steps); err != nil {
409 log.Error().Err(err).Str("domain", req.Domain).Msg("failed to bootstrap attachment env")
410 h.sendJSON(w, http.StatusInternalServerError, resp)
411 return
412 }
413
414 h.sendJSON(w, http.StatusOK, resp)
415}
416
417func (h *Handler) validateBootstrapPermissions(ctx context.Context, req dto.BootstrapRequest) error {
418 if (len(req.Env) > 0 || len(req.AttachmentEnv) > 0) && !HasAccess(ctx, domain.AdminResourceSecrets, domain.AdminActionWrite) {
419 return errors.New("insufficient permissions for secrets:write")
420 }
421 if len(req.Attachments) > 0 && !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionWrite) {
422 return errors.New("insufficient permissions for config:write")
423 }
424
425 return nil
426}
427
428func (h *Handler) bootstrapAttachments(ctx context.Context, configSvc in.ConfigService, domainName string, attachments []string, steps *[]dto.BootstrapStep) error {
429 for _, attachment := range attachments {
430 err := configSvc.AddAttachment(ctx, domainName, attachment)
431 switch {
432 case err == nil:
433 *steps = append(*steps, dto.BootstrapStep{Name: "attachment:" + attachment, Status: "created"})
434 case errors.Is(err, domain.ErrAttachmentExists):
435 *steps = append(*steps, dto.BootstrapStep{Name: "attachment:" + attachment, Status: "noop"})
436 default:
437 *steps = append(*steps, dto.BootstrapStep{Name: "attachment:" + attachment, Status: "failed"})
438 return err
439 }
440 }
441
442 return nil
443}
444
445func (h *Handler) bootstrapSecrets(ctx context.Context, secretSvc in.SecretService, domainName string, env map[string]string, steps *[]dto.BootstrapStep) error {
446 if len(env) == 0 {
447 return nil
448 }
449
450 if err := secretSvc.Set(ctx, domainName, env); err != nil {
451 *steps = append(*steps, dto.BootstrapStep{Name: "env", Status: "failed"})
452 return err
453 }
454
455 *steps = append(*steps, dto.BootstrapStep{Name: "env", Status: "updated"})
456 return nil
457}
458
459func (h *Handler) bootstrapAttachmentSecrets(ctx context.Context, secretSvc in.SecretService, domainName string, attachmentEnv map[string]map[string]string, steps *[]dto.BootstrapStep) error {
460 for service, env := range attachmentEnv {
461 if err := secretSvc.SetAttachment(ctx, domainName, service, env); err != nil {
462 *steps = append(*steps, dto.BootstrapStep{Name: "attachment_env:" + service, Status: "failed"})
463 return err
464 }
465
466 *steps = append(*steps, dto.BootstrapStep{Name: "attachment_env:" + service, Status: "updated"})
467 }
468
469 return nil
470}
471
472// sendJSON sends a JSON response.
473func (h *Handler) sendJSON(w http.ResponseWriter, status int, data any) {
474 w.Header().Set("Content-Type", "application/json")
475 w.WriteHeader(status)
476 if data != nil {
477 _ = json.NewEncoder(w).Encode(data)
478 }
479}
480
481// sendError sends an error response.
482func (h *Handler) sendError(w http.ResponseWriter, status int, message string) {
483 h.sendJSON(w, status, dto.ErrorResponse{Error: message})
484}
485
486// handleRoutes handles /admin/routes endpoints.
487func (h *Handler) handleRoutes(w http.ResponseWriter, r *http.Request, path string) {
488 // Parse domain from path if present
489 routeDomain := strings.TrimPrefix(path, "/routes/")
490 if routeDomain == "/routes" {
491 routeDomain = ""
492 }
493
494 switch r.Method {
495 case http.MethodGet:
496 h.handleRoutesGet(w, r, routeDomain)
497 case http.MethodPost:
498 h.handleRoutesPost(w, r)
499 case http.MethodPut:
500 h.handleRoutesPut(w, r, routeDomain)
501 case http.MethodDelete:
502 h.handleRoutesDelete(w, r, routeDomain)
503 default:
504 h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
505 }
506}
507
508func (h *Handler) handleRoutesGet(w http.ResponseWriter, r *http.Request, routeDomain string) {
509 ctx := r.Context()
510
511 // Check read permission
512 if !HasAccess(ctx, domain.AdminResourceRoutes, domain.AdminActionRead) {
513 h.sendError(w, http.StatusForbidden, "insufficient permissions for routes:read")
514 return
515 }
516
517 if routeDomain == "" {
518 if r.URL.Query().Get("detailed") == "true" {
519 routes := h.containerSvc.ListRoutesWithDetails(ctx)
520 response := make([]routeInfoResponse, 0, len(routes))
521 for _, route := range routes {
522 response = append(response, toRouteInfoResponse(route))
523 }
524 h.sendJSON(w, http.StatusOK, dto.RoutesDetailResponse{Routes: response})
525 return
526 }
527
528 routes := h.configSvc.GetRoutes(ctx)
529 response := make([]routeResponse, 0, len(routes))
530 for _, route := range routes {
531 response = append(response, toRouteResponse(route))
532 }
533 h.sendJSON(w, http.StatusOK, dto.RoutesResponse{Routes: response})
534 return
535 }
536
537 if strings.HasSuffix(routeDomain, "/attachments") {
538 parentDomain := strings.TrimSuffix(routeDomain, "/attachments")
539 if parentDomain == "" {
540 h.sendError(w, http.StatusBadRequest, "domain required in path")
541 return
542 }
543 attachments := h.containerSvc.ListAttachments(ctx, parentDomain)
544 response := make([]attachmentResponse, 0, len(attachments))
545 for _, attachment := range attachments {
546 response = append(response, toAttachmentResponse(attachment))
547 }
548 h.sendJSON(w, http.StatusOK, dto.AttachmentsResponse{Attachments: response})
549 return
550 }
551
552 route, err := h.configSvc.GetRoute(ctx, routeDomain)
553 if err != nil {
554 h.sendError(w, http.StatusNotFound, "route not found")
555 return
556 }
557 h.sendJSON(w, http.StatusOK, toRouteResponse(*route))
558}
559
560func (h *Handler) handleRoutesPost(w http.ResponseWriter, r *http.Request) {
561 ctx := r.Context()
562 log := zerowrap.FromCtx(ctx)
563
564 // Check write permission
565 if !HasAccess(ctx, domain.AdminResourceRoutes, domain.AdminActionWrite) {
566 h.sendError(w, http.StatusForbidden, "insufficient permissions for routes:write")
567 return
568 }
569
570 // Limit request body size
571 r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestSize)
572
573 var route domain.Route
574 if err := json.NewDecoder(r.Body).Decode(&route); err != nil {
575 log.Warn().Err(err).Msg("invalid route JSON")
576 h.sendError(w, http.StatusBadRequest, "invalid JSON")
577 return
578 }
579
580 if err := h.configSvc.AddRoute(ctx, route); err != nil {
581 log.Error().Err(err).Str("domain", route.Domain).Msg("failed to add route")
582 switch {
583 case errors.Is(err, domain.ErrRouteDomainEmpty), errors.Is(err, domain.ErrRouteImageEmpty):
584 h.sendError(w, http.StatusBadRequest, err.Error())
585 default:
586 h.sendError(w, http.StatusInternalServerError, "failed to add route")
587 }
588 return
589 }
590
591 log.Info().Str("domain", route.Domain).Str("image", route.Image).Msg("route added")
592 h.sendJSON(w, http.StatusCreated, toRouteResponse(route))
593}
594
595func (h *Handler) handleRoutesPut(w http.ResponseWriter, r *http.Request, routeDomain string) {
596 ctx := r.Context()
597 log := zerowrap.FromCtx(ctx)
598
599 // Check write permission
600 if !HasAccess(ctx, domain.AdminResourceRoutes, domain.AdminActionWrite) {
601 h.sendError(w, http.StatusForbidden, "insufficient permissions for routes:write")
602 return
603 }
604
605 if routeDomain == "" {
606 h.sendError(w, http.StatusBadRequest, "domain required in path")
607 return
608 }
609
610 // Limit request body size
611 r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestSize)
612
613 var route domain.Route
614 if err := json.NewDecoder(r.Body).Decode(&route); err != nil {
615 log.Warn().Err(err).Msg("invalid route JSON")
616 h.sendError(w, http.StatusBadRequest, "invalid JSON")
617 return
618 }
619
620 route.Domain = routeDomain
621
622 if err := h.configSvc.UpdateRoute(ctx, route); err != nil {
623 log.Error().Err(err).Str("domain", routeDomain).Msg("failed to update route")
624 switch {
625 case errors.Is(err, domain.ErrRouteNotFound):
626 h.sendError(w, http.StatusNotFound, "route not found")
627 case errors.Is(err, domain.ErrRouteDomainEmpty), errors.Is(err, domain.ErrRouteImageEmpty):
628 h.sendError(w, http.StatusBadRequest, err.Error())
629 default:
630 h.sendError(w, http.StatusInternalServerError, "failed to update route")
631 }
632 return
633 }
634
635 log.Info().Str("domain", route.Domain).Str("image", route.Image).Msg("route updated")
636 h.sendJSON(w, http.StatusOK, toRouteResponse(route))
637}
638
639func (h *Handler) handleRoutesDelete(w http.ResponseWriter, r *http.Request, routeDomain string) {
640 ctx := r.Context()
641 log := zerowrap.FromCtx(ctx)
642
643 // Check write permission
644 if !HasAccess(ctx, domain.AdminResourceRoutes, domain.AdminActionWrite) {
645 h.sendError(w, http.StatusForbidden, "insufficient permissions for routes:write")
646 return
647 }
648
649 if routeDomain == "" {
650 h.sendError(w, http.StatusBadRequest, "domain required in path")
651 return
652 }
653
654 if err := h.configSvc.RemoveRoute(ctx, routeDomain); err != nil {
655 log.Error().Err(err).Str("domain", routeDomain).Msg("failed to remove route")
656 if errors.Is(err, domain.ErrRouteNotFound) {
657 h.sendError(w, http.StatusNotFound, "route not found")
658 } else {
659 h.sendError(w, http.StatusInternalServerError, "failed to remove route")
660 }
661 return
662 }
663
664 log.Info().Str("domain", routeDomain).Msg("route removed")
665 h.sendJSON(w, http.StatusOK, dto.RouteDeleteResponse{Status: "removed"})
666}
667
668// handleRoutesByImage handles GET /admin/routes/by-image/{image} endpoint.
669// Returns all routes associated with the given image name.
670func (h *Handler) handleRoutesByImage(w http.ResponseWriter, r *http.Request, path string) {
671 if r.Method != http.MethodGet {
672 h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
673 return
674 }
675
676 ctx := r.Context()
677
678 if !HasAccess(ctx, domain.AdminResourceRoutes, domain.AdminActionRead) {
679 h.sendError(w, http.StatusForbidden, "insufficient permissions for routes:read")
680 return
681 }
682
683 imageName := strings.TrimPrefix(path, "/routes/by-image/")
684 if imageName == "" || imageName == "/routes/by-image" {
685 h.sendError(w, http.StatusBadRequest, "image name required in path")
686 return
687 }
688
689 routes := h.configSvc.FindRoutesByImage(ctx, imageName)
690
691 response := make([]routeResponse, 0, len(routes))
692 for _, route := range routes {
693 response = append(response, toRouteResponse(route))
694 }
695
696 h.sendJSON(w, http.StatusOK, dto.RoutesByImageResponse{
697 Image: imageName,
698 Routes: response,
699 })
700}
701
702func (h *Handler) handleNetworks(w http.ResponseWriter, r *http.Request) {
703 if r.Method != http.MethodGet {
704 h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
705 return
706 }
707
708 ctx := r.Context()
709
710 if !HasAccess(ctx, domain.AdminResourceStatus, domain.AdminActionRead) {
711 h.sendError(w, http.StatusForbidden, "insufficient permissions for status:read")
712 return
713 }
714
715 networks, err := h.containerSvc.ListNetworks(ctx)
716 if err != nil {
717 h.sendError(w, http.StatusInternalServerError, "failed to list networks")
718 return
719 }
720
721 response := make([]dto.Network, 0, len(networks))
722 for _, network := range networks {
723 if network == nil {
724 continue
725 }
726 response = append(response, dto.Network{
727 Name: network.Name,
728 Driver: network.Driver,
729 Containers: append([]string{}, network.Containers...),
730 })
731 }
732
733 h.sendJSON(w, http.StatusOK, dto.NetworksResponse{Networks: response})
734}
735
736// handleSecrets handles /admin/secrets endpoints.
737func (h *Handler) handleSecrets(w http.ResponseWriter, r *http.Request, path string) {
738 // Parse path: /secrets/{domain} or /secrets/{domain}/{key}
739 parts := strings.Split(strings.TrimPrefix(path, "/secrets/"), "/")
740 if len(parts) == 0 || parts[0] == "" {
741 h.sendError(w, http.StatusBadRequest, "domain required")
742 return
743 }
744
745 secretDomain := parts[0]
746
747 // Check for attachment sub-path: /secrets/{domain}/attachments/{service}[/{key}]
748 if len(parts) >= 2 && parts[1] == "attachments" {
749 switch r.Method {
750 case http.MethodPost:
751 // Expected path: /secrets/{domain}/attachments/{service}
752 if len(parts) != 3 {
753 h.sendError(w, http.StatusBadRequest, "invalid attachment path: expected /secrets/{domain}/attachments/{service}")
754 return
755 }
756 service := parts[2]
757 h.handleAttachmentSecrets(w, r, secretDomain, service, "")
758 return
759 case http.MethodDelete:
760 // Expected path: /secrets/{domain}/attachments/{service}/{key}
761 if len(parts) != 4 {
762 h.sendError(w, http.StatusBadRequest, "invalid attachment path: expected /secrets/{domain}/attachments/{service}/{key}")
763 return
764 }
765 service := parts[2]
766 attachmentKey := parts[3]
767 h.handleAttachmentSecrets(w, r, secretDomain, service, attachmentKey)
768 return
769 default:
770 h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
771 return
772 }
773 }
774
775 secretKey := ""
776 if len(parts) > 1 {
777 secretKey = parts[1]
778 }
779
780 switch r.Method {
781 case http.MethodGet:
782 h.handleSecretsGet(w, r, secretDomain)
783 case http.MethodPost:
784 h.handleSecretsPost(w, r, secretDomain)
785 case http.MethodDelete:
786 h.handleSecretsDelete(w, r, secretDomain, secretKey)
787 default:
788 h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
789 }
790}
791
792// handleSecretsPost handles POST /admin/secrets/{domain} - set secrets.
793func (h *Handler) handleSecretsPost(w http.ResponseWriter, r *http.Request, secretDomain string) {
794 ctx := r.Context()
795 log := zerowrap.FromCtx(ctx)
796
797 if !HasAccess(ctx, domain.AdminResourceSecrets, domain.AdminActionWrite) {
798 h.sendError(w, http.StatusForbidden, "insufficient permissions for secrets:write")
799 return
800 }
801
802 r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestSize)
803
804 var data map[string]string
805 if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
806 log.Warn().Err(err).Msg("invalid secrets JSON")
807 h.sendError(w, http.StatusBadRequest, "invalid JSON")
808 return
809 }
810
811 if err := h.secretSvc.Set(ctx, secretDomain, data); err != nil {
812 log.Error().Err(err).Str("domain", secretDomain).Msg("failed to set secrets")
813 h.sendError(w, http.StatusBadRequest, "invalid domain")
814 return
815 }
816
817 log.Info().Str("domain", secretDomain).Int("count", len(data)).Msg("secrets set")
818 h.sendJSON(w, http.StatusOK, dto.SecretsStatusResponse{Status: "updated"})
819}
820
821// handleSecretsDelete handles DELETE /admin/secrets/{domain}/{key} - delete a secret.
822func (h *Handler) handleSecretsDelete(w http.ResponseWriter, r *http.Request, secretDomain, secretKey string) {
823 ctx := r.Context()
824 log := zerowrap.FromCtx(ctx)
825
826 if !HasAccess(ctx, domain.AdminResourceSecrets, domain.AdminActionWrite) {
827 h.sendError(w, http.StatusForbidden, "insufficient permissions for secrets:write")
828 return
829 }
830 if secretKey == "" {
831 h.sendError(w, http.StatusBadRequest, "key required in path")
832 return
833 }
834
835 if err := h.secretSvc.Delete(ctx, secretDomain, secretKey); err != nil {
836 log.Error().Err(err).Str("domain", secretDomain).Str("key", secretKey).Msg("failed to delete secret")
837 h.sendError(w, http.StatusBadRequest, "invalid domain")
838 return
839 }
840
841 log.Info().Str("domain", secretDomain).Str("key", secretKey).Msg("secret deleted")
842 h.sendJSON(w, http.StatusOK, dto.SecretsStatusResponse{Status: "deleted"})
843}
844
845// handleSecretsGet handles GET /admin/secrets/{domain} - list secrets.
846func (h *Handler) handleSecretsGet(w http.ResponseWriter, r *http.Request, secretDomain string) {
847 ctx := r.Context()
848 log := zerowrap.FromCtx(ctx)
849
850 // Check read permission
851 if !HasAccess(ctx, domain.AdminResourceSecrets, domain.AdminActionRead) {
852 h.sendError(w, http.StatusForbidden, "insufficient permissions for secrets:read")
853 return
854 }
855
856 // List secrets for domain (names only, not values) including attachments
857 keys, attachments, err := h.secretSvc.ListKeysWithAttachments(ctx, secretDomain)
858 if err != nil {
859 log.Error().Err(err).Str("domain", secretDomain).Msg("failed to list secrets")
860 h.sendError(w, http.StatusBadRequest, "invalid domain")
861 return
862 }
863
864 // Convert attachments to DTO format
865 var attachmentDTOs []dto.AttachmentSecretsResponse
866 for _, att := range attachments {
867 attachmentDTOs = append(attachmentDTOs, dto.AttachmentSecretsResponse{
868 Service: att.Service,
869 Keys: att.Keys,
870 })
871 }
872
873 h.sendJSON(w, http.StatusOK, dto.SecretsListResponse{
874 Domain: secretDomain,
875 Keys: keys,
876 Attachments: attachmentDTOs,
877 })
878}
879
880// handleAttachmentSecrets handles /admin/secrets/{domain}/attachments/{service}[/{key}] endpoints.
881func (h *Handler) handleAttachmentSecrets(w http.ResponseWriter, r *http.Request, secretDomain, service, key string) {
882 ctx := r.Context()
883 log := zerowrap.FromCtx(ctx)
884
885 switch r.Method {
886 case http.MethodPost:
887 if !HasAccess(ctx, domain.AdminResourceSecrets, domain.AdminActionWrite) {
888 h.sendError(w, http.StatusForbidden, "insufficient permissions for secrets:write")
889 return
890 }
891
892 r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestSize)
893
894 var data map[string]string
895 if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
896 log.Warn().Err(err).Msg("invalid attachment secrets JSON")
897 h.sendError(w, http.StatusBadRequest, "invalid JSON")
898 return
899 }
900
901 if err := h.secretSvc.SetAttachment(ctx, secretDomain, service, data); err != nil {
902 log.Error().Err(err).Str("domain", secretDomain).Str("service", service).Msg("failed to set attachment secrets")
903 h.sendError(w, http.StatusBadRequest, "invalid domain or service")
904 return
905 }
906
907 log.Info().Str("domain", secretDomain).Str("service", service).Int("count", len(data)).Msg("attachment secrets set")
908 h.sendJSON(w, http.StatusOK, dto.SecretsStatusResponse{Status: "updated"})
909
910 case http.MethodDelete:
911 if !HasAccess(ctx, domain.AdminResourceSecrets, domain.AdminActionWrite) {
912 h.sendError(w, http.StatusForbidden, "insufficient permissions for secrets:write")
913 return
914 }
915
916 if key == "" {
917 h.sendError(w, http.StatusBadRequest, "key required in path")
918 return
919 }
920
921 if err := h.secretSvc.DeleteAttachment(ctx, secretDomain, service, key); err != nil {
922 log.Error().Err(err).Str("domain", secretDomain).Str("service", service).Str("key", key).Msg("failed to delete attachment secret")
923 h.sendError(w, http.StatusBadRequest, "invalid domain or service")
924 return
925 }
926
927 log.Info().Str("domain", secretDomain).Str("service", service).Str("key", key).Msg("attachment secret deleted")
928 h.sendJSON(w, http.StatusOK, dto.SecretsStatusResponse{Status: "deleted"})
929
930 default:
931 h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
932 }
933}
934
935// handleHealth handles /admin/health endpoint.
936// Returns detailed health status for all routes including HTTP probe results.
937func (h *Handler) handleHealth(w http.ResponseWriter, r *http.Request) {
938 if r.Method != http.MethodGet {
939 h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
940 return
941 }
942
943 ctx := r.Context()
944
945 // Check read permission
946 if !HasAccess(ctx, domain.AdminResourceStatus, domain.AdminActionRead) {
947 h.sendError(w, http.StatusForbidden, "insufficient permissions for status:read")
948 return
949 }
950
951 health := h.healthSvc.CheckAllRoutes(ctx)
952
953 response := make(map[string]dto.HealthStatus, len(health))
954 for domain, healthStatus := range health {
955 if healthStatus == nil {
956 continue
957 }
958 response[domain] = dto.HealthStatus{
959 ContainerStatus: healthStatus.ContainerStatus,
960 HTTPStatus: healthStatus.HTTPStatus,
961 ResponseTimeMs: healthStatus.ResponseTimeMs,
962 Healthy: healthStatus.Healthy,
963 Error: healthStatus.Error,
964 }
965 }
966
967 h.sendJSON(w, http.StatusOK, dto.HealthResponse{Health: response})
968}
969
970// handleStatus handles /admin/status endpoint.
971func (h *Handler) handleStatus(w http.ResponseWriter, r *http.Request) {
972 if r.Method != http.MethodGet {
973 h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
974 return
975 }
976
977 ctx := r.Context()
978
979 // Check read permission
980 if !HasAccess(ctx, domain.AdminResourceStatus, domain.AdminActionRead) {
981 h.sendError(w, http.StatusForbidden, "insufficient permissions for status:read")
982 return
983 }
984
985 routes := h.configSvc.GetRoutes(ctx)
986
987 // Get container statuses
988 statuses := make(map[string]string)
989 for _, route := range routes {
990 status := "unknown"
991 container, ok := h.containerSvc.Get(ctx, route.Domain)
992 if ok && container != nil {
993 status = container.Status
994 }
995 statuses[route.Domain] = status
996 }
997
998 status := dto.StatusResponse{
999 Routes: len(routes),
1000 RegistryDomain: h.configSvc.GetRegistryDomain(),
1001 RegistryPort: h.configSvc.GetRegistryPort(),
1002 ServerPort: h.configSvc.GetServerPort(),
1003 AutoRoute: h.configSvc.IsAutoRouteEnabled(),
1004 NetworkIsolation: h.configSvc.IsNetworkIsolationEnabled(),
1005 ContainerStatuses: statuses,
1006 }
1007
1008 h.sendJSON(w, http.StatusOK, status)
1009}
1010
1011// handleBackups handles /admin/backups endpoints.
1012func (h *Handler) handleBackups(w http.ResponseWriter, r *http.Request, path string) {
1013 if h.backupSvc == nil {
1014 h.sendError(w, http.StatusServiceUnavailable, "backup service not available")
1015 return
1016 }
1017
1018 path = strings.TrimSuffix(path, "/")
1019
1020 if path == "/backups" || path == "/backups/status" {
1021 h.handleBackupsStatus(w, r)
1022 return
1023 }
1024
1025 suffix := strings.TrimPrefix(path, "/backups/")
1026 parts := strings.Split(suffix, "/")
1027 if len(parts) == 0 || parts[0] == "" {
1028 h.sendError(w, http.StatusBadRequest, "domain required in path")
1029 return
1030 }
1031
1032 backupDomain := parts[0]
1033 if len(parts) == 1 {
1034 h.handleBackupsDomain(w, r, backupDomain)
1035 return
1036 }
1037
1038 if len(parts) == 2 && parts[1] == "detect" {
1039 h.handleBackupsDetect(w, r, backupDomain)
1040 return
1041 }
1042
1043 h.sendError(w, http.StatusNotFound, "route not found")
1044}
1045
1046func (h *Handler) handleBackupsStatus(w http.ResponseWriter, r *http.Request) {
1047 ctx := r.Context()
1048 if r.Method != http.MethodGet {
1049 h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
1050 return
1051 }
1052 if !HasAccess(ctx, domain.AdminResourceStatus, domain.AdminActionRead) {
1053 h.sendError(w, http.StatusForbidden, "insufficient permissions for status:read")
1054 return
1055 }
1056
1057 jobs, err := h.backupSvc.Status(ctx)
1058 if err != nil {
1059 h.sendError(w, http.StatusInternalServerError, "failed to get backup status")
1060 return
1061 }
1062 h.sendJSON(w, http.StatusOK, dto.BackupsResponse{Backups: mapBackupJobsResponse(jobs)})
1063}
1064
1065func (h *Handler) handleBackupsDomain(w http.ResponseWriter, r *http.Request, backupDomain string) {
1066 switch r.Method {
1067 case http.MethodGet:
1068 h.handleBackupsDomainList(w, r, backupDomain)
1069 case http.MethodPost:
1070 h.handleBackupsDomainRun(w, r, backupDomain)
1071 default:
1072 h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
1073 }
1074}
1075
1076func (h *Handler) handleBackupsDomainList(w http.ResponseWriter, r *http.Request, backupDomain string) {
1077 ctx := r.Context()
1078 if !HasAccess(ctx, domain.AdminResourceStatus, domain.AdminActionRead) {
1079 h.sendError(w, http.StatusForbidden, "insufficient permissions for status:read")
1080 return
1081 }
1082
1083 jobs, err := h.backupSvc.ListBackups(ctx, backupDomain)
1084 if err != nil {
1085 h.sendError(w, http.StatusInternalServerError, "failed to list backups")
1086 return
1087 }
1088
1089 h.sendJSON(w, http.StatusOK, dto.BackupsResponse{Backups: mapBackupJobsResponse(jobs)})
1090}
1091
1092func (h *Handler) handleBackupsDomainRun(w http.ResponseWriter, r *http.Request, backupDomain string) {
1093 ctx := r.Context()
1094 if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionWrite) {
1095 h.sendError(w, http.StatusForbidden, "insufficient permissions for config:write")
1096 return
1097 }
1098
1099 var req dto.BackupRunRequest
1100 r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestSize)
1101 if err := json.NewDecoder(r.Body).Decode(&req); err != nil && !errors.Is(err, io.EOF) {
1102 h.sendError(w, http.StatusBadRequest, "invalid JSON")
1103 return
1104 }
1105
1106 result, err := h.backupSvc.RunBackup(ctx, backupDomain, req.DB)
1107 if err != nil {
1108 log := zerowrap.FromCtx(ctx)
1109 log.Error().Err(err).Str("domain", backupDomain).Msg("backup run failed")
1110 h.sendError(w, http.StatusInternalServerError, "failed to run backup")
1111 return
1112 }
1113 log := zerowrap.FromCtx(ctx)
1114 log.Info().Str("domain", backupDomain).Str("db", req.DB).Str("job_id", result.Job.ID).Msg("backup completed via admin API")
1115
1116 job := toBackupJobResponse(result.Job)
1117 h.sendJSON(w, http.StatusOK, dto.BackupRunResponse{
1118 Status: "completed",
1119 Backup: &job,
1120 })
1121}
1122
1123func (h *Handler) handleBackupsDetect(w http.ResponseWriter, r *http.Request, backupDomain string) {
1124 ctx := r.Context()
1125 if r.Method != http.MethodGet {
1126 h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
1127 return
1128 }
1129 if !HasAccess(ctx, domain.AdminResourceStatus, domain.AdminActionRead) {
1130 h.sendError(w, http.StatusForbidden, "insufficient permissions for status:read")
1131 return
1132 }
1133
1134 dbs, err := h.backupSvc.DetectDatabases(ctx, backupDomain)
1135 if err != nil {
1136 h.sendError(w, http.StatusInternalServerError, "failed to detect databases")
1137 return
1138 }
1139
1140 response := make([]dto.DatabaseInfo, 0, len(dbs))
1141 for _, db := range dbs {
1142 response = append(response, toDatabaseInfoResponse(db))
1143 }
1144
1145 h.sendJSON(w, http.StatusOK, dto.BackupDetectResponse{Databases: response})
1146}
1147
1148func mapBackupJobsResponse(jobs []domain.BackupJob) []dto.BackupJob {
1149 response := make([]dto.BackupJob, 0, len(jobs))
1150 for _, job := range jobs {
1151 response = append(response, toBackupJobResponse(job))
1152 }
1153 return response
1154}
1155
1156// handleReload handles /admin/reload endpoint.
1157// This reloads configuration from file into memory and triggers container sync.
1158func (h *Handler) handleReload(w http.ResponseWriter, r *http.Request) {
1159 if r.Method != http.MethodPost {
1160 h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
1161 return
1162 }
1163
1164 ctx := r.Context()
1165 log := zerowrap.FromCtx(ctx)
1166
1167 // Check write permission (reload modifies state)
1168 if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionWrite) {
1169 h.sendError(w, http.StatusForbidden, "insufficient permissions for config:write")
1170 return
1171 }
1172
1173 // Reload configuration from file into memory
1174 // Using Reload() instead of Load() to ensure we re-read the file from disk
1175 if err := h.configSvc.Reload(ctx); err != nil {
1176 log.Error().Err(err).Msg("failed to reload config")
1177 h.sendError(w, http.StatusInternalServerError, "failed to reload config")
1178 return
1179 }
1180
1181 // Publish manual reload event to sync containers
1182 // ManualReloadHandler starts missing containers without restarting running ones
1183 if h.eventBus != nil {
1184 if err := h.eventBus.Publish(domain.EventManualReload, nil); err != nil {
1185 log.Warn().Err(err).Msg("failed to publish manual reload event")
1186 }
1187 }
1188
1189 log.Info().Msg("config reloaded via admin API")
1190 h.sendJSON(w, http.StatusOK, dto.ReloadResponse{Status: "reloaded"})
1191}
1192
1193// handleConfig handles /admin/config endpoint.
1194func (h *Handler) handleConfig(w http.ResponseWriter, r *http.Request) {
1195 if r.Method != http.MethodGet {
1196 h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
1197 return
1198 }
1199
1200 ctx := r.Context()
1201
1202 // Check read permission
1203 if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionRead) {
1204 h.sendError(w, http.StatusForbidden, "insufficient permissions for config:read")
1205 return
1206 }
1207
1208 routes := h.configSvc.GetRoutes(ctx)
1209 routeResponses := make([]dto.Route, 0, len(routes))
1210 for _, route := range routes {
1211 routeResponses = append(routeResponses, toRouteResponse(route))
1212 }
1213
1214 externalRoutes := h.configSvc.GetExternalRoutes()
1215 externalResponses := make([]dto.ExternalRoute, 0, len(externalRoutes))
1216 for domain, target := range externalRoutes {
1217 externalResponses = append(externalResponses, dto.ExternalRoute{
1218 Domain: domain,
1219 Target: target,
1220 })
1221 }
1222
1223 config := dto.ConfigResponse{
1224 Server: dto.ServerConfig{
1225 Port: h.configSvc.GetServerPort(),
1226 RegistryPort: h.configSvc.GetRegistryPort(),
1227 RegistryDomain: h.configSvc.GetRegistryDomain(),
1228 DataDir: h.configSvc.GetDataDir(),
1229 },
1230 AutoRoute: dto.AutoRouteConfig{
1231 Enabled: h.configSvc.IsAutoRouteEnabled(),
1232 },
1233 NetworkIsolation: dto.NetworkIsolationConfig{
1234 Enabled: h.configSvc.IsNetworkIsolationEnabled(),
1235 Prefix: h.configSvc.GetNetworkPrefix(),
1236 },
1237 Routes: routeResponses,
1238 ExternalRoutes: externalResponses,
1239 }
1240
1241 h.sendJSON(w, http.StatusOK, config)
1242}
1243
1244// handleDeploy handles /admin/deploy/:domain endpoint.
1245// POST triggers a deployment for the specified domain.
1246func (h *Handler) handleDeploy(w http.ResponseWriter, r *http.Request, path string) {
1247 if r.Method != http.MethodPost {
1248 h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
1249 return
1250 }
1251
1252 ctx := r.Context()
1253 log := zerowrap.FromCtx(ctx)
1254
1255 // Check write permission
1256 if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionWrite) {
1257 h.sendError(w, http.StatusForbidden, "insufficient permissions for config:write")
1258 return
1259 }
1260
1261 // Parse domain from path
1262 deployDomain := strings.TrimPrefix(path, "/deploy/")
1263 if deployDomain == "" || deployDomain == "/deploy" {
1264 h.sendError(w, http.StatusBadRequest, "domain required in path")
1265 return
1266 }
1267 if err := validation.ValidateDomainParam(deployDomain); err != nil {
1268 h.sendError(w, http.StatusBadRequest, "invalid domain")
1269 return
1270 }
1271
1272 // Get the route for this domain
1273 route, err := h.configSvc.GetRoute(ctx, deployDomain)
1274 if err != nil {
1275 h.sendError(w, http.StatusNotFound, "route not found")
1276 return
1277 }
1278
1279 // Deploy is an internal server-side action: pull from local registry path.
1280 container, err := h.containerSvc.Deploy(domain.WithInternalDeploy(ctx), *route)
1281 if err != nil {
1282 log.Error().Err(err).Str("domain", deployDomain).Msg("failed to deploy container")
1283 var deployErr *domain.DeployFailureError
1284 if errors.As(err, &deployErr) {
1285 h.sendJSON(w, http.StatusInternalServerError, dto.DeployErrorResponse{
1286 Error: deployErr.Error(),
1287 Cause: deployErr.Cause,
1288 Hint: deployErr.Hint,
1289 Logs: deployErr.Logs,
1290 })
1291 return
1292 }
1293 h.sendError(w, http.StatusInternalServerError, "failed to deploy container")
1294 return
1295 }
1296
1297 // Clear deploy event suppression now that the explicit deploy has completed.
1298 // This re-enables event-based deploys for future direct docker pushes.
1299 if route.Image != "" && h.registrySvc != nil {
1300 // Use the registry package's image name normaliser so digest-form refs
1301 // and multi-segment paths are handled correctly.
1302 imageName := registry.ExtractImageName(route.Image)
1303 h.registrySvc.ClearDeployEventSuppression(imageName)
1304 }
1305
1306 log.Info().Str("domain", deployDomain).Str("container_id", container.ID).Msg("container deployed via admin API")
1307 h.sendJSON(w, http.StatusOK, dto.DeployResponse{
1308 Status: "deployed",
1309 ContainerID: container.ID,
1310 Domain: deployDomain,
1311 })
1312}
1313
1314// handleRestart handles /admin/restart/:domain endpoint.
1315func (h *Handler) handleRestart(w http.ResponseWriter, r *http.Request, path string) {
1316 if r.Method != http.MethodPost {
1317 h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
1318 return
1319 }
1320
1321 ctx := r.Context()
1322 log := zerowrap.FromCtx(ctx)
1323
1324 if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionWrite) {
1325 h.sendError(w, http.StatusForbidden, "insufficient permissions for config:write")
1326 return
1327 }
1328
1329 restartDomain := strings.TrimPrefix(path, "/restart/")
1330 if restartDomain == "" || restartDomain == "/restart" {
1331 h.sendError(w, http.StatusBadRequest, "domain required in path")
1332 return
1333 }
1334 if err := validation.ValidateDomainParam(restartDomain); err != nil {
1335 h.sendError(w, http.StatusBadRequest, "invalid domain")
1336 return
1337 }
1338
1339 withAttachments := r.URL.Query().Get("attachments") == "true"
1340
1341 // Use a detached context so the restart completes even if the HTTP client
1342 // disconnects. Podman restart can take 30+ seconds (SIGTERM + wait + start).
1343 restartCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 2*time.Minute)
1344 defer cancel()
1345
1346 if err := h.containerSvc.Restart(restartCtx, restartDomain, withAttachments); err != nil {
1347 log.Error().Err(err).Str("domain", restartDomain).Msg("failed to restart container")
1348 if errors.Is(err, domain.ErrContainerNotFound) {
1349 h.sendError(w, http.StatusNotFound, "container not found")
1350 return
1351 }
1352 h.sendError(w, http.StatusInternalServerError, "failed to restart container")
1353 return
1354 }
1355
1356 log.Info().Str("domain", restartDomain).Bool("with_attachments", withAttachments).Msg("container restarted via admin API")
1357 h.sendJSON(w, http.StatusOK, dto.RestartResponse{
1358 Status: "restarted",
1359 Domain: restartDomain,
1360 })
1361}
1362
1363// handleTags handles /admin/tags/:repository endpoint.
1364func (h *Handler) handleTags(w http.ResponseWriter, r *http.Request, path string) {
1365 if r.Method != http.MethodGet {
1366 h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
1367 return
1368 }
1369
1370 ctx := r.Context()
1371
1372 if !HasAccess(ctx, domain.AdminResourceStatus, domain.AdminActionRead) {
1373 h.sendError(w, http.StatusForbidden, "insufficient permissions for status:read")
1374 return
1375 }
1376
1377 repository := strings.TrimPrefix(path, "/tags/")
1378 if repository == "" || repository == "/tags" {
1379 h.sendError(w, http.StatusBadRequest, "repository name required in path")
1380 return
1381 }
1382 if err := validation.ValidateRepositoryName(repository); err != nil {
1383 h.sendError(w, http.StatusBadRequest, "invalid repository name")
1384 return
1385 }
1386
1387 if h.registrySvc == nil {
1388 h.sendError(w, http.StatusServiceUnavailable, "registry service not available")
1389 return
1390 }
1391
1392 tags, err := h.registrySvc.ListTags(ctx, repository)
1393 if err != nil {
1394 if errors.Is(err, registry.ErrRepositoryNotFound) {
1395 h.sendError(w, http.StatusNotFound, "repository not found")
1396 return
1397 }
1398 h.sendError(w, http.StatusInternalServerError, "failed to list tags")
1399 return
1400 }
1401
1402 h.sendJSON(w, http.StatusOK, dto.RepositoryTagsResponse{
1403 Repository: repository,
1404 Tags: tags,
1405 })
1406}
1407
1408// handleLogs handles /admin/logs endpoints.
1409// GET /admin/logs - Gordon process logs
1410// GET /admin/logs/:domain - Container logs for a specific domain
1411func (h *Handler) handleLogs(w http.ResponseWriter, r *http.Request, path string) {
1412 if r.Method != http.MethodGet {
1413 h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
1414 return
1415 }
1416
1417 ctx := r.Context()
1418 log := zerowrap.FromCtx(ctx)
1419
1420 // Check read permission
1421 if !HasAccess(ctx, domain.AdminResourceStatus, domain.AdminActionRead) {
1422 h.sendError(w, http.StatusForbidden, "insufficient permissions for status:read")
1423 return
1424 }
1425
1426 // Check if LogService is available
1427 if h.logSvc == nil {
1428 h.sendError(w, http.StatusServiceUnavailable, "log service not available")
1429 return
1430 }
1431
1432 // Parse query parameters
1433 lines := 50 // default
1434 if linesStr := r.URL.Query().Get("lines"); linesStr != "" {
1435 if n, err := strconv.Atoi(linesStr); err == nil && n > 0 {
1436 lines = n
1437 }
1438 }
1439 if lines > maxLogLines {
1440 lines = maxLogLines
1441 }
1442 follow := r.URL.Query().Get("follow") == "true"
1443
1444 // Parse domain from path
1445 logDomain := strings.TrimPrefix(path, "/logs/")
1446 if logDomain == "/logs" {
1447 logDomain = ""
1448 }
1449
1450 if logDomain != "" {
1451 if err := validation.ValidateDomainParam(logDomain); err != nil {
1452 h.sendError(w, http.StatusBadRequest, "invalid domain")
1453 return
1454 }
1455 }
1456
1457 if logDomain == "" {
1458 // Gordon process logs
1459 h.handleProcessLogs(w, r, lines, follow)
1460 } else {
1461 // Container logs
1462 h.handleContainerLogs(w, r, logDomain, lines, follow)
1463 }
1464
1465 // Prevent unused variable warning when follow is implemented
1466 _ = log
1467}
1468
1469// handleProcessLogs handles Gordon process logs.
1470func (h *Handler) handleProcessLogs(w http.ResponseWriter, r *http.Request, lines int, follow bool) {
1471 ctx := r.Context()
1472 log := zerowrap.FromCtx(ctx)
1473
1474 if follow {
1475 // SSE streaming
1476 h.streamProcessLogs(w, r, lines)
1477 return
1478 }
1479
1480 // Return last N lines as JSON
1481 logLines, err := h.logSvc.GetProcessLogs(ctx, lines)
1482 if err != nil {
1483 log.Warn().Err(err).Msg("failed to get process logs")
1484 h.sendError(w, http.StatusInternalServerError, "failed to get logs")
1485 return
1486 }
1487
1488 h.sendJSON(w, http.StatusOK, dto.ProcessLogsResponse{Lines: logLines})
1489}
1490
1491// handleContainerLogs handles container logs for a specific domain.
1492func (h *Handler) handleContainerLogs(w http.ResponseWriter, r *http.Request, logDomain string, lines int, follow bool) {
1493 ctx := r.Context()
1494 log := zerowrap.FromCtx(ctx)
1495
1496 if follow {
1497 // SSE streaming
1498 h.streamContainerLogs(w, r, logDomain, lines)
1499 return
1500 }
1501
1502 // Return last N lines as JSON
1503 logLines, err := h.logSvc.GetContainerLogs(ctx, logDomain, lines)
1504 if err != nil {
1505 log.Warn().Err(err).Str("domain", logDomain).Msg("failed to get container logs")
1506 h.sendError(w, http.StatusInternalServerError, "failed to get container logs")
1507 return
1508 }
1509
1510 h.sendJSON(w, http.StatusOK, dto.ContainerLogsResponse{
1511 Domain: logDomain,
1512 Lines: logLines,
1513 })
1514}
1515
1516// streamProcessLogs streams Gordon process logs via SSE.
1517func (h *Handler) streamProcessLogs(w http.ResponseWriter, r *http.Request, lines int) {
1518 ctx := r.Context()
1519 log := zerowrap.FromCtx(ctx)
1520
1521 // Check for flusher support before setting up SSE
1522 flusher, ok := w.(http.Flusher)
1523 if !ok {
1524 h.sendError(w, http.StatusInternalServerError, "streaming not supported")
1525 return
1526 }
1527
1528 // Set up SSE
1529 w.Header().Set("Content-Type", "text/event-stream")
1530 w.Header().Set("Cache-Control", "no-cache")
1531 w.Header().Set("Connection", "keep-alive")
1532 w.WriteHeader(http.StatusOK)
1533
1534 ch, err := h.logSvc.FollowProcessLogs(ctx, lines)
1535 if err != nil {
1536 log.Warn().Err(err).Msg("failed to follow process logs")
1537 _, _ = fmt.Fprintf(w, "event: error\ndata: failed to stream logs\n\n")
1538 flusher.Flush()
1539 return
1540 }
1541
1542 for {
1543 select {
1544 case line, ok := <-ch:
1545 if !ok {
1546 return
1547 }
1548 // SECURITY: Normalize line endings and escape newlines in log lines to prevent SSE event injection.
1549 // First normalize CRLF (\r\n) and CR (\r) to LF (\n), then escape newlines.
1550 // Per the SSE spec, multi-line data must use separate "data:" prefixes.
1551 normalized := strings.ReplaceAll(line, "\r\n", "\n")
1552 normalized = strings.ReplaceAll(normalized, "\r", "\n")
1553 escaped := strings.ReplaceAll(normalized, "\n", "\ndata: ")
1554 _, _ = fmt.Fprintf(w, "data: %s\n\n", escaped)
1555 flusher.Flush()
1556 case <-ctx.Done():
1557 return
1558 }
1559 }
1560}
1561
1562// streamContainerLogs streams container logs via SSE.
1563func (h *Handler) streamContainerLogs(w http.ResponseWriter, r *http.Request, logDomain string, lines int) {
1564 ctx := r.Context()
1565 log := zerowrap.FromCtx(ctx)
1566
1567 // Check for flusher support before setting up SSE
1568 flusher, ok := w.(http.Flusher)
1569 if !ok {
1570 h.sendError(w, http.StatusInternalServerError, "streaming not supported")
1571 return
1572 }
1573
1574 // Set up SSE
1575 w.Header().Set("Content-Type", "text/event-stream")
1576 w.Header().Set("Cache-Control", "no-cache")
1577 w.Header().Set("Connection", "keep-alive")
1578 w.WriteHeader(http.StatusOK)
1579
1580 ch, err := h.logSvc.FollowContainerLogs(ctx, logDomain, lines)
1581 if err != nil {
1582 log.Warn().Err(err).Str("domain", logDomain).Msg("failed to follow container logs")
1583 _, _ = fmt.Fprintf(w, "event: error\ndata: failed to stream container logs\n\n")
1584 flusher.Flush()
1585 return
1586 }
1587
1588 for {
1589 select {
1590 case line, ok := <-ch:
1591 if !ok {
1592 return
1593 }
1594 // SECURITY: Normalize line endings and escape newlines in log lines to prevent SSE event injection.
1595 // First normalize CRLF (\r\n) and CR (\r) to LF (\n), then escape newlines.
1596 // Per the SSE spec, multi-line data must use separate "data:" prefixes.
1597 normalized := strings.ReplaceAll(line, "\r\n", "\n")
1598 normalized = strings.ReplaceAll(normalized, "\r", "\n")
1599 escaped := strings.ReplaceAll(normalized, "\n", "\ndata: ")
1600 _, _ = fmt.Fprintf(w, "data: %s\n\n", escaped)
1601 flusher.Flush()
1602 case <-ctx.Done():
1603 return
1604 }
1605 }
1606}
1607
1608// handleAttachmentsConfig handles /admin/attachments endpoints for config-level attachments.
1609func (h *Handler) handleAttachmentsConfig(w http.ResponseWriter, r *http.Request, path string) {
1610 // Parse target (domain or group) from path
1611 target := strings.TrimPrefix(path, "/attachments/")
1612 if target == "/attachments" {
1613 target = ""
1614 }
1615
1616 switch r.Method {
1617 case http.MethodGet:
1618 h.handleAttachmentsConfigGet(w, r, target)
1619 case http.MethodPost:
1620 h.handleAttachmentsConfigPost(w, r, target)
1621 case http.MethodDelete:
1622 h.handleAttachmentsConfigDelete(w, r, target)
1623 default:
1624 h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
1625 }
1626}
1627
1628func (h *Handler) handleAttachmentsConfigGet(w http.ResponseWriter, r *http.Request, target string) {
1629 ctx := r.Context()
1630
1631 // Check read permission
1632 if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionRead) {
1633 h.sendError(w, http.StatusForbidden, "insufficient permissions for config:read")
1634 return
1635 }
1636
1637 if target == "" {
1638 // List all attachments
1639 attachments := h.configSvc.GetAllAttachments(ctx)
1640 h.sendJSON(w, http.StatusOK, dto.AttachmentsConfigResponse{Attachments: attachments})
1641 return
1642 }
1643
1644 // List attachments for specific target
1645 images, err := h.configSvc.GetAttachmentsFor(ctx, target)
1646 if err != nil {
1647 if errors.Is(err, domain.ErrAttachmentNotFound) {
1648 h.sendError(w, http.StatusNotFound, "no attachments found for target")
1649 return
1650 }
1651 h.sendError(w, http.StatusInternalServerError, "failed to get attachments")
1652 return
1653 }
1654
1655 h.sendJSON(w, http.StatusOK, dto.AttachmentConfigResponse{Target: target, Images: images})
1656}
1657
1658func (h *Handler) handleAttachmentsConfigPost(w http.ResponseWriter, r *http.Request, target string) {
1659 ctx := r.Context()
1660 log := zerowrap.FromCtx(ctx)
1661
1662 // Check write permission
1663 if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionWrite) {
1664 h.sendError(w, http.StatusForbidden, "insufficient permissions for config:write")
1665 return
1666 }
1667
1668 if target == "" {
1669 h.sendError(w, http.StatusBadRequest, "target (domain or group) required in path")
1670 return
1671 }
1672
1673 // Limit request body size
1674 r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestSize)
1675
1676 var req dto.AttachmentAddRequest
1677 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
1678 log.Warn().Err(err).Msg("invalid attachment JSON")
1679 h.sendError(w, http.StatusBadRequest, "invalid JSON")
1680 return
1681 }
1682
1683 if err := h.configSvc.AddAttachment(ctx, target, req.Image); err != nil {
1684 log.Error().Err(err).Str("target", target).Str("image", req.Image).Msg("failed to add attachment")
1685 switch {
1686 case errors.Is(err, domain.ErrAttachmentExists):
1687 h.sendError(w, http.StatusConflict, "attachment already exists")
1688 case errors.Is(err, domain.ErrAttachmentImageEmpty):
1689 h.sendError(w, http.StatusBadRequest, err.Error())
1690 case errors.Is(err, domain.ErrAttachmentTargetEmpty):
1691 h.sendError(w, http.StatusBadRequest, err.Error())
1692 default:
1693 h.sendError(w, http.StatusInternalServerError, "failed to add attachment")
1694 }
1695 return
1696 }
1697
1698 log.Info().Str("target", target).Str("image", req.Image).Msg("attachment added")
1699 h.sendJSON(w, http.StatusCreated, dto.AttachmentStatusResponse{Status: "added"})
1700}
1701
1702func (h *Handler) handleAttachmentsConfigDelete(w http.ResponseWriter, r *http.Request, target string) {
1703 ctx := r.Context()
1704 log := zerowrap.FromCtx(ctx)
1705
1706 // Check write permission
1707 if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionWrite) {
1708 h.sendError(w, http.StatusForbidden, "insufficient permissions for config:write")
1709 return
1710 }
1711
1712 if target == "" {
1713 h.sendError(w, http.StatusBadRequest, "target (domain or group) required in path")
1714 return
1715 }
1716
1717 // Parse image from path: /attachments/{target}/{image}
1718 // target at this point contains "{domain}/{image}" or just "{domain}"
1719 parts := strings.SplitN(target, "/", 2)
1720 if len(parts) != 2 {
1721 h.sendError(w, http.StatusBadRequest, "image required in path: /attachments/{target}/{image}")
1722 return
1723 }
1724
1725 domainOrGroup := parts[0]
1726 image := parts[1]
1727
1728 if err := h.configSvc.RemoveAttachment(ctx, domainOrGroup, image); err != nil {
1729 log.Error().Err(err).Str("target", domainOrGroup).Str("image", image).Msg("failed to remove attachment")
1730 switch {
1731 case errors.Is(err, domain.ErrAttachmentNotFound):
1732 h.sendError(w, http.StatusNotFound, "attachment not found")
1733 case errors.Is(err, domain.ErrAttachmentImageEmpty):
1734 h.sendError(w, http.StatusBadRequest, err.Error())
1735 case errors.Is(err, domain.ErrAttachmentTargetEmpty):
1736 h.sendError(w, http.StatusBadRequest, err.Error())
1737 default:
1738 h.sendError(w, http.StatusInternalServerError, "failed to remove attachment")
1739 }
1740 return
1741 }
1742
1743 log.Info().Str("target", domainOrGroup).Str("image", image).Msg("attachment removed")
1744 h.sendJSON(w, http.StatusOK, dto.AttachmentStatusResponse{Status: "removed"})
1745}
1746
1747func (h *Handler) handleAutoRouteAllowedDomains(w http.ResponseWriter, r *http.Request, path string) {
1748 switch r.Method {
1749 case http.MethodGet:
1750 h.handleAutoRouteAllowedDomainsGet(w, r)
1751 case http.MethodPost:
1752 h.handleAutoRouteAllowedDomainsPost(w, r)
1753 case http.MethodDelete:
1754 h.handleAutoRouteAllowedDomainsDelete(w, r, path)
1755 default:
1756 h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
1757 }
1758}
1759
1760func (h *Handler) handleAutoRouteAllowedDomainsGet(w http.ResponseWriter, r *http.Request) {
1761 ctx := r.Context()
1762 if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionRead) {
1763 h.sendError(w, http.StatusForbidden, "insufficient permissions for config:read")
1764 return
1765 }
1766
1767 domains, err := h.configSvc.GetAutoRouteAllowedDomains(ctx)
1768 if err != nil {
1769 h.sendError(w, http.StatusInternalServerError, "failed to get auto-route allowed domains")
1770 return
1771 }
1772
1773 h.sendJSON(w, http.StatusOK, dto.AutoRouteAllowedDomainsResponse{Domains: domains})
1774}
1775
1776func (h *Handler) handleAutoRouteAllowedDomainsPost(w http.ResponseWriter, r *http.Request) {
1777 ctx := r.Context()
1778 log := zerowrap.FromCtx(ctx)
1779 if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionWrite) {
1780 h.sendError(w, http.StatusForbidden, "insufficient permissions for config:write")
1781 return
1782 }
1783
1784 r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestSize)
1785 var req dto.AutoRouteAllowedDomainRequest
1786 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
1787 log.Warn().Err(err).Msg("invalid auto-route allowlist JSON")
1788 h.sendError(w, http.StatusBadRequest, "invalid JSON")
1789 return
1790 }
1791
1792 if err := h.configSvc.AddAutoRouteAllowedDomain(ctx, req.Pattern); err != nil {
1793 if errors.Is(err, domain.ErrInvalidDomainPattern) {
1794 h.sendError(w, http.StatusBadRequest, err.Error())
1795 } else {
1796 log.Error().Err(err).Str("pattern", req.Pattern).Msg("failed to add auto-route allowed domain")
1797 h.sendError(w, http.StatusInternalServerError, "failed to add auto-route allowed domain")
1798 }
1799 return
1800 }
1801
1802 h.sendJSON(w, http.StatusCreated, dto.AutoRouteStatusResponse{Status: "added"})
1803}
1804
1805func (h *Handler) handleAutoRouteAllowedDomainsDelete(w http.ResponseWriter, r *http.Request, path string) {
1806 ctx := r.Context()
1807 log := zerowrap.FromCtx(ctx)
1808 if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionWrite) {
1809 h.sendError(w, http.StatusForbidden, "insufficient permissions for config:write")
1810 return
1811 }
1812
1813 raw := strings.TrimPrefix(path, "/autoroute/allowed-domains/")
1814 if raw == path || raw == "" || raw == "/" {
1815 h.sendError(w, http.StatusBadRequest, "missing domain pattern")
1816 return
1817 }
1818 pattern, err := url.PathUnescape(raw)
1819 if err != nil || strings.TrimSpace(pattern) == "" {
1820 h.sendError(w, http.StatusBadRequest, "invalid domain pattern")
1821 return
1822 }
1823
1824 if err := h.configSvc.RemoveAutoRouteAllowedDomain(ctx, pattern); err != nil {
1825 log.Error().Err(err).Str("pattern", pattern).Msg("failed to remove auto-route allowed domain")
1826 h.sendError(w, http.StatusInternalServerError, "failed to remove auto-route allowed domain")
1827 return
1828 }
1829
1830 h.sendJSON(w, http.StatusOK, dto.AutoRouteStatusResponse{Status: "removed"})
1831}
1832
1833// handleAuthVerify handles /admin/auth/verify endpoint.
1834// Validates authentication session and returns token status.
1835func (h *Handler) handleAuthVerify(w http.ResponseWriter, r *http.Request) {
1836 ctx := r.Context()
1837
1838 // Only allow GET method
1839 if r.Method != http.MethodGet {
1840 w.Header().Set("Allow", http.MethodGet)
1841 h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
1842 return
1843 }
1844
1845 // Call use case to get auth status
1846 status, err := h.authSvc.GetAuthStatus(ctx)
1847 if err != nil {
1848 h.sendError(w, http.StatusInternalServerError, "failed to get auth status")
1849 return
1850 }
1851
1852 // Convert domain.AuthStatus to DTO
1853 response := dto.AuthVerifyResponse{
1854 Valid: status.Valid,
1855 Subject: status.Subject,
1856 Scopes: status.Scopes,
1857 ExpiresAt: status.ExpiresAt,
1858 IssuedAt: status.IssuedAt,
1859 }
1860
1861 h.sendJSON(w, http.StatusOK, response)
1862}