~bigbes/sr-ht-dolt

6efd2748c1c096073689f81cd4cde9829e82532c — Eugene Blikh 30 days ago 19645e7
db: postgres layer for repos, ACLs, dolt keys

Store wraps a Querier (*sql.DB/*sql.Tx/*sql.Conn); context-first methods, FromContext for core-go middleware, WithTx for the create-repo transaction. Repo CRUD + listing-visibility rules, effective-access/ACL upsert, dolt_key CRUD with typed ErrNotFound/ErrNameTaken/ErrKeyExists. Tests gated on DOLTSRHT_TEST_PG: per-run scratch schema + schema.sql, CRUD/visibility/effective-access cases; skip when unset.
8 files changed, 1041 insertions(+), 0 deletions(-)

A db/access.go
A db/access_test.go
A db/db_test.go
A db/keys.go
A db/keys_test.go
A db/repos.go
A db/repos_test.go
A db/store.go
A db/access.go => db/access.go +103 -0
@@ 0,0 1,103 @@
package db

import (
	"context"
	"database/sql"
	"errors"
	"fmt"
	"time"

	"go.bigb.es/sourcehut-dolt/core"
)

// ACLEntry is one row of the access table, joined with the grantee's username
// for display in the settings UI.
type ACLEntry struct {
	ID       int
	RepoID   int
	UserID   int
	Username string
	Mode     core.AccessMode
	Created  time.Time
	Updated  time.Time
}

// EffectiveAccess returns the ACL grant a user holds on a repository, or nil if
// the user has no access entry. This is exactly the aclMode input that
// core.Allowed expects: it reflects only explicit ACL grants, never ownership or
// visibility (those are the caller's to combine via core.Allowed). A nil result
// with a nil error means "no grant", which is not an error condition.
func (s *Store) EffectiveAccess(ctx context.Context, userID, repoID int) (*core.AccessMode, error) {
	const q = `SELECT mode FROM access WHERE user_id = $1 AND repo_id = $2`
	var mode string
	err := s.q.QueryRowContext(ctx, q, userID, repoID).Scan(&mode)
	if errors.Is(err, sql.ErrNoRows) {
		return nil, nil
	}
	if err != nil {
		return nil, fmt.Errorf("effective access user=%d repo=%d: %w", userID, repoID, err)
	}
	m := core.AccessMode(mode)
	return &m, nil
}

// ListACL returns every access entry for a repository, ordered by username, with
// the grantee's username resolved for display.
func (s *Store) ListACL(ctx context.Context, repoID int) ([]*ACLEntry, error) {
	const q = `
SELECT a.id, a.repo_id, a.user_id, COALESCE(u.username, ''), a.mode, a.created, a.updated
FROM access a
JOIN "user" u ON u.id = a.user_id
WHERE a.repo_id = $1
ORDER BY u.username ASC, a.id ASC`
	rows, err := s.q.QueryContext(ctx, q, repoID)
	if err != nil {
		return nil, fmt.Errorf("list acl repo=%d: %w", repoID, err)
	}
	defer rows.Close()
	var entries []*ACLEntry
	for rows.Next() {
		var (
			e    ACLEntry
			mode string
		)
		if err := rows.Scan(&e.ID, &e.RepoID, &e.UserID, &e.Username,
			&mode, &e.Created, &e.Updated); err != nil {
			return nil, fmt.Errorf("scan acl: %w", err)
		}
		e.Mode = core.AccessMode(mode)
		entries = append(entries, &e)
	}
	if err := rows.Err(); err != nil {
		return nil, fmt.Errorf("iterate acl: %w", err)
	}
	return entries, nil
}

// UpsertACL grants (or updates) userID's access mode on repoID. If a grant
// already exists it is updated in place (and updated is bumped); otherwise a new
// row is inserted.
func (s *Store) UpsertACL(ctx context.Context, repoID, userID int, mode core.AccessMode) error {
	now := time.Now().UTC()
	const q = `
INSERT INTO access (created, updated, repo_id, user_id, mode)
VALUES ($1, $1, $2, $3, $4)
ON CONFLICT ON CONSTRAINT uq_access_user_id_repo_id
DO UPDATE SET mode = EXCLUDED.mode, updated = EXCLUDED.updated`
	_, err := s.q.ExecContext(ctx, q, now, repoID, userID, string(mode))
	if err != nil {
		return fmt.Errorf("upsert acl repo=%d user=%d: %w", repoID, userID, err)
	}
	return nil
}

// DeleteACL revokes userID's access on repoID. Returns ErrNotFound if no grant
// existed.
func (s *Store) DeleteACL(ctx context.Context, repoID, userID int) error {
	res, err := s.q.ExecContext(ctx,
		`DELETE FROM access WHERE repo_id = $1 AND user_id = $2`, repoID, userID)
	if err != nil {
		return fmt.Errorf("delete acl repo=%d user=%d: %w", repoID, userID, err)
	}
	return requireOne(res, "delete acl")
}

A db/access_test.go => db/access_test.go +78 -0
@@ 0,0 1,78 @@
package db

import (
	"context"
	"errors"
	"testing"

	"go.bigb.es/sourcehut-dolt/core"
)

func TestEffectiveAccessAndACL(t *testing.T) {
	s, db, cleanup := newTestStore(t)
	defer cleanup()
	ctx := context.Background()

	owner := insertUser(t, db, 1, "alice", core.UserTypeUser)
	grantee := insertUser(t, db, 2, "bob", core.UserTypeUser)
	repo := mkRepo(t, s, ctx, owner, "alice", "widgets", core.VisibilityPrivate)

	// No grant yet: nil, nil.
	mode, err := s.EffectiveAccess(ctx, grantee, repo.ID)
	if err != nil {
		t.Fatalf("effective access: %v", err)
	}
	if mode != nil {
		t.Fatalf("expected nil access, got %v", *mode)
	}

	// Grant RO, then verify.
	if err := s.UpsertACL(ctx, repo.ID, grantee, core.AccessRO); err != nil {
		t.Fatalf("upsert RO: %v", err)
	}
	mode, err = s.EffectiveAccess(ctx, grantee, repo.ID)
	if err != nil {
		t.Fatalf("effective access after grant: %v", err)
	}
	if mode == nil || *mode != core.AccessRO {
		t.Fatalf("expected RO, got %v", mode)
	}

	// Upsert to RW updates in place.
	if err := s.UpsertACL(ctx, repo.ID, grantee, core.AccessRW); err != nil {
		t.Fatalf("upsert RW: %v", err)
	}
	mode, err = s.EffectiveAccess(ctx, grantee, repo.ID)
	if err != nil {
		t.Fatalf("effective access after update: %v", err)
	}
	if mode == nil || *mode != core.AccessRW {
		t.Fatalf("expected RW, got %v", mode)
	}

	// ListACL resolves the username.
	entries, err := s.ListACL(ctx, repo.ID)
	if err != nil {
		t.Fatalf("list acl: %v", err)
	}
	if len(entries) != 1 || entries[0].Username != "bob" || entries[0].Mode != core.AccessRW {
		t.Fatalf("unexpected acl entries: %+v", entries)
	}

	// DeleteACL revokes; effective access returns to nil.
	if err := s.DeleteACL(ctx, repo.ID, grantee); err != nil {
		t.Fatalf("delete acl: %v", err)
	}
	mode, err = s.EffectiveAccess(ctx, grantee, repo.ID)
	if err != nil {
		t.Fatalf("effective access after delete: %v", err)
	}
	if mode != nil {
		t.Fatalf("expected nil after delete, got %v", *mode)
	}

	// Deleting again is ErrNotFound.
	if err := s.DeleteACL(ctx, repo.ID, grantee); !errors.Is(err, ErrNotFound) {
		t.Fatalf("expected ErrNotFound, got %v", err)
	}
}

A db/db_test.go => db/db_test.go +146 -0
@@ 0,0 1,146 @@
package db

import (
	"context"
	"crypto/rand"
	"database/sql"
	"encoding/hex"
	"net/url"
	"os"
	"strings"
	"testing"
	"time"

	_ "github.com/lib/pq"

	"go.bigb.es/sourcehut-dolt/core"
)

// testEnv names the DSN env var that gates the Postgres-backed tests. When
// unset, every test in this package skips with a clear message; the package
// still compiles and its skip path is exercised.
const testEnv = "DOLTSRHT_TEST_PG"

// newTestStore connects to the Postgres pointed at by DOLTSRHT_TEST_PG, creates
// an isolated scratch schema (doltsrht_test_<random>), applies schema.sql into
// it, and returns a Store bound to a pool whose search_path is that schema. The
// returned cleanup drops the schema and closes the pools. If the env var is
// unset the test is skipped.
//
// Isolation is achieved with a per-test schema rather than a whole database so
// no admin/CREATE DATABASE privilege is required and cleanup is a single
// DROP SCHEMA ... CASCADE. The scratch pool routes every connection to that
// schema via lib/pq's `options=-c search_path=...` startup parameter.
func newTestStore(t *testing.T) (*Store, *sql.DB, func()) {
	t.Helper()
	base := os.Getenv(testEnv)
	if base == "" {
		t.Skipf("%s not set; skipping Postgres-backed test (set it to a DSN to run)", testEnv)
	}

	admin, err := sql.Open("postgres", base)
	if err != nil {
		t.Fatalf("open admin pool: %v", err)
	}
	if err := admin.Ping(); err != nil {
		admin.Close()
		t.Fatalf("ping %s: %v", testEnv, err)
	}

	schema := "doltsrht_test_" + randToken()
	if _, err := admin.Exec(`CREATE SCHEMA "` + schema + `"`); err != nil {
		admin.Close()
		t.Fatalf("create schema %s: %v", schema, err)
	}

	scopedDSN, err := withSearchPath(base, schema)
	if err != nil {
		admin.Exec(`DROP SCHEMA "` + schema + `" CASCADE`)
		admin.Close()
		t.Fatalf("build scoped dsn: %v", err)
	}
	pool, err := sql.Open("postgres", scopedDSN)
	if err != nil {
		admin.Exec(`DROP SCHEMA "` + schema + `" CASCADE`)
		admin.Close()
		t.Fatalf("open scoped pool: %v", err)
	}

	ddl, err := os.ReadFile("../schema.sql")
	if err != nil {
		pool.Close()
		admin.Exec(`DROP SCHEMA "` + schema + `" CASCADE`)
		admin.Close()
		t.Fatalf("read schema.sql: %v", err)
	}
	if _, err := pool.Exec(string(ddl)); err != nil {
		pool.Close()
		admin.Exec(`DROP SCHEMA "` + schema + `" CASCADE`)
		admin.Close()
		t.Fatalf("apply schema.sql: %v", err)
	}

	cleanup := func() {
		pool.Close()
		if _, err := admin.Exec(`DROP SCHEMA "` + schema + `" CASCADE`); err != nil {
			t.Errorf("drop schema %s: %v", schema, err)
		}
		admin.Close()
	}
	return NewStore(pool), pool, cleanup
}

// withSearchPath returns base with a connection option that pins search_path to
// schema for every pooled connection, handling both URL and keyword DSN forms.
func withSearchPath(base, schema string) (string, error) {
	opt := "-c search_path=" + schema
	if strings.Contains(base, "://") {
		u, err := url.Parse(base)
		if err != nil {
			return "", err
		}
		q := u.Query()
		q.Set("options", opt)
		u.RawQuery = q.Encode()
		return u.String(), nil
	}
	return base + " options='" + opt + "'", nil
}

func randToken() string {
	b := make([]byte, 8)
	if _, err := rand.Read(b); err != nil {
		panic(err)
	}
	return hex.EncodeToString(b)
}

// insertUser inserts a mirror user row and returns its id.
func insertUser(t *testing.T, db *sql.DB, id int, username string, ut core.UserType) int {
	t.Helper()
	now := time.Now().UTC()
	_, err := db.Exec(`
INSERT INTO "user" (id, username, created, updated, email, user_type)
VALUES ($1, $2, $3, $3, $4, $5)`,
		id, username, now, username+"@example.test", string(ut))
	if err != nil {
		t.Fatalf("insert user %s: %v", username, err)
	}
	return id
}

// mkRepo is a convenience for CreateRepo in tests.
func mkRepo(t *testing.T, s *Store, ctx context.Context, ownerID int, ownerName, name string, vis core.Visibility) *core.Repo {
	t.Helper()
	repo, err := s.CreateRepo(ctx, &core.Repo{
		Name:       name,
		OwnerID:    ownerID,
		OwnerName:  ownerName,
		Path:       "/var/lib/dolt/~" + ownerName + "/" + name,
		Visibility: vis,
	})
	if err != nil {
		t.Fatalf("create repo %s: %v", name, err)
	}
	return repo
}

A db/keys.go => db/keys.go +152 -0
@@ 0,0 1,152 @@
package db

import (
	"context"
	"database/sql"
	"errors"
	"fmt"
	"time"

	"github.com/lib/pq"

	"go.bigb.es/sourcehut-dolt/core"
)

// DoltKey is a registered Ed25519 credential (dolt creds / dolt login). PubKey
// is the raw 32-byte public key; Comment and LastUsed are nullable in the
// schema and default to "" / nil.
type DoltKey struct {
	ID       int
	UserID   int
	KID      string
	PubKey   []byte
	Comment  string
	Created  time.Time
	LastUsed *time.Time
}

// KeyAuth is everything the Bearer-JWT verifier needs to authenticate a dolt
// keypair request: the stored public key to verify the JWS signature, plus the
// owning user's identity to build a core.Caller. Suspended is derived by the
// caller from UserType == core.UserTypeSuspended.
type KeyAuth struct {
	KeyID    int
	UserID   int
	Username string
	UserType core.UserType
	PubKey   []byte
}

// InsertKey registers a dolt key for a user. kid is base32(SHA-512/224(pubkey))
// in dolt's alphabet; pubkey is the raw 32-byte Ed25519 public key. A duplicate
// kid (dolt_key.kid UNIQUE) is mapped to ErrKeyExists. Returns the created row.
func (s *Store) InsertKey(ctx context.Context, userID int, kid string, pubkey []byte, comment string) (*DoltKey, error) {
	now := time.Now().UTC()
	const q = `
INSERT INTO dolt_key (created, user_id, kid, pubkey, comment)
VALUES ($1, $2, $3, $4, $5)
RETURNING id`
	var cmt any
	if comment != "" {
		cmt = comment
	}
	var id int
	err := s.q.QueryRowContext(ctx, q, now, userID, kid, pubkey, cmt).Scan(&id)
	if err != nil {
		var pqErr *pq.Error
		if errors.As(err, &pqErr) && pqErr.Code == "23505" {
			return nil, ErrKeyExists
		}
		return nil, fmt.Errorf("insert dolt key: %w", err)
	}
	return &DoltKey{
		ID:      id,
		UserID:  userID,
		KID:     kid,
		PubKey:  pubkey,
		Comment: comment,
		Created: now,
	}, nil
}

// KeyByKID resolves a key by its kid and returns the public key together with
// the owning user's identity, for authentication. Returns ErrNotFound if no key
// with that kid is registered.
func (s *Store) KeyByKID(ctx context.Context, kid string) (*KeyAuth, error) {
	const q = `
SELECT k.id, k.pubkey, u.id, COALESCE(u.username, ''), u.user_type
FROM dolt_key k
JOIN "user" u ON u.id = k.user_id
WHERE k.kid = $1`
	var (
		ka       KeyAuth
		userType string
	)
	err := s.q.QueryRowContext(ctx, q, kid).Scan(
		&ka.KeyID, &ka.PubKey, &ka.UserID, &ka.Username, &userType)
	if errors.Is(err, sql.ErrNoRows) {
		return nil, ErrNotFound
	}
	if err != nil {
		return nil, fmt.Errorf("key by kid %s: %w", kid, err)
	}
	ka.UserType = core.UserType(userType)
	return &ka, nil
}

// ListKeysByUser returns all of a user's registered dolt keys, newest first.
func (s *Store) ListKeysByUser(ctx context.Context, userID int) ([]*DoltKey, error) {
	const q = `
SELECT id, user_id, kid, pubkey, COALESCE(comment, ''), created, last_used
FROM dolt_key
WHERE user_id = $1
ORDER BY created DESC, id DESC`
	rows, err := s.q.QueryContext(ctx, q, userID)
	if err != nil {
		return nil, fmt.Errorf("list keys user=%d: %w", userID, err)
	}
	defer rows.Close()
	var keys []*DoltKey
	for rows.Next() {
		var (
			k        DoltKey
			lastUsed sql.NullTime
		)
		if err := rows.Scan(&k.ID, &k.UserID, &k.KID, &k.PubKey,
			&k.Comment, &k.Created, &lastUsed); err != nil {
			return nil, fmt.Errorf("scan key: %w", err)
		}
		if lastUsed.Valid {
			t := lastUsed.Time
			k.LastUsed = &t
		}
		keys = append(keys, &k)
	}
	if err := rows.Err(); err != nil {
		return nil, fmt.Errorf("iterate keys: %w", err)
	}
	return keys, nil
}

// DeleteKey removes one of a user's keys. It is scoped by userID so a user can
// only delete keys they own; a mismatch (or missing id) yields ErrNotFound.
func (s *Store) DeleteKey(ctx context.Context, id, userID int) error {
	res, err := s.q.ExecContext(ctx,
		`DELETE FROM dolt_key WHERE id = $1 AND user_id = $2`, id, userID)
	if err != nil {
		return fmt.Errorf("delete key %d: %w", id, err)
	}
	return requireOne(res, "delete key")
}

// TouchKeyLastUsed stamps a key's last_used with the current time, called after
// a successful keypair authentication. Returns ErrNotFound if the kid vanished
// (e.g. the key was deleted concurrently).
func (s *Store) TouchKeyLastUsed(ctx context.Context, kid string) error {
	res, err := s.q.ExecContext(ctx,
		`UPDATE dolt_key SET last_used = $2 WHERE kid = $1`, kid, time.Now().UTC())
	if err != nil {
		return fmt.Errorf("touch key %s: %w", kid, err)
	}
	return requireOne(res, "touch key")
}

A db/keys_test.go => db/keys_test.go +81 -0
@@ 0,0 1,81 @@
package db

import (
	"bytes"
	"context"
	"errors"
	"testing"

	"go.bigb.es/sourcehut-dolt/core"
)

func TestDoltKeyLifecycle(t *testing.T) {
	s, db, cleanup := newTestStore(t)
	defer cleanup()
	ctx := context.Background()

	uid := insertUser(t, db, 1, "alice", core.UserTypeUser)
	pub := bytes.Repeat([]byte{0xab}, 32)

	key, err := s.InsertKey(ctx, uid, "kid-one", pub, "laptop")
	if err != nil {
		t.Fatalf("insert key: %v", err)
	}
	if key.ID == 0 {
		t.Fatal("expected non-zero key id")
	}

	// Duplicate kid → ErrKeyExists.
	if _, err := s.InsertKey(ctx, uid, "kid-one", pub, "dup"); !errors.Is(err, ErrKeyExists) {
		t.Fatalf("expected ErrKeyExists, got %v", err)
	}

	// KeyByKID returns pubkey + user identity for authn.
	ka, err := s.KeyByKID(ctx, "kid-one")
	if err != nil {
		t.Fatalf("key by kid: %v", err)
	}
	if ka.UserID != uid || ka.Username != "alice" || ka.UserType != core.UserTypeUser {
		t.Fatalf("unexpected key auth identity: %+v", ka)
	}
	if !bytes.Equal(ka.PubKey, pub) {
		t.Fatalf("pubkey mismatch: %x", ka.PubKey)
	}

	// Missing kid → ErrNotFound.
	if _, err := s.KeyByKID(ctx, "absent"); !errors.Is(err, ErrNotFound) {
		t.Fatalf("expected ErrNotFound, got %v", err)
	}

	// TouchKeyLastUsed sets last_used.
	if err := s.TouchKeyLastUsed(ctx, "kid-one"); err != nil {
		t.Fatalf("touch: %v", err)
	}
	keys, err := s.ListKeysByUser(ctx, uid)
	if err != nil {
		t.Fatalf("list keys: %v", err)
	}
	if len(keys) != 1 || keys[0].Comment != "laptop" || keys[0].LastUsed == nil {
		t.Fatalf("unexpected keys after touch: %+v", keys)
	}

	// Touching an absent kid → ErrNotFound.
	if err := s.TouchKeyLastUsed(ctx, "absent"); !errors.Is(err, ErrNotFound) {
		t.Fatalf("expected ErrNotFound touching absent, got %v", err)
	}

	// DeleteKey is scoped by user: wrong user cannot delete.
	if err := s.DeleteKey(ctx, key.ID, 999); !errors.Is(err, ErrNotFound) {
		t.Fatalf("expected ErrNotFound deleting other user's key, got %v", err)
	}
	if err := s.DeleteKey(ctx, key.ID, uid); err != nil {
		t.Fatalf("delete own key: %v", err)
	}
	keys, err = s.ListKeysByUser(ctx, uid)
	if err != nil {
		t.Fatalf("list after delete: %v", err)
	}
	if len(keys) != 0 {
		t.Fatalf("expected no keys after delete, got %d", len(keys))
	}
}

A db/repos.go => db/repos.go +197 -0
@@ 0,0 1,197 @@
package db

import (
	"context"
	"database/sql"
	"errors"
	"fmt"
	"time"

	"github.com/lib/pq"

	"go.bigb.es/sourcehut-dolt/core"
)

// repoSelect is the common projection used by every repository read. It joins
// "user" to resolve the owner's username (core.Repo.OwnerName). description is
// nullable in the schema, so it is coalesced to the empty string.
const repoSelect = `
SELECT r.id, r.name, COALESCE(r.description, ''), r.owner_id,
       COALESCE(u.username, ''), r.path, r.visibility
FROM repository r
JOIN "user" u ON u.id = r.owner_id`

func scanRepo(sc rowScanner) (*core.Repo, error) {
	var (
		r          core.Repo
		visibility string
	)
	if err := sc.Scan(&r.ID, &r.Name, &r.Description, &r.OwnerID,
		&r.OwnerName, &r.Path, &visibility); err != nil {
		return nil, err
	}
	r.Visibility = core.Visibility(visibility)
	return &r, nil
}

// CreateRepo inserts a new repository row. It is normally run on a transaction
// (Store.WithTx) so the caller can create the on-disk NBS store in the same
// unit of work and roll both back on failure. r.Name, r.OwnerID, r.Path and
// r.Visibility must be set; ID, created and updated are assigned here and the
// populated repo is returned (with OwnerName carried through from the input,
// which the caller already knows). A uq_repo_owner_id_name violation is mapped
// to ErrNameTaken; any other unique violation (e.g. duplicate path) is returned
// unwrapped so the caller sees the true cause.
func (s *Store) CreateRepo(ctx context.Context, r *core.Repo) (*core.Repo, error) {
	now := time.Now().UTC()
	const q = `
INSERT INTO repository (created, updated, name, description, owner_id, path, visibility)
VALUES ($1, $1, $2, $3, $4, $5, $6)
RETURNING id`
	var desc any
	if r.Description != "" {
		desc = r.Description
	}
	var id int
	err := s.q.QueryRowContext(ctx, q,
		now, r.Name, desc, r.OwnerID, r.Path, string(r.Visibility)).Scan(&id)
	if err != nil {
		var pqErr *pq.Error
		if errors.As(err, &pqErr) && pqErr.Code == "23505" &&
			pqErr.Constraint == "uq_repo_owner_id_name" {
			return nil, ErrNameTaken
		}
		return nil, fmt.Errorf("insert repository: %w", err)
	}
	out := *r
	out.ID = id
	return &out, nil
}

// GetRepoByOwnerAndName resolves a repository by its owner's username and name.
// Returns ErrNotFound if no such repository exists.
func (s *Store) GetRepoByOwnerAndName(ctx context.Context, ownerUsername, name string) (*core.Repo, error) {
	q := repoSelect + `
WHERE u.username = $1 AND r.name = $2`
	repo, err := scanRepo(s.q.QueryRowContext(ctx, q, ownerUsername, name))
	if errors.Is(err, sql.ErrNoRows) {
		return nil, ErrNotFound
	}
	if err != nil {
		return nil, fmt.Errorf("get repo %s/%s: %w", ownerUsername, name, err)
	}
	return repo, nil
}

// GetRepoByID resolves a repository by its primary key. Returns ErrNotFound if
// no such repository exists.
func (s *Store) GetRepoByID(ctx context.Context, id int) (*core.Repo, error) {
	q := repoSelect + `
WHERE r.id = $1`
	repo, err := scanRepo(s.q.QueryRowContext(ctx, q, id))
	if errors.Is(err, sql.ErrNoRows) {
		return nil, ErrNotFound
	}
	if err != nil {
		return nil, fmt.Errorf("get repo %d: %w", id, err)
	}
	return repo, nil
}

// ListReposByOwner lists the repositories owned by ownerUsername that viewer is
// allowed to see, newest first. The listing rule (distinct from clone/browse
// authorization) is:
//
//   - The owner, and any user holding an ACL entry on a repo, always see it
//     regardless of visibility (including PRIVATE and UNLISTED).
//   - Everyone else — including anonymous viewers (viewer == nil) — sees only
//     PUBLIC repositories. UNLISTED repositories are never listed to non-owners
//     without an ACL, and PRIVATE ones are never listed either.
//
// viewer is the browsing principal; pass nil for an anonymous request.
func (s *Store) ListReposByOwner(ctx context.Context, ownerUsername string, viewer *core.Caller) ([]*core.Repo, error) {
	viewerID := 0
	if viewer != nil {
		viewerID = viewer.UserID
	}
	q := repoSelect + `
WHERE u.username = $1 AND (
    r.visibility = 'PUBLIC'
    OR r.owner_id = $2
    OR EXISTS (SELECT 1 FROM access a WHERE a.repo_id = r.id AND a.user_id = $2)
)
ORDER BY r.created DESC, r.id DESC`
	return s.queryRepos(ctx, q, ownerUsername, viewerID)
}

// ListReposForDashboard lists every repository the given user owns or holds an
// ACL entry on, newest first. Used for the signed-in user's dashboard.
func (s *Store) ListReposForDashboard(ctx context.Context, userID int) ([]*core.Repo, error) {
	q := repoSelect + `
WHERE r.owner_id = $1
   OR EXISTS (SELECT 1 FROM access a WHERE a.repo_id = r.id AND a.user_id = $1)
ORDER BY r.created DESC, r.id DESC`
	return s.queryRepos(ctx, q, userID)
}

func (s *Store) queryRepos(ctx context.Context, q string, args ...any) ([]*core.Repo, error) {
	rows, err := s.q.QueryContext(ctx, q, args...)
	if err != nil {
		return nil, fmt.Errorf("list repos: %w", err)
	}
	defer rows.Close()
	var repos []*core.Repo
	for rows.Next() {
		repo, err := scanRepo(rows)
		if err != nil {
			return nil, fmt.Errorf("scan repo: %w", err)
		}
		repos = append(repos, repo)
	}
	if err := rows.Err(); err != nil {
		return nil, fmt.Errorf("iterate repos: %w", err)
	}
	return repos, nil
}

// UpdateRepo updates the mutable repository fields (description and visibility)
// and bumps updated. Returns ErrNotFound if id does not exist.
func (s *Store) UpdateRepo(ctx context.Context, id int, description string, visibility core.Visibility) error {
	const q = `
UPDATE repository
SET description = $2, visibility = $3, updated = $4
WHERE id = $1`
	var desc any
	if description != "" {
		desc = description
	}
	res, err := s.q.ExecContext(ctx, q, id, desc, string(visibility), time.Now().UTC())
	if err != nil {
		return fmt.Errorf("update repo %d: %w", id, err)
	}
	return requireOne(res, "update repo")
}

// DeleteRepo removes a repository row (cascading to its access entries). The
// on-disk store removal is the caller's responsibility. Returns ErrNotFound if
// id does not exist.
func (s *Store) DeleteRepo(ctx context.Context, id int) error {
	res, err := s.q.ExecContext(ctx, `DELETE FROM repository WHERE id = $1`, id)
	if err != nil {
		return fmt.Errorf("delete repo %d: %w", id, err)
	}
	return requireOne(res, "delete repo")
}

// requireOne turns a zero-rows-affected result into ErrNotFound so that missing
// targets surface loudly instead of passing silently.
func requireOne(res sql.Result, what string) error {
	n, err := res.RowsAffected()
	if err != nil {
		return fmt.Errorf("%s: rows affected: %w", what, err)
	}
	if n == 0 {
		return ErrNotFound
	}
	return nil
}

A db/repos_test.go => db/repos_test.go +200 -0
@@ 0,0 1,200 @@
package db

import (
	"context"
	"errors"
	"testing"

	"go.bigb.es/sourcehut-dolt/core"
)

func TestCreateAndGetRepo(t *testing.T) {
	s, db, cleanup := newTestStore(t)
	defer cleanup()
	ctx := context.Background()

	insertUser(t, db, 1, "alice", core.UserTypeUser)
	repo := mkRepo(t, s, ctx, 1, "alice", "widgets", core.VisibilityPublic)
	if repo.ID == 0 {
		t.Fatal("expected non-zero repo id")
	}

	got, err := s.GetRepoByOwnerAndName(ctx, "alice", "widgets")
	if err != nil {
		t.Fatalf("get by owner/name: %v", err)
	}
	if got.ID != repo.ID || got.OwnerName != "alice" || got.Visibility != core.VisibilityPublic {
		t.Fatalf("unexpected repo: %+v", got)
	}

	byID, err := s.GetRepoByID(ctx, repo.ID)
	if err != nil {
		t.Fatalf("get by id: %v", err)
	}
	if byID.Name != "widgets" || byID.OwnerName != "alice" {
		t.Fatalf("unexpected repo by id: %+v", byID)
	}
}

func TestCreateRepoDuplicateName(t *testing.T) {
	s, db, cleanup := newTestStore(t)
	defer cleanup()
	ctx := context.Background()

	insertUser(t, db, 1, "alice", core.UserTypeUser)
	mkRepo(t, s, ctx, 1, "alice", "widgets", core.VisibilityPublic)

	_, err := s.CreateRepo(ctx, &core.Repo{
		Name: "widgets", OwnerID: 1, OwnerName: "alice",
		Path: "/var/lib/dolt/~alice/widgets2", Visibility: core.VisibilityPrivate,
	})
	if !errors.Is(err, ErrNameTaken) {
		t.Fatalf("expected ErrNameTaken, got %v", err)
	}
}

func TestGetRepoNotFound(t *testing.T) {
	s, _, cleanup := newTestStore(t)
	defer cleanup()
	ctx := context.Background()

	if _, err := s.GetRepoByOwnerAndName(ctx, "nobody", "nope"); !errors.Is(err, ErrNotFound) {
		t.Fatalf("expected ErrNotFound, got %v", err)
	}
	if _, err := s.GetRepoByID(ctx, 999); !errors.Is(err, ErrNotFound) {
		t.Fatalf("expected ErrNotFound, got %v", err)
	}
}

func TestUpdateAndDeleteRepo(t *testing.T) {
	s, db, cleanup := newTestStore(t)
	defer cleanup()
	ctx := context.Background()

	insertUser(t, db, 1, "alice", core.UserTypeUser)
	repo := mkRepo(t, s, ctx, 1, "alice", "widgets", core.VisibilityPublic)

	if err := s.UpdateRepo(ctx, repo.ID, "now with docs", core.VisibilityPrivate); err != nil {
		t.Fatalf("update: %v", err)
	}
	got, err := s.GetRepoByID(ctx, repo.ID)
	if err != nil {
		t.Fatalf("get: %v", err)
	}
	if got.Description != "now with docs" || got.Visibility != core.VisibilityPrivate {
		t.Fatalf("update not reflected: %+v", got)
	}

	if err := s.UpdateRepo(ctx, 999, "x", core.VisibilityPublic); !errors.Is(err, ErrNotFound) {
		t.Fatalf("expected ErrNotFound updating missing repo, got %v", err)
	}

	if err := s.DeleteRepo(ctx, repo.ID); err != nil {
		t.Fatalf("delete: %v", err)
	}
	if _, err := s.GetRepoByID(ctx, repo.ID); !errors.Is(err, ErrNotFound) {
		t.Fatalf("expected ErrNotFound after delete, got %v", err)
	}
	if err := s.DeleteRepo(ctx, repo.ID); !errors.Is(err, ErrNotFound) {
		t.Fatalf("expected ErrNotFound deleting twice, got %v", err)
	}
}

// TestListingVisibility exercises the listing rule: owners and ACL holders see
// everything; everyone else (incl. anonymous) sees only PUBLIC; UNLISTED is
// never listed to non-owners without an ACL.
func TestListingVisibility(t *testing.T) {
	s, db, cleanup := newTestStore(t)
	defer cleanup()
	ctx := context.Background()

	owner := insertUser(t, db, 1, "alice", core.UserTypeUser)
	aclUser := insertUser(t, db, 2, "bob", core.UserTypeUser)
	stranger := insertUser(t, db, 3, "carol", core.UserTypeUser)

	mkRepo(t, s, ctx, owner, "alice", "pub", core.VisibilityPublic)
	mkRepo(t, s, ctx, owner, "alice", "unl", core.VisibilityUnlisted)
	priv := mkRepo(t, s, ctx, owner, "alice", "priv", core.VisibilityPrivate)

	// bob gets RO on the private repo.
	if err := s.UpsertACL(ctx, priv.ID, aclUser, core.AccessRO); err != nil {
		t.Fatalf("grant acl: %v", err)
	}

	names := func(repos []*core.Repo) map[string]bool {
		m := map[string]bool{}
		for _, r := range repos {
			m[r.Name] = true
		}
		return m
	}

	// Owner sees all three.
	ownerCaller := &core.Caller{UserID: owner, Username: "alice", UserType: core.UserTypeUser}
	got, err := s.ListReposByOwner(ctx, "alice", ownerCaller)
	if err != nil {
		t.Fatalf("list owner: %v", err)
	}
	if n := names(got); !n["pub"] || !n["unl"] || !n["priv"] || len(got) != 3 {
		t.Fatalf("owner should see all three, got %v", n)
	}

	// Anonymous sees only PUBLIC.
	got, err = s.ListReposByOwner(ctx, "alice", nil)
	if err != nil {
		t.Fatalf("list anon: %v", err)
	}
	if n := names(got); !n["pub"] || n["unl"] || n["priv"] || len(got) != 1 {
		t.Fatalf("anon should see only pub, got %v", n)
	}

	// Stranger (no ACL) sees only PUBLIC.
	strangerCaller := &core.Caller{UserID: stranger, Username: "carol", UserType: core.UserTypeUser}
	got, err = s.ListReposByOwner(ctx, "alice", strangerCaller)
	if err != nil {
		t.Fatalf("list stranger: %v", err)
	}
	if n := names(got); !n["pub"] || n["unl"] || n["priv"] || len(got) != 1 {
		t.Fatalf("stranger should see only pub, got %v", n)
	}

	// ACL holder sees PUBLIC plus the private repo they were granted (not UNLISTED).
	aclCaller := &core.Caller{UserID: aclUser, Username: "bob", UserType: core.UserTypeUser}
	got, err = s.ListReposByOwner(ctx, "alice", aclCaller)
	if err != nil {
		t.Fatalf("list acl user: %v", err)
	}
	if n := names(got); !n["pub"] || !n["priv"] || n["unl"] || len(got) != 2 {
		t.Fatalf("acl user should see pub+priv, got %v", n)
	}
}

func TestListReposForDashboard(t *testing.T) {
	s, db, cleanup := newTestStore(t)
	defer cleanup()
	ctx := context.Background()

	owner := insertUser(t, db, 1, "alice", core.UserTypeUser)
	other := insertUser(t, db, 2, "bob", core.UserTypeUser)

	own := mkRepo(t, s, ctx, owner, "alice", "mine", core.VisibilityPrivate)
	shared := mkRepo(t, s, ctx, other, "bob", "shared", core.VisibilityPrivate)
	mkRepo(t, s, ctx, other, "bob", "hidden", core.VisibilityPrivate)

	if err := s.UpsertACL(ctx, shared.ID, owner, core.AccessRW); err != nil {
		t.Fatalf("grant: %v", err)
	}

	got, err := s.ListReposForDashboard(ctx, owner)
	if err != nil {
		t.Fatalf("dashboard: %v", err)
	}
	found := map[string]bool{}
	for _, r := range got {
		found[r.Name] = true
	}
	if !found["mine"] || !found["shared"] || found["hidden"] || len(got) != 2 {
		t.Fatalf("dashboard should list own+acl only, got %v", found)
	}
	_ = own
}

A db/store.go => db/store.go +84 -0
@@ 0,0 1,84 @@
// Package db is the PostgreSQL persistence layer for dolt.sr.ht. It maps the
// repository, access (ACL) and dolt_key tables to core value types with plain
// database/sql and $n placeholders (no ORM), and enforces the listing/effective
// -access rules that the pure core.Allowed matrix cannot express in SQL.
//
// Design: a Store wraps a Querier — an interface satisfied by *sql.DB, *sql.Tx
// and *sql.Conn alike. This gives us two things the task requires at once:
//
//   - Context-first, middleware-compatible signatures. Production callers build
//     a Store from the connection that core-go's database middleware injects
//     into the request context (see FromContext, which reads the same *sql.DB
//     that database.Middleware installed). Every method takes ctx first and
//     threads it into the query for cancellation.
//
//   - Trivial test injection. Tests call NewStore(db) with a plain *sql.DB, no
//     HTTP context required.
//
// Because the wrapped value is an interface, a Store can be re-bound to an
// open transaction with WithTx(tx): the multi-statement create-repo flow (INSERT
// the row, then write the on-disk NBS store, rolling back both on failure) runs
// every query on the caller's *sql.Tx while sharing the exact same method set.
package db

import (
	"context"
	"database/sql"
	"errors"

	"git.sr.ht/~sircmpwn/core-go/database"
)

// Querier is the common subset of *sql.DB, *sql.Tx and *sql.Conn used by this
// package. Binding a Store to any of them keeps every method identical whether
// it runs standalone (autocommit) or inside a caller-managed transaction.
type Querier interface {
	ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
	QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)
	QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row
}

// Store is the entry point for all queries in this package.
type Store struct {
	q Querier
}

// NewStore builds a Store over a database handle (or any Querier). Tests pass a
// plain *sql.DB; production wiring passes the shared pool.
func NewStore(q Querier) *Store {
	return &Store{q: q}
}

// FromContext builds a Store over the *sql.DB that core-go's database.Middleware
// installed in ctx. It panics (via database.DBForContext) if no database is
// present in the context — a programming error, never a runtime condition to
// recover from. We wrap the pooled *sql.DB rather than checking out a *sql.Conn
// (database.ForContext) so the Store has no connection to leak; the pool manages
// connection lifetime and ctx still bounds each query.
func FromContext(ctx context.Context) *Store {
	return &Store{q: database.DBForContext(ctx)}
}

// WithTx returns a Store that runs every query on tx instead of the pool. Used
// by the repository create flow, which must coordinate the SQL insert with the
// on-disk store creation under one transaction.
func (s *Store) WithTx(tx *sql.Tx) *Store {
	return &Store{q: tx}
}

// Typed errors returned by this package. Callers match them with errors.Is.
var (
	// ErrNotFound is returned when a lookup, update or delete matched no row.
	ErrNotFound = errors.New("db: not found")
	// ErrNameTaken is returned by CreateRepo when the owner already has a
	// repository with the requested name (uq_repo_owner_id_name violation).
	ErrNameTaken = errors.New("db: repository name already taken")
	// ErrKeyExists is returned by InsertKey when the key id (kid) is already
	// registered (dolt_key.kid UNIQUE violation).
	ErrKeyExists = errors.New("db: dolt key already registered")
)

// rowScanner is satisfied by both *sql.Row and *sql.Rows.
type rowScanner interface {
	Scan(dest ...any) error
}