hub v0.114.0: self-bind auto-send while a customer waits for a box (R-509); node_* bypass the quiet hour (ruling 2026-09-15); PBS re-issue adopts an endpoint token (R-511); controller supervisor events (R-523); event registers

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
2026-09-15 10:05:41 +02:00
parent a028a9a7f5
commit 07959e61b5
16 changed files with 693 additions and 9 deletions
+33
View File
@@ -3,6 +3,7 @@ package web
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"net/http"
"time"
@@ -118,6 +119,7 @@ func (s *Server) autoMintSelfBindLink(customerID, email, occasion string) {
switch outcome {
case selfBindSent:
s.logger.Printf("[INFO] self-bind link auto-minted for %s on %s (the console banner's promised email now exists)", customerID, occasion)
s.recordSelfBindSent(customerID, occasion)
case selfBindSkippedNoMailer:
s.logger.Printf("[INFO] self-bind link NOT auto-minted for %s on %s: no mailer configured on this hub", customerID, occasion)
case selfBindSkippedNoEmail:
@@ -129,6 +131,36 @@ func (s *Server) autoMintSelfBindLink(customerID, email, occasion string) {
}
}
// selfBindSentEvent (R-509, v0.114.0) records WHEN and WHY a self-bind link went out, so the customer
// page can say „Kapcsolódó link elküldve: <date> (<occasion>)". Hub-internal: stored, never dispatched.
const selfBindSentEvent = "selfbind_link_sent"
func (s *Server) recordSelfBindSent(customerID, occasion string) {
details, _ := json.Marshal(map[string]string{"occasion": occasion})
if _, err := s.store.SaveEvent(customerID, selfBindSentEvent, "info", "Self-bind link e-mailed ("+occasion+")", string(details), "hub"); err != nil {
s.logger.Printf("[WARN] self-bind: sent-record for %s not stored: %v", customerID, err)
}
}
// autoMintSelfBindIfWaiting (R-509, operator decision A 2026-09-15) sends the link whenever an event
// leaves a customer WAITING FOR A BOX: an e-mail set or changed on a customer with no bound host, and a
// host delete that keeps the customer. Appliance registration is deliberately NOT a trigger — it knows
// no customer (api/appliance.go). No host bound is re-checked here, so a customer who still has a box
// is never mailed a pairing link.
func (s *Server) autoMintSelfBindIfWaiting(customerID, occasion string) {
cfg, err := s.store.GetCustomerConfig(customerID)
if err != nil || cfg == nil {
return
}
if h, herr := s.store.GetHostByCustomer(customerID); herr != nil || h != nil {
if herr != nil {
s.logger.Printf("[WARN] self-bind auto-send for %s on %s skipped: host lookup failed: %v", customerID, occasion, herr)
}
return
}
s.autoMintSelfBindLink(customerID, cfg.Email, occasion)
}
// handleSelfBindLinkSend — POST /customers/{id}/selfbind-link. Mints a single-active capability token
// for the customer and emails the public bind link. Honesty rules:
// - F1: no registered email → nothing is minted, LOUD flash (a link no one can receive is useless).
@@ -158,6 +190,7 @@ func (s *Server) handleSelfBindLinkSend(w http.ResponseWriter, r *http.Request,
s.logger.Printf("[ERROR] self-bind link for %s: %v", customerID, err)
http.Error(w, "Internal error", http.StatusInternalServerError)
default: // selfBindSent (the no-mailer case was refused above)
s.recordSelfBindSent(customerID, "operator button")
http.Redirect(w, r, "/customers/"+customerID+"?flash=selfbind-sent#tab=setup", http.StatusSeeOther)
}
}