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:
Lemon-miaow committed 2026-09-23 07:48:54 +08:00
1 parent f6dbfd3625
commit 55d515d41f
6 files changed
+173 -27

No files matched your search

+16 -9
View File
@@ -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{