vole
Clone
Read only, no credentials:
https://rodent.software/git/vole.git
Write over SSH with a registered key:
git clone ssh://git@0.0.0.0:2222/vole.git
A free and open-source forge that lives in your Git repo.
Identify accounts by their registered name
62576fa8157d431dcf2d360f3ebe481de77bda03 · Keenan Nemetz · 14d ago · parent 642e5994
Identify accounts by their registered name Drop the internal account id and display name. Users, keys, passkeys, invites, sessions, ACL entries, approvals, locks and CI records now all use the registered name as the single identity. The roster migrates in place: old id-keyed tables are rebuilt around the name, and access-rule/lock references are rewritten through the same mapping. Display-name editing and whoami --id are removed.
client/remote.go +25−40
| @@ -94,18 +94,15 @@ func RunSSH(host, port, verb string) (string, error) { | |||
| 94 | 94 | return out.String(), nil | |
| 95 | 95 | } | |
| 96 | 96 | ||
| 97 | // Roster is the client's read of the instance's user roster. | ||
| 98 | // | ||
| 99 | // The roster is a server-side SQLite database, so the client asks the instance | ||
| 100 | // for the one view it needs — active account id and name — over the same | ||
| 101 | // key-authenticated SSH channel it already pushes with (`ssh host vole-roster`). | ||
| 102 | // No new endpoint, no second credential, and nothing the client can write. | ||
| 97 | // Roster is the client's read of the instance's user roster: the active account | ||
| 98 | // names. The roster is a server-side SQLite database, so the client asks the | ||
| 99 | // instance for the one view it needs over the same key-authenticated SSH channel | ||
| 100 | // it already pushes with (`ssh host vole-roster`). | ||
| 103 | 101 | type Roster struct { | |
| 104 | byName map[string]string // active name -> id | ||
| 105 | byID map[string]string // id -> current name | ||
| 102 | names map[string]bool | ||
| 106 | 103 | } | |
| 107 | 104 | ||
| 108 | // LoadRoster asks the instance for its roster and indexes it both ways. | ||
| 105 | // LoadRoster asks the instance for its roster and indexes the names. | ||
| 109 | 106 | func LoadRoster(gitDir, remote string) (*Roster, error) { | |
| 110 | 107 | host, port, _, err := SSHTarget(gitDir, remote) | |
| 111 | 108 | if err != nil { | |
| @@ -115,47 +112,35 @@ func LoadRoster(gitDir, remote string) (*Roster, error) { | |||
| 115 | 112 | if err != nil { | |
| 116 | 113 | return nil, fmt.Errorf("cannot read the instance roster from %s: %w", host, err) | |
| 117 | 114 | } | |
| 118 | r := &Roster{byName: map[string]string{}, byID: map[string]string{}} | ||
| 115 | r := &Roster{names: map[string]bool{}} | ||
| 119 | 116 | for _, line := range strings.Split(strings.TrimSpace(out), "\n") { | |
| 117 | line = strings.TrimSpace(line) | ||
| 120 | 118 | if line == "" { | |
| 121 | 119 | continue | |
| 122 | 120 | } | |
| 123 | f := strings.SplitN(line, "\t", 2) | ||
| 124 | if len(f) != 2 || f[0] == "" || f[1] == "" { | ||
| 125 | return nil, fmt.Errorf("roster: cannot parse %q", line) | ||
| 121 | // A roster row is one name. Taking the last field also accepts a line | ||
| 122 | // from an instance that still prints two columns. | ||
| 123 | f := strings.Fields(line) | ||
| 124 | if len(f) == 0 { | ||
| 125 | continue | ||
| 126 | 126 | } | |
| 127 | r.byName[f[1]] = f[0] | ||
| 128 | r.byID[f[0]] = f[1] | ||
| 127 | r.names[f[len(f)-1]] = true | ||
| 129 | 128 | } | |
| 130 | 129 | return r, nil | |
| 131 | 130 | } | |
| 132 | 131 | ||
| 133 | // IDFor resolves a name or a full id to an account id. | ||
| 134 | func (r *Roster) IDFor(nameOrID string) (string, error) { | ||
| 135 | if id, ok := r.byName[nameOrID]; ok { | ||
| 136 | return id, nil | ||
| 137 | } | ||
| 138 | if _, ok := r.byID[nameOrID]; ok { | ||
| 139 | return nameOrID, nil | ||
| 140 | } | ||
| 141 | return "", fmt.Errorf("no active account %q in the instance roster (see `vole admin user list` on the server)", nameOrID) | ||
| 142 | } | ||
| 143 | |||
| 144 | // NameFor renders an account id as a name when the roster knows it. | ||
| 145 | func (r *Roster) NameFor(accountID string) string { | ||
| 146 | if n, ok := r.byID[accountID]; ok { | ||
| 147 | return n | ||
| 148 | } | ||
| 149 | return accountID | ||
| 132 | // Has reports whether name is an active account. | ||
| 133 | func (r *Roster) Has(name string) bool { | ||
| 134 | return r != nil && r.names[name] | ||
| 150 | 135 | } | |
| 151 | 136 | ||
| 152 | // Actor is who a write will be attributed to. The id and URL come from the | ||
| 137 | // Actor is who a write will be attributed to. The name and URL come from the | ||
| 153 | 138 | // instance; Repo comes from the remote the command was given. | |
| 154 | 139 | type Actor struct { | |
| 155 | ID, Name, URL, Repo string | ||
| 140 | Name, URL, Repo string | ||
| 156 | 141 | // Guessed means the instance could not be asked and the name came from git | |
| 157 | 142 | // config. It is never silent: the caller warns, and commands that need an | |
| 158 | // account id (`vole change new`) refuse instead of writing a random name. | ||
| 143 | // active account (`vole change new`) refuse instead of writing a random name. | ||
| 159 | 144 | Guessed bool | |
| 160 | 145 | } | |
| 161 | 146 | ||
| @@ -172,13 +157,13 @@ func AskIdentity(gitDir, remote string) (*Actor, error) { | |||
| 172 | 157 | if err != nil { | |
| 173 | 158 | return nil, fmt.Errorf("cannot ask %s who you are: %w", host, err) | |
| 174 | 159 | } | |
| 175 | f := strings.SplitN(strings.TrimSpace(out), "\t", 3) | ||
| 176 | if len(f) < 2 || f[0] == "" || f[1] == "" { | ||
| 160 | f := strings.SplitN(strings.TrimSpace(out), "\t", 2) | ||
| 161 | if len(f) < 1 || f[0] == "" { | ||
| 177 | 162 | return nil, fmt.Errorf("whoami: cannot parse %q", strings.TrimSpace(out)) | |
| 178 | 163 | } | |
| 179 | a := &Actor{ID: f[0], Name: f[1], Repo: RepoNameFromPath(path)} | ||
| 180 | if len(f) == 3 { | ||
| 181 | a.URL = strings.TrimRight(f[2], "/") | ||
| 164 | a := &Actor{Name: f[0], Repo: RepoNameFromPath(path)} | ||
| 165 | if len(f) == 2 { | ||
| 166 | a.URL = strings.TrimRight(f[1], "/") | ||
| 182 | 167 | } | |
| 183 | 168 | return a, nil | |
| 184 | 169 | } | |