The family gate (decisions 63/64, R-780): family members with their own logins, a permanent forwardAuth door per family app, anchored exceptions, min_controller
gates / gates (push) Successful in 27s

- internal/family: the family list (bcrypt, generated 4x4 passwords shown once) + 30-day sessions in family.json
  (0600, atomic); a reset (generation), a removal or a logout ends sessions at the next request.
- internal/stacks/family_gate.go: family_gate / family_gate_except / min_controller in .felhom.yml; the door is written
  BEFORE the first start (install and a removed app's restore), a life record in app.yaml, reconciled by the gate loop;
  priority below the install hold, setup gate and sign-up block; every exception anchored ^/prefix(/|$) (finding F1).
- internal/web/family_gate.go: forwardAuth /__felhom_gate/family (app cookie felhom_famgate, host-only, names a store
  session); /__family/start|login|logout on the dashboard host (session cookie felhom_family, Path=/__family);
  sign-in counted per visitor (clientIP) AND per name, short windows; the household's dashboard session vouches.
  RequireAuth never reads a family cookie. The "Család" card on the security page: add / new password / remove.
Red-proofs RP-F1..RP-F7 (felhom.eu audits/family-gate-2026-10-02/A/).

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-10-02 07:42:47 +02:00
parent a4a753b9e2
commit 977665d8c0
25 changed files with 2168 additions and 10 deletions
+35
View File
@@ -184,6 +184,8 @@ type AppConfig struct {
// SetupGate (v0.280.0, decision 46) is the app's setup gate: closed from a fresh install until the first
// setup is done. A life record (carried across a restore). See setup_gate.go.
SetupGate *SetupGateRecord `yaml:"setup_gate,omitempty" json:"setup_gate,omitempty"`
// FamilyGate (v0.287.0, decisions 63/64): the app's permanent family gate, on since its install. A life record.
FamilyGate *FamilyGateRecord `yaml:"family_gate,omitempty" json:"family_gate,omitempty"`
// InstallHold (R-741, decision 45): an after_install app is held (the gate's door) from its fresh install until
// its known first login is replaced. Same record shape as SetupGate. See install_hold.go.
InstallHold *SetupGateRecord `yaml:"install_hold,omitempty" json:"install_hold,omitempty"`
@@ -428,6 +430,12 @@ func (m *Manager) DeployStack(req DeployRequest) (string, error) {
}
}
// v0.287.0: a template that needs a newer controller is refused before anything is written.
if err := checkMinController(&meta); err != nil {
clearDeploying()
m.logger.Printf("[ERROR] [stacks] Deploy %s REFUSED: %v", req.StackName, err)
return "", util.MsgError("err.stacks.needs_newer_controller", err.Error())
}
// `09` §3 decision 46: a gated template is installed CLOSED, and the gate's traefik file is written BEFORE
// the first start (spike F2). Cannot write it → the install is refused: never published open.
var gate *SetupGateRecord
@@ -455,6 +463,24 @@ func (m *Manager) DeployStack(req DeployRequest) (string, error) {
hold = h
}
// v0.287.0 (decisions 63/64): a family app is never published open — its door is written before the first start.
var family *FamilyGateRecord
if meta.FamilyGate {
f, err := m.prepareFamilyGate(req.StackName, stack.ComposePath, env, &meta)
if err != nil {
clearDeploying()
if gate != nil {
_ = m.removeSetupGateFile(req.StackName)
}
if hold != nil {
_ = os.Remove(m.installHoldPath(req.StackName))
}
m.logger.Printf("[ERROR] [stacks] Deploy %s REFUSED: the family gate could not be prepared: %v", req.StackName, err)
return "", util.MsgError("err.stacks.family_gate_failed", err.Error())
}
family = f
}
// Save app.yaml.
// CTRL-T2-1: persist the env now, but mark the ON-DISK state Deployed:false
// until `docker compose up -d` actually succeeds (done in runComposeDeploy).
@@ -476,6 +502,7 @@ func (m *Manager) DeployStack(req DeployRequest) (string, error) {
DesiredState: DesiredStateRunning,
SetupGate: gate,
InstallHold: hold,
FamilyGate: family,
}
diskCfg := *appCfg
@@ -772,6 +799,14 @@ func (m *Manager) PersistUnitRedeployConfig(name string, env map[string]string)
// its setup: the lock record and its block, written HERE — before anything starts. An app that was never removed
// keeps exactly the record it had (carryLifeRecords): a restore never adds a lock to an installed app that the
// household has not closed (decision 49). Pinned by TestR773_*.
// v0.287.0: a removed family app restored from its backup gets its door before anything starts (the R-773 lesson).
if priorRaw == nil && cfg.FamilyGate == nil && meta.FamilyGate {
rec, err := m.prepareFamilyGate(name, stack.ComposePath, env, &meta)
if err != nil {
return fmt.Errorf("the family gate could not be prepared (the app was not started): %w", err)
}
cfg.FamilyGate = rec
}
if priorRaw == nil && cfg.SetupGate == nil {
rec, err := m.restoreSignupLock(name, stack.ComposePath, env, &meta)
if err != nil {