You are performing a code quality review on the dragonrelay project's database layer. The files to review are already committed to the Forgejo repo (owner: zgaetano, repo: dragonrelay). Read the following files using forgejo_get_file and review them for code quality issues:

1. backend/internal/db/db.go
2. backend/internal/db/migrations_test.go
3. backend/internal/db/db_test.go

The context: This is a Go SQLite user store for a self-hosted gaming relay server. It was written to add:
- v0→v1→v2 versioned migration (users table, host_acl table, invites table)
- User role rename: 'user' → 'member' in v2 migration
- Host ACL CRUD helpers (GrantHostAccess, RevokeHostAccess, GetHostAccess, ListHostACL, ListUserHostAccess)
- Invite CRUD + redemption (CreateInvite, GetInviteByToken, ListActiveInvites, RevokeInvite, RedeemInvite)
- BackfillHostACL is NOT yet added — it's coming in a future task

Review for:
1. Correctness: are the SQL queries correct? Are error paths handled?
2. Safety: SQL injection risks? Concurrency issues?
3. Transaction handling: are all multi-step operations wrapped in transactions?
4. Edge cases: what happens with empty inputs, invalid levels, duplicate grants?
5. Test coverage: are the tests in migrations_test.go and db_test.go adequate?
6. Code style: Go idioms, error wrapping, naming?

Be specific about any issues — line numbers or function names. Also note things that look good.

Return a structured review: ISSUES (with severity HIGH/MEDIUM/LOW), GOOD THINGS, and RECOMMENDATIONS.


I'll start by fetching all three files from the Forgejo repository simultaneously.


ToolSearch

Question: select:mcp__claude_ai_mcp-server__forgejo_forgejo_get_file


Now let me fetch all three files in parallel.


File: backend/internal/db/db.go

// Package db manages the SQLite user store for Dragon Flight Relay.
package db

import (
	"crypto/sha256"
	"database/sql"
	"encoding/hex"
	"encoding/json"
	"errors"
	"fmt"
	"time"

	_ "github.com/mattn/go-sqlite3"
	"golang.org/x/crypto/bcrypt"
)

// User represents a row in the users table.
type User struct {
	ID        int64
	Username  string
	Password  string // bcrypt hash
	Role      string // "admin", "member", or "viewer"
	CreatedAt time.Time
}

// Store wraps a SQLite database connection.
type Store struct {
	db *sql.DB
}

// Open opens (or creates) the SQLite database at path.
// Pass ":memory:" for tests.
func Open(path string) (*Store, error) {
	d, err := sql.Open("sqlite3", path)
	if err != nil {
		return nil, fmt.Errorf("open db: %w", err)
	}
	// SQLite disables foreign-key enforcement by default. Turn it on so the
	// FK clauses in our schema (ON DELETE CASCADE, ON DELETE SET NULL) actually
	// fire. We enable it via a query string PRAGMA at connection-open time.
	// Since *sql.DB pools connections internally and PRAGMA foreign_keys is
	// per-connection, we also set MaxOpenConns(1) to enforce a single connection,
	// ensuring all operations use an FK-enforcing connection. This is safe for
	// this homelab project (very low concurrency, single goroutine handling
	// one HTTP request at a time).
	if _, err := d.Exec("PRAGMA foreign_keys = ON"); err != nil {
		d.Close()
		return nil, fmt.Errorf("enable foreign_keys: %w", err)
	}
	d.SetMaxOpenConns(1)
	s := &Store{db: d}
	if err := s.migrate(); err != nil {
		d.Close()
		return nil, err
	}
	return s, nil
}

// Close closes the underlying database.
func (s *Store) Close() error {
	return s.db.Close()
}

func (s *Store) migrate() error {
	tx, err := s.db.Begin()
	if err != nil {
		return err
	}
	defer tx.Rollback()

	// Read current schema version
	var version int
	if err := tx.QueryRow(`PRAGMA user_version`).Scan(&version); err != nil {
		return fmt.Errorf("read user_version: %w", err)
	}

	// v0 → v1: original users table.
	if version < 1 {
		if _, err := tx.Exec(`
			CREATE TABLE IF NOT EXISTS users (
				id         INTEGER PRIMARY KEY AUTOINCREMENT,
				username   TEXT UNIQUE NOT NULL,
				password   TEXT NOT NULL,
				role       TEXT NOT NULL CHECK(role IN ('admin','user','viewer')),
				created_at DATETIME DEFAULT CURRENT_TIMESTAMP
			)
		`); err != nil {
			return fmt.Errorf("create users: %w", err)
		}
		if _, err := tx.Exec(`PRAGMA user_version = 1`); err != nil {
			return err
		}
		version = 1
	}

	// v1 → v2: per-host ACL, invites, role rename.
	if version < 2 {
		// Rebuild users with the new role enum (superset for rollback safety).
		if _, err := tx.Exec(`
			CREATE TABLE users_new (
				id         INTEGER PRIMARY KEY AUTOINCREMENT,
				username   TEXT UNIQUE NOT NULL,
				password   TEXT NOT NULL,
				role       TEXT NOT NULL CHECK(role IN ('admin','user','member','viewer')),
				created_at DATETIME DEFAULT CURRENT_TIMESTAMP
			)
		`); err != nil {
			return fmt.Errorf("create users_new: %w", err)
		}
		if _, err := tx.Exec(`
			INSERT INTO users_new (id, username, password, role, created_at)
			SELECT id, username, password,
			       CASE WHEN role='user' THEN 'member' ELSE role END,
			       created_at FROM users
		`); err != nil {
			return fmt.Errorf("copy users: %w", err)
		}
		if _, err := tx.Exec(`DROP TABLE users`); err != nil {
			return fmt.Errorf("drop users: %w", err)
		}
		if _, err := tx.Exec(`ALTER TABLE users_new RENAME TO users`); err != nil {
			return fmt.Errorf("rename users: %w", err)
		}

		// New tables.
		if _, err := tx.Exec(`
			CREATE TABLE IF NOT EXISTS host_acl (
				host_ip    TEXT    NOT NULL,
				user_id    INTEGER NOT NULL,
				level      TEXT    NOT NULL CHECK(level IN ('view','connect','manage')),
				granted_by INTEGER,
				granted_at DATETIME DEFAULT CURRENT_TIMESTAMP,
				PRIMARY KEY (host_ip, user_id),
				FOREIGN KEY (user_id)    REFERENCES users(id) ON DELETE CASCADE,
				FOREIGN KEY (granted_by) REFERENCES users(id) ON DELETE SET NULL
			)
		`); err != nil {
			return fmt.Errorf("create host_acl: %w", err)
		}
		if _, err := tx.Exec(`
			CREATE TABLE IF NOT EXISTS invites (
				id              INTEGER PRIMARY KEY AUTOINCREMENT,
				token_hash      TEXT    NOT NULL UNIQUE,
				label           TEXT    NOT NULL DEFAULT '',
				role            TEXT    NOT NULL CHECK(role IN ('admin','member','viewer')),
				host_access     TEXT    NOT NULL DEFAULT '[]',
				created_by      INTEGER NOT NULL,
				created_at      DATETIME DEFAULT CURRENT_TIMESTAMP,
				expires_at      DATETIME NOT NULL,
				used_at         DATETIME,
				used_by_user_id INTEGER,
				FOREIGN KEY (created_by)      REFERENCES users(id),
				FOREIGN KEY (used_by_user_id) REFERENCES users(id)
			)
		`); err != nil {
			return fmt.Errorf("create invites: %w", err)
		}

		if _, err := tx.Exec(`PRAGMA user_version = 2`); err != nil {
			return err
		}
	}

	return tx.Commit()
}

// CreateUser creates a new user with a bcrypt-hashed password.
func (s *Store) CreateUser(username, plainPassword, role string) error {
	hash, err := bcrypt.GenerateFromPassword([]byte(plainPassword), bcrypt.DefaultCost)
	if err != nil {
		return fmt.Errorf("hash password: %w", err)
	}
	_, err = s.db.Exec(
		`INSERT INTO users (username, password, role) VALUES (?, ?, ?)`,
		username, string(hash), role,
	)
	return err
}

// GetUserByUsername returns a user by username, or sql.ErrNoRows if not found.
func (s *Store) GetUserByUsername(username string) (*User, error) {
	u := &User{}
	err := s.db.QueryRow(
		`SELECT id, username, password, role, created_at FROM users WHERE username = ?`,
		username,
	).Scan(&u.ID, &u.Username, &u.Password, &u.Role, &u.CreatedAt)
	if err != nil {
		return nil, err
	}
	return u, nil
}

// GetUserByID returns a user by ID, or sql.ErrNoRows if not found.
func (s *Store) GetUserByID(id int64) (*User, error) {
	u := &User{}
	err := s.db.QueryRow(
		`SELECT id, username, password, role, created_at FROM users WHERE id = ?`,
		id,
	).Scan(&u.ID, &u.Username, &u.Password, &u.Role, &u.CreatedAt)
	if err != nil {
		return nil, err
	}
	return u, nil
}

// ListUsers returns all users. Password field is empty (not returned).
func (s *Store) ListUsers() ([]*User, error) {
	rows, err := s.db.Query(`SELECT id, username, role, created_at FROM users ORDER BY id`)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	var users []*User
	for rows.Next() {
		u := &User{}
		if err := rows.Scan(&u.ID, &u.Username, &u.Role, &u.CreatedAt); err != nil {
			return nil, err
		}
		users = append(users, u)
	}
	return users, rows.Err()
}

// CheckPassword returns true if plainPassword matches the stored bcrypt hash.
func (s *Store) CheckPassword(u *User, plainPassword string) bool {
	return bcrypt.CompareHashAndPassword([]byte(u.Password), []byte(plainPassword)) == nil
}

// UpdateRole changes a user's role by ID.
func (s *Store) UpdateRole(id int64, role string) error {
	_, err := s.db.Exec(`UPDATE users SET role = ? WHERE id = ?`, role, id)
	return err
}

// ResetPassword sets a new bcrypt-hashed password for a user by ID.
func (s *Store) ResetPassword(id int64, plainPassword string) error {
	hash, err := bcrypt.GenerateFromPassword([]byte(plainPassword), bcrypt.DefaultCost)
	if err != nil {
		return fmt.Errorf("hash password: %w", err)
	}
	_, err = s.db.Exec(`UPDATE users SET password = ? WHERE id = ?`, string(hash), id)
	return err
}

// DeleteUser removes a user by ID.
func (s *Store) DeleteUser(id int64) error {
	_, err := s.db.Exec(`DELETE FROM users WHERE id = ?`, id)
	return err
}

// SeedAdminIfEmpty creates an admin user with the given password if the
// users table is empty. Returns true if a user was created.
func (s *Store) SeedAdminIfEmpty(adminPassword string) (bool, error) {
	var count int
	if err := s.db.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&count); err != nil {
		return false, err
	}
	if count > 0 {
		return false, nil
	}
	if err := s.CreateUser("admin", adminPassword, "admin"); err != nil {
		return false, err
	}
	return true, nil
}

// IsEmpty returns true if no users exist yet (used by setup wizard).
func (s *Store) IsEmpty() (bool, error) {
	var count int
	if err := s.db.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&count); err != nil {
		return false, err
	}
	return count == 0, nil
}

// ─── Host ACL ──────────────────────────────────────────────────────────────

// HostGrant pairs a host IP with the level a user has on it.
type HostGrant struct {
	HostIP string `json:"host_ip"`
	Level  string `json:"level"` // "view" | "connect" | "manage"
}

// HostACLEntry is one row of "who can do what on this host".
type HostACLEntry struct {
	UserID   int64  `json:"user_id"`
	Username string `json:"username"`
	Level    string `json:"level"`
}

// GrantHostAccess inserts or upgrades a user's grant for a host.
// Pass grantedBy = 0 for system-issued grants (migration backfill).
func (s *Store) GrantHostAccess(userID int64, hostIP, level string, grantedBy int64) error {
	if level != "view" && level != "connect" && level != "manage" {
		return fmt.Errorf("invalid level: %s", level)
	}
	var grantedByVal interface{}
	if grantedBy == 0 {
		grantedByVal = nil
	} else {
		grantedByVal = grantedBy
	}
	_, err := s.db.Exec(`
		INSERT INTO host_acl (host_ip, user_id, level, granted_by)
		VALUES (?, ?, ?, ?)
		ON CONFLICT(host_ip, user_id)
		DO UPDATE SET level = excluded.level, granted_by = excluded.granted_by, granted_at = CURRENT_TIMESTAMP
	`, hostIP, userID, level, grantedByVal)
	return err
}

// RevokeHostAccess removes a user's grant for a host.
func (s *Store) RevokeHostAccess(userID int64, hostIP string) error {
	_, err := s.db.Exec(`DELETE FROM host_acl WHERE host_ip = ? AND user_id = ?`, hostIP, userID)
	return err
}

// GetHostAccess returns the level a user has on a host, or ok=false if none.
func (s *Store) GetHostAccess(userID int64, hostIP string) (level string, ok bool, err error) {
	err = s.db.QueryRow(
		`SELECT level FROM host_acl WHERE host_ip = ? AND user_id = ?`,
		hostIP, userID,
	).Scan(&level)
	if err == sql.ErrNoRows {
		return "", false, nil
	}
	if err != nil {
		return "", false, err
	}
	return level, true, nil
}

// ListUserHostAccess returns every host this user has been granted access to.
func (s *Store) ListUserHostAccess(userID int64) ([]HostGrant, error) {
	rows, err := s.db.Query(
		`SELECT host_ip, level FROM host_acl WHERE user_id = ? ORDER BY host_ip`,
		userID,
	)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	var grants []HostGrant
	for rows.Next() {
		var g HostGrant
		if err := rows.Scan(&g.HostIP, &g.Level); err != nil {
			return nil, err
		}
		grants = append(grants, g)
	}
	return grants, rows.Err()
}

// ListHostACL returns every user grant for a given host.
func (s *Store) ListHostACL(hostIP string) ([]HostACLEntry, error) {
	rows, err := s.db.Query(`
		SELECT u.id, u.username, h.level
		FROM host_acl h JOIN users u ON u.id = h.user_id
		WHERE h.host_ip = ? ORDER BY u.username
	`, hostIP)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	var entries []HostACLEntry
	for rows.Next() {
		var e HostACLEntry
		if err := rows.Scan(&e.UserID, &e.Username, &e.Level); err != nil {
			return nil, err
		}
		entries = append(entries, e)
	}
	return entries, rows.Err()
}

// ─── Invites ───────────────────────────────────────────────────────────────

// hashToken turns a raw invite token into the value we store.
func hashToken(raw string) string {
	sum := sha256.Sum256([]byte(raw))
	return hex.EncodeToString(sum[:])
}

// InviteRecord is the read-side view of an invites row.
type InviteRecord struct {
	ID        int64
	Label     string
	Role      string
	HostAccess []HostGrant
	CreatedBy  int64
	CreatedAt  time.Time
	ExpiresAt  time.Time
	UsedAt     *time.Time
	UsedByID   *int64
}

// ErrInviteInvalid is returned when an invite is missing, expired, or used.
var ErrInviteInvalid = errors.New("invite invalid")

// CreateInvite stores the hashed token along with metadata. Returns the new id.
func (s *Store) CreateInvite(rawToken, label, role string, hostAccess []HostGrant, createdBy int64, expires time.Time) (int64, error) {
	if role != "admin" && role != "member" && role != "viewer" {
		return 0, fmt.Errorf("invalid role: %s", role)
	}
	if hostAccess == nil {
		hostAccess = []HostGrant{}
	}
	hostAccessJSON, err := json.Marshal(hostAccess)
	if err != nil {
		return 0, err
	}
	res, err := s.db.Exec(`
		INSERT INTO invites (token_hash, label, role, host_access, created_by, expires_at)
		VALUES (?, ?, ?, ?, ?, ?)
	`, hashToken(rawToken), label, role, string(hostAccessJSON), createdBy, expires)
	if err != nil {
		return 0, err
	}
	return res.LastInsertId()
}

// GetInviteByToken returns the invite for a raw token. Does NOT check expiry/used.
func (s *Store) GetInviteByToken(rawToken string) (*InviteRecord, error) {
	r := &InviteRecord{}
	var hostAccessJSON string
	var usedAt sql.NullTime
	var usedBy sql.NullInt64
	err := s.db.QueryRow(`
		SELECT id, label, role, host_access, created_by, created_at, expires_at, used_at, used_by_user_id
		FROM invites WHERE token_hash = ?
	`, hashToken(rawToken)).Scan(
		&r.ID, &r.Label, &r.Role, &hostAccessJSON,
		&r.CreatedBy, &r.CreatedAt, &r.ExpiresAt, &usedAt, &usedBy,
	)
	if err != nil {
		return nil, err
	}
	if usedAt.Valid {
		r.UsedAt = &usedAt.Time
	}
	if usedBy.Valid {
		v := usedBy.Int64
		r.UsedByID = &v
	}
	if err := json.Unmarshal([]byte(hostAccessJSON), &r.HostAccess); err != nil {
		return nil, err
	}
	return r, nil
}

// ListActiveInvites returns invites that are unused and not expired.
func (s *Store) ListActiveInvites() ([]InviteRecord, error) {
	rows, err := s.db.Query(`
		SELECT id, label, role, host_access, created_by, created_at, expires_at
		FROM invites
		WHERE used_at IS NULL AND expires_at > CURRENT_TIMESTAMP
		ORDER BY created_at DESC
	`)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	var out []InviteRecord
	for rows.Next() {
		var r InviteRecord
		var hostAccessJSON string
		if err := rows.Scan(&r.ID, &r.Label, &r.Role, &hostAccessJSON, &r.CreatedBy, &r.CreatedAt, &r.ExpiresAt); err != nil {
			return nil, err
		}
		_ = json.Unmarshal([]byte(hostAccessJSON), &r.HostAccess)
		out = append(out, r)
	}
	return out, rows.Err()
}

// RevokeInvite deletes an unused invite. Returns error if not found.
func (s *Store) RevokeInvite(id int64) error {
	res, err := s.db.Exec(`DELETE FROM invites WHERE id = ? AND used_at IS NULL`, id)
	if err != nil {
		return err
	}
	n, _ := res.RowsAffected()
	if n == 0 {
		return errors.New("invite not found or already used")
	}
	return nil
}

// RedeemInvite atomically: validates, creates the user, inserts host_acl rows,
// marks the invite used. Returns the new user id.
func (s *Store) RedeemInvite(rawToken, username, plainPassword string) (int64, error) {
	hash := hashToken(rawToken)
	tx, err := s.db.Begin()
	if err != nil {
		return 0, err
	}
	defer tx.Rollback()

	var (
		id             int64
		role           string
		hostAccessJSON string
		expiresAt      time.Time
		usedAt         sql.NullTime
	)
	err = tx.QueryRow(`
		SELECT id, role, host_access, expires_at, used_at
		FROM invites WHERE token_hash = ?
	`, hash).Scan(&id, &role, &hostAccessJSON, &expiresAt, &usedAt)
	if err == sql.ErrNoRows {
		return 0, ErrInviteInvalid
	}
	if err != nil {
		return 0, err
	}
	if usedAt.Valid || time.Now().After(expiresAt) {
		return 0, ErrInviteInvalid
	}

	// Hash password and insert user.
	pwHash, err := bcrypt.GenerateFromPassword([]byte(plainPassword), bcrypt.DefaultCost)
	if err != nil {
		return 0, err
	}
	res, err := tx.Exec(
		`INSERT INTO users (username, password, role) VALUES (?, ?, ?)`,
		username, string(pwHash), role,
	)
	if err != nil {
		return 0, err
	}
	userID, err := res.LastInsertId()
	if err != nil {
		return 0, err
	}

	// Insert host_acl rows.
	var grants []HostGrant
	if err := json.Unmarshal([]byte(hostAccessJSON), &grants); err != nil {
		return 0, err
	}
	for _, g := range grants {
		// Cap viewer-role users at 'view' regardless of grant.
		level := g.Level
		if role == "viewer" && level != "view" {
			level = "view"
		}
		if _, err := tx.Exec(`
			INSERT INTO host_acl (host_ip, user_id, level)
			VALUES (?, ?, ?)
		`, g.HostIP, userID, level); err != nil {
			return 0, err
		}
	}

	// Mark invite used.
	if _, err := tx.Exec(`
		UPDATE invites SET used_at = CURRENT_TIMESTAMP, used_by_user_id = ? WHERE id = ?
	`, userID, id); err != nil {
		return 0, err
	}

	if err := tx.Commit(); err != nil {
		return 0, err
	}
	return userID, nil
}


File: backend/internal/db/migrations_test.go

package db

import (
	"database/sql"
	"testing"

	_ "github.com/mattn/go-sqlite3"
)

func TestMigrateCreatesHostACLAndInvites(t *testing.T) {
	s, err := Open(":memory:")
	if err != nil {
		t.Fatalf("open: %v", err)
	}
	defer s.Close()

	tables := []string{"host_acl", "invites"}
	for _, table := range tables {
		var name string
		err := s.db.QueryRow(
			`SELECT name FROM sqlite_master WHERE type='table' AND name=?`, table,
		).Scan(&name)
		if err != nil {
			t.Fatalf("expected table %s, got error: %v", table, err)
		}
		if name != table {
			t.Fatalf("expected table %s, got %s", table, name)
		}
	}
}

func TestUserVersionAfterMigrate(t *testing.T) {
	s, err := Open(":memory:")
	if err != nil {
		t.Fatalf("open: %v", err)
	}
	defer s.Close()

	var v int
	if err := s.db.QueryRow(`PRAGMA user_version`).Scan(&v); err != nil {
		t.Fatalf("read user_version: %v", err)
	}
	if v < 2 {
		t.Fatalf("expected user_version >= 2, got %d", v)
	}
}

// TestMigrateRenamesUserToMember seeds a v1 schema with a row whose role is
// 'user' and verifies the v1→v2 migration maps it to 'member'.
func TestMigrateRenamesUserToMember(t *testing.T) {
	d, err := sql.Open("sqlite3", ":memory:")
	if err != nil {
		t.Fatalf("open: %v", err)
	}
	defer d.Close()

	// Seed v1 schema by hand.
	if _, err := d.Exec(`
		CREATE TABLE users (
			id         INTEGER PRIMARY KEY AUTOINCREMENT,
			username   TEXT UNIQUE NOT NULL,
			password   TEXT NOT NULL,
			role       TEXT NOT NULL CHECK(role IN ('admin','user','viewer')),
			created_at DATETIME DEFAULT CURRENT_TIMESTAMP
		)
	`); err != nil {
		t.Fatalf("seed v1: %v", err)
	}
	if _, err := d.Exec(`PRAGMA user_version = 1`); err != nil {
		t.Fatalf("set version: %v", err)
	}
	if _, err := d.Exec(
		`INSERT INTO users (username, password, role) VALUES ('alex','x','user')`,
	); err != nil {
		t.Fatalf("insert user: %v", err)
	}

	// Run migration.
	s := &Store{db: d}
	if err := s.migrate(); err != nil {
		t.Fatalf("migrate: %v", err)
	}

	// Verify role rewritten.
	var role string
	if err := d.QueryRow(`SELECT role FROM users WHERE username='alex'`).Scan(&role); err != nil {
		t.Fatalf("read role: %v", err)
	}
	if role != "member" {
		t.Fatalf("expected role=member, got %s", role)
	}

	// Verify version bumped.
	var v int
	if err := d.QueryRow(`PRAGMA user_version`).Scan(&v); err != nil {
		t.Fatalf("read version: %v", err)
	}
	if v != 2 {
		t.Fatalf("expected user_version=2, got %d", v)
	}

	// Idempotency: running migrate again must be a no-op.
	if err := s.migrate(); err != nil {
		t.Fatalf("migrate (rerun): %v", err)
	}
}


File: backend/internal/db/db_test.go

package db_test

import (
	"testing"

	"dragonrelay/internal/db"
)

func setup(t *testing.T) *db.Store {
	t.Helper()
	store, err := db.Open(":memory:")
	if err != nil {
		t.Fatalf("Open: %v", err)
	}
	t.Cleanup(func() { store.Close() })
	return store
}

func TestCreateAndGetUser(t *testing.T) {
	store := setup(t)
	if err := store.CreateUser("alice", "password123", "admin"); err != nil {
		t.Fatalf("CreateUser: %v", err)
	}
	user, err := store.GetUserByUsername("alice")
	if err != nil {
		t.Fatalf("GetUserByUsername: %v", err)
	}
	if user.Username != "alice" {
		t.Errorf("Username: got %q want alice", user.Username)
	}
	if user.Role != "admin" {
		t.Errorf("Role: got %q want admin", user.Role)
	}
}

func TestPasswordHash(t *testing.T) {
	store := setup(t)
	_ = store.CreateUser("bob", "secret", "user")
	user, _ := store.GetUserByUsername("bob")
	if user.Password == "secret" {
		t.Error("password stored in plaintext")
	}
	if !store.CheckPassword(user, "secret") {
		t.Error("CheckPassword returned false for correct password")
	}
	if store.CheckPassword(user, "wrong") {
		t.Error("CheckPassword returned true for wrong password")
	}
}

func TestListUsers(t *testing.T) {
	store := setup(t)
	_ = store.CreateUser("alice", "pw", "admin")
	_ = store.CreateUser("bob", "pw", "user")
	users, err := store.ListUsers()
	if err != nil {
		t.Fatalf("ListUsers: %v", err)
	}
	if len(users) != 2 {
		t.Errorf("len: got %d want 2", len(users))
	}
}

func TestUpdateRole(t *testing.T) {
	store := setup(t)
	_ = store.CreateUser("alice", "pw", "user")
	user, _ := store.GetUserByUsername("alice")
	if err := store.UpdateRole(user.ID, "viewer"); err != nil {
		t.Fatalf("UpdateRole: %v", err)
	}
	updated, _ := store.GetUserByUsername("alice")
	if updated.Role != "viewer" {
		t.Errorf("Role: got %q want viewer", updated.Role)
	}
}

func TestResetPassword(t *testing.T) {
	store := setup(t)
	_ = store.CreateUser("alice", "old", "user")
	user, _ := store.GetUserByUsername("alice")
	if err := store.ResetPassword(user.ID, "newpass"); err != nil {
		t.Fatalf("ResetPassword: %v", err)
	}
	updated, _ := store.GetUserByUsername("alice")
	if !store.CheckPassword(updated, "newpass") {
		t.Error("password not updated correctly")
	}
}

func TestDeleteUser(t *testing.T) {
	store := setup(t)
	_ = store.CreateUser("alice", "pw", "user")
	user, _ := store.GetUserByUsername("alice")
	if err := store.DeleteUser(user.ID); err != nil {
		t.Fatalf("DeleteUser: %v", err)
	}
	users, _ := store.ListUsers()
	if len(users) != 0 {
		t.Errorf("expected 0 users after delete, got %d", len(users))
	}
}

func TestSeedAdmin(t *testing.T) {
	store := setup(t)
	seeded, err := store.SeedAdminIfEmpty("initpass")
	if err != nil {
		t.Fatalf("SeedAdminIfEmpty: %v", err)
	}
	if !seeded {
		t.Error("expected seeded=true on empty store")
	}
	seeded2, _ := store.SeedAdminIfEmpty("initpass")
	if seeded2 {
		t.Error("expected seeded=false when users already exist")
	}
}


I now have all three files. Let me do a thorough analysis before writing the review.


Error: Error executing tool forgejo_get_file: 'list' object has no attribute 'get'


I have everything I need. Here is the full structured review.

---

## Code Quality Review — `backend/internal/db`

---

### ISSUES

---

#### HIGH — `RedeemInvite`: empty `username` or `plainPassword` not validated

**Function:** `RedeemInvite` (~line 310)

There is no guard against an empty `username` or `plainPassword`. An empty username will produce a row with `username = ''`, which is technically `UNIQUE NOT NULL`-safe but semantically wrong and very hard to undo cleanly. An empty password will produce a valid bcrypt hash of `""`, which is a permanently weak credential. Both cases should be caught before the transaction begins:

```go
if username == "" {
    return 0, fmt.Errorf("username must not be empty")
}
if plainPassword == "" {
    return 0, fmt.Errorf("password must not be empty")
}
```

The same omission exists in `CreateUser`, though it is slightly less critical there because callers are presumably authenticated admins.

---

#### HIGH — `PRAGMA user_version` set inside a transaction is unreliable

**Function:** `migrate()` (~lines 72–120)

`PRAGMA user_version = N` is a DDL-level pragma in SQLite. It works when issued inside a transaction, but some versions of the go-sqlite3 driver use WAL mode or deferred transactions that can make the PRAGMA commit timing unpredictable (it is technically outside the SQL transaction model). The established safe pattern is to commit the schema changes first, then set `user_version` in its own `Exec` on the base connection, or use `PRAGMA user_version = 2` as the very last statement before `tx.Commit()` with awareness that SQLite does in practice support it. This is low-risk in practice but worth a note since it is a migration guard — if the PRAGMA silently no-ops, the migration will re-run on the next open and `CREATE TABLE IF NOT EXISTS` will save you, but the `DROP TABLE users` / rename will not be idempotent.

The current v1→v2 sequence does `DROP TABLE users` and `ALTER TABLE users_new RENAME TO users` without `IF NOT EXISTS` guards. If the PRAGMA write fails and the process crashes mid-migration, re-running will fail on `CREATE TABLE users_new` (table already exists) rather than on the rename. Mitigations: set `user_version` as the final operation immediately before `tx.Commit()` (it already is at the end of v<2 block, which is good), and consider wrapping the rename guard with a check.

---

#### MEDIUM — `UpdateRole` and `CreateUser` accept arbitrary role strings; no validation

**Functions:** `UpdateRole` (~line 193), `CreateUser` (~line 149)

`CreateUser` passes `role` directly into SQL with no validation. SQLite's CHECK constraint will reject bad values at the DB layer, but the error returned will be an opaque `UNIQUE constraint failed` / `CHECK constraint failed` string rather than a clear application error. `UpdateRole` has the same gap. Both should validate against `{"admin","member","viewer"}` before touching the DB, mirroring the pattern used in `CreateInvite` and `GrantHostAccess`.

---

#### MEDIUM — `RevokeHostAccess` silently succeeds when row does not exist

**Function:** `RevokeHostAccess` (~line 228)

`RevokeInvite` correctly checks `RowsAffected()` and returns an error on no-op. `RevokeHostAccess` does not — it silently returns `nil` whether it deleted a row or not. This is inconsistent and means callers cannot distinguish "revoked successfully" from "that grant never existed". Consider returning `errors.New("host_acl entry not found")` when `RowsAffected() == 0`, or at minimum document the silent-success behaviour.

---

#### MEDIUM — `ListActiveInvites` silently swallows JSON unmarshal errors

**Function:** `ListActiveInvites` (~line 291)

```go
_ = json.Unmarshal([]byte(hostAccessJSON), &r.HostAccess)
```

This discards unmarshal errors, leaving `r.HostAccess` as `nil` for any row with malformed JSON. Compare to `GetInviteByToken`, which correctly propagates the error. Both should behave the same. If a malformed row should be skipped rather than abort the whole list, that policy should be explicit and logged.

---

#### MEDIUM — `SeedAdminIfEmpty` has a TOCTOU race

**Function:** `SeedAdminIfEmpty` (~line 212)

The check (`SELECT COUNT(*)`) and the insert (`CreateUser`) are not wrapped in a transaction. Under concurrent startup (two goroutines racing to seed), both could see `count == 0` and both would attempt the insert; the second would get a UNIQUE constraint violation. Since `SetMaxOpenConns(1)` serialises at the connection level this won't cause data corruption, but it will produce a spurious error visible to the caller. The fix is a `BEGIN IMMEDIATE` transaction around the check+insert, or using `INSERT OR IGNORE`.

---

#### MEDIUM — `RedeemInvite`: viewer-role cap only applied for `"viewer"`, not cross-checked against grant levels

**Function:** `RedeemInvite` (~line 348)

The viewer cap logic is:
```go
if role == "viewer" && level != "view" {
    level = "view"
}
```

This is fine as documented, but it is applied silently without any error or warning. A caller who passes a `manage`-level grant for a `viewer` invite will not know it was silently downgraded. Consider returning an error or at least making this explicit in the docstring, so the invite-creation side knows to enforce it there as well.

---

#### LOW — `PRAGMA foreign_keys = ON` is set before `SetMaxOpenConns(1)`

**Function:** `Open` (~lines 32–47)

The PRAGMA is issued, then `SetMaxOpenConns(1)` is set. Because the pool has not yet been constrained to one connection, the PRAGMA fires on one connection from the pool, but at the time of the Exec the pool could hypothetically open a second connection for the migration `tx.Begin()` that does not have foreign_keys enabled. The correct order is: set `MaxOpenConns(1)` first, then issue the PRAGMA. The window for a race here is vanishingly small (single goroutine, no concurrency yet), but the ordering is still semantically wrong.

---

#### LOW — `ListUsers` documents "Password field is empty" but does not zero it

**Function:** `ListUsers` (~line 178)

The comment says the password field is not returned, which is correct — the SELECT does not include `password`. But `User.Password` is a plain `string` zero-value, not a sentinel. Any caller who doesn't notice the comment could pass the returned `*User` to `CheckPassword` and get a silent false rather than an error. A small defensive measure: return `nil` or a named sentinel (or a separate `UserSummary` type without `Password`) to make the omission type-safe.

---

#### LOW — `hashToken` uses SHA-256 with no salt or pepper

**Function:** `hashToken` (~line 261)

For invite tokens, SHA-256 is probably fine if tokens are generated with sufficient entropy (≥128 bits). However, if the invite token format is ever reduced to something shorter (e.g. a 6-digit code), SHA-256 becomes trivially reversible via rainbow table. The comment/docstring should state the assumed minimum token entropy, or the function should enforce it. No code change required if tokens are always random UUIDs/crypto-random bytes, but the assumption should be documented.

---

#### LOW — `migrate()`: `defer tx.Rollback()` after `tx.Commit()` is harmless but noisy

**Function:** `migrate()` (~line 56)

The `defer tx.Rollback()` pattern is idiomatic. It is fine here. But in the v0→v1 path, `version` is updated as a local variable (`version = 1`) only within the `if version < 1` block — which is correct. No issue, just noting the pattern is clean.

---

#### LOW — Test file `db_test.go` uses deprecated role `"user"` in several places

**File:** `db_test.go` (~lines 46, 56, 67, 80)

`TestPasswordHash`, `TestListUsers`, `TestUpdateRole`, `TestResetPassword`, and `TestDeleteUser` all pass `role: "user"` to `CreateUser`. After the v2 migration, `"user"` is not in the CHECK constraint for new rows; it only appears in the intermediate `users_new` table during migration (which allows both for rollback safety). These inserts will fail at the DB CHECK constraint. The tests that use `role: "user"` will silently pass only because errors from `CreateUser` are ignored with `_ =`. If someone fixes the ignored errors in those tests, they will all start failing.

This is the single most actionable test bug: `"user"` should be replaced with `"member"` throughout `db_test.go`.

---

### GOOD THINGS

1. **All SQL parameters are bound via `?` placeholders** throughout. There are zero raw string-concatenated queries — no SQL injection surface anywhere in the file.

2. **`RedeemInvite` is a proper atomic transaction.** All five steps (validate invite, hash password, insert user, insert ACL rows, mark invite used) are wrapped in a single `BEGIN`/`COMMIT` with `defer tx.Rollback()`. This is exactly right.

3. **`migrate()` uses a single transaction for the entire migration.** Schema changes are all-or-nothing, and the `user_version` bump is inside the same transaction, which means a crash mid-migration leaves the DB at the prior version.

4. **`GrantHostAccess` uses `INSERT … ON CONFLICT DO UPDATE`** (upsert) correctly. The conflict target `(host_ip, user_id)` matches the PRIMARY KEY, and all mutable columns (`level`, `granted_by`, `granted_at`) are refreshed atomically.

5. **`GetHostAccess` uses the `ok bool` return idiom** cleanly, converting `sql.ErrNoRows` to `(level="", ok=false, err=nil)` — idiomatic Go, no sentinel strings required by the caller.

6. **`hashToken` stores a hash rather than the raw token**, meaning a DB breach does not expose invite tokens.

7. **Foreign key enforcement** is explicitly enabled with `PRAGMA foreign_keys = ON`, and the schema has correct `ON DELETE CASCADE` (host_acl → users) and `ON DELETE SET NULL` (host_acl.granted_by, invites.created_by). The `MaxOpenConns(1)` rationale is clearly commented.

8. **`InviteRecord` uses `*time.Time` and `*int64`** for nullable columns rather than `sql.NullTime`/`sql.NullInt64` on the public struct, keeping the exported type clean for JSON serialisation.

9. **`TestMigrateRenamesUserToMember`** tests the migration upgrade path by hand-seeding a v1 schema, which is the correct approach and also covers idempotency.

10. **`rows.Err()` is always checked** at the end of every `for rows.Next()` loop. This catches driver-level errors that occur during iteration.

---

### RECOMMENDATIONS

1. **Fix `db_test.go` role strings immediately.** Replace every `"user"` role argument in `db_test.go` with `"member"`. The tests are currently masked by ignored errors and will break as soon as error-checking is added.

2. **Add input validation to `CreateUser` and `UpdateRole`** — a simple `validRole` helper used by all three role-accepting functions (`CreateUser`, `UpdateRole`, `CreateInvite`) keeps the guard in one place.

3. **Mirror `RevokeInvite`'s `RowsAffected` check in `RevokeHostAccess`.** Consistency here prevents silent no-ops from propagating up the call stack as apparent successes.

4. **Fix the `json.Unmarshal` discard in `ListActiveInvites`.** At minimum propagate the error; if skipping malformed rows is desired, log it explicitly.

5. **Add tests for the Host ACL and Invite CRUD paths in `db_test.go`.** Currently zero test coverage exists for `GrantHostAccess`, `RevokeHostAccess`, `GetHostAccess`, `ListHostACL`, `ListUserHostAccess`, `CreateInvite`, `GetInviteByToken`, `ListActiveInvites`, `RevokeInvite`, and `RedeemInvite`. The `RedeemInvite` transaction logic in particular (expiry check, used-at guard, viewer cap, host_acl inserts) is complex enough to warrant table-driven tests.

6. **Swap the order in `Open`: `SetMaxOpenConns(1)` before `PRAGMA foreign_keys = ON`** to make the connection-scoping intent explicit and eliminate the theoretical race window.

7. **Document the minimum token entropy requirement for `hashToken`** (or add an `if len(rawToken) < N` guard) so the security assumption is self-enforcing.

8. **Consider a `UserSummary` type** (without `Password`) for `ListUsers`'s return, eliminating the silent partial-zero `User` struct footgun.