Vellum/internal/httpapi/server.go
mithral 9772e55c73 Phase 1: accounts, sessions, per-library grants
Multi-user from the first migration, as planned.

- auth: argon2id password hashing (PHC encoded, parameters travel with the
  hash), opaque session tokens stored only as SHA-256, login rate limiting,
  and account enumeration resistance (unknown user hashes anyway)
- sessions: cookie or Bearer auth, HttpOnly + SameSite=Lax, revocation on
  password change
- grants: library_grants is the single access-control chokepoint. Admins see
  every library; everyone else sees exactly their can_read grants
- bootstrap: VELLUM_ADMIN_USER/PASSWORD create the first admin only on an
  empty database. No default password is ever invented.
- httpapi: mounts modules on a shared mux, session middleware innermost
- httpx: shared JSON helpers, strict body decoding

Gate: two users see different libraries. Tests cover that, plus session
expiry, token-at-rest, grant upsert, and the full login/me/logout flow.
2026-10-09 16:31:24 -04:00

167 lines
4.6 KiB
Go

// Package httpapi is the HTTP layer: routing, middleware, and handlers.
//
// In Phase 0 it serves only health and version. Feature modules (auth, library,
// ebooks, comics, ...) mount their own routes onto the same mux via
// Server.Handle, so this package never imports a feature module.
package httpapi
import (
"context"
"crypto/rand"
"database/sql"
"encoding/hex"
"encoding/json"
"log/slog"
"net/http"
"time"
"github.com/mithral/vellum/internal/auth"
)
// Server holds the dependencies every handler needs.
type Server struct {
db *sql.DB
log *slog.Logger
version string
started time.Time
mux *http.ServeMux
auth *auth.Authenticator
}
// New builds a Server and registers the core routes.
func New(db *sql.DB, log *slog.Logger, version string) *Server {
s := &Server{
db: db,
log: log,
version: version,
started: time.Now(),
mux: http.NewServeMux(),
}
s.routes()
return s
}
// Mux exposes the root mux so feature modules can mount their own routes.
func (s *Server) Mux() *http.ServeMux { return s.mux }
// SetAuth installs the session middleware. Until it is set, no request carries
// a current user.
func (s *Server) SetAuth(a *auth.Authenticator) { s.auth = a }
// Handle lets a module mount routes on the shared mux.
func (s *Server) Handle(pattern string, h http.Handler) {
s.mux.Handle(pattern, h)
}
func (s *Server) routes() {
s.mux.HandleFunc("GET /healthz", s.handleHealth)
s.mux.HandleFunc("GET /api/v1/version", s.handleVersion)
s.mux.HandleFunc("/", s.handleNotFound)
}
// Handler returns the fully wrapped root handler.
//
// Order matters: the request ID must be attached before the logging
// middleware runs, so the log line can include it. Session loading sits
// innermost so it runs after logging has started timing.
func (s *Server) Handler() http.Handler {
var h http.Handler = s.mux
if s.auth != nil {
h = s.auth.Middleware(h)
}
return s.withRecovery(s.withRequestID(s.withRequestLogging(h)))
}
// ── Middleware ─────────────────────────────────────────────────────────
type ctxKey string
const ctxRequestID ctxKey = "request_id"
func withValue(ctx context.Context, k ctxKey, v string) context.Context {
return context.WithValue(ctx, k, v)
}
func valueString(ctx context.Context, k ctxKey) string {
if v, ok := ctx.Value(k).(string); ok {
return v
}
return ""
}
func (s *Server) withRequestID(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id := r.Header.Get("X-Request-ID")
if id == "" {
var b [8]byte
_, _ = rand.Read(b[:])
id = hex.EncodeToString(b[:])
}
w.Header().Set("X-Request-ID", id)
next.ServeHTTP(w, r.WithContext(withValue(r.Context(), ctxRequestID, id)))
})
}
func (s *Server) withRequestLogging(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(rec, r)
s.log.Info("request",
"method", r.Method,
"path", r.URL.Path,
"status", rec.status,
"bytes", rec.bytes,
"duration_ms", time.Since(start).Milliseconds(),
"request_id", valueString(r.Context(), ctxRequestID),
)
})
}
func (s *Server) withRecovery(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rec := recover(); rec != nil {
s.log.Error("panic",
"path", r.URL.Path,
"recover", rec,
"request_id", valueString(r.Context(), ctxRequestID),
)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal server error"})
}
}()
next.ServeHTTP(w, r)
})
}
// ── Helpers ────────────────────────────────────────────────────────────
type statusRecorder struct {
http.ResponseWriter
status int
bytes int
}
func (r *statusRecorder) WriteHeader(code int) {
r.status = code
r.ResponseWriter.WriteHeader(code)
}
func (r *statusRecorder) Write(b []byte) (int, error) {
n, err := r.ResponseWriter.Write(b)
r.bytes += n
return n, err
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
func (s *Server) handleNotFound(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusNotFound, map[string]string{
"error": "not found",
"path": r.URL.Path,
})
}