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:
2026-07-18 21:45:11 +02:00
parent 28811c207b
commit b6d537d86c
9 changed files with 572 additions and 33 deletions
+49
View File
@@ -341,6 +341,18 @@ func (s *Server) handleCustomerUnified(w http.ResponseWriter, r *http.Request, c
// Claim (v0.50.0, customer-claim arc): the dashboard claim state for the Setup-tab card —
// nil when no code has been issued yet (pre-arc / never-pulled customer).
Claim *store.ClaimState
// StaleSinceReset (v0.67.0, R-37): a RESET completed AFTER the newest report, so every
// health number on this page describes a lifecycle that no longer exists. Without this the
// page keeps showing pre-RESET warnings as if they were current.
StaleSinceReset bool
ResetAt string
// OffsiteUnprovisioned (v0.67.0, R-36 interim): the customer's config says offsite is
// ENABLED, but no descriptor was ever provisioned (type == ""). Provisioning is
// Save-triggered (applyOffsite), and the re-enroll auto-re-issue deliberately skips an
// unprovisioned target — so this state is stable and silent until someone presses Save.
OffsiteUnprovisioned bool
}
pendingSet := make(map[string]bool, len(pendingTails))
@@ -369,6 +381,35 @@ func (s *Server) handleCustomerUnified(w http.ResponseWriter, r *http.Request, c
drUpdated = rec.UpdatedAt
}
// R-37 (v0.67.0): is every health figure on this page pre-RESET? True only when a reset actually
// COMPLETED and no report has arrived since. A reset still running, or one older than the newest
// report, leaves the page exactly as it was.
var staleSinceReset bool
var resetAt string
if cr, err := s.store.LatestCustomerReset(customerID); err != nil {
s.logger.Printf("[WARN] LatestCustomerReset %s: %v", customerID, err)
} else if cr != nil && cr.CompletedAt != nil {
resetAt = cr.CompletedAt.Format("2006-01-02 15:04 MST")
// Stale unless a report is STRICTLY newer than the reset. SQLite timestamps are
// second-resolution, so a report and a reset can tie; a tie resolves to STALE because a
// same-second report almost certainly arrived just before the reset destroyed the state it
// describes. Erring the other way would hide the banner exactly when it matters most.
staleSinceReset = customer == nil || !customer.ReceivedAt.After(*cr.CompletedAt)
}
// R-36 interim (v0.67.0): enabled-but-unprovisioned is a real, stable state — the same predicate
// the offsite re-issue handler already uses to refuse ("No provisioned offsite tier").
var offsiteView struct {
Offsite struct {
Enabled bool `json:"enabled"`
Type string `json:"type"`
} `json:"offsite"`
}
if cfg != nil {
_ = json.Unmarshal([]byte(cfg.ConfigJSON), &offsiteView)
}
offsiteUnprovisioned := offsiteView.Offsite.Enabled && offsiteView.Offsite.Type == ""
data := pageData{
CustomerID: customerID,
CustomerName: name,
@@ -386,6 +427,10 @@ func (s *Server) handleCustomerUnified(w http.ResponseWriter, r *http.Request, c
OverallStatus: overallStatus,
HostCause: hostCause,
StaleSinceReset: staleSinceReset,
ResetAt: resetAt,
OffsiteUnprovisioned: offsiteUnprovisioned,
LatestVersion: latestVersion,
UpdateAvailable: updateAvailable,
ControllerURL: controllerURL,
@@ -561,6 +606,10 @@ func (s *Server) handleConfigCreate(w http.ResponseWriter, r *http.Request) {
}
s.logger.Printf("[INFO] Customer config created: %s", customerID)
// v0.67.0 (R-36 sub-item): mint the self-bind link NOW, not when the operator remembers. The
// box's console banner tells the customer to open „az e-mailben kapott link" — that email should
// already exist by the time anyone reads the banner. Never fails the create (see autoMint…).
s.autoMintSelfBindLink(customerID, cfg.Email, "customer creation")
s.bumpIntent(customerID) // Direction-2: wake a long-polling box in seconds
http.Redirect(w, r, "/customers/"+customerID+"?flash=created", http.StatusSeeOther)
}