a40729a698
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
83 lines
3.6 KiB
Go
83 lines
3.6 KiB
Go
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])
|
|
}
|