vole
Clone
Read only, no credentials:
https://rodent.software/vole.git
Write over SSH with a registered key:
git clone ssh://git@rodent.software:2222/vole.git
A free and open-source forge that lives in your Git repo.
Name branches after the account, and labels after the display
474a812d199a109ba894124cd6f26be12c73285d · Keenan Nemetz · 16d ago · parent ade6107e
Name branches after the account, and labels after the display
A contributor's folder was `refs/heads/contrib/<account-id>/`, which told a
reader nothing without decoding a ULID. It is now the account name,
refs/heads/contrib/<name>/<branch>
and the name is therefore fixed at creation: renaming an account would strand
every ref under its old folder, so `vole admin user rename` is gone and the
users row itself — kept after retirement — is what stops a name being reissued.
What people see is now a separate, mutable display name, validated by the same
rules wherever it is set (CLI, remote admin, registration, `/account`) and shown
on pages, approvals, listings and refusals. Nothing is authorized by it and
nothing on disk is keyed by it: metadata authors stay account names because the
push boundary resolves them for ownership, and approvals stay account ids.
`user_names`, `Snapshot.Reserved` and `Account.Former` are dropped: with a fixed
name there are no former names to remember, and the UNIQUE column already does
the reserving. Opening an older roster adds the `display` column and drops the
alias table in place; contributor refs written under the old id namespace are
left where they are, readable, and landable by a maintainer.
server/web/auth_handlers.go +12−5
| @@ -262,6 +262,7 @@ func (w *Web) RegisterBegin(rw http.ResponseWriter, r *http.Request) { | |||
| 262 | 262 | } | |
| 263 | 263 | var body struct { | |
| 264 | 264 | Username string `json:"username"` | |
| 265 | Display string `json:"display"` | ||
| 265 | 266 | } | |
| 266 | 267 | if err := decodeJSONBody(r, &body); err != nil { | |
| 267 | 268 | writeJSONError(rw, http.StatusBadRequest, err.Error()) | |
| @@ -272,6 +273,11 @@ func (w *Web) RegisterBegin(rw http.ResponseWriter, r *http.Request) { | |||
| 272 | 273 | writeJSONError(rw, http.StatusBadRequest, err.Error()) | |
| 273 | 274 | return | |
| 274 | 275 | } | |
| 276 | display := strings.TrimSpace(body.Display) | ||
| 277 | if err := identity.ValidateDisplay(display); err != nil { | ||
| 278 | writeJSONError(rw, http.StatusBadRequest, err.Error()) | ||
| 279 | return | ||
| 280 | } | ||
| 275 | 281 | if _, err := w.Ident.Find(name); err == nil { | |
| 276 | 282 | writeJSONError(rw, http.StatusConflict, "that account name is already taken") | |
| 277 | 283 | return | |
| @@ -281,9 +287,10 @@ func (w *Web) RegisterBegin(rw http.ResponseWriter, r *http.Request) { | |||
| 281 | 287 | writeJSONError(rw, http.StatusInternalServerError, "cannot create an account") | |
| 282 | 288 | return | |
| 283 | 289 | } | |
| 284 | // The user handle is fixed now and written only when the ceremony succeeds. | ||
| 285 | // An abandoned registration therefore reserves neither a name nor an account. | ||
| 286 | user := &waUser{account: &identity.Account{ID: accountID, Name: name, Status: "active"}} | ||
| 290 | // The user handle and display name are fixed now and written only when the | ||
| 291 | // ceremony succeeds. An abandoned registration therefore reserves neither a | ||
| 292 | // name nor an account. | ||
| 293 | user := &waUser{account: &identity.Account{ID: accountID, Name: name, Display: display, Status: "active"}} | ||
| 287 | 294 | creation, data, err := w.Auth.WA.BeginRegistration(user, | |
| 288 | 295 | webauthn.WithResidentKeyRequirement(protocol.ResidentKeyRequirementPreferred)) | |
| 289 | 296 | if err != nil { | |
| @@ -291,7 +298,7 @@ func (w *Web) RegisterBegin(rw http.ResponseWriter, r *http.Request) { | |||
| 291 | 298 | return | |
| 292 | 299 | } | |
| 293 | 300 | if err := w.beginCeremony(rw, ceremony{ | |
| 294 | Kind: "register", Data: *data, AccountID: accountID, Name: name, | ||
| 301 | Kind: "register", Data: *data, AccountID: accountID, Name: name, Display: display, | ||
| 295 | 302 | }, creation); err != nil { | |
| 296 | 303 | writeJSONError(rw, http.StatusInternalServerError, "cannot store the ceremony") | |
| 297 | 304 | } | |
| @@ -316,7 +323,7 @@ func (w *Web) RegisterFinish(rw http.ResponseWriter, r *http.Request) { | |||
| 316 | 323 | writeJSONError(rw, http.StatusBadRequest, "registration failed: "+err.Error()) | |
| 317 | 324 | return | |
| 318 | 325 | } | |
| 319 | acct, err := w.Ident.CreatePasskey(c.Name, c.AccountID, fromWebAuthn(cred, "first passkey"), "self-registration") | ||
| 326 | acct, err := w.Ident.CreatePasskey(c.Name, c.Display, c.AccountID, fromWebAuthn(cred, "first passkey"), "self-registration") | ||
| 320 | 327 | if err != nil { | |
| 321 | 328 | writeJSONError(rw, http.StatusConflict, "cannot create the account: "+err.Error()) | |
| 322 | 329 | return | |