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 {
+236
View File
@@ -0,0 +1,236 @@
package stacks
import (
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/util"
)
// ── The family gate (v0.287.0, `09` §3 decisions 63 and 64; R-780) ───────────────────────────────────────
//
// A PERMANENT gate in front of an app whose template says `family_gate: true`: only a member of the household's family
// list (internal/family — each with their OWN password) or the household itself (a dashboard session vouching) gets
// through. Same mechanism as the setup gate (decision 46): a traefik file-provider file puts a forwardAuth door in
// front of every router the app publishes; internal/web/family_gate.go answers it. Differences, each deliberate:
//
// - it never opens: the file stays while the app is installed;
// - its priority is BELOW the install hold, the setup gate and the sign-up block (each is stricter), ABOVE the app's
// own docker routers;
// - `family_gate_except:` lists path prefixes a phone or e-reader app calls (Grimmory's OPDS/Kobo/KOReader). Each
// gets a router WITHOUT the door — the app's OWN login decides there. Every exception is ANCHORED at a path-segment
// boundary (`^/prefix(/|$)`): traefik's PathPrefix is a plain string prefix, and the spike measured
// `/api/v1/opdsx` walking past an unanchored `/api/v1/opds` (finding F1, audits/permanent-gate-2026-10-01/).
//
// The record (`family_gate:` in app.yaml) is written at install (and at the restore of a removed app — the R-773
// lesson), so a catalog change never gates or un-gates an installed app; the exceptions follow the current template.
// Pinned by internal/stacks/family_gate_test.go.
const (
familyGateAuthURL = "http://felhom-controller:8080/__felhom_gate/family"
familyGatePriority = 40000 // < setupGatePriority (100000): the setup gate, sign-up block, install hold outrank it
familyExceptBoost = 20000 // an exception router outranks the family door, never the setup gate
)
// FamilyGateRecord is the app's family gate: on since its install. A life record (carried across a restore).
type FamilyGateRecord struct {
Since string `yaml:"since" json:"since"`
Hosts []string `yaml:"hosts,omitempty" json:"hosts,omitempty"`
}
// exceptPathRE: a literal path prefix — no traefik matcher, no regex. Anything else is refused, never escaped into
// something it did not say.
var exceptPathRE = regexp.MustCompile(`^/[A-Za-z0-9._~/-]*$`)
// FamilyExceptRegexp turns one exception prefix into the anchored regexp the router uses: the prefix itself, or the
// prefix followed by "/". A trailing "/" in the template is the same prefix.
func FamilyExceptRegexp(p string) (string, error) {
if !exceptPathRE.MatchString(p) || strings.Contains(p, "//") || strings.Contains(p, "/../") || strings.HasSuffix(p, "/..") {
return "", fmt.Errorf("family_gate_except %q: a literal path prefix starting with /", p)
}
p = strings.TrimRight(p, "/")
if p == "" {
return "", fmt.Errorf("family_gate_except %q would except the whole app", "/")
}
return "^" + regexp.QuoteMeta(p) + "(/|$)", nil
}
func renderFamilyGate(name string, rs []gateRouter, except []string) (string, error) {
var res []string
for _, p := range except {
re, err := FamilyExceptRegexp(p)
if err != nil {
return "", err
}
res = append(res, re)
}
var b strings.Builder
mw := "felhom-family-gate-" + name
fmt.Fprintf(&b, "# Family gate for %s — managed by felhom-controller (`09` §3 decisions 63-64).\n", name)
b.WriteString("# Only the household's family members (and the household) reach the app; the listed paths keep the app's own login.\n")
b.WriteString("http:\n middlewares:\n")
fmt.Fprintf(&b, " %s:\n forwardAuth:\n address: %q\n", mw, familyGateAuthURL)
b.WriteString(" routers:\n")
tls := func(r gateRouter) {
if r.CertResolver != "" {
fmt.Fprintf(&b, " tls:\n certResolver: %s\n", r.CertResolver)
} else {
b.WriteString(" tls: {}\n")
}
}
for _, r := range rs {
fmt.Fprintf(&b, " %s-%s:\n", mw, r.Name)
fmt.Fprintf(&b, " rule: %q\n", r.Rule)
fmt.Fprintf(&b, " priority: %d\n", familyGatePriority+len(r.Rule))
b.WriteString(" entryPoints:\n - websecure\n")
tls(r)
fmt.Fprintf(&b, " middlewares:\n - %s@file\n", mw)
fmt.Fprintf(&b, " service: %q\n", r.Service+"@docker")
for i, re := range res {
rule := fmt.Sprintf("(%s) && PathRegexp(`%s`)", r.Rule, re)
fmt.Fprintf(&b, " %s-%s-except-%d:\n", mw, r.Name, i)
fmt.Fprintf(&b, " rule: %q\n", rule)
fmt.Fprintf(&b, " priority: %d\n", familyGatePriority+familyExceptBoost+len(rule))
b.WriteString(" entryPoints:\n - websecure\n")
tls(r)
fmt.Fprintf(&b, " service: %q\n", r.Service+"@docker")
}
}
return b.String(), nil
}
func (m *Manager) familyGatePath(name string) string {
return filepath.Join(m.setupGateDir(), "family-gate-"+name+".yml")
}
// writeFamilyGate writes (or refreshes) the app's family-gate file. Returns the hosts it covers.
func (m *Manager) writeFamilyGate(name, composePath string, env map[string]string, except []string) ([]string, error) {
rs, err := gateRoutersFromCompose(composePath, env)
if err != nil {
return nil, err
}
want, err := renderFamilyGate(name, rs, except)
if err != nil {
return nil, err
}
if err := os.MkdirAll(m.setupGateDir(), 0o755); err != nil {
return nil, err
}
p := m.familyGatePath(name)
if cur, err := os.ReadFile(p); err == nil && string(cur) == want {
return gateHosts(rs), nil
}
tmp := p + ".tmp"
if err := os.WriteFile(tmp, []byte(want), 0o644); err != nil {
return nil, err
}
if err := os.Rename(tmp, p); err != nil {
return nil, err
}
return gateHosts(rs), nil
}
// prepareFamilyGate is the install's (and a removed app's restore's) step: the file BEFORE the first start, then the
// record the caller saves. Cannot write it → the caller refuses: a family app is never published open.
func (m *Manager) prepareFamilyGate(name, composePath string, env map[string]string, meta *Metadata) (*FamilyGateRecord, error) {
hosts, err := m.writeFamilyGate(name, composePath, env, meta.FamilyGateExcept)
if err != nil {
return nil, err
}
m.logger.Printf("[INFO] [stacks] %s: family gate ON before the first start — only family members reach %v (exceptions: %v)", name, hosts, meta.FamilyGateExcept)
return &FamilyGateRecord{Since: m.now().UTC().Format(time.RFC3339), Hosts: hosts}, nil
}
// FamilyGateHost maps a host to the family-gated app that owns it.
func (m *Manager) FamilyGateHost(host string) (name string, found bool) {
host = strings.ToLower(host)
m.mu.RLock()
defer m.mu.RUnlock()
for n, st := range m.stacks {
if st.Deployed && st.AppConfig != nil && st.AppConfig.FamilyGate != nil && containsStr(st.AppConfig.FamilyGate.Hosts, host) {
return n, true
}
}
return "", false
}
// familyGateTick: every installed family app has its file (rewritten from the current template's exceptions); every
// other family-gate file goes.
func (m *Manager) familyGateTick() {
type item struct {
name, dir, compose string
except []string
}
var items []item
keep := map[string]bool{}
m.mu.RLock()
for n, st := range m.stacks {
if !st.Deployed || st.AppConfig == nil || st.AppConfig.FamilyGate == nil {
continue
}
items = append(items, item{name: n, dir: filepath.Dir(st.ComposePath), compose: st.ComposePath,
except: append([]string(nil), st.Meta.FamilyGateExcept...)})
keep[n] = true
}
m.mu.RUnlock()
if ents, err := os.ReadDir(m.setupGateDir()); err == nil {
for _, e := range ents {
n := e.Name()
if !strings.HasPrefix(n, "family-gate-") || !strings.HasSuffix(n, ".yml") {
continue
}
app := strings.TrimSuffix(strings.TrimPrefix(n, "family-gate-"), ".yml")
if !keep[app] {
if err := os.Remove(m.familyGatePath(app)); err == nil {
m.logger.Printf("[INFO] [stacks] %s: removed the family-gate file of an app that is not installed", app)
}
}
}
}
sort.Slice(items, func(i, j int) bool { return items[i].name < items[j].name })
for _, it := range items {
cfg := LoadAppConfigDecrypted(it.dir, m.encKey)
if cfg == nil {
continue
}
if _, err := m.writeFamilyGate(it.name, it.compose, cfg.Env, it.except); err != nil {
m.logger.Printf("[ERROR] [stacks] %s: the family gate's traefik file could not be (re)written: %v", it.name, err)
}
}
}
// ── the template's minimum controller (v0.287.0) ─────────────────────────────────────────────────────────
var controllerVersion string
// SetControllerVersion tells the stacks package which controller it runs in (main.go). Empty = unknown (a dev build):
// min_controller is then not enforced, and that is logged.
func SetControllerVersion(v string) { controllerVersion = v }
// ErrNeedsNewerController: the template needs a newer controller than this one.
var ErrNeedsNewerController = fmt.Errorf("the app needs a newer box software")
// checkMinController refuses a template whose `min_controller` is above this controller. A family-gated app on a
// controller that does not know the field would be installed OPEN — this is the field a NEWER template uses to say so.
func checkMinController(meta *Metadata) error {
if strings.TrimSpace(meta.MinController) == "" {
return nil
}
need, err := util.ParseVersion(meta.MinController)
if err != nil {
return fmt.Errorf("min_controller %q unreadable: %w", meta.MinController, err)
}
have, err := util.ParseVersion(controllerVersion)
if err != nil {
return nil // a dev build: no version to compare (logged at the caller)
}
if have.Compare(need) < 0 {
return fmt.Errorf("%w (needs %s, this box runs %s)", ErrNeedsNewerController, need, have)
}
return nil
}
@@ -0,0 +1,178 @@
package stacks
import (
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"testing"
"time"
)
// v0.287.0 (`09` §3 decisions 63/64) — the family gate's traefik side.
const familyYml = "display_name: Family App\nfamily_gate: true\n" +
"family_gate_except: [\"/api/v1/opds\", \"/api/kobo/\"]\n" +
"deploy_fields:\n - env_var: DOMAIN\n type: domain\n - env_var: SUBDOMAIN\n type: subdomain\n default: gapp\n"
// Rule 5 (finding F1): every exception is anchored at a path-segment boundary — `/api/v1/opds` must not match
// `/api/v1/opdsx` or `/api/v1/opds-evil`; a regex or matcher in the template is refused, never escaped into meaning.
// COMPANION RED-PROOF: return "^"+QuoteMeta(p) (no boundary) → the look-alikes match and this fails.
func TestFamilyExceptRegexp_Anchored(t *testing.T) {
re, err := FamilyExceptRegexp("/api/v1/opds")
if err != nil {
t.Fatal(err)
}
rx := regexp.MustCompile(re)
for p, want := range map[string]bool{
"/api/v1/opds": true, "/api/v1/opds/": true, "/api/v1/opds/catalog": true,
"/api/v1/opdsx": false, "/api/v1/opds-evil": false, "/api/v1/opds.json": false, "/x/api/v1/opds": false, "/api/v1/opd": false,
} {
if rx.MatchString(p) != want {
t.Errorf("%q: match=%v want %v (regexp %s)", p, !want, want, re)
}
}
if re2, _ := FamilyExceptRegexp("/api/kobo/"); re2 != re2 || !regexp.MustCompile(re2).MatchString("/api/kobo/tok/v1/x") || regexp.MustCompile(re2).MatchString("/api/koboz") {
t.Errorf("a trailing slash is the same prefix: %s", re2)
}
for _, bad := range []string{"", "/", "api", "/api/(.*)", "/api/v1/opds|/", "PathPrefix(`/x`)", "/a//b", "/a/../b", "/a/..", "/a b"} {
if _, err := FamilyExceptRegexp(bad); err == nil {
t.Errorf("%q must be refused", bad)
}
}
}
// The install writes the family door BEFORE the first start; exceptions get routers WITHOUT the door, above the family
// router and below the setup gate; the record is saved.
// COMPANION RED-PROOF: drop the prepareFamilyGate block in DeployStack → "the family-gate file did not exist" fails.
func TestFamilyGate_WrittenBeforeTheFirstStart(t *testing.T) {
m := gateManager(t, familyYml)
p := m.familyGatePath("gapp")
var atUp string
existed := false
m.composeExecFn = func(_ string, _ map[string]string, args ...string) (string, error) {
if len(args) > 0 && args[0] == "up" {
b, err := os.ReadFile(p)
existed, atUp = err == nil, string(b)
}
return "", nil
}
done := make(chan bool, 1)
m.SetDeployDoneHook(func(_ string, ok bool, _ string) { done <- ok })
if _, err := m.DeployStack(DeployRequest{StackName: "gapp"}); err != nil {
t.Fatal(err)
}
select {
case <-done:
case <-time.After(20 * time.Second):
t.Fatal("the deploy never ended")
}
if !existed {
t.Fatal("the family-gate file did not exist when the app was first started — it was published open")
}
for _, want := range []string{"http://felhom-controller:8080/__felhom_gate/family", "felhom-family-gate-gapp@file",
"PathRegexp(`^/api/v1/opds(/|$)`)", "PathRegexp(`^/api/kobo(/|$)`)"} {
if !strings.Contains(atUp, want) {
t.Errorf("the file lacks %q:\n%s", want, atUp)
}
}
// each except router has NO middleware; each family router has one
blocks := strings.Split(atUp, "\n felhom-family-gate-gapp-")
nExcept, nDoor := 0, 0
for _, b := range blocks[1:] {
isExcept := strings.Contains(strings.SplitN(b, "\n", 2)[0], "-except-")
hasMW := strings.Contains(b, "middlewares:")
if isExcept && hasMW {
t.Errorf("an exception router carries the door:\n%s", b)
}
if !isExcept && !hasMW {
t.Errorf("a family router lacks the door:\n%s", b)
}
if isExcept {
nExcept++
} else {
nDoor++
}
for _, line := range strings.Split(b, "\n") {
var pr int
if _, err := fmt.Sscanf(strings.TrimSpace(line), "priority: %d", &pr); err == nil && pr >= setupGatePriority {
t.Errorf("a family router outranks the setup gate (%d)", pr)
}
}
}
if nDoor != 2 || nExcept != 4 {
t.Errorf("want 2 door routers and 4 exception routers (2 app routers × 2 exceptions), got %d/%d", nDoor, nExcept)
}
cfg := LoadAppConfig(filepath.Join(m.cfg.Paths.StacksDir, "gapp"))
if cfg == nil || cfg.FamilyGate == nil || strings.Join(cfg.FamilyGate.Hosts, ",") != "gapp.example.hu" {
t.Fatalf("record: %+v", cfg)
}
if n, ok := m.FamilyGateHost("GAPP.example.hu"); !ok || n != "gapp" {
t.Fatalf("FamilyGateHost: %q %v", n, ok)
}
}
// An unanchorable exception in the template refuses the install — never published open.
func TestFamilyGate_BadExceptionRefusesTheInstall(t *testing.T) {
m := gateManager(t, strings.Replace(familyYml, `"/api/kobo/"`, `"/api/(.*)"`, 1))
m.composeExecFn = func(_ string, _ map[string]string, _ ...string) (string, error) { return "", nil }
if _, err := m.DeployStack(DeployRequest{StackName: "gapp"}); err == nil {
t.Fatal("a template with an unanchorable exception must be refused")
}
if _, err := os.Stat(m.familyGatePath("gapp")); !os.IsNotExist(err) {
t.Fatal("no file may be left behind")
}
}
// A REMOVED family app restored from its backup gets its door before anything starts; the loop keeps it; a stale
// file of an uninstalled app goes.
func TestFamilyGate_RestoreOfARemovedAppAndTheLoop(t *testing.T) {
m := gateManager(t, familyYml)
must(t, m.PersistUnitRedeployConfig("gapp", map[string]string{"DOMAIN": "example.hu", "SUBDOMAIN": "gapp"}))
cfg := LoadAppConfig(filepath.Join(m.cfg.Paths.StacksDir, "gapp"))
if cfg == nil || cfg.FamilyGate == nil {
t.Fatal("the restore of a removed family app must record its door")
}
if _, err := os.Stat(m.familyGatePath("gapp")); err != nil {
t.Fatal("the restore must write the door before the start")
}
must(t, os.Remove(m.familyGatePath("gapp")))
stale := m.familyGatePath("ghost")
must(t, os.WriteFile(stale, []byte("x"), 0o644))
m.SetupGateTick()
if _, err := os.Stat(m.familyGatePath("gapp")); err != nil {
t.Fatal("the loop must put the door back")
}
if _, err := os.Stat(stale); !os.IsNotExist(err) {
t.Fatal("the loop must remove a stale family-gate file")
}
}
// min_controller: a template that needs a newer box is refused before anything is written.
func TestMinController(t *testing.T) {
old := controllerVersion
t.Cleanup(func() { controllerVersion = old })
controllerVersion = "0.286.1"
if err := checkMinController(&Metadata{MinController: "0.287.0"}); err == nil {
t.Fatal("0.286.1 must refuse a template needing 0.287.0")
}
controllerVersion = "0.287.0"
if err := checkMinController(&Metadata{MinController: "0.287.0"}); err != nil {
t.Fatalf("equal version must pass: %v", err)
}
if err := checkMinController(&Metadata{}); err != nil {
t.Fatal("no field must pass")
}
if err := checkMinController(&Metadata{MinController: "garbage"}); err == nil {
t.Fatal("an unreadable min_controller must refuse")
}
m := gateManager(t, familyYml+"min_controller: \"9.9.9\"\n")
controllerVersion = "0.287.0"
if _, err := m.DeployStack(DeployRequest{StackName: "gapp"}); err == nil {
t.Fatal("DeployStack must refuse a template needing a newer controller")
}
if _, err := os.Stat(m.familyGatePath("gapp")); !os.IsNotExist(err) {
t.Fatal("nothing may be written for a refused template")
}
}
@@ -36,6 +36,7 @@ func carryLifeRecords(logger *log.Logger, name string, prior, cfg *AppConfig) {
// opened; a gate that was still closed stays closed (its probe opens it if the restored data is set up).
// No prior record (a removed app, kept data, a rebuilt guest) = no gate: the data comes back with its admin.
cfg.SetupGate = prior.SetupGate
cfg.FamilyGate = prior.FamilyGate // v0.287.0: a restore never un-gates a family app
cfg.InstallHold = prior.InstallHold // R-741: the loop opens it when the restored record says the login was replaced
cfg.DefaultLogin = prior.DefaultLogin
cfg.AfterSetup = prior.AfterSetup
+9 -2
View File
@@ -65,8 +65,15 @@ type Metadata struct {
// setup gate opens. See signup_block.go.
SignupBlock string `yaml:"signup_block,omitempty" json:"signup_block,omitempty"`
// AfterSetup (v0.282.0, decisions 47/49): the app's OWN sign-up switch, set when the gate opens. See after_setup.go.
AfterSetup *AfterSetupSpec `yaml:"after_setup,omitempty" json:"after_setup,omitempty"`
Integrations []IntegrationDef `yaml:"integrations,omitempty" json:"integrations,omitempty"`
AfterSetup *AfterSetupSpec `yaml:"after_setup,omitempty" json:"after_setup,omitempty"`
// FamilyGate (v0.287.0, `09` §3 decisions 63/64): a PERMANENT gate — only family members (and the household) reach
// the app; FamilyGateExcept are literal path prefixes a phone/e-reader app calls, left to the app's own login
// (anchored at a segment boundary). See family_gate.go.
FamilyGate bool `yaml:"family_gate,omitempty" json:"family_gate,omitempty"`
FamilyGateExcept []string `yaml:"family_gate_except,omitempty" json:"family_gate_except,omitempty"`
// MinController (v0.287.0): the lowest box software that installs this template correctly; a lower one refuses.
MinController string `yaml:"min_controller,omitempty" json:"min_controller,omitempty"`
Integrations []IntegrationDef `yaml:"integrations,omitempty" json:"integrations,omitempty"`
// InitialCreds: for apps that auto-generate a first-login credential into a file inside the
// container (e.g. Crafty's default-creds.txt). The controller reads + parses that file live and
// surfaces it on the app page, so the customer never has to dig through logs. Optional.
+1
View File
@@ -499,6 +499,7 @@ func (m *Manager) SetupGateTick() {
}
m.reconcileSignupBlocks()
m.installHoldTick()
m.familyGateTick()
}
// RunSetupGateLoop runs SetupGateTick every interval until ctx ends.