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
+}