Files
admin 0fe315b759
gates / gates (push) Successful in 15s
v0.246.0: an interrupted restore is told; the recovery-code reminder waits until the box can take it
MinAgent: 0.131.0 (unchanged). Requires hub v0.117.0 for restore_interrupted.

R-550 (operator ruling: fix). A design reversed and recorded: the restore
op-status was in memory by choice. Now restore-status.json in DataDir, written
atomically at both ends of an op. At startup a record still marked running
becomes a failed, interrupted result kept per app until that app's next
restore, shown on /backups/restore and the off-site wizard, and raised once as
restore_interrupted. Cooldowns stay in memory.

R-546. The R-543 reminder bar consults the agent's own preflight ok (every
blocking item, not a copy of pbs_storage_id), cached 60 s, probed only while
paused. /backup/escrow shows a waiting card that polls and reloads instead of
red crosses and English diagnostics. POST /api/escrow/start refuses 409 before
staging or starting - the direct path chaos night used. Unknown readiness keeps
the bar.

Red-proofs (each seen failing): restore record across restart; main() calls
both startup functions; startup helper with loading skipped; restore page card;
bar held back; waiting card; start refusal. go build/vet/test ./... green, 28
packages; controller_gates --fast all OK.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-17 10:45:29 +02:00

97 lines
4.5 KiB
Go

package web
import "net/http"
// The escrow reminder bar (R-543) — the household is ASKED for its recovery code.
//
// Off-site backup is ON by default (hub v0.116.0), and it does not RUN until the household has
// created its recovery code. That pause is a DESIGN, not a defect: the escrow is zero-knowledge, the
// household's code is the only key, and a run started without one would produce a copy nobody could
// ever open. Nothing here touches that mechanism.
//
// What was missing is that nothing ASKED. A fresh box sat at „Kulcsletétre vár" indefinitely while
// the backup page told the household their files were protected — measured on a fresh box
// 2026-09-16. A promise plus a pause nobody mentions is the same class of untruth as R-537 and R-538.
//
// This is the R-241 reminder bar, SECOND INSTANCE, on purpose: same session-cookie dismissal, same
// layout block shape, same "back at the next visit" behaviour. No second banner system was built.
const escrowBannerCookie = "felhom_escrow_banner"
// escrowPaused reads the same two facts tier3State reads (backup_page_state.go): the off-site tier is
// configured, and its escrow is not complete. Deliberately one predicate — a second copy would let
// the bar and the app rows tell the household two different stories about the same box.
func (s *Server) escrowPaused() bool {
if s.backupMgr == nil || !s.backupMgr.OffboxConfigured() || s.settings == nil {
return false
}
t := s.settings.GetOffboxTarget()
if t == nil {
return false
}
// The vocabulary is "" | "pending" | "escrowed" (agentapi). Anything that is not the finished
// state is a paused state — fail LOUD, so an unknown value reminds rather than goes quiet.
return t.EscrowState != "escrowed"
}
// hasAdminSession — the household is logged in right now.
//
// It mirrors RequireAuth's own check (auth.go), including the legacy-open box where no password is
// configured and every page is served. It exists because the bar hangs off executeTemplate, the
// render choke point for EVERY page: the login and claim pages bypass that function entirely, and
// this check is what additionally keeps a household reminder off the public guest share page, which
// carries a capability token and no admin session.
func (s *Server) hasAdminSession(r *http.Request) bool {
if !s.authEnabled() {
return true
}
c, err := r.Cookie(sessionCookieName)
return err == nil && s.isValidSession(c.Value)
}
// escrowBannerVisible — logged in, the tier is paused, and they have not clicked it away this visit.
func (s *Server) escrowBannerVisible(r *http.Request) bool {
if r == nil || !s.hasAdminSession(r) || !s.escrowPaused() {
return false
}
// R-546: while the agent says the ceremony cannot run yet, do not urge the household into it.
// Unknown readiness keeps the bar (fail loud).
if ready, known := s.escrowReadiness(r.Context(), false); known && !ready {
return false
}
if c, err := r.Cookie(escrowBannerCookie); err == nil && c.Value == "1" {
return false
}
return true
}
// addEscrowBanner is called from executeTemplate for every authenticated page.
func (s *Server) addEscrowBanner(data map[string]interface{}, r *http.Request) {
if data == nil || !s.escrowBannerVisible(r) {
return
}
data["EscrowBanner"] = true
data["EscrowBannerBack"] = r.URL.Path
if data["CSRFField"] == nil {
data["CSRFField"] = s.csrfField(r)
}
// STATE, never customer data: which page, and the reason the bar is up. DEBUG because this fires
// on every page render and INFO is the operator's state-change level.
if s.isDebug() {
s.logger.Printf("[DEBUG] [web] escrow reminder: rendered on %s (offsite configured, escrow not complete)", r.URL.Path)
}
}
// escrowBannerDismissHandler records „Most nem" (POST /backup/escrow/banner/dismiss) — a browser
// SESSION cookie and nothing durable, exactly like R-241. The off-site tier is still paused whether
// or not anyone clicked, so the bar is back at the next visit and gone for good only when the escrow
// state becomes "escrowed".
func (s *Server) escrowBannerDismissHandler(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, &http.Cookie{
Name: escrowBannerCookie, Value: "1", Path: "/",
HttpOnly: true, SameSite: http.SameSiteLaxMode, Secure: r.TLS != nil,
// NO MaxAge and NO Expires — a session cookie, deliberately.
})
s.logger.Printf("[INFO] [web] escrow reminder: dismissed for this browser session; the off-site tier stays paused until the recovery code exists")
http.Redirect(w, r, redirectBackTo(r, "/launcher"), http.StatusFound)
}