Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

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