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.
forge/identity/passkey.go +12−4
| @@ -88,10 +88,14 @@ type Invite struct { | |||
| 88 | 88 | // handle is fixed when the ceremony begins; the row is only written when the | |
| 89 | 89 | // authenticator has actually produced a credential, so an abandoned registration | |
| 90 | 90 | // reserves no name. | |
| 91 | func (s *Store) CreatePasskey(name, accountID string, cred Credential, actor string) (*Account, error) { | ||
| 91 | func (s *Store) CreatePasskey(name, display, accountID string, cred Credential, actor string) (*Account, error) { | ||
| 92 | 92 | if err := ValidateName(name); err != nil { | |
| 93 | 93 | return nil, err | |
| 94 | 94 | } | |
| 95 | display = strings.TrimSpace(display) | ||
| 96 | if err := ValidateDisplay(display); err != nil { | ||
| 97 | return nil, err | ||
| 98 | } | ||
| 95 | 99 | if !id.Valid(accountID) { | |
| 96 | 100 | return nil, fmt.Errorf("identity: %q is not a valid account id", accountID) | |
| 97 | 101 | } | |
| @@ -100,7 +104,7 @@ func (s *Store) CreatePasskey(name, accountID string, cred Credential, actor str | |||
| 100 | 104 | } | |
| 101 | 105 | now := time.Now().UTC() | |
| 102 | 106 | err := s.mutate("identity.create", actor, accountID, name+" passkey", func(tx *sql.Tx) error { | |
| 103 | if err := createAccount(tx, name, accountID, now, nil, nil); err != nil { | ||
| 107 | if err := createAccount(tx, name, display, accountID, now, nil, nil); err != nil { | ||
| 104 | 108 | return err | |
| 105 | 109 | } | |
| 106 | 110 | return insertCredential(tx, accountID, cred, now) | |
| @@ -114,10 +118,14 @@ func (s *Store) CreatePasskey(name, accountID string, cred Credential, actor str | |||
| 114 | 118 | // CreateWithInvite creates an account with no SSH key and an enrollment invite | |
| 115 | 119 | // in the same transaction. This is how a browser-only account — somebody who | |
| 116 | 120 | // files issues but does not push — comes into existence. | |
| 117 | func (s *Store) CreateWithInvite(name, actor string, ttl time.Duration) (*Account, *Invite, error) { | ||
| 121 | func (s *Store) CreateWithInvite(name, display, actor string, ttl time.Duration) (*Account, *Invite, error) { | ||
| 118 | 122 | if err := ValidateName(name); err != nil { | |
| 119 | 123 | return nil, nil, err | |
| 120 | 124 | } | |
| 125 | display = strings.TrimSpace(display) | ||
| 126 | if err := ValidateDisplay(display); err != nil { | ||
| 127 | return nil, nil, err | ||
| 128 | } | ||
| 121 | 129 | now := time.Now().UTC() | |
| 122 | 130 | accountID, err := id.New(now) | |
| 123 | 131 | if err != nil { | |
| @@ -130,7 +138,7 @@ func (s *Store) CreateWithInvite(name, actor string, ttl time.Duration) (*Accoun | |||
| 130 | 138 | inv := &Invite{Token: token, Expires: now.Add(inviteTTL(ttl))} | |
| 131 | 139 | pending := &pendingInvite{tokenHash: tokenHash, expires: inv.Expires, createdBy: actor} | |
| 132 | 140 | err = s.mutate("identity.create", actor, accountID, name+" invite", func(tx *sql.Tx) error { | |
| 133 | return createAccount(tx, name, accountID, now, nil, pending) | ||
| 141 | return createAccount(tx, name, display, accountID, now, nil, pending) | ||
| 134 | 142 | }) | |
| 135 | 143 | if err != nil { | |
| 136 | 144 | return nil, nil, err | |