// 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" "log/slog" "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" ) // 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 WIZARD is enabled — the config toggle // `setup` (D-045). When nil it defaults to Config.Setup; the entrypoint // also clears it in-process once the wizard completes. SetupMode func() bool // LockedMode reports whether the instance is LOCKED: `setup: false` and no // admin exists yet (D-045). In that state `GET /` and `GET /setup` render // the "enable setup" boilerplate and every other route is 404 — the state // is deliberately not publicly claimable. When nil it is never locked. LockedMode func() bool // SetupGate is the first-run gate: the OPTIONAL setup code + the signed // unlock cookie, and the first-run pages (D-044/D-045). When nil, setup // mode keeps the strict behaviour (only `/setup` served, everything else // 404) — the entrypoint always wires it. SetupGate SetupGate // 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 setup func() bool locked func() bool gate SetupGate roles RoleGate log *slog.Logger now func() time.Time limiter *middleware.RateLimiter } // SetupGate is the first-run gate the door consults. It reports whether a // required setup code has been unlocked and renders the first-run pages that // replace a bare 404 (D-044/D-045). It is implemented by *setup.Gate; // declaring the seam here keeps the door free of the setup package's internals. type SetupGate interface { // Required reports whether a setup code is required to reach the wizard // (D-045: `setup_code` is set). When false the `setup` toggle alone admits. Required() bool // Unlocked reports whether the request carries a valid setup-code unlock // (D-044). Unlocked(r *http.Request) bool // CodeForm renders the setup-code entry page (setup_code set, not yet // unlocked). CodeForm(w http.ResponseWriter, r *http.Request) error // Landing renders the page `GET /` shows while setup is enabled. Landing(w http.ResponseWriter, r *http.Request) error // Locked renders the "enable setup and restart" page (setup: false, no // admin). Locked(w http.ResponseWriter, r *http.Request) error } // 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 } } locked := d.LockedMode if locked == nil { locked = func() bool { return false } } 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, setup: setup, locked: locked, gate: d.SetupGate, 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. Any address may be // bound — including `0.0.0.0:8080` behind a reverse proxy (D-043); the // bootstrap is gated per request by the `setup` toggle (D-045), not by a bind // check. func (s *Server) Serve(listen string) error { 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) // The scheme is resolved per request (D-043): TLS, or a trusted proxy's // X-Forwarded-Proto. Every cookie writer reads it back off the context. secure := httpx.RequestIsHTTPS(r, s.trusted) ctx = httpx.WithSecure(ctx, secure) // 3. security headers. httpx.SetSecurityHeaders(w, 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 / D-045: the setup gate is the config toggle `setup`, // evaluated per request. // // * wizard mode (`setup: true`) — `GET /setup` serves the wizard. If a // `setup_code` is set it must be unlocked first (D-044); `GET /` points // at the wizard; every other route is 404. // * locked mode (`setup: false`, no admin) — `GET /` and `GET /setup` // render the "enable setup" boilerplate; every other route is 404. // Deliberately not claimable: the operator must flip the toggle. if s.setup() { admitted := s.setupAdmitted(r) // Static assets are the first-run pages' stylesheet — served in every // first-run state (SPEC-005 §3). static := res.Kind == routing.KindRoute && res.Workflow == "static" switch { case res.Kind == routing.KindRedirect && res.Redirect == "/home": // `GET /` — point at the wizard (never the app, D-045). s.serveLanding(w, r, cid, ip) return case res.Kind == routing.KindSetup && !admitted: // `GET /setup` with a required code not yet entered. s.serveCodeForm(w, r, cid, ip) return case static: // fall through — the wizard's stylesheet. case !s.isSetupUnlock(res) && (!admitted || !setupAllowed(res)): // SPEC-005 §3: once admitted, the app serves ONLY /setup; every // other route is 404 (delete, don't redirect). The unlock action is // the one exception — it is what grants the unlock (D-044). s.log.Warn("setup request refused: the wizard is not admitted", "correlation_id", cid, "client_ip", ip, "path", httpx.RedactPath(r.URL.Path)) s.writeError(w, http.StatusNotFound, "not found") return } } else if s.locked() { // D-045: `setup: false` and no admin — a friendly, non-claimable // boilerplate at `/` and `/setup`; 404 elsewhere (static assets are the // one exception, so the boilerplate renders inside the shell). switch { case res.Kind == routing.KindRedirect && res.Redirect == "/home": s.serveLocked(w, r, cid, ip) return case res.Kind == routing.KindSetup: s.serveLocked(w, r, cid, ip) return case res.Kind == routing.KindRoute && res.Workflow == "static": // fall through — the boilerplate's stylesheet (SPEC-005 §3). default: 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 } // M1c/L3s — pin the setup MODULE at the door: the wizard's functions are // reachable ONLY through the reserved `setup` workflow. Any other // resolution naming `do=setup` (a GUID path, a reserved workflow) is // invalid in every mode and 404s rather than falling through to the // module registry or the GUID door. if res.Module == setupModule { s.writeError(w, http.StatusNotFound, "not found") 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 the wizard itself — the // reserved `setup` workflow, path or action (SPEC-005 §3). Used while the // wizard is enabled: everything else is 404. Static assets are handled // separately (they are served in every first-run state). func setupAllowed(res routing.Resolution) bool { switch res.Kind { case routing.KindSetup: return true case routing.KindAction: return res.Workflow == "setup" } return false } // setupAdmitted reports whether the request may reach the wizard while // `setup: true` (D-045): always when no `setup_code` is required, otherwise // only with a valid code unlock (D-044). func (s *Server) setupAdmitted(r *http.Request) bool { if s.gate == nil || !s.gate.Required() { return true } return s.gate.Unlocked(r) } // isSetupUnlock reports whether res is the D-044 unlock action — the ONE request // served while the wizard is enabled without an unlock, because it is what // grants the unlock. The door still pins it to the `setup` module // (serveSetupAction), so a `do=core` under /setup cannot ride this carve-out. func (s *Server) isSetupUnlock(res routing.Resolution) bool { return res.Kind == routing.KindAction && res.Workflow == "setup" && res.Module == setupModule && res.Function == "unlock" } // serveCodeForm renders the setup-code entry page — `GET /setup` with a required // code not yet entered. A nil gate (not wired) keeps the strict 404; the // entrypoint always wires it. func (s *Server) serveCodeForm(w http.ResponseWriter, r *http.Request, cid, ip string) { s.serveGatePage(w, r, cid, ip, "code form", func(g SetupGate) error { return g.CodeForm(w, r) }) } // serveLanding renders the page `GET /` shows while `setup: true` (D-045). func (s *Server) serveLanding(w http.ResponseWriter, r *http.Request, cid, ip string) { s.serveGatePage(w, r, cid, ip, "setup landing", func(g SetupGate) error { return g.Landing(w, r) }) } // serveLocked renders the "enable setup" boilerplate (D-045). func (s *Server) serveLocked(w http.ResponseWriter, r *http.Request, cid, ip string) { s.serveGatePage(w, r, cid, ip, "locked boilerplate", func(g SetupGate) error { return g.Locked(w, r) }) } // serveGatePage hands off to a first-run page rendered by the gate, mapping a // render fault to a logged 500. A nil gate keeps the strict 404 — the // entrypoint always wires it. func (s *Server) serveGatePage(w http.ResponseWriter, r *http.Request, cid, ip, label string, render func(SetupGate) error) { if s.gate == nil { s.writeError(w, http.StatusNotFound, "not found") return } if err := render(s.gate); err != nil { s.log.Error("setup: render "+label, "correlation_id", cid, "client_ip", ip, "error", err) s.writeError(w, http.StatusInternalServerError, "internal error") } } // 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 } // M1c: the door pins the action door to the `setup` module. Without this a // `do=core&action=modules.toggle` under /setup would reach a core action // that the session-gated role gate (admin-only) would otherwise protect. if res.Module != setupModule { 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). // Over https the cookie name carries the `__Host-` prefix (L1s/D-043). secure := httpx.SecureFromRequest(r) cookieName := httpx.SessionCookieNameFor(secure) cookie, err := r.Cookie(cookieName) 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, cookieName, 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")) } // 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)