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:
@@ -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(`
|
||||
|
||||
Reference in New Issue
Block a user