From a40729a6982e4c3008837d1ec9a971e5b73f26dc Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Thu, 8 Oct 2026 10:07:03 +0200 Subject: [PATCH] R-304 option C: operator mail when a household's code opens or may open an older package (once a day); R-298 side fix (no eject/format on a backup-target drive); R-717 comments Unreleased; ships with tomorrow's release (needs the hub of the same day). Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS --- CHANGELOG.md | 15 +++- controller/internal/stacks/after_setup.go | 3 +- controller/internal/stacks/signup_block.go | 2 +- controller/internal/web/recovery_handlers.go | 2 + .../internal/web/recovery_older_mail.go | 82 +++++++++++++++++++ .../internal/web/recovery_older_mail_test.go | 66 +++++++++++++++ controller/internal/web/server.go | 4 + .../internal/web/templates/storage.html | 5 ++ .../testdata/i18n_parity/storage_empty.html | 5 ++ .../testdata/i18n_parity/storage_full.html | 5 ++ 10 files changed, 186 insertions(+), 3 deletions(-) create mode 100644 controller/internal/web/recovery_older_mail.go create mode 100644 controller/internal/web/recovery_older_mail_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index f601a86..ce2088f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,4 @@ -## Unreleased (2026-10-08) — a daytime press never cancels the night's whole-guest backup (R-899; operator ruling 2026-10-08, option A); an honest answer when older recovery packages were not checked (R-304) — ships with tomorrow's release +## Unreleased (2026-10-08) — a daytime press never cancels the night's whole-guest backup (R-899; operator ruling 2026-10-08, option A); an honest answer when older recovery packages were not checked, and the operator's mail (R-304) — ships with tomorrow's release **MinAgent: 0.131.0** (unchanged — the new `trigger=manual` query is ignored by an older agent, which keeps running the OS leg after a press exactly as before; the night itself is fixed by the controller alone). @@ -20,6 +20,19 @@ OS leg after a press exactly as before; the night itself is fixed by the control Hungarian and English. Not version-gated on purpose (it accuses nobody; a stray 424 lands on a neutral sentence). Tests `TestR304_OlderUnchecked_IsNotAWrongCode` (hu + en; red-proved: without the case the page fell to the „nem tudjuk biztosan, miért" default) and `TestR304_Classify424`. +- **R-304 option C (operator ruling 2026-10-08 09:04, `09` §3 decision 183):** when a household's recovery code OPENS an + older sealed package (agent 422) or MAY open one (424), the controller sends the operator-only event + `recovery_older_package` (warning → the operator's mail): the class and the package's date, never the code. At most + once per day per box (`/recovery-older-mail.day`, survives a restart). **Needs the hub release of the same day** + (the type is in its allowlist and `operatorOnlyEvents` there); against an older hub the event is refused (400) and only + logged. `internal/web/recovery_older_mail.go`; tests `TestR304_OlderPackageMail_*` (red-proved twice: without the calls + `events = []`; without the day check `sent 2 events, want still 1`). +- **R-298 side finding:** the storage page no longer offers „Leválasztás" or „Formázás" on a user-data drive that is ALSO + the whole-system backup target (agent `backup_target`): the agent refuses its eject (403) and a data-bearing format needs + a signed job. JS in `templates/storage.html` `actions()`; the two storage parity fixtures regenerated (only these lines + differ). Not verified in a browser. +- R-717: two stale comments corrected (`after_setup.go` no longer names opengist among the `command` apps; + `signup_block.go` shows opengist's current `PathRegexp` block). - The press now reaches the agent as `POST /backup?…trigger=manual` (`agentapi.StartBackupForTrigger`); an agent that knows it runs no OS leg after a press (agent, same day). Test `TestR899_PressCarriesTriggerManual` asserts the query the agent receives for each of the four shapes. diff --git a/controller/internal/stacks/after_setup.go b/controller/internal/stacks/after_setup.go index 5921ba0..8aa83a8 100644 --- a/controller/internal/stacks/after_setup.go +++ b/controller/internal/stacks/after_setup.go @@ -30,7 +30,8 @@ import ( // // A command form (`service`, `command`, `success`, `env` names) follows after_install's argv-safe rules (R-713). // -// R-717: an app whose switch lives in its own database (opengist, wishlist) is closed by `command` and reopened for +// R-717: an app whose switch lives in its own database (today: wishlist; opengist is closed by its signup_block, +// signup_block.go) is closed by `command` and reopened for // the household's window by its twin: // // after_setup: diff --git a/controller/internal/stacks/signup_block.go b/controller/internal/stacks/signup_block.go index 65c6fd7..a25e5b5 100644 --- a/controller/internal/stacks/signup_block.go +++ b/controller/internal/stacks/signup_block.go @@ -19,7 +19,7 @@ import ( // without a gate. The household lets a family member join by opening sign-up for 15 minutes from the app page // (OpenSignupWindow); the loop closes it again. Decided by CC unattended 2026-09-29 — the operator may reverse. // -// signup_block: "PathPrefix(`/-/register`)" # opengist +// signup_block: "PathRegexp(`(?i)^/+-/+register`)" # opengist (catalog templates/opengist/.felhom.yml) // // Only an app whose setup gate this box opened carries a block: an app installed before (no gate record) is never // touched — the same rule as the gate itself (Part 0, 2026-09-29). diff --git a/controller/internal/web/recovery_handlers.go b/controller/internal/web/recovery_handlers.go index aaf2771..1359b2e 100644 --- a/controller/internal/web/recovery_handlers.go +++ b/controller/internal/web/recovery_handlers.go @@ -444,6 +444,7 @@ func (s *Server) recoveryUnlockHandler(w http.ResponseWriter, r *http.Request) { when = " (" + at + ")" } s.logger.Printf("[INFO] [web] recovery: the code opened a RETAINED package — the customer is not at fault") + s.notifyRecoveryOlderPackage(class, func() string { _, at := s.recoverySuperseded(); return at }()) s.renderRecovery(w, r, "A kódod helyes, de egy korábbi csomagot nyit meg, nem azt, amit most őrzünk ehhez a géphez. A géped időközben új mentési kulcsot kapott. A korábbi csomagot"+when+" nem töröltük, megőrizzük — a mostani mentéseidet ez nem érinti, azokkal semmi nem történt. A régebbi előzményed visszanyitásához a Felhom ügyfélszolgálatának segítsége kell: írj nekik, és add meg, hogy a régi mentéseidhez szeretnél hozzáférni. A kódodat tedd el, szükség lesz rá.", "", nil) return case agentapi.RecoveryOlderUnchecked: @@ -455,6 +456,7 @@ func (s *Server) recoveryUnlockHandler(w http.ResponseWriter, r *http.Request) { // possibility (R-226: a new-code holder may be typing), and names the route. Pinned by // TestR304_OlderUnchecked_IsNotAWrongCode. s.logger.Printf("[WARN] [web] recovery: earlier sealed packages were not all checked — not reported as a wrong code (R-304)") + s.notifyRecoveryOlderPackage(class, func() string { _, at := s.recoverySuperseded(); return at }()) s.renderRecovery(w, r, s.msg(r, "recovery.older_unchecked"), "", nil) return case agentapi.RecoveryAskedAndRefused: diff --git a/controller/internal/web/recovery_older_mail.go b/controller/internal/web/recovery_older_mail.go new file mode 100644 index 0000000..736cdba --- /dev/null +++ b/controller/internal/web/recovery_older_mail.go @@ -0,0 +1,82 @@ +package web + +import ( + "os" + "path/filepath" + "strings" + + "gitea.dooplex.hu/admin/felhom-controller/internal/agentapi" +) + +// R-304 option C (operator ruling 2026-10-08 09:04, `09` §3 decision 183): when a household's recovery code OPENS an +// older sealed package (the agent's 422), or MAY open one (424 — not every older package was tried), the OPERATOR gets +// one mail: retention is an operator-only capability (R-312), so „contact support" on the screen is only true in +// practice if the operator already knows. Never the code, never a password — the class and the package's date only. +// +// At most ONCE PER DAY per box: the day (UTC, YYYY-MM-DD) of the last send is kept in `/recovery-older-mail.day`, +// so a controller restart does not mail twice. A household retyping its code ten times sends one mail. +// The event type `recovery_older_package` is operator-only at the hub (`notify.operatorOnlyEvents`, same day's hub). +// Pinned by TestR304_OlderPackageMail_* (recovery_older_mail_test.go). + +const recoveryOlderMailFile = "recovery-older-mail.day" + +// recoveryOlderPush sends the event; recoveryOlderPushFn is the test seam (nil → the notifier). +// The severity is a literal on purpose: the hub's severity vocabulary is checked statically +// (TestR329_EveryEmittedSeverityIsInTheHubVocabulary). +func (s *Server) recoveryOlderPush(message string, details map[string]string) { + if s.recoveryOlderPushFn != nil { + s.recoveryOlderPushFn("recovery_older_package", "warning", message, details) + return + } + if s.notifier != nil { + s.notifier.PushEvent("recovery_older_package", "warning", message, details) + } +} + +func (s *Server) recoveryOlderMailPath() string { + if s.cfg == nil || s.cfg.Paths.DataDir == "" { + return "" + } + return filepath.Join(s.cfg.Paths.DataDir, recoveryOlderMailFile) +} + +// notifyRecoveryOlderPackage sends the operator mail for a 422/424 unlock, at most once per day. +func (s *Server) notifyRecoveryOlderPackage(class agentapi.RecoveryFailure, supersededAt string) { + today := s.recoveryNow().UTC().Format("2006-01-02") + p := s.recoveryOlderMailPath() + s.recoveryOlderMu.Lock() + defer s.recoveryOlderMu.Unlock() + last := s.recoveryOlderLastDay + if p != "" { + if b, err := os.ReadFile(p); err == nil { + last = strings.TrimSpace(string(b)) + } + } + if last == today { + s.logger.Printf("[INFO] [web] recovery: older-package operator mail already sent today — not again (R-304)") + return + } + when := supersededAt + if when == "" { + when = "unknown" + } + var msg string + switch class { + case agentapi.RecoveryCodeOpensRetained: + msg = "A household's recovery code OPENED an older sealed escrow package (superseded " + when + ") — not the current one. " + + "Their old off-site history may be what they need; reopening it is operator-only (R-312). The screen told them to contact support." + default: + msg = "A household's recovery code did not open the current sealed escrow package, and NOT every older package was tried " + + "(newest older package superseded " + when + ") — the code may belong to one of them. The screen told them to contact support." + } + s.recoveryOlderPush(msg, map[string]string{ + "class": class.String(), "superseded_at": supersededAt, + }) + s.recoveryOlderLastDay = today + if p != "" { + if err := os.WriteFile(p, []byte(today+"\n"), 0o600); err != nil { + s.logger.Printf("[WARN] [web] recovery: could not record today's older-package mail (%v) — a restart may send one more today", err) + } + } + s.logger.Printf("[INFO] [web] recovery: operator told — the code %s an older package (R-304)", map[bool]string{true: "opens", false: "may open"}[class == agentapi.RecoveryCodeOpensRetained]) +} diff --git a/controller/internal/web/recovery_older_mail_test.go b/controller/internal/web/recovery_older_mail_test.go new file mode 100644 index 0000000..7ec6255 --- /dev/null +++ b/controller/internal/web/recovery_older_mail_test.go @@ -0,0 +1,66 @@ +package web + +import ( + "net/http" + "strings" + "testing" + "time" +) + +// R-304 option C (decision 183): the operator is mailed when a household's code opens (422) or may open (424) an older +// sealed package — once per day per box, never on a wrong code, and never with the code in it. +// RED-PROOF: remove the two notifyRecoveryOlderPackage calls from recoveryUnlockHandler → no event → the first +// assertion FAILS; drop the day check → the second unlock sends a second event → FAILS. + +type olderEvent struct{ typ, sev, msg, class string } + +func olderFixture(t *testing.T, now *time.Time) (*recoveryFixture, *[]olderEvent) { + t.Helper() + f := newRecoveryFixture(t) + var evs []olderEvent + f.s.recoveryOlderPushFn = func(typ, sev, msg string, d map[string]string) { + evs = append(evs, olderEvent{typ, sev, msg, d["class"]}) + } + f.s.SetRecoveryClock(func() time.Time { return *now }) + return f, &evs +} + +func TestR304_OlderPackageMail_OncePerDay(t *testing.T) { + now := time.Date(2026, 10, 8, 10, 0, 0, 0, time.UTC) + f, evs := olderFixture(t, &now) + f.rec.failWith = refusal(422, "the recovery code is correct, but it belongs to an EARLIER sealed package") + postUnlockWith(t, f.s, testRecoveryCode) + if len(*evs) != 1 || (*evs)[0].typ != "recovery_older_package" || (*evs)[0].sev != "warning" || (*evs)[0].class != "code-opens-retained" { + t.Fatalf("events = %+v, want one warning recovery_older_package (code-opens-retained)", *evs) + } + if strings.Contains((*evs)[0].msg, testRecoveryCode) { + t.Fatal("the operator mail must never carry the recovery code") + } + now = now.Add(3 * time.Hour) + postUnlockWith(t, f.s, testRecoveryCode) + if len(*evs) != 1 { + t.Fatalf("a second try the same day sent %d events, want still 1", len(*evs)) + } + // A controller restart the same day: a new Server over the same data dir must not mail again. + f.s.recoveryOlderLastDay = "" + postUnlockWith(t, f.s, testRecoveryCode) + if len(*evs) != 1 { + t.Fatalf("after a restart the same day: %d events, want still 1 (the day is on disk)", len(*evs)) + } + now = now.Add(24 * time.Hour) + f.rec.failWith = refusal(http.StatusFailedDependency, "not all checked") + postUnlockWith(t, f.s, testRecoveryCode) + if len(*evs) != 2 || (*evs)[1].class != "older-unchecked" { + t.Fatalf("next day, a 424: events = %+v, want a second one (older-unchecked)", *evs) + } +} + +func TestR304_OlderPackageMail_NotOnAWrongCode(t *testing.T) { + now := time.Date(2026, 10, 8, 10, 0, 0, 0, time.UTC) + f, evs := olderFixture(t, &now) + f.rec.failWith = refusal(400, "the recovery code did not open the sealed bundle") + postUnlockWith(t, f.s, testRecoveryCode) + if len(*evs) != 0 { + t.Fatalf("a plain wrong code must not mail the operator: %+v", *evs) + } +} diff --git a/controller/internal/web/server.go b/controller/internal/web/server.go index f6e03a6..c270b18 100644 --- a/controller/internal/web/server.go +++ b/controller/internal/web/server.go @@ -154,6 +154,10 @@ type Server struct { // recoveryRetainedTrustedFn overrides the R-311 gate deciding whether a 422 may be read as "the // code is correct and opens a RETAINED earlier package" (tests). INIT-ONLY. recoveryRetainedTrustedFn func(context.Context) bool + // R-304 option C: the operator's older-package mail — the test seam, and the once-per-day record (recovery_older_mail.go). + recoveryOlderPushFn func(eventType, severity, message string, details map[string]string) + recoveryOlderMu sync.Mutex + recoveryOlderLastDay string // recoveryNowFn is the unlock path's clock (tests inject; nil → time.Now). Observability and // tests only — never a classifier. recoveryNowFn func() time.Time diff --git a/controller/internal/web/templates/storage.html b/controller/internal/web/templates/storage.html index fe3c9f9..85b9678 100644 --- a/controller/internal/web/templates/storage.html +++ b/controller/internal/web/templates/storage.html @@ -302,6 +302,11 @@ window.__registeredPaths=[{{range .StoragePaths}}{{if .Path}}"{{.Path}}",{{end}} function actions(d, registered, hasSafeDisconnect){ // Destructive controls ONLY for user-data drives that are mounted under /mnt. System/backup get none. if(d.role!=='user-data' || !d.mount_path || d.mount_path.indexOf('/mnt/')!==0) return ''; + // A user-data drive that ALSO backs the whole-system backup (agent `backup_target`): the agent refuses its eject + // (403) and a data-bearing format needs a signed job, so neither button is offered (R-298 design, 2026-10-08). + if(d.backup_target){ + return registered[regKey(d)] ? '' : '
'; + } var dev = esc(d.backing_device||''), mpRaw = esc(d.mount_path), reg = esc(regKey(d)); var btns = ''; // Two distinct path arguments (the intermediary model): diff --git a/controller/internal/web/testdata/i18n_parity/storage_empty.html b/controller/internal/web/testdata/i18n_parity/storage_empty.html index 005b51a..a12fc77 100644 --- a/controller/internal/web/testdata/i18n_parity/storage_empty.html +++ b/controller/internal/web/testdata/i18n_parity/storage_empty.html @@ -314,6 +314,11 @@ window.__registeredPaths=[]; function actions(d, registered, hasSafeDisconnect){ if(d.role!=='user-data' || !d.mount_path || d.mount_path.indexOf('/mnt/')!==0) return ''; + + + if(d.backup_target){ + return registered[regKey(d)] ? '' : '
'; + } var dev = esc(d.backing_device||''), mpRaw = esc(d.mount_path), reg = esc(regKey(d)); var btns = ''; diff --git a/controller/internal/web/testdata/i18n_parity/storage_full.html b/controller/internal/web/testdata/i18n_parity/storage_full.html index bf28dda..4c70d00 100644 --- a/controller/internal/web/testdata/i18n_parity/storage_full.html +++ b/controller/internal/web/testdata/i18n_parity/storage_full.html @@ -614,6 +614,11 @@ window.__registeredPaths=["\/mnt\/usb1","\/mnt\/old","\/mnt\/hdd1","\/mnt\/hdd2" function actions(d, registered, hasSafeDisconnect){ if(d.role!=='user-data' || !d.mount_path || d.mount_path.indexOf('/mnt/')!==0) return ''; + + + if(d.backup_target){ + return registered[regKey(d)] ? '' : '
'; + } var dev = esc(d.backing_device||''), mpRaw = esc(d.mount_path), reg = esc(regKey(d)); var btns = '';