v0.245.0 — R-543: the household is asked for the recovery code, the page says "szunetel" until then
gates / gates (push) Successful in 14s

Off-site backup is ON by default and does not RUN until the household creates its
recovery code. The pause is the zero-knowledge escrow design and is untouched here;
what was missing is that nothing ASKED, while the app-backup page promised the very
copy that had never run.

- a reminder bar on every authenticated page while the off-site tier is configured
  and its escrow is not complete, linking /backup/escrow. It is the R-241 bar, second
  instance: same session-cookie dismissal, back next visit, gone for good when
  escrowed. No second banner system. It hangs off executeTemplate, the single render
  choke point, so it cannot reach only the pages someone remembered.
- the tier-1 file sentence renders by tier3State's own vocabulary instead of the
  app's shape: active -> "vedi", escrow_pending -> "vedene ... szunetel" + the route,
  no copy at all -> says so and names both ways out.
- both fixes red-proofed: the bar test fails on BOTH pages with the hook removed; the
  sentence test quotes the exact v0.244.0 promise when the state is ignored.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
2026-09-16 21:02:51 +02:00
parent 2f8ff2414c
commit ad398b60d9
10 changed files with 436 additions and 9 deletions
@@ -47,3 +47,36 @@ func tier3State(configured, toggled bool, escrowState string) string {
return "active"
}
}
// driveFilesNoteFor is the one sentence under a class-A app's Tier-1 row: where the household's own
// FILES are protected, said in the state the box is actually in.
//
// R-543. v0.244.0 shipped this sentence in one form — „a távoli másolat … védi" — and it was true of
// the design and false of a fresh box: off-site is ON by default (hub v0.116.0) but PAUSED until the
// household performs the escrow ceremony, which nothing asked them to do. A sentence that promises a
// copy which is not running is the same class of lie R-537 and R-538 were filed for, committed by the
// fix for them.
//
// It is pure, and it takes tier3State's OWN vocabulary rather than re-deriving the state, so the row
// and the sentence can never disagree:
// "active" → the copy exists and runs → „védi"
// "escrow_pending" → enabled, paused for the code → „védené … szünetel" + the route to fix it
// "off"/"unconfig" → no off-site at all → say so, and name both ways out
// The link is returned separately so the template renders a real anchor and the copy gate sees plain
// text; empty note means render nothing (an app whose data is in its volumes needs no sentence).
func driveFilesNoteFor(hasDriveFileLegs bool, tier3State string, tier2Configured bool) (note, linkHref, linkText string) {
if !hasDriveFileLegs {
return "", "", ""
}
switch tier3State {
case "active":
return "Az alkalmazás fájljait a távoli másolat (és a második meghajtó) védi — ez a helyi mentés a beállításokat és az adatbázist tartalmazza.", "", ""
case "escrow_pending":
return "Az alkalmazás fájljait a távoli másolat védené — a távoli mentés a helyreállítási kód létrehozásáig szünetel.", "/backup/escrow", "Helyreállítási kód létrehozása"
default:
if tier2Configured {
return "Az alkalmazás fájljait a második meghajtóra készülő másolat védi — ez a helyi mentés a beállításokat és az adatbázist tartalmazza.", "", ""
}
return "Az alkalmazás fájljairól jelenleg nincs másolat — kapcsold be a távoli mentést vagy adj hozzá második meghajtót.", "/backups/remote", "Távoli mentés beállítása"
}
}
+91
View File
@@ -0,0 +1,91 @@
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
}
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)
}
+12 -7
View File
@@ -1182,7 +1182,12 @@ type AppBackupRow struct {
Tier23Contents string
// DriveFilesNote is non-empty exactly when this app keeps files on the data drive that a Tier-1
// unit cannot hold — the one sentence that tells the household where those files ARE protected.
DriveFilesNote string
// Its wording follows the tier-3 STATE, not the app's shape (R-543): a copy that is paused for
// the recovery code protects nothing yet, and „védi" would be the same lie R-537 was filed for.
// DriveFilesNoteLink is the route out of that state, empty when there is nothing to press.
DriveFilesNote string
DriveFilesNoteLink string
DriveFilesNoteLinkText string
// RestoreHeld (R-379) — this app is deliberately stopped because a database restore failed AND
// the rollback failed. Distinct from any backup status: it is about the app's LIVE data, not its
@@ -1379,11 +1384,6 @@ func (s *Server) buildAppBackupRows(status *backup.FullBackupStatus) []AppBackup
tier1Contents := withData(app.HasVolumeData && !hasDriveFileLegs)
// Tier 2 / Tier 3 carry the file legs, so for them „Adatok" is true either way.
tier23Contents := withData(app.HasVolumeData || hasDriveFileLegs)
driveFilesNote := ""
if hasDriveFileLegs {
driveFilesNote = "Az alkalmazás fájljait a távoli másolat (és a második meghajtó) védi — ez a helyi mentés a beállításokat és az adatbázist tartalmazza."
}
slug := ""
if s.stackMgr != nil {
if st, ok := s.stackMgr.GetStack(app.StackName); ok {
@@ -1403,7 +1403,6 @@ func (s *Server) buildAppBackupRows(status *backup.FullBackupStatus) []AppBackup
HDDSizeHuman: app.HDDSizeHuman,
Tier1Contents: tier1Contents,
Tier23Contents: tier23Contents,
DriveFilesNote: driveFilesNote,
Tier1DBStatus: tier1DBStatus,
}
@@ -1515,6 +1514,12 @@ func (s *Server) buildAppBackupRows(status *backup.FullBackupStatus) []AppBackup
}
}
// The one sentence about the app's own FILES. Set HERE, at the end of the loop, because it
// depends on the tier states resolved above — a note computed from the app's shape alone
// promised a copy that was paused (R-543). See driveFilesNoteFor.
row.DriveFilesNote, row.DriveFilesNoteLink, row.DriveFilesNoteLinkText =
driveFilesNoteFor(hasDriveFileLegs, row.Tier3State, row.Tier2Configured)
rows = append(rows, row)
}
// R-487: the list is keyed on the DRIVES, not on what is deployed — a removed app whose unit was
@@ -0,0 +1,148 @@
package web
import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
)
// ── R-543 — the household is ASKED for the recovery code ────────────────────────────────────────
//
// Off-site backup is ON by default and does NOT run until the escrow ceremony is done. A fresh box
// measured on 2026-09-16 sat at „Kulcsletétre vár" with nothing anywhere asking for the code, while
// the backup page told the household their files were protected.
//
// These drive the REAL pages through ServeHTTP, so they bite on the WIRING — the bar hangs off
// executeTemplate, and a bar that renders only where someone remembered to call a helper is the
// seam-built-but-never-wired failure this repo has shipped four times.
const (
escrowBarSentence = "A távoli mentés szünetel, amíg nem hozod létre a helyreállítási kódot."
escrowBarLink = `href="/backup/escrow"`
)
// escrowServer builds a box whose off-site tier is genuinely CONFIGURED — enabled, valid, with the
// key and password files on disk — and whose escrow sits in the given state. The fixture asserts
// OffboxConfigured() itself: a test that silently loses its precondition measures nothing.
func escrowServer(t *testing.T, escrowState string) *Server {
t.Helper()
s := newDashboardServer(t, time.Time{})
if err := s.settings.SetOffboxTarget(&settings.OffboxTarget{
Enabled: true, Host: "nas.local", User: "felhom", RepoPath: "/srv/repo",
EscrowState: escrowState,
}); err != nil {
t.Fatalf("set offbox target: %v", err)
}
dir := filepath.Join(s.cfg.Paths.DataDir, "offbox")
if err := os.MkdirAll(dir, 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "ssh_key"), []byte("not-a-real-key"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "repo_password"), []byte(strings.Repeat("a", 64)), 0o600); err != nil {
t.Fatal(err)
}
if !s.backupMgr.OffboxConfigured() {
t.Fatal("fixture invalid: the off-site tier is not configured, so nothing here measures the paused state")
}
return s
}
// ── SCENARIO A — paused, and EVERY dashboard page asks ──────────────────────────────────────────
//
// RED-PROOF: remove `s.addEscrowBanner(data, r)` from executeTemplate (server.go) and this fails on
// every page — the fresh box goes back to saying nothing while the copy never runs.
func TestR543_A_PausedBoxAsksOnEveryPage(t *testing.T) {
s := escrowServer(t, "pending")
for _, path := range []string{"/dashboard", "/launcher"} {
rec := getPage(t, s, path)
if rec.Code != 200 {
t.Fatalf("GET %s = %d: %s", path, rec.Code, rec.Body.String())
}
body := rec.Body.String()
if !strings.Contains(body, escrowBarSentence) {
t.Errorf("R-543: %s does not tell the household the off-site copy is PAUSED. "+
"The tier is on, nothing is running, and the page is silent about it", path)
}
if !strings.Contains(body, escrowBarLink) {
t.Errorf("R-543: %s states the pause but names no route to end it — a reminder without "+
"its door is the shape that left a fresh box waiting indefinitely", path)
}
}
}
// ── SCENARIO B — the finished state is silent (negative control) ────────────────────────────────
func TestR543_B_EscrowedBoxIsNotNagged(t *testing.T) {
s := escrowServer(t, "escrowed")
rec := getPage(t, s, "/dashboard")
if rec.Code != 200 {
t.Fatalf("GET /dashboard = %d", rec.Code)
}
if strings.Contains(rec.Body.String(), escrowBarSentence) {
t.Error("R-543: a box whose recovery code EXISTS is still told the copy is paused — " +
"the bar must disappear for good when the state is escrowed, not merely be dismissable")
}
}
// ── SCENARIO C — an UNCONFIGURED off-site tier is not nagged either ─────────────────────────────
//
// A box with no off-site target at all has a different, honest sentence on the backups page. Asking
// it for a recovery code would be asking about a copy it has not chosen.
func TestR543_C_UnconfiguredBoxIsNotNagged(t *testing.T) {
s := newDashboardServer(t, time.Time{}) // no offbox target at all
rec := getPage(t, s, "/dashboard")
if rec.Code != 200 {
t.Fatalf("GET /dashboard = %d", rec.Code)
}
if strings.Contains(rec.Body.String(), escrowBarSentence) {
t.Error("R-543: a box with no off-site tier is asked for a recovery code it does not need")
}
}
// ── SCENARIO D — „Most nem" silences it for the VISIT, and the next visit asks again ────────────
//
// RED-PROOF: give the cookie a MaxAge in escrowBannerDismissHandler and the last assertion fails —
// the reminder becomes permanent while the copy stays paused, which is the R-241 lesson exactly.
func TestR543_D_DismissIsForThisVisitOnly(t *testing.T) {
s := escrowServer(t, "pending")
// The dismiss POST issues a SESSION cookie: no Max-Age and no Expires.
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/backup/escrow/banner/dismiss", nil)
s.ServeHTTP(rec, req)
if rec.Code != http.StatusFound {
t.Fatalf("dismiss POST = %d, want 302: %s", rec.Code, rec.Body.String())
}
setCookie := rec.Header().Get("Set-Cookie")
if !strings.Contains(setCookie, escrowBannerCookie+"=1") {
t.Fatalf("dismiss set no banner cookie: %q", setCookie)
}
if strings.Contains(strings.ToLower(setCookie), "max-age") || strings.Contains(strings.ToLower(setCookie), "expires") {
t.Errorf("R-543: the dismissal outlives the browser session (%q). The off-site copy is still "+
"paused tomorrow, so the question must still be asked tomorrow", setCookie)
}
// A visit carrying the cookie: quiet.
rec2 := httptest.NewRecorder()
req2 := httptest.NewRequest(http.MethodGet, "/dashboard", nil)
req2.AddCookie(&http.Cookie{Name: escrowBannerCookie, Value: "1"})
s.ServeHTTP(rec2, req2)
if strings.Contains(rec2.Body.String(), escrowBarSentence) {
t.Error("R-543: the dismissal does not quieten the bar for this visit")
}
// A NEW visit (no cookie): asked again.
if !strings.Contains(getPage(t, s, "/dashboard").Body.String(), escrowBarSentence) {
t.Error("R-543: the next visit is not asked again, although nothing about the box changed")
}
}
@@ -0,0 +1,80 @@
package web
import (
"strings"
"testing"
)
// ── R-543 (second half) — the tier-1 sentence tells the truth about the STATE ───────────────────
//
// v0.244.0 fixed the label (R-537) and introduced a new promise in its place: „a távoli másolat …
// védi", printed from the app's SHAPE alone. On a fresh box the off-site tier is on and PAUSED for
// the recovery code, so that sentence named a protection that had never run once.
//
// The states are tier3State's own vocabulary — the row and the sentence read one source.
const (
protectedNow = "védi" // the active claim
protectedWould = "védené" // the paused, conditional form
pausedClause = "szünetel" // ...and WHY it has not happened
)
func TestR543_Tier1Sentence_ActiveStateKeepsThePromise(t *testing.T) {
note, link, _ := driveFilesNoteFor(true, "active", false)
if !strings.Contains(note, protectedNow) {
t.Errorf("a RUNNING off-site copy must say so plainly; got %q", note)
}
if link != "" {
t.Errorf("a box in the finished state is offered a button it does not need: %q", link)
}
}
// RED-PROOF: return the v0.244.0 sentence for every state and this fails — the paused box is told
// its files are protected by a copy that has never run.
func TestR543_Tier1Sentence_PausedStateDoesNotPromise(t *testing.T) {
note, link, linkText := driveFilesNoteFor(true, "escrow_pending", false)
if strings.Contains(note, protectedNow) {
t.Errorf("R-543: the sentence claims the files ARE protected while the copy is paused for the "+
"recovery code. This is the exact promise a fresh box read for its whole first day: %q", note)
}
if !strings.Contains(note, protectedWould) || !strings.Contains(note, pausedClause) {
t.Errorf("R-543: the paused sentence must say the copy WOULD protect them and that it is "+
"waiting; got %q", note)
}
if link != "/backup/escrow" || linkText == "" {
t.Errorf("R-543: the paused sentence names no route out (link=%q text=%q)", link, linkText)
}
}
// No off-site, no second drive: say there is no copy. Silence here was how the gap survived.
func TestR543_Tier1Sentence_NoCopyAtAllSaysSo(t *testing.T) {
note, link, _ := driveFilesNoteFor(true, "off", false)
if note == "" {
t.Fatal("R-543: an app whose files have NO copy renders no sentence at all")
}
if strings.Contains(note, protectedNow) || strings.Contains(note, protectedWould) {
t.Errorf("R-543: a box with no copy of these files still talks about protection: %q", note)
}
if link == "" {
t.Error("R-543: the no-copy sentence names neither of the two ways out")
}
}
// A second drive IS a real copy of the files — it must not be told it has none.
func TestR543_Tier1Sentence_SecondDriveCounts(t *testing.T) {
note, _, _ := driveFilesNoteFor(true, "off", true)
if !strings.Contains(note, protectedNow) {
t.Errorf("a box whose second drive holds the files is told they are unprotected: %q", note)
}
}
// Negative control: an app whose data really is inside its captured volumes gets no sentence at all,
// in any state — the note exists only for drive-side file legs.
func TestR543_Tier1Sentence_NoFileLegsNoSentence(t *testing.T) {
for _, st := range []string{"active", "escrow_pending", "off", "unconfigured"} {
if note, _, _ := driveFilesNoteFor(false, st, false); note != "" {
t.Errorf("state %q: an app with no drive-side files gets a sentence about them: %q", st, note)
}
}
}
+7
View File
@@ -568,6 +568,8 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
case path == "/backup/shares/place" && r.Method == http.MethodPost:
s.sharesPlaceHandler(w, r)
// Controller-driven escrow ceremony wizard (v0.127.0): the customer-facing R flow.
case path == "/backup/escrow/banner/dismiss" && r.Method == http.MethodPost:
s.escrowBannerDismissHandler(w, r)
case path == "/backup/escrow" && r.Method == http.MethodGet:
s.escrowWizardPageHandler(w, r)
// fork-4: escrow atomicity — confirm the R-escrow ceremony; DR pre-place the recovered password.
@@ -759,6 +761,11 @@ func (s *Server) executeTemplate(w http.ResponseWriter, r *http.Request, name st
}
data["CSRFField"] = s.csrfField(r)
data["CSRFToken"] = s.csrfToken(r)
// R-543 — the escrow reminder hangs HERE, on the single render choke point, so it reaches every
// dashboard page instead of the three pages a per-handler call would have covered. The login and
// claim pages render through s.tmpl.ExecuteTemplate directly and never pass through here; the
// session check inside keeps it off the guest share page, which has no admin session.
s.addEscrowBanner(data, r)
var buf bytes.Buffer
if err := s.tmpl.ExecuteTemplate(&buf, name, data); err != nil {
s.logger.Printf("[ERROR] [web] Template error (%s): %v", name, err)
@@ -203,7 +203,7 @@
</span>
{{end}}
<span class="tier-contents">{{.Tier1Contents}}</span>
{{if .DriveFilesNote}}<span class="state-text-neutral" style="font-size:.8rem">{{.DriveFilesNote}}</span>{{end}}
{{if .DriveFilesNote}}<span class="state-text-neutral" style="font-size:.8rem">{{.DriveFilesNote}}{{if .DriveFilesNoteLink}} <a href="{{.DriveFilesNoteLink}}">{{.DriveFilesNoteLinkText}} &rarr;</a>{{end}}</span>{{end}}
{{if and .HasDB (eq .Tier1DBStatus "error")}}
<span class="text-error" style="font-size:.8rem"><svg class="ico ico-sm"><use href="#i-triangle-alert"/></svg> DB dump hiba</span>
{{end}}
@@ -174,6 +174,27 @@
</div>
</div>
{{end}}
{{/* R-543 (v0.245.0) — the escrow reminder bar. The off-site tier is ON by default and does NOT RUN
until the household creates its recovery code (zero-knowledge escrow: their code is the only key).
The pause is the design; what was missing was ASKING. Same mechanism as the R-241 bar above —
dismissable for the visit, back at the next one, gone for good when the escrow completes. */}}
{{if .EscrowBanner}}
<div class="alerts-container">
<div class="alert-banner alert-banner-warning">
<span class="alert-icon"><svg class="ico"><use href="#i-triangle-alert"/></svg></span>
<span class="alert-message">
A távoli mentés szünetel, amíg nem hozod létre a helyreállítási kódot.
<a href="/backup/escrow"><strong>Helyreállítási kód létrehozása &rarr;</strong></a>
</span>
<span class="alert-actions">
<form method="POST" action="/backup/escrow/banner/dismiss" style="display:inline">
{{.CSRFField}}<input type="hidden" name="back" value="{{.EscrowBannerBack}}">
<button type="submit" class="btn btn-sm btn-outline">Most nem</button>
</form>
</span>
</div>
</div>
{{end}}
{{if .Alerts}}
<div class="alerts-container">
{{range .Alerts}}