hub v0.67.0 — auto-minted self-bind link, post-RESET staleness, unprovisioned-offsite warning
Four small items, each a case where the hub already knew something and said
nothing. Green: build, vet, tests all pass.
(a) Self-bind link is minted automatically at customer creation AND at RESET
completion (R-36 sub-item). The console banner tells the customer to open
"az e-mailben kapott link"; until now that email existed only once the
operator remembered the button, so the banner could point at something that
did not exist — during the 2026-07-18 rehearsal the box waited ~11.7 min on
exactly that. handleSelfBindLinkSend's body was extracted into a shared
mintAndSendSelfBindLink core so the button and the auto-mint callers cannot
drift apart on the honesty rules: F1 (no address -> mint nothing) and F2
(send failed -> delete the token, never leave it live). The wrapper NEVER
fails the operation it rides on — a create that provisioned Cloudflare,
offsite and PBS must not 500 over a courtesy email.
Gap found and closed while wiring it: PurgeCustomerResetDBState does NOT
clear selfbind_tokens, so a link minted BEFORE a reset would have stayed
live across it. A successful mint already replaces it (delete-then-insert,
single-active); the skip paths would not have, so they now clear stale
tokens too. Invariant: after auto-mint runs the only live link is one it
just issued, or none.
(b) Post-RESET staleness banner (R-37). When a RESET COMPLETED after the newest
report, every health figure on the page describes a lifecycle that no longer
exists, and the page kept showing pre-RESET warnings as current. Narrow on
purpose: an in-flight reset does not trigger it, and it clears itself when a
report arrives. Ties resolve to STALE — SQLite timestamps are second-
resolution and a same-second report almost certainly predates the reset;
erring the other way would hide the banner exactly when it matters.
(c) Unprovisioned-offsite warning (R-36 interim). enabled==true with type=="" is
a real, stable, silent state: provisioning is Save-triggered and the
re-enroll auto-re-issue deliberately skips an unprovisioned target, so
nothing self-heals it. Reuses the exact predicate the offsite re-issue
handler already refuses on.
(d) pbsdr_reissued rendered an EMPTY flash box — the key had no template branch,
so re-issuing PBS credentials showed a success box with no words (observed
live 2026-07-18). Now describes what was staged plus the R-39 caveat:
confirm `pvesm status` shows the entry active, because a converged agent can
report `applied` while the storage still 401s.
New .flash-warn (amber, --warn tokens) for the deviation tier between success
and error — exception-color principle: only on deviation, never on a healthy
page.
Tests assert each banner is ABSENT in the nominal cases as well as present in
the deviating one — a banner that always renders is worse than none. Both
red-proofed: deleting the pbsdr_reissued branch reproduces the original empty
box; neutering the staleness predicate fails the banner assertion. New
read-only store accessor CountSelfBindTokens makes the single-active invariant
assertable.
NOT in this train: the R-39 hub-side generation-bump fix the pre-travel task
made conditional. Its condition was REFUTED (SetHostDesired bumps
unconditionally; applyPBSDR is idempotent as documented) — the real mechanism is
the agent's descriptor-hash convergence and needs its own spec.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nn3VgQk9iwEGgyx6QJ2NvE
This commit is contained in:
@@ -37,6 +37,98 @@ func selfBindHash(token string) string {
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// selfBindOutcome is what mintAndSendSelfBindLink did, so each caller can decide how loud to be.
|
||||
// The operator BUTTON turns these into flashes; the AUTO-MINT callers (create / RESET completion)
|
||||
// only log, because neither may fail an otherwise-successful operation over a courtesy email.
|
||||
type selfBindOutcome int
|
||||
|
||||
const (
|
||||
selfBindSent selfBindOutcome = iota // minted + emailed
|
||||
selfBindSkippedNoMailer // no mailer wired on this hub
|
||||
selfBindSkippedNoEmail // F1: customer has no registered address
|
||||
selfBindSendFailed // F2: send failed, token invalidated
|
||||
selfBindMintFailed // token generation or DB write failed
|
||||
)
|
||||
|
||||
// mintAndSendSelfBindLink is the shared mint+send core (v0.67.0). It was extracted from
|
||||
// handleSelfBindLinkSend so the auto-mint callers reuse the SAME honesty rules rather than
|
||||
// re-implementing them:
|
||||
//
|
||||
// - F1: no registered email → nothing is minted (a link nobody can receive is worse than none).
|
||||
// - F2: send failed → the just-minted token is DELETED, never left silently live.
|
||||
//
|
||||
// The plaintext token exists only between minting and the send: never logged (only an 8-char hash
|
||||
// prefix), never persisted (only its sha256). Callers get the outcome and the underlying error;
|
||||
// nothing here writes an HTTP response, which is what makes it reusable off the request path.
|
||||
func (s *Server) mintAndSendSelfBindLink(customerID, email string) (selfBindOutcome, error) {
|
||||
if s.selfBindMailer == nil {
|
||||
return selfBindSkippedNoMailer, nil
|
||||
}
|
||||
if email == "" {
|
||||
return selfBindSkippedNoEmail, nil
|
||||
}
|
||||
|
||||
token, err := configgen.RandomHex(32) // 256-bit capability token
|
||||
if err != nil {
|
||||
return selfBindMintFailed, err
|
||||
}
|
||||
hash := selfBindHash(token)
|
||||
if err := s.store.MintSelfBindToken(customerID, hash, selfBindTTL); err != nil {
|
||||
return selfBindMintFailed, err
|
||||
}
|
||||
|
||||
link := selfBindBaseURL + "/bind/" + token
|
||||
if err := s.selfBindMailer.SendSelfBindEmail(customerID, email, link); err != nil {
|
||||
if derr := s.store.DeleteSelfBindTokens(customerID); derr != nil {
|
||||
s.logger.Printf("[ERROR] self-bind link for %s: send failed AND cleanup failed: send=%v cleanup=%v", customerID, err, derr)
|
||||
} else {
|
||||
s.logger.Printf("[ERROR] self-bind link for %s: email send failed, token invalidated (hash %s…): %v", customerID, hash[:8], err)
|
||||
}
|
||||
return selfBindSendFailed, err
|
||||
}
|
||||
if err := s.store.MarkSelfBindEmailed(hash); err != nil {
|
||||
s.logger.Printf("[WARN] self-bind link sent to %s but emailed_at not recorded: %v", customerID, err)
|
||||
}
|
||||
s.logger.Printf("[INFO] self-bind link (hash %s…, valid 7 days) emailed to the registered address of %s", hash[:8], customerID)
|
||||
return selfBindSent, nil
|
||||
}
|
||||
|
||||
// autoMintSelfBindLink is the fire-and-log wrapper used at customer creation and at RESET
|
||||
// completion (v0.67.0, R-36 sub-item). The box's console banner tells the customer to open „az
|
||||
// e-mailben kapott link", so that email should already exist by the time anyone reads the banner —
|
||||
// previously it existed only once the operator remembered to press the button.
|
||||
//
|
||||
// It NEVER fails the caller's operation: a customer create that provisioned Cloudflare, offsite and
|
||||
// PBS successfully must not 500 because a courtesy email bounced. Every outcome is logged; the
|
||||
// operator can always re-send from the Setup tab.
|
||||
func (s *Server) autoMintSelfBindLink(customerID, email, occasion string) {
|
||||
outcome, err := s.mintAndSendSelfBindLink(customerID, email)
|
||||
|
||||
// Invariant both call sites need: once this returns, the only live capability token for this
|
||||
// customer is one we just minted — or none at all. It matters on the RESET path, because
|
||||
// PurgeCustomerResetDBState does NOT clear selfbind_tokens, so a link minted BEFORE the reset
|
||||
// would otherwise stay live across it. A successful mint already replaces it (MintSelfBindToken
|
||||
// deletes-then-inserts, single-active); the skip paths are the ones that would leave it behind.
|
||||
if outcome == selfBindSkippedNoMailer || outcome == selfBindSkippedNoEmail {
|
||||
if derr := s.store.DeleteSelfBindTokens(customerID); derr != nil {
|
||||
s.logger.Printf("[WARN] self-bind: could not clear stale tokens for %s on %s: %v", customerID, occasion, derr)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
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:
|
||||
s.logger.Printf("[WARN] self-bind link NOT auto-minted for %s on %s: no registered email address (F1) — set one, then send from the Setup tab", customerID, occasion)
|
||||
case selfBindSendFailed:
|
||||
s.logger.Printf("[WARN] self-bind link auto-mint for %s on %s FAILED to send; token invalidated — re-send from the Setup tab: %v", customerID, occasion, err)
|
||||
case selfBindMintFailed:
|
||||
s.logger.Printf("[ERROR] self-bind link auto-mint for %s on %s failed to mint: %v", customerID, occasion, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 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).
|
||||
@@ -54,41 +146,18 @@ func (s *Server) handleSelfBindLinkSend(w http.ResponseWriter, r *http.Request,
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
// F1: refuse to mint a link that cannot be delivered.
|
||||
if cfg.Email == "" {
|
||||
// The honesty rules (F1/F2) live in the shared core so the button and the auto-mint callers
|
||||
// cannot drift apart; the button's job is only to turn the outcome into an operator-visible flash.
|
||||
switch outcome, err := s.mintAndSendSelfBindLink(customerID, cfg.Email); outcome {
|
||||
case selfBindSkippedNoEmail:
|
||||
s.logger.Printf("[WARN] self-bind link for %s NOT sent: customer has no registered email", customerID)
|
||||
http.Redirect(w, r, "/customers/"+customerID+"?flash=selfbind-no-email#tab=setup", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
token, err := configgen.RandomHex(32) // 256-bit capability token
|
||||
if err != nil {
|
||||
s.logger.Printf("[ERROR] self-bind link for %s: token generation: %v", customerID, err)
|
||||
http.Error(w, "Internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
hash := selfBindHash(token)
|
||||
if err := s.store.MintSelfBindToken(customerID, hash, selfBindTTL); err != nil {
|
||||
s.logger.Printf("[ERROR] self-bind link for %s: minting token: %v", customerID, err)
|
||||
http.Error(w, "Internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
link := selfBindBaseURL + "/bind/" + token
|
||||
if err := s.selfBindMailer.SendSelfBindEmail(customerID, cfg.Email, link); err != nil {
|
||||
// F2: delivery failed — do not leave a live capability token behind (the plaintext link is
|
||||
// already gone from memory, so nobody could ever use it; delete it and surface the failure).
|
||||
if derr := s.store.DeleteSelfBindTokens(customerID); derr != nil {
|
||||
s.logger.Printf("[ERROR] self-bind link for %s: send failed AND cleanup failed: send=%v cleanup=%v", customerID, err, derr)
|
||||
} else {
|
||||
s.logger.Printf("[ERROR] self-bind link for %s: email send failed, token invalidated (hash %s…): %v", customerID, hash[:8], err)
|
||||
}
|
||||
case selfBindSendFailed:
|
||||
http.Redirect(w, r, "/customers/"+customerID+"?flash=selfbind-send-failed#tab=setup", http.StatusSeeOther)
|
||||
return
|
||||
case selfBindMintFailed:
|
||||
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)
|
||||
http.Redirect(w, r, "/customers/"+customerID+"?flash=selfbind-sent#tab=setup", http.StatusSeeOther)
|
||||
}
|
||||
if err := s.store.MarkSelfBindEmailed(hash); err != nil {
|
||||
s.logger.Printf("[WARN] self-bind link sent to %s but emailed_at not recorded: %v", customerID, err)
|
||||
}
|
||||
s.logger.Printf("[INFO] self-bind link (hash %s…, valid 7 days) emailed to the registered address of %s", hash[:8], customerID)
|
||||
http.Redirect(w, r, "/customers/"+customerID+"?flash=selfbind-sent#tab=setup", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user