hub: customer-claim password arc parts 1+2 — code engine, emails, ACK, configgen bake, UI (v0.50.0)

Closes DRILL-day0-vm F-4 hub-side: per-customer claim state (customer_claims,
bcrypt-only custody), the claim engine (issue at real config retrieve = Day-0
bake; first-report issue for live boxes; resend rotates generation; reset
rate-limited 3/day), three Hungarian emails via the dispatcher, report-ACK
claim object {code_hash, generation, issued_at} + set-only claimed ingest,
web.claim_code_* baked into generated controller.yaml, Setup-tab status chip
+ resend button, POST /api/v1/claim/reset-request (self-scoped), claim_lockout
event allowlisted. 13 new tests; full repo green.

Claude-Session: https://claude.ai/code/session_01NptTCFtu7dz2Ru89qHRagN
This commit is contained in:
2026-07-12 18:12:48 +02:00
parent b904477ed9
commit 6b40eb8619
14 changed files with 1103 additions and 7 deletions
+13 -1
View File
@@ -13,7 +13,11 @@ import (
// Generate takes the template YAML and a customer config,
// then produces a complete controller.yaml with customer-specific values
// merged in. The returned string is valid YAML ready for deployment.
func Generate(templateYAML string, cfg *store.CustomerConfig) (string, error) {
//
// claimState (v0.50.0, nil-safe): when present, the ACTIVE claim-code bcrypt hash + generation
// are baked into web.claim_code_* so a Day-0 box is claim-gated from its FIRST boot (the
// controller's precedence: a set password always wins; the hash alone never overrides one).
func Generate(templateYAML string, cfg *store.CustomerConfig, claimState *store.ClaimState) (string, error) {
// Parse template into generic map
var base map[string]interface{}
if err := yaml.Unmarshal([]byte(templateYAML), &base); err != nil {
@@ -53,6 +57,14 @@ func Generate(templateYAML string, cfg *store.CustomerConfig) (string, error) {
}
setNested(base, []string{"web", "session_secret"}, sessionSecret)
// Customer-claim arc (v0.50.0): bake the active claim-code hash so the gate is armed from
// first boot. bcrypt only — the plaintext code never reaches any config.
if claimState != nil && claimState.CodeHash != "" {
setNested(base, []string{"web", "claim_code_hash"}, claimState.CodeHash)
setNested(base, []string{"web", "claim_code_generation"}, claimState.Generation)
setNested(base, []string{"web", "claim_code_issued_at"}, claimState.IssuedAt.UTC().Format(time.RFC3339))
}
// Marshal back to YAML
out, err := yaml.Marshal(base)
if err != nil {
@@ -18,7 +18,7 @@ func TestGenerate_DebugLevelOverride(t *testing.T) {
// Override present → the generated YAML carries level: debug, and the info default is gone.
dbg, err := Generate(tmpl, &store.CustomerConfig{
CustomerID: "c1", ConfigJSON: `{"logging":{"level":"debug"}}`,
})
}, nil)
if err != nil {
t.Fatalf("generate (debug): %v", err)
}
@@ -30,7 +30,7 @@ func TestGenerate_DebugLevelOverride(t *testing.T) {
}
// No override → the template default (info) stands, debug absent.
def, err := Generate(tmpl, &store.CustomerConfig{CustomerID: "c1", ConfigJSON: "{}"})
def, err := Generate(tmpl, &store.CustomerConfig{CustomerID: "c1", ConfigJSON: "{}"}, nil)
if err != nil {
t.Fatalf("generate (default): %v", err)
}