krz/gitbay

A CLI-first git forge.

clone: git clone https://gitbay.org/krz/gitbay.git

a0dd5878fbda93a49fe7d4503e2ac80fc6f6c8f3

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-08-23T23:17:24Z

M8: web accounts and editing

- SSH-minted browser sessions: web login mints a single-use 5-minute
  URL (token hashes only in the DB, migration 0002); sessions are
  SameSite=Strict HttpOnly cookies plus an Origin check on every POST
- accounts-mode routes registered only when web.mode = accounts: login,
  logout, repo create, issue create/comment, MR comment, file edit;
  route-table tests cover both modes
- logged-in viewers browse repos their grants allow; anonymous behavior
  unchanged (private = 404); index shows the viewer's private repos
- web file edits commit server-side via a temp-index read-tree/
  update-index/write-tree/commit-tree pipeline with update-ref CAS;
  authored with the user's verified primary email and honestly
  unsigned; refused outright on require_signed_commits repos
- web.password_auth rejected by check-config as not implemented
- e2e: full login flow incl. single-use token, web repo create visible
  over ssh, edit verified via repo log (author email + unsigned),
  cross-origin POST refused, logout kills the session, and view_only
  serves 404 for /login with ssh web login refused
 e2e/accounts_test.go                               | 217 +++++++++++++++
 e2e/ssh_test.go                                    |   6 +
 internal/config/config.go                          |   4 +
 internal/config/config_test.go                     |   9 +-
 internal/control/web.go                            |  38 +++
 internal/gitutil/merge.go                          |  57 ++++
 internal/httpd/accounts.go                         | 302 +++++++++++++++++++++
 internal/httpd/routes.go                           |  24 +-
 internal/httpd/routes_test.go                      |  21 +-
 internal/httpd/web.go                              |  50 +++-
 .../store/migrations/0002_login_tokens.down.sql    |   1 +
 internal/store/migrations/0002_login_tokens.up.sql |   7 +
 internal/store/sessions.go                         |  81 ++++++
 internal/web/static/style.css                      |   1 +
 internal/web/templates/blob.html                   |   2 +-
 internal/web/templates/edit.html                   |  11 +
 internal/web/templates/index.html                  |   7 +
 internal/web/templates/issue.html                  |   6 +
 internal/web/templates/login.html                  |   9 +
 internal/web/templates/mr.html                     |   6 +
 internal/web/templates/new.html                    |  11 +
 21 files changed, 858 insertions(+), 12 deletions(-)

diff --git a/e2e/accounts_test.go b/e2e/accounts_test.go
new file mode 100644
index 0000000..984b07c
--- /dev/null
+++ b/e2e/accounts_test.go
@@ -0,0 +1,217 @@
+package e2e
+
+import (
+	"encoding/json"
+	"fmt"
+	"io"
+	"net/http"
+	"net/http/cookiejar"
+	"net/url"
+	"os"
+	"path/filepath"
+	"strings"
+	"testing"
+)
+
+// browser is an HTTP client with a cookie jar, standing in for a logged-in
+// user's browser.
+func newBrowser(t *testing.T) *http.Client {
+	t.Helper()
+	jar, err := cookiejar.New(nil)
+	if err != nil {
+		t.Fatal(err)
+	}
+	return &http.Client{Jar: jar}
+}
+
+func (i *instance) base() string { return fmt.Sprintf("http://127.0.0.1:%d", i.httpPort) }
+
+func browserGet(t *testing.T, c *http.Client, url string) (int, string) {
+	t.Helper()
+	resp, err := c.Get(url)
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer resp.Body.Close()
+	body, _ := io.ReadAll(resp.Body)
+	return resp.StatusCode, string(body)
+}
+
+func browserPost(t *testing.T, c *http.Client, u string, form url.Values) (int, string) {
+	t.Helper()
+	resp, err := c.PostForm(u, form)
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer resp.Body.Close()
+	body, _ := io.ReadAll(resp.Body)
+	return resp.StatusCode, string(body)
+}
+
+func TestWebAccounts(t *testing.T) {
+	inst := startInstanceWith(t, "[web]\nmode = \"accounts\"\n")
+
+	aliceKey := inst.newKey(t, "alice")
+	inst.admin(t, "admin", "user", "create", "alice",
+		"--key", aliceKey+".pub", "--email", "alice@example.test", "--verified")
+
+	// A repo with one file to edit.
+	if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/site"); code != 0 {
+		t.Fatalf("repo create: %s", errOut)
+	}
+	work := t.TempDir()
+	env := inst.gitEnv(aliceKey)
+	mustGit(t, work, env, "clone", inst.sshURL("alice/site"), "w")
+	dir := filepath.Join(work, "w")
+	os.WriteFile(filepath.Join(dir, "notes.txt"), []byte("original\n"), 0o644)
+	mustGit(t, dir, env, "checkout", "-q", "-b", "main")
+	mustGit(t, dir, env, "add", ".")
+	mustGit(t, dir, env, "commit", "-q", "-m", "base")
+	mustGit(t, dir, env, "push", "-q", "origin", "main")
+
+	// SSH-minted login URL.
+	out, errOut, code := inst.ssh(t, aliceKey, "", "web", "login", "--json")
+	if code != 0 {
+		t.Fatalf("web login: %s", errOut)
+	}
+	var env2 struct {
+		Data struct {
+			URL string `json:"url"`
+		} `json:"data"`
+	}
+	if err := json.Unmarshal([]byte(out), &env2); err != nil {
+		t.Fatalf("web login JSON: %v\n%s", err, out)
+	}
+	// The URL carries the configured site host; rewrite to the test port.
+	loginPath := env2.Data.URL[strings.Index(env2.Data.URL, "/login"):]
+
+	browser := newBrowser(t)
+	status, body := browserGet(t, browser, inst.base()+loginPath)
+	if status != 200 || !strings.Contains(body, "logged in as alice") {
+		t.Fatalf("login redirect landed wrong: %d\n%s", status, body)
+	}
+
+	// The token is single-use.
+	fresh := newBrowser(t)
+	_, body = browserGet(t, fresh, inst.base()+loginPath)
+	if !strings.Contains(body, "invalid, expired, or already used") {
+		t.Fatalf("token reuse not refused:\n%s", body)
+	}
+
+	// Create a repo through the web.
+	status, _ = browserPost(t, browser, inst.base()+"/new",
+		url.Values{"name": {"webborn"}, "visibility": {"private"}})
+	if status != 200 {
+		t.Fatalf("web repo create: %d", status)
+	}
+	if out, _, code := inst.ssh(t, aliceKey, "", "repo", "show", "alice/webborn"); code != 0 {
+		t.Fatalf("web-created repo missing over ssh: %s", out)
+	}
+
+	// Logged-in viewer sees their private repo; anonymous still gets 404.
+	if status, _ = browserGet(t, browser, inst.base()+"/alice/webborn"); status != 200 {
+		t.Fatalf("owner blocked from private repo page: %d", status)
+	}
+	if status, _ := inst.get(t, "/alice/webborn"); status != 404 {
+		t.Fatalf("anonymous sees private repo: %d", status)
+	}
+
+	// File edit: form loads with current content, POST commits.
+	status, body = browserGet(t, browser, inst.base()+"/alice/site/edit/main/notes.txt")
+	if status != 200 || !strings.Contains(body, "original") {
+		t.Fatalf("edit form: %d\n%s", status, body)
+	}
+	status, _ = browserPost(t, browser, inst.base()+"/alice/site/edit/main/notes.txt",
+		url.Values{"content": {"edited from the web\n"}, "message": {"web edit"}})
+	if status != 200 {
+		t.Fatalf("edit submit: %d", status)
+	}
+
+	// The edit is a real commit: authored with the verified email, and it
+	// displays as unsigned — the honest outcome for a server-side commit.
+	logOut, _, code := inst.ssh(t, aliceKey, "", "repo", "log", "alice/site", "--limit", "1", "--json")
+	if code != 0 {
+		t.Fatal("repo log failed")
+	}
+	var logEnv struct {
+		Data []struct {
+			Subject     string `json:"subject"`
+			AuthorEmail string `json:"author_email"`
+			Signature   struct {
+				State string `json:"state"`
+			} `json:"signature"`
+		} `json:"data"`
+	}
+	if err := json.Unmarshal([]byte(logOut), &logEnv); err != nil || len(logEnv.Data) == 0 {
+		t.Fatalf("log JSON: %v\n%s", err, logOut)
+	}
+	tip := logEnv.Data[0]
+	if tip.Subject != "web edit" || tip.AuthorEmail != "alice@example.test" || tip.Signature.State != "unsigned" {
+		t.Fatalf("web edit commit wrong: %+v", tip)
+	}
+	if status, body = browserGet(t, browser, inst.base()+"/alice/site/raw/main/notes.txt"); !strings.Contains(body, "edited from the web") {
+		t.Fatalf("edited content not served: %d %q", status, body)
+	}
+
+	// A require-signed repo refuses web edits instead of violating itself.
+	if _, _, code := inst.ssh(t, aliceKey, "", "repo", "settings", "require-signed", "alice/site", "on"); code != 0 {
+		t.Fatal("require-signed failed")
+	}
+	_, body = browserPost(t, browser, inst.base()+"/alice/site/edit/main/notes.txt",
+		url.Values{"content": {"x"}, "message": {"x"}})
+	if !strings.Contains(body, "requires signed commits") {
+		t.Fatalf("require-signed web edit not refused:\n%s", body)
+	}
+
+	// Issue participation through the web.
+	if _, _, code := inst.ssh(t, aliceKey, "", "issue", "create", "alice/site", "--title", "'from ssh'"); code != 0 {
+		t.Fatal("issue create failed")
+	}
+	status, _ = browserPost(t, browser, inst.base()+"/alice/site/issues/1/comment",
+		url.Values{"body": {"web comment"}})
+	if status != 200 {
+		t.Fatalf("web comment: %d", status)
+	}
+	showOut, _, _ := inst.ssh(t, aliceKey, "", "issue", "show", "alice/site", "1")
+	if !strings.Contains(showOut, "web comment") {
+		t.Fatalf("web comment missing over ssh:\n%s", showOut)
+	}
+
+	// Cross-origin POSTs are refused.
+	req, _ := http.NewRequest("POST", inst.base()+"/alice/site/issues/1/comment",
+		strings.NewReader("body=evil"))
+	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+	req.Header.Set("Origin", "https://evil.example")
+	resp, err := browser.Do(req)
+	if err != nil {
+		t.Fatal(err)
+	}
+	resp.Body.Close()
+	if resp.StatusCode != 403 {
+		t.Fatalf("cross-origin POST: %d, want 403", resp.StatusCode)
+	}
+
+	// Logout kills the session.
+	if status, _ = browserPost(t, browser, inst.base()+"/logout", url.Values{}); status != 200 {
+		t.Fatalf("logout: %d", status)
+	}
+	if status, _ = browserGet(t, browser, inst.base()+"/alice/webborn"); status != 404 {
+		t.Fatalf("session survived logout: %d", status)
+	}
+}
+
+// TestViewOnlyHasNoLoginOnTheWire is the M8 negative: in view_only mode the
+// login route does not exist and web login over ssh is refused.
+func TestViewOnlyHasNoLoginOnTheWire(t *testing.T) {
+	inst := startInstance(t) // default: view_only
+	aliceKey := inst.newKey(t, "alice")
+	inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub")
+
+	if status, _ := inst.get(t, "/login"); status != 404 {
+		t.Fatalf("view_only /login = %d, want 404", status)
+	}
+	_, errOut, code := inst.ssh(t, aliceKey, "", "web", "login")
+	if code != 4 || !strings.Contains(errOut, "view-only") {
+		t.Fatalf("web login in view_only: exit %d, %s", code, errOut)
+	}
+}
diff --git a/e2e/ssh_test.go b/e2e/ssh_test.go
index 9877ec2..ce05e88 100644
--- a/e2e/ssh_test.go
+++ b/e2e/ssh_test.go
@@ -46,6 +46,11 @@ func freePort(t *testing.T) int {
 }
 
 func startInstance(t *testing.T) *instance {
+	return startInstanceWith(t, "")
+}
+
+// startInstanceWith appends extra TOML to the instance config.
+func startInstanceWith(t *testing.T, extra string) *instance {
 	t.Helper()
 	inst := &instance{
 		forged:   buildForged(t),
@@ -69,6 +74,7 @@ tls = "off"
 enabled = true
 port = %d
 `, inst.root, inst.port, inst.httpPort, inst.gitPort)
+	cfg += extra + "\n"
 	if err := os.WriteFile(inst.config, []byte(cfg), 0o600); err != nil {
 		t.Fatal(err)
 	}
diff --git a/internal/config/config.go b/internal/config/config.go
index 847b00c..382d501 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -152,6 +152,10 @@ func (c Config) Validate() error {
 		errs = append(errs, errors.New(
 			"web.password_auth = true is meaningless with web.mode = \"view_only\": no login route exists"))
 	}
+	if c.Web.PasswordAuth && c.Web.Mode == "accounts" {
+		errs = append(errs, errors.New(
+			"web.password_auth is not implemented yet; browser sessions are minted over SSH (forge web login)"))
+	}
 
 	return errors.Join(errs...)
 }
diff --git a/internal/config/config_test.go b/internal/config/config_test.go
index d827108..89cc5a9 100644
--- a/internal/config/config_test.go
+++ b/internal/config/config_test.go
@@ -60,6 +60,11 @@ func TestContradictions(t *testing.T) {
 			minimal + "\n[web]\nmode = \"view_only\"\npassword_auth = true\n",
 			"password_auth",
 		},
+		{
+			"password auth not implemented",
+			minimal + "\n[web]\nmode = \"accounts\"\npassword_auth = true\n",
+			"not implemented",
+		},
 		{
 			"bad ssh mode",
 			minimal + "\n[ssh]\nmode = \"tcp\"\n",
@@ -103,8 +108,8 @@ func TestValidCombinations(t *testing.T) {
 			minimal + "\n[ssh]\nmode = \"system\"\n",
 		},
 		{
-			"accounts web with password auth",
-			minimal + "\n[web]\nmode = \"accounts\"\npassword_auth = true\n",
+			"accounts web without password auth",
+			minimal + "\n[web]\nmode = \"accounts\"\n",
 		},
 		{
 			"closed registration, no smtp at all",
diff --git a/internal/control/web.go b/internal/control/web.go
new file mode 100644
index 0000000..a925d90
--- /dev/null
+++ b/internal/control/web.go
@@ -0,0 +1,38 @@
+package control
+
+import (
+	"fmt"
+	"io"
+	"time"
+
+	"github.com/krazywarez/forge/internal/protocol"
+	"github.com/krazywarez/forge/internal/store"
+)
+
+func newStoredToken() (token, hash string, err error) { return store.NewToken() }
+
+func init() {
+	register(Command{Path: []string{"web", "login"},
+		Summary: "mint a one-time browser login URL", Run: runWebLogin})
+}
+
+func runWebLogin(c *Ctx, args []string) int {
+	if len(args) != 0 {
+		return c.fail(protocol.ExitUsage, "usage: web login [--json]")
+	}
+	if c.Cfg.Web.Mode != "accounts" {
+		return c.fail(protocol.ExitDenied,
+			"this instance runs the web in view-only mode (web.mode = %q); there is nothing to log in to", c.Cfg.Web.Mode)
+	}
+	token, hash, err := newStoredToken()
+	if err != nil {
+		return c.fail(protocol.ExitFailure, "%v", err)
+	}
+	if err := c.Store.CreateLoginToken(c.User.ID, hash, 5*time.Minute); err != nil {
+		return c.fail(protocol.ExitFailure, "%v", err)
+	}
+	url := c.Cfg.Server.SiteURL + "/login?token=" + token
+	return c.emit(map[string]string{"url": url, "expires_in": "5m"}, func(w io.Writer) {
+		fmt.Fprintf(w, "open within 5 minutes (single use):\n%s\n", url)
+	})
+}
diff --git a/internal/gitutil/merge.go b/internal/gitutil/merge.go
index 8fb7a6e..467f8a1 100644
--- a/internal/gitutil/merge.go
+++ b/internal/gitutil/merge.go
@@ -115,3 +115,60 @@ func MergeBase(dir, a, b string) (string, error) {
 	}
 	return strings.TrimSpace(string(out)), nil
 }
+
+// CommitFileChange writes content at path on branch as a new commit and
+// advances the branch with compare-and-swap. Used by web edits; hooks do not
+// run, so callers enforce policy themselves.
+func CommitFileChange(dir, branch, path string, content []byte, name, email, message string) (string, error) {
+	branchRef := "refs/heads/" + branch
+	parent, err := ResolveRef(dir, branchRef)
+	if err != nil {
+		return "", fmt.Errorf("branch %s: %w", branch, err)
+	}
+
+	// Hash the new blob.
+	hb := exec.Command("git", "-C", dir, "hash-object", "-w", "--stdin")
+	hb.Stdin = strings.NewReader(string(content))
+	out, err := hb.Output()
+	if err != nil {
+		return "", fmt.Errorf("hash-object: %w", err)
+	}
+	blob := strings.TrimSpace(string(out))
+
+	// Stage the parent tree in a temporary index, splice the blob in, and
+	// write the new tree.
+	idx, err := os.CreateTemp("", "forge-index-*")
+	if err != nil {
+		return "", err
+	}
+	idx.Close()
+	defer os.Remove(idx.Name())
+	env := append(os.Environ(), "GIT_INDEX_FILE="+idx.Name())
+
+	rt := exec.Command("git", "-C", dir, "read-tree", parent+"^{tree}")
+	rt.Env = env
+	if out, err := rt.CombinedOutput(); err != nil {
+		return "", fmt.Errorf("read-tree: %v\n%s", err, out)
+	}
+	ui := exec.Command("git", "-C", dir, "update-index", "--add", "--cacheinfo", "100644,"+blob+","+path)
+	ui.Env = env
+	if out, err := ui.CombinedOutput(); err != nil {
+		return "", fmt.Errorf("update-index: %v\n%s", err, out)
+	}
+	wt := exec.Command("git", "-C", dir, "write-tree")
+	wt.Env = env
+	out, err = wt.Output()
+	if err != nil {
+		return "", fmt.Errorf("write-tree: %w", err)
+	}
+	tree := strings.TrimSpace(string(out))
+
+	sha, err := CommitTree(dir, tree, []string{parent}, name, email, message)
+	if err != nil {
+		return "", err
+	}
+	if err := UpdateRefCAS(dir, branchRef, sha, parent); err != nil {
+		return "", fmt.Errorf("branch moved during edit; reload and retry: %w", err)
+	}
+	return sha, nil
+}
diff --git a/internal/httpd/accounts.go b/internal/httpd/accounts.go
new file mode 100644
index 0000000..15b05db
--- /dev/null
+++ b/internal/httpd/accounts.go
@@ -0,0 +1,302 @@
+package httpd
+
+import (
+	"fmt"
+	"net/http"
+	"strconv"
+	"strings"
+	"time"
+
+	"github.com/krazywarez/forge/internal/control"
+	"github.com/krazywarez/forge/internal/gitutil"
+	"github.com/krazywarez/forge/internal/policy"
+	"github.com/krazywarez/forge/internal/store"
+)
+
+const sessionCookie = "forge_session"
+
+// viewer returns the logged-in user, or a zero User for anonymous visitors.
+// Only meaningful in accounts mode; in view_only no session route exists so
+// every request is anonymous.
+func (s *Server) viewer(r *http.Request) store.User {
+	ck, err := r.Cookie(sessionCookie)
+	if err != nil {
+		return store.User{}
+	}
+	u, err := s.st.WebSessionUser(store.HashToken(ck.Value))
+	if err != nil {
+		return store.User{}
+	}
+	return u
+}
+
+// requireUser wraps a handler that needs a session.
+func (s *Server) requireUser(h func(http.ResponseWriter, *http.Request, store.User)) http.HandlerFunc {
+	return func(w http.ResponseWriter, r *http.Request) {
+		u := s.viewer(r)
+		if u.ID == 0 {
+			http.Redirect(w, r, "/login", http.StatusSeeOther)
+			return
+		}
+		h(w, r, u)
+	}
+}
+
+// checkOrigin rejects cross-site POSTs. Sessions also use SameSite=Strict;
+// this is the second layer.
+func (s *Server) checkOrigin(h http.HandlerFunc) http.HandlerFunc {
+	return func(w http.ResponseWriter, r *http.Request) {
+		if origin := r.Header.Get("Origin"); origin != "" && origin != "null" {
+			host := strings.TrimPrefix(strings.TrimPrefix(origin, "https://"), "http://")
+			if host != r.Host {
+				http.Error(w, "cross-origin request refused", http.StatusForbidden)
+				return
+			}
+		}
+		h(w, r)
+	}
+}
+
+func (s *Server) login(w http.ResponseWriter, r *http.Request) {
+	token := r.URL.Query().Get("token")
+	if token == "" {
+		s.render(w, "login.html", struct {
+			Site  string
+			Error string
+		}{s.siteName(), ""})
+		return
+	}
+	userID, err := s.st.ConsumeLoginToken(store.HashToken(token))
+	if err != nil {
+		s.render(w, "login.html", struct {
+			Site  string
+			Error string
+		}{s.siteName(), "that login link is invalid, expired, or already used — mint a new one"})
+		return
+	}
+	sessTok, sessHash, err := store.NewToken()
+	if err != nil {
+		http.Error(w, "internal error", http.StatusInternalServerError)
+		return
+	}
+	if err := s.st.CreateWebSession(sessHash, userID, 7*24*time.Hour); err != nil {
+		http.Error(w, "internal error", http.StatusInternalServerError)
+		return
+	}
+	http.SetCookie(w, &http.Cookie{
+		Name: sessionCookie, Value: sessTok, Path: "/",
+		HttpOnly: true, SameSite: http.SameSiteStrictMode,
+		Secure: s.cfg.HTTP.TLS != "off",
+		MaxAge: 7 * 24 * 3600,
+	})
+	http.Redirect(w, r, "/", http.StatusSeeOther)
+}
+
+func (s *Server) logout(w http.ResponseWriter, r *http.Request) {
+	if ck, err := r.Cookie(sessionCookie); err == nil {
+		s.st.DeleteWebSession(store.HashToken(ck.Value))
+	}
+	http.SetCookie(w, &http.Cookie{Name: sessionCookie, Value: "", Path: "/", MaxAge: -1})
+	http.Redirect(w, r, "/", http.StatusSeeOther)
+}
+
+func (s *Server) newRepoForm(w http.ResponseWriter, r *http.Request, u store.User) {
+	s.render(w, "new.html", struct {
+		Site   string
+		Viewer string
+		Error  string
+	}{s.siteName(), u.Username, ""})
+}
+
+func (s *Server) newRepoSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
+	name := r.FormValue("name")
+	visibility := "public"
+	if r.FormValue("visibility") == "private" {
+		visibility = "private"
+	}
+	fail := func(msg string) {
+		s.render(w, "new.html", struct {
+			Site   string
+			Viewer string
+			Error  string
+		}{s.siteName(), u.Username, msg})
+	}
+	if err := policy.ValidateName(name); err != nil {
+		fail(err.Error())
+		return
+	}
+	id, err := s.st.CreateRepo("user", u.ID, name, visibility)
+	if err != nil {
+		fail(err.Error())
+		return
+	}
+	dir := control.RepoDir(s.cfg.Server.Root, u.Username, name)
+	if err := gitutil.InitBare(dir, "main", control.HooksDir(s.cfg.Server.Root)); err != nil {
+		s.st.DeleteRepo(id)
+		fail("initializing repository failed")
+		return
+	}
+	http.Redirect(w, r, "/"+u.Username+"/"+name, http.StatusSeeOther)
+}
+
+// repoForUser is repoFor with a write/read permission requirement for a
+// logged-in user.
+func (s *Server) repoForUser(w http.ResponseWriter, r *http.Request, u store.User,
+	perm func(store.User, store.Repo, string) bool) (store.Repo, bool) {
+	repo, err := s.st.RepoByPath(r.PathValue("owner") + "/" + r.PathValue("repo"))
+	if err != nil {
+		http.NotFound(w, r)
+		return store.Repo{}, false
+	}
+	grant, err := s.st.AccessRole(repo.ID, u.ID)
+	if err != nil {
+		http.Error(w, "internal error", http.StatusInternalServerError)
+		return store.Repo{}, false
+	}
+	if !policy.CanRead(u, repo, grant) {
+		http.NotFound(w, r) // invisible: same as nonexistent
+		return store.Repo{}, false
+	}
+	if !perm(u, repo, grant) {
+		http.Error(w, "permission denied", http.StatusForbidden)
+		return store.Repo{}, false
+	}
+	return repo, true
+}
+
+func (s *Server) issueCreateSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
+	repo, ok := s.repoForUser(w, r, u, policy.CanRead)
+	if !ok {
+		return
+	}
+	title := strings.TrimSpace(r.FormValue("title"))
+	if title == "" {
+		http.Error(w, "title required", http.StatusBadRequest)
+		return
+	}
+	n, err := s.st.CreateIssue(repo.ID, u.ID, title, r.FormValue("body"))
+	if err != nil {
+		http.Error(w, "internal error", http.StatusInternalServerError)
+		return
+	}
+	http.Redirect(w, r, fmt.Sprintf("/%s/issues/%d", repo.Path(), n), http.StatusSeeOther)
+}
+
+func (s *Server) issueCommentSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
+	repo, ok := s.repoForUser(w, r, u, policy.CanRead)
+	if !ok {
+		return
+	}
+	n, _ := strconv.ParseInt(r.PathValue("n"), 10, 64)
+	iss, err := s.st.IssueByNumber(repo.ID, n)
+	if err != nil {
+		http.NotFound(w, r)
+		return
+	}
+	body := strings.TrimSpace(r.FormValue("body"))
+	if body == "" {
+		http.Error(w, "empty comment", http.StatusBadRequest)
+		return
+	}
+	if err := s.st.AddIssueComment(iss.ID, u.ID, body); err != nil {
+		http.Error(w, "internal error", http.StatusInternalServerError)
+		return
+	}
+	http.Redirect(w, r, fmt.Sprintf("/%s/issues/%d", repo.Path(), n), http.StatusSeeOther)
+}
+
+func (s *Server) mrCommentSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
+	repo, ok := s.repoForUser(w, r, u, policy.CanRead)
+	if !ok {
+		return
+	}
+	n, _ := strconv.ParseInt(r.PathValue("n"), 10, 64)
+	m, err := s.st.MRByNumber(repo.ID, n)
+	if err != nil {
+		http.NotFound(w, r)
+		return
+	}
+	body := strings.TrimSpace(r.FormValue("body"))
+	if body == "" {
+		http.Error(w, "empty comment", http.StatusBadRequest)
+		return
+	}
+	if err := s.st.AddMRComment(m.ID, u.ID, body); err != nil {
+		http.Error(w, "internal error", http.StatusInternalServerError)
+		return
+	}
+	http.Redirect(w, r, fmt.Sprintf("/%s/mrs/%d", repo.Path(), n), http.StatusSeeOther)
+}
+
+type editPage struct {
+	Site    string
+	Viewer  string
+	Repo    store.Repo
+	Ref     string
+	Path    string
+	Content string
+	Error   string
+}
+
+func (s *Server) editForm(w http.ResponseWriter, r *http.Request, u store.User) {
+	repo, ok := s.repoForUser(w, r, u, policy.CanWrite)
+	if !ok {
+		return
+	}
+	ref := r.PathValue("ref")
+	filePath := strings.Trim(r.PathValue("path"), "/")
+	dir := control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name)
+	content, err := gitutil.ReadBlob(dir, "refs/heads/"+ref, filePath, maxRenderBytes)
+	if err != nil {
+		content = nil // new file
+	}
+	if gitutil.IsBinary(content) {
+		http.Error(w, "binary files cannot be edited in the browser", http.StatusBadRequest)
+		return
+	}
+	s.render(w, "edit.html", editPage{
+		Site: s.siteName(), Viewer: u.Username, Repo: repo,
+		Ref: ref, Path: filePath, Content: string(content),
+	})
+}
+
+func (s *Server) editSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
+	repo, ok := s.repoForUser(w, r, u, policy.CanWrite)
+	if !ok {
+		return
+	}
+	ref := r.PathValue("ref")
+	filePath := strings.Trim(r.PathValue("path"), "/")
+	fail := func(msg string) {
+		s.render(w, "edit.html", editPage{
+			Site: s.siteName(), Viewer: u.Username, Repo: repo,
+			Ref: ref, Path: filePath, Content: r.FormValue("content"), Error: msg,
+		})
+	}
+	// Web edits produce unsigned commits; a repo that requires signed
+	// commits must refuse them rather than violate its own policy.
+	if repo.Settings.RequireSignedCommits {
+		fail("this repository requires signed commits; web edits are unsigned — push a signed commit over SSH instead")
+		return
+	}
+	email, err := s.st.PrimaryVerifiedEmail(u.ID)
+	if err != nil {
+		fail("internal error")
+		return
+	}
+	if email == "" {
+		fail("commits carry your identity: your account needs a verified primary email")
+		return
+	}
+	message := strings.TrimSpace(r.FormValue("message"))
+	if message == "" {
+		message = "edit " + filePath
+	}
+	dir := control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name)
+	if _, err := gitutil.CommitFileChange(dir, ref, filePath,
+		[]byte(r.FormValue("content")), u.Username, email, message); err != nil {
+		fail(err.Error())
+		return
+	}
+	http.Redirect(w, r, fmt.Sprintf("/%s/blob/%s/%s", repo.Path(), ref, filePath), http.StatusSeeOther)
+}
diff --git a/internal/httpd/routes.go b/internal/httpd/routes.go
index 8377e1d..cbdd625 100644
--- a/internal/httpd/routes.go
+++ b/internal/httpd/routes.go
@@ -43,8 +43,28 @@ func (s *Server) Routes() []Route {
 		Route{Method: "GET", Pattern: "/{owner}/{repo}/mrs/{n}", Handler: s.mr},
 	)
 
-	// Account-mode routes (login, web edits) are appended here in M8 —
-	// and only when s.cfg.Web.Mode == "accounts".
+	// Account-mode routes exist only when web.mode = "accounts". In
+	// view_only they are never registered — the structural guarantee.
+	if s.cfg.Web.Mode == "accounts" {
+		routes = append(routes,
+			Route{Method: "GET", Pattern: "/login", Handler: s.login, Mutating: true}, // consumes a one-time token
+			Route{Method: "POST", Pattern: "/logout", Mutating: true,
+				Handler: s.checkOrigin(s.logout)},
+			Route{Method: "GET", Pattern: "/new", Handler: s.requireUser(s.newRepoForm)},
+			Route{Method: "POST", Pattern: "/new", Mutating: true,
+				Handler: s.checkOrigin(s.requireUser(s.newRepoSubmit))},
+			Route{Method: "POST", Pattern: "/{owner}/{repo}/issues/new", Mutating: true,
+				Handler: s.checkOrigin(s.requireUser(s.issueCreateSubmit))},
+			Route{Method: "POST", Pattern: "/{owner}/{repo}/issues/{n}/comment", Mutating: true,
+				Handler: s.checkOrigin(s.requireUser(s.issueCommentSubmit))},
+			Route{Method: "POST", Pattern: "/{owner}/{repo}/mrs/{n}/comment", Mutating: true,
+				Handler: s.checkOrigin(s.requireUser(s.mrCommentSubmit))},
+			Route{Method: "GET", Pattern: "/{owner}/{repo}/edit/{ref}/{path...}",
+				Handler: s.requireUser(s.editForm)},
+			Route{Method: "POST", Pattern: "/{owner}/{repo}/edit/{ref}/{path...}", Mutating: true,
+				Handler: s.checkOrigin(s.requireUser(s.editSubmit))},
+		)
+	}
 	return routes
 }
 
diff --git a/internal/httpd/routes_test.go b/internal/httpd/routes_test.go
index 577fc3f..c161349 100644
--- a/internal/httpd/routes_test.go
+++ b/internal/httpd/routes_test.go
@@ -33,11 +33,30 @@ func TestViewOnlyHasNoMutatingRoutes(t *testing.T) {
 	}
 }
 
+// TestAccountsModeHasLoginRoute is the positive counterpart: switching the
+// mode on registers the session routes.
+func TestAccountsModeHasLoginRoute(t *testing.T) {
+	cfg := config.Default()
+	cfg.Web.Mode = "accounts"
+	s := New(cfg, nil)
+	found := false
+	for _, r := range s.Routes() {
+		if r.Pattern == "/login" {
+			found = true
+		}
+	}
+	if !found {
+		t.Fatal("accounts mode is missing the /login route")
+	}
+}
+
 // TestTopLevelRouteWordsAreReserved keeps the route table and the reserved
 // username list in agreement: every literal first path segment must be an
 // unclaimable username.
 func TestTopLevelRouteWordsAreReserved(t *testing.T) {
-	s := New(config.Default(), nil)
+	cfg := config.Default()
+	cfg.Web.Mode = "accounts" // superset of routes
+	s := New(cfg, nil)
 	for _, r := range s.Routes() {
 		seg := strings.TrimPrefix(r.Pattern, "/")
 		seg, _, _ = strings.Cut(seg, "/")
diff --git a/internal/httpd/web.go b/internal/httpd/web.go
index e68c642..146cbe6 100644
--- a/internal/httpd/web.go
+++ b/internal/httpd/web.go
@@ -3,6 +3,8 @@ package httpd
 import (
 	"bytes"
 	"fmt"
+
+	"github.com/krazywarez/forge/internal/policy"
 	"html/template"
 	"net/http"
 	"path"
@@ -50,15 +52,32 @@ func (s *Server) index(w http.ResponseWriter, r *http.Request) {
 		http.Error(w, "internal error", http.StatusInternalServerError)
 		return
 	}
+	var viewer store.User
+	var mine []store.Repo
+	if s.cfg.Web.Mode == "accounts" {
+		if viewer = s.viewer(r); viewer.ID != 0 {
+			all, err := s.st.ListReposForUser(viewer.ID)
+			if err == nil {
+				for _, rp := range all {
+					if rp.Visibility == "private" {
+						mine = append(mine, rp)
+					}
+				}
+			}
+		}
+	}
 	s.render(w, "index.html", struct {
-		Site  string
-		Repos []store.Repo
-	}{s.siteName(), repos})
+		Site   string
+		Viewer string
+		Repos  []store.Repo
+		Mine   []store.Repo
+	}{s.siteName(), viewer.Username, repos, mine})
 }
 
 // repoPage is the shared context for repo-scoped pages.
 type repoPage struct {
 	Site     string
+	Viewer   string
 	Repo     store.Repo
 	Ref      string
 	CloneURL string
@@ -66,10 +85,24 @@ type repoPage struct {
 }
 
 // repoFor resolves the repo for a web request; false means 404 was sent.
-// The anonymous web sees public repos only — private and missing repos are
-// indistinguishable.
+// Anonymous visitors see public repos only; in accounts mode a logged-in
+// viewer additionally sees repos their grants allow. Private and missing
+// repos are indistinguishable either way.
 func (s *Server) repoFor(w http.ResponseWriter, r *http.Request, ref string) (repoPage, bool) {
-	repo, ok := s.publicRepo(r.PathValue("owner"), r.PathValue("repo"))
+	var repo store.Repo
+	var viewer store.User
+	if s.cfg.Web.Mode == "accounts" {
+		viewer = s.viewer(r)
+	}
+	repo, err := s.st.RepoByPath(r.PathValue("owner") + "/" + r.PathValue("repo"))
+	ok := err == nil
+	if ok {
+		grant := ""
+		if viewer.ID != 0 {
+			grant, _ = s.st.AccessRole(repo.ID, viewer.ID)
+		}
+		ok = policyCanRead(viewer, repo, grant)
+	}
 	if !ok {
 		http.NotFound(w, r)
 		return repoPage{}, false
@@ -79,6 +112,7 @@ func (s *Server) repoFor(w http.ResponseWriter, r *http.Request, ref string) (re
 	}
 	return repoPage{
 		Site:     s.siteName(),
+		Viewer:   viewer.Username,
 		Repo:     repo,
 		Ref:      ref,
 		CloneURL: s.cfg.Server.SiteURL + "/" + repo.Path() + ".git",
@@ -511,3 +545,7 @@ func (s *Server) archive(w http.ResponseWriter, r *http.Request) {
 	w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", prefix+".tar.gz"))
 	gitutil.Archive(p.Dir, ref, prefix, w)
 }
+
+func policyCanRead(u store.User, repo store.Repo, grant string) bool {
+	return policy.CanRead(u, repo, grant)
+}
diff --git a/internal/store/migrations/0002_login_tokens.down.sql b/internal/store/migrations/0002_login_tokens.down.sql
new file mode 100644
index 0000000..3a5ac21
--- /dev/null
+++ b/internal/store/migrations/0002_login_tokens.down.sql
@@ -0,0 +1 @@
+DROP TABLE login_tokens;
diff --git a/internal/store/migrations/0002_login_tokens.up.sql b/internal/store/migrations/0002_login_tokens.up.sql
new file mode 100644
index 0000000..8257d93
--- /dev/null
+++ b/internal/store/migrations/0002_login_tokens.up.sql
@@ -0,0 +1,7 @@
+CREATE TABLE login_tokens (
+    token_hash TEXT PRIMARY KEY,
+    user_id    INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+    created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
+    expires_at TEXT NOT NULL,
+    used_at    TEXT
+);
diff --git a/internal/store/sessions.go b/internal/store/sessions.go
new file mode 100644
index 0000000..c487001
--- /dev/null
+++ b/internal/store/sessions.go
@@ -0,0 +1,81 @@
+package store
+
+import (
+	"crypto/rand"
+	"crypto/sha256"
+	"database/sql"
+	"encoding/hex"
+	"errors"
+	"time"
+)
+
+// NewToken returns a fresh random token and its storage hash. Only the hash
+// is persisted; the token itself goes to the user once.
+func NewToken() (token, hash string, err error) {
+	var b [32]byte
+	if _, err := rand.Read(b[:]); err != nil {
+		return "", "", err
+	}
+	token = hex.EncodeToString(b[:])
+	return token, HashToken(token), nil
+}
+
+func HashToken(token string) string {
+	sum := sha256.Sum256([]byte(token))
+	return hex.EncodeToString(sum[:])
+}
+
+func fmtTime(t time.Time) string { return t.UTC().Format("2006-01-02T15:04:05.000Z") }
+
+// CreateLoginToken stores a one-time login token hash.
+func (s *Store) CreateLoginToken(userID int64, hash string, ttl time.Duration) error {
+	_, err := s.DB.Exec(
+		"INSERT INTO login_tokens (token_hash, user_id, expires_at) VALUES (?, ?, ?)",
+		hash, userID, fmtTime(time.Now().Add(ttl)))
+	return err
+}
+
+// ConsumeLoginToken redeems a token exactly once; expired or used tokens
+// fail identically.
+func (s *Store) ConsumeLoginToken(hash string) (int64, error) {
+	res, err := s.DB.Exec(`
+		UPDATE login_tokens SET used_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')
+		WHERE token_hash = ? AND used_at IS NULL AND expires_at > ?`,
+		hash, fmtTime(time.Now()))
+	if err != nil {
+		return 0, err
+	}
+	if n, _ := res.RowsAffected(); n == 0 {
+		return 0, ErrNotFound
+	}
+	var userID int64
+	err = s.DB.QueryRow("SELECT user_id FROM login_tokens WHERE token_hash = ?", hash).Scan(&userID)
+	return userID, err
+}
+
+func (s *Store) CreateWebSession(hash string, userID int64, ttl time.Duration) error {
+	_, err := s.DB.Exec(
+		"INSERT INTO web_sessions (token_hash, user_id, expires_at) VALUES (?, ?, ?)",
+		hash, userID, fmtTime(time.Now().Add(ttl)))
+	return err
+}
+
+// WebSessionUser resolves a session cookie hash to its user.
+func (s *Store) WebSessionUser(hash string) (User, error) {
+	var userID int64
+	err := s.DB.QueryRow(
+		"SELECT user_id FROM web_sessions WHERE token_hash = ? AND expires_at > ?",
+		hash, fmtTime(time.Now())).Scan(&userID)
+	if errors.Is(err, sql.ErrNoRows) {
+		return User{}, ErrNotFound
+	}
+	if err != nil {
+		return User{}, err
+	}
+	return s.UserByID(userID)
+}
+
+func (s *Store) DeleteWebSession(hash string) error {
+	_, err := s.DB.Exec("DELETE FROM web_sessions WHERE token_hash = ?", hash)
+	return err
+}
diff --git a/internal/web/static/style.css b/internal/web/static/style.css
index 12f398d..28d5afb 100644
--- a/internal/web/static/style.css
+++ b/internal/web/static/style.css
@@ -40,6 +40,7 @@ pre.diff .add { color: var(--ok); }
 pre.diff .del { color: var(--bad); }
 pre.diff .hunk { color: var(--link); }
 pre.diff .meta { color: var(--muted); }
+.error { color: var(--bad); }
 .badge {
   display: inline-block; padding: 0.05rem 0.5rem; border-radius: 10px;
   font-size: 12px; border: 1px solid;
diff --git a/internal/web/templates/blob.html b/internal/web/templates/blob.html
index 3297f00..52f0b61 100644
--- a/internal/web/templates/blob.html
+++ b/internal/web/templates/blob.html
@@ -2,7 +2,7 @@
 {{define "content"}}
 {{template "repoheader" .}}
 <p class="crumbs">{{.Ref}}: {{range .Crumbs}}<a href="{{.URL}}">{{.Name}}</a>/{{end}}{{.Base}}
- · <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/raw/{{.Ref}}/{{.Path}}">raw</a></p>
+ · <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/raw/{{.Ref}}/{{.Path}}">raw</a>{{if .Viewer}} · <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/edit/{{.Ref}}/{{.Path}}">edit</a>{{end}}</p>
 {{if .Binary}}<p>binary file, {{.Size}} bytes — <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/raw/{{.Ref}}/{{.Path}}">download</a></p>
 {{else}}<div class="code">{{.CodeHTML}}</div>{{end}}
 {{end}}
diff --git a/internal/web/templates/edit.html b/internal/web/templates/edit.html
new file mode 100644
index 0000000..ad3da8d
--- /dev/null
+++ b/internal/web/templates/edit.html
@@ -0,0 +1,11 @@
+{{define "title"}}edit {{.Path}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
+{{define "content"}}
+<h1>edit {{.Repo.OwnerName}}/{{.Repo.Name}} : {{.Path}} @ {{.Ref}}</h1>
+{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
+<form method="post" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/edit/{{.Ref}}/{{.Path}}">
+<p><textarea name="content" rows="24" style="width:100%" spellcheck="false">{{.Content}}</textarea></p>
+<p><input name="message" placeholder="commit message" style="width:60%">
+<button type="submit">commit to {{.Ref}}</button></p>
+<p class="crumbs">this commit will be unsigned and authored as {{.Viewer}}</p>
+</form>
+{{end}}
diff --git a/internal/web/templates/index.html b/internal/web/templates/index.html
index 2bc620c..b04d8b8 100644
--- a/internal/web/templates/index.html
+++ b/internal/web/templates/index.html
@@ -1,8 +1,15 @@
 {{define "title"}}{{.Site}}{{end}}
 {{define "content"}}
+{{if .Viewer}}<p class="crumbs">logged in as {{.Viewer}} · <a href="/new">new repository</a> ·
+<form method="post" action="/logout" style="display:inline"><button type="submit">logout</button></form></p>{{end}}
 <h1>repositories</h1>
 <table>
 {{range .Repos}}<tr><td><a href="/{{.OwnerName}}/{{.Name}}">{{.OwnerName}}/{{.Name}}</a></td><td>{{.DefaultBranch}}</td></tr>
 {{else}}<tr><td>no public repositories</td></tr>{{end}}
 </table>
+{{if .Mine}}<h2>your private repositories</h2>
+<table>
+{{range .Mine}}<tr><td><a href="/{{.OwnerName}}/{{.Name}}">{{.OwnerName}}/{{.Name}}</a></td><td>{{.Visibility}}</td></tr>
+{{end}}
+</table>{{end}}
 {{end}}
diff --git a/internal/web/templates/issue.html b/internal/web/templates/issue.html
index ceb1b61..a5f4549 100644
--- a/internal/web/templates/issue.html
+++ b/internal/web/templates/issue.html
@@ -9,4 +9,10 @@
 {{range .Comments}}
 <div class="readme"><p class="crumbs">{{.Author}} at {{.CreatedAt}}</p><pre class="message">{{.Body}}</pre></div>
 {{end}}
+{{if .Viewer}}
+<form method="post" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/issues/{{.Issue.Number}}/comment">
+<p><textarea name="body" rows="4" style="width:100%" placeholder="comment as {{.Viewer}}"></textarea></p>
+<p><button type="submit">comment</button></p>
+</form>
+{{end}}
 {{end}}
diff --git a/internal/web/templates/login.html b/internal/web/templates/login.html
new file mode 100644
index 0000000..7a0f724
--- /dev/null
+++ b/internal/web/templates/login.html
@@ -0,0 +1,9 @@
+{{define "title"}}login · {{.Site}}{{end}}
+{{define "content"}}
+<h1>log in</h1>
+{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
+<p>Browser sessions are minted over SSH — there is no password. From a machine
+with your registered key:</p>
+<pre class="message">ssh git@{{.Site}} web login</pre>
+<p>then open the printed URL within five minutes.</p>
+{{end}}
diff --git a/internal/web/templates/mr.html b/internal/web/templates/mr.html
index 81fd089..8a9046e 100644
--- a/internal/web/templates/mr.html
+++ b/internal/web/templates/mr.html
@@ -9,6 +9,12 @@
 {{range .Comments}}
 <div class="readme"><p class="crumbs">{{.Author}} at {{.CreatedAt}}</p><pre class="message">{{.Body}}</pre></div>
 {{end}}
+{{if .Viewer}}
+<form method="post" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/mrs/{{.MR.Number}}/comment">
+<p><textarea name="body" rows="4" style="width:100%" placeholder="comment as {{.Viewer}}"></textarea></p>
+<p><button type="submit">comment</button></p>
+</form>
+{{end}}
 <h3>diff</h3>
 <pre class="diff">{{range .DiffLines}}<span class="{{.Class}}">{{.Text}}</span>
 {{end}}</pre>
diff --git a/internal/web/templates/new.html b/internal/web/templates/new.html
new file mode 100644
index 0000000..0310e23
--- /dev/null
+++ b/internal/web/templates/new.html
@@ -0,0 +1,11 @@
+{{define "title"}}new repository · {{.Site}}{{end}}
+{{define "content"}}
+<h1>new repository</h1>
+{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
+<form method="post" action="/new">
+<p><label>name <input name="name" required pattern="[a-z0-9][a-z0-9._-]*"></label> (under {{.Viewer}}/)</p>
+<p><label><input type="radio" name="visibility" value="public" checked> public</label>
+   <label><input type="radio" name="visibility" value="private"> private</label></p>
+<p><button type="submit">create</button></p>
+</form>
+{{end}}