package stacks import ( "fmt" "path/filepath" "time" ) // ── R-1191 (2026-10-21): a `.fab` import builds the app record the way the unit restore does ───────────────────── // // The import used to write a bare record (Deployed, DeployedAt, Env, DesiredState) and start the app; the family-gate // reconcile then removed the gate file of an app with no FamilyGate record, so a family-only app (Glance, Grimmory, // MeTube) came back open to anyone (proven live on 9202, felhom.eu audits/dataloss-hunt-2026-10-20/C3-R1187e-…). The // sign-up lock (R-773) and the locked fields were dropped the same way. // // Now, in two steps around the import's own order (the bundle's compose file is in place only after the config step): // - SaveImportedAppConfig — the record: locked fields, desired state running, and the PRIOR record's life records // (carryLifeRecords: an installed app keeps its gates, pins and update history; an import never un-gates it); // - PrepareImportedApp — right before the start: the family gate (when the template is a family app and the record // has none) and, for an app that had no record before, the sign-up lock — the same helpers the unit restore calls // (prepareFamilyGate, restoreSignupLock), never a second copy of them. // SaveImportedAppConfig writes an imported app's record. Returns whether the app had NO record before (a fresh import), // which PrepareImportedApp needs for the sign-up lock rule (decision 49: a restore never adds a lock to an installed // app the household has not closed). func (m *Manager) SaveImportedAppConfig(stackDir string, env map[string]string) (fresh bool, err error) { name := filepath.Base(stackDir) meta := LoadMetadata(stackDir) priorRaw := LoadAppConfig(stackDir) cfg := &AppConfig{ Deployed: true, DeployedAt: time.Now().UTC().Format(time.RFC3339), Env: env, // R-166: importing a bundle is the household installing that app — it is meant to run. DesiredState: DesiredStateRunning, } for _, f := range meta.DeployFields { if f.LockedAfterDeploy { cfg.LockedFields = append(cfg.LockedFields, f.EnvVar) } } carryLifeRecords(m.logger, name, priorRaw, cfg) if priorRaw != nil { cfg.PinnedImages = priorRaw.PinnedImages cfg.InstalledImages = priorRaw.InstalledImages cfg.PreviousImages = priorRaw.PreviousImages } if err := SaveAppConfig(stackDir, cfg, m.encKey, SensitiveEnvVars(&meta)); err != nil { return false, err } m.logger.Printf("[INFO] [stacks] %s: import record saved (fresh=%v, family gate carried=%v, sign-up lock carried=%v)", name, priorRaw == nil, cfg.FamilyGate != nil, cfg.SetupGate != nil) return priorRaw == nil, nil } // PrepareImportedApp gives an imported app its doors BEFORE it starts. Called by the import after the bundle's // config files (the compose file among them) are in place and before StartStack. An error means „do not start". func (m *Manager) PrepareImportedApp(stackDir string, env map[string]string, fresh bool) error { name := filepath.Base(stackDir) composePath := filepath.Join(stackDir, "docker-compose.yml") meta := LoadMetadata(stackDir) cfg := LoadAppConfigDecrypted(stackDir, m.encKey) if cfg == nil { return fmt.Errorf("the imported app's record could not be read") } changed := false if cfg.FamilyGate == nil && meta.FamilyGate { rec, err := m.prepareFamilyGate(name, 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, changed = rec, true } if fresh && cfg.SetupGate == nil { rec, err := m.restoreSignupLock(name, composePath, env, &meta) if err != nil { return fmt.Errorf("the sign-up lock could not be prepared (the app was not started): %w", err) } if rec != nil { cfg.SetupGate, changed = rec, true } } if changed { if err := SaveAppConfig(stackDir, cfg, m.encKey, SensitiveEnvVars(&meta)); err != nil { return fmt.Errorf("saving app config: %w", err) } } // The in-memory record is what the gate reconcile reads: without this the next tick would remove the file again. m.mu.Lock() if s, ok := m.stacks[name]; ok { s.Deployed = true s.AppConfig = cfg } m.mu.Unlock() m.logger.Printf("[INFO] [stacks] %s: import prepared before the start (family gate=%v, sign-up lock=%v)", name, cfg.FamilyGate != nil, cfg.SetupGate != nil) return nil }