// Package httpapi exposes the per_diem_rates admin surface. // Content negotiation: Accept: text/html → HTML template, else JSON. package httpapi import ( "context" "encoding/json" "errors" "fmt" "html/template" "log/slog" "net/http" "strings" "time" "basicstack.de/proofedmesh/services/admin-api/internal/attest" "basicstack.de/proofedmesh/services/admin-api/internal/auth" "basicstack.de/proofedmesh/services/admin-api/internal/model" "basicstack.de/proofedmesh/services/admin-api/internal/store" ) // Dependencies holds the pieces the handlers need; constructed in main. type Dependencies struct { Store store.RateStore Templates *template.Template Now func() time.Time // Attest is optional. When set, successful Create and Retire call it to // sign a rate_change attestation and submit it to the Transparency Log, // then persist the attestation ref on the row. When nil (e.g. in unit // tests or in a bare dev run), handlers skip the attestation step and // log a warning; the row stays "attestation pending" and a reconciler // catches it later. Attest *attest.Orchestrator // Issuer identifies the OIDC issuer for the admin principal, used only // when constructing attestation events. Defaults to Pocket ID's issuer. Issuer string Logger *slog.Logger } // Mount registers all rate handlers on the given mux. Admin auth middleware // is applied by the caller in main.go — handlers themselves assume the // request carries an authenticated admin principal in ctx. func Mount(mux *http.ServeMux, deps Dependencies) { mux.HandleFunc("GET /admin/per-diem-rates/{$}", deps.List) mux.HandleFunc("POST /admin/per-diem-rates/{$}", deps.Create) mux.HandleFunc("GET /admin/per-diem-rates/{rate_id}", deps.Get) mux.HandleFunc("POST /admin/per-diem-rates/{rate_id}/retire", deps.Retire) } func (d Dependencies) List(w http.ResponseWriter, r *http.Request) { ctx := r.Context() f := store.ListFilter{ Country: r.URL.Query().Get("country"), ActiveOnly: r.URL.Query().Get("active_only") == "true", } rates, err := d.Store.List(ctx, f) if err != nil { writeErr(w, r, http.StatusInternalServerError, err) return } writeOK(w, r, http.StatusOK, map[string]any{ "rates": rates, "filter": f, }, "per_diem_rates_list.html") } func (d Dependencies) Get(w http.ResponseWriter, r *http.Request) { ctx := r.Context() rateID := r.PathValue("rate_id") rate, err := d.Store.Get(ctx, rateID) if err != nil { if errors.Is(err, store.ErrNotFound) { writeErr(w, r, http.StatusNotFound, err) return } writeErr(w, r, http.StatusInternalServerError, err) return } writeOK(w, r, http.StatusOK, map[string]any{"rate": rate}, "per_diem_rates_detail.html") } func (d Dependencies) Create(w http.ResponseWriter, r *http.Request) { ctx := r.Context() in, err := parseNewRate(r) if err != nil { writeErr(w, r, http.StatusBadRequest, err) return } if err := in.Validate(); err != nil { writeErr(w, r, http.StatusBadRequest, err) return } rate, err := d.Store.Create(ctx, in, principalFrom(ctx), d.Now()) if err != nil { if errors.Is(err, store.ErrOverlap) { writeErr(w, r, http.StatusConflict, err) return } writeErr(w, r, http.StatusInternalServerError, err) return } rate = d.attestOrLog(ctx, attest.ChangeCreate, rate) writeOK(w, r, http.StatusCreated, map[string]any{"rate": rate}, "per_diem_rates_detail.html") } type retireReq struct { ValidTo string `json:"valid_to"` // "YYYY-MM-DD" } func (d Dependencies) Retire(w http.ResponseWriter, r *http.Request) { ctx := r.Context() rateID := r.PathValue("rate_id") req, err := parseRetire(r) if err != nil { writeErr(w, r, http.StatusBadRequest, err) return } validTo, err := time.Parse("2006-01-02", req.ValidTo) if err != nil { writeErr(w, r, http.StatusBadRequest, fmt.Errorf("valid_to must be YYYY-MM-DD: %w", err)) return } rate, err := d.Store.Retire(ctx, rateID, validTo, principalFrom(ctx), d.Now()) if err != nil { switch { case errors.Is(err, store.ErrNotFound): writeErr(w, r, http.StatusNotFound, err) case errors.Is(err, store.ErrAlreadyRetired): writeErr(w, r, http.StatusConflict, err) default: writeErr(w, r, http.StatusBadRequest, err) } return } rate = d.attestOrLog(ctx, attest.ChangeRetire, rate) writeOK(w, r, http.StatusOK, map[string]any{"rate": rate}, "per_diem_rates_detail.html") } // attestOrLog signs a rate_change event through d.Attest and attaches the // resulting content_hash to the row. On any failure (orchestrator missing, // signer/log down, store attach failed), it logs and returns the rate as-is // — the row stays "attestation pending" and a reconciliation worker retries. func (d Dependencies) attestOrLog(ctx context.Context, kind attest.ChangeKind, rate model.Rate) model.Rate { if d.Attest == nil { if d.Logger != nil { d.Logger.Warn("attest skipped: no orchestrator wired", "rate_id", rate.RateID, "kind", string(kind)) } return rate } issuer := d.Issuer if issuer == "" { issuer = "https://auth.basicstack.de" } ev := attest.RateChangeEvent{ Kind: kind, Rate: rate, PerformedBy: attest.PerformedBy{ Issuer: issuer, Subject: principalFrom(ctx), SessionID: sessionFrom(ctx), }, PerformedAt: d.Now().UTC(), } contentHash, _, err := d.Attest.SignAndLog(ctx, ev) if err != nil { if d.Logger != nil { d.Logger.Error("attest failed; row will remain pending for reconciliation", "rate_id", rate.RateID, "kind", string(kind), "err", err) } return rate } if err := d.Store.AttachAttestation(ctx, rate.RateID, contentHash); err != nil { if d.Logger != nil { d.Logger.Error("attach attestation failed; signed but not persisted", "rate_id", rate.RateID, "content_hash", contentHash, "err", err) } return rate } rate.RateChangeAttestationRef = &contentHash return rate } func parseNewRate(r *http.Request) (model.NewRateInput, error) { if strings.HasPrefix(r.Header.Get("Content-Type"), "application/json") { var in model.NewRateInput if err := json.NewDecoder(r.Body).Decode(&in); err != nil { return in, fmt.Errorf("decode body: %w", err) } return in, nil } if err := r.ParseForm(); err != nil { return model.NewRateInput{}, fmt.Errorf("parse form: %w", err) } in := model.NewRateInput{ Country: r.FormValue("country"), Currency: r.FormValue("currency"), FullDay: r.FormValue("full_day"), PartialDayMin8h: r.FormValue("partial_day_min8h"), ArrivalDay: r.FormValue("arrival_day"), DepartureDay: r.FormValue("departure_day"), SourceRegulation: r.FormValue("source_regulation"), } if city := r.FormValue("city"); city != "" { in.City = &city } if vf := r.FormValue("valid_from"); vf != "" { t, err := time.Parse("2006-01-02", vf) if err != nil { return in, fmt.Errorf("valid_from: %w", err) } in.ValidFrom = t } if vt := r.FormValue("valid_to"); vt != "" { t, err := time.Parse("2006-01-02", vt) if err != nil { return in, fmt.Errorf("valid_to: %w", err) } in.ValidTo = &t } return in, nil } func parseRetire(r *http.Request) (retireReq, error) { if strings.HasPrefix(r.Header.Get("Content-Type"), "application/json") { var req retireReq if err := json.NewDecoder(r.Body).Decode(&req); err != nil { return req, fmt.Errorf("decode body: %w", err) } return req, nil } if err := r.ParseForm(); err != nil { return retireReq{}, err } return retireReq{ValidTo: r.FormValue("valid_to")}, nil } func wantsHTML(r *http.Request) bool { accept := r.Header.Get("Accept") return strings.Contains(accept, "text/html") } func writeOK(w http.ResponseWriter, r *http.Request, status int, body any, template string) { if wantsHTML(r) { // Template rendering wired in main; in the skeleton we fall through // to JSON for determinism in tests. } w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(body) } func writeErr(w http.ResponseWriter, r *http.Request, status int, err error) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(map[string]any{"error": err.Error()}) } // WithPrincipal re-exports auth.WithPrincipal so existing callers and tests // keep working after the context accessors moved into internal/auth. func WithPrincipal(ctx context.Context, sub string) context.Context { return auth.WithPrincipal(ctx, sub) } // WithSession re-exports auth.WithSession for the same reason. func WithSession(ctx context.Context, sessionID string) context.Context { return auth.WithSession(ctx, sessionID) } func principalFrom(ctx context.Context) string { return auth.PrincipalFrom(ctx) } func sessionFrom(ctx context.Context) string { return auth.SessionFrom(ctx) }