f69f06422af623b37da2ed91a6672c84763fa7dd

Author
Ayman Bagabas <ayman.bagabas@gmail.com>
Committer
Ayman Bagabas <ayman.bagabas@gmail.com>
Date

Message

feat(web): implement git auth and lfs

Generate jwt tokens
Implement git-lfs-authenticate to generate tokens through ssh
Authenticate user using HTTP

fix: git lfs endpoint auth

feat: git lfs locks

Implement git lfs locks endpoints

fix: tests

fix: access tokens migration

add expires_at

fix: lint errors

fix: cleanup

Revert "fix: cleanup"

This reverts commit 728173fbb9594d8ba32e0ca6223be08646e0136a.

fix(db): don't drop tables

Diff

This diff is truncated to protect this page.

   1diff --git a/cmd/soft/hook.go b/cmd/soft/hook.go
   2index fdfbaed46d1b441a5f89f8412a90b2e2464c7b0a..1cbbf511e7d0786e06513f3000a3737230fe7c61 100644
   3--- a/cmd/soft/hook.go
   4+++ b/cmd/soft/hook.go
   5@@ -4,6 +4,7 @@ import (
   6 	"bufio"
   7 	"bytes"
   8 	"context"
   9+	"errors"
  10 	"fmt"
  11 	"io"
  12 	"os"
  13@@ -11,6 +12,7 @@ import (
  14 	"path/filepath"
  15 	"strings"
  16 
  17+	"github.com/charmbracelet/log"
  18 	"github.com/charmbracelet/soft-serve/server/backend"
  19 	"github.com/charmbracelet/soft-serve/server/config"
  20 	"github.com/charmbracelet/soft-serve/server/hooks"
  21@@ -18,16 +20,35 @@ import (
  22 )
  23 
  24 var (
  25+	// ErrInternalServerError indicates that an internal server error occurred.
  26+	ErrInternalServerError = errors.New("internal server error")
  27+
  28 	// Deprecated: this flag is ignored.
  29 	configPath string
  30 
  31 	hookCmd = &cobra.Command{
  32-		Use:                "hook",
  33-		Short:              "Run git server hooks",
  34-		Long:               "Handles Soft Serve git server hooks.",
  35-		Hidden:             true,
  36-		PersistentPreRunE:  initBackendContext,
  37-		PersistentPostRunE: closeDBContext,
  38+		Use:    "hook",
  39+		Short:  "Run git server hooks",
  40+		Long:   "Handles Soft Serve git server hooks.",
  41+		Hidden: true,
  42+		PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
  43+			logger := log.FromContext(cmd.Context())
  44+			if err := initBackendContext(cmd, args); err != nil {
  45+				logger.Error("failed to initialize backend context", "err", err)
  46+				return ErrInternalServerError
  47+			}
  48+
  49+			return nil
  50+		},
  51+		PersistentPostRunE: func(cmd *cobra.Command, args []string) error {
  52+			logger := log.FromContext(cmd.Context())
  53+			if err := closeDBContext(cmd, args); err != nil {
  54+				logger.Error("failed to close backend", "err", err)
  55+				return ErrInternalServerError
  56+			}
  57+
  58+			return nil
  59+		},
  60 	}
  61 
  62 	// Git hooks read the config from the environment, based on
  63diff --git a/cmd/soft/root.go b/cmd/soft/root.go
  64index 7f03982d437c09440c5c65e0932bd3c9af294f9f..9e443b5b39a77ad3028c47ce57c6ddc94146c387 100644
  65--- a/cmd/soft/root.go
  66+++ b/cmd/soft/root.go
  67@@ -2,7 +2,9 @@ package main
  68 
  69 import (
  70 	"context"
  71+	"errors"
  72 	"fmt"
  73+	"io/fs"
  74 	"os"
  75 	"runtime/debug"
  76 	"strings"
  77@@ -146,6 +148,11 @@ func newDefaultLogger(cfg *config.Config) (*log.Logger, *os.File, error) {
  78 func initBackendContext(cmd *cobra.Command, _ []string) error {
  79 	ctx := cmd.Context()
  80 	cfg := config.FromContext(ctx)
  81+	if _, err := os.Stat(cfg.DataPath); errors.Is(err, fs.ErrNotExist) {
  82+		if err := os.MkdirAll(cfg.DataPath, os.ModePerm); err != nil {
  83+			return fmt.Errorf("create data directory: %w", err)
  84+		}
  85+	}
  86 	dbx, err := db.Open(ctx, cfg.DB.Driver, cfg.DB.DataSource)
  87 	if err != nil {
  88 		return fmt.Errorf("open database: %w", err)
  89diff --git a/go.mod b/go.mod
  90index 0a9d11dcdd3532e6ac2c27621cc67f3a1a04a057..7c03a19ecdb9251d50427acff535e87aec4443c3 100644
  91--- a/go.mod
  92+++ b/go.mod
  93@@ -19,12 +19,14 @@ require (
  94 
  95 require (
  96 	github.com/caarlos0/env/v8 v8.0.0
  97-	github.com/charmbracelet/git-lfs-transfer v0.1.1-0.20230721203144-64d90e7a36a1
  98+	github.com/charmbracelet/git-lfs-transfer v0.1.1-0.20230725143853-5dd0632f9245
  99 	github.com/charmbracelet/keygen v0.4.3
 100-	github.com/charmbracelet/log v0.2.3-0.20230713155356-557335e40e35
 101+	github.com/charmbracelet/log v0.2.3-0.20230725142510-280c4e3f1ef2
 102 	github.com/charmbracelet/ssh v0.0.0-20230720143903-5bdd92839155
 103+	github.com/go-jose/go-jose/v3 v3.0.0
 104 	github.com/gobwas/glob v0.2.3
 105 	github.com/gogs/git-module v1.8.2
 106+	github.com/golang-jwt/jwt/v5 v5.0.0
 107 	github.com/hashicorp/golang-lru/v2 v2.0.4
 108 	github.com/jmoiron/sqlx v1.3.5
 109 	github.com/lib/pq v1.10.9
 110@@ -47,6 +49,7 @@ require (
 111 require (
 112 	github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect
 113 	github.com/atotto/clipboard v0.1.4 // indirect
 114+	github.com/avast/retry-go v3.0.0+incompatible // indirect
 115 	github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
 116 	github.com/aymerick/douceur v0.2.0 // indirect
 117 	github.com/beorn7/perks v1.0.1 // indirect
 118@@ -54,7 +57,10 @@ require (
 119 	github.com/cespare/xxhash/v2 v2.2.0 // indirect
 120 	github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 // indirect
 121 	github.com/dlclark/regexp2 v1.4.0 // indirect
 122+	github.com/git-lfs/git-lfs/v3 v3.3.0 // indirect
 123+	github.com/git-lfs/gitobj/v2 v2.1.1 // indirect
 124 	github.com/git-lfs/pktline v0.0.0-20230103162542-ca444d533ef1 // indirect
 125+	github.com/git-lfs/wildmatch/v2 v2.0.1 // indirect
 126 	github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
 127 	github.com/go-logfmt/logfmt v0.6.0 // indirect
 128 	github.com/golang/protobuf v1.5.3 // indirect
 129@@ -62,6 +68,7 @@ require (
 130 	github.com/gorilla/css v1.0.0 // indirect
 131 	github.com/inconshreveable/mousetrap v1.1.0 // indirect
 132 	github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
 133+	github.com/leonelquinteros/gotext v1.5.2 // indirect
 134 	github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
 135 	github.com/mattn/go-isatty v0.0.18 // indirect
 136 	github.com/mattn/go-localereader v0.0.1 // indirect
 137@@ -74,21 +81,22 @@ require (
 138 	github.com/muesli/mango v0.1.0 // indirect
 139 	github.com/muesli/mango-pflag v0.1.0 // indirect
 140 	github.com/olekukonko/tablewriter v0.0.5 // indirect
 141+	github.com/pkg/errors v0.9.1 // indirect
 142 	github.com/prometheus/client_model v0.3.0 // indirect
 143 	github.com/prometheus/common v0.42.0 // indirect
 144 	github.com/prometheus/procfs v0.10.1 // indirect
 145 	github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
 146-	github.com/rivo/uniseg v0.2.0 // indirect
 147+	github.com/rivo/uniseg v0.4.3 // indirect
 148 	github.com/sahilm/fuzzy v0.1.0 // indirect
 149 	github.com/spf13/pflag v1.0.5 // indirect
 150 	github.com/yuin/goldmark v1.5.2 // indirect
 151 	github.com/yuin/goldmark-emoji v1.0.1 // indirect
 152-	golang.org/x/mod v0.9.0 // indirect
 153+	golang.org/x/mod v0.10.0 // indirect
 154 	golang.org/x/net v0.12.0 // indirect
 155 	golang.org/x/sys v0.10.0 // indirect
 156 	golang.org/x/term v0.10.0 // indirect
 157 	golang.org/x/text v0.11.0 // indirect
 158-	golang.org/x/tools v0.6.0 // indirect
 159+	golang.org/x/tools v0.9.1 // indirect
 160 	google.golang.org/protobuf v1.30.0 // indirect
 161 	gopkg.in/warnings.v0 v0.1.2 // indirect
 162 	lukechampine.com/uint128 v1.2.0 // indirect
 163diff --git a/go.sum b/go.sum
 164index 7846d7c62bcc53de401544721db60589731d64ac..bf526c8d886dab318bd9fce79cc26acffa7f9ce7 100644
 165--- a/go.sum
 166+++ b/go.sum
 167@@ -1,9 +1,16 @@
 168+github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
 169+github.com/acomagu/bufpipe v1.0.4/go.mod h1:mxdxdup/WdsKVreO5GpW4+M/1CE2sMG4jeGJ2sYmHc4=
 170 github.com/alecthomas/chroma v0.10.0 h1:7XDcGkCQopCNKjZHfYrNLraA+M7e0fMiJ/Mfikbfjek=
 171 github.com/alecthomas/chroma v0.10.0/go.mod h1:jtJATyUxlIORhUOFNA9NZDWGAQ8wpxQQqNSB4rjA/1s=
 172+github.com/alexbrainman/sspi v0.0.0-20210105120005-909beea2cc74/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
 173 github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
 174 github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
 175+github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
 176 github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
 177 github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
 178+github.com/avast/retry-go v2.4.2+incompatible/go.mod h1:XtSnn+n/sHqQIpZ10K1qAevBhOOCWBLXXy3hyiqqBrY=
 179+github.com/avast/retry-go v3.0.0+incompatible h1:4SOWQ7Qs+oroOTQOYnAHqelpCO0biHSxpiH9JdtuBj0=
 180+github.com/avast/retry-go v3.0.0+incompatible/go.mod h1:XtSnn+n/sHqQIpZ10K1qAevBhOOCWBLXXy3hyiqqBrY=
 181 github.com/aymanbagabas/go-osc52 v1.0.3/go.mod h1:zT8H+Rk4VSabYN90pWyugflM3ZhpTZNC7cASDfUCdT4=
 182 github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
 183 github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
 184@@ -21,8 +28,8 @@ github.com/charmbracelet/bubbles v0.16.1 h1:6uzpAAaT9ZqKssntbvZMlksWHruQLNxg49H5
 185 github.com/charmbracelet/bubbles v0.16.1/go.mod h1:2QCp9LFlEsBQMvIYERr7Ww2H2bA7xen1idUDIzm/+Xc=
 186 github.com/charmbracelet/bubbletea v0.24.2 h1:uaQIKx9Ai6Gdh5zpTbGiWpytMU+CfsPp06RaW2cx/SY=
 187 github.com/charmbracelet/bubbletea v0.24.2/go.mod h1:XdrNrV4J8GiyshTtx3DNuYkR1FDaJmO3l2nejekbsgg=
 188-github.com/charmbracelet/git-lfs-transfer v0.1.1-0.20230721203144-64d90e7a36a1 h1:/QzZzTDdlDYGZeC2O2y/Qw+AiHqh3vCsO4yrKDWXtqs=
 189-github.com/charmbracelet/git-lfs-transfer v0.1.1-0.20230721203144-64d90e7a36a1/go.mod h1:eXJuVicxnjRgRMokmutZdistxoMRjBjjfqvrYq7bCIU=
 190+github.com/charmbracelet/git-lfs-transfer v0.1.1-0.20230725143853-5dd0632f9245 h1:PeGKqKX84IAFhFSWjTyPGiLzzEPcv94C9qKsYBk2nbQ=
 191+github.com/charmbracelet/git-lfs-transfer v0.1.1-0.20230725143853-5dd0632f9245/go.mod h1:eXJuVicxnjRgRMokmutZdistxoMRjBjjfqvrYq7bCIU=
 192 github.com/charmbracelet/glamour v0.6.0 h1:wi8fse3Y7nfcabbbDuwolqTqMQPMnVPeZhDM273bISc=
 193 github.com/charmbracelet/glamour v0.6.0/go.mod h1:taqWV4swIMMbWALc0m7AfE9JkPSU8om2538k9ITBxOc=
 194 github.com/charmbracelet/keygen v0.4.3 h1:ywOZRwkDlpmkawl0BgLTxaYWDSqp6Y4nfVVmgyyO1Mg=
 195@@ -31,6 +38,8 @@ github.com/charmbracelet/lipgloss v0.7.1 h1:17WMwi7N1b1rVWOjMT+rCh7sQkvDU75B2hbZ
 196 github.com/charmbracelet/lipgloss v0.7.1/go.mod h1:yG0k3giv8Qj8edTCbbg6AlQ5e8KNWpFujkNawKNhE2c=
 197 github.com/charmbracelet/log v0.2.3-0.20230713155356-557335e40e35 h1:VXEaJ1iM2L5N8T2WVbv4y631pzCD3O9s75dONqK+87g=
 198 github.com/charmbracelet/log v0.2.3-0.20230713155356-557335e40e35/go.mod h1:ZApwwzDbbETVTIRTk7724yQRJAXIktt98yGVMMaa3y8=
 199+github.com/charmbracelet/log v0.2.3-0.20230725142510-280c4e3f1ef2 h1:0O3FNIElGsbl/nnUpeUVHqET7ZETJz6cUQocn/CKhoU=
 200+github.com/charmbracelet/log v0.2.3-0.20230725142510-280c4e3f1ef2/go.mod h1:ZApwwzDbbETVTIRTk7724yQRJAXIktt98yGVMMaa3y8=
 201 github.com/charmbracelet/ssh v0.0.0-20230720143903-5bdd92839155 h1:vJqYhlL0doAWQPz+EX/hK5x/ZYguoua773oRz77zYKo=
 202 github.com/charmbracelet/ssh v0.0.0-20230720143903-5bdd92839155/go.mod h1:F1vgddWsb/Yr/OZilFeRZEh5sE/qU0Dt1mKkmke6Zvg=
 203 github.com/charmbracelet/wish v1.1.1 h1:KdICASKd2oh2JPvk1Z4CJtAi97cFErXF7NKienPICO4=
 204@@ -43,14 +52,28 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
 205 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 206 github.com/dlclark/regexp2 v1.4.0 h1:F1rxgk7p4uKjwIQxBs9oAXe5CqrXlCduYEJvrF4u93E=
 207 github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
 208+github.com/dpotapov/go-spnego v0.0.0-20210315154721-298b63a54430/go.mod h1:AVSs/gZKt1bOd2AhkhbS7Qh56Hv7klde22yXVbwYJhc=
 209 github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
 210 github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
 211+github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
 212+github.com/git-lfs/git-lfs/v3 v3.3.0 h1:cbRy9akD9/hDD7BaVifyNkWkURwC8RSPLzX9+siS+OE=
 213+github.com/git-lfs/git-lfs/v3 v3.3.0/go.mod h1:5y2vfVQpxUmceMlraOmmaQ83pYptQYCvPl32ybO2IVw=
 214+github.com/git-lfs/gitobj/v2 v2.1.1 h1:tf/VU6zL1kxa3he+nf6FO/syX+LGkm6WGDsMpfuXV7Q=
 215+github.com/git-lfs/gitobj/v2 v2.1.1/go.mod h1:q6aqxl6Uu3gWsip5GEKpw+7459F97er8COmU45ncAxw=
 216+github.com/git-lfs/go-netrc v0.0.0-20210914205454-f0c862dd687a/go.mod h1:70O4NAtvWn1jW8V8V+OKrJJYcxDLTmIozfi2fmSz5SI=
 217+github.com/git-lfs/pktline v0.0.0-20210330133718-06e9096e2825/go.mod h1:fenKRzpXDjNpsIBhuhUzvjCKlDjKam0boRAenTE0Q6A=
 218 github.com/git-lfs/pktline v0.0.0-20230103162542-ca444d533ef1 h1:mtDjlmloH7ytdblogrMz1/8Hqua1y8B4ID+bh3rvod0=
 219 github.com/git-lfs/pktline v0.0.0-20230103162542-ca444d533ef1/go.mod h1:fenKRzpXDjNpsIBhuhUzvjCKlDjKam0boRAenTE0Q6A=
 220+github.com/git-lfs/wildmatch/v2 v2.0.1 h1:Ds+aobrV5bK0wStILUOn9irllPyf9qrFETbKzwzoER8=
 221+github.com/git-lfs/wildmatch/v2 v2.0.1/go.mod h1:EVqonpk9mXbREP3N8UkwoWdrF249uHpCUo5CPXY81gw=
 222+github.com/gliderlabs/ssh v0.3.5/go.mod h1:8XB4KraRrX39qHhT6yxPsHedjA08I/uBVwj4xC+/+z4=
 223 github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
 224 github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
 225+github.com/go-git/go-billy/v5 v5.4.1/go.mod h1:vjbugF6Fz7JIflbVpl1hJsGjSHNltrSw45YK/ukIvQg=
 226 github.com/go-git/go-git/v5 v5.7.0 h1:t9AudWVLmqzlo+4bqdf7GY+46SUuRsx59SboFxkq2aE=
 227 github.com/go-git/go-git/v5 v5.7.0/go.mod h1:coJHKEOk5kUClpsNlXrUvPrDxY3w3gjHvhcZd8Fodw8=
 228+github.com/go-jose/go-jose/v3 v3.0.0 h1:s6rrhirfEP/CGIoc6p+PZAeogN2SxKav6Wp7+dyMWVo=
 229+github.com/go-jose/go-jose/v3 v3.0.0/go.mod h1:RNkWWRld676jZEYoV3+XK8L2ZnNSvIsxFMht0mSX+u8=
 230 github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4=
 231 github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
 232 github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE=
 233@@ -59,32 +82,56 @@ github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
 234 github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
 235 github.com/gogs/git-module v1.8.2 h1:fwvSMjc51d5bBG3Q2OyFF8HTZFDVbETQ+mSAvfXebQw=
 236 github.com/gogs/git-module v1.8.2/go.mod h1:GUSSUH+RM7fZOtjhS6Obh4B9aAvs3EeROpazfMNMF8g=
 237+github.com/golang-jwt/jwt/v5 v5.0.0 h1:1n1XNM9hk7O9mnQoNBGolZvzebBQ7p93ULHRc28XJUE=
 238+github.com/golang-jwt/jwt/v5 v5.0.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
 239 github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
 240 github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk=
 241 github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
 242 github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
 243 github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
 244+github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
 245 github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
 246 github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
 247+github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
 248 github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
 249 github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
 250 github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
 251 github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY=
 252 github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c=
 253+github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
 254+github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
 255+github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
 256 github.com/hashicorp/golang-lru/v2 v2.0.4 h1:7GHuZcgid37q8o5i3QI9KMT4nCWQQ3Kx3Ov6bb9MfK0=
 257 github.com/hashicorp/golang-lru/v2 v2.0.4/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
 258+github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
 259 github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
 260 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
 261+github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
 262+github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
 263+github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM=
 264+github.com/jcmturner/gofork v1.0.0/go.mod h1:MK8+TM0La+2rjBD4jE12Kj1pCCxK7d2LK/UM3ncEo0o=
 265+github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg=
 266+github.com/jcmturner/gokrb5/v8 v8.4.2/go.mod h1:sb+Xq/fTY5yktf/VxLsE3wlfPqQjp0aWNYyvBVK62bc=
 267diff --git a/server/access/context.go b/server/access/context.go
 268new file mode 100644
 269index 0000000000000000000000000000000000000000..3bc08c011eca9066c25442367ecdf5e3e53a2d68
 270--- /dev/null
 271+++ b/server/access/context.go
 272@@ -0,0 +1,20 @@
 273+package access
 274+
 275+import "context"
 276+
 277+// ContextKey is the context key for the access level.
 278+var ContextKey = &struct{ string }{"access"}
 279+
 280+// FromContext returns the access level from the context.
 281+func FromContext(ctx context.Context) AccessLevel {
 282+	if ac, ok := ctx.Value(ContextKey).(AccessLevel); ok {
 283+		return ac
 284+	}
 285+
 286+	return -1
 287+}
 288+
 289+// WithContext returns a new context with the access level.
 290+func WithContext(ctx context.Context, ac AccessLevel) context.Context {
 291+	return context.WithValue(ctx, ContextKey, ac)
 292+}
 293diff --git a/server/backend/auth.go b/server/backend/auth.go
 294new file mode 100644
 295index 0000000000000000000000000000000000000000..62160fccb22b349e5730de8945956123c7b53ee7
 296--- /dev/null
 297+++ b/server/backend/auth.go
 298@@ -0,0 +1,38 @@
 299+package backend
 300+
 301+import (
 302+	"crypto/rand"
 303+	"encoding/hex"
 304+
 305+	"github.com/charmbracelet/log"
 306+	"golang.org/x/crypto/bcrypt"
 307+)
 308+
 309+const saltySalt = "salty-soft-serve"
 310+
 311+// HashPassword hashes the password using bcrypt.
 312+func HashPassword(password string) (string, error) {
 313+	crypt, err := bcrypt.GenerateFromPassword([]byte(password+saltySalt), bcrypt.DefaultCost)
 314+	if err != nil {
 315+		return "", err
 316+	}
 317+
 318+	return string(crypt), nil
 319+}
 320+
 321+// VerifyPassword verifies the password against the hash.
 322+func VerifyPassword(password, hash string) bool {
 323+	err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password+saltySalt))
 324+	return err == nil
 325+}
 326+
 327+// GenerateAccessToken returns a random unique token.
 328+func GenerateAccessToken() string {
 329+	buf := make([]byte, 20)
 330+	if _, err := rand.Read(buf); err != nil {
 331+		log.Error("unable to generate access token")
 332+		return ""
 333+	}
 334+
 335+	return "ss_" + hex.EncodeToString(buf)
 336+}
 337diff --git a/server/backend/collab.go b/server/backend/collab.go
 338index 92bfca821854554fc7e879474da2a21ac2fef8e4..78c5b8a230ab285875953e62aeb273d69eef0381 100644
 339--- a/server/backend/collab.go
 340+++ b/server/backend/collab.go
 341@@ -52,6 +52,10 @@ func (d *Backend) Collaborators(ctx context.Context, repo string) ([]string, err
 342 //
 343 // It implements backend.Backend.
 344 func (d *Backend) IsCollaborator(ctx context.Context, repo string, username string) (bool, error) {
 345+	if username == "" {
 346+		return false, nil
 347+	}
 348+
 349 	repo = utils.SanitizeRepo(repo)
 350 	var m models.Collab
 351 	if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {
 352diff --git a/server/backend/hooks.go b/server/backend/hooks.go
 353index b28c5ecd78bf8a20d30f26cd0b4fe10adda3bae1..fe10432edb74abc72279afb6cdd02d1ef2700ea1 100644
 354--- a/server/backend/hooks.go
 355+++ b/server/backend/hooks.go
 356@@ -63,7 +63,7 @@ func populateLastModified(ctx context.Context, d *Backend, name string) error {
 357 	if r, ok := _rr.(*repo); ok {
 358 		rr = r
 359 	} else {
 360-		return proto.ErrRepoNotExist
 361+		return proto.ErrRepoNotFound
 362 	}
 363 
 364 	r, err := rr.Open()
 365diff --git a/server/backend/lfs.go b/server/backend/lfs.go
 366index dfc21ea69d5eb9193d347b39011591f4fd9c9367..eac557ecf07fee83ba8fef0216a8e1074e36e4e4 100644
 367--- a/server/backend/lfs.go
 368+++ b/server/backend/lfs.go
 369@@ -43,7 +43,8 @@ func StoreRepoMissingLFSObjects(ctx context.Context, repo proto.Repository, dbx
 370 					return db.WrapError(err)
 371 				}
 372 
 373-				return strg.Put(path.Join("objects", p.RelativePath()), content)
 374+				_, err := strg.Put(path.Join("objects", p.RelativePath()), content)
 375+				return err
 376 			})
 377 		})
 378 	}
 379diff --git a/server/backend/repo.go b/server/backend/repo.go
 380index 15eaea6ff72ea919d66ff897633471373a7c6048..840385112d5703055ac7ce73087ae55b959fd9d7 100644
 381--- a/server/backend/repo.go
 382+++ b/server/backend/repo.go
 383@@ -216,7 +216,7 @@ func (d *Backend) RenameRepository(ctx context.Context, oldName string, newName
 384 	op := filepath.Join(d.reposPath(), oldRepo)
 385 	np := filepath.Join(d.reposPath(), newRepo)
 386 	if _, err := os.Stat(op); err != nil {
 387-		return proto.ErrRepoNotExist
 388+		return proto.ErrRepoNotFound
 389 	}
 390 
 391 	if _, err := os.Stat(np); err == nil {
 392@@ -290,14 +290,20 @@ func (d *Backend) Repository(ctx context.Context, name string) (proto.Repository
 393 
 394 	rp := filepath.Join(d.reposPath(), name+".git")
 395 	if _, err := os.Stat(rp); err != nil {
 396-		return nil, os.ErrNotExist
 397+		if !errors.Is(err, fs.ErrNotExist) {
 398+			d.logger.Errorf("failed to stat repository path: %v", err)
 399+		}
 400+		return nil, proto.ErrRepoNotFound
 401 	}
 402 
 403 	if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {
 404 		var err error
 405 		m, err = d.store.GetRepoByName(ctx, tx, name)
 406-		return err
 407+		return db.WrapError(err)
 408 	}); err != nil {
 409+		if errors.Is(err, db.ErrRecordNotFound) {
 410+			return nil, proto.ErrRepoNotFound
 411+		}
 412 		return nil, db.WrapError(err)
 413 	}
 414 
 415diff --git a/server/backend/user.go b/server/backend/user.go
 416index 8b5e2a2aeae549bec6d1f19dec0a5e0d00685bbd..ff07660b0f88056c56e723704e849a1dad6095eb 100644
 417--- a/server/backend/user.go
 418+++ b/server/backend/user.go
 419@@ -2,6 +2,7 @@ package backend
 420 
 421 import (
 422 	"context"
 423+	"errors"
 424 	"strings"
 425 
 426 	"github.com/charmbracelet/soft-serve/server/access"
 427@@ -40,6 +41,7 @@ func (d *Backend) AccessLevelByPublicKey(ctx context.Context, repo string, pk ss
 428 }
 429 
 430 // AccessLevelForUser returns the access level of a user for a repository.
 431+// TODO: user repository ownership
 432 func (d *Backend) AccessLevelForUser(ctx context.Context, repo string, user proto.User) access.AccessLevel {
 433 	var username string
 434 	anon := d.AnonAccess(ctx)
 435@@ -53,7 +55,11 @@ func (d *Backend) AccessLevelForUser(ctx context.Context, repo string, user prot
 436 	}
 437 
 438 	// If the repository exists, check if the user is a collaborator.
 439-	r, _ := d.Repository(ctx, repo)
 440+	r := proto.RepositoryFromContext(ctx)
 441+	if r == nil {
 442+		r, _ = d.Repository(ctx, repo)
 443+	}
 444+
 445 	if r != nil {
 446 		// If the user is a collaborator, they have read/write access.
 447 		isCollab, _ := d.IsCollaborator(ctx, repo, username)
 448@@ -107,7 +113,11 @@ func (d *Backend) User(ctx context.Context, username string) (proto.User, error)
 449 		pks, err = d.store.ListPublicKeysByUserID(ctx, tx, m.ID)
 450 		return err
 451 	}); err != nil {
 452-		return nil, db.WrapError(err)
 453+		err = db.WrapError(err)
 454+		if errors.Is(err, db.ErrRecordNotFound) {
 455+			return nil, proto.ErrUserNotFound
 456+		}
 457+		return nil, err
 458 	}
 459 
 460 	return &user{
 461@@ -126,13 +136,17 @@ func (d *Backend) UserByPublicKey(ctx context.Context, pk ssh.PublicKey) (proto.
 462 		var err error
 463 		m, err = d.store.FindUserByPublicKey(ctx, tx, pk)
 464 		if err != nil {
 465-			return err
 466+			return db.WrapError(err)
 467 		}
 468 
 469 		pks, err = d.store.ListPublicKeysByUserID(ctx, tx, m.ID)
 470 		return err
 471 	}); err != nil {
 472-		return nil, db.WrapError(err)
 473+		err = db.WrapError(err)
 474+		if errors.Is(err, db.ErrRecordNotFound) {
 475+			return nil, proto.ErrUserNotFound
 476+		}
 477+		return nil, err
 478 	}
 479 
 480 	return &user{
 481@@ -276,6 +290,25 @@ func (d *Backend) SetAdmin(ctx context.Context, username string, admin bool) err
 482 	)
 483 }
 484 
 485+// SetPassword sets the password of a user.
 486+func (d *Backend) SetPassword(ctx context.Context, username string, rawPassword string) error {
 487+	username = strings.ToLower(username)
 488+	if err := utils.ValidateUsername(username); err != nil {
 489+		return err
 490+	}
 491+
 492+	password, err := HashPassword(rawPassword)
 493+	if err != nil {
 494+		return err
 495+	}
 496+
 497+	return db.WrapError(
 498+		d.db.TransactionContext(ctx, func(tx *db.Tx) error {
 499+			return d.store.SetUserPasswordByUsername(ctx, tx, username, password)
 500+		}),
 501+	)
 502+}
 503+
 504 type user struct {
 505 	user       models.User
 506 	publicKeys []ssh.PublicKey
 507diff --git a/server/config/ssh.go b/server/config/ssh.go
 508new file mode 100644
 509index 0000000000000000000000000000000000000000..102b391418ac86f1d0f963fc7701591bdd233ce0
 510--- /dev/null
 511+++ b/server/config/ssh.go
 512@@ -0,0 +1,8 @@
 513+package config
 514+
 515+import "github.com/charmbracelet/keygen"
 516+
 517+// KeyPair returns the server's SSH key pair.
 518+func (c SSHConfig) KeyPair() (*keygen.SSHKeyPair, error) {
 519+	return keygen.New(c.KeyPath, keygen.WithKeyType(keygen.Ed25519))
 520+}
 521diff --git a/server/db/errors.go b/server/db/errors.go
 522index f793a0588a94c7c2461da50aaf87060ac724d923..9c19b028261f699da9101ea0b6a7b72ce4164a87 100644
 523--- a/server/db/errors.go
 524+++ b/server/db/errors.go
 525@@ -14,7 +14,7 @@ var (
 526 	ErrDuplicateKey = errors.New("duplicate key value violates table constraint")
 527 
 528 	// ErrRecordNotFound is returned when a record is not found.
 529-	ErrRecordNotFound = errors.New("record not found")
 530+	ErrRecordNotFound = sql.ErrNoRows
 531 )
 532 
 533 // WrapError is a convenient function that unite various database driver
 534diff --git a/server/db/migrate/0001_create_tables_postgres.down.sql b/server/db/migrate/0001_create_tables_postgres.down.sql
 535index 35eeb70dedeae7d862935e287a9ab3516d56b674..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 100644
 536--- a/server/db/migrate/0001_create_tables_postgres.down.sql
 537+++ b/server/db/migrate/0001_create_tables_postgres.down.sql
 538@@ -1,5 +0,0 @@
 539-DROP TABLE IF EXISTS collabs;
 540-DROP TABLE IF EXISTS repos;
 541-DROP TABLE IF EXISTS public_keys;
 542-DROP TABLE IF EXISTS users;
 543-DROP TABLE IF EXISTS settings;
 544diff --git a/server/db/migrate/0001_create_tables_sqlite.down.sql b/server/db/migrate/0001_create_tables_sqlite.down.sql
 545index 35eeb70dedeae7d862935e287a9ab3516d56b674..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 100644
 546--- a/server/db/migrate/0001_create_tables_sqlite.down.sql
 547+++ b/server/db/migrate/0001_create_tables_sqlite.down.sql
 548@@ -1,5 +0,0 @@
 549-DROP TABLE IF EXISTS collabs;
 550-DROP TABLE IF EXISTS repos;
 551-DROP TABLE IF EXISTS public_keys;
 552-DROP TABLE IF EXISTS users;
 553-DROP TABLE IF EXISTS settings;
 554diff --git a/server/db/migrate/0002_create_lfs_tables_postgres.down.sql b/server/db/migrate/0002_create_lfs_tables_postgres.down.sql
 555index bae6ea0cd13f5ccf90114fb2e05146bb822a6658..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 100644
 556--- a/server/db/migrate/0002_create_lfs_tables_postgres.down.sql
 557+++ b/server/db/migrate/0002_create_lfs_tables_postgres.down.sql
 558@@ -1,2 +0,0 @@
 559-DROP TABLE IF EXISTS lfs_locks;
 560-DROP TABLE IF EXISTS lfs_objects;
 561diff --git a/server/db/migrate/0002_create_lfs_tables_postgres.up.sql b/server/db/migrate/0002_create_lfs_tables_postgres.up.sql
 562index fed48900f876e09a5777966c82bf088f41809821..36391d30a3add57cd9c666cfa8e52135797ae8f3 100644
 563--- a/server/db/migrate/0002_create_lfs_tables_postgres.up.sql
 564+++ b/server/db/migrate/0002_create_lfs_tables_postgres.up.sql
 565@@ -3,8 +3,8 @@ CREATE TABLE IF NOT EXISTS lfs_objects (
 566   oid TEXT NOT NULL,
 567   size INTEGER NOT NULL,
 568   repo_id INTEGER NOT NULL,
 569-  created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
 570-  updated_at DATETIME NOT NULL,
 571+  created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
 572+  updated_at TIMESTAMP NOT NULL,
 573   UNIQUE (oid, repo_id),
 574   CONSTRAINT repo_id_fk
 575   FOREIGN KEY(repo_id) REFERENCES repos(id)
 576@@ -18,11 +18,15 @@ CREATE TABLE IF NOT EXISTS lfs_locks (
 577   user_id INTEGER NOT NULL,
 578   path TEXT NOT NULL,
 579   refname TEXT,
 580-  created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
 581-  updated_at DATETIME NOT NULL,
 582+  created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
 583+  updated_at TIMESTAMP NOT NULL,
 584   UNIQUE (repo_id, path),
 585   CONSTRAINT repo_id_fk
 586   FOREIGN KEY(repo_id) REFERENCES repos(id)
 587   ON DELETE CASCADE
 588+  ON UPDATE CASCADE,
 589+  CONSTRAINT user_id_fk
 590+  FOREIGN KEY(user_id) REFERENCES users(id)
 591+  ON DELETE CASCADE
 592   ON UPDATE CASCADE
 593 );
 594diff --git a/server/db/migrate/0002_create_lfs_tables_sqlite.down.sql b/server/db/migrate/0002_create_lfs_tables_sqlite.down.sql
 595index bae6ea0cd13f5ccf90114fb2e05146bb822a6658..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 100644
 596--- a/server/db/migrate/0002_create_lfs_tables_sqlite.down.sql
 597+++ b/server/db/migrate/0002_create_lfs_tables_sqlite.down.sql
 598@@ -1,2 +0,0 @@
 599-DROP TABLE IF EXISTS lfs_locks;
 600-DROP TABLE IF EXISTS lfs_objects;
 601diff --git a/server/db/migrate/0002_create_lfs_tables_sqlite.up.sql b/server/db/migrate/0002_create_lfs_tables_sqlite.up.sql
 602index 0a43d6849340b93cb0783b1c560128ef005cb83c..0fdf70151afc1b4fe083c96c229ac83591dbf215 100644
 603--- a/server/db/migrate/0002_create_lfs_tables_sqlite.up.sql
 604+++ b/server/db/migrate/0002_create_lfs_tables_sqlite.up.sql
 605@@ -24,5 +24,9 @@ CREATE TABLE IF NOT EXISTS lfs_locks (
 606   CONSTRAINT repo_id_fk
 607   FOREIGN KEY(repo_id) REFERENCES repos(id)
 608   ON DELETE CASCADE
 609+  ON UPDATE CASCADE,
 610+  CONSTRAINT user_id_fk
 611+  FOREIGN KEY(user_id) REFERENCES users(id)
 612+  ON DELETE CASCADE
 613   ON UPDATE CASCADE
 614 );
 615diff --git a/server/db/migrate/0003_password_tokens.go b/server/db/migrate/0003_password_tokens.go
 616new file mode 100644
 617index 0000000000000000000000000000000000000000..2bafd57823490d225a3c394d06cbb08daade9b7f
 618--- /dev/null
 619+++ b/server/db/migrate/0003_password_tokens.go
 620@@ -0,0 +1,23 @@
 621+package migrate
 622+
 623+import (
 624+	"context"
 625+
 626+	"github.com/charmbracelet/soft-serve/server/db"
 627+)
 628+
 629+const (
 630+	passwordTokensName    = "password tokens"
 631+	passwordTokensVersion = 3
 632+)
 633+
 634+var passwordTokens = Migration{
 635+	Version: passwordTokensVersion,
 636+	Name:    passwordTokensName,
 637+	Migrate: func(ctx context.Context, tx *db.Tx) error {
 638+		return migrateUp(ctx, tx, passwordTokensVersion, passwordTokensName)
 639+	},
 640+	Rollback: func(ctx context.Context, tx *db.Tx) error {
 641+		return migrateDown(ctx, tx, passwordTokensVersion, passwordTokensName)
 642+	},
 643+}
 644diff --git a/server/db/migrate/0003_password_tokens_postgres.down.sql b/server/db/migrate/0003_password_tokens_postgres.down.sql
 645new file mode 100644
 646index 0000000000000000000000000000000000000000..e9e9b5362eac0c0902f547ebaa41b7f8877a6834
 647--- /dev/null
 648+++ b/server/db/migrate/0003_password_tokens_postgres.down.sql
 649@@ -0,0 +1,2 @@
 650+ALTER TABLE users DROP COLUMN password;
 651+
 652diff --git a/server/db/migrate/0003_password_tokens_postgres.up.sql b/server/db/migrate/0003_password_tokens_postgres.up.sql
 653new file mode 100644
 654index 0000000000000000000000000000000000000000..41e772b38f5a0bcced886a07e259263001a8db8b
 655--- /dev/null
 656+++ b/server/db/migrate/0003_password_tokens_postgres.up.sql
 657@@ -0,0 +1,15 @@
 658+ALTER TABLE users ADD COLUMN password TEXT;
 659+
 660+CREATE TABLE IF NOT EXISTS access_tokens (
 661+  id SERIAL PRIMARY KEY,
 662+  name text NOT NULL,
 663+  token TEXT NOT NULL UNIQUE,
 664+  user_id INTEGER NOT NULL,
 665+  expires_at TIMESTAMP,
 666+  created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
 667+  updated_at TIMESTAMP NOT NULL,
 668+  CONSTRAINT user_id_fk
 669+  FOREIGN KEY (user_id) REFERENCES users(id)
 670+  ON DELETE CASCADE
 671+  ON UPDATE CASCADE
 672+);
 673diff --git a/server/db/migrate/0003_password_tokens_sqlite.down.sql b/server/db/migrate/0003_password_tokens_sqlite.down.sql
 674new file mode 100644
 675index 0000000000000000000000000000000000000000..aa2562cc04f86147ca60f82aae4e5734f9cd4443
 676--- /dev/null
 677+++ b/server/db/migrate/0003_password_tokens_sqlite.down.sql
 678@@ -0,0 +1 @@
 679+ALTER TABLE users DROP COLUMN password;
 680diff --git a/server/db/migrate/0003_password_tokens_sqlite.up.sql b/server/db/migrate/0003_password_tokens_sqlite.up.sql
 681new file mode 100644
 682index 0000000000000000000000000000000000000000..56f2707fa61723d498a5cf54a6d7eb6c688f90ef
 683--- /dev/null
 684+++ b/server/db/migrate/0003_password_tokens_sqlite.up.sql
 685@@ -0,0 +1,15 @@
 686+ALTER TABLE users ADD COLUMN password TEXT;
 687+
 688+CREATE TABLE IF NOT EXISTS access_tokens (
 689+  id INTEGER primary key autoincrement,
 690+  token text NOT NULL UNIQUE,
 691+  name text NOT NULL,
 692+  user_id INTEGER NOT NULL,
 693+  expires_at DATETIME,
 694+  created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
 695+  updated_at DATETIME NOT NULL,
 696+  CONSTRAINT user_id_fk
 697+  FOREIGN KEY (user_id) REFERENCES users(id)
 698+  ON DELETE CASCADE
 699+  ON UPDATE CASCADE
 700+);
 701diff --git a/server/db/migrate/migrate.go b/server/db/migrate/migrate.go
 702index 883f3e631c05d2c72960c6e5edeab13be90e1e99..23242a302223f4b68a7d7250759d5f5d9d316ce2 100644
 703--- a/server/db/migrate/migrate.go
 704+++ b/server/db/migrate/migrate.go
 705@@ -106,7 +106,7 @@ func Rollback(ctx context.Context, dbx *db.DB) error {
 706 			}
 707 		}
 708 
 709-		if len(migrations) < int(migrs.Version) {
 710+		if migrs.Version == 0 || len(migrations) < int(migrs.Version) {
 711 			return fmt.Errorf("there are no migrations to rollback")
 712 		}
 713 
 714diff --git a/server/db/migrate/migrations.go b/server/db/migrate/migrations.go
 715index 8935ff05c8bff39197a5514f680a4ad91f85d61b..987a71105c0b51651a1d6151e790a16069aa1d21 100644
 716--- a/server/db/migrate/migrations.go
 717+++ b/server/db/migrate/migrations.go
 718@@ -17,6 +17,7 @@ var sqls embed.FS
 719 var migrations = []Migration{
 720 	createTables,
 721 	createLFSTables,
 722+	passwordTokens,
 723 }
 724 
 725 func execMigration(ctx context.Context, tx *db.Tx, version int, name string, down bool) error {
 726diff --git a/server/db/models/access_token.go b/server/db/models/access_token.go
 727new file mode 100644
 728index 0000000000000000000000000000000000000000..babedef403f24258f9bec2a3fc4ec71e1da8ce69
 729--- /dev/null
 730+++ b/server/db/models/access_token.go
 731@@ -0,0 +1,17 @@
 732+package models
 733+
 734+import (
 735+	"database/sql"
 736+	"time"
 737+)
 738+
 739+// AccessToken represents an access token.
 740+type AccessToken struct {
 741+	ID        int64        `db:"id"`
 742+	Name      string       `db:"name"`
 743+	UserID    int64        `db:"user_id"`
 744+	Token     string       `db:"token"`
 745+	ExpiresAt sql.NullTime `db:"expires_at"`
 746+	CreatedAt time.Time    `db:"created_at"`
 747+	UpdatedAt time.Time    `db:"updated_at"`
 748+}
 749diff --git a/server/db/models/user.go b/server/db/models/user.go
 750index 8404a9bb4473738fc1d21e9ce4340a88ae0ff92d..5ca0d3d9f76f7338e35bddb8aa1df29c614f3715 100644
 751--- a/server/db/models/user.go
 752+++ b/server/db/models/user.go
 753@@ -1,12 +1,16 @@
 754 package models
 755 
 756-import "time"
 757+import (
 758+	"database/sql"
 759+	"time"
 760+)
 761 
 762 // User represents a user.
 763 type User struct {
 764-	ID        int64     `db:"id"`
 765-	Username  string    `db:"username"`
 766-	Admin     bool      `db:"admin"`
 767-	CreatedAt time.Time `db:"created_at"`
 768-	UpdatedAt time.Time `db:"updated_at"`
 769+	ID        int64          `db:"id"`
 770+	Username  string         `db:"username"`
 771+	Admin     bool           `db:"admin"`
 772+	Password  sql.NullString `db:"password"`
 773+	CreatedAt time.Time      `db:"created_at"`
 774+	UpdatedAt time.Time      `db:"updated_at"`
 775 }
 776diff --git a/server/git/lfs.go b/server/git/lfs.go
 777index 047c59bbe66ee5896be423bf1342e5c407136351..1a1ead5a1ffb65cd18f084fc17a702c036e7e4b4 100644
 778--- a/server/git/lfs.go
 779+++ b/server/git/lfs.go
 780@@ -10,18 +10,18 @@ import (
 781 	"path"
 782 	"path/filepath"
 783 	"strconv"
 784+	"strings"
 785 	"time"
 786 
 787 	"github.com/charmbracelet/git-lfs-transfer/transfer"
 788 	"github.com/charmbracelet/log"
 789-	"github.com/charmbracelet/soft-serve/server/backend"
 790 	"github.com/charmbracelet/soft-serve/server/config"
 791 	"github.com/charmbracelet/soft-serve/server/db"
 792 	"github.com/charmbracelet/soft-serve/server/db/models"
 793+	"github.com/charmbracelet/soft-serve/server/lfs"
 794 	"github.com/charmbracelet/soft-serve/server/proto"
 795 	"github.com/charmbracelet/soft-serve/server/storage"
 796 	"github.com/charmbracelet/soft-serve/server/store"
 797-	"github.com/charmbracelet/soft-serve/server/utils"
 798 	"github.com/rubyist/tracerx"
 799 )
 800 
 801@@ -56,20 +56,18 @@ func LFSTransfer(ctx context.Context, cmd ServiceCommand) error {
 802 		return errors.New("missing args")
 803 	}
 804 
 805-	logger := log.FromContext(ctx).WithPrefix("lfs-transfer")
 806-	handler := transfer.NewPktline(cmd.Stdin, cmd.Stdout)
 807-	be := backend.FromContext(ctx)
 808-	repoName := cmd.Args[0]
 809-	repoName = utils.SanitizeRepo(repoName)
 810 	op := cmd.Args[1]
 811-
 812-	repo, err := be.Repository(ctx, repoName)
 813-	if err != nil {
 814-		logger.Errorf("error getting repo: %v", err)
 815-		return err
 816+	if op != lfs.OperationDownload && op != lfs.OperationUpload {
 817+		return errors.New("invalid operation")
 818 	}
 819 
 820-	ctx = context.WithValue(ctx, proto.ContextKeyRepository, repo)
 821+	logger := log.FromContext(ctx).WithPrefix("lfs-transfer")
 822+	handler := transfer.NewPktline(cmd.Stdin, cmd.Stdout)
 823+	repo := proto.RepositoryFromContext(ctx)
 824+	if repo == nil {
 825+		logger.Error("no repository in context")
 826+		return proto.ErrRepoNotFound
 827+	}
 828 
 829 	// Advertise capabilities.
 830 	for _, cap := range []string{
 831@@ -102,15 +100,10 @@ func LFSTransfer(ctx context.Context, cmd ServiceCommand) error {
 832 }
 833 
 834 // Batch implements transfer.Backend.
 835-func (t *lfsTransfer) Batch(_ string, pointers []transfer.Pointer) ([]transfer.BatchItem, error) {
 836-	repo, ok := t.ctx.Value(proto.ContextKeyRepository).(proto.Repository)
 837-	if !ok {
 838-		return nil, errors.New("no repository in context")
 839-	}
 840-
 841+func (t *lfsTransfer) Batch(_ string, pointers []transfer.Pointer, _ map[string]string) ([]transfer.BatchItem, error) {
 842 	items := make([]transfer.BatchItem, 0)
 843 	for _, p := range pointers {
 844-		obj, err := t.store.GetLFSObjectByOid(t.ctx, t.dbx, repo.ID(), p.Oid)
 845+		obj, err := t.store.GetLFSObjectByOid(t.ctx, t.dbx, t.repo.ID(), p.Oid)
 846 		if err != nil && !errors.Is(err, db.ErrRecordNotFound) {
 847 			return items, db.WrapError(err)
 848 		}
 849@@ -121,7 +114,7 @@ func (t *lfsTransfer) Batch(_ string, pointers []transfer.Pointer) ([]transfer.B
 850 		}
 851 
 852 		if exist && obj.ID == 0 {
 853-			if err := t.store.CreateLFSObject(t.ctx, t.dbx, repo.ID(), p.Oid, p.Size); err != nil {
 854+			if err := t.store.CreateLFSObject(t.ctx, t.dbx, t.repo.ID(), p.Oid, p.Size); err != nil {
 855 				return items, db.WrapError(err)
 856 			}
 857 		}
 858@@ -137,7 +130,7 @@ func (t *lfsTransfer) Batch(_ string, pointers []transfer.Pointer) ([]transfer.B
 859 }
 860 
 861 // Download implements transfer.Backend.
 862-func (t *lfsTransfer) Download(oid string, _ ...string) (fs.File, error) {
 863+func (t *lfsTransfer) Download(oid string, _ map[string]string) (fs.File, error) {
 864 	cfg := config.FromContext(t.ctx)
 865 	strg := storage.NewLocalStorage(filepath.Join(cfg.DataPath, "lfs"))
 866 	pointer := transfer.Pointer{Oid: oid}
 867@@ -146,11 +139,12 @@ func (t *lfsTransfer) Download(oid string, _ ...string) (fs.File, error) {
 868 
 869 type uploadObject struct {
 870 	oid    string
 871+	size   int64
 872 	object storage.Object
 873 }
 874 
 875 // StartUpload implements transfer.Backend.
 876-func (t *lfsTransfer) StartUpload(oid string, r io.Reader, _ ...string) (interface{}, error) {
 877+func (t *lfsTransfer) StartUpload(oid string, r io.Reader, _ map[string]string) (interface{}, error) {
 878 	if r == nil {
 879 		return nil, fmt.Errorf("no reader: %w", transfer.ErrMissingData)
 880diff --git a/server/git/lfs_auth.go b/server/git/lfs_auth.go
 881new file mode 100644
 882index 0000000000000000000000000000000000000000..0568d290742b71f5e2e30507bd9d868e2c25e4e6
 883--- /dev/null
 884+++ b/server/git/lfs_auth.go
 885@@ -0,0 +1,85 @@
 886+package git
 887+
 888+import (
 889+	"context"
 890+	"encoding/json"
 891+	"errors"
 892+	"fmt"
 893+	"time"
 894+
 895+	"github.com/charmbracelet/log"
 896+	"github.com/charmbracelet/soft-serve/server/config"
 897+	"github.com/charmbracelet/soft-serve/server/jwk"
 898+	"github.com/charmbracelet/soft-serve/server/lfs"
 899+	"github.com/charmbracelet/soft-serve/server/proto"
 900+	"github.com/golang-jwt/jwt/v5"
 901+)
 902+
 903+// LFSAuthenticate implements teh Git LFS SSH authentication command.
 904+// Context must have *config.Config, *log.Logger, proto.User.
 905+// cmd.Args should have the repo path and operation as arguments.
 906+func LFSAuthenticate(ctx context.Context, cmd ServiceCommand) error {
 907+	if len(cmd.Args) < 2 {
 908+		return errors.New("missing args")
 909+	}
 910+
 911+	logger := log.FromContext(ctx).WithPrefix("ssh.lfs-authenticate")
 912+	operation := cmd.Args[1]
 913+	if operation != lfs.OperationDownload && operation != lfs.OperationUpload {
 914+		logger.Errorf("invalid operation: %s", operation)
 915+		return errors.New("invalid operation")
 916+	}
 917+
 918+	user := proto.UserFromContext(ctx)
 919+	if user == nil {
 920+		logger.Errorf("missing user")
 921+		return proto.ErrUserNotFound
 922+	}
 923+
 924+	repo := proto.RepositoryFromContext(ctx)
 925+	if repo == nil {
 926+		logger.Errorf("missing repository")
 927+		return proto.ErrRepoNotFound
 928+	}
 929+
 930+	cfg := config.FromContext(ctx)
 931+	kp, err := jwk.NewPair(cfg)
 932+	if err != nil {
 933+		logger.Error("failed to get JWK pair", "err", err)
 934+		return err
 935+	}
 936+
 937+	now := time.Now()
 938+	expiresIn := time.Minute * 5
 939+	expiresAt := now.Add(expiresIn)
 940+	claims := jwt.RegisteredClaims{
 941+		Subject:   fmt.Sprintf("%s#%d", user.Username(), user.ID()),
 942+		ExpiresAt: jwt.NewNumericDate(expiresAt), // expire in an hour
 943+		NotBefore: jwt.NewNumericDate(now),
 944+		IssuedAt:  jwt.NewNumericDate(now),
 945+		Issuer:    cfg.HTTP.PublicURL,
 946+		Audience: []string{
 947+			repo.Name(),
 948+		},
 949+	}
 950+
 951+	token := jwt.NewWithClaims(jwk.SigningMethod, claims)
 952+	token.Header["kid"] = kp.JWK().KeyID
 953+	j, err := token.SignedString(kp.PrivateKey())
 954+	if err != nil {
 955+		logger.Error("failed to sign token", "err", err)
 956+		return err
 957+	}
 958+
 959+	href := fmt.Sprintf("%s/%s.git/info/lfs", cfg.HTTP.PublicURL, repo.Name())
 960+	logger.Debug("generated token", "token", j, "href", href, "expires_at", expiresAt)
 961+
 962+	return json.NewEncoder(cmd.Stdout).Encode(lfs.AuthenticateResponse{
 963+		Header: map[string]string{
 964+			"Authorization": fmt.Sprintf("Bearer %s", j),
 965+		},
 966+		Href:      href,
 967+		ExpiresAt: expiresAt,
 968+		ExpiresIn: expiresIn,
 969+	})
 970+}
 971diff --git a/server/git/service.go b/server/git/service.go
 972index 51a53a0c887b56c4596ecaaaa90fed1617d1c161..fb5dbf5acb5340174d178d2fb3c20f38cf4a7098 100644
 973--- a/server/git/service.go
 974+++ b/server/git/service.go
 975@@ -25,7 +25,8 @@ const (
 976 	ReceivePackService Service = "git-receive-pack"
 977 	// LFSTransferService is the LFS transfer service.
 978 	LFSTransferService Service = "git-lfs-transfer"
 979-	// TODO: add support for git-lfs-authenticate
 980+	// LFSAuthenticateService is the LFS authenticate service.
 981+	LFSAuthenticateService = "git-lfs-authenticate"
 982 )
 983 
 984 // String returns the string representation of the service.
 985@@ -45,6 +46,8 @@ func (s Service) Handler(ctx context.Context, cmd ServiceCommand) error {
 986 		return gitServiceHandler(ctx, s, cmd)
 987 	case LFSTransferService:
 988 		return LFSTransfer(ctx, cmd)
 989+	case LFSAuthenticateService:
 990+		return LFSAuthenticate(ctx, cmd)
 991 	default:
 992 		return fmt.Errorf("unsupported service: %s", s)
 993 	}
 994diff --git a/server/jwk/jwk.go b/server/jwk/jwk.go
 995new file mode 100644
 996index 0000000000000000000000000000000000000000..51ea86be8db75bff443e492614fae36425df5f0b
 997--- /dev/null
 998+++ b/server/jwk/jwk.go
 999@@ -0,0 +1,49 @@
1000+package jwk
1001+
1002+import (
1003+	"crypto"
1004+	"crypto/sha256"
1005+	"fmt"
1006+
1007+	"github.com/charmbracelet/soft-serve/server/config"
1008+	"github.com/go-jose/go-jose/v3"
1009+	"github.com/golang-jwt/jwt/v5"
1010+)
1011+
1012+// SigningMethod is a JSON Web Token signing method. It uses Ed25519 keys to
1013+// sign and verify tokens.
1014+var SigningMethod = &jwt.SigningMethodEd25519{}
1015+
1016+// Pair is a JSON Web Key pair.
1017+type Pair struct {
1018+	privateKey crypto.PrivateKey
1019+	jwk        jose.JSONWebKey
1020+}
1021+
1022+// PrivateKey returns the private key.
1023+func (p Pair) PrivateKey() crypto.PrivateKey {
1024+	return p.privateKey
1025+}
1026+
1027+// JWK returns the JSON Web Key.
1028+func (p Pair) JWK() jose.JSONWebKey {
1029+	return p.jwk
1030+}
1031+
1032+// NewPair creates a new JSON Web Key pair.
1033+func NewPair(cfg *config.Config) (Pair, error) {
1034+	kp, err := cfg.SSH.KeyPair()
1035+	if err != nil {
1036+		return Pair{}, err
1037+	}
1038+
1039+	sum := sha256.Sum256(kp.RawPrivateKey())
1040+	kid := fmt.Sprintf("%x", sum)
1041+	jwk := jose.JSONWebKey{
1042+		Key:       kp.CryptoPublicKey(),
1043+		KeyID:     kid,
1044+		Algorithm: SigningMethod.Alg(),
1045+	}
1046+
1047+	return Pair{privateKey: kp.PrivateKey(), jwk: jwk}, nil
1048+}
1049diff --git a/server/lfs/common.go b/server/lfs/common.go
1050index 1bd2473068ab09f69b829ce5b292c50d9dd08097..aa98c65278b7c35385f15324890235918b05e16e 100644
1051--- a/server/lfs/common.go
1052+++ b/server/lfs/common.go
1053@@ -1,6 +1,8 @@
1054 package lfs
1055 
1056-import "time"
1057+import (
1058+	"time"
1059+)
1060 
1061 const (
1062 	// MediaType contains the media type for LFS server requests.
1063@@ -20,6 +22,10 @@ const (
1064 
1065 	// ActionVerify is the action name for a verify request.
1066 	ActionVerify = "verify"
1067+
1068+	// DefaultLocksLimit is the default number of locks to return in a single
1069+	// request.
1070+	DefaultLocksLimit = 20
1071 )
1072 
1073 // Pointer contains LFS pointer data
1074@@ -86,3 +92,72 @@ type BatchRequest struct {
1075 type Reference struct {
1076 	Name string `json:"name"`
1077 }
1078+
1079+// AuthenticateResponse is the git-lfs-authenticate JSON response object.
1080+type AuthenticateResponse struct {
1081+	Header    map[string]string `json:"header"`
1082+	Href      string            `json:"href"`
1083+	ExpiresIn time.Duration     `json:"expires_in"`
1084+	ExpiresAt time.Time         `json:"expires_at"`
1085+}
1086+
1087+// LockCreateRequest contains the request data for creating a lock.
1088+// https://github.com/git-lfs/git-lfs/blob/main/docs/api/locking.md
1089+// https://github.com/git-lfs/git-lfs/blob/main/locking/schemas/http-lock-create-request-schema.json
1090+type LockCreateRequest struct {
1091+	Path string    `json:"path"`
1092+	Ref  Reference `json:"ref,omitempty"`
1093+}
1094+
1095+// Owner contains the owner data for a lock.
1096+type Owner struct {
1097+	Name string `json:"name"`
1098+}
1099+
1100+// Lock contains the response data for creating a lock.
1101+// https://github.com/git-lfs/git-lfs/blob/main/docs/api/locking.md
1102+// https://github.com/git-lfs/git-lfs/blob/main/locking/schemas/http-lock-create-response-schema.json
1103+type Lock struct {
1104+	ID       string    `json:"id"`
1105+	Path     string    `json:"path"`
1106+	LockedAt time.Time `json:"locked_at"`
1107+	Owner    Owner     `json:"owner,omitempty"`
1108+}
1109+
1110+// LockDeleteRequest contains the request data for deleting a lock.
1111+// https://github.com/git-lfs/git-lfs/blob/main/docs/api/locking.md
1112+// https://github.com/git-lfs/git-lfs/blob/main/locking/schemas/http-lock-delete-request-schema.json
1113+type LockDeleteRequest struct {
1114+	Force bool      `json:"force,omitempty"`
1115+	Ref   Reference `json:"ref,omitempty"`
1116+}
1117+
1118+// LockListResponse contains the response data for listing locks.
1119+// https://github.com/git-lfs/git-lfs/blob/main/docs/api/locking.md
1120+// https://github.com/git-lfs/git-lfs/blob/main/locking/schemas/http-lock-list-response-schema.json
1121+type LockListResponse struct {
1122+	Locks      []Lock `json:"locks"`
1123+	NextCursor string `json:"next_cursor,omitempty"`
1124+}
1125+
1126+// LockVerifyRequest contains the request data for verifying a lock.
1127+type LockVerifyRequest struct {
1128+	Ref    Reference `json:"ref,omitempty"`
1129+	Cursor string    `json:"cursor,omitempty"`
1130+	Limit  int       `json:"limit,omitempty"`
1131+}
1132+
1133+// LockVerifyResponse contains the response data for verifying a lock.
1134+// https://github.com/git-lfs/git-lfs/blob/main/docs/api/locking.md
1135+// https://github.com/git-lfs/git-lfs/blob/main/locking/schemas/http-lock-verify-response-schema.json
1136+type LockVerifyResponse struct {
1137+	Ours       []Lock `json:"ours"`
1138+	Theirs     []Lock `json:"theirs"`
1139+	NextCursor string `json:"next_cursor,omitempty"`
1140+}
1141+
1142+// LockResponse contains the response data for a lock.
1143+type LockResponse struct {
1144+	Lock Lock `json:"lock"`
1145+	ErrorResponse
1146+}
1147diff --git a/server/proto/context.go b/server/proto/context.go
1148new file mode 100644
1149index 0000000000000000000000000000000000000000..cd022dcac00ccae5da434ddb3583797ddf83fc2f
1150--- /dev/null
1151+++ b/server/proto/context.go
1152@@ -0,0 +1,35 @@
1153+package proto
1154+
1155+import "context"
1156+
1157+// ContextKeyRepository is the context key for the repository.
1158+var ContextKeyRepository = &struct{ string }{"repository"}
1159+
1160+// ContextKeyUser is the context key for the user.
1161+var ContextKeyUser = &struct{ string }{"user"}
1162+
1163+// RepositoryFromContext returns the repository from the context.
1164+func RepositoryFromContext(ctx context.Context) Repository {
1165+	if r, ok := ctx.Value(ContextKeyRepository).(Repository); ok {
1166+		return r
1167+	}
1168+	return nil
1169+}
1170+
1171+// UserFromContext returns the user from the context.
1172+func UserFromContext(ctx context.Context) User {
1173+	if u, ok := ctx.Value(ContextKeyUser).(User); ok {
1174+		return u
1175+	}
1176+	return nil
1177+}
1178+
1179+// WithRepositoryContext returns a new context with the repository.
1180+func WithRepositoryContext(ctx context.Context, r Repository) context.Context {
1181+	return context.WithValue(ctx, ContextKeyRepository, r)
1182+}
1183+
1184+// WithUserContext returns a new context with the user.
1185+func WithUserContext(ctx context.Context, u User) context.Context {
1186+	return context.WithValue(ctx, ContextKeyUser, u)
1187+}
1188diff --git a/server/proto/errors.go b/server/proto/errors.go
1189index b2d12dd07abf85061710e13a6611e3c2f310802f..781ee6b3296f27cdcad6ab81e1300543a4070a2e 100644
1190--- a/server/proto/errors.go
1191+++ b/server/proto/errors.go
1192@@ -6,11 +6,13 @@ import (
1193 
1194 var (
1195 	// ErrUnauthorized is returned when the user is not authorized to perform action.
1196-	ErrUnauthorized = errors.New("Unauthorized")
1197+	ErrUnauthorized = errors.New("unauthorized")
1198 	// ErrFileNotFound is returned when the file is not found.
1199-	ErrFileNotFound = errors.New("File not found")
1200-	// ErrRepoNotExist is returned when a repository does not exist.
1201-	ErrRepoNotExist = errors.New("repository does not exist")
1202+	ErrFileNotFound = errors.New("file not found")
1203+	// ErrRepoNotFound is returned when a repository does not exist.
1204+	ErrRepoNotFound = errors.New("repository not found")
1205 	// ErrRepoExist is returned when a repository already exists.
1206 	ErrRepoExist = errors.New("repository already exists")
1207+	// ErrUserNotFound is returned when a user does not exist.
1208+	ErrUserNotFound = errors.New("user does not exist")
1209 )
1210diff --git a/server/proto/repo.go b/server/proto/repo.go
1211index e721d44a2b115165d13502992a04b8287a1a7b26..2d54e9484caf3ec38269be0907a4af075b93d69d 100644
1212--- a/server/proto/repo.go
1213+++ b/server/proto/repo.go
1214@@ -6,9 +6,6 @@ import (
1215 	"github.com/charmbracelet/soft-serve/git"
1216 )
1217 
1218-// ContextKeyRepository is the context key for the repository.
1219-var ContextKeyRepository = &struct{ string }{"repository"}
1220-
1221 // Repository is a Git repository interface.
1222 type Repository interface {
1223 	// ID returns the repository's ID.
1224diff --git a/server/proto/user.go b/server/proto/user.go
1225index f8fd65a1479ccbd9736e9c4af353b303a74402eb..647b241101767450fdcd4a1cc725dff10e46055b 100644
1226--- a/server/proto/user.go
1227+++ b/server/proto/user.go
1228@@ -2,9 +2,6 @@ package proto
1229 
1230 import "golang.org/x/crypto/ssh"
1231 
1232-// ContextKeyUser is the context key for the user.
1233-var ContextKeyUser = &struct{ string }{"user"}
1234-
1235 // User is an interface representing a user.
1236 type User interface {
1237 	// ID returns the user's ID.
1238diff --git a/server/ssh/cmd/cmd.go b/server/ssh/cmd/cmd.go
1239index d7e3f5ddcc9bfe2c78437a4b0a7dff361c6c094d..b965409de30e88d3454765ad6b235b05b7d81e92 100644
1240--- a/server/ssh/cmd/cmd.go
1241+++ b/server/ssh/cmd/cmd.go
1242@@ -89,7 +89,6 @@ func cmdName(args []string) string {
1243 func RootCommand(s ssh.Session) *cobra.Command {
1244 	ctx := s.Context()
1245 	cfg := config.FromContext(ctx)
1246-	be := backend.FromContext(ctx)
1247 
1248 	args := s.Command()
1249 	cliCommandCounter.WithLabelValues(cmdName(args)).Inc()
1250@@ -140,7 +139,7 @@ func RootCommand(s ssh.Session) *cobra.Command {
1251 	rootCmd.CompletionOptions.DisableDefaultCmd = true
1252 	rootCmd.SetErr(s.Stderr())
1253 
1254-	user, _ := be.UserByPublicKey(s.Context(), s.PublicKey())
1255+	user := proto.UserFromContext(ctx)
1256 	isAdmin := isPublicKeyAdmin(cfg, s.PublicKey()) || (user != nil && user.IsAdmin())
1257 	if user != nil || isAdmin {
1258 		if isAdmin {
1259@@ -154,6 +153,7 @@ func RootCommand(s ssh.Session) *cobra.Command {
1260 			infoCommand(),
1261 			pubkeyCommand(),
1262 			setUsernameCommand(),
1263+			jwtCommand(),
1264 		)
1265 	}
1266 
1267@@ -169,8 +169,8 @@ func checkIfReadable(cmd *cobra.Command, args []string) error {
1268 	ctx := cmd.Context()
1269 	be := backend.FromContext(ctx)
1270 	rn := utils.SanitizeRepo(repo)
1271-	pk := sshutils.PublicKeyFromContext(ctx)
1272-	auth := be.AccessLevelByPublicKey(cmd.Context(), rn, pk)
1273+	user := proto.UserFromContext(ctx)
1274+	auth := be.AccessLevelForUser(cmd.Context(), rn, user)
1275 	if auth < access.ReadOnlyAccess {
1276 		return proto.ErrUnauthorized
1277 	}
1278@@ -188,14 +188,13 @@ func isPublicKeyAdmin(cfg *config.Config, pk ssh.PublicKey) bool {
1279 
1280 func checkIfAdmin(cmd *cobra.Command, _ []string) error {
1281 	ctx := cmd.Context()
1282-	be := backend.FromContext(ctx)
1283 	cfg := config.FromContext(ctx)
1284 	pk := sshutils.PublicKeyFromContext(ctx)
1285 	if isPublicKeyAdmin(cfg, pk) {
1286 		return nil
1287 	}
1288 
1289-	user, _ := be.UserByPublicKey(ctx, pk)
1290+	user := proto.UserFromContext(ctx)
1291 	if user == nil {
1292 		return proto.ErrUnauthorized
1293 	}
1294@@ -215,9 +214,9 @@ func checkIfCollab(cmd *cobra.Command, args []string) error {
1295 
1296 	ctx := cmd.Context()
1297 	be := backend.FromContext(ctx)
1298-	pk := sshutils.PublicKeyFromContext(ctx)
1299 	rn := utils.SanitizeRepo(repo)
1300-	auth := be.AccessLevelByPublicKey(ctx, rn, pk)
1301+	user := proto.UserFromContext(ctx)
1302+	auth := be.AccessLevelForUser(cmd.Context(), rn, user)
1303 	if auth < access.ReadWriteAccess {
1304 		return proto.ErrUnauthorized
1305 	}
1306diff --git a/server/ssh/cmd/jwt.go b/server/ssh/cmd/jwt.go
1307new file mode 100644
1308index 0000000000000000000000000000000000000000..b574889f5a56f9f3e56ae7c1daf76828bc4957ab
1309--- /dev/null
1310+++ b/server/ssh/cmd/jwt.go
1311@@ -0,0 +1,56 @@
1312+package cmd
1313+
1314+import (
1315+	"fmt"
1316+	"time"
1317+
1318+	"github.com/charmbracelet/soft-serve/server/config"
1319+	"github.com/charmbracelet/soft-serve/server/jwk"
1320+	"github.com/charmbracelet/soft-serve/server/proto"
1321+	"github.com/golang-jwt/jwt/v5"
1322+	"github.com/spf13/cobra"
1323+)
1324+
1325+func jwtCommand() *cobra.Command {
1326+	cmd := &cobra.Command{
1327+		Use:   "jwt [repository1 repository2...]",
1328+		Short: "Generate a JSON Web Token",
1329+		Args:  cobra.MinimumNArgs(0),
1330+		RunE: func(cmd *cobra.Command, args []string) error {
1331+			ctx := cmd.Context()
1332+			cfg := config.FromContext(ctx)
1333+			kp, err := jwk.NewPair(cfg)
1334+			if err != nil {
1335+				return err
1336+			}
1337+
1338+			user := proto.UserFromContext(ctx)
1339+			if user == nil {
1340+				return proto.ErrUserNotFound
1341+			}
1342+
1343+			now := time.Now()
1344+			expiresAt := now.Add(time.Hour)
1345+			claims := jwt.RegisteredClaims{
1346+				Subject:   fmt.Sprintf("%s#%d", user.Username(), user.ID()),
1347+				ExpiresAt: jwt.NewNumericDate(expiresAt), // expire in an hour
1348+				NotBefore: jwt.NewNumericDate(now),
1349+				IssuedAt:  jwt.NewNumericDate(now),
1350+				Issuer:    cfg.HTTP.PublicURL,
1351+				Audience:  args,
1352+			}
1353+
1354+			token := jwt.NewWithClaims(jwk.SigningMethod, claims)
1355+			token.Header["kid"] = kp.JWK().KeyID
1356+			j, err := token.SignedString(kp.PrivateKey())
1357+			if err != nil {
1358+				return err
1359+			}
1360+
1361+			cmd.Println(j)
1362+			return nil
1363+		},
1364+	}
1365+
1366+	return cmd
1367+}
1368diff --git a/server/ssh/git.go b/server/ssh/git.go
1369index d2a030f9ded5d6ef644a363b1c84fcb1e56bd835..02b59e47826c3407516d3f02220c85257b494537 100644
1370--- a/server/ssh/git.go
1371+++ b/server/ssh/git.go
1372@@ -25,47 +25,51 @@ func handleGit(s ssh.Session) {
1373 	cmdLine := s.Command()
1374 	start := time.Now()
1375 
1376-	var username string
1377-	user := ctx.Value(proto.ContextKeyUser).(proto.User)
1378-	if user != nil {
1379-		username = user.Username()
1380-	}
1381-
1382 	// repo should be in the form of "repo.git"
1383 	name := utils.SanitizeRepo(cmdLine[1])
1384 	pk := s.PublicKey()
1385 	ak := sshutils.MarshalAuthorizedKey(pk)
1386+	user := proto.UserFromContext(ctx)
1387 	accessLevel := be.AccessLevelForUser(ctx, name, user)
1388 	// git bare repositories should end in ".git"
1389 	// https://git-scm.com/docs/gitrepository-layout
1390-	repo := name + ".git"
1391+	repoDir := name + ".git"
1392 	reposDir := filepath.Join(cfg.DataPath, "repos")
1393-	if err := git.EnsureWithin(reposDir, repo); err != nil {
1394+	if err := git.EnsureWithin(reposDir, repoDir); err != nil {
1395 		sshFatal(s, err)
1396 		return
1397 	}
1398 
1399+	// Set repo in context
1400+	repo, _ := be.Repository(ctx, name)
1401+	ctx.SetValue(proto.ContextKeyRepository, repo)
1402+
1403 	// Environment variables to pass down to git hooks.
1404 	envs := []string{
1405 		"SOFT_SERVE_REPO_NAME=" + name,
1406-		"SOFT_SERVE_REPO_PATH=" + filepath.Join(reposDir, repo),
1407+		"SOFT_SERVE_REPO_PATH=" + filepath.Join(reposDir, repoDir),
1408 		"SOFT_SERVE_PUBLIC_KEY=" + ak,
1409-		"SOFT_SERVE_USERNAME=" + username,
1410 		"SOFT_SERVE_LOG_PATH=" + filepath.Join(cfg.DataPath, "log", "hooks.log"),
1411 	}
1412 
1413+	if user != nil {
1414+		envs = append(envs,
1415+			"SOFT_SERVE_USERNAME="+user.Username(),
1416+		)
1417+	}
1418+
1419 	// Add ssh session & config environ
1420 	envs = append(envs, s.Environ()...)
1421 	envs = append(envs, cfg.Environ()...)
1422 
1423-	repoDir := filepath.Join(reposDir, repo)
1424+	repoPath := filepath.Join(reposDir, repoDir)
1425 	service := git.Service(cmdLine[0])
1426 	cmd := git.ServiceCommand{
1427 		Stdin:  s,
1428 		Stdout: s,
1429 		Stderr: s.Stderr(),
1430 		Env:    envs,
1431-		Dir:    repoDir,
1432+		Dir:    repoPath,
1433 	}
1434 
1435 	logger.Debug("git middleware", "cmd", service, "access", accessLevel.String())
1436@@ -80,7 +84,7 @@ func handleGit(s ssh.Session) {
1437 			sshFatal(s, git.ErrNotAuthed)
1438 			return
1439 		}
1440-		if _, err := be.Repository(ctx, name); err != nil {
1441+		if repo == nil {
1442 			if _, err := be.CreateRepository(ctx, name, proto.RepositoryOptions{Private: false}); err != nil {
1443 				log.Errorf("failed to create repo: %s", err)
1444 				sshFatal(s, err)
1445@@ -105,10 +109,8 @@ func handleGit(s ssh.Session) {
1446 			return
1447 		}
1448 
1449-		handler := git.UploadPack
1450 		switch service {
1451 		case git.UploadArchiveService:
1452-			handler = git.UploadArchive
1453 			uploadArchiveCounter.WithLabelValues(name).Inc()
1454 			defer func() {
1455 				uploadArchiveSeconds.WithLabelValues(name).Add(time.Since(start).Seconds())
1456@@ -120,14 +122,16 @@ func handleGit(s ssh.Session) {
1457 			}()
1458 		}
1459 
1460-		err := handler(ctx, cmd)
1461+		err := service.Handler(ctx, cmd)
1462 		if errors.Is(err, git.ErrInvalidRepo) {
1463 			sshFatal(s, git.ErrInvalidRepo)
1464 		} else if err != nil {
1465 			logger.Error("git middleware", "err", err)
1466 			sshFatal(s, git.ErrSystemMalfunction)
1467 		}
1468-	case git.LFSTransferService:
1469+
1470+		return
1471+	case git.LFSTransferService, git.LFSAuthenticateService:
1472diff --git a/server/ssh/ssh.go b/server/ssh/ssh.go
1473index fe6854507ac8fe6b53bf3c6a6a08b588c660ec8d..297973ebf24d0be41b66fa4c8329310858ca4970 100644
1474--- a/server/ssh/ssh.go
1475+++ b/server/ssh/ssh.go
1476@@ -10,13 +10,11 @@ import (
1477 
1478 	"github.com/charmbracelet/keygen"
1479 	"github.com/charmbracelet/log"
1480-	"github.com/charmbracelet/soft-serve/server/access"
1481 	"github.com/charmbracelet/soft-serve/server/backend"
1482 	"github.com/charmbracelet/soft-serve/server/config"
1483 	"github.com/charmbracelet/soft-serve/server/db"
1484 	"github.com/charmbracelet/soft-serve/server/git"
1485 	"github.com/charmbracelet/soft-serve/server/proto"
1486-	"github.com/charmbracelet/soft-serve/server/sshutils"
1487 	"github.com/charmbracelet/soft-serve/server/store"
1488 	"github.com/charmbracelet/ssh"
1489 	"github.com/charmbracelet/wish"
1490@@ -191,17 +189,16 @@ func (s *SSHServer) PublicKeyHandler(ctx ssh.Context, pk ssh.PublicKey) (allowed
1491 		return false
1492 	}
1493 
1494-	ak := sshutils.MarshalAuthorizedKey(pk)
1495 	defer func(allowed *bool) {
1496 		publicKeyCounter.WithLabelValues(strconv.FormatBool(*allowed)).Inc()
1497 	}(&allowed)
1498 
1499 	user, _ := s.be.UserByPublicKey(ctx, pk)
1500-	ctx.SetValue(proto.ContextKeyUser, user)
1501+	if user != nil {
1502+		ctx.SetValue(proto.ContextKeyUser, user)
1503+		allowed = true
1504+	}
1505 
1506-	ac := s.be.AccessLevelForUser(ctx, "", user)
1507-	s.logger.Debugf("access level for %q: %s", ak, ac)
1508-	allowed = ac >= access.ReadWriteAccess
1509 	return
1510 }
1511 
1512diff --git a/server/storage/local.go b/server/storage/local.go
1513index 8a51157af6e9a796921225f142083a6243aa1720..496f20e37dcf47dba316250f0cf33878434e16ad 100644
1514--- a/server/storage/local.go
1515+++ b/server/storage/local.go
1516@@ -41,19 +41,18 @@ func (l *LocalStorage) Stat(name string) (fs.FileInfo, error) {
1517 }
1518 
1519 // Put implements Storage.
1520-func (l *LocalStorage) Put(name string, r io.Reader) error {
1521+func (l *LocalStorage) Put(name string, r io.Reader) (int64, error) {
1522 	name = l.fixPath(name)
1523 	if err := os.MkdirAll(filepath.Dir(name), os.ModePerm); err != nil {
1524-		return err
1525+		return 0, err
1526 	}
1527 
1528 	f, err := os.Create(name)
1529 	if err != nil {
1530-		return err
1531+		return 0, err
1532 	}
1533 	defer f.Close() // nolint: errcheck
1534-	_, err = io.Copy(f, r)
1535-	return err
1536+	return io.Copy(f, r)
1537 }
1538 
1539 // Exists implements Storage.
1540diff --git a/server/storage/storage.go b/server/storage/storage.go
1541index dc435dbbbd8b39b6a1e0ac69cc1269276fab32e6..9f8467262643f3b698b629a006002236935da426 100644
1542--- a/server/storage/storage.go
1543+++ b/server/storage/storage.go
1544@@ -16,7 +16,7 @@ type Object interface {
1545 type Storage interface {
1546 	Open(name string) (Object, error)
1547 	Stat(name string) (fs.FileInfo, error)
1548-	Put(name string, r io.Reader) error
1549+	Put(name string, r io.Reader) (int64, error)
1550 	Delete(name string) error
1551 	Exists(name string) (bool, error)
1552 	Rename(oldName, newName string) error
1553diff --git a/server/store/access_token.go b/server/store/access_token.go
1554new file mode 100644
1555index 0000000000000000000000000000000000000000..72440ea2a61d80d1f2121906091cdd28cfe67ffd
1556--- /dev/null
1557+++ b/server/store/access_token.go
1558@@ -0,0 +1 @@
1559+package store
1560diff --git a/server/store/collab.go b/server/store/collab.go
1561new file mode 100644
1562index 0000000000000000000000000000000000000000..430e39d8f667dfb82a57bb53b29d6656b2d95eb6
1563--- /dev/null
1564+++ b/server/store/collab.go
1565@@ -0,0 +1,17 @@
1566+package store
1567+
1568+import (
1569+	"context"
1570+
1571+	"github.com/charmbracelet/soft-serve/server/db"
1572+	"github.com/charmbracelet/soft-serve/server/db/models"
1573+)
1574+
1575+// CollaboratorStore is an interface for managing collaborators.
1576+type CollaboratorStore interface {
1577+	GetCollabByUsernameAndRepo(ctx context.Context, h db.Handler, username string, repo string) (models.Collab, error)
1578+	AddCollabByUsernameAndRepo(ctx context.Context, h db.Handler, username string, repo string) error
1579+	RemoveCollabByUsernameAndRepo(ctx context.Context, h db.Handler, username string, repo string) error
1580+	ListCollabsByRepo(ctx context.Context, h db.Handler, repo string) ([]models.Collab, error)
1581+	ListCollabsByRepoAsUsers(ctx context.Context, h db.Handler, repo string) ([]models.User, error)
1582+}
1583diff --git a/server/store/database/lfs.go b/server/store/database/lfs.go
1584index 0233dec7a0ffc48da9bca2004938d4bccb987560..64fef37165004136b701a489fce69db0cc037f31 100644
1585--- a/server/store/database/lfs.go
1586+++ b/server/store/database/lfs.go
1587@@ -2,7 +2,6 @@ package database
1588 
1589 import (
1590 	"context"
1591-	"strconv"
1592 	"strings"
1593 
1594 	"github.com/charmbracelet/soft-serve/server/db"
1595@@ -37,17 +36,43 @@ func (*lfsStore) CreateLFSLockForUser(ctx context.Context, tx db.Handler, repoID
1596 }
1597 
1598 // GetLFSLocks implements store.LFSStore.
1599-func (*lfsStore) GetLFSLocks(ctx context.Context, tx db.Handler, repoID int64) ([]models.LFSLock, error) {
1600+func (*lfsStore) GetLFSLocks(ctx context.Context, tx db.Handler, repoID int64, page int, limit int) ([]models.LFSLock, error) {
1601+	if page <= 0 {
1602+		page = 1
1603+	}
1604+
1605 	var locks []models.LFSLock
1606 	query := tx.Rebind(`
1607 		SELECT *
1608 		FROM lfs_locks
1609-		WHERE repo_id = ?;
1610+		WHERE repo_id = ?
1611+		ORDER BY updated_at DESC
1612+		LIMIT ? OFFSET ?;
1613 	`)
1614-	err := tx.SelectContext(ctx, &locks, query, repoID)
1615+	err := tx.SelectContext(ctx, &locks, query, repoID, limit, (page-1)*limit)
1616 	return locks, db.WrapError(err)
1617 }
1618 
1619+func (s *lfsStore) GetLFSLocksWithCount(ctx context.Context, tx db.Handler, repoID int64, page int, limit int) ([]models.LFSLock, int64, error) {
1620+	locks, err := s.GetLFSLocks(ctx, tx, repoID, page, limit)
1621+	if err != nil {
1622+		return nil, 0, err
1623+	}
1624+
1625+	var count int64
1626+	query := tx.Rebind(`
1627+		SELECT COUNT(*)
1628+		FROM lfs_locks
1629+		WHERE repo_id = ?;
1630+	`)
1631+	err = tx.GetContext(ctx, &count, query, repoID)
1632+	if err != nil {
1633+		return nil, 0, db.WrapError(err)
1634+	}
1635+
1636+	return locks, count, nil
1637+}
1638+
1639 // GetLFSLocksForUser implements store.LFSStore.
1640 func (*lfsStore) GetLFSLocksForUser(ctx context.Context, tx db.Handler, repoID int64, userID int64) ([]models.LFSLock, error) {
1641 	var locks []models.LFSLock
1642@@ -61,16 +86,16 @@ func (*lfsStore) GetLFSLocksForUser(ctx context.Context, tx db.Handler, repoID i
1643 }
1644 
1645 // GetLFSLocksForPath implements store.LFSStore.
1646-func (*lfsStore) GetLFSLocksForPath(ctx context.Context, tx db.Handler, repoID int64, path string) ([]models.LFSLock, error) {
1647+func (*lfsStore) GetLFSLockForPath(ctx context.Context, tx db.Handler, repoID int64, path string) (models.LFSLock, error) {
1648 	path = sanitizePath(path)
1649-	var locks []models.LFSLock
1650+	var lock models.LFSLock
1651 	query := tx.Rebind(`
1652 		SELECT *
1653 		FROM lfs_locks
1654 		WHERE repo_id = ? AND path = ?;
1655 	`)
1656-	err := tx.SelectContext(ctx, &locks, query, repoID, path)
1657-	return locks, db.WrapError(err)
1658+	err := tx.GetContext(ctx, &lock, query, repoID, path)
1659+	return lock, db.WrapError(err)
1660 }
1661 
1662 // GetLFSLockForUserPath implements store.LFSStore.
1663@@ -87,51 +112,46 @@ func (*lfsStore) GetLFSLockForUserPath(ctx context.Context, tx db.Handler, repoI
1664 }
1665 
1666 // GetLFSLockByID implements store.LFSStore.
1667-func (*lfsStore) GetLFSLockByID(ctx context.Context, tx db.Handler, id string) (models.LFSLock, error) {
1668-	iid, err := strconv.Atoi(id)
1669-	if err != nil {
1670-		return models.LFSLock{}, err
1671-	}
1672-
1673+func (*lfsStore) GetLFSLockByID(ctx context.Context, tx db.Handler, id int64) (models.LFSLock, error) {
1674 	var lock models.LFSLock
1675 	query := tx.Rebind(`
1676 		SELECT *
1677 		FROM lfs_locks
1678 		WHERE lfs_locks.id = ?;
1679 	`)
1680-	err = tx.GetContext(ctx, &lock, query, iid)
1681+	err := tx.GetContext(ctx, &lock, query, id)
1682 	return lock, db.WrapError(err)
1683 }
1684 
1685 // GetLFSLockForUserByID implements store.LFSStore.
1686-func (*lfsStore) GetLFSLockForUserByID(ctx context.Context, tx db.Handler, userID int64, id string) (models.LFSLock, error) {
1687diff --git a/server/store/database/user.go b/server/store/database/user.go
1688index 9ca824e62bfde8cdb9a2c2d77c6eecd442c59183..c5b50681b0ab5ef9ca3b70fbe044cd2710c43bb3 100644
1689--- a/server/store/database/user.go
1690+++ b/server/store/database/user.go
1691@@ -214,3 +214,22 @@ func (*userStore) SetUsernameByUsername(ctx context.Context, tx db.Handler, user
1692 	_, err := tx.ExecContext(ctx, query, newUsername, username)
1693 	return err
1694 }
1695+
1696+// SetUserPassword implements store.UserStore.
1697+func (*userStore) SetUserPassword(ctx context.Context, tx db.Handler, userID int64, password string) error {
1698+	query := tx.Rebind(`UPDATE users SET password = ? WHERE id = ?;`)
1699+	_, err := tx.ExecContext(ctx, query, password, userID)
1700+	return err
1701+}
1702+
1703+// SetUserPasswordByUsername implements store.UserStore.
1704+func (*userStore) SetUserPasswordByUsername(ctx context.Context, tx db.Handler, username string, password string) error {
1705+	username = strings.ToLower(username)
1706+	if err := utils.ValidateUsername(username); err != nil {
1707+		return err
1708+	}
1709+
1710+	query := tx.Rebind(`UPDATE users SET password = ? WHERE username = ?;`)
1711+	_, err := tx.ExecContext(ctx, query, password, username)
1712+	return err
1713+}
1714diff --git a/server/store/lfs.go b/server/store/lfs.go
1715index 7632d2472bd7b65c43141e46dab7d49eb0741735..067285eac7cf6ffb910149d2d3df2c0959f5d78b 100644
1716--- a/server/store/lfs.go
1717+++ b/server/store/lfs.go
1718@@ -16,11 +16,13 @@ type LFSStore interface {
1719 	DeleteLFSObjectByOid(ctx context.Context, h db.Handler, repoID int64, oid string) error
1720 
1721 	CreateLFSLockForUser(ctx context.Context, h db.Handler, repoID int64, userID int64, path string, refname string) error
1722-	GetLFSLocks(ctx context.Context, h db.Handler, repoID int64) ([]models.LFSLock, error)
1723+	GetLFSLocks(ctx context.Context, h db.Handler, repoID int64, page int, limit int) ([]models.LFSLock, error)
1724+	GetLFSLocksWithCount(ctx context.Context, h db.Handler, repoID int64, page int, limit int) ([]models.LFSLock, int64, error)
1725 	GetLFSLocksForUser(ctx context.Context, h db.Handler, repoID int64, userID int64) ([]models.LFSLock, error)
1726-	GetLFSLocksForPath(ctx context.Context, h db.Handler, repoID int64, path string) ([]models.LFSLock, error)
1727+	GetLFSLockForPath(ctx context.Context, h db.Handler, repoID int64, path string) (models.LFSLock, error)
1728 	GetLFSLockForUserPath(ctx context.Context, h db.Handler, repoID int64, userID int64, path string) (models.LFSLock, error)
1729-	GetLFSLockByID(ctx context.Context, h db.Handler, id string) (models.LFSLock, error)
1730-	GetLFSLockForUserByID(ctx context.Context, h db.Handler, userID int64, id string) (models.LFSLock, error)
1731-	DeleteLFSLockForUserByID(ctx context.Context, h db.Handler, userID int64, id string) error
1732+	GetLFSLockByID(ctx context.Context, h db.Handler, id int64) (models.LFSLock, error)
1733+	GetLFSLockForUserByID(ctx context.Context, h db.Handler, repoID int64, userID int64, id int64) (models.LFSLock, error)
1734+	DeleteLFSLock(ctx context.Context, h db.Handler, repoID int64, id int64) error
1735+	DeleteLFSLockForUserByID(ctx context.Context, h db.Handler, repoID int64, userID int64, id int64) error
1736 }
1737diff --git a/server/store/repo.go b/server/store/repo.go
1738new file mode 100644
1739index 0000000000000000000000000000000000000000..c64eb08ee491d46b173193908fb57e8a920f3598
1740--- /dev/null
1741+++ b/server/store/repo.go
1742@@ -0,0 +1,27 @@
1743+package store
1744+
1745+import (
1746+	"context"
1747+
1748+	"github.com/charmbracelet/soft-serve/server/db"
1749+	"github.com/charmbracelet/soft-serve/server/db/models"
1750+)
1751+
1752+// RepositoryStore is an interface for managing repositories.
1753+type RepositoryStore interface {
1754+	GetRepoByName(ctx context.Context, h db.Handler, name string) (models.Repo, error)
1755+	GetAllRepos(ctx context.Context, h db.Handler) ([]models.Repo, error)
1756+	CreateRepo(ctx context.Context, h db.Handler, name string, projectName string, description string, isPrivate bool, isHidden bool, isMirror bool) error
1757+	DeleteRepoByName(ctx context.Context, h db.Handler, name string) error
1758+	SetRepoNameByName(ctx context.Context, h db.Handler, name string, newName string) error
1759+
1760+	GetRepoProjectNameByName(ctx context.Context, h db.Handler, name string) (string, error)
1761+	SetRepoProjectNameByName(ctx context.Context, h db.Handler, name string, projectName string) error
1762+	GetRepoDescriptionByName(ctx context.Context, h db.Handler, name string) (string, error)
1763+	SetRepoDescriptionByName(ctx context.Context, h db.Handler, name string, description string) error
1764+	GetRepoIsPrivateByName(ctx context.Context, h db.Handler, name string) (bool, error)
1765+	SetRepoIsPrivateByName(ctx context.Context, h db.Handler, name string, isPrivate bool) error
1766+	GetRepoIsHiddenByName(ctx context.Context, h db.Handler, name string) (bool, error)
1767+	SetRepoIsHiddenByName(ctx context.Context, h db.Handler, name string, isHidden bool) error
1768+	GetRepoIsMirrorByName(ctx context.Context, h db.Handler, name string) (bool, error)
1769+}
1770diff --git a/server/store/settings.go b/server/store/settings.go
1771new file mode 100644
1772index 0000000000000000000000000000000000000000..75dd9d7afed808cae3593194a5e964c2e5cdfcc9
1773--- /dev/null
1774+++ b/server/store/settings.go
1775@@ -0,0 +1,16 @@
1776+package store
1777+
1778+import (
1779+	"context"
1780+
1781+	"github.com/charmbracelet/soft-serve/server/access"
1782+	"github.com/charmbracelet/soft-serve/server/db"
1783+)
1784+
1785+// SettingStore is an interface for managing settings.
1786+type SettingStore interface {
1787+	GetAnonAccess(ctx context.Context, h db.Handler) (access.AccessLevel, error)
1788+	SetAnonAccess(ctx context.Context, h db.Handler, level access.AccessLevel) error
1789+	GetAllowKeylessAccess(ctx context.Context, h db.Handler) (bool, error)
1790+	SetAllowKeylessAccess(ctx context.Context, h db.Handler, allow bool) error
1791+}