package httpapi import ( "context" "encoding/json" "fmt" "log/slog" "github.com/dtoro/oikos/internal/core/app" "github.com/dtoro/oikos/internal/core/domain" "github.com/dtoro/oikos/internal/httpapi/gen" "github.com/dtoro/oikos/internal/safego" "github.com/google/uuid" ) // ─── Approvals ───────────────────────────────────────────────────────── func (s *Server) ListApprovals(ctx context.Context, req gen.ListApprovalsRequestObject) (gen.ListApprovalsResponseObject, error) { limit := clampLimit(req.Params.Limit) var status *string if req.Params.Status != nil { st := string(*req.Params.Status) status = &st } var kind *string if req.Params.Kind != nil { k := string(*req.Params.Kind) kind = &k } rows, err := s.pool.Query(ctx, ` SELECT a.entity_id, a.action, a.risk_class, a.kind, a.payload, a.status, a.expires_at, a.decided_at, a.decided_by::text, a.created_at, e.slug FROM approvals a JOIN entities e ON e.id = COALESCE(a.subject_entity_id, a.entity_id) WHERE ($1::text IS NULL OR a.status = $1) AND ($2::text IS NULL OR a.kind = $2) AND ($3::text IS NULL OR e.slug > $3) ORDER BY e.slug LIMIT $4`, status, kind, req.Params.Cursor, limit+1) if err != nil { return nil, err } defer rows.Close() items := []gen.Approval{} for rows.Next() { var a gen.Approval var payloadBytes []byte var decidedBy *string if err := rows.Scan(&a.Id, &a.Action, &a.RiskClass, &a.Kind, &payloadBytes, &a.Status, &a.ExpiresAt, &a.DecidedAt, &decidedBy, &a.CreatedAt, &a.Slug); err != nil { return nil, err } a.DecidedBy = decidedBy var payload map[string]any if len(payloadBytes) > 0 && json.Unmarshal(payloadBytes, &payload) == nil { a.Payload = &payload } items = append(items, a) } if rows.Err() != nil { return nil, rows.Err() } var next *string if len(items) > limit { items = items[:limit] next = &items[len(items)-1].Slug } if items == nil { items = []gen.Approval{} } return gen.ListApprovals200JSONResponse{Items: items, NextCursor: next}, nil } // DecideApproval is the thin presenter over ApprovalService.Decide: it maps // the request onto the service command, then dispatches the SSH work for an // approved execution in a background goroutine. The HMAC/window/execution // un-gating logic lives in the service + repository (Phase 9 convergence). func (s *Server) DecideApproval(ctx context.Context, req gen.DecideApprovalRequestObject) (gen.DecideApprovalResponseObject, error) { if req.Body == nil { return nil, fmt.Errorf("%w: request body is required", domain.ErrInvalidInput) } id, err := s.resolveEntityID(ctx, req.Id) if err != nil { return nil, err } _, actor := actorInfo(ctx) var token string if req.Body.Token != nil { token = *req.Body.Token } result, err := s.approvalSvc.Decide(ctx, app.ApprovalDecideCmd{ ApprovalID: domain.UUID(id.String()), Token: token, Decision: string(req.Body.Decision), Actor: actor, }) if err != nil { return nil, err } // Approve: dispatch the SSH work the repo just un-gated, in the // background so the HTTP response is not blocked by the execution. if result.Resume != nil { res := result.Resume safego.Go("httpapi:executeApprovedAction", func() { s.executeApprovedAction(context.Background(), s.pool, uuid.MustParse(string(res.ExecutionID)), res.TargetSlug, res.Action) }) slog.Info("httpapi: approved execution queued", "execution_id", res.ExecutionID, "target", res.TargetSlug, "action", res.Action) } return gen.DecideApproval200JSONResponse(approvalToGenApproval(result.Approval)), nil } // approvalToGenApproval maps the domain approval onto the wire shape. The // subject slug is not carried on the domain type, so it is left empty on the // decide path. func approvalToGenApproval(a domain.Approval) gen.Approval { app := gen.Approval{ Id: uuid.MustParse(string(a.EntityID)), Action: a.Action, RiskClass: a.RiskClass, Kind: gen.ApprovalKind(a.Kind), Status: gen.ApprovalStatus(a.Status), ExpiresAt: a.ExpiresAt, DecidedAt: a.DecidedAt, CreatedAt: a.CreatedAt, } if a.DecidedBy != "" { u := uuid.MustParse(string(a.DecidedBy)) str := u.String() app.DecidedBy = &str } if len(a.Payload) > 0 { payload := map[string]any(a.Payload) app.Payload = &payload } return app }