~bigbes/lethe

e108b3e06984223f9489e5dcf1f22ed60ae8adb7 — Eugene Blikh a month ago 8383c20
feat(session): list and detail JSON API with filters
M internal/domain/session/handler.go => internal/domain/session/handler.go +197 -11
@@ 1,22 1,208 @@
// Phase-5 stub. Replaced by real implementation in Phase 8.
//
// Mount is a no-op so the /api/v1 group registers no session routes yet;
// Server.Init still wires it in so the full router topology compiles.
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
)

// Handler is a no-op steward service. Phase 8 supplies the real session
// read API.
type Handler struct{}
// 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 = "*"

// Init satisfies the steward Service contract.
// 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 no routes. Phase 8 replaces this with the sessions list /
// detail endpoints rooted at the /api/v1 group passed in by Server.Init.
func (h *Handler) Mount(_ chi.Router) {}
// 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
}

A internal/domain/session/handler_test.go => internal/domain/session/handler_test.go +349 -0
@@ 0,0 1,349 @@
package session_test

import (
	"context"
	"encoding/json"
	"net/http"
	"net/http/httptest"
	"testing"

	"github.com/go-chi/chi/v5"

	"sourcecraft.dev/bigbes/lethe/internal/domain/session"
	"sourcecraft.dev/bigbes/lethe/internal/server/auth"
)

// fakeAuthMiddleware injects a fixed Identity onto the request context so
// the handler can call auth.MustIdentity without a real Authenticator.
func fakeAuthMiddleware(id auth.Identity) func(http.Handler) http.Handler {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			ctx := auth.WithIdentity(r.Context(), id)
			next.ServeHTTP(w, r.WithContext(ctx))
		})
	}
}

// newHandler wires a Repository against a fresh in-memory database and
// returns the Handler. The DB is exposed so each test can seed rows.
func newHandler(t *testing.T) (*session.Handler, *session.Repository) {
	t.Helper()
	repo, _ := newRepo(t)
	h := &session.Handler{Repo: repo}
	if err := h.Init(context.Background()); err != nil {
		t.Fatalf("handler.Init: %v", err)
	}
	return h, repo
}

// mountWithIdentity builds a chi router with the fake auth middleware
// (injecting id) and the session handler mounted under /api/v1.
func mountWithIdentity(h *session.Handler, id auth.Identity) http.Handler {
	r := chi.NewRouter()
	r.Route("/api/v1", func(r chi.Router) {
		r.Use(fakeAuthMiddleware(id))
		h.Mount(r)
	})
	return r
}

// listBody is the decoded JSON of GET /sessions; matches the handler's
// listResponse but lives here so tests don't reach into unexported names.
type listBody struct {
	Sessions []session.Session `json:"sessions"`
	Limit    int               `json:"limit"`
	Offset   int               `json:"offset"`
}

// problemBody captures only the fields tests assert on for RFC 7807 docs.
type problemBody struct {
	Status int    `json:"status"`
	Code   string `json:"code"`
}

func doList(t *testing.T, router http.Handler, query string) (*httptest.ResponseRecorder, listBody) {
	t.Helper()
	req := httptest.NewRequest(http.MethodGet, "/api/v1/sessions"+query, nil)
	rec := httptest.NewRecorder()
	router.ServeHTTP(rec, req)
	var body listBody
	if rec.Code == http.StatusOK {
		if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
			t.Fatalf("unmarshal list body: %v (body=%s)", err, rec.Body.String())
		}
	}
	return rec, body
}

func doGet(t *testing.T, router http.Handler, tool, host, sid string) *httptest.ResponseRecorder {
	t.Helper()
	req := httptest.NewRequest(http.MethodGet, "/api/v1/sessions/"+tool+"/"+host+"/"+sid, nil)
	rec := httptest.NewRecorder()
	router.ServeHTTP(rec, req)
	return rec
}

func TestHandler_List_PaginationDefaults(t *testing.T) {
	h, repo := newHandler(t)
	seedSession(t, repo.Database.DB, "alice", "cc", "phoebe", "s1", 1700000000, 1700000010)
	router := mountWithIdentity(h, auth.Identity{User: "alice"})

	rec, body := doList(t, router, "")
	if rec.Code != http.StatusOK {
		t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
	}
	if body.Limit != 50 || body.Offset != 0 {
		t.Fatalf("defaults: limit=%d offset=%d; want 50/0", body.Limit, body.Offset)
	}
}

func TestHandler_List_PaginationCaps(t *testing.T) {
	h, _ := newHandler(t)
	router := mountWithIdentity(h, auth.Identity{User: "alice"})

	rec, body := doList(t, router, "?limit=999")
	if rec.Code != http.StatusOK {
		t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
	}
	if body.Limit != 200 {
		t.Fatalf("expected limit capped to 200; got %d", body.Limit)
	}
}

func TestHandler_List_NegativeClamped(t *testing.T) {
	h, _ := newHandler(t)
	router := mountWithIdentity(h, auth.Identity{User: "alice"})

	rec, body := doList(t, router, "?limit=-3&offset=-7")
	if rec.Code != http.StatusOK {
		t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
	}
	if body.Limit != 50 {
		t.Fatalf("expected negative limit clamped to default 50; got %d", body.Limit)
	}
	if body.Offset != 0 {
		t.Fatalf("expected negative offset clamped to 0; got %d", body.Offset)
	}
}

func TestHandler_List_SinceGreaterThanUntilReturns400(t *testing.T) {
	h, _ := newHandler(t)
	router := mountWithIdentity(h, auth.Identity{User: "alice"})

	rec, _ := doList(t, router, "?since=200&until=100")
	if rec.Code != http.StatusBadRequest {
		t.Fatalf("status=%d; want 400; body=%s", rec.Code, rec.Body.String())
	}
	var p problemBody
	_ = json.Unmarshal(rec.Body.Bytes(), &p)
	if p.Code != "INVALID" {
		t.Fatalf("expected code INVALID; got %q (body=%s)", p.Code, rec.Body.String())
	}
}

func TestHandler_List_BadSinceReturns400(t *testing.T) {
	h, _ := newHandler(t)
	router := mountWithIdentity(h, auth.Identity{User: "alice"})

	rec, _ := doList(t, router, "?since=not-a-number")
	if rec.Code != http.StatusBadRequest {
		t.Fatalf("status=%d; want 400; body=%s", rec.Code, rec.Body.String())
	}
	var p problemBody
	_ = json.Unmarshal(rec.Body.Bytes(), &p)
	if p.Code != "INVALID" {
		t.Fatalf("expected INVALID; got %q", p.Code)
	}
}

func TestHandler_List_PerUserIsolation(t *testing.T) {
	h, repo := newHandler(t)
	db := repo.Database.DB
	seedSession(t, db, "alice", "cc", "phoebe", "sA", 1700000000, 1700000010)
	seedSession(t, db, "bob", "cc", "phoebe", "sB", 1700000100, 1700000110)
	seedSession(t, db, "bob", "cc", "phoebe", "sB2", 1700000200, 1700000210)

	router := mountWithIdentity(h, auth.Identity{User: "alice"})
	// No filter — should still only see alice's row.
	rec, body := doList(t, router, "")
	if rec.Code != http.StatusOK {
		t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
	}
	if len(body.Sessions) != 1 || body.Sessions[0].Owner != "alice" {
		t.Fatalf("alice should see only her row; got %#v", body.Sessions)
	}

	// Filter by tool — bob's matching rows still excluded.
	rec, body = doList(t, router, "?tool=cc")
	if rec.Code != http.StatusOK || len(body.Sessions) != 1 || body.Sessions[0].Owner != "alice" {
		t.Fatalf("alice + tool filter should yield 1 alice row; got code=%d %#v", rec.Code, body.Sessions)
	}
}

func TestHandler_List_NonAdminOwnerParamReturns403(t *testing.T) {
	h, repo := newHandler(t)
	seedSession(t, repo.Database.DB, "alice", "cc", "phoebe", "s1", 1700000000, 1700000010)
	router := mountWithIdentity(h, auth.Identity{User: "alice", IsAdmin: false})

	for _, q := range []string{"?owner=alice", "?owner=bob", "?owner=*"} {
		rec, _ := doList(t, router, q)
		if rec.Code != http.StatusForbidden {
			t.Fatalf("query %q: status=%d; want 403; body=%s", q, rec.Code, rec.Body.String())
		}
		var p problemBody
		_ = json.Unmarshal(rec.Body.Bytes(), &p)
		if p.Code != "FORBIDDEN" {
			t.Fatalf("query %q: code=%q; want FORBIDDEN", q, p.Code)
		}
	}
}

func TestHandler_List_AdminOwnerParamHonored(t *testing.T) {
	h, repo := newHandler(t)
	db := repo.Database.DB
	seedSession(t, db, "alice", "cc", "phoebe", "sA", 1700000000, 1700000010)
	seedSession(t, db, "bob", "cc", "phoebe", "sB", 1700000100, 1700000110)
	router := mountWithIdentity(h, auth.Identity{User: "admin", IsAdmin: true})

	rec, body := doList(t, router, "?owner=bob")
	if rec.Code != http.StatusOK {
		t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
	}
	if len(body.Sessions) != 1 || body.Sessions[0].Owner != "bob" {
		t.Fatalf("expected bob's row only; got %#v", body.Sessions)
	}
}

func TestHandler_List_AdminOwnerStarReturnsAllOwners(t *testing.T) {
	h, repo := newHandler(t)
	db := repo.Database.DB
	seedSession(t, db, "alice", "cc", "phoebe", "sA", 1700000000, 1700000010)
	seedSession(t, db, "bob", "cc", "phoebe", "sB", 1700000100, 1700000110)
	router := mountWithIdentity(h, auth.Identity{User: "admin", IsAdmin: true})

	rec, body := doList(t, router, "?owner=*")
	if rec.Code != http.StatusOK {
		t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
	}
	if len(body.Sessions) != 2 {
		t.Fatalf("expected 2 rows; got %d (%#v)", len(body.Sessions), body.Sessions)
	}
}

func TestHandler_List_AdminWithoutOwnerParam_ShowsOnlyOwnRows(t *testing.T) {
	h, repo := newHandler(t)
	db := repo.Database.DB
	seedSession(t, db, "admin", "cc", "phoebe", "sAdm", 1700000000, 1700000010)
	seedSession(t, db, "bob", "cc", "phoebe", "sB", 1700000100, 1700000110)
	router := mountWithIdentity(h, auth.Identity{User: "admin", IsAdmin: true})

	rec, body := doList(t, router, "")
	if rec.Code != http.StatusOK {
		t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
	}
	if len(body.Sessions) != 1 || body.Sessions[0].Owner != "admin" {
		t.Fatalf("admin without ?owner= should see only own rows; got %#v", body.Sessions)
	}
}

func TestHandler_Get_OtherOwnersSession_ReturnsNotFound404(t *testing.T) {
	h, repo := newHandler(t)
	seedSession(t, repo.Database.DB, "bob", "cc", "phoebe", "sB", 1700000000, 1700000010)
	router := mountWithIdentity(h, auth.Identity{User: "alice"})

	rec := doGet(t, router, "cc", "phoebe", "sB")
	if rec.Code != http.StatusNotFound {
		t.Fatalf("status=%d; want 404; body=%s", rec.Code, rec.Body.String())
	}
	var p problemBody
	_ = json.Unmarshal(rec.Body.Bytes(), &p)
	if p.Code != "NOT_FOUND" {
		t.Fatalf("expected NOT_FOUND; got %q", p.Code)
	}
}

func TestHandler_Get_OwnSession_Returns200(t *testing.T) {
	h, repo := newHandler(t)
	db := repo.Database.DB
	seedSession(t, db, "alice", "cc", "phoebe", "s1", 1700000000, 1700000010)
	seedTurn(t, db, "alice", "cc", "phoebe", "s1", "tA", 1, 1700000005, "user", "hi")
	router := mountWithIdentity(h, auth.Identity{User: "alice"})

	rec := doGet(t, router, "cc", "phoebe", "s1")
	if rec.Code != http.StatusOK {
		t.Fatalf("status=%d; want 200; body=%s", rec.Code, rec.Body.String())
	}
	var got session.SessionWithTurns
	if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
		t.Fatalf("unmarshal: %v", err)
	}
	if got.SessionID != "s1" || got.Owner != "alice" {
		t.Fatalf("unexpected session: %#v", got.Session)
	}
	if len(got.Turns) != 1 || got.Turns[0].TurnID != "tA" {
		t.Fatalf("unexpected turns: %#v", got.Turns)
	}
}

func TestHandler_Get_AdminCanGetAnyOwner(t *testing.T) {
	h, repo := newHandler(t)
	db := repo.Database.DB
	seedSession(t, db, "bob", "cc", "phoebe", "sB", 1700000000, 1700000010)
	router := mountWithIdentity(h, auth.Identity{User: "admin", IsAdmin: true})

	// With ?owner=bob admins fetch any user's session.
	rec := httptest.NewRecorder()
	req := httptest.NewRequest(http.MethodGet, "/api/v1/sessions/cc/phoebe/sB?owner=bob", nil)
	router.ServeHTTP(rec, req)
	if rec.Code != http.StatusOK {
		t.Fatalf("status=%d; body=%s", rec.Code, rec.Body.String())
	}
	var got session.SessionWithTurns
	if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
		t.Fatalf("unmarshal: %v", err)
	}
	if got.Owner != "bob" {
		t.Fatalf("expected bob's session; got %#v", got.Session)
	}

	// And with ?owner=* the admin can also reach across owners.
	rec = httptest.NewRecorder()
	req = httptest.NewRequest(http.MethodGet, "/api/v1/sessions/cc/phoebe/sB?owner=*", nil)
	router.ServeHTTP(rec, req)
	if rec.Code != http.StatusOK {
		t.Fatalf("?owner=*: status=%d; body=%s", rec.Code, rec.Body.String())
	}
}

func TestHandler_Mount_RegistersExpectedRoutes(t *testing.T) {
	h, _ := newHandler(t)
	router := chi.NewRouter()
	router.Route("/api/v1", func(r chi.Router) {
		r.Use(fakeAuthMiddleware(auth.Identity{User: "alice"}))
		h.Mount(r)
	})

	wantPatterns := map[string]bool{
		"/api/v1/sessions": false,
		"/api/v1/sessions/{tool}/{host}/{session_id}": false,
	}
	walkErr := chi.Walk(router, func(method, route string, _ http.Handler, _ ...func(http.Handler) http.Handler) error {
		if method != http.MethodGet {
			return nil
		}
		for k := range wantPatterns {
			if route == k {
				wantPatterns[k] = true
			}
		}
		return nil
	})
	if walkErr != nil {
		t.Fatalf("walk: %v", walkErr)
	}
	for pat, found := range wantPatterns {
		if !found {
			t.Errorf("expected GET %s registered; not found", pat)
		}
	}
}

A internal/domain/session/repository.go => internal/domain/session/repository.go +289 -0
@@ 0,0 1,289 @@
// Package session implements the read-only sessions API: list with filters
// and detail-with-turns. The package layers as Repository (raw SQL) and
// Handler (HTTP boundary). Both are steward services; the Repository owns
// the SQL composition and the per-owner isolation invariant, while the
// Handler resolves the owner scope from the authenticated identity and
// translates errors into RFC 7807 problems.
//
// The owner-scope resolution rules (locked):
//   - Default scope is `WHERE owner = <current user>`. Non-admins always read
//     their own data only.
//   - Admins may pass `?owner=<user>` to scope to that user, or `?owner=*` to
//     read across all owners (no WHERE clause on owner).
//   - Non-admin requests that include `?owner=` (any value, including their
//     own user) are 403 — the parameter is admin-only.
//   - A Get for another owner's session returns 404 (not 403). Returning a
//     distinct status would leak existence across tenants.
package session

import (
	"context"
	"database/sql"
	"database/sql/driver"
	"encoding/json"
	"errors"
	"fmt"
	"strings"

	"go.bigb.es/auxilia/culpa"

	"sourcecraft.dev/bigbes/lethe/internal/platform/database"
)

// JSONText is a thin wrapper around the raw JSON bytes stored in TEXT columns
// (sessions.metadata, turns.tool_calls, turns.metadata). The SQLite driver
// does not know how to scan NULL into json.RawMessage directly, and the
// stored values may legitimately be NULL — Scan handles that by leaving the
// underlying slice nil. JSON marshal omits the field entirely (`,omitempty`)
// when the slice is empty, so a NULL column becomes an absent JSON key.
type JSONText []byte

// Scan implements sql.Scanner so NULL TEXT columns become a nil []byte.
// Non-NULL values are copied (the driver's buffer is not safe to retain).
func (j *JSONText) Scan(src any) error {
	if src == nil {
		*j = nil
		return nil
	}
	switch v := src.(type) {
	case []byte:
		buf := make([]byte, len(v))
		copy(buf, v)
		*j = buf
		return nil
	case string:
		*j = []byte(v)
		return nil
	default:
		return fmt.Errorf("session: cannot scan %T into JSONText", src)
	}
}

// Value implements driver.Valuer; included for completeness so the type can
// flow back through any future writes (Phase 8 itself is read-only).
func (j JSONText) Value() (driver.Value, error) {
	if len(j) == 0 {
		return nil, nil
	}
	return string(j), nil
}

// MarshalJSON returns the stored bytes verbatim (or `null` if empty) so the
// JSON output preserves whatever the collector originally produced.
func (j JSONText) MarshalJSON() ([]byte, error) {
	if len(j) == 0 {
		return []byte("null"), nil
	}
	// Validate that the stored bytes are syntactically valid JSON before
	// emitting them — guards against a corrupt row turning a 200 into an
	// HTTP-level encoding panic.
	if !json.Valid(j) {
		return nil, fmt.Errorf("session: stored JSON is invalid")
	}
	return []byte(j), nil
}

// UnmarshalJSON stores the raw bytes verbatim, mirroring json.RawMessage.
func (j *JSONText) UnmarshalJSON(b []byte) error {
	if j == nil {
		return errors.New("session.JSONText: UnmarshalJSON on nil pointer")
	}
	*j = append((*j)[0:0], b...)
	return nil
}

// Session is the row shape returned by List and embedded in SessionWithTurns.
// JSON tags mirror the wire vocabulary used by the collector and clients.
type Session struct {
	Owner      string   `db:"owner"        json:"owner"`
	Tool       string   `db:"tool"         json:"tool"`
	Host       string   `db:"host"         json:"host"`
	SessionID  string   `db:"session_id"   json:"session_id"`
	StartedAt  int64    `db:"started_at"   json:"started_at"`
	EndedAt    int64    `db:"ended_at"     json:"ended_at"`
	WorkingDir *string  `db:"working_dir"  json:"working_dir,omitempty"`
	SourceFile string   `db:"source_file"  json:"source_file"`
	Metadata   JSONText `db:"metadata"     json:"metadata,omitempty"`
}

// Turn is the row shape returned inside SessionWithTurns. Optional columns
// (model, tokens_in/out, cost_usd, tool_calls, metadata) are nullable in the
// schema and are exposed as pointers / RawMessage so callers can distinguish
// "absent" from "zero".
type Turn struct {
	Owner     string   `db:"owner"       json:"owner"`
	Tool      string   `db:"tool"        json:"tool"`
	Host      string   `db:"host"        json:"host"`
	SessionID string   `db:"session_id"  json:"session_id"`
	TurnID    string   `db:"turn_id"     json:"turn_id"`
	Seq       int64    `db:"seq"         json:"seq"`
	Role      string   `db:"role"        json:"role"`
	Timestamp int64    `db:"timestamp"   json:"timestamp"`
	Content   string   `db:"content"     json:"content"`
	Model     *string  `db:"model"       json:"model,omitempty"`
	TokensIn  *int64   `db:"tokens_in"   json:"tokens_in,omitempty"`
	TokensOut *int64   `db:"tokens_out"  json:"tokens_out,omitempty"`
	CostUSD   *float64 `db:"cost_usd"    json:"cost_usd,omitempty"`
	ToolCalls JSONText `db:"tool_calls"  json:"tool_calls,omitempty"`
	Metadata  JSONText `db:"metadata"    json:"metadata,omitempty"`
}

// SessionWithTurns is the response shape for Get. Session is embedded so the
// JSON output flattens the session columns at the top level alongside the
// "turns" array.
type SessionWithTurns struct {
	Session
	Turns []Turn `json:"turns"`
}

// OwnerScope is resolved by the Handler from the authenticated identity and
// the optional `?owner=` query parameter. It is the only knob the Repository
// has for tightening or widening the owner WHERE clause.
//
// Exactly one of the three states is meaningful per call:
//   - AllOwners=true               → no WHERE clause on owner (admin + ?owner=*)
//   - SpecificOwner != nil         → WHERE owner = *SpecificOwner (admin + ?owner=u)
//   - otherwise                    → WHERE owner = User (default; non-admins always)
type OwnerScope struct {
	User          string
	AllOwners     bool
	SpecificOwner *string
}

// ListFilter aggregates every option List supports. The Handler clamps Limit
// and Offset before constructing this struct; the Repository assumes both are
// already in the safe range.
type ListFilter struct {
	Owner  OwnerScope
	Tool   *string
	Host   *string
	Since  *int64
	Until  *int64
	Limit  int
	Offset int
}

// Repository is the SQL steward for the sessions read API. It is stateless
// beyond its injected dependencies; Init is empty.
type Repository struct {
	Database *database.Database `inject:""`
}

// Init satisfies the steward Initer contract. Nothing to set up — the
// underlying *sqlx.DB is owned by the Database steward.
func (r *Repository) Init(_ context.Context) error { return nil }

// sessionSelectColumns is the canonical column list for SELECTs against
// `sessions`. Centralized so the List and Get queries stay in lock-step with
// the Session struct's `db` tags.
const sessionSelectColumns = `owner, tool, host, session_id, started_at, ended_at, working_dir, source_file, metadata`

// turnSelectColumns mirrors sessionSelectColumns for the `turns` table.
const turnSelectColumns = `owner, tool, host, session_id, turn_id, seq, role, timestamp, content, model, tokens_in, tokens_out, cost_usd, tool_calls, metadata`

// List runs the dynamic-WHERE list query. The owner clause is built first
// (per OwnerScope), then optional filters are appended in fixed order; only
// values reach the driver via "?" placeholders — column names and the
// AND-skeleton are constructed from string literals never derived from input.
//
// Ordering is `started_at DESC, session_id DESC`: the secondary key keeps
// pagination deterministic when two sessions share a started_at.
//
// An empty result set returns a non-nil zero-length slice. Callers that
// JSON-encode the slice get `[]` rather than `null`.
func (r *Repository) List(ctx context.Context, f ListFilter) ([]Session, error) {
	var (
		sb   strings.Builder
		args []any
	)
	sb.WriteString("SELECT ")
	sb.WriteString(sessionSelectColumns)
	sb.WriteString(" FROM sessions")

	clauses := make([]string, 0, 5)
	switch {
	case f.Owner.AllOwners:
		// no owner clause
	case f.Owner.SpecificOwner != nil:
		clauses = append(clauses, "owner = ?")
		args = append(args, *f.Owner.SpecificOwner)
	default:
		clauses = append(clauses, "owner = ?")
		args = append(args, f.Owner.User)
	}
	if f.Tool != nil {
		clauses = append(clauses, "tool = ?")
		args = append(args, *f.Tool)
	}
	if f.Host != nil {
		clauses = append(clauses, "host = ?")
		args = append(args, *f.Host)
	}
	if f.Since != nil {
		clauses = append(clauses, "started_at >= ?")
		args = append(args, *f.Since)
	}
	if f.Until != nil {
		clauses = append(clauses, "started_at <= ?")
		args = append(args, *f.Until)
	}
	if len(clauses) > 0 {
		sb.WriteString(" WHERE ")
		sb.WriteString(strings.Join(clauses, " AND "))
	}
	sb.WriteString(" ORDER BY started_at DESC, session_id DESC LIMIT ? OFFSET ?")
	args = append(args, f.Limit, f.Offset)

	out := make([]Session, 0)
	if err := r.Database.DB.SelectContext(ctx, &out, sb.String(), args...); err != nil {
		return nil, culpa.WithCode(culpa.Wrap(err, "list sessions"), "DB_QUERY")
	}
	return out, nil
}

// Get returns the named session and its turns in seq order. The owner clause
// is built from scope identically to List: AllOwners means no clause,
// SpecificOwner pins the row, otherwise the current user is the only allowed
// owner. A miss for any of those reasons returns NOT_FOUND — never 403 —
// because differentiating "wrong owner" from "no such session" would leak
// existence across tenants.
//
// The turns query uses the resolved session's owner (read off the loaded
// row), keeping the result set internally consistent even under AllOwners.
func (r *Repository) Get(ctx context.Context, scope OwnerScope, tool, host, sessionID string) (*SessionWithTurns, error) {
	var (
		sb   strings.Builder
		args []any
	)
	sb.WriteString("SELECT ")
	sb.WriteString(sessionSelectColumns)
	sb.WriteString(" FROM sessions WHERE ")

	switch {
	case scope.AllOwners:
		// no owner clause
	case scope.SpecificOwner != nil:
		sb.WriteString("owner = ? AND ")
		args = append(args, *scope.SpecificOwner)
	default:
		sb.WriteString("owner = ? AND ")
		args = append(args, scope.User)
	}
	sb.WriteString("tool = ? AND host = ? AND session_id = ?")
	args = append(args, tool, host, sessionID)

	var s Session
	if err := r.Database.DB.GetContext(ctx, &s, sb.String(), args...); err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return nil, culpa.WithCode(culpa.New("session not found"), "NOT_FOUND")
		}
		return nil, culpa.WithCode(culpa.Wrap(err, "get session"), "DB_QUERY")
	}

	turns := make([]Turn, 0)
	const turnsQuery = "SELECT " + turnSelectColumns + " FROM turns WHERE owner = ? AND tool = ? AND host = ? AND session_id = ? ORDER BY seq ASC"
	if err := r.Database.DB.SelectContext(ctx, &turns, turnsQuery, s.Owner, s.Tool, s.Host, s.SessionID); err != nil {
		return nil, culpa.WithCode(culpa.Wrap(err, "list session turns"), "DB_QUERY")
	}
	return &SessionWithTurns{Session: s, Turns: turns}, nil
}

A internal/domain/session/repository_test.go => internal/domain/session/repository_test.go +344 -0
@@ 0,0 1,344 @@
package session_test

import (
	"context"
	"testing"
	"time"

	"github.com/jmoiron/sqlx"
	"go.bigb.es/auxilia/culpa"
	_ "modernc.org/sqlite"

	"sourcecraft.dev/bigbes/lethe/internal/config"
	"sourcecraft.dev/bigbes/lethe/internal/domain/session"
	"sourcecraft.dev/bigbes/lethe/internal/platform/database"
)

// newTestDatabase builds a Database steward against :memory: (one DB per
// test, isolated). Cleanup runs Destroy.
func newTestDatabase(t *testing.T) *database.Database {
	t.Helper()
	d := &database.Database{
		Cfg: config.DatabaseConfig{
			Path:        ":memory:",
			BusyTimeout: 5 * time.Second,
		},
	}
	if err := d.Init(context.Background()); err != nil {
		t.Fatalf("database.Init: %v", err)
	}
	t.Cleanup(func() { _ = d.Destroy(context.Background()) })
	return d
}

// newRepo wires a Repository against a fresh in-memory database.
func newRepo(t *testing.T) (*session.Repository, *sqlx.DB) {
	t.Helper()
	d := newTestDatabase(t)
	repo := &session.Repository{Database: d}
	if err := repo.Init(context.Background()); err != nil {
		t.Fatalf("repo.Init: %v", err)
	}
	return repo, d.DB
}

// seedSession inserts a session row directly via SQL. The tests deliberately
// do not depend on internal/domain/ingest/ — the read-side package must be
// testable in isolation.
func seedSession(t *testing.T, db *sqlx.DB, owner, tool, host, sid string, startedAt, endedAt int64) {
	t.Helper()
	_, err := db.Exec(`
		INSERT INTO sessions (owner, tool, host, session_id, started_at, ended_at, working_dir, source_file, metadata)
		VALUES (?, ?, ?, ?, ?, ?, NULL, ?, NULL)`,
		owner, tool, host, sid, startedAt, endedAt, "/tmp/x.jsonl",
	)
	if err != nil {
		t.Fatalf("seed session %s/%s/%s/%s: %v", owner, tool, host, sid, err)
	}
}

// seedTurn inserts a turn row directly via SQL. Optional columns are NULL.
func seedTurn(t *testing.T, db *sqlx.DB, owner, tool, host, sid, tid string, seq, ts int64, role, content string) {
	t.Helper()
	_, err := db.Exec(`
		INSERT INTO turns (owner, tool, host, session_id, turn_id, seq, role, timestamp, content,
		                   model, tokens_in, tokens_out, cost_usd, tool_calls, metadata)
		VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, NULL, NULL, NULL, NULL)`,
		owner, tool, host, sid, tid, seq, role, ts, content,
	)
	if err != nil {
		t.Fatalf("seed turn %s/%s: %v", sid, tid, err)
	}
}

func ptrString(v string) *string { return &v }

func TestList_FilterByTool(t *testing.T) {
	repo, db := newRepo(t)
	seedSession(t, db, "alice", "cc", "phoebe", "s1", 1700000000, 1700000010)
	seedSession(t, db, "alice", "gemini", "phoebe", "s2", 1700000020, 1700000030)

	tool := "cc"
	got, err := repo.List(context.Background(), session.ListFilter{
		Owner: session.OwnerScope{User: "alice"},
		Tool:  &tool,
		Limit: 50,
	})
	if err != nil {
		t.Fatalf("List: %v", err)
	}
	if len(got) != 1 || got[0].SessionID != "s1" {
		t.Fatalf("expected exactly s1; got %#v", got)
	}
}

func TestList_FilterByHost(t *testing.T) {
	repo, db := newRepo(t)
	seedSession(t, db, "alice", "cc", "phoebe", "s1", 1700000000, 1700000010)
	seedSession(t, db, "alice", "cc", "rhea", "s2", 1700000020, 1700000030)

	host := "rhea"
	got, err := repo.List(context.Background(), session.ListFilter{
		Owner: session.OwnerScope{User: "alice"},
		Host:  &host,
		Limit: 50,
	})
	if err != nil {
		t.Fatalf("List: %v", err)
	}
	if len(got) != 1 || got[0].SessionID != "s2" {
		t.Fatalf("expected exactly s2; got %#v", got)
	}
}

func TestList_FilterByTimeRange(t *testing.T) {
	repo, db := newRepo(t)
	seedSession(t, db, "alice", "cc", "phoebe", "s1", 1700000000, 1700000010)
	seedSession(t, db, "alice", "cc", "phoebe", "s2", 1700000100, 1700000110)
	seedSession(t, db, "alice", "cc", "phoebe", "s3", 1700000200, 1700000210)

	since := int64(1700000050)
	until := int64(1700000150)
	got, err := repo.List(context.Background(), session.ListFilter{
		Owner: session.OwnerScope{User: "alice"},
		Since: &since,
		Until: &until,
		Limit: 50,
	})
	if err != nil {
		t.Fatalf("List: %v", err)
	}
	if len(got) != 1 || got[0].SessionID != "s2" {
		t.Fatalf("expected exactly s2 in range; got %#v", got)
	}
}

func TestList_FilterCombined(t *testing.T) {
	repo, db := newRepo(t)
	seedSession(t, db, "alice", "cc", "phoebe", "s1", 1700000000, 1700000010)
	seedSession(t, db, "alice", "cc", "rhea", "s2", 1700000050, 1700000060)
	seedSession(t, db, "alice", "gemini", "phoebe", "s3", 1700000070, 1700000080)

	tool := "cc"
	host := "phoebe"
	since := int64(1699999999)
	got, err := repo.List(context.Background(), session.ListFilter{
		Owner: session.OwnerScope{User: "alice"},
		Tool:  &tool,
		Host:  &host,
		Since: &since,
		Limit: 50,
	})
	if err != nil {
		t.Fatalf("List: %v", err)
	}
	if len(got) != 1 || got[0].SessionID != "s1" {
		t.Fatalf("expected only s1; got %#v", got)
	}
}

func TestList_OrderingByStartedAtDesc(t *testing.T) {
	repo, db := newRepo(t)
	seedSession(t, db, "alice", "cc", "phoebe", "s1", 1700000000, 1700000010)
	seedSession(t, db, "alice", "cc", "phoebe", "s2", 1700000200, 1700000210)
	seedSession(t, db, "alice", "cc", "phoebe", "s3", 1700000100, 1700000110)

	got, err := repo.List(context.Background(), session.ListFilter{
		Owner: session.OwnerScope{User: "alice"},
		Limit: 50,
	})
	if err != nil {
		t.Fatalf("List: %v", err)
	}
	if len(got) != 3 {
		t.Fatalf("want 3 rows, got %d", len(got))
	}
	if got[0].SessionID != "s2" || got[1].SessionID != "s3" || got[2].SessionID != "s1" {
		t.Fatalf("ordering wrong: %s, %s, %s", got[0].SessionID, got[1].SessionID, got[2].SessionID)
	}
}

func TestList_PaginationLimitOffset(t *testing.T) {
	repo, db := newRepo(t)
	for i := 0; i < 5; i++ {
		sid := "s" + string(rune('0'+i))
		seedSession(t, db, "alice", "cc", "phoebe", sid, int64(1700000000+i*100), int64(1700000010+i*100))
	}

	page1, err := repo.List(context.Background(), session.ListFilter{
		Owner:  session.OwnerScope{User: "alice"},
		Limit:  2,
		Offset: 0,
	})
	if err != nil {
		t.Fatalf("page1: %v", err)
	}
	if len(page1) != 2 {
		t.Fatalf("page1 len=%d; want 2", len(page1))
	}
	// Newest first: s4, s3
	if page1[0].SessionID != "s4" || page1[1].SessionID != "s3" {
		t.Fatalf("page1 unexpected: %s, %s", page1[0].SessionID, page1[1].SessionID)
	}

	page2, err := repo.List(context.Background(), session.ListFilter{
		Owner:  session.OwnerScope{User: "alice"},
		Limit:  2,
		Offset: 2,
	})
	if err != nil {
		t.Fatalf("page2: %v", err)
	}
	if len(page2) != 2 {
		t.Fatalf("page2 len=%d; want 2", len(page2))
	}
	if page2[0].SessionID != "s2" || page2[1].SessionID != "s1" {
		t.Fatalf("page2 unexpected: %s, %s", page2[0].SessionID, page2[1].SessionID)
	}
}

func TestList_OwnerAllOwners(t *testing.T) {
	repo, db := newRepo(t)
	seedSession(t, db, "alice", "cc", "phoebe", "sA", 1700000000, 1700000010)
	seedSession(t, db, "bob", "cc", "phoebe", "sB", 1700000100, 1700000110)

	got, err := repo.List(context.Background(), session.ListFilter{
		Owner: session.OwnerScope{User: "admin", AllOwners: true},
		Limit: 50,
	})
	if err != nil {
		t.Fatalf("List: %v", err)
	}
	if len(got) != 2 {
		t.Fatalf("want 2 rows across owners, got %d", len(got))
	}
	owners := map[string]bool{got[0].Owner: true, got[1].Owner: true}
	if !owners["alice"] || !owners["bob"] {
		t.Fatalf("expected alice + bob; got %v", owners)
	}
}

func TestList_OwnerSpecific(t *testing.T) {
	repo, db := newRepo(t)
	seedSession(t, db, "alice", "cc", "phoebe", "sA", 1700000000, 1700000010)
	seedSession(t, db, "bob", "cc", "phoebe", "sB", 1700000100, 1700000110)

	got, err := repo.List(context.Background(), session.ListFilter{
		Owner: session.OwnerScope{User: "admin", SpecificOwner: ptrString("bob")},
		Limit: 50,
	})
	if err != nil {
		t.Fatalf("List: %v", err)
	}
	if len(got) != 1 || got[0].SessionID != "sB" || got[0].Owner != "bob" {
		t.Fatalf("expected only bob's sB; got %#v", got)
	}
}

func TestList_OwnerUserOnly(t *testing.T) {
	repo, db := newRepo(t)
	seedSession(t, db, "alice", "cc", "phoebe", "sA", 1700000000, 1700000010)
	seedSession(t, db, "bob", "cc", "phoebe", "sB", 1700000100, 1700000110)

	got, err := repo.List(context.Background(), session.ListFilter{
		Owner: session.OwnerScope{User: "alice"},
		Limit: 50,
	})
	if err != nil {
		t.Fatalf("List: %v", err)
	}
	if len(got) != 1 || got[0].Owner != "alice" {
		t.Fatalf("expected only alice's row; got %#v", got)
	}
}

func TestGet_OwnRow_Returns200WithTurnsInSeqOrder(t *testing.T) {
	repo, db := newRepo(t)
	seedSession(t, db, "alice", "cc", "phoebe", "s1", 1700000000, 1700000200)
	seedTurn(t, db, "alice", "cc", "phoebe", "s1", "tC", 3, 1700000150, "user", "third")
	seedTurn(t, db, "alice", "cc", "phoebe", "s1", "tA", 1, 1700000010, "user", "first")
	seedTurn(t, db, "alice", "cc", "phoebe", "s1", "tB", 2, 1700000080, "assistant", "second")

	got, err := repo.Get(context.Background(), session.OwnerScope{User: "alice"}, "cc", "phoebe", "s1")
	if err != nil {
		t.Fatalf("Get: %v", err)
	}
	if got == nil || got.SessionID != "s1" {
		t.Fatalf("missing session; got %#v", got)
	}
	if len(got.Turns) != 3 {
		t.Fatalf("want 3 turns, got %d", len(got.Turns))
	}
	if got.Turns[0].TurnID != "tA" || got.Turns[1].TurnID != "tB" || got.Turns[2].TurnID != "tC" {
		t.Fatalf("turns out of order: %s, %s, %s",
			got.Turns[0].TurnID, got.Turns[1].TurnID, got.Turns[2].TurnID)
	}
}

func TestGet_OtherOwnersRow_ReturnsNotFound(t *testing.T) {
	repo, db := newRepo(t)
	seedSession(t, db, "bob", "cc", "phoebe", "sB", 1700000000, 1700000010)

	_, err := repo.Get(context.Background(), session.OwnerScope{User: "alice"}, "cc", "phoebe", "sB")
	if err == nil {
		t.Fatalf("expected NOT_FOUND, got nil")
	}
	if code := codeOf(err); code != "NOT_FOUND" {
		t.Fatalf("expected code NOT_FOUND, got %q", code)
	}
}

func TestGet_AdminAllOwners_FetchesAnyOwner(t *testing.T) {
	repo, db := newRepo(t)
	seedSession(t, db, "bob", "cc", "phoebe", "sB", 1700000000, 1700000010)
	seedTurn(t, db, "bob", "cc", "phoebe", "sB", "tA", 1, 1700000005, "user", "hi")

	got, err := repo.Get(context.Background(),
		session.OwnerScope{User: "admin", AllOwners: true},
		"cc", "phoebe", "sB",
	)
	if err != nil {
		t.Fatalf("Get: %v", err)
	}
	if got == nil || got.Owner != "bob" {
		t.Fatalf("expected bob's session; got %#v", got)
	}
	if len(got.Turns) != 1 || got.Turns[0].Owner != "bob" {
		t.Fatalf("expected 1 turn owned by bob; got %#v", got.Turns)
	}
}

// codeOf walks the culpa chain for a CodeDetail and returns the string code,
// or "" if there isn't one. Local helper so tests don't reach into apierror's
// unexported lookup.
func codeOf(err error) string {
	var cd culpa.CodeDetail
	if !culpa.FindDetail(err, &cd) {
		return ""
	}
	s, ok := cd.Code.(string)
	if !ok {
		return ""
	}
	return s
}