diff --git a/hub/internal/api/claim_ack_test.go b/hub/internal/api/claim_ack_test.go index b274a26..575bf17 100644 --- a/hub/internal/api/claim_ack_test.go +++ b/hub/internal/api/claim_ack_test.go @@ -125,6 +125,49 @@ func TestClaimResetRequest_SelfScoped(t *testing.T) { } } +// v0.52.0 (take-two F-15): the reset-request response carries the ACTIVE code state — the freshly +// ROTATED hash + generation matching the stored row — so the box accepts the emailed code +// immediately instead of waiting for the next report ACK. Never a plaintext code. +func TestClaimResetRequest_ResponseCarriesRotatedHash(t *testing.T) { + h, st, _ := newTestHandler(t) + withClaimEngine(t, h, st) + st.SaveCustomerConfig(&store.CustomerConfig{ + CustomerID: "c1", RetrievalPassword: "pw", APIKey: "KEY1", ConfigJSON: "{}", Email: "a@b.hu", + }) + do(h, http.MethodPost, "/report", globalKey, `{"customer_id":"c1"}`) // issues gen 1 + before, _ := st.GetClaim("c1") + + rr := do(h, http.MethodPost, "/claim/reset-request", "KEY1", `{"customer_id":"c1"}`) + if rr.Code != http.StatusOK { + t.Fatalf("reset request = %d (%s)", rr.Code, rr.Body.String()) + } + var resp struct { + Claim *struct { + CodeHash string `json:"code_hash"` + Generation int `json:"generation"` + IssuedAt string `json:"issued_at"` + } `json:"claim"` + } + if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v", err) + } + after, _ := st.GetClaim("c1") + if resp.Claim == nil { + t.Fatalf("reset-request response carries no claim object: %s", rr.Body.String()) + } + if resp.Claim.Generation != before.Generation+1 || resp.Claim.Generation != after.Generation { + t.Fatalf("response generation = %d, want the rotated one (stored %d, pre-rotation %d)", + resp.Claim.Generation, after.Generation, before.Generation) + } + if resp.Claim.CodeHash != after.CodeHash || resp.Claim.CodeHash == before.CodeHash { + t.Fatalf("response hash must be the freshly stored one (matches-stored=%v, still-old=%v)", + resp.Claim.CodeHash == after.CodeHash, resp.Claim.CodeHash == before.CodeHash) + } + if !strings.HasPrefix(resp.Claim.CodeHash, "$2") || resp.Claim.IssuedAt == "" { + t.Fatalf("response claim malformed: %+v (want bcrypt hash + issued_at)", resp.Claim) + } +} + // The generated controller.yaml bakes the claim hash (Day-0 gate-from-first-boot) and NEVER a // plaintext code; without a claim state the web section is unchanged. func TestConfiggen_BakesClaimHashOnly(t *testing.T) { diff --git a/hub/internal/api/handler.go b/hub/internal/api/handler.go index f479c3d..7d59a90 100644 --- a/hub/internal/api/handler.go +++ b/hub/internal/api/handler.go @@ -1403,9 +1403,21 @@ func (h *Handler) handleClaimResetRequest(w http.ResponseWriter, r *http.Request } else { h.logger.Printf("[INFO] claim reset-request for %s: reset code emailed to the registered address", payload.CustomerID) } + // v0.52.0 (take-two F-15): serve the ACTIVE code state in the response — same shape and same + // bcrypt-only guarantee as the report ACK — so the box accepts the emailed code the moment it + // lands instead of waiting for the next ACK (~15 min). Served on every authorized outcome: on + // a cap-reached refusal it is the unrotated row (a controller-side no-op by generation). + resp := map[string]interface{}{"status": "ok"} + if cs, err := h.store.GetClaim(payload.CustomerID); err == nil && cs != nil { + resp["claim"] = map[string]interface{}{ + "code_hash": cs.CodeHash, + "generation": cs.Generation, + "issued_at": cs.IssuedAt.UTC().Format(time.RFC3339), + } + } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) - w.Write([]byte(`{"status":"ok"}`)) + json.NewEncoder(w).Encode(resp) } // allowedEventTypes lists all valid event_type values the Hub accepts. diff --git a/hub/internal/web/templates/customer_unified.html b/hub/internal/web/templates/customer_unified.html index 3466ba1..d368ab3 100644 --- a/hub/internal/web/templates/customer_unified.html +++ b/hub/internal/web/templates/customer_unified.html @@ -51,6 +51,8 @@ {{else if eq .Flash "blocked"}}Customer blocked — hidden from Dashboard. {{else if eq .Flash "unblocked"}}Customer unblocked — visible on Dashboard again. {{else if eq .Flash "log_tail_requested"}}Log tail requested — the controller delivers it on its next report cycle (a few minutes). A customer-visible event line was recorded. + {{else if eq .Flash "claim-resent"}}Code re-sent to the registered address. A kód a doboz következő jelentésekor (~15 percen belül) aktiválódik. + {{else if eq .Flash "claim-resend-failed"}}Claim code resend FAILED — check the hub log (email delivery / send error). {{end}} {{end}}