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
+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 =