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
gates / gates (push) Successful in 59s
gates / gates (push) Successful in 59s
Unreleased; ships with tomorrow's release (needs the hub of the same day). 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:
@@ -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:
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 `<data>/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])
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)] ? '' : '<div class="drive-actions"><button class="btn btn-xs btn-primary" onclick="registerDrive(\''+esc(d.mount_path)+'\')">{{T "storage.regisztralas"}}</button></div>';
|
||||
}
|
||||
var dev = esc(d.backing_device||''), mpRaw = esc(d.mount_path), reg = esc(regKey(d));
|
||||
var btns = '';
|
||||
// Two distinct path arguments (the intermediary model):
|
||||
|
||||
@@ -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)] ? '' : '<div class="drive-actions"><button class="btn btn-xs btn-primary" onclick="registerDrive(\''+esc(d.mount_path)+'\')">Regisztrálás</button></div>';
|
||||
}
|
||||
var dev = esc(d.backing_device||''), mpRaw = esc(d.mount_path), reg = esc(regKey(d));
|
||||
var btns = '';
|
||||
|
||||
|
||||
@@ -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)] ? '' : '<div class="drive-actions"><button class="btn btn-xs btn-primary" onclick="registerDrive(\''+esc(d.mount_path)+'\')">Regisztrálás</button></div>';
|
||||
}
|
||||
var dev = esc(d.backing_device||''), mpRaw = esc(d.mount_path), reg = esc(regKey(d));
|
||||
var btns = '';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user