v0.281.0: 'Done' asks the probe first; sign-up closed after the first admin (decision 47); R-713 code-bound values refused, ${NAME|base64}
gates / gates (push) Successful in 23s

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 09:57:58 +02:00
parent 3a12c7341a
commit 149467c795
28 changed files with 1928 additions and 15 deletions
+59
View File
@@ -48,6 +48,8 @@ const (
gateAuthPath = "/__felhom_gate/auth"
gateCallbackURI = "/__felhom_gate/cb"
gateStartPath = "/__gate/start"
// signupClosedPath: the sign-up block's replacePath target (internal/stacks/signup_block.go, decision 47).
signupClosedPath = "/__felhom_gate/signup-closed"
)
type gateState struct {
@@ -285,6 +287,14 @@ func (s *Server) appSetupGateOpenHandler(w http.ResponseWriter, r *http.Request,
escrowJSON(w, http.StatusNotFound, nil, s.msg(r, "escrow.unknown_app"))
return
}
// v0.281.0: an app that can say whether its setup is done is ASKED first — the press never opens an app that
// still says "not done" (measured 2026-09-29: uptime-kuma pressed before its setup answered anyone). An
// unreadable status refuses too (fail closed). Without a probe the page's confirm says what the press does.
if has, done, got, perr := s.stackMgr.SetupGateProbe(found.Name); has && (perr != nil || !done) {
s.logger.Printf("[INFO] [web] setup gate %s: the household's press refused — the app's own status says not done (value %q, err %v)", found.Name, got, perr)
escrowJSON(w, http.StatusConflict, nil, s.msg(r, "err.setup_gate.probe_not_done"))
return
}
if err := s.stackMgr.OpenSetupGate(found.Name, stacks.SetupGateByHousehold); err != nil {
if errors.Is(err, stacks.ErrSetupGateNotClosed) {
escrowJSON(w, http.StatusConflict, nil, s.msg(r, "err.setup_gate.not_closed"))
@@ -326,3 +336,52 @@ func (s *Server) stackBySlug(slug string) *stacks.Stack {
}
return nil
}
// appSignupWindowHandler is the household's "open sign-up for 15 minutes" (POST /apps/<slug>/signup-window, decision 47).
func (s *Server) appSignupWindowHandler(w http.ResponseWriter, r *http.Request, slug string) {
found := s.stackBySlug(slug)
if found == nil {
escrowJSON(w, http.StatusNotFound, nil, s.msg(r, "escrow.unknown_app"))
return
}
until, err := s.stackMgr.OpenSignupWindow(found.Name)
if err != nil {
if errors.Is(err, stacks.ErrNoSignupBlock) {
escrowJSON(w, http.StatusConflict, nil, s.msg(r, "err.setup_gate.no_signup_block"))
return
}
s.logger.Printf("[ERROR] [web] signup window %s: %v", found.Name, err)
escrowJSON(w, http.StatusInternalServerError, nil, s.msg(r, "err.setup_gate.open_failed"))
return
}
escrowJSON(w, http.StatusOK, map[string]any{"open_until": until}, "")
}
// ServeSignupClosed answers the app's own sign-up address while it is closed (the sign-up block's replacePath
// sends it here). A browser gets a page, anything else a 403 JSON. It holds no secret and changes nothing.
func (s *Server) ServeSignupClosed(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
if !strings.Contains(r.Header.Get("Accept"), "text/html") || r.Method != http.MethodGet {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusForbidden)
_, _ = w.Write([]byte(`{"error":"sign-up is closed on this app; its admin adds new accounts"}`))
return
}
host := strings.ToLower(r.Host)
if i := strings.LastIndex(host, ":"); i != -1 {
host = host[:i]
}
data := map[string]interface{}{"Host": host}
if s.stackMgr != nil {
if app, _, found := s.stackMgr.SetupGateHost(host); found {
if st, ok := s.stackMgr.GetStack(app); ok {
data["AppName"] = st.Meta.DisplayName
}
}
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusForbidden)
if err := s.executeTemplateLang(w, r, "signupclosed", data); err != nil {
s.logger.Printf("[ERROR] [web] signup-closed page: %v", err)
}
}