R-1112: open the family store when the controller starts
gates / gates (push) Successful in 46s

Found on demo-hp with 0.325.0-rc.1: the store (and so the conversion of an old family.json) opened only on the
first family request; a box nobody visits through a family gate kept the session values. OpenFamilyStore from
main, one INFO line with the count. TestR1112_StartConvertsTheFamilyFile, TestR1112_MainOpensTheFamilyStore,
red-proved.

Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
2026-10-17 09:56:12 +02:00
parent 94f44931dd
commit 6ed223886d
6 changed files with 82 additions and 2 deletions
+11
View File
@@ -88,6 +88,8 @@ type Store struct {
mu sync.Mutex
members map[string]*Member
sessions map[string]Session
converted int // sessions Open found stored the old way (R-1112) — ConvertedAtOpen, for the start-up log line
}
var (
@@ -134,6 +136,7 @@ func Open(dir string) (*Store, error) {
}
s.sessions[se.FP] = se
}
s.converted = converted
if converted > 0 {
s.mu.Lock()
err := s.saveLocked()
@@ -306,6 +309,14 @@ func (s *Store) Verify(name, password string) bool {
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
}
// ConvertedAtOpen is how many sessions Open found stored the old way and rewrote as fingerprints (0 for a nil store).
func (s *Store) ConvertedAtOpen() int {
if s == nil {
return 0
}
return s.converted
}
// NewSession starts a session for member ("" = the household) and returns its cookie value — the only time the value
// exists outside the browser; the store keeps its Fingerprint. It refuses a member who does not exist.
func (s *Store) NewSession(member string) (string, error) {
+11
View File
@@ -93,6 +93,17 @@ func (s *Server) familyStore() *family.Store {
return s.fam.store
}
// OpenFamilyStore opens the family list when the controller starts (R-1112). A family.json written before v0.325.0
// holds the session values themselves, and the store converts them to fingerprints when it is OPENED — which was
// lazily, on the first family request: on a box where nobody passes a family gate, never (measured on demo-hp 9201,
// 2026-10-17: rc.1 up, family.json still in the old shape). Pinned by TestR1112_StartConvertsTheFamilyFile and
// cmd/controller TestR1112_MainOpensTheFamilyStore.
func (s *Server) OpenFamilyStore() {
if n := s.familyStore().ConvertedAtOpen(); n > 0 {
s.logger.Printf("[INFO] [web] family store: %d session(s) stored the old way were rewritten as fingerprints (R-1112)", n)
}
}
// familyReserve checks whether this visitor or this name is out of tries and, when neither is, counts this try
// against both — one step under one lock (R-983, v0.324.0), so parallel tries cannot all pass a check that none of
// them has counted yet. Windows slide; a success clears both (familyCleared). It reports whether the try may go ahead.
@@ -4,6 +4,7 @@ import (
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
"net/http/httptest"
"net/url"
@@ -17,6 +18,7 @@ import (
"golang.org/x/crypto/bcrypt"
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
"gitea.dooplex.hu/admin/felhom-controller/internal/family"
)
@@ -308,3 +310,32 @@ func TestR1113_HouseholdPassesLeaveOneSession(t *testing.T) {
t.Fatalf("after the older one expired: %d live household sessions, want 1", live)
}
}
// R-1112 at START: the conversion happens when the dashboard starts, not on the first family request (measured on demo-hp
// 9201 with rc.1: up, family.json still in the old shape, because nobody had passed a family gate). OpenFamilyStore is
// what main calls; the conversion is logged by count, never a value.
// Red-proof: OpenFamilyStore with an empty body → the old values stay in the file and this fails.
func TestR1112_StartConvertsTheFamilyFile(t *testing.T) {
dir := t.TempDir()
household := "0f1e2d3c4b5a69788796a5b4c3d2e1f00f1e2d3c4b5a6978"
exp := time.Now().Add(20 * 24 * time.Hour).UTC().Format(time.RFC3339)
path := filepath.Join(dir, "family.json")
if err := os.WriteFile(path, []byte(`{"members":[],"sessions":[{"id":"`+household+`","member":"","gen":0,"exp":"`+exp+`"}]}`), 0o600); err != nil {
t.Fatal(err)
}
cfg := config.Default()
cfg.Paths.DataDir = dir
var logs strings.Builder
s := &Server{cfg: cfg, logger: log.New(&logs, "", 0)}
s.OpenFamilyStore()
b, _ := os.ReadFile(path)
if strings.Contains(string(b), household) {
t.Fatalf("family.json still holds the session value after the start: %s", b)
}
if !strings.Contains(logs.String(), "1 session(s) stored the old way were rewritten as fingerprints") {
t.Errorf("the conversion must be logged by count, got %q", logs.String())
}
if strings.Contains(logs.String(), household) {
t.Error("the log line carries the session value")
}
}