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
+130
View File
@@ -459,6 +459,29 @@ func (s *Store) migrate() error {
return err
}
// v0.50.0 — customer-claim password arc (DRILL-day0-vm F-4): one row per customer holding the
// ACTIVE claim/reset code state. code_hash is bcrypt(code) — the plaintext exists ONLY inside
// the email send (same custody rule as the retrieval passphrase). generation is monotonic: a
// resend/reset rotates the code (generation+1) and the controller refuses codes of an already-
// consumed generation. claimed_at is set once (first successful claim) and NEVER cleared by a
// rotation — a reset code on a claimed box must not un-claim it. emailed_at records the last
// send; reset_day/reset_count are the hub-side 3/day reset-request limiter.
_, err = s.db.Exec(`
CREATE TABLE IF NOT EXISTS customer_claims (
customer_id TEXT PRIMARY KEY,
code_hash TEXT NOT NULL,
generation INTEGER NOT NULL DEFAULT 1,
issued_at DATETIME NOT NULL,
emailed_at DATETIME,
claimed_at DATETIME,
reset_day TEXT NOT NULL DEFAULT '',
reset_count INTEGER NOT NULL DEFAULT 0
);
`)
if err != nil {
return err
}
// v0.43.0 — remote app-log diagnostics. Additive columns on app_log_issues:
// context = JSON array of ±5 redacted lines around the FIRST occurrence (first capture
// wins — stable repro context, no churn); context_customer = whose box it came from
@@ -1030,6 +1053,113 @@ func (s *Store) GetCustomerConfigByAPIKey(apiKey string) (*CustomerConfig, error
return &cfg, nil
}
// ── Customer-claim password arc (v0.50.0, DRILL-day0-vm F-4) ──────────────────────────────
// ClaimState is one customer's active claim/reset-code state. CodeHash is bcrypt(code) — the
// store NEVER holds a plaintext code. Claimed means the customer has completed the claim (set
// their own password) at least once; rotations never clear it.
type ClaimState struct {
CustomerID string
CodeHash string
Generation int
IssuedAt time.Time
EmailedAt *time.Time
ClaimedAt *time.Time
ResetDay string
ResetCount int
}
// Claimed reports whether the claim has been completed at least once.
func (c *ClaimState) Claimed() bool { return c != nil && c.ClaimedAt != nil }
// GetClaim returns the customer's claim state, or nil when none exists.
func (s *Store) GetClaim(customerID string) (*ClaimState, error) {
var cs ClaimState
var issuedAt string
var emailedAt, claimedAt sql.NullString
err := s.db.QueryRow(`
SELECT customer_id, code_hash, generation, issued_at, emailed_at, claimed_at, reset_day, reset_count
FROM customer_claims WHERE customer_id = ?`, customerID,
).Scan(&cs.CustomerID, &cs.CodeHash, &cs.Generation, &issuedAt, &emailedAt, &claimedAt, &cs.ResetDay, &cs.ResetCount)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
cs.IssuedAt = parseSQLiteTime(issuedAt)
if emailedAt.Valid {
t := parseSQLiteTime(emailedAt.String)
cs.EmailedAt = &t
}
if claimedAt.Valid {
t := parseSQLiteTime(claimedAt.String)
cs.ClaimedAt = &t
}
return &cs, nil
}
// RotateClaimCode installs a fresh code hash: creates the row (generation 1) or rotates it
// (generation+1, new issued_at, emailed_at cleared until the send is confirmed). claimed_at is
// deliberately PRESERVED — rotating a claimed customer's code (a password reset) never un-claims
// the box. Returns the new generation.
func (s *Store) RotateClaimCode(customerID, codeHash string) (int, error) {
_, err := s.db.Exec(`
INSERT INTO customer_claims (customer_id, code_hash, generation, issued_at)
VALUES (?, ?, 1, datetime('now'))
ON CONFLICT(customer_id) DO UPDATE SET
code_hash = excluded.code_hash,
generation = customer_claims.generation + 1,
issued_at = datetime('now'),
emailed_at = NULL`,
customerID, codeHash)
if err != nil {
return 0, err
}
var gen int
if err := s.db.QueryRow(`SELECT generation FROM customer_claims WHERE customer_id = ?`, customerID).Scan(&gen); err != nil {
return 0, err
}
return gen, nil
}
// MarkClaimEmailed records that the active code was delivered to the registered address.
func (s *Store) MarkClaimEmailed(customerID string) error {
_, err := s.db.Exec(`UPDATE customer_claims SET emailed_at = datetime('now') WHERE customer_id = ?`, customerID)
return err
}
// MarkClaimed records the first successful claim (idempotent — an already-set claimed_at stays).
// Returns true when this call performed the unclaimed→claimed transition.
func (s *Store) MarkClaimed(customerID string) (bool, error) {
res, err := s.db.Exec(`UPDATE customer_claims SET claimed_at = datetime('now') WHERE customer_id = ? AND claimed_at IS NULL`, customerID)
if err != nil {
return false, err
}
n, _ := res.RowsAffected()
return n > 0, nil
}
// BumpResetCount enforces the hub-side reset-request limiter: increments today's counter and
// returns the post-increment count (the caller compares against the daily cap). The day key
// rolls over automatically (UTC date).
func (s *Store) BumpResetCount(customerID string) (int, error) {
day := time.Now().UTC().Format("2006-01-02")
_, err := s.db.Exec(`
UPDATE customer_claims SET
reset_count = CASE WHEN reset_day = ? THEN reset_count + 1 ELSE 1 END,
reset_day = ?
WHERE customer_id = ?`, day, day, customerID)
if err != nil {
return 0, err
}
var count int
if err := s.db.QueryRow(`SELECT reset_count FROM customer_claims WHERE customer_id = ?`, customerID).Scan(&count); err != nil {
return 0, err
}
return count, nil
}
// SetCustomerConfigStatus sets the status (active/blocked) for a customer config.
func (s *Store) SetCustomerConfigStatus(customerID, status string) error {
_, err := s.db.Exec(`