diff --git a/CHANGES.md b/CHANGES.md
index 04cce0d..e19ab28 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -1,5 +1,23 @@
 # Changelog
 
+## 0.3.1 (2026-10-18)
+
+### pygittools
+
+- `pgt` changes for less aggressive scanning, particularly helpful when working in large repos.
+
+### pygitweb
+
+- New Logo!
+- Restyle the login page in the Tabler layout.
+- Drop per-branch read checks and the `pre-receive.branch-permissions` hook. Project read still gates visibility, and a user who can see a project can see every object in it.
+- Enforce ref push rules in Smart HTTP. `pgw.write_branch` still covers `refs/heads`. New `pgw.write_ref` scopes cover any ref under `refs/` (tags, notes, and the rest). Project write remains the fallback when no specific grant matches.
+- Give OAuth sessions a passwordless local account, matched by email, so they can store Smart HTTP keys. A session with no email cannot store keys. If a local account already uses that email, the keys attach to it.
+
+### Tests and docs
+
+- Add Python 3.15 to the version plane, even if dependencies aren't ready yet.
+
 ## 0.3.0 (2026-10-13)
 
 ### pygittools
diff --git a/pygittools/pyproject.toml b/pygittools/pyproject.toml
index 24332b1..c1fc7a1 100644
--- a/pygittools/pyproject.toml
+++ b/pygittools/pyproject.toml
@@ -1,11 +1,11 @@
 [project]
 name = "pygittools"
-version = "0.3.0"
+version = "0.3.1"
 description = "Hooks and task storage in Git"
 readme = "README.md"
 license = "MIT"
 license-files = ["LICENSE"]
-requires-python = ">=3.11"
+requires-python = ">=3.12"
 dependencies = [
     "abcreg==0.2.0",
     "pygit2>=1.12.0",
diff --git a/pygittools/src/pygittools/__meta__.py b/pygittools/src/pygittools/__meta__.py
index d7f9fa4..f98a95c 100644
--- a/pygittools/src/pygittools/__meta__.py
+++ b/pygittools/src/pygittools/__meta__.py
@@ -2,7 +2,7 @@
 Metadata for PyGitTools - this is the canonical source of all information below.
 """
 
-__version__ = "0.2.0"
+__version__ = "0.3.1"
 __author__ = "Will Bowers"
 __license__ = "MIT"
 __description__ = "Python hooks and metadata storage in Git"
diff --git a/pygittools/src/pygittools/actions_test.py b/pygittools/src/pygittools/actions_test.py
index d9ba003..c4ddafb 100644
--- a/pygittools/src/pygittools/actions_test.py
+++ b/pygittools/src/pygittools/actions_test.py
@@ -90,7 +90,7 @@ def test_workspace_python_version_set() -> None:
 	root = Path(__file__).resolve().parents[3]
 	sets = {item.name: item for item in load_action_sets(root)}
 	versions = sets["testversions"]
-	assert [spec.args for spec in versions.specs] == [("3.12",), ("3.13",), ("3.14",)]
+	assert [spec.args for spec in versions.specs] == [("3.12",), ("3.13",), ("3.14",), ("3.15",)]
 	assert {spec.script for spec in versions.specs} == {"scripts/pytest_version.py"}
 
 
diff --git a/pygitweb/auth_keys_test.py b/pygitweb/auth_keys_test.py
deleted file mode 100644
index ad12881..0000000
--- a/pygitweb/auth_keys_test.py
+++ /dev/null
@@ -1,308 +0,0 @@
-import json
-from datetime import UTC, datetime, timedelta
-from pathlib import Path
-from unittest.mock import patch
-
-import pytest
-from fastapi.testclient import TestClient
-from pydantic import ValidationError
-from pygitweb.auth import authenticate_http_key, authenticate_local_user
-from pygitweb.auth_config import AuthConfig, LocalUser
-from pygitweb.config import Settings, settings
-from pygitweb.conftest import set_client_session
-from pygitweb.main import app
-from pygitweb.password_hash import is_password_hash, verify_password
-from pygitweb.sessions import create_session
-
-_SALT = "0123456789abcdef"
-
-
-def _config() -> AuthConfig:
-	return AuthConfig(
-		auth_mode="local",
-		salt=_SALT,
-		local_users=[LocalUser(user="admin", password="secret", email="admin@example.com")],
-	)
-
-
-def test_api_key_settings_default_to_two_keys_without_expiry() -> None:
-	cfg = Settings(AUTH=False)
-	assert cfg.API_KEY_MAX_COUNT == 2
-	assert cfg.API_KEY_EXPIRY_DAYS == 0
-	with pytest.raises(ValidationError):
-		Settings(AUTH=False, API_KEY_MAX_COUNT=-1)
-
-
-def test_issue_api_key_returns_plaintext_and_stores_hash(tmp_path: Path) -> None:
-	cfg = _config()
-	path = tmp_path / "auth.json"
-	client = TestClient(app)
-	with (
-		patch.object(settings, "AUTH", True),
-		patch.object(settings, "AUTH_CONFIG", str(path)),
-		patch("pygitweb.auth_config.auth_config", cfg),
-		patch("pygitweb.auth.auth_config", cfg),
-		patch("pygitweb.main.auth_config", cfg),
-	):
-		set_client_session(client, "admin")
-		page = client.get("/user")
-		assert page.status_code == 200
-		assert "API keys" in page.text
-		assert "Create key" in page.text
-		issued = client.post("/user/keys", json={"id": "laptop", "readonly": True})
-		assert issued.status_code == 200, issued.text
-		body = issued.json()
-		assert body["id"] == "laptop"
-		assert body["readonly"] is True
-		plaintext = body["secret"]
-		assert isinstance(plaintext, str)
-		assert not plaintext.startswith("scrypt$")
-		assert body["time_expiry"] is None
-		stored = cfg.local_users[0].keys[0]
-		assert stored.secret != plaintext
-		assert is_password_hash(stored.secret)
-		assert verify_password(plaintext, stored.secret, config_salt=_SALT)
-		on_disk = json.loads(path.read_text(encoding="utf-8"))
-		assert on_disk["local_users"][0]["keys"][0]["secret"] == stored.secret
-		assert plaintext not in path.read_text(encoding="utf-8")
-		matched = authenticate_http_key("admin", plaintext)
-		assert matched is not None
-		assert matched[1] is True
-		again = client.get("/user")
-		assert "laptop" in again.text
-		assert plaintext not in again.text
-
-
-def test_issue_api_key_enforces_limit_expiry_and_duplicate_id(tmp_path: Path) -> None:
-	cfg = _config()
-	path = tmp_path / "auth.json"
-	client = TestClient(app)
-	with (
-		patch.object(settings, "AUTH", True),
-		patch.object(settings, "AUTH_CONFIG", str(path)),
-		patch.object(settings, "API_KEY_MAX_COUNT", 2),
-		patch.object(settings, "API_KEY_EXPIRY_DAYS", 7),
-		patch("pygitweb.auth_config.auth_config", cfg),
-		patch("pygitweb.auth.auth_config", cfg),
-		patch("pygitweb.main.auth_config", cfg),
-	):
-		set_client_session(client, "admin")
-		before = datetime.now(UTC)
-		first = client.post("/user/keys", json={"id": "alpha-key"})
-		assert first.status_code == 200, first.text
-		duplicate = client.post("/user/keys", json={"id": "alpha-key"})
-		second = client.post("/user/keys", json={"id": "beta-key"})
-		assert second.status_code == 200, second.text
-		third = client.post("/user/keys", json={"id": "gamma-key"})
-		assert duplicate.status_code == 409
-		assert third.status_code == 409
-		assert len(cfg.local_users[0].keys) == 2
-		expiry = datetime.fromisoformat(first.json()["time_expiry"])
-		assert before + timedelta(days=7) <= expiry <= datetime.now(UTC) + timedelta(days=7)
-		assert second.json()["time_expiry"] is not None
-		revoked = client.delete("/user/keys", params={"id": "alpha-key"})
-		assert revoked.status_code == 204
-		assert [key.id for key in cfg.local_users[0].keys] == ["beta-key"]
-		page = client.get("/user")
-		assert "alpha-key" not in page.text
-		assert "beta-key" in page.text
-		replaced = client.post("/user/keys", json={"id": "alpha-key"})
-		assert replaced.status_code == 200
-		assert replaced.json()["id"] == "alpha-key"
-
-
-def test_issue_api_key_requires_a_local_account_and_a_session(tmp_path: Path) -> None:
-	cfg = _config()
-	path = tmp_path / "auth.json"
-	client = TestClient(app)
-	with (
-		patch.object(settings, "AUTH", True),
-		patch.object(settings, "AUTH_CONFIG", str(path)),
-		patch("pygitweb.auth_config.auth_config", cfg),
-		patch("pygitweb.auth.auth_config", cfg),
-		patch("pygitweb.main.auth_config", cfg),
-	):
-		missing = client.post("/user/keys", json={"id": "laptop"})
-		assert missing.status_code == 401
-		token = create_session("nobody", auth_method="local")
-		unknown = client.post(
-			"/user/keys",
-			json={"id": "laptop"},
-			headers={"Authorization": f"Bearer {token}"},
-		)
-		assert unknown.status_code == 404
-		page = client.get("/user", headers={"Authorization": f"Bearer {token}"})
-		assert "cannot be stored" in page.text
-		email_token = create_session("other-name", email="Admin@Example.com", auth_method="oauth")
-		by_email = client.post(
-			"/user/keys",
-			json={"id": "mail-key"},
-			headers={"Authorization": f"Bearer {email_token}"},
-		)
-		assert by_email.status_code == 200, by_email.text
-		assert len(cfg.local_users) == 1
-		assert cfg.local_users[0].keys[0].id == "mail-key"
-		missing_key = client.delete(
-			"/user/keys", params={"id": "missing"}, headers={"Authorization": f"Bearer {email_token}"}
-		)
-		assert missing_key.status_code == 404
-		revoke_unknown = client.delete(
-			"/user/keys", params={"id": "mail-key"}, headers={"Authorization": f"Bearer {token}"}
-		)
-		assert revoke_unknown.status_code == 404
-	with patch.object(settings, "AUTH", False):
-		disabled = client.post("/user/keys", json={"id": "laptop"})
-		assert disabled.status_code == 400
-		disabled_revoke = client.delete("/user/keys", params={"id": "laptop"})
-		assert disabled_revoke.status_code == 400
-
-
-def test_issue_api_key_keeps_memory_unchanged_when_save_fails(tmp_path: Path) -> None:
-	cfg = _config()
-	path = tmp_path / "auth.json"
-	client = TestClient(app, raise_server_exceptions=False)
-	with (
-		patch.object(settings, "AUTH", True),
-		patch.object(settings, "AUTH_CONFIG", str(path)),
-		patch("pygitweb.auth_config.auth_config", cfg),
-		patch("pygitweb.auth.auth_config", cfg),
-		patch("pygitweb.main.auth_config", cfg),
-		patch("pygitweb.auth.write_auth_config", side_effect=OSError("disk")),
-	):
-		set_client_session(client, "admin")
-		response = client.post("/user/keys", json={"id": "laptop"})
-		assert response.status_code == 500
-		assert cfg.local_users[0].keys == []
-
-
-def test_revoke_api_key_keeps_memory_unchanged_when_save_fails(tmp_path: Path) -> None:
-	cfg = _config()
-	path = tmp_path / "auth.json"
-	client = TestClient(app, raise_server_exceptions=False)
-	with (
-		patch.object(settings, "AUTH", True),
-		patch.object(settings, "AUTH_CONFIG", str(path)),
-		patch("pygitweb.auth_config.auth_config", cfg),
-		patch("pygitweb.auth.auth_config", cfg),
-		patch("pygitweb.main.auth_config", cfg),
-	):
-		set_client_session(client, "admin")
-		issued = client.post("/user/keys", json={"id": "laptop"})
-		assert issued.status_code == 200, issued.text
-		with patch("pygitweb.auth.write_auth_config", side_effect=OSError("disk")):
-			response = client.delete("/user/keys", params={"id": "laptop"})
-		assert response.status_code == 500
-		assert [key.id for key in cfg.local_users[0].keys] == ["laptop"]
-
-
-def test_oauth_session_issues_a_passwordless_local_user(tmp_path: Path) -> None:
-	cfg = _config()
-	path = tmp_path / "auth.json"
-	client = TestClient(app)
-	with (
-		patch.object(settings, "AUTH", True),
-		patch.object(settings, "AUTH_CONFIG", str(path)),
-		patch("pygitweb.auth_config.auth_config", cfg),
-		patch("pygitweb.auth.auth_config", cfg),
-		patch("pygitweb.main.auth_config", cfg),
-	):
-		token = create_session("admin", email="bob@example.com", auth_method="oauth")
-		headers = {"Authorization": f"Bearer {token}"}
-		page = client.get("/user", headers=headers)
-		assert page.status_code == 200
-		assert "Create key" in page.text
-		assert "Smart HTTP username for these keys: <code>bob@example.com</code>" in page.text
-		assert "cannot be stored" not in page.text
-		assert cfg.local_users[0].keys == []
-		issued = client.post("/user/keys", json={"id": "laptop", "readonly": True}, headers=headers)
-		assert issued.status_code == 200, issued.text
-		plaintext = issued.json()["secret"]
-		assert len(cfg.local_users) == 2
-		created = cfg.local_users[1]
-		assert created.user == "bob@example.com"
-		assert created.email == "bob@example.com"
-		assert created.password is None
-		assert created.pass_hash == ""
-		assert created.assume_anonymous is False
-		assert [key.id for key in cfg.local_users[0].keys] == []
-		assert created.keys[0].readonly is True
-		assert verify_password(plaintext, created.keys[0].secret, config_salt=_SALT)
-		on_disk = json.loads(path.read_text(encoding="utf-8"))
-		stored = on_disk["local_users"][1]
-		assert "pass" not in stored
-		assert stored["pass_hash"] == ""
-		assert plaintext not in path.read_text(encoding="utf-8")
-		assert authenticate_local_user("bob@example.com", plaintext) is None
-		matched = authenticate_http_key("bob@example.com", plaintext)
-		assert matched is not None
-		assert matched[0].email == "bob@example.com"
-		assert matched[1] is True
-		assert authenticate_http_key("BOB@example.com", plaintext) == matched
-		assert authenticate_http_key("admin", plaintext) is None
-		again = client.post("/user/keys", json={"id": "phone"}, headers=headers)
-		assert again.status_code == 200, again.text
-		assert len(cfg.local_users) == 2
-		assert [key.id for key in created.keys] == ["laptop", "phone"]
-		revoked = client.delete("/user/keys", params={"id": "laptop"}, headers=headers)
-		assert revoked.status_code == 204
-		assert [key.id for key in created.keys] == ["phone"]
-		no_email = create_session("admin", auth_method="oauth")
-		missing = client.post(
-			"/user/keys",
-			json={"id": "laptop"},
-			headers={"Authorization": f"Bearer {no_email}"},
-		)
-		assert missing.status_code == 404
-		assert missing.json()["detail"] == "An email is required to store API keys"
-		blocked = client.get("/user", headers={"Authorization": f"Bearer {no_email}"})
-		assert "cannot be stored" in blocked.text
-
-
-def test_oauth_key_name_collision_does_not_attach_to_the_other_account(tmp_path: Path) -> None:
-	cfg = AuthConfig(
-		auth_mode="local",
-		salt=_SALT,
-		local_users=[LocalUser(user="taken@example.com", password="secret", email="other@example.com")],
-	)
-	path = tmp_path / "auth.json"
-	client = TestClient(app)
-	with (
-		patch.object(settings, "AUTH", True),
-		patch.object(settings, "AUTH_CONFIG", str(path)),
-		patch("pygitweb.auth_config.auth_config", cfg),
-		patch("pygitweb.auth.auth_config", cfg),
-		patch("pygitweb.main.auth_config", cfg),
-	):
-		token = create_session("taken@example.com", email="taken@example.com", auth_method="oauth")
-		response = client.post(
-			"/user/keys",
-			json={"id": "laptop"},
-			headers={"Authorization": f"Bearer {token}"},
-		)
-		assert response.status_code == 409
-		assert response.json()["detail"] == "A local account already uses this name"
-		assert len(cfg.local_users) == 1
-		assert cfg.local_users[0].keys == []
-
-
-def test_oauth_key_user_is_removed_when_save_fails(tmp_path: Path) -> None:
-	cfg = _config()
-	path = tmp_path / "auth.json"
-	client = TestClient(app, raise_server_exceptions=False)
-	with (
-		patch.object(settings, "AUTH", True),
-		patch.object(settings, "AUTH_CONFIG", str(path)),
-		patch("pygitweb.auth_config.auth_config", cfg),
-		patch("pygitweb.auth.auth_config", cfg),
-		patch("pygitweb.main.auth_config", cfg),
-		patch("pygitweb.auth.write_auth_config", side_effect=OSError("disk")),
-	):
-		token = create_session("admin", email="bob@example.com", auth_method="oauth")
-		response = client.post(
-			"/user/keys",
-			json={"id": "laptop"},
-			headers={"Authorization": f"Bearer {token}"},
-		)
-		assert response.status_code == 500
-		assert [user.user for user in cfg.local_users] == ["admin"]
-		assert cfg.local_users[0].keys == []
diff --git a/pygitweb/pyproject.toml b/pygitweb/pyproject.toml
index 6e6ac09..55fc63a 100644
--- a/pygitweb/pyproject.toml
+++ b/pygitweb/pyproject.toml
@@ -1,13 +1,13 @@
 [project]
 name = "pygitweb"
-version = "0.3.0"
+version = "0.3.1"
 description = "Gitweb reimplementation with FastAPI and Pygit2"
 readme = "README.md"
 license = "MIT"
 license-files = ["LICENSE"]
-requires-python = ">=3.11"
+requires-python = ">=3.12"
 dependencies = [
-    "pygittools>=0.2.0",
+    "pygittools>=0.3.1",
     "fastapi[standard-no-fastapi-cloud-cli]>=0.104.0",
     "uvicorn[standard]>=0.24.0",
     "python-multipart>=0.0.6",
diff --git a/pygitweb/src/pygitweb/__meta__.py b/pygitweb/src/pygitweb/__meta__.py
index 83f5cf4..7a7ae17 100644
--- a/pygitweb/src/pygitweb/__meta__.py
+++ b/pygitweb/src/pygitweb/__meta__.py
@@ -2,7 +2,7 @@
 Metadata for PyGitWeb - this is the canonical source of all information below.
 """
 
-__version__ = "0.2.3"
+__version__ = "0.3.1"
 __author__ = "Will Bowers"
 __license__ = "MIT"
 __description__ = "FastAPI + Pygit2 Repo Browser"
diff --git a/pygitweb/src/pygitweb/auth_keys_test.py b/pygitweb/src/pygitweb/auth_keys_test.py
new file mode 100644
index 0000000..f096f3f
--- /dev/null
+++ b/pygitweb/src/pygitweb/auth_keys_test.py
@@ -0,0 +1,309 @@
+import json
+from datetime import UTC, datetime, timedelta
+from pathlib import Path
+from unittest.mock import patch
+
+import pytest
+from fastapi.testclient import TestClient
+from pydantic import ValidationError
+
+from pygitweb.auth import authenticate_http_key, authenticate_local_user
+from pygitweb.auth_config import AuthConfig, LocalUser
+from pygitweb.config import Settings, settings
+from pygitweb.conftest import set_client_session
+from pygitweb.main import app
+from pygitweb.password_hash import is_password_hash, verify_password
+from pygitweb.sessions import create_session
+
+_SALT = "0123456789abcdef"
+
+
+def _config() -> AuthConfig:
+	return AuthConfig(
+		auth_mode="local",
+		salt=_SALT,
+		local_users=[LocalUser(user="admin", password="secret", email="admin@example.com")],
+	)
+
+
+def test_api_key_settings_default_to_two_keys_without_expiry() -> None:
+	cfg = Settings(AUTH=False)
+	assert cfg.API_KEY_MAX_COUNT == 2
+	assert cfg.API_KEY_EXPIRY_DAYS == 0
+	with pytest.raises(ValidationError):
+		Settings(AUTH=False, API_KEY_MAX_COUNT=-1)
+
+
+def test_issue_api_key_returns_plaintext_and_stores_hash(tmp_path: Path) -> None:
+	cfg = _config()
+	path = tmp_path / "auth.json"
+	client = TestClient(app)
+	with (
+		patch.object(settings, "AUTH", True),
+		patch.object(settings, "AUTH_CONFIG", str(path)),
+		patch("pygitweb.auth_config.auth_config", cfg),
+		patch("pygitweb.auth.auth_config", cfg),
+		patch("pygitweb.main.auth_config", cfg),
+	):
+		set_client_session(client, "admin")
+		page = client.get("/user")
+		assert page.status_code == 200
+		assert "API keys" in page.text
+		assert "Create key" in page.text
+		issued = client.post("/user/keys", json={"id": "laptop", "readonly": True})
+		assert issued.status_code == 200, issued.text
+		body = issued.json()
+		assert body["id"] == "laptop"
+		assert body["readonly"] is True
+		plaintext = body["secret"]
+		assert isinstance(plaintext, str)
+		assert not plaintext.startswith("scrypt$")
+		assert body["time_expiry"] is None
+		stored = cfg.local_users[0].keys[0]
+		assert stored.secret != plaintext
+		assert is_password_hash(stored.secret)
+		assert verify_password(plaintext, stored.secret, config_salt=_SALT)
+		on_disk = json.loads(path.read_text(encoding="utf-8"))
+		assert on_disk["local_users"][0]["keys"][0]["secret"] == stored.secret
+		assert plaintext not in path.read_text(encoding="utf-8")
+		matched = authenticate_http_key("admin", plaintext)
+		assert matched is not None
+		assert matched[1] is True
+		again = client.get("/user")
+		assert "laptop" in again.text
+		assert plaintext not in again.text
+
+
+def test_issue_api_key_enforces_limit_expiry_and_duplicate_id(tmp_path: Path) -> None:
+	cfg = _config()
+	path = tmp_path / "auth.json"
+	client = TestClient(app)
+	with (
+		patch.object(settings, "AUTH", True),
+		patch.object(settings, "AUTH_CONFIG", str(path)),
+		patch.object(settings, "API_KEY_MAX_COUNT", 2),
+		patch.object(settings, "API_KEY_EXPIRY_DAYS", 7),
+		patch("pygitweb.auth_config.auth_config", cfg),
+		patch("pygitweb.auth.auth_config", cfg),
+		patch("pygitweb.main.auth_config", cfg),
+	):
+		set_client_session(client, "admin")
+		before = datetime.now(UTC)
+		first = client.post("/user/keys", json={"id": "alpha-key"})
+		assert first.status_code == 200, first.text
+		duplicate = client.post("/user/keys", json={"id": "alpha-key"})
+		second = client.post("/user/keys", json={"id": "beta-key"})
+		assert second.status_code == 200, second.text
+		third = client.post("/user/keys", json={"id": "gamma-key"})
+		assert duplicate.status_code == 409
+		assert third.status_code == 409
+		assert len(cfg.local_users[0].keys) == 2
+		expiry = datetime.fromisoformat(first.json()["time_expiry"])
+		assert before + timedelta(days=7) <= expiry <= datetime.now(UTC) + timedelta(days=7)
+		assert second.json()["time_expiry"] is not None
+		revoked = client.delete("/user/keys", params={"id": "alpha-key"})
+		assert revoked.status_code == 204
+		assert [key.id for key in cfg.local_users[0].keys] == ["beta-key"]
+		page = client.get("/user")
+		assert "alpha-key" not in page.text
+		assert "beta-key" in page.text
+		replaced = client.post("/user/keys", json={"id": "alpha-key"})
+		assert replaced.status_code == 200
+		assert replaced.json()["id"] == "alpha-key"
+
+
+def test_issue_api_key_requires_a_local_account_and_a_session(tmp_path: Path) -> None:
+	cfg = _config()
+	path = tmp_path / "auth.json"
+	client = TestClient(app)
+	with (
+		patch.object(settings, "AUTH", True),
+		patch.object(settings, "AUTH_CONFIG", str(path)),
+		patch("pygitweb.auth_config.auth_config", cfg),
+		patch("pygitweb.auth.auth_config", cfg),
+		patch("pygitweb.main.auth_config", cfg),
+	):
+		missing = client.post("/user/keys", json={"id": "laptop"})
+		assert missing.status_code == 401
+		token = create_session("nobody", auth_method="local")
+		unknown = client.post(
+			"/user/keys",
+			json={"id": "laptop"},
+			headers={"Authorization": f"Bearer {token}"},
+		)
+		assert unknown.status_code == 404
+		page = client.get("/user", headers={"Authorization": f"Bearer {token}"})
+		assert "cannot be stored" in page.text
+		email_token = create_session("other-name", email="Admin@Example.com", auth_method="oauth")
+		by_email = client.post(
+			"/user/keys",
+			json={"id": "mail-key"},
+			headers={"Authorization": f"Bearer {email_token}"},
+		)
+		assert by_email.status_code == 200, by_email.text
+		assert len(cfg.local_users) == 1
+		assert cfg.local_users[0].keys[0].id == "mail-key"
+		missing_key = client.delete(
+			"/user/keys", params={"id": "missing"}, headers={"Authorization": f"Bearer {email_token}"}
+		)
+		assert missing_key.status_code == 404
+		revoke_unknown = client.delete(
+			"/user/keys", params={"id": "mail-key"}, headers={"Authorization": f"Bearer {token}"}
+		)
+		assert revoke_unknown.status_code == 404
+	with patch.object(settings, "AUTH", False):
+		disabled = client.post("/user/keys", json={"id": "laptop"})
+		assert disabled.status_code == 400
+		disabled_revoke = client.delete("/user/keys", params={"id": "laptop"})
+		assert disabled_revoke.status_code == 400
+
+
+def test_issue_api_key_keeps_memory_unchanged_when_save_fails(tmp_path: Path) -> None:
+	cfg = _config()
+	path = tmp_path / "auth.json"
+	client = TestClient(app, raise_server_exceptions=False)
+	with (
+		patch.object(settings, "AUTH", True),
+		patch.object(settings, "AUTH_CONFIG", str(path)),
+		patch("pygitweb.auth_config.auth_config", cfg),
+		patch("pygitweb.auth.auth_config", cfg),
+		patch("pygitweb.main.auth_config", cfg),
+		patch("pygitweb.auth.write_auth_config", side_effect=OSError("disk")),
+	):
+		set_client_session(client, "admin")
+		response = client.post("/user/keys", json={"id": "laptop"})
+		assert response.status_code == 500
+		assert cfg.local_users[0].keys == []
+
+
+def test_revoke_api_key_keeps_memory_unchanged_when_save_fails(tmp_path: Path) -> None:
+	cfg = _config()
+	path = tmp_path / "auth.json"
+	client = TestClient(app, raise_server_exceptions=False)
+	with (
+		patch.object(settings, "AUTH", True),
+		patch.object(settings, "AUTH_CONFIG", str(path)),
+		patch("pygitweb.auth_config.auth_config", cfg),
+		patch("pygitweb.auth.auth_config", cfg),
+		patch("pygitweb.main.auth_config", cfg),
+	):
+		set_client_session(client, "admin")
+		issued = client.post("/user/keys", json={"id": "laptop"})
+		assert issued.status_code == 200, issued.text
+		with patch("pygitweb.auth.write_auth_config", side_effect=OSError("disk")):
+			response = client.delete("/user/keys", params={"id": "laptop"})
+		assert response.status_code == 500
+		assert [key.id for key in cfg.local_users[0].keys] == ["laptop"]
+
+
+def test_oauth_session_issues_a_passwordless_local_user(tmp_path: Path) -> None:
+	cfg = _config()
+	path = tmp_path / "auth.json"
+	client = TestClient(app)
+	with (
+		patch.object(settings, "AUTH", True),
+		patch.object(settings, "AUTH_CONFIG", str(path)),
+		patch("pygitweb.auth_config.auth_config", cfg),
+		patch("pygitweb.auth.auth_config", cfg),
+		patch("pygitweb.main.auth_config", cfg),
+	):
+		token = create_session("admin", email="bob@example.com", auth_method="oauth")
+		headers = {"Authorization": f"Bearer {token}"}
+		page = client.get("/user", headers=headers)
+		assert page.status_code == 200
+		assert "Create key" in page.text
+		assert "Smart HTTP username for these keys: <code>bob@example.com</code>" in page.text
+		assert "cannot be stored" not in page.text
+		assert cfg.local_users[0].keys == []
+		issued = client.post("/user/keys", json={"id": "laptop", "readonly": True}, headers=headers)
+		assert issued.status_code == 200, issued.text
+		plaintext = issued.json()["secret"]
+		assert len(cfg.local_users) == 2
+		created = cfg.local_users[1]
+		assert created.user == "bob@example.com"
+		assert created.email == "bob@example.com"
+		assert created.password is None
+		assert created.pass_hash == ""
+		assert created.assume_anonymous is False
+		assert [key.id for key in cfg.local_users[0].keys] == []
+		assert created.keys[0].readonly is True
+		assert verify_password(plaintext, created.keys[0].secret, config_salt=_SALT)
+		on_disk = json.loads(path.read_text(encoding="utf-8"))
+		stored = on_disk["local_users"][1]
+		assert "pass" not in stored
+		assert stored["pass_hash"] == ""
+		assert plaintext not in path.read_text(encoding="utf-8")
+		assert authenticate_local_user("bob@example.com", plaintext) is None
+		matched = authenticate_http_key("bob@example.com", plaintext)
+		assert matched is not None
+		assert matched[0].email == "bob@example.com"
+		assert matched[1] is True
+		assert authenticate_http_key("BOB@example.com", plaintext) == matched
+		assert authenticate_http_key("admin", plaintext) is None
+		again = client.post("/user/keys", json={"id": "phone"}, headers=headers)
+		assert again.status_code == 200, again.text
+		assert len(cfg.local_users) == 2
+		assert [key.id for key in created.keys] == ["laptop", "phone"]
+		revoked = client.delete("/user/keys", params={"id": "laptop"}, headers=headers)
+		assert revoked.status_code == 204
+		assert [key.id for key in created.keys] == ["phone"]
+		no_email = create_session("admin", auth_method="oauth")
+		missing = client.post(
+			"/user/keys",
+			json={"id": "laptop"},
+			headers={"Authorization": f"Bearer {no_email}"},
+		)
+		assert missing.status_code == 404
+		assert missing.json()["detail"] == "An email is required to store API keys"
+		blocked = client.get("/user", headers={"Authorization": f"Bearer {no_email}"})
+		assert "cannot be stored" in blocked.text
+
+
+def test_oauth_key_name_collision_does_not_attach_to_the_other_account(tmp_path: Path) -> None:
+	cfg = AuthConfig(
+		auth_mode="local",
+		salt=_SALT,
+		local_users=[LocalUser(user="taken@example.com", password="secret", email="other@example.com")],
+	)
+	path = tmp_path / "auth.json"
+	client = TestClient(app)
+	with (
+		patch.object(settings, "AUTH", True),
+		patch.object(settings, "AUTH_CONFIG", str(path)),
+		patch("pygitweb.auth_config.auth_config", cfg),
+		patch("pygitweb.auth.auth_config", cfg),
+		patch("pygitweb.main.auth_config", cfg),
+	):
+		token = create_session("taken@example.com", email="taken@example.com", auth_method="oauth")
+		response = client.post(
+			"/user/keys",
+			json={"id": "laptop"},
+			headers={"Authorization": f"Bearer {token}"},
+		)
+		assert response.status_code == 409
+		assert response.json()["detail"] == "A local account already uses this name"
+		assert len(cfg.local_users) == 1
+		assert cfg.local_users[0].keys == []
+
+
+def test_oauth_key_user_is_removed_when_save_fails(tmp_path: Path) -> None:
+	cfg = _config()
+	path = tmp_path / "auth.json"
+	client = TestClient(app, raise_server_exceptions=False)
+	with (
+		patch.object(settings, "AUTH", True),
+		patch.object(settings, "AUTH_CONFIG", str(path)),
+		patch("pygitweb.auth_config.auth_config", cfg),
+		patch("pygitweb.auth.auth_config", cfg),
+		patch("pygitweb.main.auth_config", cfg),
+		patch("pygitweb.auth.write_auth_config", side_effect=OSError("disk")),
+	):
+		token = create_session("admin", email="bob@example.com", auth_method="oauth")
+		response = client.post(
+			"/user/keys",
+			json={"id": "laptop"},
+			headers={"Authorization": f"Bearer {token}"},
+		)
+		assert response.status_code == 500
+		assert [user.user for user in cfg.local_users] == ["admin"]
+		assert cfg.local_users[0].keys == []
diff --git a/pyproject.toml b/pyproject.toml
index e8f8ab4..7b91a3f 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
 [project]
 name = "pygitweb-workspace"
-version = "0.3.0"
+version = "0.3.1"
 description = "Workspace root for postgit, pygittools, and pygitweb"
 requires-python = ">=3.12"
 dependencies = []
@@ -75,4 +75,5 @@ actions = [
 	{ name = "pytest 3.12", script = "scripts/pytest_version.py", args = ["3.12"] },
 	{ name = "pytest 3.13", script = "scripts/pytest_version.py", args = ["3.13"] },
 	{ name = "pytest 3.14", script = "scripts/pytest_version.py", args = ["3.14"] },
+	{ name = "pytest 3.15", script = "scripts/pytest_version.py", args = ["3.15"] }
 ]
diff --git a/uv.lock b/uv.lock
index 473446e..9a09dc6 100644
--- a/uv.lock
+++ b/uv.lock
@@ -1120,7 +1120,7 @@ wheels = [
 
 [[package]]
 name = "pygittools"
-version = "0.3.0"
+version = "0.3.1"
 source = { editable = "pygittools" }
 dependencies = [
     { name = "abcreg" },
@@ -1152,7 +1152,7 @@ requires-dist = [
 
 [[package]]
 name = "pygitweb"
-version = "0.3.0"
+version = "0.3.1"
 source = { editable = "pygitweb" }
 dependencies = [
     { name = "abcreg" },
@@ -1185,7 +1185,7 @@ requires-dist = [
 
 [[package]]
 name = "pygitweb-workspace"
-version = "0.3.0"
+version = "0.3.1"
 source = { virtual = "." }
 
 [package.dev-dependencies]
