A CLI-first git forge.

cli forge git self-hosted

https://gitbay.org

Commit 53f8811759

53f8811759e4173e0c5775fe6b6f508df3b541aa

parent: b8b7c3b111

Verified · cmc

cmc <hello@cleberg.net> · 2026-08-24T15:20:54Z

registration: atomic redemption, invite validation, friendly errors

Invite redemption and open registration each run in one transaction —
a failure at any step (taken username, duplicate email or key) leaves
the invite redeemable and no partial account, fixing an orphaned-user
+ burned-invite state observed in production. admin invite refuses
addresses already on an account. A registered key running register now
gets told whose key it is and how to register a different one.
cmd/gitbayd/main.go +5
@@ -281,6 +281,11 @@ func adminInviteCmd() *cobra.Command {
281281 }
282282 defer st.Close()
283283
284 if used, err := st.EmailInUse(email); err != nil {
285 return err
286 } else if used {
287 return fmt.Errorf("%s already belongs to an account; invites are for new users", email)
288 }
284289 code, hash, err := store.NewToken()
285290 if err != nil {
286291 return err
e2e/registration_test.go +31
@@ -123,6 +123,14 @@ func TestOpenRegistration(t *testing.T) {
123123 t.Fatalf("stranger whoami: exit %d, %s", code, errOut)
124124 }
125125
126 // Registering with an address already on an account is refused
127 // atomically (the username stays free for the real attempt).
128 inst.admin(t, "admin", "user", "create", "existing", "--key", inst.newKey(t, "existing")+".pub", "--email", "taken@example.test")
129 _, errOut2, code2 := inst.ssh(t, newKey, "", "register", "--username", "dana", "--email", "taken@example.test")
130 if code2 != 2 || !strings.Contains(errOut2, "already belongs") {
131 t.Fatalf("open register with taken email: exit %d, %s", code2, errOut2)
132 }
133
126134 // Register: account created pending, verification mail sent.
127135 out, errOut, code := inst.ssh(t, newKey, "", "register", "--username", "dana", "--email", "dana@example.test")
128136 if code != 0 {
@@ -218,4 +226,27 @@ func TestInviteRegistration(t *testing.T) {
218226 if code != 4 || !strings.Contains(errOut, "already used") {
219227 t.Fatalf("invite reuse: exit %d, %s", code, errOut)
220228 }
229
230 // Atomicity: a failed redemption (taken username) leaves the invite
231 // redeemable and no partial account.
232 inst.admin(t, "admin", "invite", "--email", "gray@example.test")
233 code2 := extractCode(t, smtp.waitMail(t, 1))
234 if _, errOut, code = inst.ssh(t, otherKey, "", "register", "--username", "erin", "--invite", code2); code != 2 || !strings.Contains(errOut, "taken") {
235 t.Fatalf("taken-name register: exit %d, %s", code, errOut)
236 }
237 if _, errOut, code = inst.ssh(t, otherKey, "", "register", "--username", "gray", "--invite", code2); code != 0 {
238 t.Fatalf("invite not redeemable after failed attempt: %s", errOut)
239 }
240
241 // Inviting an address that already has an account is refused.
242 out2 := inst.forgedAdminErr(t, "admin", "invite", "--email", "erin@example.test")
243 if !strings.Contains(out2, "already belongs") {
244 t.Fatalf("invite to existing address: %s", out2)
245 }
246
247 // A registered key running register gets a pointer, not confusion.
248 _, errOut, code = inst.ssh(t, otherKey, "", "register", "--username", "again", "--invite", "x")
249 if code != 2 || !strings.Contains(errOut, "already belongs to gray") {
250 t.Fatalf("register with known key: exit %d, %s", code, errOut)
251 }
221252 }
internal/control/register.go +18 −24
@@ -17,6 +17,13 @@ import (
1717 )
1818
1919 func init() {
20 register(Command{Path: []string{"register"},
21 Summary: "create an account (only meaningful for unregistered keys)",
22 Run: func(c *Ctx, args []string) int {
23 return c.fail(protocol.ExitUsage,
24 "this SSH key already belongs to %s. To register a new account, connect with the key it should use:\n ssh -F /dev/null -i <newkey> git@<host> register ...",
25 c.User.Username)
26 }})
2027 register(Command{Path: []string{"email", "add"},
2128 Summary: "add an address and mail a verification code: email add <address>", Run: runEmailAdd})
2229 register(Command{Path: []string{"email", "verify"},
@@ -123,25 +130,20 @@ func RunRegister(cfg config.Config, st *store.Store, pub ssh.PublicKey, argv []s
123130 return fail(protocol.ExitUsage, "%v", err)
124131 }
125132
133 fp := ssh.FingerprintSHA256(pub)
126134 switch cfg.Registration.Mode {
127135 case "invite":
128136 if invite == "" {
129137 return fail(protocol.ExitDenied, "this instance is invite-only: register --username <name> --invite <code>")
130138 }
131 addr, err := st.ConsumeInvite(store.HashToken(invite))
139 // One transaction: a failure at any step leaves the invite
140 // redeemable and no partial account behind.
141 _, err := st.RedeemInvite(store.HashToken(invite), username, fp, pub.Type(), pub.Marshal())
132142 if err != nil {
133 return fail(protocol.ExitDenied, "that invite is invalid or already used")
134 }
135 uid, err := st.CreateRegisteredUser(username, false)
136 if err != nil {
137 return fail(protocol.ExitFailure, "%v", err)
138 }
139 // Possession of the emailed invite code proves the mailbox.
140 if err := st.AddEmail(uid, addr, "smtp", true); err != nil {
141 return fail(protocol.ExitFailure, "%v", err)
142 }
143 if err := addRegisteredKey(st, uid, pub); err != nil {
144 return fail(protocol.ExitFailure, "%v", err)
143 if errors.Is(err, store.ErrNotFound) {
144 return fail(protocol.ExitDenied, "that invite is invalid or already used")
145 }
146 return fail(protocol.ExitUsage, "%v", err)
145147 }
146148 fmt.Fprintf(stdout, "welcome, %s — your account is active\n", username)
147149 return protocol.ExitOK
@@ -150,15 +152,9 @@ func RunRegister(cfg config.Config, st *store.Store, pub ssh.PublicKey, argv []s
150152 if email == "" || !strings.Contains(email, "@") {
151153 return fail(protocol.ExitUsage, "usage: register --username <name> --email <address>")
152154 }
153 uid, err := st.CreateRegisteredUser(username, true)
155 uid, err := st.RegisterOpen(username, email, fp, pub.Type(), pub.Marshal())
154156 if err != nil {
155 return fail(protocol.ExitFailure, "%v", err)
156 }
157 if err := st.AddEmail(uid, email, "", true); err != nil {
158 return fail(protocol.ExitFailure, "%v", err)
159 }
160 if err := addRegisteredKey(st, uid, pub); err != nil {
161 return fail(protocol.ExitFailure, "%v", err)
157 return fail(protocol.ExitUsage, "%v", err)
162158 }
163159 if err := sendVerification(cfg, st, uid, email); err != nil {
164160 return fail(protocol.ExitFailure, "sending verification mail: %v", err)
@@ -173,6 +169,4 @@ func RunRegister(cfg config.Config, st *store.Store, pub ssh.PublicKey, argv []s
173169 }
174170 }
175171
176func addRegisteredKey(st *store.Store, uid int64, pub ssh.PublicKey) error {
177 return st.AddSSHKey(uid, ssh.FingerprintSHA256(pub), pub.Type(), pub.Marshal(), "full")
178}
172
internal/store/registration.go +109
@@ -76,3 +76,112 @@ func (s *Store) ClearPending(userID int64) error {
7676 _, err := s.DB.Exec("UPDATE users SET pending = 0 WHERE id = ?", userID)
7777 return err
7878 }
79
80// EmailInUse reports whether an address is attached to any account.
81func (s *Store) EmailInUse(address string) (bool, error) {
82 var n int
83 err := s.DB.QueryRow("SELECT COUNT(*) FROM emails WHERE address = ?", address).Scan(&n)
84 return n > 0, err
85}
86
87// RedeemInvite performs the whole invite registration in one transaction:
88// consume the code, create the user, attach the invite's email as verified,
89// register the key. Any failure rolls everything back — the invite stays
90// redeemable and no partial account exists.
91func (s *Store) RedeemInvite(codeHash, username, keyFP, keyAlgo string, keyBlob []byte) (string, error) {
92 tx, err := s.DB.Begin()
93 if err != nil {
94 return "", err
95 }
96 defer tx.Rollback()
97
98 res, err := tx.Exec(
99 "UPDATE invites SET used_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE code_hash = ? AND used_at IS NULL",
100 codeHash)
101 if err != nil {
102 return "", err
103 }
104 if n, _ := res.RowsAffected(); n == 0 {
105 return "", ErrNotFound
106 }
107 var email string
108 if err := tx.QueryRow("SELECT email FROM invites WHERE code_hash = ?", codeHash).Scan(&email); err != nil {
109 return "", err
110 }
111
112 if taken, err := ownerNameTaken(tx, username); err != nil {
113 return "", err
114 } else if taken {
115 return "", errors.New("that username is taken")
116 }
117 ures, err := tx.Exec("INSERT INTO users (username) VALUES (?)", username)
118 if err != nil {
119 return "", err
120 }
121 uid, err := ures.LastInsertId()
122 if err != nil {
123 return "", err
124 }
125 if _, err := tx.Exec(
126 `INSERT INTO emails (user_id, address, verified_at, verified_by, is_primary)
127 VALUES (?, ?, strftime('%Y-%m-%dT%H:%M:%fZ','now'), 'smtp', 1)`, uid, email); err != nil {
128 if isUniqueErr(err) {
129 return "", errors.New("the invited address already belongs to an account")
130 }
131 return "", err
132 }
133 if _, err := tx.Exec(
134 "INSERT INTO ssh_keys (user_id, fingerprint, algo, blob, scope) VALUES (?, ?, ?, ?, 'full')",
135 uid, keyFP, keyAlgo, keyBlob); err != nil {
136 if isUniqueErr(err) {
137 return "", ErrDuplicateKey
138 }
139 return "", err
140 }
141 if err := bumpKeyEpoch(tx); err != nil {
142 return "", err
143 }
144 return email, tx.Commit()
145}
146
147// RegisterOpen performs open registration in one transaction: pending user,
148// unverified email, key. Failure leaves nothing behind.
149func (s *Store) RegisterOpen(username, email, keyFP, keyAlgo string, keyBlob []byte) (int64, error) {
150 tx, err := s.DB.Begin()
151 if err != nil {
152 return 0, err
153 }
154 defer tx.Rollback()
155 if taken, err := ownerNameTaken(tx, username); err != nil {
156 return 0, err
157 } else if taken {
158 return 0, errors.New("that username is taken")
159 }
160 ures, err := tx.Exec("INSERT INTO users (username, pending) VALUES (?, 1)", username)
161 if err != nil {
162 return 0, err
163 }
164 uid, err := ures.LastInsertId()
165 if err != nil {
166 return 0, err
167 }
168 if _, err := tx.Exec(
169 "INSERT INTO emails (user_id, address, is_primary) VALUES (?, ?, 1)", uid, email); err != nil {
170 if isUniqueErr(err) {
171 return 0, errors.New("that address already belongs to an account")
172 }
173 return 0, err
174 }
175 if _, err := tx.Exec(
176 "INSERT INTO ssh_keys (user_id, fingerprint, algo, blob, scope) VALUES (?, ?, ?, ?, 'full')",
177 uid, keyFP, keyAlgo, keyBlob); err != nil {
178 if isUniqueErr(err) {
179 return 0, ErrDuplicateKey
180 }
181 return 0, err
182 }
183 if err := bumpKeyEpoch(tx); err != nil {
184 return 0, err
185 }
186 return uid, tx.Commit()
187}