package session
import (
"context"
"log/slog"
"net/http"
"strconv"
"strings"
"github.com/go-chi/chi/v5"
"go.bigb.es/auxilia/culpa"
"go.bigb.es/auxilia/scribe"
"sourcecraft.dev/bigbes/lethe/internal/pkg/apierror"
"sourcecraft.dev/bigbes/lethe/internal/pkg/httputil"
"sourcecraft.dev/bigbes/lethe/internal/server/auth"
)
// Pagination knobs locked by the spec. The Handler clamps client-supplied
// values into [0, defaultLimit] / [0, maxLimit] before reaching Repository.
const (
defaultLimit = 50
maxLimit = 200
)
// allOwnersSentinel is the literal value of `?owner=` that an admin uses to
// scope a list across every owner. Any other non-empty value is interpreted
// as a SpecificOwner.
const allOwnersSentinel = "*"
// Handler is the steward-managed HTTP boundary for the sessions read API.
// Repo is the injected SQL steward; the Handler holds no other state.
type Handler struct {
Repo *Repository `inject:""`
}
// Init satisfies the steward Initer contract. The Handler is stateless
// beyond its injected dependencies.
func (h *Handler) Init(_ context.Context) error { return nil }
// Mount registers the two read routes under r. Server.Init mounts this
// inside the /api/v1 group, so the effective paths are
// `/api/v1/sessions` and `/api/v1/sessions/{tool}/{host}/{session_id}`.
func (h *Handler) Mount(r chi.Router) {
r.Get("/sessions", h.List)
r.Get("/sessions/{tool}/{host}/{session_id}", h.Get)
}
// listResponse is the JSON body returned by List. The Limit/Offset echo back
// the (possibly clamped) effective values so clients can detect that their
// supplied limit was capped.
type listResponse struct {
Sessions []Session `json:"sessions"`
Limit int `json:"limit"`
Offset int `json:"offset"`
}
// List handles GET /sessions. It resolves the owner scope (admin gating on
// `?owner=`), parses optional filters, clamps pagination, and writes a
// listResponse. Errors surface through apierror.Render as RFC 7807.
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
scope, err := h.resolveScope(r)
if err != nil {
apierror.Render(w, r, err)
return
}
q := r.URL.Query()
filter := ListFilter{Owner: scope}
if v := q.Get("tool"); v != "" {
filter.Tool = &v
}
if v := q.Get("host"); v != "" {
filter.Host = &v
}
if v := q.Get("since"); v != "" {
n, perr := strconv.ParseInt(v, 10, 64)
if perr != nil {
apierror.Render(w, r, culpa.WithCode(
culpa.WithPublic(culpa.Wrap(perr, "parse since"), "since must be an integer (unix epoch seconds)"),
"INVALID",
))
return
}
filter.Since = &n
}
if v := q.Get("until"); v != "" {
n, perr := strconv.ParseInt(v, 10, 64)
if perr != nil {
apierror.Render(w, r, culpa.WithCode(
culpa.WithPublic(culpa.Wrap(perr, "parse until"), "until must be an integer (unix epoch seconds)"),
"INVALID",
))
return
}
filter.Until = &n
}
if filter.Since != nil && filter.Until != nil && *filter.Since > *filter.Until {
apierror.Render(w, r, culpa.WithCode(
culpa.WithPublic(culpa.New("since > until"), "since must be <= until"),
"INVALID",
))
return
}
filter.Limit = clampLimit(q.Get("limit"))
filter.Offset = clampOffset(q.Get("offset"))
rows, err := h.Repo.List(r.Context(), filter)
if err != nil {
apierror.Render(w, r, err)
return
}
if writeErr := httputil.WriteJSON(w, http.StatusOK, listResponse{
Sessions: rows,
Limit: filter.Limit,
Offset: filter.Offset,
}); writeErr != nil {
slog.Default().ErrorContext(r.Context(), "write sessions response", scribe.Err(writeErr))
}
}
// Get handles GET /sessions/{tool}/{host}/{session_id}. The chi router
// guarantees non-empty captures, but we defend in depth (a misconfigured
// mount would otherwise produce a SQL query with empty keys).
func (h *Handler) Get(w http.ResponseWriter, r *http.Request) {
scope, err := h.resolveScope(r)
if err != nil {
apierror.Render(w, r, err)
return
}
tool := chi.URLParam(r, "tool")
host := chi.URLParam(r, "host")
sessionID := chi.URLParam(r, "session_id")
if tool == "" || host == "" || sessionID == "" {
apierror.Render(w, r, culpa.WithCode(
culpa.WithPublic(culpa.New("tool, host, and session_id are required"), "tool, host, and session_id are required"),
"INVALID",
))
return
}
out, err := h.Repo.Get(r.Context(), scope, tool, host, sessionID)
if err != nil {
apierror.Render(w, r, err)
return
}
if writeErr := httputil.WriteJSON(w, http.StatusOK, out); writeErr != nil {
slog.Default().ErrorContext(r.Context(), "write session response", scribe.Err(writeErr))
}
}
// resolveScope reads the authenticated identity off the context and the
// optional `?owner=` query parameter, then returns the appropriate
// OwnerScope. Non-admin requests with `?owner=` set (any value, including
// the requester's own user) are 403 — the parameter is admin-only and must
// not be ignored silently for non-admins.
func (h *Handler) resolveScope(r *http.Request) (OwnerScope, error) {
id := auth.MustIdentity(r.Context())
param := r.URL.Query().Get("owner")
if param == "" {
return OwnerScope{User: id.User}, nil
}
if !id.IsAdmin {
return OwnerScope{}, culpa.WithCode(
culpa.WithPublic(culpa.New("?owner= is admin-only"), "?owner= is admin-only"),
"FORBIDDEN",
)
}
if param == allOwnersSentinel {
return OwnerScope{User: id.User, AllOwners: true}, nil
}
owner := strings.ToLower(param)
return OwnerScope{User: id.User, SpecificOwner: &owner}, nil
}
// clampLimit returns the effective limit: defaultLimit when missing,
// non-numeric, or negative; capped at maxLimit when the parsed value
// exceeds it.
func clampLimit(raw string) int {
if raw == "" {
return defaultLimit
}
n, err := strconv.Atoi(raw)
if err != nil || n < 0 {
return defaultLimit
}
if n > maxLimit {
return maxLimit
}
return n
}
// clampOffset returns the effective offset: 0 when missing, non-numeric,
// or negative.
func clampOffset(raw string) int {
if raw == "" {
return 0
}
n, err := strconv.Atoi(raw)
if err != nil || n < 0 {
return 0
}
return n
}