NextMyDay/internal/core/server/server.go
Claus Lohmar d11e14295b feat(core): add the store, identity plumbing and the door (SPEC-003/004)
- store: SQLite (WAL, foreign keys ON, CGO-free driver) + the embedded
  migration runner; each module's migrations are name-prefixed (D-012)
- audit: the append-only audit log; the actor threads via context (R-1)
- users: the user_id/GUID/email anchors (D-013)
- config: the YAML settings file (D-035) + validation
- httpx: cookies · loopback detection · trusted-proxy XFF · CSRF · security
  headers · redaction
- middleware: correlation id · audit · rate limiting
- mail: the invite/recovery mailer seam
- server: the core DOOR — the resolution switch, the dispatch registry
  (RegisterModule), the role gates, /setup · enroll/login · recovery flows,
  the reserved `static` workflow, and the loopback-only bootstrap (D-025)
2026-10-14 10:53:51 +00:00

591 lines
19 KiB
Go

// Package server is MyDay's ONE door (SPEC-003 §1/§4): it turns an inbound
// request into a dispatched module function, applying identity · session · role
// and every cross-cutting concern. The door RESOLVES and GATES; a module never
// sees a request it should not (D-002, D-039).
//
// The pipeline (SPEC-003 §4):
//
// 1. correlation id -> context + response header
// 2. client IP -> from server.trusted_proxies (SET XFF, A6)
// 3. security headers
// 4. rate limit -> /{GUID}, /access, /setup (A3)
// 5. routing.Dispatch -> the pure model (SPEC-002)
// 6. the Kind switch -> Redirect · NotFound · Setup · Enroll · Route/Action
// Route/Action under a GUID -> the identity/session/role gate:
// session valid? (non-empty, not expired/revoked — A2)
// session.user_id == the GUID's user?
// role permits the function?
// attach audit.Meta -> dispatch
//
// Spec: docs/specs/spec-003-core-door.md.
package server
import (
"errors"
"fmt"
"log/slog"
"net"
"net/http"
"strconv"
"strings"
"time"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/audit"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/auth"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/config"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/httpx"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/middleware"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/routing"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/users"
)
// ErrNonLoopbackSetup is returned when a non-loopback listen address is
// requested while in setup mode (D-025) — the process must refuse to start.
var ErrNonLoopbackSetup = errors.New("server: refusing to bind a non-loopback address in setup mode")
// Deps assembles the door's dependencies.
type Deps struct {
// Config is the loaded configuration.
Config *config.Config
// Resolver answers the pure model's GUID question (SPEC-002 §3.1).
Resolver routing.Resolver
// Registry is the module + core-handler dispatch surface.
Registry Registry
// Sessions validates the session cookie.
Sessions auth.Sessions
// Identities resolves a GUID to a user (the door's own lookup, distinct
// from the pure Resolver surface).
Identities auth.Identities
// Users loads the authenticated user's role for the role gate.
Users users.Users
// Audit receives the door's and the services' audit rows.
Audit audit.Audit
// CSRF verifies action requests (A8).
CSRF *httpx.CSRF
// PreSession verifies the anonymous enroll/login double-submit token
// (A15). When nil the `auth` action module is unavailable (404).
PreSession *httpx.PreSessionCSRF
// TrustedProxies overrides the set parsed from Config.Server.TrustedProxies.
TrustedProxies httpx.TrustedProxies
// SetupMode reports whether the instance is in setup mode (D-025). When nil
// it defaults to Config.Setup; the entrypoint widens it to "or no user
// exists" (SPEC-005 §3).
SetupMode func() bool
// RoleGate enforces per-function authorisation. Defaults to
// AllowAuthenticated.
RoleGate RoleGate
// Logger receives structured logs. Defaults to slog.Default().
Logger *slog.Logger
// Now is the clock (tests). Defaults to time.Now.
Now func() time.Time
// RateLimit / RateWindow tune the auth-sensitive limiter (A3).
RateLimit int
RateWindow time.Duration
}
// Server is the door.
type Server struct {
cfg *config.Config
disp *routing.Dispatcher
reg Registry
sessions auth.Sessions
ids auth.Identities
users users.Users
audit audit.Audit
csrf *httpx.CSRF
precsrf *httpx.PreSessionCSRF
trusted httpx.TrustedProxies
secure bool
setup func() bool
roles RoleGate
log *slog.Logger
now func() time.Time
limiter *middleware.RateLimiter
}
// New validates the dependencies and builds the door.
func New(d Deps) (*Server, error) {
if d.Config == nil {
return nil, errors.New("server: Config is required")
}
if d.Resolver == nil {
return nil, errors.New("server: Resolver is required")
}
if d.Registry == nil {
return nil, errors.New("server: Registry is required")
}
if d.Sessions == nil {
return nil, errors.New("server: Sessions is required")
}
if d.Identities == nil {
return nil, errors.New("server: Identities is required")
}
if d.Users == nil {
return nil, errors.New("server: Users is required")
}
if d.CSRF == nil {
return nil, errors.New("server: CSRF is required")
}
trusted := d.TrustedProxies
if trusted == nil {
var err error
trusted, err = httpx.ParseTrustedProxies(d.Config.Server.TrustedProxies)
if err != nil {
return nil, err
}
}
setup := d.SetupMode
if setup == nil {
cfg := d.Config
setup = func() bool { return cfg.Setup }
}
logger := d.Logger
if logger == nil {
logger = slog.Default()
}
clock := d.Now
if clock == nil {
clock = time.Now
}
roles := d.RoleGate
if roles == nil {
roles = AllowAuthenticated{}
}
return &Server{
cfg: d.Config,
disp: routing.New(d.Resolver, d.Registry),
reg: d.Registry,
sessions: d.Sessions,
ids: d.Identities,
users: d.Users,
audit: d.Audit,
csrf: d.CSRF,
precsrf: d.PreSession,
trusted: trusted,
secure: d.Config.IsSecure(),
setup: setup,
roles: roles,
log: logger,
now: clock,
limiter: middleware.NewRateLimiter(d.RateLimit, d.RateWindow),
}, nil
}
// Handler wraps the door with the correlation-id middleware (SPEC-003 §4).
func (s *Server) Handler() http.Handler { return middleware.CorrelationID(s) }
// Serve binds listen and serves the door until it fails. In setup mode a
// non-loopback listen is refused outright (D-025, AC-7).
func (s *Server) Serve(listen string) error {
if err := CheckSetupBind(s.setup(), listen); err != nil {
return err
}
s.log.Info("myday: listening", "addr", listen, "setup_mode", s.setup())
srv := &http.Server{
Addr: listen,
Handler: s.Handler(),
ReadHeaderTimeout: 10 * time.Second,
}
return srv.ListenAndServe()
}
// ServeHTTP is the door pipeline (SPEC-003 §4).
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
start := s.now()
ctx := r.Context()
cid := middleware.CorrelationFrom(ctx)
if cid == "" {
cid = middleware.NewCorrelationID()
ctx = middleware.WithCorrelation(ctx, cid)
}
w.Header().Set(middleware.CorrelationHeader, cid)
// 2. client IP (SET XFF, never append — A6).
ip := httpx.ClientIP(r, s.trusted)
// 3. security headers.
httpx.SetSecurityHeaders(w, s.secure)
// The door attaches audit.Meta before any service call (R-1). The actor is
// filled in once the identity gate resolves it.
ctx = middleware.AttachAudit(ctx, 0, ip, cid)
r = r.WithContext(ctx)
sw := &statusWriter{ResponseWriter: w, status: http.StatusOK}
var res routing.Resolution
defer func() {
s.log.Info("request",
"correlation_id", cid,
"method", r.Method,
"path", httpx.RedactPath(r.URL.Path),
"query", httpx.RedactQuery(r.URL.RawQuery),
"client_ip", ip,
"status", sw.status,
"workflow", res.Workflow,
"module", res.Module,
"function", res.Function,
"duration_ms", s.now().Sub(start).Milliseconds(),
)
}()
// 4. rate limit the auth-sensitive routes (A3, AC-9).
if class := middleware.Class(r.URL.Path); class != "" {
key := ip + "|" + class
if !s.limiter.Allow(key) {
if secs := s.limiter.RetryAfter(key); secs > 0 {
sw.Header().Set("Retry-After", strconv.Itoa(secs))
}
s.writeError(sw, http.StatusTooManyRequests, "too many requests")
return
}
}
// 5. the pure model.
res = s.disp.Dispatch(r.URL.Path, r.FormValue("do"), r.FormValue("action"))
// 6. the Kind switch.
s.dispatch(sw, r, res, cid, ip)
}
// dispatch is the Kind switch (SPEC-003 §4 step 5).
func (s *Server) dispatch(w http.ResponseWriter, r *http.Request, res routing.Resolution, cid, ip string) {
// SPEC-005 §3: in setup mode the app serves ONLY /setup (+ static assets);
// every other route is 404 (delete, don't redirect). Reserved names like
// /home and /access are 404'd too — the bootstrap has no app yet.
if s.setup() && !setupAllowed(res) {
s.writeError(w, http.StatusNotFound, "not found")
return
}
switch res.Kind {
case routing.KindRedirect:
// The ONE canonical redirect ("/" -> "/home").
http.Redirect(w, r, res.Redirect, http.StatusFound)
case routing.KindNotFound:
s.writeError(w, http.StatusNotFound, "not found")
case routing.KindSetup:
// /setup is served ONLY in setup mode; otherwise it 404s (delete,
// don't redirect — SPEC-005 §5).
if !s.setup() {
s.writeError(w, http.StatusNotFound, "not found")
return
}
s.serveCore(w, r, "setup", res, cid, ip)
case routing.KindEnroll:
// GUID found but not enrolled -> the enrollment screen (SPEC-006).
s.serveCore(w, r, "enroll", res, cid, ip)
case routing.KindLogin:
s.serveCore(w, r, "login", res, cid, ip)
case routing.KindRoute, routing.KindAction:
// /setup is setup-mode-only through BOTH doors — a path request
// classifies as KindSetup, but `do`/`action` under /setup classifies as
// KindAction with Workflow "setup". Productive mode 404s either way
// (delete, don't redirect — SPEC-005 §5).
if res.Workflow == "setup" && !s.setup() {
s.writeError(w, http.StatusNotFound, "not found")
return
}
// SPEC-005: the wizard's steps are actions under the reserved `setup`
// workflow. They run anonymously (pre-session CSRF, A15).
if res.Kind == routing.KindAction && res.Workflow == "setup" {
s.serveSetupAction(w, r, res, cid, ip)
return
}
// SPEC-006: the `auth` module's actions are the enroll/login flows.
// They run BEFORE the session gate (the whole point is to obtain a
// session); logout is the exception and stays session-gated.
if res.Kind == routing.KindAction && res.Module == authModule &&
!routing.IsReservedWorkflow(res.Workflow) {
s.serveAuthAction(w, r, res, cid, ip)
return
}
// SPEC-007: the anonymous /access recovery action — the recovery
// request form (`do=auth`). It runs before any session (pre-session
// CSRF, A15) and is always a generic response (A4). Only the `auth`
// module is served here: the session-gated approve/deny actions must
// ride a GUID path and are deliberately NOT reachable at /access.
if res.Kind == routing.KindAction && res.Workflow == "access" && res.Module == authModule {
s.serveAccessAction(w, r, res, cid, ip)
return
}
// A reserved level-1 workflow (home · access · api · static · …) is
// core-owned and needs no identity gate.
if routing.IsReservedWorkflow(res.Workflow) {
s.serveCore(w, r, res.Workflow, res, cid, ip)
return
}
// Otherwise it is a GUID branch: the identity/session/role gate.
s.serveGUID(w, r, res, cid, ip)
default:
s.writeError(w, http.StatusNotFound, "not found")
}
}
// setupAllowed reports whether a Resolution is one of the only routes served
// while in setup mode: the wizard itself (`/setup`, path or action) and static
// assets (SPEC-005 §3). Everything else — including the canonical redirect and
// every GUID branch — is 404.
func setupAllowed(res routing.Resolution) bool {
switch res.Kind {
case routing.KindSetup:
return true
case routing.KindAction:
return res.Workflow == "setup"
case routing.KindRoute:
return res.Workflow == "static"
}
return false
}
// serveCore hands off to a core-owned workflow handler.
func (s *Server) serveCore(w http.ResponseWriter, r *http.Request, workflow string, res routing.Resolution, cid, ip string) {
h, ok := s.reg.CoreHandler(workflow)
if !ok {
s.writeError(w, http.StatusNotFound, "not found")
return
}
s.invoke(w, r, h, newRequestContext(res, 0, cid, ip))
}
// serveAuthAction runs the anonymous enroll/login actions (SPEC-006 §4/§5).
// It runs BEFORE the identity/session gate — the whole point is to obtain a
// session — so it verifies the signed pre-session double-submit token (A15)
// instead of the session-bound one. `logout` is session-gated (serveGUID).
func (s *Server) serveAuthAction(w http.ResponseWriter, r *http.Request, res routing.Resolution, cid, ip string) {
if s.precsrf == nil {
s.writeError(w, http.StatusNotFound, "not found")
return
}
// `logout` revokes an existing session, and the SPEC-006 §8 passkey-nudge
// actions (passkey.add / passkey.later / passkey.never) require one — they
// are session-gated (SPEC-003 §6 CSRF), NOT anonymous like the enroll/login
// actions above.
if res.Function == "logout" || strings.HasPrefix(res.Function, "passkey.") {
s.serveGUID(w, r, res, cid, ip)
return
}
if !s.precsrf.Valid(r, r.FormValue("csrf")) {
s.writeError(w, http.StatusForbidden, "forbidden")
return
}
h, ok := s.reg.Handler(res.Module, res.Function)
if !ok {
s.writeError(w, http.StatusNotFound, "not found")
return
}
s.invoke(w, r, h, newRequestContext(res, 0, cid, ip))
}
// serveSetupAction runs a SPEC-005 wizard step. The wizard is anonymous — no
// session exists before the admin is created — so it verifies the signed
// pre-session double-submit token (A15) rather than the session-bound CSRF. The
// door has already gated this to setup mode.
func (s *Server) serveSetupAction(w http.ResponseWriter, r *http.Request, res routing.Resolution, cid, ip string) {
if !s.setup() {
s.writeError(w, http.StatusNotFound, "not found")
return
}
if s.precsrf == nil {
s.writeError(w, http.StatusNotFound, "not found")
return
}
if !s.precsrf.Valid(r, r.FormValue("csrf")) {
s.writeError(w, http.StatusForbidden, "forbidden")
return
}
h, ok := s.reg.Handler(res.Module, res.Function)
if !ok {
s.writeError(w, http.StatusNotFound, "not found")
return
}
s.invoke(w, r, h, newRequestContext(res, 0, cid, ip))
}
// serveAccessAction runs a SPEC-007 /access action. The recovery entry is
// anonymous — no session exists yet — so it verifies the signed pre-session
// double-submit token (A15) rather than the session-bound CSRF (§6). The
// response is generic whatever the email matches (AC-4).
func (s *Server) serveAccessAction(w http.ResponseWriter, r *http.Request, res routing.Resolution, cid, ip string) {
if s.precsrf == nil {
s.writeError(w, http.StatusNotFound, "not found")
return
}
if !s.precsrf.Valid(r, r.FormValue("csrf")) {
s.writeError(w, http.StatusForbidden, "forbidden")
return
}
h, ok := s.reg.Handler(res.Module, res.Function)
if !ok {
s.writeError(w, http.StatusNotFound, "not found")
return
}
s.invoke(w, r, h, newRequestContext(res, 0, cid, ip))
}
// serveGUID runs the identity/session/role gate, then dispatches to the module
// function (SPEC-003 §4 step 5).
func (s *Server) serveGUID(w http.ResponseWriter, r *http.Request, res routing.Resolution, cid, ip string) {
ctx := r.Context()
// (a) load the session cookie; an absent or empty id is rejected (A2).
cookie, err := r.Cookie(httpx.SessionCookieName)
if err != nil || cookie.Value == "" {
s.loginRequired(w, r, res, cid, ip)
return
}
// Validate: exists · not expired · not revoked · non-empty (A2/AC-5).
sess, err := s.sessions.Validate(ctx, cookie.Value)
if err != nil {
httpx.ClearSessionCookie(w, httpx.SessionCookieName, s.secure)
s.loginRequired(w, r, res, cid, ip)
return
}
// Resolve the GUID's user and require an ACTIVE identity.
idn, err := s.ids.ByGUID(ctx, res.Workflow)
if err != nil || idn.Status != auth.StatusActive {
s.writeError(w, http.StatusNotFound, "not found")
return
}
// (b) the identity match: the session must belong to the GUID's user
// (AC-4). The rejection is generic — no oracle about the session's owner.
if sess.UserID != idn.UserID {
s.loginRequired(w, r, res, cid, ip)
return
}
// (d) the role gate.
u, err := s.users.Get(ctx, sess.UserID)
if err != nil {
s.writeError(w, http.StatusNotFound, "not found")
return
}
if !s.roles.Allowed(u.Role, res.Module, res.Function) {
s.writeError(w, http.StatusForbidden, "forbidden")
return
}
// Attach the actor now that the identity is known (R-1).
ctx = middleware.AttachAudit(ctx, sess.UserID, ip, cid)
// CSRF on every action (A8/AC-6). The routing fields are not a control.
if res.Kind == routing.KindAction {
if !s.csrf.Valid(sess.ID, r.FormValue("csrf")) {
s.writeError(w, http.StatusForbidden, "invalid csrf token")
return
}
}
// (e) build the RequestContext and dispatch.
h, ok := s.reg.Handler(res.Module, res.Function)
if !ok {
s.writeError(w, http.StatusNotFound, "not found")
return
}
ctx = httpx.WithToken(ctx, s.csrf.Token(sess.ID))
r = r.WithContext(ctx)
s.invoke(w, r, h, newRequestContext(res, sess.UserID, cid, ip))
}
// loginRequired hands off to the login screen without revealing why the session
// was rejected (missing, empty, invalid, or belonging to another user). The
// screen is a RENDERED PAGE and answers 200; only a failed credential attempt is
// 401/400 (F-1). The 401 below is a degenerate fallback for a registry that
// carries no login screen at all.
func (s *Server) loginRequired(w http.ResponseWriter, r *http.Request, res routing.Resolution, cid, ip string) {
h, ok := s.reg.CoreHandler("login")
if !ok {
s.writeError(w, http.StatusUnauthorized, "login required")
return
}
s.invoke(w, r, h, newRequestContext(res, 0, cid, ip))
}
// invoke calls a handler and maps a returned error to a 500 (logged).
func (s *Server) invoke(w http.ResponseWriter, r *http.Request, h Handler, rc *RequestContext) {
if err := h(w, r, rc); err != nil {
s.log.Error("handler error",
"correlation_id", rc.Correlation,
"workflow", rc.Workflow,
"function", rc.Function,
"error", err,
)
s.writeError(w, http.StatusInternalServerError, "internal error")
}
}
// writeError writes a terse plain-text error response.
func (s *Server) writeError(w http.ResponseWriter, status int, msg string) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.WriteHeader(status)
_, _ = w.Write([]byte(msg + "\n"))
}
// CheckSetupBind enforces D-025: while in setup mode the process MUST NOT bind
// a non-loopback address. It is fail-closed — an unparseable or wildcard listen
// is refused.
func CheckSetupBind(setup bool, listen string) error {
if !setup {
return nil
}
if !IsLoopback(listen) {
return fmt.Errorf("%w: listen %q", ErrNonLoopbackSetup, listen)
}
return nil
}
// IsLoopback reports whether a listen address ("host:port") is loopback-only.
func IsLoopback(listen string) bool {
host := strings.TrimSpace(listen)
if h, _, err := net.SplitHostPort(listen); err == nil {
host = h
}
host = strings.Trim(strings.TrimSpace(host), "[]")
if host == "" {
return false // ":8080" / "0.0.0.0" bind every interface — never loopback
}
if strings.EqualFold(host, "localhost") {
return true
}
ip := net.ParseIP(host)
return ip != nil && ip.IsLoopback()
}
// statusWriter captures the response status for the access log.
type statusWriter struct {
http.ResponseWriter
status int
wrote bool
}
func (w *statusWriter) WriteHeader(code int) {
if w.wrote {
return
}
w.status = code
w.wrote = true
w.ResponseWriter.WriteHeader(code)
}
func (w *statusWriter) Write(b []byte) (int, error) {
if !w.wrote {
w.WriteHeader(http.StatusOK)
}
return w.ResponseWriter.Write(b)
}
var _ http.Handler = (*Server)(nil)