v0.280.0: the setup gate (decision 46); R-710 'I changed it' + absent-record window; R-709 password fields off the page; password:N:special generator
gates / gates (push) Successful in 25s

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
2026-09-29 08:51:46 +02:00
parent 2548b4c924
commit 7fa8768cfd
37 changed files with 4015 additions and 107 deletions
+23 -4
View File
@@ -46,10 +46,13 @@ type Server struct {
updater *selfupdate.Updater
logger *log.Logger
version string
encKey []byte // AES-256 key for decrypting app.yaml values
tmpl *template.Template // the Hungarian set (i18n.Default) — every pre-i18n caller renders this
tmplByLang map[string]*template.Template
i18n *i18n.Bundle
encKey []byte // AES-256 key for decrypting app.yaml values
// gate / gateClock (v0.280.0, decision 46): the setup gate's key + used tokens; the clock is a test seam.
gate gateState
gateClock func() time.Time
tmpl *template.Template // the Hungarian set (i18n.Default) — every pre-i18n caller renders this
tmplByLang map[string]*template.Template
i18n *i18n.Bundle
// versionPosition (v0.275.0) — where a just-restored app stands against the catalog; nil → the stack
// manager's RestoredVersionPosition. A seam so the restore sentence is testable without a catalog.
@@ -811,6 +814,12 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// R-254: the app's generated first-login password is fetched by an explicit authenticated act,
// never templated into the info page. Placed BEFORE the /apps/ catch-all so the more specific
// path wins. POST (not GET) so CsrfProtect covers it and it is not cacheable — see the handler.
// v0.280.0: the household's two presses on the app page — "Done, I set it up" (decision 46) and "I changed
// it" under a known default login (R-710). POST, so CsrfProtect covers them.
case strings.HasPrefix(path, "/apps/") && strings.HasSuffix(path, "/setup-gate/open") && r.Method == http.MethodPost:
s.appSetupGateOpenHandler(w, r, strings.TrimSuffix(strings.TrimPrefix(path, "/apps/"), "/setup-gate/open"))
case strings.HasPrefix(path, "/apps/") && strings.HasSuffix(path, "/default-login/changed") && r.Method == http.MethodPost:
s.appDefaultLoginChangedHandler(w, r, strings.TrimSuffix(strings.TrimPrefix(path, "/apps/"), "/default-login/changed"))
case strings.HasPrefix(path, "/apps/") && strings.HasSuffix(path, "/initial-credentials/reveal") && r.Method == http.MethodPost:
slug := strings.TrimSuffix(strings.TrimPrefix(path, "/apps/"), "/initial-credentials/reveal")
s.appInitialCredsRevealHandler(w, r, slug)
@@ -839,6 +848,16 @@ func (s *Server) CatchAllMiddleware(next http.Handler) http.Handler {
if idx := strings.LastIndex(host, ":"); idx != -1 {
host = host[:idx]
}
// v0.280.0 (decision 46): traefik's forwardAuth call for a gated app arrives with the controller's own
// container name as Host, so it is answered before the host check. The start page is the dashboard's.
if r.URL.Path == gateAuthPath {
s.ServeGateAuth(w, r)
return
}
if r.URL.Path == gateStartPath && strings.EqualFold(host, controllerHost) && r.Method == http.MethodGet {
s.ServeGateStart(w, r)
return
}
// Pass through: controller host, localhost (healthcheck/internal), or empty
if strings.EqualFold(host, controllerHost) || host == "" ||
host == "localhost" || host == "127.0.0.1" {