fix(provisioning): keep the Owner seat single; clash on the operator name stays retryable
Two defects live-drilled in the break-glass staff provisioning: - An Owner reset that typed any username other than the occupied seat took UpsertOwner's insert arm and silently minted a SECOND owner row, leaving the existing seat — possibly the compromised account the reset was meant to replace — live; every owner row is undeletable through the panel, so the tier could never converge back to one. provisionOwner now refuses with ownerSeatTakenError naming the seat (recoverable: the TUI routes back to the form); bootstrap still mints, and the seat's own username still resets in place. PGRepo gains OwnerUsername for the guard. - InsertOperator returned the raw driver error on a taken username while the console keys its rename prompt off api.ErrConflict — the "choose another name" leg died with SQLSTATE 23505 against real Postgres (the fake encoded the contract; PGRepo had drifted). Map the unique violation to ErrConflict and pin it in pgint. Live (auditfix37): fresh username refused naming the seat; seat reset kept the id/email with still exactly one owner; taken operator name returned to the form with the retry note, and the retyped name succeeded (drill rows cleaned).
This commit is contained in:
6 files changed
+173
-27
No files matched your search
+16
-9
@@ -174,12 +174,13 @@ func (m *ownerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
|
||||
case owProvisionMsg:
|
||||
if msg.err != nil {
|
||||
// A taken Operator username is the expected, recoverable outcome of the
|
||||
// insert-only operator path (refusing the clash is the whole reason it is
|
||||
// insert-only, not an upsert). Route back to the form with a note so the
|
||||
// operator can pick another name, rather than tearing down the console —
|
||||
// any other error is a genuine fault and still ends the session.
|
||||
if m.operation == bgAddOperator && errors.Is(msg.err, api.ErrConflict) {
|
||||
// api.ErrConflict marks the two recoverable refusals: a taken Operator
|
||||
// username (insert-only clash) and an Owner reset naming anything but the
|
||||
// occupied seat (ownerSeatTakenError Is ErrConflict). Route back to the
|
||||
// form with a note so the operator can retype, rather than tearing down
|
||||
// the console — any other error is a genuine fault and still ends the
|
||||
// session.
|
||||
if errors.Is(msg.err, api.ErrConflict) {
|
||||
m.provisionErr = msg.err
|
||||
m.step = owProvision
|
||||
m.form = m.sized(m.buildProvisionForm())
|
||||
@@ -364,9 +365,15 @@ func (m *ownerModel) buildProvisionForm() *huh.Form {
|
||||
}
|
||||
}
|
||||
if m.provisionErr != nil {
|
||||
// The only error routed back to this form is a username clash on the insert-only
|
||||
// operator path; show a concrete prompt to choose another name.
|
||||
desc = "That username is already taken — choose a different one.\n\n" + desc
|
||||
// Recoverable refusals routed back here: the seat refusal already names the
|
||||
// username to enter, so show it verbatim; the operator-name clash gets the
|
||||
// generic retry prompt.
|
||||
note := "That username is already taken — choose a different one."
|
||||
var seatErr *ownerSeatTakenError
|
||||
if errors.As(m.provisionErr, &seatErr) {
|
||||
note = seatErr.Error()
|
||||
}
|
||||
desc = note + "\n\n" + desc
|
||||
}
|
||||
|
||||
fields := []huh.Field{
|
||||
|
||||
Reference in new issue
Block a user