README.fr.md
diff --git a/README.fr.md b/README.fr.md
index 9ef9dee..bb37e09 100644
--- a/README.fr.md
+++ b/README.fr.md
@@ -51,7 +51,8 @@ instance en fonctionnement.
`/.well-known/gitfed.json`.
- **`gitfed-web`** — l'interface web : un navigateur de dépôts public, et,
derrière une connexion par mot de passe, la gestion autonome (vos propres
- clés et dépôts) et une section admin, en français ou en anglais.
+ clés et dépôts), les merge requests avec revue et fil de discussion, et
+ une section admin, en français ou en anglais.
- **`gitfed-tui`** — un outil d'administration en terminal ; le seul moyen
d'amorcer le tout premier compte.
- **`gitfed-renew-cert`** — un petit outil client pour renouveler votre
@@ -119,6 +120,11 @@ et casse toutes les relations de confiance fédérées qui pointent vers elle
— il n'y a pas de récupération possible en dehors de tout reconstruire
depuis zéro. Sauvegardez le volume entier, pas seulement les données git.
+Pour le déploiement Kubernetes, [`deploy/backup.sh`](deploy/backup.sh)
+automatise ça — capture tout le volume dans une archive datée téléchargée
+depuis le serveur, avec purge automatique. La procédure de restauration
+est dans [`deploy/k8s/README.md`](deploy/k8s/README.md#backups).
+
## Licence
[GNU AGPLv3](LICENSE) — la licence à copyleft réseau : si vous faites
README.md
diff --git a/README.md b/README.md
index 30bca6d..53b2d5f 100644
--- a/README.md
+++ b/README.md
@@ -46,8 +46,9 @@ instance.
- **`gitfed-server`** — SSH server (git operations + certificate issuance)
and the `/.well-known/gitfed.json` federation endpoint.
- **`gitfed-web`** — the web UI: a public repo browser, and, behind a
- password login, self-service (your own keys and repos) and an admin
- section, in French or English.
+ password login, self-service (your own keys and repos), merge requests
+ with review and a discussion thread, and an admin section, in French or
+ English.
- **`gitfed-tui`** — a terminal admin tool; the only way to bootstrap the
first account.
- **`gitfed-renew-cert`** — a small client tool to renew your own
@@ -113,6 +114,11 @@ breaks every federated trust relationship pointing at it — there's no
recovery short of everyone re-establishing trust from scratch. Back up the
whole volume, not just the git data.
+For the Kubernetes deployment, [`deploy/backup.sh`](deploy/backup.sh)
+automates this — snapshots the whole volume to a timestamped tarball
+downloaded off the server, with pruning. The restore procedure is in
+[`deploy/k8s/README.md`](deploy/k8s/README.md#backups).
+
## License
[GNU AGPLv3](LICENSE) — the network-copyleft license: if you run a modified
deploy/k8s/README.md
diff --git a/deploy/k8s/README.md b/deploy/k8s/README.md
index 2eea4c4..cfc84fc 100644
--- a/deploy/k8s/README.md
+++ b/deploy/k8s/README.md
@@ -195,12 +195,16 @@ this repo works fine).
## Further hardening (not included here, worth doing later)
-- A `NetworkPolicy` restricting which namespaces/pods can reach
- `gitfed-wellknown`/`gitfed-web` at all, if your cluster runs anything
- else you don't fully trust.
+- **`deploy/k8s/networkpolicy.yaml` exists but isn't part of the "Apply"
+ step above, and k3s' bundled flannel CNI doesn't enforce `NetworkPolicy`
+ at all.** Applying it as-is is documentation of intent, not an active
+ control, until the cluster runs a policy-capable CNI (Cilium, Calico,
+ ...) — worth doing if this cluster ever hosts anything you don't fully
+ trust alongside gitfed.
- Rotating `cert_ttl_hours` down and setting up `gitfed-renew-cert` on a
timer for any users who script access, instead of the default 48h/manual
`gitfed-cert` request.
-- Rate limiting or a CAPTCHA on `/login` if this instance ever becomes a
- public target — right now a wrong password just redirects back with an
- error, no lockout or backoff.
+
+Login already has rate limiting (per-account and per-IP, see
+`cmd/gitfed-web/ratelimit.go`) — it used to be listed here as missing;
+it isn't anymore.
docs/ARCHITECTURE.en.md
diff --git a/docs/ARCHITECTURE.en.md b/docs/ARCHITECTURE.en.md
index 9b86e03..ca7039e 100644
--- a/docs/ARCHITECTURE.en.md
+++ b/docs/ARCHITECTURE.en.md
@@ -35,8 +35,8 @@ speaks SSH to a remote instance.
| `ssh` | The SSH server itself: authentication (bare key or certificate), revocation checking, running `git-upload-pack`/`git-receive-pack`. |
| `federation` | `.well-known` discovery, anti-SSRF guards (`wellknown.go`), the trust store and its policy (`resolver.go`). |
| `acl` | Per-repo authorization rules (public/private, read/write/admin roles). |
-| `gitexec` | Everything that invokes `git` as a subprocess: init, reading a tree/file at HEAD, default-branch resolution. |
-| `store` | bbolt persistence: users, repos, ACLs, sessions, trust store, audit log, revocations. |
+| `gitexec` | Everything that invokes `git` as a subprocess: init, reading a tree/file at HEAD, default-branch resolution, commit history/diffs, and the branch/merge plumbing behind merge requests (§8). |
+| `store` | bbolt persistence: users, repos, ACLs, sessions, trust store, audit log, revocations, pinned repos, federated notifications, merge requests and their comments. |
| `admin` | The `Ops` interface: every admin or self-service operation, in one place, implemented once. |
| `adminrpc` | The JSON-over-Unix-socket protocol that exposes `admin.Ops` to a remote client (see §3). |
| `opsconnect` | Automatically picks live-socket vs. direct-store mode depending on whether a `gitfed-server` is already running. |
@@ -164,3 +164,43 @@ This POST route is explicitly exempted from the same-origin CSRF check
(`sameOriginPOST`): a `git` client never sends an `Origin`/`Referer`
header. That's not a CSRF hole — the request carries no session cookie and
mutates no state.
+
+## 8. Merge requests, and why a web-triggered merge doesn't reopen §6
+
+§6 says writes only ever happen over SSH. Merge requests (`/repo-mrs`,
+`/repo-mr`, `cmd/gitfed-web/handlers_merge_requests.go`) let a logged-in
+user merge one branch into another from the web UI, which sounds like an
+exception — it isn't, for a specific reason: **the browser never sends git
+data.** A merge request stores nothing but a title, description, two
+branch names and a status (`store.MergeRequest`); the diff shown on its
+page is computed live from the two branches' current tips
+(`gitexec.BranchDiff`), never cached. Clicking "Merge" doesn't upload
+anything — it tells `gitfed-server` "combine these two refs you already
+have," and every commit either ref points at only ever got into the repo
+through a real SSH-authenticated push in the first place. Nothing resembling
+`git-receive-pack` is reachable from this path.
+
+Authorization is the same `CheckAccess(repo, principal, RoleWrite)` gate a
+`git push` over SSH already goes through — a web merge grants no new
+capability, since any write collaborator could reach the identical end
+state by cloning over SSH, merging locally, and pushing back. The one
+honest trade-off: the merge commit's author identity comes from the web
+session (password login), not an SSH certificate — but that's already true
+of every other web-only write (deleting a repo, changing its visibility,
+granting/revoking a collaborator), so this isn't a new category of trust.
+
+The merge itself (`gitexec.MergeBranches`) never touches a branch's real
+checked-out state:
+
+- It runs inside a throwaway `git worktree add --detach`, discarded (`git
+ worktree remove --force`) whether it succeeds, conflicts, or errors —
+ the target branch's ref is untouched until the very last step.
+- A conflict (`git merge` exits non-zero with files still unmerged) is
+ detected *before* anything is written back — `CheckMergeable` runs the
+ same attempt in its own throwaway worktree purely to report conflicting
+ files, then discards it.
+- The one real mutation is `git update-ref refs/heads/<target> <new> <old>`
+ — compare-and-swap, not a blind overwrite. If `<target>` moved between
+ the merge being computed and this call (a concurrent `git push`, most
+ likely), the update is rejected instead of silently discarding that
+ push.
docs/ARCHITECTURE.md
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index ad56dcb..09268d7 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -36,8 +36,8 @@ timer), qui ne parle qu'en SSH à une instance distante.
| `ssh` | Le serveur SSH lui-même : authentification (clé nue ou certificat), vérification de révocation, exécution de `git-upload-pack`/`git-receive-pack`. |
| `federation` | Découverte `.well-known`, garde-fous anti-SSRF (`wellknown.go`), magasin de confiance et sa politique (`resolver.go`). |
| `acl` | Les règles d'autorisation par dépôt (public/privé, rôles read/write/admin). |
-| `gitexec` | Tout ce qui invoque `git` en sous-processus : init, lecture d'arbre/fichier à HEAD, résolution de branche par défaut. |
-| `store` | Persistance bbolt : utilisateurs, dépôts, ACL, sessions, magasin de confiance, journal d'audit, révocations. |
+| `gitexec` | Tout ce qui invoque `git` en sous-processus : init, lecture d'arbre/fichier à HEAD, résolution de branche par défaut, historique/diffs de commits, et la plomberie branches/fusion derrière les merge requests (§8). |
+| `store` | Persistance bbolt : utilisateurs, dépôts, ACL, sessions, magasin de confiance, journal d'audit, révocations, dépôts épinglés, notifications fédérées, merge requests et leurs commentaires. |
| `admin` | L'interface `Ops` : chaque opération d'administration ou de self-service, dans un seul endroit, implémentée une fois. |
| `adminrpc` | Protocole JSON-sur-socket-Unix qui expose `admin.Ops` à un client distant (voir §3). |
| `opsconnect` | Choisit automatiquement le mode socket-vivant ou base-directe selon qu'un `gitfed-server` tourne déjà. |
@@ -167,3 +167,48 @@ Cette route POST est explicitement exemptée de la vérification
same-origin CSRF (`sameOriginPOST`) : un client `git` n'envoie jamais
d'en-tête `Origin`/`Referer`. Ce n'est pas une brèche CSRF — cette requête
ne porte aucun cookie de session et ne modifie aucun état.
+
+## 8. Merge requests, et pourquoi une fusion déclenchée depuis le web ne rouvre pas le §6
+
+Le §6 dit que les écritures ne se font que par SSH. Les merge requests
+(`/repo-mrs`, `/repo-mr`, `cmd/gitfed-web/handlers_merge_requests.go`)
+permettent à un·e utilisateur·ice connecté·e de fusionner une branche dans
+une autre depuis l'interface web, ce qui ressemble à une exception — ça
+n'en est pas une, pour une raison précise : **le navigateur n'envoie
+jamais de donnée git.** Une merge request ne stocke qu'un titre, une
+description, deux noms de branche et un statut (`store.MergeRequest`) ; le
+diff affiché sur sa page est calculé en direct à partir des pointes
+actuelles des deux branches (`gitexec.BranchDiff`), jamais mis en cache.
+Cliquer sur « Fusionner » n'envoie rien — ça dit à `gitfed-server`
+« combine ces deux refs que tu as déjà », et chaque commit que l'une ou
+l'autre ref pointe n'est arrivé dans le dépôt que via un vrai push
+authentifié par SSH au départ. Rien qui ressemble à `git-receive-pack`
+n'est joignable par ce chemin.
+
+L'autorisation passe par le même verrou `CheckAccess(repo, principal,
+RoleWrite)` qu'un `git push` par SSH traverse déjà — une fusion web
+n'ouvre aucune capacité nouvelle, puisque n'importe quel collaborateur en
+écriture pourrait atteindre le même état final en clonant par SSH, en
+fusionnant en local, puis en repoussant. Le seul compromis honnête :
+l'identité de l'auteur du commit de fusion vient de la session web
+(connexion par mot de passe), pas d'un certificat SSH — mais c'est déjà
+vrai de toute autre écriture web (supprimer un dépôt, changer sa
+visibilité, accorder/révoquer un collaborateur), donc ce n'est pas une
+nouvelle catégorie de confiance.
+
+La fusion elle-même (`gitexec.MergeBranches`) ne touche jamais l'état
+réellement extrait (checked-out) d'une branche :
+
+- Elle tourne dans un `git worktree add --detach` jetable, supprimé (`git
+ worktree remove --force`) qu'elle réussisse, entre en conflit, ou
+ échoue — la ref de la branche cible n'est touchée qu'à la toute
+ dernière étape.
+- Un conflit (`git merge` sort en erreur avec des fichiers non fusionnés)
+ est détecté *avant* toute écriture — `CheckMergeable` tente la même
+ fusion dans son propre worktree jetable uniquement pour rapporter les
+ fichiers en conflit, puis le jette.
+- La seule vraie mutation est `git update-ref refs/heads/<cible> <nouveau>
+ <ancien>` — un compare-and-swap, pas un écrasement aveugle. Si `<cible>`
+ a bougé entre le calcul de la fusion et cet appel (un `git push`
+ concurrent, le plus probable), la mise à jour est rejetée plutôt que de
+ silencieusement perdre ce push.
docs/HOW_IT_WORKS.en.md
diff --git a/docs/HOW_IT_WORKS.en.md b/docs/HOW_IT_WORKS.en.md
index 4fcdc2b..327195a 100644
--- a/docs/HOW_IT_WORKS.en.md
+++ b/docs/HOW_IT_WORKS.en.md
@@ -157,3 +157,18 @@ write is possible this way (there is no `git-receive-pack` route over
HTTP, literally none), and a repo that goes back to private instantly
stops being reachable this way. See [`ARCHITECTURE.en.md`](ARCHITECTURE.en.md)
§7 for the implementation detail.
+
+## 9. Special case: merging a branch from the web
+
+Merge requests let a write collaborator merge one branch into another from
+the web UI — a "write" that doesn't go over SSH, which sounds like a
+second exception to §2's identity model. It isn't one: the browser never
+sends git data, it only tells the server to combine two branches it
+already has, and every commit either branch points at only got into the
+repo through a real SSH-authenticated push in the first place. Access is
+gated by the exact same write-role check a `git push` already goes
+through — the web button grants no capability a write collaborator
+couldn't already reach by cloning, merging locally, and pushing back. See
+[`ARCHITECTURE.en.md`](ARCHITECTURE.en.md) §8 for how the merge itself is
+isolated from a branch's real state (scratch worktree, conflict detection
+before any write, compare-and-swap ref update).
docs/HOW_IT_WORKS.md
diff --git a/docs/HOW_IT_WORKS.md b/docs/HOW_IT_WORKS.md
index 32913ae..6ee7318 100644
--- a/docs/HOW_IT_WORKS.md
+++ b/docs/HOW_IT_WORKS.md
@@ -164,3 +164,20 @@ a pas de route `git-receive-pack` en HTTP, littéralement aucune), et un
dépôt qui redevient privé cesse instantanément d'être accessible ainsi.
Voir [`ARCHITECTURE.md`](ARCHITECTURE.md) §7 pour le détail
d'implémentation.
+
+## 9. Cas particulier : fusionner une branche depuis le web
+
+Les merge requests permettent à un collaborateur en écriture de fusionner
+une branche dans une autre depuis l'interface web — une « écriture » qui
+ne passe pas par SSH, ce qui ressemble à une deuxième exception au modèle
+d'identité du §2. Ça n'en est pas une : le navigateur n'envoie jamais de
+donnée git, il dit seulement au serveur de combiner deux branches qu'il a
+déjà, et chaque commit que l'une ou l'autre pointe n'est arrivé dans le
+dépôt que via un vrai push authentifié par SSH au départ. L'accès passe
+par exactement le même contrôle de rôle en écriture qu'un `git push`
+traverse déjà — le bouton web n'ouvre aucune capacité qu'un collaborateur
+en écriture n'aurait pas déjà en clonant, fusionnant en local, puis en
+repoussant. Voir [`ARCHITECTURE.md`](ARCHITECTURE.md) §8 pour comment la
+fusion elle-même est isolée de l'état réel d'une branche (worktree
+jetable, détection de conflit avant toute écriture, mise à jour de ref en
+compare-and-swap).