hub v0.126.0: fresh connect link from the old one (R-719); day-one mails (R-723); bind-page wording (R-725); volunteer guide current (R-722); ep0 cleanup and release evidence
gates / gates (push) Successful in 29s

Red-proofs RP40-RP42.

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:
2026-09-30 10:35:17 +02:00
parent 27641ce9e7
commit 80aeac71f6
21 changed files with 562 additions and 40 deletions
+16
View File
@@ -1,3 +1,19 @@
## v0.126.0 — a fresh connect link from the old one (R-719); no "recovered" for a new box and no first-hour tier-skip mail (R-723); the bind page names who hands over the phrase (R-725) (2026-09-30)
- **R-719:** a box that registers is UNCLAIMED — the registration carries uuid, MACs, host keys and hardware, nothing
of a customer (measured, `internal/api/appliance.go`) — so the hub cannot send "your" link when "your" box
registers without mailing every waiting customer. Instead the self-bind page's **expired** and **already used**
states offer „Új linket kérek" (`POST /bind/<token>/resend`): the hub mints and mails a fresh link to the address
REGISTERED for that link's customer, only when the customer has no box, at most once an hour per customer. The
answer is the same page in the default language whatever the token was (no oracle — `TestBindExpiredIsAlwaysDefaultLanguage`
now compares the two pages with each visitor's own token normalised; every other byte must still match).
- **R-723:** `node_recovered` is not emitted when the customer's current host was enrolled after the outage began
(or, after a hub restart, within the stale threshold) — a new box is not a recovery; `backup_tier_skipped` in a box's
first hour is recorded, not mailed (provisioning in flight). Real recoveries and old boxes' skips still mail.
- **R-725 (hub half):** the bind page's passphrase hint says „amelyet a Felhom üzemeltetőjétől kaptál", as the mail
and the console do.
- Tests: `TestR719_*`, `TestR723_*`. Red-proofs RP40–RP42, each seen failing on an assertion.
## v0.125.0 — the customer delete stops promising a Cloudflare removal it never did (2026-09-25, R-688)
- **Customer delete dialog** (`web/customer_delete.go`, `templates/customer_unified.html`): no leg of the cascade
+6 -2
View File
@@ -66,7 +66,7 @@
"bind.hint.pairing": "It appears on the box's monitor, after the install.",
"bind.label.passphrase": "Owner passphrase",
"bind.placeholder.passphrase": "the words, with hyphens or spaces",
"bind.hint.passphrase": "The word phrase you received from your operator during setup. It proves the account is yours.",
"bind.hint.passphrase": "The five-word phrase you received from your Felhom operator. It proves the account is yours.",
"bind.submit": "Link the box",
"bind.note": "For safety the link locks after 5 failed attempts. If that happens, contact support.",
"bind.success.lead": "Linked successfully.",
@@ -82,5 +82,9 @@
"mail.test.body": "Dear Customer,\n\nThis is a test notification from the Felhom monitoring system.\nNotifications are working correctly.\n\nBest regards,\nFelhom.eu monitoring",
"mail.event.app_update_undone": "%s: the update did not work; the app runs on its previous version",
"mail.event.app_update_held": "%s: the app is stopped and needs a restore",
"mail.event.app_stopped_unhealthy": "%s: the app was stopped because it kept crashing"
"mail.event.app_stopped_unhealthy": "%s: the app was stopped because it kept crashing",
"bind.resend.hint": "If this was your link and your box is not linked yet, we send a fresh link to the e-mail address you gave Felhom.",
"bind.resend.button": "Send me a new link",
"bind.resent.lead": "Done.",
"bind.resent.body": "If this was a real link and your box is not linked yet, a new e-mail reaches your registered address within a few minutes. If it does not, contact support."
}
+6 -2
View File
@@ -66,7 +66,7 @@
"bind.hint.pairing": "A doboz monitorán jelenik meg, a telepítés után.",
"bind.label.passphrase": "Tulajdonosi jelmondat",
"bind.placeholder.passphrase": "öt szó, kötőjellel vagy szóközzel",
"bind.hint.passphrase": "Az öt szóból álló kifejezés, amelyet a beállításkor kaptál. Ez igazolja, hogy a fiók a tiéd.",
"bind.hint.passphrase": "Az öt szóból álló kifejezés, amelyet a Felhom üzemeltetőjétől kaptál. Ez igazolja, hogy a fiók a tiéd.",
"bind.submit": "Összekötés",
"bind.note": "Biztonsági okból 5 sikertelen próbálkozás után a hivatkozás zárolódik. Ilyenkor vedd fel a kapcsolatot az ügyfélszolgálattal.",
"bind.success.lead": "Sikeres összekötés.",
@@ -82,5 +82,9 @@
"mail.test.body": "Kedves Ügyfél!\n\nEz egy teszt értesítés a Felhom monitoring rendszerből.\nAz értesítések megfelelően működnek.\n\nÜdvözlettel,\nFelhom.eu monitoring",
"mail.event.app_update_undone": "%s: a frissítés nem sikerült, az alkalmazás a korábbi változattal fut",
"mail.event.app_update_held": "%s: az alkalmazás leállítva, visszaállítás szükséges",
"mail.event.app_stopped_unhealthy": "%s: az alkalmazást leállítottuk, mert újra és újra összeomlott"
"mail.event.app_stopped_unhealthy": "%s: az alkalmazást leállítottuk, mert újra és újra összeomlott",
"bind.resend.hint": "Ha ez a te linked volt, és a dobozod még nincs összekötve, új linket küldünk arra az e-mail címre, amelyet a Felhomnál megadtál.",
"bind.resend.button": "Új linket kérek",
"bind.resent.lead": "Kész.",
"bind.resent.body": "Ha ez egy valódi hivatkozás volt, és a dobozod még nincs összekötve, néhány percen belül új e-mailt kapsz a regisztrált címedre. Ha nem jön, szólj az ügyfélszolgálatnak."
}
+58
View File
@@ -0,0 +1,58 @@
package monitor
import (
"database/sql"
"testing"
"time"
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
)
func hostFor(t *testing.T, st *store.Store, path string, createdAgo time.Duration) {
t.Helper()
if err := st.UpsertHost(&store.Host{HostID: "c1-abc123", CustomerID: "c1", APIKey: "hk"}); err != nil {
t.Fatal(err)
}
db, err := sql.Open("sqlite", path)
if err != nil {
t.Fatal(err)
}
defer db.Close()
if _, err := db.Exec(`UPDATE hosts SET created_at = ? WHERE host_id = 'c1-abc123'`,
time.Now().UTC().Add(-createdAgo).Format("2006-01-02 15:04:05")); err != nil {
t.Fatal(err)
}
}
// R-723 (v0.126.0) — the 2026-09-29 shape: the customer's previous box went silent 12 days ago (the hub
// seeds the customer as down), a NEW box enrolls and sends its first report. The CONSEQUENCE asserted: no
// `node_recovered` (it is the operator mail „recovered" for an outage the new box never had).
// COMPANION RED-PROOF: make isNewBox return false → the event list is [node_recovered].
func TestR723_ANewBoxIsNotARecovery(t *testing.T) {
st, path := seedStalenessCustomer(t, "ok", 12*24*time.Hour)
sc, events := newChecker(t, st) // seeds c1 as down (the old box's silence)
if sc.GetState("c1") != "down" {
t.Fatalf("setup: want down, got %q", sc.GetState("c1"))
}
hostFor(t, st, path, 2*time.Minute) // the new box enrolled 2 min ago
saveReportAged(t, st, path, "ok", 0) // …and reported
sc.Check()
if len(*events) != 0 {
t.Fatalf("a new box's first report emitted %v", *events)
}
if sc.GetState("c1") != "ok" {
t.Fatalf("state %q, want ok", sc.GetState("c1"))
}
}
// Control: the SAME box, enrolled long ago, coming back after an outage IS a recovery and still mails.
func TestR723_AnOldBoxComingBackIsStillARecovery(t *testing.T) {
st, path := seedStalenessCustomer(t, "ok", 12*24*time.Hour)
sc, events := newChecker(t, st)
hostFor(t, st, path, 40*24*time.Hour)
saveReportAged(t, st, path, "ok", 0)
sc.Check()
if len(*events) != 1 || (*events)[0] != "node_recovered" {
t.Fatalf("a real recovery must still emit node_recovered, got %v", *events)
}
}
+25
View File
@@ -171,6 +171,19 @@ func (sc *StalenessChecker) Check() {
continue
}
// R-723 (v0.126.0): a customer's NEW box is not a recovery. Staleness is tracked per CUSTOMER, so a
// customer whose previous box went silent reads stale/down, and the new box's first report used to
// send `node_recovered` for an outage the new box never had (measured 2026-09-29: tester-1, silent 12
// days, new box enrolled 19:20:07Z, operator mail „recovered" 2 s after its first report). The
// discriminator is the host record: enrolled AFTER the outage began (or, when the hub restarted during
// the outage and has no start time, within the stale threshold) → a first observation, no event.
if newState == "ok" && (oldState == "stale" || oldState == "down") && sc.isNewBox(c.CustomerID) {
sc.logger.Printf("[INFO] Staleness: %s → ok on the first reports of a NEW box — not a recovery, no event", c.CustomerID)
sc.states[c.CustomerID] = newState
delete(sc.downtimeStart, c.CustomerID)
continue
}
// State transition — emit event
sc.states[c.CustomerID] = newState
if newState == "stale" && oldState == "ok" {
@@ -250,3 +263,15 @@ func formatDuration(d time.Duration) string {
}
return fmt.Sprintf("%dh%dm", h, m)
}
// isNewBox reports whether the customer's current host was enrolled after its outage began (R-723).
func (sc *StalenessChecker) isNewBox(customerID string) bool {
h, err := sc.store.GetHostByCustomer(customerID)
if err != nil || h == nil || h.CreatedAt.IsZero() {
return false // no evidence of a new box → keep the old behaviour (a real recovery mails)
}
if since, ok := sc.downtimeStart[customerID]; ok {
return h.CreatedAt.After(since)
}
return time.Since(h.CreatedAt) < sc.threshold
}
+27
View File
@@ -531,6 +531,18 @@ func (d *Dispatcher) processOperator(customerID, eventType, severity, message, d
cooldownTierSuffix(detailsJSON) + cooldownRunSuffix(detailsJSON) +
cooldownStackSuffix(eventType, detailsJSON) + cooldownStorageSuffix(eventType, detailsJSON)
window := operatorCooldownFor(eventType)
// R-723 (v0.126.0): a whole-guest tier skipped for absent storage in a box's FIRST HOUR is provisioning
// still in flight, not a fault (measured 2026-09-29: the first local backup ran 7 min after enrolment,
// the off-site descriptor arrived with the next 15-min host report, the off-site tier then backed up
// fine — and the operator had been mailed „skipped"). Recorded, not mailed.
if eventType == "backup_tier_skipped" && d.hostEnrolledWithin(customerID, firstHourOfABox) {
if err := d.store.LogNotification(customerID, eventType, severity, message,
"suppressed", "first hour of a new box (R-723)", "operator"); err != nil {
d.logger.Printf("[WARN] Failed to record suppressed operator notification for %s/%s: %v", customerID, eventType, err)
}
d.logger.Printf("[INFO] Operator email suppressed for %s/%s — the box enrolled less than %s ago", customerID, eventType, firstHourOfABox)
return
}
d.mu.Lock()
if last, ok := d.opCooldowns[cooldownKey]; ok && time.Since(last) < window {
d.mu.Unlock()
@@ -856,3 +868,18 @@ func (d *Dispatcher) SendSelfBindEmail(customerID, email, link string) error {
d.store.LogNotification(customerID, "selfbind_link", "info", subject, "sent", "", "customer")
return nil
}
// firstHourOfABox is how long after enrolment a whole-guest tier skip is provisioning, not a fault (R-723).
const firstHourOfABox = time.Hour
// hostEnrolledWithin reports whether the customer's current host was enrolled less than d ago.
func (d *Dispatcher) hostEnrolledWithin(customerID string, within time.Duration) bool {
if d.store == nil {
return false
}
h, err := d.store.GetHostByCustomer(customerID)
if err != nil || h == nil || h.CreatedAt.IsZero() {
return false
}
return time.Since(h.CreatedAt) < within
}
@@ -0,0 +1,65 @@
package notify
import (
"database/sql"
"io"
"log"
"path/filepath"
"testing"
"time"
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
_ "modernc.org/sqlite"
)
// storeWithHost opens a store with customer c1 and one host enrolled `ago` before now.
func storeWithHost(t *testing.T, ago time.Duration) *store.Store {
t.Helper()
path := filepath.Join(t.TempDir(), "d.db")
st, err := store.New(path, log.New(io.Discard, "", 0))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "c1", APIKey: "k", RetrievalPassword: "p"})
if err := st.UpsertHost(&store.Host{HostID: "c1-abc123", CustomerID: "c1", APIKey: "hk"}); err != nil {
t.Fatal(err)
}
db, err := sql.Open("sqlite", path)
if err != nil {
t.Fatal(err)
}
defer db.Close()
if _, err := db.Exec(`UPDATE hosts SET created_at = ? WHERE host_id = 'c1-abc123'`,
time.Now().UTC().Add(-ago).Format("2006-01-02 15:04:05")); err != nil {
t.Fatal(err)
}
return st
}
const tierSkipMsg = "Whole-guest backup tier felhom-pbs skipped: its storage does not exist on the host (never provisioned or removed). No app was stopped for it."
// R-723 (v0.126.0) — the 2026-09-29 shape: the first whole-guest run fired 7 min after enrolment, before
// the off-site descriptor arrived, and the operator was mailed „skipped". The CONSEQUENCE asserted: no
// operator mail in a box's first hour.
// COMPANION RED-PROOF: drop the first-hour check in processOperator → 1 operator mail.
func TestR723_FirstHourTierSkipIsNotMailed(t *testing.T) {
st := storeWithHost(t, 7*time.Minute)
d := NewDispatcher(st, "test-key", "hub@felhom.eu", "op@felhom.eu", true, log.New(io.Discard, "", 0))
mails := captureSeam(d)
d.ProcessEvent("c1", "backup_tier_skipped", "warning", tierSkipMsg, `{"tier":"felhom-pbs"}`, "controller")
if op := mailsFor(*mails, "op@felhom.eu"); len(op) != 0 {
t.Fatalf("a first-hour tier skip mailed the operator (%d mails)", len(op))
}
}
// Control: the same skip on a box enrolled a week ago is a real provisioning gap and still mails.
func TestR723_TierSkipOnAnOldBoxStillMails(t *testing.T) {
st := storeWithHost(t, 7*24*time.Hour)
d := NewDispatcher(st, "test-key", "hub@felhom.eu", "op@felhom.eu", true, log.New(io.Discard, "", 0))
mails := captureSeam(d)
d.ProcessEvent("c1", "backup_tier_skipped", "warning", tierSkipMsg, `{"tier":"felhom-pbs"}`, "controller")
if op := mailsFor(*mails, "op@felhom.eu"); len(op) != 1 {
t.Fatalf("an old box's tier skip must still mail the operator, got %d", len(op))
}
}
+5
View File
@@ -83,6 +83,11 @@ func TestBindExpiredIsAlwaysDefaultLanguage(t *testing.T) {
// A token that was never real.
unknown := getBind(t, s, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
// v0.126.0 (R-719): the expired page echoes the visitor's OWN token into its „Új linket kérek" form. The
// visitor already holds that token (it is the URL they opened), so it discloses nothing; every OTHER
// byte must still be identical, which is what the comparison below keeps asserting.
realExpired = strings.ReplaceAll(realExpired, expiredTok, "<token>")
unknown = strings.ReplaceAll(unknown, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "<token>")
if realExpired != unknown {
t.Errorf("an expired REAL token and an unknown token render differently — the page is an "+
"oracle for whether a link existed.\n--- real ---\n%s\n--- unknown ---\n%s", realExpired, unknown)
+54 -3
View File
@@ -97,7 +97,7 @@ func bindClientIP(r *http.Request) string {
// --- the page ---
type bindPageData struct {
State string // "form" | "success" | "expired" | "consumed" | "locked"
State string // "form" | "success" | "expired" | "consumed" | "locked" | "resent"
Token string // echoed into the form action (the capability itself; already in the URL)
Failed bool // generic factor-check failure (form state only)
// Lang is the language to render in. It is the CUSTOMER'S CREATION-TIME language and nothing
@@ -157,6 +157,11 @@ func (s *Server) handleBind(w http.ResponseWriter, r *http.Request) {
return
}
token := strings.TrimPrefix(r.URL.Path, "/bind/")
// R-719 (v0.126.0): „Új linket kérek" on an expired or used link.
if t, ok := strings.CutSuffix(token, "/resend"); ok && r.Method == http.MethodPost && t != "" && !strings.Contains(t, "/") {
s.handleBindResend(w, t)
return
}
// A trailing segment only — reject anything with further path structure (defence in depth atop
// the ServeMux path-clean; the token is a flat hex string).
if token == "" || strings.Contains(token, "/") {
@@ -194,10 +199,11 @@ func (s *Server) handleBind(w http.ResponseWriter, r *http.Request) {
// (nil) is folded into "expired": no oracle for "was this link ever real".
switch {
case tok == nil || tok.Expired(now):
s.renderBind(w, http.StatusOK, bindPageData{State: "expired", Lang: i18n.Default})
// The token is echoed for the resend form whether or not it is real: the page must not differ.
s.renderBind(w, http.StatusOK, bindPageData{State: "expired", Lang: i18n.Default, Token: token})
return
case tok.Consumed():
s.renderBind(w, http.StatusOK, bindPageData{State: "consumed", Lang: lang})
s.renderBind(w, http.StatusOK, bindPageData{State: "consumed", Lang: lang, Token: token})
return
case tok.Locked:
s.renderBind(w, http.StatusOK, bindPageData{State: "locked", Lang: lang})
@@ -327,15 +333,60 @@ const bindPageHTML = `<!DOCTYPE html>
{{else if eq .State "consumed"}}
<p class="lead">{{T "bind.consumed.lead"}}</p>
<p>{{T "bind.consumed.body"}}</p>
<form method="POST" action="/bind/{{.Token}}/resend"><p class="hint">{{T "bind.resend.hint"}}</p><button type="submit">{{T "bind.resend.button"}}</button></form>
{{else if eq .State "resent"}}
<p class="lead">{{T "bind.resent.lead"}}</p>
<p>{{T "bind.resent.body"}}</p>
{{else if eq .State "locked"}}
<p class="lead">{{T "bind.locked.lead"}}</p>
<p>{{T "bind.locked.body"}}</p>
{{else}}
<p class="lead">{{T "bind.invalid.lead"}}</p>
<p>{{T "bind.invalid.body"}}</p>
{{if .Token}}<form method="POST" action="/bind/{{.Token}}/resend"><p class="hint">{{T "bind.resend.hint"}}</p><button type="submit">{{T "bind.resend.button"}}</button></form>{{end}}
{{end}}
</div>
<p class="foot">Felhom.eu</p>
</div>
</body>
</html>`
// ── R-719 (v0.126.0): a returning customer asks for a fresh link from the old one ─────────────────────
//
// A box that registers is UNCLAIMED — the hub cannot know whose it is until the bind (measured 2026-09-30:
// the registration carries uuid, MACs, host keys and hardware, nothing of a customer). So "send the link
// when their box registers" cannot be built without mailing every waiting customer. What the returning
// customer DOES have is their old mail: its link now answers „expired" (7-day TTL) or „already used". That
// page offers one press; the hub mints and mails a fresh link to the address REGISTERED for that link's
// customer, and only when the customer has no box (the same guard as every other auto-send).
//
// No oracle: the answer is the same „resent" page, in the default language, whether the token was real,
// unknown, still live, or the customer already has a box. Limits: the per-IP bind limiter, and one mail
// per customer per bindResendEvery. Pinned by internal/web/selfbind_resend_test.go.
const bindResendEvery = time.Hour
func (s *Server) handleBindResend(w http.ResponseWriter, token string) {
defer s.renderBind(w, http.StatusOK, bindPageData{State: "resent", Lang: i18n.Default})
tok, err := s.store.SelfBindTokenByHash(selfBindHash(token))
if err != nil || tok == nil {
return
}
now := time.Now()
if !tok.Expired(now) && !tok.Consumed() {
return // a live link needs no replacement
}
s.bindResendMu.Lock()
last := s.bindResendAt[tok.CustomerID]
if now.Sub(last) < bindResendEvery {
s.bindResendMu.Unlock()
s.logger.Printf("[INFO] self-bind: fresh link for %s NOT sent — one was sent %s ago (limit %s)", tok.CustomerID, now.Sub(last).Round(time.Second), bindResendEvery)
return
}
if s.bindResendAt == nil {
s.bindResendAt = map[string]time.Time{}
}
s.bindResendAt[tok.CustomerID] = now
s.bindResendMu.Unlock()
s.autoMintSelfBindIfWaiting(tok.CustomerID, "fresh link asked on an expired or used link")
}
+78
View File
@@ -0,0 +1,78 @@
package web
import (
"net/http/httptest"
"strings"
"testing"
"time"
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
)
func resendPOST(s *Server, token string) *httptest.ResponseRecorder {
rr := httptest.NewRecorder()
s.handleBind(rr, httptest.NewRequest("POST", "/bind/"+token+"/resend", nil))
return rr
}
// R-719 (v0.126.0) — a returning customer's old link has expired; the page offers a fresh one.
// The CONSEQUENCE asserted: a NEW link is mailed to the registered address, and it binds (a live token).
// COMPANION RED-PROOF: make handleBindResend return before autoMintSelfBindIfWaiting → no link mailed.
func TestR719_AnExpiredLinkOffersAndSendsAFreshOne(t *testing.T) {
s, st := newTestServer(t)
m := &stubMailer{}
s.SetSelfBindMailer(m)
seedForMint(t, st, "tester", "tester1@felhom.example")
old := mintLink(t, st, "tester", -time.Hour) // expired a while ago
if body := bindGET(t, s, old).Body.String(); !strings.Contains(body, "/bind/"+old+"/resend") || !strings.Contains(body, "Új linket kérek") {
t.Fatal("the expired page does not offer a fresh link")
}
rr := resendPOST(s, old)
if !strings.Contains(rr.Body.String(), "Kész.") {
t.Fatalf("resend page: %s", rr.Body.String())
}
if m.link == "" {
t.Fatal("no fresh link was mailed for an expired link of a box-less customer")
}
fresh := m.link[strings.LastIndexByte(m.link, '/')+1:]
if tok, _ := st.SelfBindTokenByHash(selfBindHash(fresh)); tok == nil || tok.Expired(time.Now()) {
t.Fatal("the mailed link is not live")
}
}
// No oracle, and no spam: an unknown token, a customer that already has a box, and a second press within
// the hour all get the SAME page and NO mail.
func TestR719_ResendIsNoOracleAndRateLimited(t *testing.T) {
s, st := newTestServer(t)
m := &stubMailer{}
s.SetSelfBindMailer(m)
seedForMint(t, st, "tester", "tester1@felhom.example")
old := mintLink(t, st, "tester", -time.Hour)
unknown := resendPOST(s, strings.Repeat("a", 64)).Body.String()
if m.link != "" {
t.Fatal("an unknown token mailed someone")
}
_ = resendPOST(s, old) // first press → mail
first := m.link
m.link = ""
again := resendPOST(s, old).Body.String()
if m.link != "" {
t.Fatal("a second press within the hour mailed again")
}
if first == "" || unknown != again {
t.Fatal("the answer differs between an unknown token and a real one — an oracle")
}
// A customer that already has a box gets no link either.
seedForMint(t, st, "boxed", "b@felhom.example")
if err := st.UpsertHost(&store.Host{HostID: "boxed-1", CustomerID: "boxed", APIKey: "hk"}); err != nil {
t.Fatal(err)
}
b := mintLink(t, st, "boxed", -time.Hour)
_ = resendPOST(s, b)
if m.link != "" {
t.Fatal("a customer with a box was mailed a bind link")
}
}
+2
View File
@@ -73,6 +73,8 @@ type Server struct {
claimEngine *claim.Engine // optional; enables the customer-claim resend button (v0.50.0)
selfBindMailer SelfBindMailer // optional; enables the customer self-bind link button (v0.66.0, R-27)
bindLimiter *bindRateLimiter // per-IP throttle for the PUBLIC /bind/ surface (v0.66.0, R-27)
bindResendMu sync.Mutex // R-719: the fresh-link resend limiter
bindResendAt map[string]time.Time // customer → last fresh-link mail (R-719)
// intentHub (v0.58.0, Direction-2 immediate-sync) is Bumped by every operator-intent handler
// (config save/delete, claim resend, offsite re-issue/freeze, floor, block/unblock, log pull)
// so a box long-polling GET /api/v1/wait wakes in seconds. Shared with the API handler. nil =