From d85198b835b6e1ba4119b60c9f6b968e42a1b42b Mon Sep 17 00:00:00 2001
From: kisfenyo
Date: Tue, 27 Oct 2026 17:23:37 +0100
Subject: [PATCH] hub(R-1299): the one-time clean-up window gets a button on
the customer page
The Backup card's off-site part shows the window state (weekly switch, the normal cap, a waiting grant, the last
window) and a form: optional limit -> a confirm step that names the customer and the limit -> the SAME route and
rule as the script address (POST /offsite/window-grant/, wr(grpOffsite), the page's CSRF). A waiting grant
can be cancelled. A raised limit above the store's measured snapshot count is refused (the 500 ceiling stays). Every
grant and cancel: a log line and an operator-only info event offsite_window_grant (who, customer, limit).
Tests TestR1299_* (five), red-proved by five mutations.
---
hub/CHANGELOG.md | 19 ++
hub/internal/notify/dispatcher.go | 3 +
hub/internal/store/offsite_keys.go | 40 ++++
hub/internal/web/configs.go | 5 +-
hub/internal/web/offsite_window.go | 203 +++++++++++++++++
hub/internal/web/offsite_window_test.go | 214 ++++++++++++++++++
hub/internal/web/server.go | 35 +--
.../web/templates/customer_unified.html | 62 +++++
8 files changed, 549 insertions(+), 32 deletions(-)
create mode 100644 hub/internal/web/offsite_window.go
create mode 100644 hub/internal/web/offsite_window_test.go
diff --git a/hub/CHANGELOG.md b/hub/CHANGELOG.md
index d2928c6f..e6e7ce8e 100644
--- a/hub/CHANGELOG.md
+++ b/hub/CHANGELOG.md
@@ -1,3 +1,22 @@
+## Unreleased (next: v0.174.0) — R-1299: the one-time clean-up window gets a button on the customer page
+
+- **R-1299:** the customer page's Backup card, in its off-site part, shows a „Clean-up window" block: the weekly
+ switch, the normal cap per window (half the snapshots, at least 5 — `offsitekeys.MaxRemove`), a waiting one-time
+ grant and its limit, and the last window (`#id`, time, result, count before → after, cap). A form takes an optional
+ limit; a confirm step (a GET that stores nothing) names the customer and the limit; the confirm posts to the SAME
+ route as before, `POST /offsite/window-grant/` (rule unchanged: `wr(grpOffsite)` — a session or an
+ offsite:write token), with the page's CSRF token, `from=page` and `confirm=1`, and answers with a redirect and a
+ flash. A waiting grant shows a „Cancel" button instead (`cancel=1`, `store.CancelOffsiteWindowGrant`).
+- **New bound:** a raised limit above the store's own snapshot count (when the box has measured it) is refused — a
+ window cannot remove more than exist, so a larger number is a typo; the 500 ceiling stays (`windowGrantBound`). The
+ script path gets the same bound (400).
+- Every grant and cancel: a log line (customer, limit, who — the token's name or „operator session") and an
+ operator-only info event `offsite_window_grant` (recorded, never mailed; `notify.operatorOnlyEvents`). The raised-cap
+ grant still sends its warning `offsite_window_large_grant` as well. An unknown customer is refused on both paths.
+- `internal/web/offsite_window.go` (the handler moved out of `server.go`), `store.PendingOffsiteWindowGrant` (reads
+ without consuming), `store.LatestOffsiteWindow`. Tests `TestR1299_*` (five), red-proved by five mutations (the block
+ removed, the limit ignored, no bound, no confirm check, no cancel).
+
## v0.173.0 (2026-10-27) — D53: a held app reaches the operator (`08` §10); R-1218: a keyed recovery package is never replaced by a key-less one — DEPLOYED on the operator's yes (image sha256:458bfb03…, from `29134c52`, CI 2418)
- **R-972: Go 1.27.3** (`go.mod` + the builder image `golang:1.27.3-*` pinned by digest; released 2026-10-19 with a
diff --git a/hub/internal/notify/dispatcher.go b/hub/internal/notify/dispatcher.go
index 6ec7cdb8..e46da93e 100644
--- a/hub/internal/notify/dispatcher.go
+++ b/hub/internal/notify/dispatcher.go
@@ -802,6 +802,9 @@ var operatorOnlyEvents = map[string]bool{
"offsite_prune_guard_refused": true,
// R-833 (v0.129.0): the operator raised ONE window's removal cap — an operator act, logged.
"offsite_window_large_grant": true,
+ // R-1299 (v0.174.0): every grant and cancel of the one-shot window, with who and which limit — an operator act,
+ // recorded (info), never mailed. Hub-origin only, like the large grant.
+ "offsite_window_grant": true,
// R-895 slice 2 (v0.158.0): the hub's own snapshot listing — files gone outside a clean-up window, files no box run
// explains. Custody facts about a repository the household cannot inspect; listed in the SAME commit that mints
// them. Hub-origin only: deliberately NOT in api.allowedEventTypes, so no box can POST one.
diff --git a/hub/internal/store/offsite_keys.go b/hub/internal/store/offsite_keys.go
index 3f00ae06..9d87ca0d 100644
--- a/hub/internal/store/offsite_keys.go
+++ b/hub/internal/store/offsite_keys.go
@@ -125,6 +125,20 @@ func (s *Store) GetOffsiteWindow(id int64) (*OffsiteWindow, error) {
return &w, nil
}
+// LatestOffsiteWindow returns the customer's most recent window row, or (nil, nil) when there is none (R-1299: the
+// customer page shows its result next to the button).
+func (s *Store) LatestOffsiteWindow(customerID string) (*OffsiteWindow, error) {
+ var id int64
+ err := s.db.QueryRow(`SELECT id FROM offsite_windows WHERE customer_id = ? ORDER BY id DESC LIMIT 1`, customerID).Scan(&id)
+ if err == sql.ErrNoRows {
+ return nil, nil
+ }
+ if err != nil {
+ return nil, err
+ }
+ return s.GetOffsiteWindow(id)
+}
+
// LastOffsiteWindowOpened returns when the customer's most recent window was opened (zero = never).
func (s *Store) LastOffsiteWindowOpened(customerID string) time.Time {
var v sql.NullString
@@ -203,6 +217,32 @@ func (s *Store) TakeOffsiteWindowGrant(customerID string) (granted bool, maxRemo
return true, maxRemove
}
+// PendingOffsiteWindowGrant reads a one-shot grant WITHOUT consuming it (R-1299: the page says a grant is waiting).
+// maxRemove is the raised cap, 0 = the default cap.
+func (s *Store) PendingOffsiteWindowGrant(customerID string) (pending bool, maxRemove int) {
+ v := s.getSetting("offsite_window_grant:" + customerID)
+ switch {
+ case v == "1":
+ return true, 0
+ case strings.HasPrefix(v, "max:"):
+ n, err := strconv.Atoi(strings.TrimPrefix(v, "max:"))
+ if err != nil || n <= 0 {
+ n = 0
+ }
+ return true, n
+ }
+ return false, 0
+}
+
+// CancelOffsiteWindowGrant removes a waiting one-shot grant (R-1299: the operator's undo before the box uses it).
+// cancelled is false when none was waiting.
+func (s *Store) CancelOffsiteWindowGrant(customerID string) (cancelled bool, err error) {
+ if pending, _ := s.PendingOffsiteWindowGrant(customerID); !pending {
+ return false, nil
+ }
+ return true, s.setSetting("offsite_window_grant:"+customerID, "")
+}
+
// ForceOffsiteWindowDueForTest back-dates a window's closes_by. TEST-ONLY.
func (s *Store) ForceOffsiteWindowDueForTest(id int64) error {
_, err := s.db.Exec(`UPDATE offsite_windows SET closes_by = datetime('now', '-1 minute') WHERE id = ?`, id)
diff --git a/hub/internal/web/configs.go b/hub/internal/web/configs.go
index 5f735b8b..909b4f3b 100644
--- a/hub/internal/web/configs.go
+++ b/hub/internal/web/configs.go
@@ -305,7 +305,9 @@ func (s *Server) handleCustomerUnified(w http.ResponseWriter, r *http.Request, c
// BackupCard (R-331) is resolved in Go, not in the template: the card must distinguish
// "no data reported" from "measured empty" from "never measured", and a {{if}} chain over
// Report's float64s cannot. See backup_card.go.
- BackupCard backupCardView
+ BackupCard backupCardView
+ // WindowCard (R-1299) is the one-shot clean-up window block inside the Backup card's off-site part.
+ WindowCard windowCardView
OverallStatus string
HostCause string // v0.53.0 roll-up: "" or "host down|stale|pending: "
@@ -486,6 +488,7 @@ func (s *Server) handleCustomerUnified(w http.ResponseWriter, r *http.Request, c
Customer: customer,
Report: report,
BackupCard: backupCard,
+ WindowCard: s.buildWindowCard(customerID, backupCard, r.URL.Query()),
OverallStatus: overallStatus,
HostCause: hostCause,
diff --git a/hub/internal/web/offsite_window.go b/hub/internal/web/offsite_window.go
new file mode 100644
index 00000000..380ef399
--- /dev/null
+++ b/hub/internal/web/offsite_window.go
@@ -0,0 +1,203 @@
+package web
+
+import (
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/url"
+ "strconv"
+ "strings"
+
+ "gitea.dooplex.hu/admin/felhom-hub/internal/offsitekeys"
+ "gitea.dooplex.hu/admin/felhom-hub/internal/store"
+)
+
+// The one-shot clean-up window on the customer page (R-1299). Until hub v0.174.0 the grant was an address only a
+// script could call (`POST /offsite/window-grant/`), so on 2026-10-26 the operator had to mint a one-purpose
+// token to grant demo-hp one window (D55). The customer page's Backup card now shows the window state and a form: an
+// optional limit, a confirm step that names the customer and the limit, then the same route under the same rule
+// (`wr(grpOffsite)` — a session, or a token with offsite:write), with the page's CSRF token. Pinned by
+// offsite_window_test.go.
+
+// eventWindowGrant records every grant and every cancel: who, which customer, which limit. Hub-origin, operator-only
+// (notify.operatorOnlyEvents), severity info — recorded, never mailed. The raised-cap grant keeps its own warning
+// mail as well (offsitekeys.EventWindowLargeGrant).
+const eventWindowGrant = "offsite_window_grant"
+
+// windowCardView is the customer page's clean-up window block.
+type windowCardView struct {
+ WeeklyOn bool // the fleet-wide weekly window switch
+ Pending bool // a one-shot grant waits for the box's next off-site run
+ PendingMax int // its raised limit, 0 = the normal cap
+ Last *store.OffsiteWindow // the latest window, nil = never
+ CapKnown bool // the snapshot count is known, so the normal cap is
+ NormalCap int // offsitekeys.MaxRemove(snapshots): half, at least 5
+ Bound int // the highest limit the form accepts (windowGrantBound)
+ // Confirm is the second step: the page was opened with ?window=confirm; ConfirmLimit is the limit to confirm ("" =
+ // the normal cap), already checked — an invalid one renders the refusal instead (LimitError).
+ Confirm bool
+ ConfirmLimit string
+ LimitError string
+}
+
+// windowGrantBound is the highest raised limit the hub accepts for this customer: never above the store's own
+// snapshot count when the box has measured it (a window cannot remove more than exist, so a larger number is a typo),
+// and never above offsitekeys.MaxRemoveGrantCeiling. known=false: only the ceiling applies.
+func windowGrantBound(card backupCardView) (bound int, known bool) {
+ bound = offsitekeys.MaxRemoveGrantCeiling
+ if card.OffsiteReported && card.StatsKnown && card.Snapshots > 0 && card.Snapshots < bound {
+ return card.Snapshots, true
+ }
+ return bound, false
+}
+
+// checkWindowLimit parses an optional limit: "" = the normal cap (0). The error is the sentence the page shows.
+func checkWindowLimit(raw string, card backupCardView) (int, error) {
+ raw = strings.TrimSpace(raw)
+ if raw == "" {
+ return 0, nil
+ }
+ n, err := strconv.Atoi(raw)
+ if err != nil || n < 1 {
+ return 0, fmt.Errorf("the limit must be a whole number of 1 or more, or empty for the normal cap")
+ }
+ if bound, known := windowGrantBound(card); n > bound {
+ if known {
+ return 0, fmt.Errorf("the limit %d is above the store's %d snapshot(s) — a window cannot remove more than exist", n, bound)
+ }
+ return 0, fmt.Errorf("the limit %d is above the ceiling %d", n, bound)
+ }
+ return n, nil
+}
+
+func (s *Server) customerBackupCard(customerID string) backupCardView {
+ c, _ := s.store.GetCustomer(customerID)
+ if c == nil {
+ return backupCardView{}
+ }
+ return reportBackupCard(c.ReportJSON)
+}
+
+// buildWindowCard resolves the block for the page (the confirm step reads ?window=confirm&window_max=N).
+func (s *Server) buildWindowCard(customerID string, card backupCardView, q url.Values) windowCardView {
+ v := windowCardView{WeeklyOn: s.store.OffsiteWindowsEnabled()}
+ v.Pending, v.PendingMax = s.store.PendingOffsiteWindowGrant(customerID)
+ v.Last, _ = s.store.LatestOffsiteWindow(customerID)
+ if card.StatsKnown {
+ v.CapKnown, v.NormalCap = true, offsitekeys.MaxRemove(card.Snapshots)
+ }
+ v.Bound, _ = windowGrantBound(card)
+ if q.Get("window") == "confirm" {
+ if _, err := checkWindowLimit(q.Get("window_max"), card); err != nil {
+ v.LimitError = err.Error()
+ } else {
+ v.Confirm, v.ConfirmLimit = true, strings.TrimSpace(q.Get("window_max"))
+ }
+ }
+ return v
+}
+
+// actorOf names who made a request: the token's name, or the operator's session.
+func actorOf(r *http.Request) string {
+ if t := requestToken(r); t != nil {
+ return "token " + t.Name
+ }
+ return "operator session"
+}
+
+// handleOffsiteWindowGrant serves POST /offsite/window-grant/ and POST /offsite/windows-enabled
+// (decision 68; R-833; R-1299). A script gets JSON as before; the page's form (from=page) gets a redirect with a flash.
+//
+// max_remove optional raised limit for this ONE window (bounded: windowGrantBound)
+// cancel=1 remove a waiting grant instead
+// confirm=1 required from the page: the form's second step
+func (s *Server) handleOffsiteWindowGrant(w http.ResponseWriter, r *http.Request, path string) {
+ if r.Method != http.MethodPost || s.offsiteWindowGrant == nil {
+ http.Error(w, "unavailable", http.StatusServiceUnavailable)
+ return
+ }
+ if path == "/offsite/windows-enabled" {
+ if err := s.offsiteWindowSwitch(r.FormValue("on") == "1"); err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte("{\"ok\":true}\n"))
+ return
+ }
+ cid := strings.TrimPrefix(path, "/offsite/window-grant/")
+ page := r.FormValue("from") == "page"
+ answer := func(code int, flash, msg string) {
+ if page {
+ http.Redirect(w, r, "/customers/"+url.PathEscape(cid)+"?flash="+flash+"#offsite-window", http.StatusSeeOther)
+ return
+ }
+ if code >= 400 {
+ http.Error(w, msg, code)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte("{\"ok\":true}\n"))
+ }
+ if cfg, _ := s.store.GetCustomerConfig(cid); cfg == nil {
+ answer(http.StatusNotFound, "window_unknown_customer", "no customer "+cid)
+ return
+ }
+ who := actorOf(r)
+
+ if r.FormValue("cancel") == "1" {
+ cancelled, err := s.store.CancelOffsiteWindowGrant(cid)
+ if err != nil {
+ answer(http.StatusInternalServerError, "window_failed", err.Error())
+ return
+ }
+ if !cancelled {
+ answer(http.StatusConflict, "window_none_waiting", "no grant is waiting for "+cid)
+ return
+ }
+ s.logger.Printf("[INFO] offsite window: the waiting grant for %s was CANCELLED by %s", cid, who)
+ s.emitWindowEvent(cid, fmt.Sprintf("Off-site clean-up: the waiting one-time window was cancelled (by %s).", who), map[string]any{"cancelled": true, "by": who})
+ answer(http.StatusOK, "window_cancelled", "")
+ return
+ }
+
+ if page && r.FormValue("confirm") != "1" {
+ answer(http.StatusBadRequest, "window_needs_confirm", "confirm=1 required")
+ return
+ }
+ limit, err := checkWindowLimit(r.FormValue("max_remove"), s.customerBackupCard(cid))
+ if err != nil {
+ s.logger.Printf("[WARN] offsite window: grant for %s REFUSED (%v) — asked by %s", cid, err, who)
+ answer(http.StatusBadRequest, "window_limit_refused", err.Error())
+ return
+ }
+ if limit > 0 {
+ if s.offsiteWindowGrantMax == nil {
+ answer(http.StatusServiceUnavailable, "window_failed", "a raised limit is unavailable")
+ return
+ }
+ err = s.offsiteWindowGrantMax(cid, limit)
+ } else {
+ err = s.offsiteWindowGrant(cid)
+ }
+ if err != nil {
+ answer(http.StatusBadRequest, "window_failed", err.Error())
+ return
+ }
+ lim := "the normal cap"
+ if limit > 0 {
+ lim = fmt.Sprintf("a limit of %d", limit)
+ }
+ s.logger.Printf("[INFO] offsite window: ONE clean-up window granted for %s with %s, by %s", cid, lim, who)
+ s.emitWindowEvent(cid, fmt.Sprintf("Off-site clean-up: one window granted with %s (by %s); the box uses it on its next off-site run.", lim, who),
+ map[string]any{"max_remove": limit, "by": who})
+ answer(http.StatusOK, "window_granted", "")
+}
+
+func (s *Server) emitWindowEvent(customerID, msg string, details map[string]any) {
+ if s.emit == nil {
+ return
+ }
+ b, _ := json.Marshal(details)
+ s.emit(customerID, eventWindowGrant, "info", msg, string(b), "hub")
+}
diff --git a/hub/internal/web/offsite_window_test.go b/hub/internal/web/offsite_window_test.go
new file mode 100644
index 00000000..d61faff0
--- /dev/null
+++ b/hub/internal/web/offsite_window_test.go
@@ -0,0 +1,214 @@
+package web
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strings"
+ "testing"
+ "time"
+
+ "gitea.dooplex.hu/admin/felhom-hub/internal/store"
+)
+
+// R-1299: the one-shot clean-up window on the customer page. The page renders the block per gate branch; a grant
+// through the form (session + CSRF + the confirm step) stores ONE grant with its limit; the box's next window uses it
+// up; a limit above the store's snapshot count is refused; a POST without CSRF never reaches the store.
+
+const windowReport = `{"customer_id":"acme","customer_name":"Acme","offsite":{"enabled":true,"state":"active",
+ "last_status":"ok","last_success":"2026-10-27T03:17:00Z","snapshot_count":40,"stats_known":true}}`
+
+type windowEvent struct{ customer, typ, sev, msg, details string }
+
+// windowRig: a password-protected server (so RequireAuth and the CSRF gate run) with one customer whose box reported
+// 40 off-site snapshots, the grant functions wired as main.go wires them (the store's), and the events captured.
+func windowRig(t *testing.T, report string) (*Server, *store.Store, http.Handler, *[]windowEvent) {
+ t.Helper()
+ s, st := serverWithPassword(t, "op-pass")
+ if err := st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "acme", CustomerName: "Acme", Domain: "acme.hu",
+ RetrievalPassword: "pw", APIKey: "k", Status: "active"}); err != nil {
+ t.Fatal(err)
+ }
+ if report != "" {
+ if err := st.SaveReport("acme", []byte(report)); err != nil {
+ t.Fatal(err)
+ }
+ }
+ s.SetOffsiteWindowAdmin(st.GrantOffsiteWindowOnce, st.SetOffsiteWindowsEnabled)
+ s.SetOffsiteWindowLargeGrant(st.GrantOffsiteWindowOnceMax)
+ var evs []windowEvent
+ s.SetEventEmitter(func(c, typ, sev, msg, det, _ string) { evs = append(evs, windowEvent{c, typ, sev, msg, det}) })
+ s.sessionsMu.Lock()
+ s.sessions["sess1"] = &hubSession{expiresAt: time.Now().Add(time.Hour), csrfToken: "tok1"}
+ s.sessionsMu.Unlock()
+ return s, st, s.Handler(), &evs
+}
+
+func windowPost(h http.Handler, form url.Values, csrf bool) *httptest.ResponseRecorder {
+ if csrf {
+ form.Set("_csrf", "tok1")
+ }
+ r := httptest.NewRequest(http.MethodPost, "/offsite/window-grant/acme", strings.NewReader(form.Encode()))
+ r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ r.AddCookie(&http.Cookie{Name: SessionCookieName, Value: "sess1"})
+ w := httptest.NewRecorder()
+ h.ServeHTTP(w, r)
+ return w
+}
+
+func windowPage(t *testing.T, h http.Handler, query string) string {
+ t.Helper()
+ r := httptest.NewRequest(http.MethodGet, "/customers/acme"+query, nil)
+ r.AddCookie(&http.Cookie{Name: SessionCookieName, Value: "sess1"})
+ w := httptest.NewRecorder()
+ h.ServeHTTP(w, r)
+ if w.Code != http.StatusOK {
+ t.Fatalf("GET /customers/acme%s: %d", query, w.Code)
+ }
+ return w.Body.String()
+}
+
+// The page, per branch of its gates (the seam-wiring rule: a handler test alone proves nothing about reachability).
+// RED-PROOF: remove the block from customer_unified.html → the first case fails; drop `{{if .Pending}}` → the third.
+func TestR1299_ThePageShowsTheWindowBlockPerBranch(t *testing.T) {
+ _, st, h, _ := windowRig(t, windowReport)
+
+ page := windowPage(t, h, "")
+ for _, want := range []string{`id="offsite-window"`, `id="window-form"`, `name="window" value="confirm"`, "Normal cap per window",
+ "20 (half the snapshots", "at most 40", "none waiting", "never"} {
+ if !strings.Contains(page, want) {
+ t.Errorf("the page lacks %q", want)
+ }
+ }
+
+ page = windowPage(t, h, "?window=confirm&window_max=33")
+ for _, want := range []string{`id="window-confirm"`, "a limit of 33", `name="confirm" value="1"`, `name="_csrf" value="tok1"`, `name="from" value="page"`} {
+ if !strings.Contains(page, want) {
+ t.Errorf("the confirm step lacks %q", want)
+ }
+ }
+ if !strings.Contains(windowPage(t, h, "?window=confirm&window_max=41"), "above the store's 40 snapshot(s)") {
+ t.Error("a limit above the snapshot count must render the refusal at the confirm step, not the confirm")
+ }
+
+ if err := st.GrantOffsiteWindowOnceMax("acme", 30); err != nil {
+ t.Fatal(err)
+ }
+ page = windowPage(t, h, "")
+ if !strings.Contains(page, "limit 30") || !strings.Contains(page, `name="cancel" value="1"`) || strings.Contains(page, `id="window-form"`) {
+ t.Error("a waiting grant must show its limit and the cancel button, and no second form")
+ }
+
+ _, _, h2, _ := windowRig(t, `{"customer_id":"acme","offsite":{"enabled":false}}`)
+ if strings.Contains(windowPage(t, h2, ""), `id="offsite-window"`) {
+ t.Error("the block must not render when off-site is not enabled")
+ }
+}
+
+// The form's grant: ONE grant with the limit, a redirect with its flash, an operator event naming who and the limit;
+// the box's window uses it up, and the next window finds none.
+// RED-PROOF: make the handler ignore max_remove → the stored grant has no limit → fails.
+func TestR1299_AGrantThroughTheFormStoresOneGrantWithItsLimit(t *testing.T) {
+ _, st, h, evs := windowRig(t, windowReport)
+ w := windowPost(h, url.Values{"from": {"page"}, "confirm": {"1"}, "max_remove": {"33"}}, true)
+ if w.Code != http.StatusSeeOther || !strings.Contains(w.Header().Get("Location"), "flash=window_granted") {
+ t.Fatalf("grant: %d Location=%q", w.Code, w.Header().Get("Location"))
+ }
+ if pending, max := st.PendingOffsiteWindowGrant("acme"); !pending || max != 33 {
+ t.Fatalf("stored grant: pending=%v max=%d, want true 33", pending, max)
+ }
+ if len(*evs) != 1 || (*evs)[0].typ != eventWindowGrant || (*evs)[0].sev != "info" ||
+ !strings.Contains((*evs)[0].details, `"max_remove":33`) || !strings.Contains((*evs)[0].details, `"by":"operator session"`) {
+ t.Fatalf("events: %+v — want one info %s naming the limit and who", *evs, eventWindowGrant)
+ }
+ if granted, max := st.TakeOffsiteWindowGrant("acme"); !granted || max != 33 {
+ t.Fatalf("the window took granted=%v max=%d", granted, max)
+ }
+ if granted, _ := st.TakeOffsiteWindowGrant("acme"); granted {
+ t.Fatal("a second window reused the one-shot grant")
+ }
+ if pending, _ := st.PendingOffsiteWindowGrant("acme"); pending {
+ t.Fatal("the page would still show a used grant as waiting")
+ }
+}
+
+// Refusals that store nothing: a limit above the snapshot count, a non-number, no confirm step, no CSRF token.
+// RED-PROOF: drop the bound in checkWindowLimit → the 41 case stores a grant → fails; drop the confirm check → the
+// no-confirm case stores one.
+func TestR1299_RefusalsStoreNothing(t *testing.T) {
+ cases := []struct {
+ name string
+ form url.Values
+ csrf bool
+ want func(*httptest.ResponseRecorder) bool
+ }{
+ {"above the snapshot count", url.Values{"from": {"page"}, "confirm": {"1"}, "max_remove": {"41"}}, true,
+ func(w *httptest.ResponseRecorder) bool {
+ return strings.Contains(w.Header().Get("Location"), "flash=window_limit_refused")
+ }},
+ {"not a number", url.Values{"from": {"page"}, "confirm": {"1"}, "max_remove": {"2e2"}}, true,
+ func(w *httptest.ResponseRecorder) bool {
+ return strings.Contains(w.Header().Get("Location"), "flash=window_limit_refused")
+ }},
+ {"script above the snapshot count", url.Values{"max_remove": {"500"}}, true,
+ func(w *httptest.ResponseRecorder) bool { return w.Code == http.StatusBadRequest }},
+ {"no confirm step", url.Values{"from": {"page"}, "max_remove": {"10"}}, true,
+ func(w *httptest.ResponseRecorder) bool {
+ return strings.Contains(w.Header().Get("Location"), "flash=window_needs_confirm")
+ }},
+ {"no CSRF token", url.Values{"from": {"page"}, "confirm": {"1"}}, false,
+ func(w *httptest.ResponseRecorder) bool { return w.Code == http.StatusForbidden }},
+ }
+ for _, c := range cases {
+ _, st, h, evs := windowRig(t, windowReport)
+ w := windowPost(h, c.form, c.csrf)
+ if !c.want(w) {
+ t.Errorf("%s: %d Location=%q", c.name, w.Code, w.Header().Get("Location"))
+ }
+ if pending, _ := st.PendingOffsiteWindowGrant("acme"); pending {
+ t.Errorf("%s: a grant was stored", c.name)
+ }
+ if len(*evs) != 0 {
+ t.Errorf("%s: %d event(s) for a refused request", c.name, len(*evs))
+ }
+ }
+}
+
+// Without a measured snapshot count only the ceiling bounds the limit (D55's 260 must stay possible).
+func TestR1299_WithoutACountOnlyTheCeilingBounds(t *testing.T) {
+ _, st, h, _ := windowRig(t, "")
+ if w := windowPost(h, url.Values{"from": {"page"}, "confirm": {"1"}, "max_remove": {"260"}}, true); !strings.Contains(w.Header().Get("Location"), "flash=window_granted") {
+ t.Fatalf("260 with no count: Location=%q", w.Header().Get("Location"))
+ }
+ if _, max := st.PendingOffsiteWindowGrant("acme"); max != 260 {
+ t.Fatalf("stored max %d, want 260", max)
+ }
+ _, st2, h2, _ := windowRig(t, "")
+ if w := windowPost(h2, url.Values{"from": {"page"}, "confirm": {"1"}, "max_remove": {"501"}}, true); !strings.Contains(w.Header().Get("Location"), "flash=window_limit_refused") {
+ t.Fatalf("501: Location=%q", w.Header().Get("Location"))
+ }
+ if pending, _ := st2.PendingOffsiteWindowGrant("acme"); pending {
+ t.Fatal("501 stored a grant")
+ }
+}
+
+// The cancel removes a waiting grant and records who; with none waiting it changes nothing.
+func TestR1299_CancelRemovesAWaitingGrant(t *testing.T) {
+ _, st, h, evs := windowRig(t, windowReport)
+ if w := windowPost(h, url.Values{"from": {"page"}, "cancel": {"1"}}, true); !strings.Contains(w.Header().Get("Location"), "flash=window_none_waiting") {
+ t.Fatalf("cancel with none waiting: Location=%q", w.Header().Get("Location"))
+ }
+ _ = st.GrantOffsiteWindowOnce("acme")
+ if w := windowPost(h, url.Values{"from": {"page"}, "cancel": {"1"}}, true); !strings.Contains(w.Header().Get("Location"), "flash=window_cancelled") {
+ t.Fatalf("cancel: Location=%q", w.Header().Get("Location"))
+ }
+ if pending, _ := st.PendingOffsiteWindowGrant("acme"); pending {
+ t.Fatal("the grant is still waiting after the cancel")
+ }
+ if granted, _ := st.TakeOffsiteWindowGrant("acme"); granted {
+ t.Fatal("the box's window would still use the cancelled grant")
+ }
+ if len(*evs) != 1 || !strings.Contains((*evs)[0].details, `"cancelled":true`) {
+ t.Fatalf("events: %+v — want one cancel event", *evs)
+ }
+}
diff --git a/hub/internal/web/server.go b/hub/internal/web/server.go
index c994fa83..8efaa0a0 100644
--- a/hub/internal/web/server.go
+++ b/hub/internal/web/server.go
@@ -12,7 +12,6 @@ import (
"log"
"math"
"net/http"
- "strconv"
"strings"
"sync"
"time"
@@ -777,36 +776,10 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]int{"changed": n})
case strings.HasPrefix(path, "/offsite/window-grant/") || path == "/offsite/windows-enabled":
- // Operator (decision 68): a one-shot grant lets the customer's NEXT window request through;
- // the switch turns the WEEKLY window on/off fleet-wide (off = the interim: nothing prunes).
- if r.Method != http.MethodPost || s.offsiteWindowGrant == nil {
- http.Error(w, "unavailable", http.StatusServiceUnavailable)
- return
- }
- var err error
- if path == "/offsite/windows-enabled" {
- err = s.offsiteWindowSwitch(r.FormValue("on") == "1")
- } else if mr := r.FormValue("max_remove"); mr != "" {
- // R-833: one window with a raised removal cap (operator only — this server is behind the
- // operator's login; the box API has no route to it).
- n, perr := strconv.Atoi(mr)
- if perr != nil || s.offsiteWindowGrantMax == nil {
- http.Error(w, "max_remove must be a number", http.StatusBadRequest)
- return
- }
- if err = s.offsiteWindowGrantMax(strings.TrimPrefix(path, "/offsite/window-grant/"), n); err != nil {
- http.Error(w, err.Error(), http.StatusBadRequest)
- return
- }
- } else {
- err = s.offsiteWindowGrant(strings.TrimPrefix(path, "/offsite/window-grant/"))
- }
- if err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
- return
- }
- w.Header().Set("Content-Type", "application/json")
- _, _ = w.Write([]byte("{\"ok\":true}\n"))
+ // Operator (decision 68): a one-shot grant lets the customer's NEXT window request through, with an optional
+ // raised cap (R-833); the switch turns the WEEKLY window on/off fleet-wide. The customer page's form (R-1299)
+ // posts here too — offsite_window.go.
+ s.handleOffsiteWindowGrant(w, r, path)
case strings.HasPrefix(path, "/os/"):
// Operator (hub v0.130.0, `11` §8 step 2): per-box ring and switch, approve now, the fleet lines.
s.handleOSAdmin(w, r, path)
diff --git a/hub/internal/web/templates/customer_unified.html b/hub/internal/web/templates/customer_unified.html
index 977b2fab..5629e381 100644
--- a/hub/internal/web/templates/customer_unified.html
+++ b/hub/internal/web/templates/customer_unified.html
@@ -67,6 +67,13 @@
{{else if eq .Flash "selfbind-no-email"}}Self-bind link NOT sent — this customer has no registered email address. Set one first, or bind the appliance manually from the Hosts page.
{{else if eq .Flash "selfbind-send-failed"}}Self-bind link send FAILED — the link was invalidated (not left live). Check the hub log (email delivery / send error).
{{else if eq .Flash "reset_done"}}Customer RESET complete — every operational trace was destroyed (offsite repo, PBS namespace, DR recipe, claim state, retained escrow custody). Identity and basic config survive; the audit event stream records it.
+ {{else if eq .Flash "window_granted"}}One clean-up window granted — the box uses it on its next off-site run (see „Clean-up window” in the Backup card).
+ {{else if eq .Flash "window_cancelled"}}The waiting clean-up window was cancelled — nothing will be removed for it.
+ {{else if eq .Flash "window_none_waiting"}}No clean-up window was waiting — nothing changed.
+ {{else if eq .Flash "window_needs_confirm"}}Not granted — the clean-up window needs the confirm step. Nothing was saved.
+ {{else if eq .Flash "window_limit_refused"}}Not granted — the limit is not a whole number of 1 or more, or it is above the store's snapshot count. Nothing was saved.
+ {{else if eq .Flash "window_unknown_customer"}}Not granted — no such customer.
+ {{else if eq .Flash "window_failed"}}The clean-up window could not be saved — see the hub log. Nothing was granted.
{{end}}
{{end}}
@@ -337,6 +344,61 @@
claim the repository is empty and that is not what is known. They fill in after the next
off-site run reads the repository.
{{end}}
+
+
+ {{with $.WindowCard}}
+ Clean-up window
+
+
+ Weekly windows
+ {{if .WeeklyOn}}On{{else}}Off (fleet-wide){{end}}
+
+
+ Normal cap per window
+ {{if .CapKnown}}{{.NormalCap}} (half the snapshots, at least 5){{else}}— (half the snapshots; count not measured){{end}}
+
+
+ One-time window
+ {{if .Pending}}waiting — {{if .PendingMax}}limit {{.PendingMax}}{{else}}the normal cap{{end}}{{else}}none waiting{{end}}
+
+
+ Last window
+ {{with .Last}}#{{.ID}} {{.OpenedAt.UTC.Format "2006-01-02 15:04"}} UTC — {{if .ClosedAt.IsZero}}open{{else}}{{.BoxResult}}, {{.CountBefore}} → {{.CountAfter}}{{end}}{{if .MaxRemove}} (cap {{.MaxRemove}}){{end}}{{else}}never{{end}}
+
+
+ {{if .Pending}}
+
+ {{else if .Confirm}}
+
+ Open
one clean-up window for
{{$.CustomerID}} with
+
{{if .ConfirmLimit}}a limit of {{.ConfirmLimit}}{{else}}the normal cap{{end}}?
+ The box uses it on its next off-site run; it removes only what its keep rule selects, and the
+ fake-snapshot guard still runs. One use, then it is gone.
+
+
+ {{else}}
+ {{if .LimitError}}Not granted: {{.LimitError}}.
{{end}}
+
+ {{end}}
+ {{end}}
{{end}}
{{end}}