Commit 7d004e2

Eric Bower  ·  2026-07-16 22:11:42 -0400 EDT
parent b18549a
feat: block account creation by ip
7 files changed,  +111, -79
+2, -1
 1@@ -158,10 +158,11 @@ migrate:
 2 	$(DOCKER_CMD) exec -i $(DB_CONTAINER) psql -U $(PGUSER) -d $(PGDATABASE) < ./sql/migrations/20260305_add_forms_table.sql
 3 	$(DOCKER_CMD) exec -i $(DB_CONTAINER) psql -U $(PGUSER) -d $(PGDATABASE) < ./sql/migrations/20260503_add_analytics_summary_tables.sql
 4 	$(DOCKER_CMD) exec -i $(DB_CONTAINER) psql -U $(PGUSER) -d $(PGDATABASE) < ./sql/migrations/20260504_add_analytics_summary_indexes.sql
 5+	$(DOCKER_CMD) exec -i $(DB_CONTAINER) psql -U $(PGUSER) -d $(PGDATABASE) < ./sql/migrations/20260716_block_signups.sql
 6 .PHONY: migrate
 7 
 8 latest:
 9-	$(DOCKER_CMD) exec -i $(DB_CONTAINER) psql -U $(PGUSER) -d $(PGDATABASE) < ./sql/migrations/20260504_add_analytics_summary_indexes.sql
10+	$(DOCKER_CMD) exec -i $(DB_CONTAINER) psql -U $(PGUSER) -d $(PGDATABASE) < ./sql/migrations/20260716_block_signups.sql
11 .PHONY: latest
12 
13 psql:
+8, -1
 1@@ -31,6 +31,13 @@ func tcast(value any) ([]byte, error) {
 2 	}
 3 }
 4 
 5+type BlockSignups struct {
 6+	ID        int64      `db:"id"`
 7+	IP        string     `db:"ip"`
 8+	Reason    string     `db:"reason"`
 9+	CreatedAt *time.Time `db:"created_at"`
10+}
11+
12 type PublicKey struct {
13 	ID        string     `json:"id" db:"id"`
14 	UserID    string     `json:"user_id" db:"user_id"`
15@@ -559,7 +566,7 @@ var DenyList = []string{
16 }
17 
18 type DB interface {
19-	RegisterUser(name, pubkey, comment string) (*User, error)
20+	RegisterUser(name, pubkey, comment, ip string) (*User, error)
21 	UpdatePublicKey(pubkeyID, name string) (*PublicKey, error)
22 	InsertPublicKey(userID, pubkey, name string) error
23 	FindKeysByUser(user *User) ([]*PublicKey, error)
+16, -1
 1@@ -91,13 +91,28 @@ func NewDB(databaseUrl string, logger *slog.Logger) *PsqlDB {
 2 	return d
 3 }
 4 
 5-func (me *PsqlDB) RegisterUser(username, pubkey, comment string) (*db.User, error) {
 6+func (me *PsqlDB) shouldBlockSingup(ip string) error {
 7+	blocked := &db.BlockSignups{}
 8+	err := me.Db.Get(blocked, `SELECT * FROM block_signups WHERE ip = $1`, ip)
 9+	// an error in this case means the result is empty (no record found)
10+	if err != nil {
11+		return nil
12+	}
13+	return fmt.Errorf("your IP address has been blocked: %s", blocked.Reason)
14+}
15+
16+func (me *PsqlDB) RegisterUser(username, pubkey, comment, ip string) (*db.User, error) {
17 	lowerName := strings.ToLower(username)
18 	valid, err := me.validateName(lowerName)
19 	if !valid {
20 		return nil, err
21 	}
22 
23+	err = me.shouldBlockSingup(ip)
24+	if err != nil {
25+		return nil, err
26+	}
27+
28 	tx, err := me.Db.Beginx()
29 	if err != nil {
30 		return nil, err
+74, -74
  1@@ -190,7 +190,7 @@ func mustInsertPost(t *testing.T, post *db.Post) *db.Post {
  2 func TestRegisterUser_Success(t *testing.T) {
  3 	cleanupTestData(t)
  4 
  5-	user, err := testDB.RegisterUser("testuser", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI testkey", "test comment")
  6+	user, err := testDB.RegisterUser("testuser", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI testkey", "test comment", "")
  7 	if err != nil {
  8 		t.Fatalf("RegisterUser failed: %v", err)
  9 		return
 10@@ -210,12 +210,12 @@ func TestRegisterUser_Success(t *testing.T) {
 11 func TestRegisterUser_DuplicateName(t *testing.T) {
 12 	cleanupTestData(t)
 13 
 14-	_, err := testDB.RegisterUser("duplicateuser", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI key1", "comment1")
 15+	_, err := testDB.RegisterUser("duplicateuser", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI key1", "comment1", "")
 16 	if err != nil {
 17 		t.Fatalf("first RegisterUser failed: %v", err)
 18 	}
 19 
 20-	_, err = testDB.RegisterUser("duplicateuser", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI key2", "comment2")
 21+	_, err = testDB.RegisterUser("duplicateuser", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI key2", "comment2", "")
 22 	if err == nil {
 23 		t.Error("expected error for duplicate name, got nil")
 24 	}
 25@@ -224,7 +224,7 @@ func TestRegisterUser_DuplicateName(t *testing.T) {
 26 func TestRegisterUser_DeniedName(t *testing.T) {
 27 	cleanupTestData(t)
 28 
 29-	_, err := testDB.RegisterUser("admin", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI adminkey", "comment")
 30+	_, err := testDB.RegisterUser("admin", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI adminkey", "comment", "")
 31 	if err == nil {
 32 		t.Error("expected error for denied name 'admin', got nil")
 33 	}
 34@@ -233,7 +233,7 @@ func TestRegisterUser_DeniedName(t *testing.T) {
 35 func TestRegisterUser_InvalidName(t *testing.T) {
 36 	cleanupTestData(t)
 37 
 38-	_, err := testDB.RegisterUser("user@invalid", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI invalidkey", "comment")
 39+	_, err := testDB.RegisterUser("user@invalid", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI invalidkey", "comment", "")
 40 	if err == nil {
 41 		t.Error("expected error for invalid name, got nil")
 42 	}
 43@@ -242,7 +242,7 @@ func TestRegisterUser_InvalidName(t *testing.T) {
 44 func TestFindUser(t *testing.T) {
 45 	cleanupTestData(t)
 46 
 47-	created, err := testDB.RegisterUser("findme", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI findmekey", "comment")
 48+	created, err := testDB.RegisterUser("findme", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI findmekey", "comment", "")
 49 	if err != nil {
 50 		t.Fatalf("RegisterUser failed: %v", err)
 51 	}
 52@@ -259,7 +259,7 @@ func TestFindUser(t *testing.T) {
 53 func TestFindUserByName(t *testing.T) {
 54 	cleanupTestData(t)
 55 
 56-	_, err := testDB.RegisterUser("nameduser", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI namedkey", "comment")
 57+	_, err := testDB.RegisterUser("nameduser", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI namedkey", "comment", "")
 58 	if err != nil {
 59 		t.Fatalf("RegisterUser failed: %v", err)
 60 	}
 61@@ -277,7 +277,7 @@ func TestFindUserByPubkey(t *testing.T) {
 62 	cleanupTestData(t)
 63 
 64 	pubkey := "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI pubkeyuser"
 65-	_, err := testDB.RegisterUser("pubkeyuser", pubkey, "comment")
 66+	_, err := testDB.RegisterUser("pubkeyuser", pubkey, "comment", "")
 67 	if err != nil {
 68 		t.Fatalf("RegisterUser failed: %v", err)
 69 	}
 70@@ -295,7 +295,7 @@ func TestFindUserByKey(t *testing.T) {
 71 	cleanupTestData(t)
 72 
 73 	pubkey := "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI forkeyfind"
 74-	_, err := testDB.RegisterUser("forkeyfind", pubkey, "comment")
 75+	_, err := testDB.RegisterUser("forkeyfind", pubkey, "comment", "")
 76 	if err != nil {
 77 		t.Fatalf("RegisterUser failed: %v", err)
 78 	}
 79@@ -312,8 +312,8 @@ func TestFindUserByKey(t *testing.T) {
 80 func TestFindUsers(t *testing.T) {
 81 	cleanupTestData(t)
 82 
 83-	_, _ = testDB.RegisterUser("user1", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI user1key", "comment")
 84-	_, _ = testDB.RegisterUser("user2", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI user2key", "comment")
 85+	_, _ = testDB.RegisterUser("user1", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI user1key", "comment", "")
 86+	_, _ = testDB.RegisterUser("user2", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI user2key", "comment", "")
 87 
 88 	users, err := testDB.FindUsers()
 89 	if err != nil {
 90@@ -329,7 +329,7 @@ func TestFindUsers(t *testing.T) {
 91 func TestInsertPublicKey_Success(t *testing.T) {
 92 	cleanupTestData(t)
 93 
 94-	user, err := testDB.RegisterUser("keyowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI keyowner1", "comment")
 95+	user, err := testDB.RegisterUser("keyowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI keyowner1", "comment", "")
 96 	if err != nil {
 97 		t.Fatalf("RegisterUser failed: %v", err)
 98 	}
 99@@ -351,7 +351,7 @@ func TestInsertPublicKey_Success(t *testing.T) {
100 func TestInsertPublicKey_Duplicate(t *testing.T) {
101 	cleanupTestData(t)
102 
103-	user, _ := testDB.RegisterUser("dupkeyowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI dupkey", "comment")
104+	user, _ := testDB.RegisterUser("dupkeyowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI dupkey", "comment", "")
105 
106 	err := testDB.InsertPublicKey(user.ID, "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI dupkey", "same key")
107 	if err == nil {
108@@ -362,7 +362,7 @@ func TestInsertPublicKey_Duplicate(t *testing.T) {
109 func TestUpdatePublicKey(t *testing.T) {
110 	cleanupTestData(t)
111 
112-	user, _ := testDB.RegisterUser("updatekeyowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI updatekeyowner", "original")
113+	user, _ := testDB.RegisterUser("updatekeyowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI updatekeyowner", "original", "")
114 
115 	updated, err := testDB.UpdatePublicKey(user.PublicKey.ID, "new-name")
116 	if err != nil {
117@@ -376,7 +376,7 @@ func TestUpdatePublicKey(t *testing.T) {
118 func TestFindKeysByUser(t *testing.T) {
119 	cleanupTestData(t)
120 
121-	user, _ := testDB.RegisterUser("multikeyowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI multikeyowner1", "key1")
122+	user, _ := testDB.RegisterUser("multikeyowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI multikeyowner1", "key1", "")
123 	_ = testDB.InsertPublicKey(user.ID, "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI multikeyowner2", "key2")
124 
125 	keys, err := testDB.FindKeysByUser(user)
126@@ -391,7 +391,7 @@ func TestFindKeysByUser(t *testing.T) {
127 func TestRemoveKeys(t *testing.T) {
128 	cleanupTestData(t)
129 
130-	user, _ := testDB.RegisterUser("removekeyowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI removekeyowner", "key1")
131+	user, _ := testDB.RegisterUser("removekeyowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI removekeyowner", "key1", "")
132 	_ = testDB.InsertPublicKey(user.ID, "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI removekeyowner2", "key2")
133 
134 	keys, _ := testDB.FindKeysByUser(user)
135@@ -415,7 +415,7 @@ func TestRemoveKeys(t *testing.T) {
136 func TestInsertToken(t *testing.T) {
137 	cleanupTestData(t)
138 
139-	user, _ := testDB.RegisterUser("tokenowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI tokenowner", "comment")
140+	user, _ := testDB.RegisterUser("tokenowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI tokenowner", "comment", "")
141 
142 	token, err := testDB.InsertToken(user.ID, "my-token")
143 	if err != nil {
144@@ -429,7 +429,7 @@ func TestInsertToken(t *testing.T) {
145 func TestUpsertToken(t *testing.T) {
146 	cleanupTestData(t)
147 
148-	user, _ := testDB.RegisterUser("upserttokenowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI upserttokenowner", "comment")
149+	user, _ := testDB.RegisterUser("upserttokenowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI upserttokenowner", "comment", "")
150 
151 	token1, err := testDB.UpsertToken(user.ID, "upsert-token")
152 	if err != nil {
153@@ -449,7 +449,7 @@ func TestUpsertToken(t *testing.T) {
154 func TestFindTokensByUser(t *testing.T) {
155 	cleanupTestData(t)
156 
157-	user, _ := testDB.RegisterUser("tokensowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI tokensowner", "comment")
158+	user, _ := testDB.RegisterUser("tokensowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI tokensowner", "comment", "")
159 	_, _ = testDB.InsertToken(user.ID, "token1")
160 	_, _ = testDB.InsertToken(user.ID, "token2")
161 
162@@ -465,7 +465,7 @@ func TestFindTokensByUser(t *testing.T) {
163 func TestFindUserByToken_Valid(t *testing.T) {
164 	cleanupTestData(t)
165 
166-	user, _ := testDB.RegisterUser("validtokenowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI validtokenowner", "comment")
167+	user, _ := testDB.RegisterUser("validtokenowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI validtokenowner", "comment", "")
168 	token, _ := testDB.InsertToken(user.ID, "valid-token")
169 
170 	found, err := testDB.FindUserByToken(token)
171@@ -480,7 +480,7 @@ func TestFindUserByToken_Valid(t *testing.T) {
172 func TestFindUserByToken_Expired(t *testing.T) {
173 	cleanupTestData(t)
174 
175-	user, _ := testDB.RegisterUser("expiredtokenowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI expiredtokenowner", "comment")
176+	user, _ := testDB.RegisterUser("expiredtokenowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI expiredtokenowner", "comment", "")
177 	token, _ := testDB.InsertToken(user.ID, "expired-token")
178 
179 	_, err := testDB.Db.Exec("UPDATE tokens SET expires_at = NOW() - INTERVAL '1 day' WHERE token = $1", token)
180@@ -497,7 +497,7 @@ func TestFindUserByToken_Expired(t *testing.T) {
181 func TestRemoveToken(t *testing.T) {
182 	cleanupTestData(t)
183 
184-	user, _ := testDB.RegisterUser("removetokenowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI removetokenowner", "comment")
185+	user, _ := testDB.RegisterUser("removetokenowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI removetokenowner", "comment", "")
186 	_, _ = testDB.InsertToken(user.ID, "remove-token")
187 
188 	tokens, _ := testDB.FindTokensByUser(user.ID)
189@@ -521,7 +521,7 @@ func TestRemoveToken(t *testing.T) {
190 func TestInsertPost(t *testing.T) {
191 	cleanupTestData(t)
192 
193-	user, _ := testDB.RegisterUser("postowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI postowner", "comment")
194+	user, _ := testDB.RegisterUser("postowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI postowner", "comment", "")
195 
196 	now := time.Now()
197 	post := &db.Post{
198@@ -552,7 +552,7 @@ func TestInsertPost(t *testing.T) {
199 func TestUpdatePost(t *testing.T) {
200 	cleanupTestData(t)
201 
202-	user, _ := testDB.RegisterUser("updatepostowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI updatepostowner", "comment")
203+	user, _ := testDB.RegisterUser("updatepostowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI updatepostowner", "comment", "")
204 
205 	now := time.Now()
206 	post := mustInsertPost(t, &db.Post{
207@@ -580,7 +580,7 @@ func TestUpdatePost(t *testing.T) {
208 func TestRemovePosts(t *testing.T) {
209 	cleanupTestData(t)
210 
211-	user, _ := testDB.RegisterUser("removepostowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI removepostowner", "comment")
212+	user, _ := testDB.RegisterUser("removepostowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI removepostowner", "comment", "")
213 
214 	post := mustInsertPost(t, &db.Post{
215 		UserID:   user.ID,
216@@ -604,7 +604,7 @@ func TestRemovePosts(t *testing.T) {
217 func TestFindPost(t *testing.T) {
218 	cleanupTestData(t)
219 
220-	user, _ := testDB.RegisterUser("findpostowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI findpostowner", "comment")
221+	user, _ := testDB.RegisterUser("findpostowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI findpostowner", "comment", "")
222 
223 	created := mustInsertPost(t, &db.Post{
224 		UserID:   user.ID,
225@@ -626,7 +626,7 @@ func TestFindPost(t *testing.T) {
226 func TestFindPostWithFilename(t *testing.T) {
227 	cleanupTestData(t)
228 
229-	user, _ := testDB.RegisterUser("filenamepostowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI filenamepostowner", "comment")
230+	user, _ := testDB.RegisterUser("filenamepostowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI filenamepostowner", "comment", "")
231 
232 	_ = mustInsertPost(t, &db.Post{
233 		UserID:   user.ID,
234@@ -648,7 +648,7 @@ func TestFindPostWithFilename(t *testing.T) {
235 func TestFindPostWithSlug(t *testing.T) {
236 	cleanupTestData(t)
237 
238-	user, _ := testDB.RegisterUser("slugpostowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI slugpostowner", "comment")
239+	user, _ := testDB.RegisterUser("slugpostowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI slugpostowner", "comment", "")
240 
241 	_ = mustInsertPost(t, &db.Post{
242 		UserID:   user.ID,
243@@ -670,7 +670,7 @@ func TestFindPostWithSlug(t *testing.T) {
244 func TestFindPosts(t *testing.T) {
245 	cleanupTestData(t)
246 
247-	user, _ := testDB.RegisterUser("postsowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI postsowner", "comment")
248+	user, _ := testDB.RegisterUser("postsowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI postsowner", "comment", "")
249 
250 	_ = mustInsertPost(t, &db.Post{UserID: user.ID, Filename: "post1.md", Slug: "post1", Title: "Post 1", Space: "prose"})
251 	_ = mustInsertPost(t, &db.Post{UserID: user.ID, Filename: "post2.md", Slug: "post2", Title: "Post 2", Space: "prose"})
252@@ -687,7 +687,7 @@ func TestFindPosts(t *testing.T) {
253 func TestFindPostsByUser(t *testing.T) {
254 	cleanupTestData(t)
255 
256-	user, _ := testDB.RegisterUser("userpostsowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI userpostsowner", "comment")
257+	user, _ := testDB.RegisterUser("userpostsowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI userpostsowner", "comment", "")
258 	now := time.Now()
259 
260 	_ = mustInsertPost(t, &db.Post{UserID: user.ID, Filename: "userpost1.md", Slug: "userpost1", Title: "User Post 1", Space: "prose", PublishAt: &now})
261@@ -706,7 +706,7 @@ func TestFindPostsByUser(t *testing.T) {
262 func TestFindAllPostsByUser(t *testing.T) {
263 	cleanupTestData(t)
264 
265-	user, _ := testDB.RegisterUser("allpostsowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI allpostsowner", "comment")
266+	user, _ := testDB.RegisterUser("allpostsowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI allpostsowner", "comment", "")
267 
268 	_ = mustInsertPost(t, &db.Post{UserID: user.ID, Filename: "allpost1.md", Slug: "allpost1", Title: "All Post 1", Space: "prose"})
269 	_ = mustInsertPost(t, &db.Post{UserID: user.ID, Filename: "allpost2.md", Slug: "allpost2", Title: "All Post 2", Space: "prose"})
270@@ -723,7 +723,7 @@ func TestFindAllPostsByUser(t *testing.T) {
271 func TestFindUsersWithPost(t *testing.T) {
272 	cleanupTestData(t)
273 
274-	user, _ := testDB.RegisterUser("feedspostowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI feedspostowner", "comment")
275+	user, _ := testDB.RegisterUser("feedspostowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI feedspostowner", "comment", "")
276 
277 	_ = mustInsertPost(t, &db.Post{UserID: user.ID, Filename: "feed.txt", Slug: "feed", Title: "Feed", Space: "feeds"})
278 
279@@ -739,7 +739,7 @@ func TestFindUsersWithPost(t *testing.T) {
280 func TestFindExpiredPosts(t *testing.T) {
281 	cleanupTestData(t)
282 
283-	user, _ := testDB.RegisterUser("expiredpostowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI expiredpostowner", "comment")
284+	user, _ := testDB.RegisterUser("expiredpostowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI expiredpostowner", "comment", "")
285 
286 	expired := time.Now().Add(-24 * time.Hour)
287 	_ = mustInsertPost(t, &db.Post{
288@@ -763,7 +763,7 @@ func TestFindExpiredPosts(t *testing.T) {
289 func TestFindPostsByFeed(t *testing.T) {
290 	cleanupTestData(t)
291 
292-	user, _ := testDB.RegisterUser("feedowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI feedowner", "comment")
293+	user, _ := testDB.RegisterUser("feedowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI feedowner", "comment", "")
294 
295 	now := time.Now()
296 	_ = mustInsertPost(t, &db.Post{UserID: user.ID, Filename: "feedpost.md", Slug: "feedpost", Title: "Feed Post", Space: "prose", PublishAt: &now})
297@@ -783,7 +783,7 @@ func TestFindPostsByFeed(t *testing.T) {
298 func TestReplaceTagsByPost(t *testing.T) {
299 	cleanupTestData(t)
300 
301-	user, _ := testDB.RegisterUser("tagspostowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI tagspostowner", "comment")
302+	user, _ := testDB.RegisterUser("tagspostowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI tagspostowner", "comment", "")
303 	post := mustInsertPost(t, &db.Post{UserID: user.ID, Filename: "tagged.md", Slug: "tagged", Title: "Tagged", Space: "prose"})
304 
305 	err := testDB.ReplaceTagsByPost([]string{"tag1", "tag2"}, post.ID)
306@@ -800,7 +800,7 @@ func TestReplaceTagsByPost(t *testing.T) {
307 func TestFindUserPostsByTag(t *testing.T) {
308 	cleanupTestData(t)
309 
310-	user, _ := testDB.RegisterUser("usertagowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI usertagowner", "comment")
311+	user, _ := testDB.RegisterUser("usertagowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI usertagowner", "comment", "")
312 	now := time.Now()
313 	post := mustInsertPost(t, &db.Post{UserID: user.ID, Filename: "usertag.md", Slug: "usertag", Title: "User Tag", Space: "prose", PublishAt: &now})
314 	_ = testDB.ReplaceTagsByPost([]string{"mytag"}, post.ID)
315@@ -818,7 +818,7 @@ func TestFindUserPostsByTag(t *testing.T) {
316 func TestFindPostsByTag(t *testing.T) {
317 	cleanupTestData(t)
318 
319-	user, _ := testDB.RegisterUser("tagsearchowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI tagsearchowner", "comment")
320+	user, _ := testDB.RegisterUser("tagsearchowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI tagsearchowner", "comment", "")
321 	now := time.Now()
322 	post := mustInsertPost(t, &db.Post{UserID: user.ID, Filename: "tagsearch.md", Slug: "tagsearch", Title: "Tag Search", Space: "prose", PublishAt: &now})
323 	_ = testDB.ReplaceTagsByPost([]string{"searchtag"}, post.ID)
324@@ -836,7 +836,7 @@ func TestFindPostsByTag(t *testing.T) {
325 func TestFindPopularTags(t *testing.T) {
326 	cleanupTestData(t)
327 
328-	user, _ := testDB.RegisterUser("populartagowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI populartagowner", "comment")
329+	user, _ := testDB.RegisterUser("populartagowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI populartagowner", "comment", "")
330 	post1 := mustInsertPost(t, &db.Post{UserID: user.ID, Filename: "pop1.md", Slug: "pop1", Title: "Pop 1", Space: "prose"})
331 	post2 := mustInsertPost(t, &db.Post{UserID: user.ID, Filename: "pop2.md", Slug: "pop2", Title: "Pop 2", Space: "prose"})
332 	_ = testDB.ReplaceTagsByPost([]string{"popular"}, post1.ID)
333@@ -856,7 +856,7 @@ func TestFindPopularTags(t *testing.T) {
334 func TestReplaceAliasesByPost(t *testing.T) {
335 	cleanupTestData(t)
336 
337-	user, _ := testDB.RegisterUser("aliasowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI aliasowner", "comment")
338+	user, _ := testDB.RegisterUser("aliasowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI aliasowner", "comment", "")
339 	post := mustInsertPost(t, &db.Post{UserID: user.ID, Filename: "aliased.md", Slug: "aliased", Title: "Aliased", Space: "prose"})
340 
341 	err := testDB.ReplaceAliasesByPost([]string{"alias1", "alias2"}, post.ID)
342@@ -868,7 +868,7 @@ func TestReplaceAliasesByPost(t *testing.T) {
343 func TestFindPostWithSlug_Alias(t *testing.T) {
344 	cleanupTestData(t)
345 
346-	user, _ := testDB.RegisterUser("aliassearchowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI aliassearchowner", "comment")
347+	user, _ := testDB.RegisterUser("aliassearchowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI aliassearchowner", "comment", "")
348 	post := mustInsertPost(t, &db.Post{UserID: user.ID, Filename: "original.md", Slug: "original", Title: "Original", Space: "prose"})
349 	_ = testDB.ReplaceAliasesByPost([]string{"my-alias"}, post.ID)
350 
351@@ -886,7 +886,7 @@ func TestFindPostWithSlug_Alias(t *testing.T) {
352 func TestInsertVisit(t *testing.T) {
353 	cleanupTestData(t)
354 
355-	user, _ := testDB.RegisterUser("visitowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI visitowner", "comment")
356+	user, _ := testDB.RegisterUser("visitowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI visitowner", "comment", "")
357 
358 	visit := &db.AnalyticsVisits{
359 		UserID:    user.ID,
360@@ -907,7 +907,7 @@ func TestInsertVisit(t *testing.T) {
361 func TestVisitSummary(t *testing.T) {
362 	cleanupTestData(t)
363 
364-	user, _ := testDB.RegisterUser("summaryowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI summaryowner", "comment")
365+	user, _ := testDB.RegisterUser("summaryowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI summaryowner", "comment", "")
366 
367 	visit := &db.AnalyticsVisits{
368 		UserID:    user.ID,
369@@ -937,7 +937,7 @@ func TestVisitSummary(t *testing.T) {
370 func TestFindVisitSiteList(t *testing.T) {
371 	cleanupTestData(t)
372 
373-	user, _ := testDB.RegisterUser("sitelistowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI sitelistowner", "comment")
374+	user, _ := testDB.RegisterUser("sitelistowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI sitelistowner", "comment", "")
375 
376 	_ = testDB.InsertVisit(&db.AnalyticsVisits{
377 		UserID:    user.ID,
378@@ -960,7 +960,7 @@ func TestFindVisitSiteList(t *testing.T) {
379 func TestVisitUrlNotFound(t *testing.T) {
380 	cleanupTestData(t)
381 
382-	user, _ := testDB.RegisterUser("notfoundowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI notfoundowner", "comment")
383+	user, _ := testDB.RegisterUser("notfoundowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI notfoundowner", "comment", "")
384 
385 	_ = testDB.InsertVisit(&db.AnalyticsVisits{
386 		UserID:    user.ID,
387@@ -989,7 +989,7 @@ func TestVisitUrlNotFound(t *testing.T) {
388 func TestInsertFeature(t *testing.T) {
389 	cleanupTestData(t)
390 
391-	user, _ := testDB.RegisterUser("featureowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI featureowner", "comment")
392+	user, _ := testDB.RegisterUser("featureowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI featureowner", "comment", "")
393 
394 	expiresAt := time.Now().Add(365 * 24 * time.Hour)
395 	feature, err := testDB.InsertFeature(user.ID, "plus", expiresAt)
396@@ -1004,7 +1004,7 @@ func TestInsertFeature(t *testing.T) {
397 func TestFindFeature(t *testing.T) {
398 	cleanupTestData(t)
399 
400-	user, _ := testDB.RegisterUser("findfeatureowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI findfeatureowner", "comment")
401+	user, _ := testDB.RegisterUser("findfeatureowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI findfeatureowner", "comment", "")
402 	expiresAt := time.Now().Add(365 * 24 * time.Hour)
403 	_, _ = testDB.InsertFeature(user.ID, "plus", expiresAt)
404 
405@@ -1020,7 +1020,7 @@ func TestFindFeature(t *testing.T) {
406 func TestFindFeaturesByUser(t *testing.T) {
407 	cleanupTestData(t)
408 
409-	user, _ := testDB.RegisterUser("featuresowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI featuresowner", "comment")
410+	user, _ := testDB.RegisterUser("featuresowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI featuresowner", "comment", "")
411 	expiresAt := time.Now().Add(365 * 24 * time.Hour)
412 	_, _ = testDB.InsertFeature(user.ID, "plus", expiresAt)
413 	_, _ = testDB.InsertFeature(user.ID, "pro", expiresAt)
414@@ -1037,7 +1037,7 @@ func TestFindFeaturesByUser(t *testing.T) {
415 func TestHasFeatureByUser(t *testing.T) {
416 	cleanupTestData(t)
417 
418-	user, _ := testDB.RegisterUser("hasfeatureowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI hasfeatureowner", "comment")
419+	user, _ := testDB.RegisterUser("hasfeatureowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI hasfeatureowner", "comment", "")
420 	expiresAt := time.Now().Add(365 * 24 * time.Hour)
421 	_, _ = testDB.InsertFeature(user.ID, "plus", expiresAt)
422 
423@@ -1055,7 +1055,7 @@ func TestHasFeatureByUser(t *testing.T) {
424 func TestRemoveFeature(t *testing.T) {
425 	cleanupTestData(t)
426 
427-	user, _ := testDB.RegisterUser("removefeatureowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI removefeatureowner", "comment")
428+	user, _ := testDB.RegisterUser("removefeatureowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI removefeatureowner", "comment", "")
429 	expiresAt := time.Now().Add(365 * 24 * time.Hour)
430 	_, _ = testDB.InsertFeature(user.ID, "plus", expiresAt)
431 
432@@ -1073,7 +1073,7 @@ func TestRemoveFeature(t *testing.T) {
433 func TestAddPicoPlusUser(t *testing.T) {
434 	cleanupTestData(t)
435 
436-	_, _ = testDB.RegisterUser("picoplusowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI picoplusowner", "comment")
437+	_, _ = testDB.RegisterUser("picoplusowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI picoplusowner", "comment", "")
438 
439 	err := testDB.AddPicoPlusUser("picoplusowner", "test@example.com", "stripe", "tx123")
440 	if err != nil {
441@@ -1086,7 +1086,7 @@ func TestAddPicoPlusUser(t *testing.T) {
442 func TestInsertFeedItems(t *testing.T) {
443 	cleanupTestData(t)
444 
445-	user, _ := testDB.RegisterUser("feeditemsowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI feeditemsowner", "comment")
446+	user, _ := testDB.RegisterUser("feeditemsowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI feeditemsowner", "comment", "")
447 	post := mustInsertPost(t, &db.Post{UserID: user.ID, Filename: "feed.txt", Slug: "feed", Title: "Feed", Space: "feeds"})
448 
449 	items := []*db.FeedItem{
450@@ -1103,7 +1103,7 @@ func TestInsertFeedItems(t *testing.T) {
451 func TestFindFeedItemsByPostID(t *testing.T) {
452 	cleanupTestData(t)
453 
454-	user, _ := testDB.RegisterUser("findfeeditemsowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI findfeeditemsowner", "comment")
455+	user, _ := testDB.RegisterUser("findfeeditemsowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI findfeeditemsowner", "comment", "")
456 	post := mustInsertPost(t, &db.Post{UserID: user.ID, Filename: "findfeed.txt", Slug: "findfeed", Title: "Find Feed", Space: "feeds"})
457 
458 	items := []*db.FeedItem{
459@@ -1125,7 +1125,7 @@ func TestFindFeedItemsByPostID(t *testing.T) {
460 func TestUpsertProject(t *testing.T) {
461 	cleanupTestData(t)
462 
463-	user, _ := testDB.RegisterUser("projectowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI projectowner", "comment")
464+	user, _ := testDB.RegisterUser("projectowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI projectowner", "comment", "")
465 
466 	project, err := testDB.UpsertProject(user.ID, "my-project", "my-project")
467 	if err != nil {
468@@ -1147,7 +1147,7 @@ func TestUpsertProject(t *testing.T) {
469 func TestFindProjectByName(t *testing.T) {
470 	cleanupTestData(t)
471 
472-	user, _ := testDB.RegisterUser("findprojectowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI findprojectowner", "comment")
473+	user, _ := testDB.RegisterUser("findprojectowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI findprojectowner", "comment", "")
474 	_, _ = testDB.UpsertProject(user.ID, "findme-project", "findme-project")
475 
476 	project, err := testDB.FindProjectByName(user.ID, "findme-project")
477@@ -1164,7 +1164,7 @@ func TestFindProjectByName(t *testing.T) {
478 func TestFindUserStats(t *testing.T) {
479 	cleanupTestData(t)
480 
481-	user, _ := testDB.RegisterUser("statsowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI statsowner", "comment")
482+	user, _ := testDB.RegisterUser("statsowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI statsowner", "comment", "")
483 	_ = mustInsertPost(t, &db.Post{UserID: user.ID, Filename: "stat.md", Slug: "stat", Title: "Stat", Space: "prose"})
484 	_, _ = testDB.UpsertProject(user.ID, "stat-project", "stat-project")
485 
486@@ -1185,7 +1185,7 @@ func TestFindUserStats(t *testing.T) {
487 func TestInsertTunsEventLog(t *testing.T) {
488 	cleanupTestData(t)
489 
490-	user, _ := testDB.RegisterUser("tunsowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI tunsowner", "comment")
491+	user, _ := testDB.RegisterUser("tunsowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI tunsowner", "comment", "")
492 
493 	log := &db.TunsEventLog{
494 		UserId:         user.ID,
495@@ -1206,7 +1206,7 @@ func TestInsertTunsEventLog(t *testing.T) {
496 func TestFindTunsEventLogs(t *testing.T) {
497 	cleanupTestData(t)
498 
499-	user, _ := testDB.RegisterUser("findtunsowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI findtunsowner", "comment")
500+	user, _ := testDB.RegisterUser("findtunsowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI findtunsowner", "comment", "")
501 	_ = testDB.InsertTunsEventLog(&db.TunsEventLog{
502 		UserId:         user.ID,
503 		ServerID:       "server-1",
504@@ -1229,7 +1229,7 @@ func TestFindTunsEventLogs(t *testing.T) {
505 func TestFindTunsEventLogsByAddr(t *testing.T) {
506 	cleanupTestData(t)
507 
508-	user, _ := testDB.RegisterUser("tunsaddrowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI tunsaddrowner", "comment")
509+	user, _ := testDB.RegisterUser("tunsaddrowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI tunsaddrowner", "comment", "")
510 	_ = testDB.InsertTunsEventLog(&db.TunsEventLog{
511 		UserId:         user.ID,
512 		ServerID:       "server-1",
513@@ -1254,7 +1254,7 @@ func TestFindTunsEventLogsByAddr(t *testing.T) {
514 func TestInsertAccessLog(t *testing.T) {
515 	cleanupTestData(t)
516 
517-	user, _ := testDB.RegisterUser("accesslogowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI accesslogowner", "comment")
518+	user, _ := testDB.RegisterUser("accesslogowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI accesslogowner", "comment", "")
519 
520 	log := &db.AccessLog{
521 		UserID:   user.ID,
522@@ -1272,7 +1272,7 @@ func TestInsertAccessLog(t *testing.T) {
523 func TestFindAccessLogs(t *testing.T) {
524 	cleanupTestData(t)
525 
526-	user, _ := testDB.RegisterUser("findaccesslogowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI findaccesslogowner", "comment")
527+	user, _ := testDB.RegisterUser("findaccesslogowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI findaccesslogowner", "comment", "")
528 	_ = testDB.InsertAccessLog(&db.AccessLog{
529 		UserID:   user.ID,
530 		Service:  "pgs",
531@@ -1293,7 +1293,7 @@ func TestFindAccessLogs(t *testing.T) {
532 func TestFindPubkeysInAccessLogs(t *testing.T) {
533 	cleanupTestData(t)
534 
535-	user, _ := testDB.RegisterUser("pubkeylogowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI pubkeylogowner", "comment")
536+	user, _ := testDB.RegisterUser("pubkeylogowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI pubkeylogowner", "comment", "")
537 	_ = testDB.InsertAccessLog(&db.AccessLog{
538 		UserID:   user.ID,
539 		Service:  "pgs",
540@@ -1313,7 +1313,7 @@ func TestFindPubkeysInAccessLogs(t *testing.T) {
541 func TestFindAccessLogsByPubkey(t *testing.T) {
542 	cleanupTestData(t)
543 
544-	user, _ := testDB.RegisterUser("accessbypubkeyowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI accessbypubkeyowner", "comment")
545+	user, _ := testDB.RegisterUser("accessbypubkeyowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI accessbypubkeyowner", "comment", "")
546 	pubkey := "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI accessbypubkeyowner"
547 	_ = testDB.InsertAccessLog(&db.AccessLog{
548 		UserID:   user.ID,
549@@ -1337,7 +1337,7 @@ func TestFindAccessLogsByPubkey(t *testing.T) {
550 func TestPostData_JSONBRoundtrip(t *testing.T) {
551 	cleanupTestData(t)
552 
553-	user, _ := testDB.RegisterUser("postdataowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI postdataowner", "comment")
554+	user, _ := testDB.RegisterUser("postdataowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI postdataowner", "comment", "")
555 
556 	now := time.Now().Truncate(time.Second)
557 	postData := db.PostData{
558@@ -1370,7 +1370,7 @@ func TestPostData_JSONBRoundtrip(t *testing.T) {
559 func TestProjectAcl_JSONBRoundtrip(t *testing.T) {
560 	cleanupTestData(t)
561 
562-	user, _ := testDB.RegisterUser("aclowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI aclowner", "comment")
563+	user, _ := testDB.RegisterUser("aclowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI aclowner", "comment", "")
564 
565 	project, err := testDB.UpsertProject(user.ID, "acl-project", "acl-project")
566 	if err != nil {
567@@ -1390,7 +1390,7 @@ func TestProjectAcl_JSONBRoundtrip(t *testing.T) {
568 func TestFeedItemData_JSONBRoundtrip(t *testing.T) {
569 	cleanupTestData(t)
570 
571-	user, _ := testDB.RegisterUser("feedjsonbowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI feedjsonbowner", "comment")
572+	user, _ := testDB.RegisterUser("feedjsonbowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI feedjsonbowner", "comment", "")
573 	post := mustInsertPost(t, &db.Post{UserID: user.ID, Filename: "feedjsonb.txt", Slug: "feedjsonb", Title: "Feed JSONB", Space: "feeds"})
574 
575 	now := time.Now().Truncate(time.Second)
576@@ -1424,7 +1424,7 @@ func TestFeedItemData_JSONBRoundtrip(t *testing.T) {
577 func TestFeatureFlagData_JSONBRoundtrip(t *testing.T) {
578 	cleanupTestData(t)
579 
580-	user, _ := testDB.RegisterUser("featurejsonbowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI featurejsonbowner", "comment")
581+	user, _ := testDB.RegisterUser("featurejsonbowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI featurejsonbowner", "comment", "")
582 
583 	err := testDB.AddPicoPlusUser("featurejsonbowner", "test@example.com", "stripe", "tx456")
584 	if err != nil {
585@@ -1446,7 +1446,7 @@ func TestFeatureFlagData_JSONBRoundtrip(t *testing.T) {
586 func TestPaymentHistoryData_JSONBRoundtrip(t *testing.T) {
587 	cleanupTestData(t)
588 
589-	user, _ := testDB.RegisterUser("paymentjsonbowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI paymentjsonbowner", "comment")
590+	user, _ := testDB.RegisterUser("paymentjsonbowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI paymentjsonbowner", "comment", "")
591 
592 	err := testDB.AddPicoPlusUser("paymentjsonbowner", "payment@example.com", "stripe", "tx789")
593 	if err != nil {
594@@ -1468,7 +1468,7 @@ func TestPaymentHistoryData_JSONBRoundtrip(t *testing.T) {
595 func TestUpsertPipeMonitor(t *testing.T) {
596 	cleanupTestData(t)
597 
598-	user, _ := testDB.RegisterUser("pipemonitorowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI pipemonitorowner", "comment")
599+	user, _ := testDB.RegisterUser("pipemonitorowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI pipemonitorowner", "comment", "")
600 
601 	winEnd := time.Now().Add(time.Hour)
602 	err := testDB.UpsertPipeMonitor(user.ID, "test-topic", 5*time.Minute, &winEnd)
603@@ -1491,7 +1491,7 @@ func TestUpsertPipeMonitor(t *testing.T) {
604 func TestUpsertPipeMonitor_Update(t *testing.T) {
605 	cleanupTestData(t)
606 
607-	user, _ := testDB.RegisterUser("pipeupdateowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI pipeupdateowner", "comment")
608+	user, _ := testDB.RegisterUser("pipeupdateowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI pipeupdateowner", "comment", "")
609 
610 	winEnd1 := time.Now().Add(time.Hour)
611 	err := testDB.UpsertPipeMonitor(user.ID, "update-topic", 5*time.Minute, &winEnd1)
612@@ -1517,7 +1517,7 @@ func TestUpsertPipeMonitor_Update(t *testing.T) {
613 func TestUpdatePipeMonitorLastPing(t *testing.T) {
614 	cleanupTestData(t)
615 
616-	user, _ := testDB.RegisterUser("pipepingowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI pipepingowner", "comment")
617+	user, _ := testDB.RegisterUser("pipepingowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI pipepingowner", "comment", "")
618 
619 	winEnd := time.Now().Add(time.Hour)
620 	err := testDB.UpsertPipeMonitor(user.ID, "ping-topic", 5*time.Minute, &winEnd)
621@@ -1543,7 +1543,7 @@ func TestUpdatePipeMonitorLastPing(t *testing.T) {
622 func TestRemovePipeMonitor(t *testing.T) {
623 	cleanupTestData(t)
624 
625-	user, _ := testDB.RegisterUser("piperemoveowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI piperemoveowner", "comment")
626+	user, _ := testDB.RegisterUser("piperemoveowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI piperemoveowner", "comment", "")
627 
628 	winEnd := time.Now().Add(time.Hour)
629 	err := testDB.UpsertPipeMonitor(user.ID, "remove-topic", 5*time.Minute, &winEnd)
630@@ -1565,7 +1565,7 @@ func TestRemovePipeMonitor(t *testing.T) {
631 func TestFindPipeMonitorByTopic_NotFound(t *testing.T) {
632 	cleanupTestData(t)
633 
634-	user, _ := testDB.RegisterUser("pipenotfoundowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI pipenotfoundowner", "comment")
635+	user, _ := testDB.RegisterUser("pipenotfoundowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI pipenotfoundowner", "comment", "")
636 
637 	_, err := testDB.FindPipeMonitorByTopic(user.ID, "nonexistent-topic")
638 	if err == nil {
639@@ -1576,7 +1576,7 @@ func TestFindPipeMonitorByTopic_NotFound(t *testing.T) {
640 func TestFindPipeMonitorsByUser(t *testing.T) {
641 	cleanupTestData(t)
642 
643-	user, _ := testDB.RegisterUser("pipemonlistowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI pipemonlistowner", "comment")
644+	user, _ := testDB.RegisterUser("pipemonlistowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI pipemonlistowner", "comment", "")
645 
646 	winEnd := time.Now().Add(time.Hour)
647 	_ = testDB.UpsertPipeMonitor(user.ID, "service-a", 5*time.Minute, &winEnd)
648@@ -1607,7 +1607,7 @@ func TestFindPipeMonitorsByUser(t *testing.T) {
649 func TestFindPipeMonitorsByUser_Empty(t *testing.T) {
650 	cleanupTestData(t)
651 
652-	user, _ := testDB.RegisterUser("pipenomonitors", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI pipenomonitors", "comment")
653+	user, _ := testDB.RegisterUser("pipenomonitors", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI pipenomonitors", "comment", "")
654 
655 	monitors, err := testDB.FindPipeMonitorsByUser(user.ID)
656 	if err != nil {
+1, -1
1@@ -24,7 +24,7 @@ func NewStubDB(logger *slog.Logger) *StubDB {
2 
3 var errNotImpl = fmt.Errorf("not implemented")
4 
5-func (me *StubDB) RegisterUser(username, pubkey, comment string) (*db.User, error) {
6+func (me *StubDB) RegisterUser(username, pubkey, comment, ip string) (*db.User, error) {
7 	return nil, errNotImpl
8 }
9 
+4, -1
 1@@ -2,6 +2,7 @@ package tui
 2 
 3 import (
 4 	"fmt"
 5+	"net"
 6 
 7 	"git.sr.ht/~rockorager/vaxis"
 8 	"git.sr.ht/~rockorager/vaxis/vxfw"
 9@@ -45,7 +46,9 @@ func (m *SignupPage) createAccount(name string) (*db.User, error) {
10 		return nil, fmt.Errorf("name cannot be empty")
11 	}
12 	key := shared.KeyForKeyText(m.shared.Session.PublicKey())
13-	return m.shared.Dbpool.RegisterUser(name, key, "")
14+	remoteAddr := m.shared.Session.Conn.RemoteAddr().String()
15+	userIp, _, _ := net.SplitHostPort(remoteAddr)
16+	return m.shared.Dbpool.RegisterUser(name, key, "", userIp)
17 }
18 
19 func (m *SignupPage) CaptureEvent(ev vaxis.Event) (vxfw.Command, error) {
+6, -0
1@@ -0,0 +1,6 @@
2+CREATE TABLE IF NOT EXISTS block_signups (
3+  id SERIAL PRIMARY KEY,
4+  ip inet NOT NULL,
5+  reason TEXT not null,
6+  created_at timestamp without time zone NOT NULL DEFAULT NOW()
7+);