docs/plans/2026-09-27-credentials-and-sessions.md

v1.43.0
gitbay/docs/plans/2026-09-27-credentials-and-sessions.md rendered · source · history · blame · raw

3568 lines · 115133 bytes

   1# Credentials and sessions implementation plan
   2
   3> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
   4
   5**Goal:** Revocation of an SSH key takes effect on open connections
   6(#256); an expiring credential cannot mint one that outlives it, and
   7credentials record the token that made them (#257); SSH and deploy
   8keys take an optional expiry and show their last use (#277); browser
   9sessions end after 12 hours idle (#276); `web login` over SSH spends
  10the login-link budget (#278).
  11
  12**Architecture:** The store announces revocations it commits
  13(`Store.OnRevoke`); the SSH listener tracks which key opened each
  14connection and cuts the ones a revocation names, killing a git
  15transport's process group. A 15-second sweep catches revocations made
  16by another process and keys that expire while connected. Every exec
  17re-reads its key. `Command.MintsCredential` marks the commands that
  18create credentials; `Dispatch` refuses them when `Ctx.Expires` is set.
  19`api_tokens` and `ssh_keys` gain `created_by_token`; `ssh_keys` gains
  20`expires_at`; `web_sessions` gains a sliding expiry under an absolute
  21cap.
  22
  23**Tech stack:** Go, `golang.org/x/crypto/ssh`, SQLite (modernc), OpenSSH
  24client for e2e.
  25
  26**Spec:** issues #256, #257, #276, #277, #278 on krz/gitbay (the
  27decisions on #256 and #257 are recorded there, and the later ones in
  28"Decisions" at the end of this plan).
  29
  30## Global constraints
  31
  32- Each MR on its own branch off `main`. Commits are signed (the repo
  33  refuses unsigned), messages reference issues (`Ref #N`, and
  34  `Closes #N` on the commit that finishes one). No attribution to any
  35  assistant, model or AI anywhere: commits, MR bodies, comments.
  36- MR: `gitbay mr create --source <branch> --target main --title "..."`;
  37  merge with `gitbay mr merge <n> --strategy ff` once CI is green, then
  38  delete the branch locally and on the remote. Behind main → rebase,
  39  force-push, merge again.
  40- Locally: `go build ./...`, `go vet ./...`, unit tests of touched
  41  packages, and at most the one e2e test being written
  42  (`go test ./e2e -run TestName -count=1`). CI on bay1 runs the full suite.
  43- Registries that fail CI when a new thing lacks its row: top-level route
  44  word in `internal/policy/names.go`; new page template in the width map
  45  of `TestMainWidthClass` (`internal/web/web_test.go`); new `ReadOnly`
  46  command in `readArgs` in `e2e/readonly_test.go`; new control command
  47  needs a `pass()` entry in `cmd/gitbay/main.go` (coverage test);
  48  a command reading stdin needs `ReadsStdin: true`. This plan adds no
  49  route, template, command or read command; it changes flags and a
  50  `Summary` of none, so `cmd/gitbay/summaries_gen.go` stays current.
  51- Migrations: this plan owns 0060–0064 and uses 0060, 0061, 0062. Six
  52  plans are written in parallel with pre-assigned ranges; whoever lands
  53  second renumbers to the next free number at execution time.
  54  Migrations come in `.up.sql`/`.down.sql` pairs. Hand-written SQL, no
  55  ORM. `TestMigrateUpDown` (`internal/store/store_test.go`) exercises
  56  every down script.
  57- Secrets travel on stdin, never argv; never logged or echoed.
  58- Wiki pages live in `.gitbay/wiki/` (Parity, API, Admin, Threat-Model,
  59  CI, Users, Performance, and the `Architecture/` folder with its
  60  Known-Gaps table and controls matrix). Update the page in the same MR
  61  that changes the behaviour it describes, and remove the matching
  62  row from `Architecture/10-Known-Gaps.org`.
  63- Release notes go in `CHANGELOG.org` under the topmost heading that
  64  has no tag yet. If the top heading is a released version, add
  65  `* Unreleased` above it; whoever tags renames it.
  66- Writing style: plain, direct, no hype; code comments match the
  67  surrounding density. Comments and docs state facts, never
  68  before/after narration.
  69- Work in a worktree of `krz/gitbay`; the main checkout may hold
  70  another session's edits.
  71
  72## Order and dependencies
  73
  74| # | Branch | Closes | Migration | Depends on |
  75|---|---|---|---|---|
  76| 1 | `revoke-closes-connections` | #256 | none | — |
  77| 2 | `token-delegation` | #257 | 0060 | MR 1 (`Store.announce`, `fingerprint` e2e helper, `Exec` taking a key) |
  78| 3 | `key-expiry` | #277 | 0061 | MR 1 (sweep, per-exec check), MR 2 (`KeyOrigin`, `Ctx.Expires`) |
  79| 4 | `session-idle` | #276 | 0062 | — |
  80| 5 | `weblogin-limit` | #278 | none | — |
  81
  82\#256 goes first. #257 and #277 both add columns to `ssh_keys`; both
  83are nullable `ADD COLUMN`s with no table rebuild, so they compose in
  84either order, and `KeyOrigin` (MR 2) is the one insert path both use.
  85
  86Other plans:
  87
  88- Plan 5 (web-ux) #264 depends on MR 2's `--scope read` default.
  89- Plan 3 (server-hardening) touches the same code: #262 limits
  90  concurrent git processes in `gitutil.Transport` / `sshd.runGit`
  91  (MR 1 changes both signatures), #275 audits refused commands in
  92  `control.Dispatch` (MR 2 adds a refusal there, which #275 should
  93  audit like the others), #282 changes `hookd`. Whoever lands second
  94  rebases; the conflicts are mechanical.
  95
  96## File map
  97
  98| File | MR | Responsibility |
  99|---|---|---|
 100| `internal/store/revoke.go` (create) | 1 | `Revoked`, `OnRevoke`, `announce`, `LiveSSHKeys` |
 101| `internal/store/store.go` | 1 | subscriber fields on `Store` |
 102| `internal/store/users.go` | 1, 2, 3 | removals announce; `KeyOrigin`, `AddSSHKeyFrom`; `expires_at` |
 103| `internal/sshd/sshd.go` | 1, 3 | connection tracking, `cut`, sweep, per-exec check, `Exec(key)`; expired keys refused |
 104| `internal/gitutil/gitutil.go`, `proc_unix.go`, `proc_other.go` | 1 | `Transport` takes a cancel channel, kills the process group |
 105| `cmd/gitbayd/system.go` | 1, 3 | `Exec` call; expired keys in system mode |
 106| `internal/control/control.go` | 2 | `MintsCredential`, `Ctx.TokenID`, `Ctx.Expires`, the refusal |
 107| `internal/store/tokens.go` | 2 | token id, creator, chained revoke |
 108| `internal/control/token.go` | 2, 3 | default read, creator, `revoke --created`; `ttlFlag` |
 109| `internal/control/identity.go`, `deploykey.go`, `runnerrepo.go`, `adminhost.go`, `register.go`, `web.go` | 2, 3, 4, 5 | flags, creator, `--ttl`, list columns, login limit |
 110| `internal/httpd/api.go` | 2 | token into `Ctx` |
 111| `internal/store/sessions.go` | 4 | sliding session expiry |
 112| `internal/control/loginlink.go` | 5 | comment |
 113| `e2e/revoke_test.go` (create) | 1 | multiplexed connection cut |
 114| `e2e/tokenorigin_test.go` (create) | 2 | expiring token refused, `revoke --created` |
 115| `.gitbay/wiki/*`, `CHANGELOG.org` | all | docs in the MR that changes behaviour |
 116
 117---
 118
 119# MR 1: removing a key closes its connections (branch `revoke-closes-connections`, #256)
 120
 121Decision on the issue: revocation is immediate, running commands
 122included. What that means here, stated in the Threat-Model page:
 123
 124- Every exec and every git transport session re-reads its key: gone,
 125  moved to another account, or re-scoped takes effect on the next
 126  command.
 127- `keys remove`, `repo deploy-key remove`, `admin user disable`,
 128  `admin user delete` and (MR 2) `token revoke --created` close every
 129  connection opened by an affected key. A git transport on it is
 130  killed with its process group. A control command in flight loses its
 131  channel; one that watches `Done` (`build log --follow`) stops, one
 132  already inside its store write finishes that write and its output is
 133  lost.
 134- A push cut before its pre-receive hook answers updates no refs. The
 135  ref transaction that follows the hook is not interrupted mid-write in
 136  practice; it is milliseconds long.
 137- Revocations committed by another process (`gitbayd admin` on the
 138  host) are found by a 15-second sweep.
 139- System mode (`ssh.mode = "system"`) runs one process per exec, so
 140  the per-exec check applies; a forced-command process already running
 141  is not cut (open question 4).
 142
 143### Task 1.1: the store announces revocations and answers which keys are live
 144
 145**Files:**
 146- Create: `internal/store/revoke.go`
 147- Modify: `internal/store/store.go:23-30` (`Store` struct)
 148- Modify: `internal/store/users.go` — `DeleteUser` (58-101), `SetUserDisabled` (171-194), `RemoveSSHKey` (291-309), `RemoveDeployKey` (534-554)
 149- Test: `internal/store/revoke_test.go` (create)
 150
 151**Interfaces:**
 152- Produces:
 153  - `type Revoked struct { KeyIDs []int64; UserID int64 }`
 154  - `func (s *Store) OnRevoke(f func(Revoked))`
 155  - `func (s *Store) announce(r Revoked)` (package-private; MR 2 calls it)
 156  - `func (s *Store) LiveSSHKeys(ids []int64) (map[int64]bool, error)`
 157
 158- [ ] **Step 1: Write the failing test**
 159
 160`internal/store/revoke_test.go`:
 161
 162```go
 163package store
 164
 165import (
 166	"slices"
 167	"testing"
 168)
 169
 170func revokeFixture(t *testing.T) (*Store, int64, *[]Revoked) {
 171	t.Helper()
 172	s := open(t)
 173	if err := s.MigrateUp(); err != nil {
 174		t.Fatal(err)
 175	}
 176	uid, err := s.CreateUser("alice", false)
 177	if err != nil {
 178		t.Fatal(err)
 179	}
 180	var got []Revoked
 181	s.OnRevoke(func(r Revoked) { got = append(got, r) })
 182	return s, uid, &got
 183}
 184
 185func keyID(t *testing.T, s *Store, fp string) int64 {
 186	t.Helper()
 187	k, err := s.SSHKeyByFingerprint(fp)
 188	if err != nil {
 189		t.Fatal(err)
 190	}
 191	return k.ID
 192}
 193
 194func TestRemovalsAnnounceTheirKeys(t *testing.T) {
 195	s, uid, got := revokeFixture(t)
 196	if err := s.AddSSHKey(uid, "SHA256:a", "ssh-ed25519", []byte("a"), "full", ""); err != nil {
 197		t.Fatal(err)
 198	}
 199	if err := s.AddSSHKey(uid, "SHA256:d", "ssh-ed25519", []byte("d"), "deploy:7:ro", ""); err != nil {
 200		t.Fatal(err)
 201	}
 202	a, d := keyID(t, s, "SHA256:a"), keyID(t, s, "SHA256:d")
 203
 204	if err := s.RemoveSSHKey(uid, "SHA256:a"); err != nil {
 205		t.Fatal(err)
 206	}
 207	if err := s.RemoveDeployKey(7, "SHA256:d"); err != nil {
 208		t.Fatal(err)
 209	}
 210	if err := s.SetUserDisabled(uid, true); err != nil {
 211		t.Fatal(err)
 212	}
 213	if err := s.SetUserDisabled(uid, false); err != nil {
 214		t.Fatal(err)
 215	}
 216	want := []Revoked{{KeyIDs: []int64{a}}, {KeyIDs: []int64{d}}, {UserID: uid}}
 217	if !slices.EqualFunc(*got, want, func(x, y Revoked) bool {
 218		return slices.Equal(x.KeyIDs, y.KeyIDs) && x.UserID == y.UserID
 219	}) {
 220		t.Fatalf("announced %+v, want %+v (enabling announces nothing)", *got, want)
 221	}
 222	// A removal that found nothing announces nothing.
 223	if err := s.RemoveSSHKey(uid, "SHA256:a"); err != ErrNotFound {
 224		t.Fatalf("second remove: %v", err)
 225	}
 226	if len(*got) != 3 {
 227		t.Fatalf("a miss was announced: %+v", *got)
 228	}
 229}
 230
 231func TestDeleteUserAnnounces(t *testing.T) {
 232	s, uid, got := revokeFixture(t)
 233	if err := s.DeleteUser(uid); err != nil {
 234		t.Fatal(err)
 235	}
 236	if len(*got) != 1 || (*got)[0].UserID != uid {
 237		t.Fatalf("announced %+v", *got)
 238	}
 239}
 240
 241func TestLiveSSHKeys(t *testing.T) {
 242	s, uid, _ := revokeFixture(t)
 243	bob, err := s.CreateUser("bob", false)
 244	if err != nil {
 245		t.Fatal(err)
 246	}
 247	for _, k := range []struct {
 248		uid int64
 249		fp  string
 250	}{{uid, "SHA256:a"}, {bob, "SHA256:b"}} {
 251		if err := s.AddSSHKey(k.uid, k.fp, "ssh-ed25519", []byte(k.fp), "full", ""); err != nil {
 252			t.Fatal(err)
 253		}
 254	}
 255	a, b := keyID(t, s, "SHA256:a"), keyID(t, s, "SHA256:b")
 256	if _, err := s.DB.Exec("UPDATE users SET disabled = 1 WHERE id = ?", bob); err != nil {
 257		t.Fatal(err)
 258	}
 259	live, err := s.LiveSSHKeys([]int64{a, b, 999})
 260	if err != nil {
 261		t.Fatal(err)
 262	}
 263	if !live[a] || live[b] || live[999] {
 264		t.Fatalf("live = %v; want only %d", live, a)
 265	}
 266	if live, err := s.LiveSSHKeys(nil); err != nil || len(live) != 0 {
 267		t.Fatalf("no ids: %v %v", live, err)
 268	}
 269}
 270```
 271
 272- [ ] **Step 2: Run it and see it fail**
 273
 274Run: `go test ./internal/store -run 'TestRemovalsAnnounceTheirKeys|TestDeleteUserAnnounces|TestLiveSSHKeys' -count=1`
 275Expected: FAIL to compile, `s.OnRevoke undefined`.
 276
 277- [ ] **Step 3: Add the subscriber fields**
 278
 279In `internal/store/store.go`, the `Store` struct becomes:
 280
 281```go
 282type Store struct {
 283	DB *sql.DB
 284
 285	// logWait holds one channel per build someone is following, closed
 286	// by the next change to that build's row (BuildLogWait).
 287	logMu   sync.Mutex
 288	logWait map[int64]chan struct{}
 289
 290	// onRevoke runs after each key revocation this process commits.
 291	revokeMu sync.Mutex
 292	onRevoke []func(Revoked)
 293}
 294```
 295
 296- [ ] **Step 4: Create `internal/store/revoke.go`**
 297
 298```go
 299package store
 300
 301import (
 302	"slices"
 303	"strings"
 304)
 305
 306// Revoked names SSH keys that stopped being valid: by id, or every key
 307// of an account. The SSH listener closes the connections they opened.
 308type Revoked struct {
 309	KeyIDs []int64
 310	UserID int64 // every key of this account; 0 for none
 311}
 312
 313// OnRevoke registers f to run after each revocation this process
 314// commits. Revocations committed by another process (gitbayd admin on
 315// the host) are not announced; the listener's sweep finds those.
 316func (s *Store) OnRevoke(f func(Revoked)) {
 317	s.revokeMu.Lock()
 318	defer s.revokeMu.Unlock()
 319	s.onRevoke = append(s.onRevoke, f)
 320}
 321
 322// announce runs the subscribers. Call it after the commit, outside any
 323// transaction.
 324func (s *Store) announce(r Revoked) {
 325	s.revokeMu.Lock()
 326	fs := slices.Clone(s.onRevoke)
 327	s.revokeMu.Unlock()
 328	for _, f := range fs {
 329		f(r)
 330	}
 331}
 332
 333// LiveSSHKeys reports which of ids still name a registered key on an
 334// account that is not disabled.
 335func (s *Store) LiveSSHKeys(ids []int64) (map[int64]bool, error) {
 336	live := map[int64]bool{}
 337	if len(ids) == 0 {
 338		return live, nil
 339	}
 340	args := make([]any, len(ids))
 341	for i, id := range ids {
 342		args[i] = id
 343	}
 344	rows, err := s.DB.Query(`SELECT k.id FROM ssh_keys k JOIN users u ON u.id = k.user_id
 345		WHERE u.disabled = 0 AND k.id IN (?`+strings.Repeat(", ?", len(ids)-1)+`)`, args...)
 346	if err != nil {
 347		return nil, err
 348	}
 349	defer rows.Close()
 350	for rows.Next() {
 351		var id int64
 352		if err := rows.Scan(&id); err != nil {
 353			return nil, err
 354		}
 355		live[id] = true
 356	}
 357	return live, rows.Err()
 358}
 359```
 360
 361- [ ] **Step 5: Announce from the four removals**
 362
 363`RemoveSSHKey` in `internal/store/users.go`:
 364
 365```go
 366// RemoveSSHKey removes a key owned by userID, bumps the key epoch, and
 367// announces the revocation.
 368func (s *Store) RemoveSSHKey(userID int64, fingerprint string) error {
 369	tx, err := s.DB.Begin()
 370	if err != nil {
 371		return err
 372	}
 373	defer tx.Rollback()
 374	var id int64
 375	err = tx.QueryRow("DELETE FROM ssh_keys WHERE user_id = ? AND fingerprint = ? RETURNING id", userID, fingerprint).Scan(&id)
 376	if errors.Is(err, sql.ErrNoRows) {
 377		return ErrNotFound
 378	}
 379	if err != nil {
 380		return err
 381	}
 382	if err := bumpKeyEpoch(tx); err != nil {
 383		return err
 384	}
 385	if err := tx.Commit(); err != nil {
 386		return err
 387	}
 388	s.announce(Revoked{KeyIDs: []int64{id}})
 389	return nil
 390}
 391```
 392
 393`RemoveDeployKey`:
 394
 395```go
 396// RemoveDeployKey removes a deploy key from a repository by fingerprint;
 397// any repo admin may remove it regardless of who added it.
 398func (s *Store) RemoveDeployKey(repoID int64, fingerprint string) error {
 399	tx, err := s.DB.Begin()
 400	if err != nil {
 401		return err
 402	}
 403	defer tx.Rollback()
 404	var id int64
 405	err = tx.QueryRow(
 406		"DELETE FROM ssh_keys WHERE fingerprint = ? AND scope LIKE 'deploy:' || ? || ':%' RETURNING id",
 407		fingerprint, repoID).Scan(&id)
 408	if errors.Is(err, sql.ErrNoRows) {
 409		return ErrNotFound
 410	}
 411	if err != nil {
 412		return err
 413	}
 414	if err := bumpKeyEpoch(tx); err != nil {
 415		return err
 416	}
 417	if err := tx.Commit(); err != nil {
 418		return err
 419	}
 420	s.announce(Revoked{KeyIDs: []int64{id}})
 421	return nil
 422}
 423```
 424
 425`SetUserDisabled`, the tail from `if disabled {`:
 426
 427```go
 428	if disabled {
 429		// A pending login link is a session in waiting, so it goes with
 430		// the sessions and API tokens. Re-enabling means minting again.
 431		for _, table := range []string{"web_sessions", "api_tokens", "login_tokens"} {
 432			if _, err := s.DB.Exec("DELETE FROM "+table+" WHERE user_id = ?", userID); err != nil {
 433				return err
 434			}
 435		}
 436		s.announce(Revoked{UserID: userID})
 437	}
 438	return nil
 439}
 440```
 441
 442Update its doc comment's last clause to: "and leaves the SSH keys registered but refused at every entry point until re-enabled; connections they opened are closed."
 443
 444`DeleteUser`, the tail:
 445
 446```go
 447	res, err := s.DB.Exec("DELETE FROM users WHERE id = ?", id)
 448	if err != nil {
 449		return err
 450	}
 451	if n, _ := res.RowsAffected(); n == 0 {
 452		return ErrNotFound
 453	}
 454	s.announce(Revoked{UserID: id})
 455	return nil
 456}
 457```
 458
 459- [ ] **Step 6: Run the tests**
 460
 461Run: `go test ./internal/store -count=1`
 462Expected: PASS.
 463
 464- [ ] **Step 7: Commit**
 465
 466```bash
 467git add internal/store/revoke.go internal/store/revoke_test.go internal/store/store.go internal/store/users.go
 468git commit -S -m "store: announce key revocations; LiveSSHKeys
 469
 470Ref #256"
 471```
 472
 473### Task 1.2: `gitutil.Transport` can be cancelled
 474
 475**Files:**
 476- Modify: `internal/gitutil/gitutil.go:39-56`
 477- Create: `internal/gitutil/proc_unix.go`, `internal/gitutil/proc_other.go`
 478- Test: `internal/gitutil/transport_test.go` (create)
 479
 480**Interfaces:**
 481- Produces: `func Transport(service, repoPath string, stdin io.Reader, stdout, errW io.Writer, extraEnv []string, maxPack int64, cancel <-chan struct{}) error` — closing `cancel` kills the git process and its children; a nil `cancel` never fires.
 482
 483- [ ] **Step 1: Write the failing test**
 484
 485`internal/gitutil/transport_test.go`:
 486
 487```go
 488package gitutil
 489
 490import (
 491	"io"
 492	"os/exec"
 493	"strings"
 494	"testing"
 495	"time"
 496)
 497
 498// Closing cancel kills the transport; it does not wait for the client
 499// to hang up. Stdin ends only after the kill, as a cut connection's
 500// does, so a clean exit here would mean the kill never happened.
 501func TestTransportCancelKillsGit(t *testing.T) {
 502	dir := t.TempDir()
 503	if out, err := exec.Command("git", "init", "-q", "--bare", dir).CombinedOutput(); err != nil {
 504		t.Fatalf("git init: %v\n%s", err, out)
 505	}
 506	in, w := io.Pipe()
 507	cancel := make(chan struct{})
 508	errc := make(chan error, 1)
 509	go func() { errc <- Transport("git-upload-pack", dir, in, io.Discard, io.Discard, nil, 0, cancel) }()
 510	close(cancel)
 511	time.AfterFunc(500*time.Millisecond, func() { w.Close() })
 512	select {
 513	case err := <-errc:
 514		if err == nil || !strings.Contains(err.Error(), "killed") {
 515			t.Fatalf("Transport returned %v, want the process killed", err)
 516		}
 517	case <-time.After(5 * time.Second):
 518		t.Fatal("Transport did not return after cancel")
 519	}
 520}
 521```
 522
 523- [ ] **Step 2: Run it and see it fail**
 524
 525Run: `go test ./internal/gitutil -run TestTransportCancelKillsGit -count=1`
 526Expected: FAIL to compile, too many arguments to `Transport`.
 527
 528- [ ] **Step 3: Process-group helpers**
 529
 530`internal/gitutil/proc_unix.go`:
 531
 532```go
 533//go:build unix
 534
 535package gitutil
 536
 537import (
 538	"os/exec"
 539	"syscall"
 540)
 541
 542// ownProcessGroup puts cmd in a process group of its own, so killTree
 543// ends what it started too: receive-pack runs index-pack and the hooks.
 544func ownProcessGroup(cmd *exec.Cmd) {
 545	if cmd.SysProcAttr == nil {
 546		cmd.SysProcAttr = &syscall.SysProcAttr{}
 547	}
 548	cmd.SysProcAttr.Setpgid = true
 549}
 550
 551func killTree(cmd *exec.Cmd) {
 552	if cmd.Process == nil {
 553		return
 554	}
 555	if err := syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL); err != nil {
 556		cmd.Process.Kill()
 557	}
 558}
 559```
 560
 561`internal/gitutil/proc_other.go`:
 562
 563```go
 564//go:build !unix
 565
 566package gitutil
 567
 568import "os/exec"
 569
 570func ownProcessGroup(cmd *exec.Cmd) {}
 571
 572func killTree(cmd *exec.Cmd) {
 573	if cmd.Process != nil {
 574		cmd.Process.Kill()
 575	}
 576}
 577```
 578
 579- [ ] **Step 4: Transport**
 580
 581Replace `Transport` in `internal/gitutil/gitutil.go`:
 582
 583```go
 584// Transport runs a git transport service against repoPath with the
 585// client's streams. Closing cancel kills the service and everything it
 586// started; a push killed before its pre-receive hook answers updates no
 587// refs.
 588func Transport(service, repoPath string, stdin io.Reader, stdout, errW io.Writer, extraEnv []string, maxPack int64, cancel <-chan struct{}) error {
 589	var args []string
 590	switch service {
 591	case "git-upload-pack", "git-receive-pack", "git-upload-archive":
 592		if service == "git-receive-pack" && maxPack > 0 {
 593			args = []string{"-c", fmt.Sprintf("receive.maxInputSize=%d", maxPack)}
 594		}
 595		args = append(args, strings.TrimPrefix(service, "git-"), repoPath)
 596	default:
 597		return fmt.Errorf("unknown service %q", service)
 598	}
 599	cmd := exec.Command(toolpath.Look("git"), args...)
 600	cmd.Env = append(os.Environ(), extraEnv...)
 601	cmd.Stdin = stdin
 602	cmd.Stdout = stdout
 603	cmd.Stderr = errW
 604	ownProcessGroup(cmd)
 605	if err := cmd.Start(); err != nil {
 606		return err
 607	}
 608	finished := make(chan struct{})
 609	go func() {
 610		select {
 611		case <-cancel:
 612			killTree(cmd)
 613		case <-finished:
 614		}
 615	}()
 616	err := cmd.Wait()
 617	close(finished)
 618	return err
 619}
 620```
 621
 622If the current doc comment above `Transport` (line 38 and up) says something different, keep its first sentence and replace the rest with the above.
 623
 624- [ ] **Step 5: Fix the caller so the tree builds**
 625
 626In `internal/sshd/sshd.go:464`, pass `nil` for now (Task 1.3 wires the channel):
 627
 628```go
 629	if err := gitutil.Transport(service, dir, stdin, stdout, stderr, env, maxPack, nil); err != nil {
 630```
 631
 632- [ ] **Step 6: Run the tests**
 633
 634Run: `go build ./... && go test ./internal/gitutil -count=1`
 635Expected: PASS.
 636
 637- [ ] **Step 7: Commit**
 638
 639```bash
 640git add internal/gitutil internal/sshd/sshd.go
 641git commit -S -m "gitutil: Transport takes a cancel channel and kills its process group
 642
 643Ref #256"
 644```
 645
 646### Task 1.3: sshd re-reads the key per exec and cuts revoked connections
 647
 648**Files:**
 649- Modify: `internal/sshd/sshd.go` — `conn` (46-53), `New` (55-71), `Serve` (158-180), `handleConn` (214-238), `handleSession` (240-292), `runExec` (299-313), `Exec` (339-385), `runGit` (387-468)
 650- Modify: `cmd/gitbayd/system.go:97`
 651- Modify: `internal/sshd/sshd_test.go:20-87` (`followServer` split)
 652- Test: `internal/sshd/revoke_test.go` (create)
 653
 654**Interfaces:**
 655- Consumes: `store.Revoked`, `Store.OnRevoke`, `Store.LiveSSHKeys` (Task 1.1); `gitutil.Transport(..., cancel)` (Task 1.2).
 656- Produces:
 657  - `func Exec(cfg config.Config, st *store.Store, user store.User, key store.SSHKey, term control.Term, cmdline string, stdin io.Reader, stdout, stderr io.Writer, done, stopping, revoked <-chan struct{}) int` — scope and audit source come from `key`.
 658  - `func (s *Server) sweepOnce()` (tests call it).
 659  - Test helpers in package `sshd`: `type testServer struct{ srv *Server; st *store.Store; client *ssh.Client; uid, keyID int64; fp string }`, `newTestServer(t) testServer`, `withBuild(t, ts)`, `execStatus(client, cmd) (int, string)`, `waitClosed(t, client)`.
 660
 661- [ ] **Step 1: Split the test fixture**
 662
 663In `internal/sshd/sshd_test.go`, replace `followServer` (lines 20-87) with:
 664
 665```go
 666// testServer is an embedded server over a fresh store holding alice
 667// with one full-scope key, and a client connected with that key.
 668type testServer struct {
 669	srv    *Server
 670	st     *store.Store
 671	client *ssh.Client
 672	uid    int64
 673	keyID  int64
 674	fp     string
 675}
 676
 677func newTestServer(t *testing.T) testServer {
 678	t.Helper()
 679	root := t.TempDir()
 680	st, err := store.Open(filepath.Join(root, "gitbay.db"))
 681	if err != nil {
 682		t.Fatal(err)
 683	}
 684	t.Cleanup(func() { st.Close() })
 685	if err := st.MigrateUp(); err != nil {
 686		t.Fatal(err)
 687	}
 688	uid, err := st.CreateUser("alice", false)
 689	if err != nil {
 690		t.Fatal(err)
 691	}
 692	_, priv, err := ed25519.GenerateKey(rand.Reader)
 693	if err != nil {
 694		t.Fatal(err)
 695	}
 696	signer, err := ssh.NewSignerFromKey(priv)
 697	if err != nil {
 698		t.Fatal(err)
 699	}
 700	pub := signer.PublicKey()
 701	fp := ssh.FingerprintSHA256(pub)
 702	if err := st.AddSSHKey(uid, fp, pub.Type(), pub.Marshal(), "full", "test"); err != nil {
 703		t.Fatal(err)
 704	}
 705	key, err := st.SSHKeyByFingerprint(fp)
 706	if err != nil {
 707		t.Fatal(err)
 708	}
 709
 710	cfg := config.Default()
 711	cfg.Server.Root = root
 712	srv, err := New(cfg, st)
 713	if err != nil {
 714		t.Fatal(err)
 715	}
 716	ln, err := net.Listen("tcp", "127.0.0.1:0")
 717	if err != nil {
 718		t.Fatal(err)
 719	}
 720	go srv.Serve(ln)
 721	t.Cleanup(func() { ln.Close() })
 722
 723	client, err := ssh.Dial("tcp", ln.Addr().String(), &ssh.ClientConfig{
 724		User:            "git",
 725		Auth:            []ssh.AuthMethod{ssh.PublicKeys(signer)},
 726		HostKeyCallback: ssh.InsecureIgnoreHostKey(),
 727		Timeout:         5 * time.Second,
 728	})
 729	if err != nil {
 730		t.Fatal(err)
 731	}
 732	t.Cleanup(func() { client.Close() })
 733	return testServer{srv: srv, st: st, client: client, uid: uid, keyID: key.ID, fp: fp}
 734}
 735
 736// withBuild gives alice the public repo alice/app and a queued build 1
 737// whose log has one line.
 738func withBuild(t *testing.T, ts testServer) {
 739	t.Helper()
 740	repoID, err := ts.st.CreateRepo("user", ts.uid, "app", "public")
 741	if err != nil {
 742		t.Fatal(err)
 743	}
 744	id, err := ts.st.CreateBuild(repoID, "unit", "abc", "main", `["true"]`, "", "", true)
 745	if err != nil {
 746		t.Fatal(err)
 747	}
 748	if err := ts.st.AppendBuildLog(id, []byte("queued\n")); err != nil {
 749		t.Fatal(err)
 750	}
 751}
 752
 753// followServer starts an embedded server holding alice, her public repo
 754// alice/app and a queued build 1 whose log has one line, and returns it
 755// with a client connected as alice.
 756func followServer(t *testing.T) (*Server, *ssh.Client) {
 757	t.Helper()
 758	ts := newTestServer(t)
 759	withBuild(t, ts)
 760	return ts.srv, ts.client
 761}
 762```
 763
 764Run: `go test ./internal/sshd -count=1`
 765Expected: PASS (behaviour unchanged).
 766
 767- [ ] **Step 2: Write the failing tests**
 768
 769`internal/sshd/revoke_test.go`:
 770
 771```go
 772package sshd
 773
 774import (
 775	"bytes"
 776	"errors"
 777	"strings"
 778	"testing"
 779	"time"
 780
 781	"golang.org/x/crypto/ssh"
 782)
 783
 784// execStatus runs cmd on a new session and returns its exit status and
 785// stderr; -1 when the session could not run.
 786func execStatus(client *ssh.Client, cmd string) (int, string) {
 787	sess, err := client.NewSession()
 788	if err != nil {
 789		return -1, err.Error()
 790	}
 791	defer sess.Close()
 792	var stderr bytes.Buffer
 793	sess.Stderr = &stderr
 794	err = sess.Run(cmd)
 795	var exit *ssh.ExitError
 796	switch {
 797	case err == nil:
 798		return 0, stderr.String()
 799	case errors.As(err, &exit):
 800		return exit.ExitStatus(), stderr.String()
 801	}
 802	return -1, err.Error()
 803}
 804
 805// waitClosed fails unless the server closes the client's connection
 806// within five seconds.
 807func waitClosed(t *testing.T, client *ssh.Client) {
 808	t.Helper()
 809	done := make(chan struct{})
 810	go func() { client.Wait(); close(done) }()
 811	select {
 812	case <-done:
 813	case <-time.After(5 * time.Second):
 814		t.Fatal("the connection stayed open")
 815	}
 816}
 817
 818// Each exec reads the key again. The rows change behind the store's
 819// back here, so no revocation is announced and the connection stays up:
 820// what refuses the command is the per-exec check alone.
 821func TestExecRevalidatesKey(t *testing.T) {
 822	ts := newTestServer(t)
 823	if code, errOut := execStatus(ts.client, "whoami"); code != 0 {
 824		t.Fatalf("whoami: %d %s", code, errOut)
 825	}
 826	if _, err := ts.st.DB.Exec("UPDATE ssh_keys SET scope = 'git' WHERE id = ?", ts.keyID); err != nil {
 827		t.Fatal(err)
 828	}
 829	if code, errOut := execStatus(ts.client, "whoami"); code != 4 || !strings.Contains(errOut, "does not allow control commands") {
 830		t.Fatalf("whoami after re-scope: %d %q", code, errOut)
 831	}
 832	if _, err := ts.st.DB.Exec("DELETE FROM ssh_keys WHERE id = ?", ts.keyID); err != nil {
 833		t.Fatal(err)
 834	}
 835	if code, errOut := execStatus(ts.client, "whoami"); code != 4 || !strings.Contains(errOut, "no longer registered") {
 836		t.Fatalf("whoami after delete: %d %q", code, errOut)
 837	}
 838}
 839
 840// Removing the key cuts the connection, ending a command running on it.
 841func TestRemoveKeyCutsConnection(t *testing.T) {
 842	ts := newTestServer(t)
 843	withBuild(t, ts)
 844	var stderr bytes.Buffer
 845	sess := startFollow(t, ts.client, &stderr)
 846	if err := ts.st.RemoveSSHKey(ts.uid, ts.fp); err != nil {
 847		t.Fatal(err)
 848	}
 849	waited := make(chan error, 1)
 850	go func() { waited <- sess.Wait() }()
 851	select {
 852	case err := <-waited:
 853		if err == nil {
 854			t.Fatal("the follow exited cleanly after its key was removed")
 855		}
 856	case <-time.After(5 * time.Second):
 857		t.Fatal("the follow outlived its key")
 858	}
 859	waitClosed(t, ts.client)
 860}
 861
 862func TestDisableCutsConnection(t *testing.T) {
 863	ts := newTestServer(t)
 864	if err := ts.st.SetUserDisabled(ts.uid, true); err != nil {
 865		t.Fatal(err)
 866	}
 867	waitClosed(t, ts.client)
 868}
 869
 870// A revocation made by another process is not announced here; the
 871// sweep finds it. A live key survives the sweep.
 872func TestSweepCutsOutOfProcessRevocation(t *testing.T) {
 873	ts := newTestServer(t)
 874	ts.srv.sweepOnce()
 875	if code, errOut := execStatus(ts.client, "whoami"); code != 0 {
 876		t.Fatalf("the sweep cut a live key: %d %s", code, errOut)
 877	}
 878	if _, err := ts.st.DB.Exec("UPDATE users SET disabled = 1 WHERE id = ?", ts.uid); err != nil {
 879		t.Fatal(err)
 880	}
 881	ts.srv.sweepOnce()
 882	waitClosed(t, ts.client)
 883}
 884```
 885
 886- [ ] **Step 3: Run them and see them fail**
 887
 888Run: `go test ./internal/sshd -run 'TestExecRevalidatesKey|TestRemoveKeyCutsConnection|TestDisableCutsConnection|TestSweepCutsOutOfProcessRevocation' -count=1`
 889Expected: FAIL to compile, `ts.srv.sweepOnce undefined`.
 890
 891- [ ] **Step 4: Track the key on each connection**
 892
 893In `internal/sshd/sshd.go` add `"slices"` to the imports. Replace `conn`:
 894
 895```go
 896// conn is one accepted connection and how many sessions it is running.
 897// A CLI's shared connection sits idle between commands; on shutdown an
 898// idle connection is closed at once and only a session mid-command is
 899// waited for (#141).
 900type conn struct {
 901	net    net.Conn
 902	active atomic.Int32
 903	// keyID and userID are the key that authenticated the connection and
 904	// its account: 0 before the handshake and for an unregistered key.
 905	// Guarded by Server.mu.
 906	keyID, userID int64
 907	revoked       chan struct{} // closed by cut
 908	cutOnce       sync.Once
 909}
 910
 911// cut ends the connection because its key was revoked: a git transport
 912// on it is killed, and every other command loses its channel.
 913func (c *conn) cut() {
 914	c.cutOnce.Do(func() { close(c.revoked) })
 915	c.net.Close()
 916}
 917```
 918
 919In `New`, after `s.sshCfg = sc`:
 920
 921```go
 922	st.OnRevoke(s.revoke)
 923```
 924
 925`Serve` becomes:
 926
 927```go
 928// Serve accepts connections on ln until it is closed.
 929func (s *Server) Serve(ln net.Listener) error {
 930	served := make(chan struct{})
 931	defer close(served)
 932	go s.sweep(served)
 933	for {
 934		nc, err := ln.Accept()
 935		if err != nil {
 936			return err
 937		}
 938		c := &conn{net: nc, revoked: make(chan struct{})}
 939		s.mu.Lock()
 940		s.conns[c] = struct{}{}
 941		s.mu.Unlock()
 942		s.sessions.Add(1)
 943		go func() {
 944			defer s.sessions.Done()
 945			defer func() {
 946				s.mu.Lock()
 947				delete(s.conns, c)
 948				s.mu.Unlock()
 949			}()
 950			s.handleConn(c)
 951		}()
 952	}
 953}
 954```
 955
 956Add after `Serve`:
 957
 958```go
 959// revoke closes the connections opened by the keys r names.
 960func (s *Server) revoke(r store.Revoked) {
 961	s.mu.Lock()
 962	defer s.mu.Unlock()
 963	for c := range s.conns {
 964		if c.keyID == 0 {
 965			continue
 966		}
 967		if (r.UserID != 0 && c.userID == r.UserID) || slices.Contains(r.KeyIDs, c.keyID) {
 968			c.cut()
 969		}
 970	}
 971}
 972
 973// sweepInterval bounds how long a revocation this process was not told
 974// about (gitbayd admin on the host) leaves a connection open.
 975const sweepInterval = 15 * time.Second
 976
 977func (s *Server) sweep(served <-chan struct{}) {
 978	t := time.NewTicker(sweepInterval)
 979	defer t.Stop()
 980	for {
 981		select {
 982		case <-t.C:
 983			s.sweepOnce()
 984		case <-served:
 985			return
 986		case <-s.stopping:
 987			return
 988		}
 989	}
 990}
 991
 992// sweepOnce cuts every connection whose key is no longer live. Only
 993// connections whose key was asked about are judged: one that
 994// authenticated while the query ran waits for the next sweep.
 995func (s *Server) sweepOnce() {
 996	asked := map[int64]bool{}
 997	s.mu.Lock()
 998	for c := range s.conns {
 999		if c.keyID != 0 {
1000			asked[c.keyID] = true
1001		}
1002	}
1003	s.mu.Unlock()
1004	if len(asked) == 0 {
1005		return
1006	}
1007	live, err := s.st.LiveSSHKeys(slices.Collect(maps.Keys(asked)))
1008	if err != nil {
1009		slog.Error("ssh sweep: key lookup", "err", err)
1010		return
1011	}
1012	s.mu.Lock()
1013	defer s.mu.Unlock()
1014	for c := range s.conns {
1015		if asked[c.keyID] && !live[c.keyID] {
1016			c.cut()
1017		}
1018	}
1019}
1020```
1021
1022Add `"maps"` to the imports.
1023
1024In `handleConn`, after `defer sconn.Close()`:
1025
1026```go
1027	ext := sconn.Permissions.Extensions
1028	s.mu.Lock()
1029	c.keyID, _ = strconv.ParseInt(ext["key-id"], 10, 64)
1030	c.userID, _ = strconv.ParseInt(ext["user-id"], 10, 64)
1031	s.mu.Unlock()
1032```
1033
1034and pass `c` to the session: `s.handleSession(c, sconn, ch, chReqs)`.
1035
1036`handleSession` takes the connection:
1037
1038```go
1039func (s *Server) handleSession(c *conn, sconn *ssh.ServerConn, ch ssh.Channel, reqs <-chan *ssh.Request) {
1040```
1041
1042and its exec case calls `code := s.runExec(c, sconn, ch, term, payload.Command, done)`.
1043
1044- [ ] **Step 5: Re-read the key per exec**
1045
1046Replace `runExec`:
1047
1048```go
1049func (s *Server) runExec(c *conn, sconn *ssh.ServerConn, ch ssh.Channel, term control.Term, cmdline string, done <-chan struct{}) int {
1050	ext := sconn.Permissions.Extensions
1051	if blob := ext["anon-key"]; blob != "" {
1052		return s.runAnonymous(ch, blob, cmdline)
1053	}
1054	userID, _ := strconv.ParseInt(ext["user-id"], 10, 64)
1055	keyID, _ := strconv.ParseInt(ext["key-id"], 10, 64)
1056	// A connection outlives its commands, so the key is read again for
1057	// each one: what it may do is what it may do now (#256).
1058	key, err := s.st.SSHKeyByID(keyID)
1059	if errors.Is(err, store.ErrNotFound) || (err == nil && key.UserID != userID) {
1060		fmt.Fprintln(ch.Stderr(), "this key is no longer registered")
1061		return protocol.ExitDenied
1062	}
1063	if err != nil {
1064		slog.Error("ssh exec: key lookup", "err", err)
1065		fmt.Fprintln(ch.Stderr(), "authentication temporarily unavailable")
1066		return protocol.ExitFailure
1067	}
1068	user, err := s.st.UserByID(userID)
1069	if err != nil {
1070		fmt.Fprintln(ch.Stderr(), "account no longer exists")
1071		return protocol.ExitDenied
1072	}
1073	_ = s.st.TouchSSHKey(keyID)
1074	return Exec(s.cfg, s.st, user, key, term, cmdline, ch, ch, ch.Stderr(), done, s.stopping, c.revoked)
1075}
1076```
1077
1078- [ ] **Step 6: `Exec` takes the key; `runGit` takes the cancel channel**
1079
1080```go
1081// Exec runs one SSH exec command line for an authenticated key. It is the
1082// single dispatch path shared by the embedded listener and the system-sshd
1083// forced command (gitbayd shell). Closing revoked kills a git transport.
1084func Exec(cfg config.Config, st *store.Store, user store.User, key store.SSHKey, term control.Term, cmdline string,
1085	stdin io.Reader, stdout, stderr io.Writer, done, stopping, revoked <-chan struct{}) int {
1086```
1087
1088Inside `Exec`: `runGit(cfg, st, user, key.Scope, argv, stdin, stdout, stderr, revoked)`, `runLFSAuthenticate(cfg, st, user, key.Scope, argv, stdout, stderr)`, and in the `Ctx` literal `Scope: key.Scope, Source: key.Fingerprint`.
1089
1090`runGit` gains a last parameter `revoked <-chan struct{}` and passes it on:
1091
1092```go
1093func runGit(cfg config.Config, st *store.Store, user store.User, scope string, argv []string,
1094	stdin io.Reader, stdout, stderr io.Writer, revoked <-chan struct{}) int {
1095```
1096
1097```go
1098	if err := gitutil.Transport(service, dir, stdin, stdout, stderr, env, maxPack, revoked); err != nil {
1099```
1100
1101In `cmd/gitbayd/system.go:97`:
1102
1103```go
1104			code := sshd.Exec(cfg, st, user, key, control.ParseTerm(os.Getenv("GITBAY_TERM")), cmdline, os.Stdin, os.Stdout, os.Stderr, nil, nil, nil)
1105```
1106
1107- [ ] **Step 7: Run the tests**
1108
1109Run: `go build ./... && go vet ./... && go test ./internal/sshd ./internal/gitutil ./internal/store -count=1`
1110Expected: PASS.
1111
1112- [ ] **Step 8: Commit**
1113
1114```bash
1115git add internal/sshd cmd/gitbayd/system.go
1116git commit -S -m "sshd: re-read the key per exec; revocation cuts its connections
1117
1118Ref #256"
1119```
1120
1121### Task 1.4: e2e — a multiplexed connection is cut, a push in flight moves no ref
1122
1123**Files:**
1124- Create: `e2e/revoke_test.go`
1125
1126**Interfaces:**
1127- Produces (package `e2e`): `pkt(s string) string`, `readPkt(r *bufio.Reader) (string, error)`, `fingerprint(t *testing.T, pubPath string) string` — MR 2 uses `fingerprint`.
1128
1129- [ ] **Step 1: Write the test**
1130
1131```go
1132package e2e
1133
1134import (
1135	"bufio"
1136	"fmt"
1137	"io"
1138	"os"
1139	"os/exec"
1140	"path/filepath"
1141	"strconv"
1142	"strings"
1143	"testing"
1144	"time"
1145)
1146
1147// pkt frames one pkt-line.
1148func pkt(s string) string { return fmt.Sprintf("%04x%s", len(s)+4, s) }
1149
1150// readPkt reads one pkt-line; a flush reads as "".
1151func readPkt(r *bufio.Reader) (string, error) {
1152	var n [4]byte
1153	if _, err := io.ReadFull(r, n[:]); err != nil {
1154		return "", err
1155	}
1156	size, err := strconv.ParseUint(string(n[:]), 16, 16)
1157	if err != nil {
1158		return "", err
1159	}
1160	if size == 0 {
1161		return "", nil
1162	}
1163	buf := make([]byte, size-4)
1164	_, err = io.ReadFull(r, buf)
1165	return string(buf), err
1166}
1167
1168// fingerprint is the SHA256 fingerprint of a public key file.
1169func fingerprint(t *testing.T, pubPath string) string {
1170	t.Helper()
1171	out, err := exec.Command("ssh-keygen", "-lf", pubPath).Output()
1172	if err != nil {
1173		t.Fatalf("ssh-keygen -lf: %v", err)
1174	}
1175	return strings.Fields(string(out))[1]
1176}
1177
1178// Removing a key cuts the connections it opened: every session
1179// multiplexed on a ControlMaster, and a push in flight, which moves no
1180// ref (#256).
1181func TestRemovedKeyCutsMultiplexedConnection(t *testing.T) {
1182	t.Parallel()
1183	inst := startInstance(t)
1184	aliceKey := setupPublicRepo(t, inst, "alice/app")
1185	spare := inst.newKey(t, "spare")
1186	pub, err := os.ReadFile(spare + ".pub")
1187	if err != nil {
1188		t.Fatal(err)
1189	}
1190	if _, errOut, code := inst.ssh(t, aliceKey, string(pub), "keys", "add"); code != 0 {
1191		t.Fatalf("keys add: %s", errOut)
1192	}
1193
1194	// The control socket sits under the system temp dir: t.TempDir() on
1195	// macOS is long enough to pass the 104-byte socket path limit.
1196	cmDir, err := os.MkdirTemp("", "cm")
1197	if err != nil {
1198		t.Fatal(err)
1199	}
1200	t.Cleanup(func() { os.RemoveAll(cmDir) })
1201	muxArgs := []string{
1202		"-p", fmt.Sprint(inst.port),
1203		"-i", aliceKey,
1204		"-o", "IdentitiesOnly=yes",
1205		"-o", "StrictHostKeyChecking=no",
1206		"-o", "UserKnownHostsFile=" + filepath.Join(inst.sshDir, "known_hosts"),
1207		"-o", "BatchMode=yes",
1208		"-o", "ControlMaster=auto",
1209		"-o", "ControlPath=" + filepath.Join(cmDir, "%C"),
1210		"-o", "ControlPersist=60",
1211	}
1212	mux := func(args ...string) *exec.Cmd {
1213		return exec.Command("ssh", append(append([]string{}, muxArgs...), args...)...)
1214	}
1215	t.Cleanup(func() { mux("-O", "exit", "git@127.0.0.1").Run() })
1216
1217	if out, err := mux("git@127.0.0.1", "whoami").Output(); err != nil || strings.TrimSpace(string(out)) != "alice" {
1218		t.Fatalf("whoami over the master: %v %q", err, out)
1219	}
1220
1221	// A push held open mid-pack: the ref update is sent, the pack is not.
1222	push := mux("git@127.0.0.1", "git-receive-pack", "alice/app")
1223	stdin, err := push.StdinPipe()
1224	if err != nil {
1225		t.Fatal(err)
1226	}
1227	stdout, err := push.StdoutPipe()
1228	if err != nil {
1229		t.Fatal(err)
1230	}
1231	if err := push.Start(); err != nil {
1232		t.Fatal(err)
1233	}
1234	adv := bufio.NewReader(stdout)
1235	first, err := readPkt(adv)
1236	if err != nil || len(first) < 40 {
1237		t.Fatalf("advertisement: %q %v", first, err)
1238	}
1239	oldSHA := first[:40]
1240	for {
1241		line, err := readPkt(adv)
1242		if err != nil {
1243			t.Fatalf("advertisement: %v", err)
1244		}
1245		if line == "" {
1246			break
1247		}
1248	}
1249	newSHA := strings.Repeat("1", 40)
1250	io.WriteString(stdin, pkt(oldSHA+" "+newSHA+" refs/heads/main\x00report-status\n")+"0000")
1251	// A pack header announcing one object, and no object.
1252	stdin.Write([]byte("PACK\x00\x00\x00\x02\x00\x00\x00\x01"))
1253	exited := make(chan error, 1)
1254	go func() {
1255		io.Copy(io.Discard, adv)
1256		exited <- push.Wait()
1257	}()
1258
1259	if _, errOut, code := inst.ssh(t, spare, "", "keys", "remove", fingerprint(t, aliceKey+".pub")); code != 0 {
1260		t.Fatalf("keys remove: %s", errOut)
1261	}
1262	select {
1263	case err := <-exited:
1264		if err == nil {
1265			t.Fatal("the push exited cleanly after its key was removed")
1266		}
1267	case <-time.After(10 * time.Second):
1268		t.Fatal("the push outlived its key")
1269	}
1270
1271	// The master went with the connection; a new one authenticates
1272	// again, and the key is unknown.
1273	if out, err := mux("git@127.0.0.1", "whoami").CombinedOutput(); err == nil {
1274		t.Fatalf("whoami after removal succeeded: %s", out)
1275	}
1276	refs := mustGit(t, t.TempDir(), inst.gitEnv(spare), "ls-remote", inst.sshURL("alice/app"), "refs/heads/main")
1277	if !strings.HasPrefix(refs, oldSHA) {
1278		t.Fatalf("main moved: %s, want %s", refs, oldSHA)
1279	}
1280}
1281```
1282
1283- [ ] **Step 2: Run it**
1284
1285Run: `go test ./e2e -run TestRemovedKeyCutsMultiplexedConnection -count=1`
1286Expected: PASS. To see it fail, stash Task 1.3's `st.OnRevoke(s.revoke)` line: the push then hangs until the 10-second timeout.
1287
1288- [ ] **Step 3: Commit**
1289
1290```bash
1291git add e2e/revoke_test.go
1292git commit -S -m "e2e: removing a key cuts its multiplexed connection and a push in flight
1293
1294Ref #256"
1295```
1296
1297### Task 1.5: docs
1298
1299**Files:**
1300- Modify: `.gitbay/wiki/Architecture/10-Known-Gaps.org`, `09-Controls.org:28`, `05-Identity-and-Access.org:17-18`, `08-Operations.org:88-89`, `.gitbay/wiki/Threat-Model.org` (Trust boundaries), `.gitbay/wiki/Users.org` (after the keys block, ~line 70)
1301
1302- [ ] **Step 1: Edit the pages**
1303
1304`10-Known-Gaps.org`: delete the `#256` row. In the paragraph under the table, drop "#256 closes a removed key's connections, running commands included;" so it opens "Decisions already taken on these: #257 refuses ...".
1305
1306`09-Controls.org`, the revocation row becomes:
1307
1308```
1309| Revocation takes effect immediately         | in place | removing a key or disabling an account closes its connections; every exec re-reads its key (=internal/sshd/sshd.go=) |
1310```
1311
1312`05-Identity-and-Access.org`, the SSH user key and deploy key rows' Revocation cells become `=keys remove= (own keys); closes its connections` and `=repo deploy-key remove= (repo admin); closes its connections`.
1313
1314`08-Operations.org`: delete the two lines "Open connections of a removed key keep working until they close; see #256."
1315
1316`Threat-Model.org`, add to "Trust boundaries" after the "SSH public key = identity" bullet:
1317
1318```
1319- *Revocation is immediate.* Every exec and every git transport session
1320  re-reads its key. Removing a key, removing a deploy key, disabling or
1321  deleting an account closes the connections the affected keys opened:
1322  a git transport is killed with its children, and a push killed before
1323  its pre-receive hook answers moves no ref. A control command already
1324  inside its database write finishes it; its output is lost. A
1325  revocation made by =gitbayd admin= on the host, another process, is
1326  found within 15 seconds. In =ssh.mode = "system"= each exec is its
1327  own process: the next exec is refused, one already running is not cut.
1328```
1329
1330`Users.org`, after the paragraph that ends "Labels are one line of up to 64 bytes.":
1331
1332```
1333Removing a key closes every connection it opened, including the CLI's
1334shared one; removing the key the current command runs on ends that
1335command's connection too.
1336```
1337
1338- [ ] **Step 2: Commit, open the MR**
1339
1340```bash
1341git add .gitbay/wiki
1342git commit -S -m "wiki: revocation closes open connections
1343
1344Closes #256"
1345git push -u origin revoke-closes-connections
1346gitbay mr create --source revoke-closes-connections --target main --title "sshd: removing a key closes its connections"
1347```
1348
1349Merge with `--strategy ff` once CI is green; delete the branch locally and on the remote.
1350
1351---
1352
1353# MR 2: expiring credentials cannot mint; credentials record their token (branch `token-delegation`, #257)
1354
1355Decisions on the issue: a token with an expiry is refused on every
1356credential-minting command, marked on `Command` and checked in
1357`Dispatch`; tokens and keys record the token that created them;
1358`token revoke` lists what the token created and can revoke it too;
1359`token create` defaults to `--scope read`.
1360
1361Commands marked `MintsCredential`: `token create`, `keys add`,
1362`repo deploy-key add`, `repo runner add` (attaches or creates a key
1363that can claim builds), `web login` (a login link opens a seven-day
1364session), `admin invite`, `admin user create` (with `--key` or a
1365verified address it is a way in), `email verify` and
1366`admin email verify` (a verified address receives login links). See
1367open question 2.
1368
1369`created_by_token` references `api_tokens(id) ON DELETE SET NULL`: a
1370revoked token's id is never reused for a live row, because SQLite
1371reuses the highest rowid after it is deleted and a dangling integer
1372would then name the wrong token. Revoking without `--created` lists
1373what the token made and then drops the link.
1374
1375### Task 2.1: migration 0060 and the store
1376
1377**Files:**
1378- Create: `internal/store/migrations/0060_credential_origin.up.sql`, `.down.sql`
1379- Modify: `internal/store/tokens.go` (whole file)
1380- Modify: `internal/store/users.go` — `SSHKey` (18-28), `AddSSHKey` (270-289), `ListSSHKeys` (335-353)
1381- Test: `internal/store/tokens_test.go` (create)
1382
1383**Interfaces:**
1384- Consumes: `Store.announce`, `Revoked` (MR 1).
1385- Produces:
1386  - `type APIToken struct { ID int64; Name, Scope, CreatedAt string; ExpiresAt, LastUsedAt *time.Time; CreatedBy string }` — `CreatedBy` is the creating token's name, "" for none.
1387  - `func (s *Store) CreateAPIToken(userID int64, name, tokenHash, scope string, expires *time.Time, createdByToken int64) error`
1388  - `func (s *Store) APITokenUser(tokenHash string) (User, APIToken, error)`
1389  - `type Created struct { Tokens []string; Keys []string }` — token names and key fingerprints.
1390  - `func (s *Store) RevokeAPIToken(userID int64, name string, withCreated bool) (Created, error)`
1391  - `type KeyOrigin struct { CreatedByToken int64 }` (MR 3 adds `ExpiresAt`)
1392  - `func (s *Store) AddSSHKeyFrom(userID int64, fingerprint, algo string, blob []byte, scope, label string, o KeyOrigin) error`; `AddSSHKey` keeps its signature and calls it with `KeyOrigin{}`.
1393  - `SSHKey.CreatedBy string` — filled by `ListSSHKeys` only.
1394
1395- [ ] **Step 1: Write the failing test**
1396
1397`internal/store/tokens_test.go`:
1398
1399```go
1400package store
1401
1402import (
1403	"slices"
1404	"testing"
1405	"time"
1406)
1407
1408func tokenID(t *testing.T, s *Store, hash string) int64 {
1409	t.Helper()
1410	_, tok, err := s.APITokenUser(hash)
1411	if err != nil {
1412		t.Fatal(err)
1413	}
1414	return tok.ID
1415}
1416
1417// parent made child, child made grandchild and a key; the key belongs
1418// to another account, as admin user create --key makes one.
1419func tokenChain(t *testing.T) (*Store, int64, *[]Revoked) {
1420	t.Helper()
1421	s, uid, got := revokeFixture(t)
1422	bob, err := s.CreateUser("bob", false)
1423	if err != nil {
1424		t.Fatal(err)
1425	}
1426	if err := s.CreateAPIToken(uid, "parent", "h-parent", "full", nil, 0); err != nil {
1427		t.Fatal(err)
1428	}
1429	if err := s.CreateAPIToken(uid, "child", "h-child", "full", nil, tokenID(t, s, "h-parent")); err != nil {
1430		t.Fatal(err)
1431	}
1432	child := tokenID(t, s, "h-child")
1433	if err := s.CreateAPIToken(uid, "grandchild", "h-grand", "read", nil, child); err != nil {
1434		t.Fatal(err)
1435	}
1436	if err := s.AddSSHKeyFrom(bob, "SHA256:k", "ssh-ed25519", []byte("k"), "full", "", KeyOrigin{CreatedByToken: child}); err != nil {
1437		t.Fatal(err)
1438	}
1439	return s, uid, got
1440}
1441
1442func TestTokenRecordsItsCreator(t *testing.T) {
1443	s, uid, _ := tokenChain(t)
1444	toks, err := s.ListAPITokens(uid)
1445	if err != nil {
1446		t.Fatal(err)
1447	}
1448	by := map[string]string{}
1449	for _, tk := range toks {
1450		by[tk.Name] = tk.CreatedBy
1451	}
1452	if by["parent"] != "" || by["child"] != "parent" || by["grandchild"] != "child" {
1453		t.Fatalf("created by: %v", by)
1454	}
1455	bob, _ := s.UserByUsername("bob")
1456	keys, err := s.ListSSHKeys(bob.ID)
1457	if err != nil || len(keys) != 1 || keys[0].CreatedBy != "child" {
1458		t.Fatalf("key created by: %+v %v", keys, err)
1459	}
1460}
1461
1462func TestRevokeAPITokenListsWhatItCreated(t *testing.T) {
1463	s, uid, got := tokenChain(t)
1464	c, err := s.RevokeAPIToken(uid, "parent", false)
1465	if err != nil {
1466		t.Fatal(err)
1467	}
1468	if !slices.Equal(c.Tokens, []string{"child", "grandchild"}) || !slices.Equal(c.Keys, []string{"SHA256:k"}) {
1469		t.Fatalf("created = %+v", c)
1470	}
1471	// Listed, not removed; the link to the revoked parent is gone.
1472	toks, _ := s.ListAPITokens(uid)
1473	if len(toks) != 2 || toks[0].Name != "child" || toks[0].CreatedBy != "" {
1474		t.Fatalf("tokens after revoke: %+v", toks)
1475	}
1476	if _, err := s.SSHKeyByFingerprint("SHA256:k"); err != nil {
1477		t.Fatalf("the key went: %v", err)
1478	}
1479	if len(*got) != 0 {
1480		t.Fatalf("announced %+v with nothing revoked but the token", *got)
1481	}
1482}
1483
1484func TestRevokeAPITokenWithCreated(t *testing.T) {
1485	s, uid, got := tokenChain(t)
1486	k, _ := s.SSHKeyByFingerprint("SHA256:k")
1487	if _, err := s.RevokeAPIToken(uid, "parent", true); err != nil {
1488		t.Fatal(err)
1489	}
1490	if toks, _ := s.ListAPITokens(uid); len(toks) != 0 {
1491		t.Fatalf("tokens left: %+v", toks)
1492	}
1493	if _, err := s.SSHKeyByFingerprint("SHA256:k"); err != ErrNotFound {
1494		t.Fatalf("key left: %v", err)
1495	}
1496	if len(*got) != 1 || !slices.Equal((*got)[0].KeyIDs, []int64{k.ID}) {
1497		t.Fatalf("announced %+v", *got)
1498	}
1499	if _, err := s.RevokeAPIToken(uid, "parent", true); err != ErrNotFound {
1500		t.Fatalf("second revoke: %v", err)
1501	}
1502}
1503
1504func TestAPITokenUserCarriesExpiry(t *testing.T) {
1505	s, uid, _ := revokeFixture(t)
1506	exp := time.Now().Add(time.Hour)
1507	if err := s.CreateAPIToken(uid, "brief", "h-brief", "full", &exp, 0); err != nil {
1508		t.Fatal(err)
1509	}
1510	_, tok, err := s.APITokenUser("h-brief")
1511	if err != nil || tok.ExpiresAt == nil || tok.Name != "brief" || tok.ID == 0 {
1512		t.Fatalf("token %+v %v", tok, err)
1513	}
1514}
1515```
1516
1517- [ ] **Step 2: Run it and see it fail**
1518
1519Run: `go test ./internal/store -run 'TestTokenRecordsItsCreator|TestRevokeAPIToken|TestAPITokenUserCarriesExpiry' -count=1`
1520Expected: FAIL to compile.
1521
1522- [ ] **Step 3: Migration**
1523
1524`internal/store/migrations/0060_credential_origin.up.sql`:
1525
1526```sql
1527-- The API token a credential was created through. NULL when it was not,
1528-- and once that token is revoked.
1529ALTER TABLE api_tokens ADD COLUMN created_by_token INTEGER REFERENCES api_tokens(id) ON DELETE SET NULL;
1530ALTER TABLE ssh_keys ADD COLUMN created_by_token INTEGER REFERENCES api_tokens(id) ON DELETE SET NULL;
1531```
1532
1533`internal/store/migrations/0060_credential_origin.down.sql`:
1534
1535```sql
1536ALTER TABLE ssh_keys DROP COLUMN created_by_token;
1537ALTER TABLE api_tokens DROP COLUMN created_by_token;
1538```
1539
1540- [ ] **Step 4: Tokens in the store**
1541
1542Replace `internal/store/tokens.go`:
1543
1544```go
1545package store
1546
1547import (
1548	"database/sql"
1549	"errors"
1550	"fmt"
1551	"strings"
1552	"time"
1553)
1554
1555type APIToken struct {
1556	ID         int64
1557	Name       string
1558	Scope      string
1559	CreatedAt  string
1560	ExpiresAt  *time.Time
1561	LastUsedAt *time.Time
1562	CreatedBy  string // name of the token that created this one; "" for none
1563}
1564
1565// nullID stores 0 as NULL.
1566func nullID(id int64) any {
1567	if id == 0 {
1568		return nil
1569	}
1570	return id
1571}
1572
1573// CreateAPIToken stores a token hash; expires nil means no expiry,
1574// createdByToken 0 means it was not created through a token.
1575func (s *Store) CreateAPIToken(userID int64, name, tokenHash, scope string, expires *time.Time, createdByToken int64) error {
1576	var exp any
1577	if expires != nil {
1578		exp = fmtTime(*expires)
1579	}
1580	_, err := s.DB.Exec(
1581		"INSERT INTO api_tokens (user_id, name, token_hash, scope, expires_at, created_by_token) VALUES (?, ?, ?, ?, ?, ?)",
1582		userID, name, tokenHash, scope, exp, nullID(createdByToken))
1583	if isUniqueErr(err) {
1584		return fmt.Errorf("you already have a token named %q", name)
1585	}
1586	return err
1587}
1588
1589// APITokenUser resolves a presented token to its user and the token;
1590// expired and unknown tokens fail identically.
1591func (s *Store) APITokenUser(tokenHash string) (User, APIToken, error) {
1592	var userID int64
1593	var t APIToken
1594	var exp sql.NullString
1595	err := s.DB.QueryRow(`
1596		SELECT user_id, id, name, scope, expires_at FROM api_tokens
1597		WHERE token_hash = ? AND (expires_at IS NULL OR expires_at > ?)`,
1598		tokenHash, fmtTime(time.Now())).Scan(&userID, &t.ID, &t.Name, &t.Scope, &exp)
1599	if errors.Is(err, sql.ErrNoRows) {
1600		return User{}, APIToken{}, ErrNotFound
1601	}
1602	if err != nil {
1603		return User{}, APIToken{}, err
1604	}
1605	t.ExpiresAt = parseTime(exp)
1606	s.DB.Exec("UPDATE api_tokens SET last_used_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE token_hash = ?", tokenHash)
1607	u, err := s.UserByID(userID)
1608	return u, t, err
1609}
1610
1611func (s *Store) ListAPITokens(userID int64) ([]APIToken, error) {
1612	rows, err := s.DB.Query(`
1613		SELECT t.id, t.name, t.scope, t.created_at, t.expires_at, t.last_used_at, COALESCE(p.name, '')
1614		FROM api_tokens t LEFT JOIN api_tokens p ON p.id = t.created_by_token
1615		WHERE t.user_id = ? ORDER BY t.name`, userID)
1616	if err != nil {
1617		return nil, err
1618	}
1619	defer rows.Close()
1620	var out []APIToken
1621	for rows.Next() {
1622		var t APIToken
1623		var exp, used sql.NullString
1624		if err := rows.Scan(&t.ID, &t.Name, &t.Scope, &t.CreatedAt, &exp, &used, &t.CreatedBy); err != nil {
1625			return nil, err
1626		}
1627		t.ExpiresAt = parseTime(exp)
1628		t.LastUsedAt = parseTime(used)
1629		out = append(out, t)
1630	}
1631	return out, rows.Err()
1632}
1633
1634// Created is what a token made, directly or through tokens it made:
1635// token names and SSH key fingerprints.
1636type Created struct {
1637	Tokens []string `json:"tokens"`
1638	Keys   []string `json:"keys"`
1639}
1640
1641// chainCTE selects the token named by the first argument and every
1642// token created from it, at any depth.
1643const chainCTE = `WITH RECURSIVE chain(id) AS (
1644	SELECT ? UNION SELECT t.id FROM api_tokens t JOIN chain ON t.created_by_token = chain.id)`
1645
1646// RevokeAPIToken deletes the user's token by name and returns what it
1647// created. withCreated deletes those too; otherwise they stay and lose
1648// the link to the revoked token.
1649func (s *Store) RevokeAPIToken(userID int64, name string, withCreated bool) (Created, error) {
1650	tx, err := s.DB.Begin()
1651	if err != nil {
1652		return Created{}, err
1653	}
1654	defer tx.Rollback()
1655	var id int64
1656	err = tx.QueryRow("SELECT id FROM api_tokens WHERE user_id = ? AND name = ?", userID, name).Scan(&id)
1657	if errors.Is(err, sql.ErrNoRows) {
1658		return Created{}, ErrNotFound
1659	}
1660	if err != nil {
1661		return Created{}, err
1662	}
1663	var c Created
1664	rows, err := tx.Query(chainCTE+` SELECT name FROM api_tokens WHERE id IN (SELECT id FROM chain) AND id != ? ORDER BY name`, id, id)
1665	if err != nil {
1666		return Created{}, err
1667	}
1668	for rows.Next() {
1669		var n string
1670		if err := rows.Scan(&n); err != nil {
1671			rows.Close()
1672			return Created{}, err
1673		}
1674		c.Tokens = append(c.Tokens, n)
1675	}
1676	rows.Close()
1677	var keyIDs []int64
1678	rows, err = tx.Query(chainCTE+` SELECT id, fingerprint FROM ssh_keys WHERE created_by_token IN (SELECT id FROM chain) ORDER BY id`, id)
1679	if err != nil {
1680		return Created{}, err
1681	}
1682	for rows.Next() {
1683		var kid int64
1684		var fp string
1685		if err := rows.Scan(&kid, &fp); err != nil {
1686			rows.Close()
1687			return Created{}, err
1688		}
1689		keyIDs = append(keyIDs, kid)
1690		c.Keys = append(c.Keys, fp)
1691	}
1692	rows.Close()
1693
1694	if !withCreated {
1695		if _, err := tx.Exec("DELETE FROM api_tokens WHERE id = ?", id); err != nil {
1696			return Created{}, err
1697		}
1698		return c, tx.Commit()
1699	}
1700	if len(keyIDs) > 0 {
1701		args := make([]any, len(keyIDs))
1702		for i, k := range keyIDs {
1703			args[i] = k
1704		}
1705		if _, err := tx.Exec("DELETE FROM ssh_keys WHERE id IN (?"+strings.Repeat(", ?", len(keyIDs)-1)+")", args...); err != nil {
1706			return Created{}, err
1707		}
1708		if err := bumpKeyEpoch(tx); err != nil {
1709			return Created{}, err
1710		}
1711	}
1712	if _, err := tx.Exec(chainCTE+` DELETE FROM api_tokens WHERE id IN (SELECT id FROM chain)`, id); err != nil {
1713		return Created{}, err
1714	}
1715	if err := tx.Commit(); err != nil {
1716		return Created{}, err
1717	}
1718	if len(keyIDs) > 0 {
1719		s.announce(Revoked{KeyIDs: keyIDs})
1720	}
1721	return c, nil
1722}
1723```
1724
1725Before writing `nullID`, run `grep -rn "func nullID" internal/store`; if one exists, use it and drop this copy.
1726
1727- [ ] **Step 5: Keys in the store**
1728
1729In `internal/store/users.go`, add to `SSHKey` after `LastUsedAt`:
1730
1731```go
1732	CreatedBy   string // name of the API token that added the key; "" for none. ListSSHKeys only.
1733```
1734
1735Replace `AddSSHKey`:
1736
1737```go
1738// KeyOrigin is how a key came to be.
1739type KeyOrigin struct {
1740	CreatedByToken int64 // the API token that added it; 0 for none
1741}
1742
1743// AddSSHKey registers a key and bumps the key epoch in one transaction.
1744func (s *Store) AddSSHKey(userID int64, fingerprint, algo string, blob []byte, scope, label string) error {
1745	return s.AddSSHKeyFrom(userID, fingerprint, algo, blob, scope, label, KeyOrigin{})
1746}
1747
1748// AddSSHKeyFrom is AddSSHKey recording where the key came from.
1749func (s *Store) AddSSHKeyFrom(userID int64, fingerprint, algo string, blob []byte, scope, label string, o KeyOrigin) error {
1750	tx, err := s.DB.Begin()
1751	if err != nil {
1752		return err
1753	}
1754	defer tx.Rollback()
1755	if _, err := tx.Exec(
1756		"INSERT INTO ssh_keys (user_id, fingerprint, algo, blob, scope, label, created_by_token) VALUES (?, ?, ?, ?, ?, ?, ?)",
1757		userID, fingerprint, algo, blob, scope, label, nullID(o.CreatedByToken)); err != nil {
1758		if isUniqueErr(err) {
1759			return ErrDuplicateKey
1760		}
1761		return err
1762	}
1763	if err := bumpKeyEpoch(tx); err != nil {
1764		return err
1765	}
1766	return tx.Commit()
1767}
1768```
1769
1770`ListSSHKeys`:
1771
1772```go
1773func (s *Store) ListSSHKeys(userID int64) ([]SSHKey, error) {
1774	rows, err := s.DB.Query(
1775		`SELECT k.id, k.user_id, k.fingerprint, k.algo, k.blob, k.scope, k.label, k.created_at,
1776		        COALESCE(k.last_used_at, ''), COALESCE(t.name, '')
1777		 FROM ssh_keys k LEFT JOIN api_tokens t ON t.id = k.created_by_token
1778		 WHERE k.user_id = ? ORDER BY k.id`,
1779		userID)
1780	if err != nil {
1781		return nil, err
1782	}
1783	defer rows.Close()
1784	var keys []SSHKey
1785	for rows.Next() {
1786		var k SSHKey
1787		if err := rows.Scan(&k.ID, &k.UserID, &k.Fingerprint, &k.Algo, &k.Blob, &k.Scope, &k.Label, &k.CreatedAt, &k.LastUsedAt, &k.CreatedBy); err != nil {
1788			return nil, err
1789		}
1790		keys = append(keys, k)
1791	}
1792	return keys, rows.Err()
1793}
1794```
1795
1796- [ ] **Step 6: Run the store tests**
1797
1798Run: `go test ./internal/store -count=1`
1799Expected: PASS. (`go build ./...` fails until Task 2.2 updates the callers.)
1800
1801- [ ] **Step 7: Commit**
1802
1803```bash
1804git add internal/store
1805git commit -S -m "store: tokens and keys record the token that created them; chained revoke
1806
1807Ref #257"
1808```
1809
1810### Task 2.2: `MintsCredential`, `Ctx.Expires`, and the API wiring
1811
1812**Files:**
1813- Modify: `internal/control/control.go` — `Ctx` (21-57), `Command` (79-91), `Dispatch` (after line 161)
1814- Modify: `internal/httpd/api.go:30-78`, `:126-144`; `internal/httpd/apiread.go:26`
1815- Modify: registrations in `internal/control/token.go:16`, `identity.go:33`, `deploykey.go:16`, `runnerrepo.go:21`, `web.go:16`, `adminhost.go:24`, `:53`, `:58`, `register.go:38`
1816- Test: `internal/control/token_test.go` (create)
1817
1818**Interfaces:**
1819- Consumes: `store.APITokenUser` returning `APIToken` (Task 2.1).
1820- Produces:
1821  - `Command.MintsCredential bool`
1822  - `Ctx.TokenID int64` — the API token behind the request, 0 for none.
1823  - `Ctx.Expires *time.Time` — when the credential behind the request lapses; nil when it does not. MR 3 sets it for keys.
1824
1825- [ ] **Step 1: Write the failing tests**
1826
1827`internal/control/token_test.go`:
1828
1829```go
1830package control
1831
1832import (
1833	"bytes"
1834	"slices"
1835	"strings"
1836	"testing"
1837	"time"
1838
1839	"gitbay.org/gitbay/internal/protocol"
1840	"gitbay.org/gitbay/internal/store"
1841)
1842
1843// The minting commands, pinned: adding one to the list, or dropping
1844// one, is a decision this test makes someone take.
1845func TestMintingCommandsMarked(t *testing.T) {
1846	want := []string{
1847		"admin email verify", "admin invite", "admin user create", "email verify",
1848		"keys add", "repo deploy-key add", "repo runner add", "token create", "web login",
1849	}
1850	var got []string
1851	for _, cmd := range Commands() {
1852		if cmd.MintsCredential {
1853			got = append(got, joinPath(cmd.Path))
1854		}
1855	}
1856	slices.Sort(got)
1857	if !slices.Equal(got, want) {
1858		t.Fatalf("MintsCredential on %q, want %q", got, want)
1859	}
1860}
1861
1862// Dispatch refuses before the command runs, so no arguments are needed.
1863func TestExpiringCredentialCannotMint(t *testing.T) {
1864	exp := time.Now().Add(time.Hour)
1865	for _, cmd := range Commands() {
1866		if !cmd.MintsCredential {
1867			continue
1868		}
1869		var out, errOut bytes.Buffer
1870		c := &Ctx{User: store.User{ID: 1, Username: "root", IsAdmin: true}, Scope: "full", Expires: &exp, Stdout: &out, Stderr: &errOut}
1871		if code := Dispatch(c, cmd.Path); code != protocol.ExitDenied || !strings.Contains(errOut.String(), "expires") {
1872			t.Errorf("%s: exit %d %q, want %d and the reason", joinPath(cmd.Path), code, errOut.String(), protocol.ExitDenied)
1873		}
1874	}
1875}
1876
1877func TestTokenCreateDefaultsToReadAndRecordsCreator(t *testing.T) {
1878	st, _, uid := newQueueTestRepo(t)
1879	if err := st.CreateAPIToken(uid, "parent", "h-parent", "full", nil, 0); err != nil {
1880		t.Fatal(err)
1881	}
1882	_, parent, err := st.APITokenUser("h-parent")
1883	if err != nil {
1884		t.Fatal(err)
1885	}
1886	c, errOut := pruneCtx(st, t.TempDir(), store.User{ID: uid, Username: "alice"})
1887	c.Cfg.Limits.WriteRate = -1
1888	c.TokenID = parent.ID
1889	if code := Dispatch(c, []string{"token", "create", "--name", "child"}); code != protocol.ExitOK {
1890		t.Fatalf("exit %d: %s", code, errOut)
1891	}
1892	toks, err := st.ListAPITokens(uid)
1893	if err != nil {
1894		t.Fatal(err)
1895	}
1896	for _, tk := range toks {
1897		if tk.Name == "child" && (tk.Scope != "read" || tk.CreatedBy != "parent") {
1898			t.Fatalf("child: %+v", tk)
1899		}
1900	}
1901}
1902```
1903
1904- [ ] **Step 2: Run them and see them fail**
1905
1906Run: `go test ./internal/control -run 'TestMintingCommandsMarked|TestExpiringCredentialCannotMint|TestTokenCreateDefaultsToRead' -count=1`
1907Expected: FAIL to compile, `unknown field MintsCredential`.
1908
1909- [ ] **Step 3: `Ctx`, `Command`, `Dispatch`**
1910
1911In `Ctx`, after `Source string`:
1912
1913```go
1914	// TokenID is the API token behind this request, 0 for none. A
1915	// credential the request creates records it.
1916	TokenID int64
1917	// Expires is when the credential behind this request lapses; nil
1918	// when it does not. Dispatch refuses MintsCredential commands when
1919	// it is set.
1920	Expires *time.Time
1921```
1922
1923In `Command`, after `ReadOnly`:
1924
1925```go
1926	// MintsCredential marks a command that creates a credential or a way
1927	// to obtain one: tokens, keys, login links, invites, accounts,
1928	// verified addresses. An expiring credential may not run it.
1929	MintsCredential bool
1930```
1931
1932In `Dispatch`, after the `c.ReadOnly && !cmd.ReadOnly` check:
1933
1934```go
1935	// What an expiring credential creates would outlive it (#257).
1936	if cmd.MintsCredential && c.Expires != nil {
1937		return c.fail(protocol.ExitDenied,
1938			"%s creates a credential, and the one this request came with expires; use a token or key without an expiry", joinPath(cmd.Path))
1939	}
1940```
1941
1942- [ ] **Step 4: Mark the nine commands**
1943
1944Add `MintsCredential: true,` to each registration: `token create` (`token.go:16`), `keys add` (`identity.go:33`), `repo deploy-key add` (`deploykey.go:16`), `repo runner add` (`runnerrepo.go:21`), `web login` (`web.go:16`), `admin user create` (`adminhost.go:24`), `admin email verify` (`adminhost.go:53`), `admin invite` (`adminhost.go:58`), `email verify` (`register.go:38`). For example:
1945
1946```go
1947	register(Command{Path: []string{"web", "login"},
1948		Summary:         "mint a one-time browser login URL",
1949		Usage:           "web login",
1950		MintsCredential: true,
1951		Examples:        []string{"web login"}, Run: runWebLogin})
1952```
1953
1954- [ ] **Step 5: The API passes the token**
1955
1956In `internal/httpd/api.go`, `apiAuth` returns the token:
1957
1958```go
1959// apiAuth resolves the bearer token; failures are uniform 401s.
1960func (s *Server) apiAuth(w http.ResponseWriter, r *http.Request) (store.User, store.APIToken, bool) {
1961	token, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ")
1962	if !ok || token == "" {
1963		w.Header().Set("WWW-Authenticate", `Bearer realm="gitbay api"`)
1964		apiError(w, http.StatusUnauthorized, "missing bearer token; mint one over SSH: token create --name <n>")
1965		return store.User{}, store.APIToken{}, false
1966	}
1967	user, tok, err := s.st.APITokenUser(store.HashToken(strings.TrimSpace(token)))
1968	if err != nil {
1969		if errors.Is(err, store.ErrNotFound) {
1970			apiError(w, http.StatusUnauthorized, "invalid or expired token")
1971			return store.User{}, store.APIToken{}, false
1972		}
1973		apiError(w, http.StatusInternalServerError, "internal error")
1974		return store.User{}, store.APIToken{}, false
1975	}
1976	return user, tok, true
1977}
1978```
1979
1980In `apiCmd`: `user, tok, ok := s.apiAuth(w, r)`, and in the `Ctx` literal replace `ReadOnly: scope == "read",` with:
1981
1982```go
1983		ReadOnly: tok.Scope == "read",
1984		TokenID:  tok.ID,
1985		Expires:  tok.ExpiresAt,
1986```
1987
1988`apiRead` keeps `user, _, ok := s.apiAuth(w, r)`; it compiles unchanged.
1989
1990- [ ] **Step 6: Run the tests**
1991
1992Run: `go test ./internal/control -run 'TestMintingCommandsMarked|TestExpiringCredentialCannotMint' -count=1`
1993Expected: PASS. `TestTokenCreateDefaultsToRead...` still fails until Task 2.3.
1994
1995- [ ] **Step 7: Commit**
1996
1997```bash
1998git add internal/control/control.go internal/control/token_test.go internal/control/*.go internal/httpd/api.go
1999git commit -S -m "control: expiring credentials cannot run credential-minting commands
2000
2001Ref #257"
2002```
2003
2004### Task 2.3: commands record their token; `token create` defaults to read; `token revoke --created`
2005
2006**Files:**
2007- Modify: `internal/control/token.go` (registrations 16-34, `runTokenCreate` 49-88, `runTokenList` 90-122, `runTokenRevoke` 124-137)
2008- Modify: `internal/control/identity.go` — `runKeysList` (76-100), `runKeysAdd` (152)
2009- Modify: `internal/control/deploykey.go:71`, `internal/control/runnerrepo.go:60`, `internal/control/adminhost.go:125`
2010- Modify: `e2e/api_test.go:50`, `:266-282` (`mintToken`)
2011
2012**Interfaces:**
2013- Consumes: `Ctx.TokenID`, `store.KeyOrigin`, `Store.AddSSHKeyFrom`, `Store.RevokeAPIToken` (Tasks 2.1–2.2).
2014
2015- [ ] **Step 1: `token create`**
2016
2017Registration:
2018
2019```go
2020	register(Command{Path: []string{"token", "create"},
2021		Summary: "mint an API token (shown once)",
2022		Usage:   "token create --name <n> [--scope read|full] [--ttl 30d|720h]",
2023		Flags: []Flag{
2024			{"--name", "<n>", "the token's name", ""},
2025			{"--scope", "read|full", "what the token may do; full is needed to change anything", "read"},
2026			{"--ttl", "30d|720h", "how long the token is valid; an expiring token cannot mint credentials", "never expires"},
2027		},
2028		Examples:        []string{"token create --name laptop --ttl 30d", "token create --name phone --scope full"},
2029		MintsCredential: true,
2030		Run:             runTokenCreate})
2031```
2032
2033In `runTokenCreate`: the `parseFlags` usage becomes `Usage: c.Cmd.Usage`; `name, scope, ttl := f.Value("--name"), "read", f.Value("--ttl")`; the store call:
2034
2035```go
2036	if err := c.Store.CreateAPIToken(c.User.ID, name, store.HashToken(token), scope, expires, c.TokenID); err != nil {
2037```
2038
2039- [ ] **Step 2: `token list` shows the creator in JSON**
2040
2041In `runTokenList`, the `out` struct gains `CreatedBy string `json:"created_by,omitempty"`` after `LastUsedAt`, and the append becomes `out{t.Name, t.Scope, t.CreatedAt, t.ExpiresAt, t.LastUsedAt, t.CreatedBy}`. Plain output is unchanged.
2042
2043- [ ] **Step 3: `token revoke [--created]`**
2044
2045Registration:
2046
2047```go
2048	register(Command{Path: []string{"token", "revoke"},
2049		Summary: "revoke an API token by name",
2050		Usage:   "token revoke <name> [--created]",
2051		Flags: []Flag{
2052			{"--created", "", "also revoke the tokens and keys it created, at any depth", ""},
2053		},
2054		Examples: []string{"token revoke laptop", "token revoke laptop --created"},
2055		Run:      runTokenRevoke})
2056```
2057
2058```go
2059func runTokenRevoke(c *Ctx, args []string) int {
2060	f, err := parseFlags(args, flagSpec{Bools: []string{"--created"}, MaxPos: 1, Usage: c.Cmd.Usage})
2061	if err != nil {
2062		return c.fail(protocol.ExitUsage, "%v", err)
2063	}
2064	name := f.pos(0)
2065	if name == "" {
2066		return c.usage()
2067	}
2068	withCreated := f.Has("--created")
2069	created, err := c.Store.RevokeAPIToken(c.User.ID, name, withCreated)
2070	if err != nil {
2071		if errors.Is(err, store.ErrNotFound) {
2072			return c.fail(protocol.ExitNotFound, "no token named %q", name)
2073		}
2074		return c.fail(protocol.ExitFailure, "%v", err)
2075	}
2076	type out struct {
2077		Revoked        string        `json:"revoked"`
2078		Created        store.Created `json:"created"`
2079		CreatedRevoked bool          `json:"created_revoked"`
2080	}
2081	d := out{name, created, withCreated}
2082	return c.emit(d, func(w io.Writer) {
2083		fmt.Fprintf(w, "revoked %s\n", name)
2084		if len(created.Tokens)+len(created.Keys) == 0 {
2085			return
2086		}
2087		if withCreated {
2088			fmt.Fprintln(w, "and what it created:")
2089		} else {
2090			fmt.Fprintln(w, "it created these, still in place:")
2091		}
2092		for _, n := range created.Tokens {
2093			fmt.Fprintf(w, "  token %s\n", n)
2094		}
2095		for _, fp := range created.Keys {
2096			fmt.Fprintf(w, "  key %s\n", fp)
2097		}
2098	})
2099}
2100```
2101
2102- [ ] **Step 4: Key-adding commands record the token**
2103
2104`identity.go`, `runKeysAdd`:
2105
2106```go
2107	if err := c.Store.AddSSHKeyFrom(c.User.ID, fp, pub.Type(), pub.Marshal(), scope, label, store.KeyOrigin{CreatedByToken: c.TokenID}); err != nil {
2108```
2109
2110`deploykey.go:71`:
2111
2112```go
2113	if err := c.Store.AddSSHKeyFrom(c.User.ID, fp, pub.Type(), pub.Marshal(), scope, label, store.KeyOrigin{CreatedByToken: c.TokenID}); err != nil {
2114```
2115
2116`runnerrepo.go:60`:
2117
2118```go
2119		if err := c.Store.AddSSHKeyFrom(c.User.ID, fp, pub.Type(), pub.Marshal(), "runner", label, store.KeyOrigin{CreatedByToken: c.TokenID}); err != nil {
2120```
2121
2122`adminhost.go:125`:
2123
2124```go
2125		if err := c.Store.AddSSHKeyFrom(uid, fp, pub.Type(), pub.Marshal(), "full", label, store.KeyOrigin{CreatedByToken: c.TokenID}); err != nil {
2126```
2127
2128`keys list` JSON: in `runKeysList` the `out` struct gains `CreatedBy string `json:"created_by,omitempty"`` and the append passes `k.CreatedBy`. Plain output is unchanged in this MR.
2129
2130- [ ] **Step 5: e2e callers that write with a default-scope token**
2131
2132`e2e/api_test.go:50`: `"token", "create", "--name", "ci", "--scope", "full", "--json"`.
2133`mintToken` (`e2e/api_test.go:268`): `"token", "create", "--name", name, "--scope", "full", "--json"`.
2134Then `grep -rn '"token", "create"' e2e` and check each remaining call: a token only used for reads, or for a refusal, needs nothing.
2135
2136- [ ] **Step 6: Run the tests**
2137
2138Run: `go build ./... && go vet ./... && go test ./internal/control ./internal/store ./internal/httpd -count=1`
2139Expected: PASS, including `TestHelpIsComplete` (every flag in the usage is described) and `TestTokenCreateDefaultsToReadAndRecordsCreator`.
2140
2141- [ ] **Step 7: Commit**
2142
2143```bash
2144git add internal/control e2e/api_test.go
2145git commit -S -m "token: default --scope read; record the creating token; revoke --created
2146
2147Ref #257"
2148```
2149
2150### Task 2.4: e2e — delegation over the API
2151
2152**Files:**
2153- Create: `e2e/tokenorigin_test.go`
2154
2155**Interfaces:**
2156- Consumes: `fingerprint` (MR 1, `e2e/revoke_test.go`), `inst.apiCall`.
2157
2158- [ ] **Step 1: Write the test**
2159
2160```go
2161package e2e
2162
2163import (
2164	"encoding/json"
2165	"fmt"
2166	"os"
2167	"strings"
2168	"testing"
2169)
2170
2171// An expiring token cannot mint a credential that outlives it, and
2172// revoking a token can take what it created with it (#257).
2173func TestTokenDelegation(t *testing.T) {
2174	t.Parallel()
2175	inst := startInstanceWith(t, "[api]\nenabled = true\n")
2176	aliceKey := inst.newKey(t, "alice")
2177	inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub")
2178
2179	mint := func(args ...string) (token, scope string) {
2180		t.Helper()
2181		out, errOut, code := inst.ssh(t, aliceKey, "", append([]string{"token", "create", "--json"}, args...)...)
2182		if code != 0 {
2183			t.Fatalf("token create %v: %s", args, errOut)
2184		}
2185		var env struct {
2186			Data struct {
2187				Token string `json:"token"`
2188				Scope string `json:"scope"`
2189			} `json:"data"`
2190		}
2191		if err := json.Unmarshal([]byte(out), &env); err != nil {
2192			t.Fatalf("token create output: %v %s", err, out)
2193		}
2194		return env.Data.Token, env.Data.Scope
2195	}
2196	if _, scope := mint("--name", "plain"); scope != "read" {
2197		t.Fatalf("default scope %q, want read", scope)
2198	}
2199	brief, _ := mint("--name", "brief", "--scope", "full", "--ttl", "1h")
2200	lasting, _ := mint("--name", "lasting", "--scope", "full")
2201
2202	spare := inst.newKey(t, "spare")
2203	pub, err := os.ReadFile(spare + ".pub")
2204	if err != nil {
2205		t.Fatal(err)
2206	}
2207	status, body := inst.apiCall(t, brief, []string{"keys", "add"}, string(pub))
2208	if status != 403 || !strings.Contains(fmt.Sprint(body["error"]), "expires") {
2209		t.Fatalf("expiring token added a key: %d %v", status, body)
2210	}
2211	if status, _ := inst.apiCall(t, brief, []string{"whoami"}, ""); status != 200 {
2212		t.Fatalf("expiring token refused a read: %d", status)
2213	}
2214	if status, body := inst.apiCall(t, lasting, []string{"keys", "add"}, string(pub)); status != 200 {
2215		t.Fatalf("keys add: %d %v", status, body)
2216	}
2217	if status, body := inst.apiCall(t, lasting, []string{"token", "create", "--name", "child"}, ""); status != 200 {
2218		t.Fatalf("token create: %d %v", status, body)
2219	}
2220	if _, errOut, code := inst.ssh(t, spare, "", "whoami"); code != 0 {
2221		t.Fatalf("the added key does not work: %s", errOut)
2222	}
2223
2224	out, errOut, code := inst.ssh(t, aliceKey, "", "token", "revoke", "lasting", "--created")
2225	if code != 0 || !strings.Contains(out, "token child") || !strings.Contains(out, fingerprint(t, spare+".pub")) {
2226		t.Fatalf("revoke --created: exit %d\n%s%s", code, out, errOut)
2227	}
2228	if _, _, code := inst.ssh(t, spare, "", "whoami"); code == 0 {
2229		t.Fatal("a key the revoked token created still works")
2230	}
2231	if out, _, _ := inst.ssh(t, aliceKey, "", "token", "list"); strings.Contains(out, "child") {
2232		t.Fatalf("the child token survived:\n%s", out)
2233	}
2234}
2235```
2236
2237- [ ] **Step 2: Run it**
2238
2239Run: `go test ./e2e -run TestTokenDelegation -count=1`
2240Expected: PASS.
2241
2242- [ ] **Step 3: Commit**
2243
2244```bash
2245git add e2e/tokenorigin_test.go
2246git commit -S -m "e2e: expiring tokens refused on minting; revoke --created
2247
2248Ref #257"
2249```
2250
2251### Task 2.5: docs and release note
2252
2253**Files:**
2254- Modify: `.gitbay/wiki/API.org:14-33` (Tokens), `.gitbay/wiki/Threat-Model.org` (Trust boundaries), `.gitbay/wiki/Architecture/05-Identity-and-Access.org:20`, `09-Controls.org:29`, `10-Known-Gaps.org`, `.gitbay/wiki/Parity.org:358`, `CHANGELOG.org`, `internal/web/templates/account.html:194`
2255
2256- [ ] **Step 1: Edit the pages**
2257
2258`API.org`, the Tokens section's first paragraph and code block become:
2259
2260```
2261Tokens are minted wherever the registry is reached: over SSH, on the
2262API, anywhere. =token create= makes a =read= token unless =--scope full=
2263is given; a read token runs only commands marked read-only. A full-scope
2264token can mint another, but a token with a =--ttl= cannot run any
2265command that creates a credential — =token create=, =keys add=,
2266=repo deploy-key add=, =repo runner add=, =web login=, =admin invite=,
2267=admin user create=, =email verify=, =admin email verify= — since what
2268it made would outlive it. Give a token the narrowest scope and shortest
2269TTL that does its job, and revoke it when the job is over.
2270
2271#+begin_src sh
2272gitbay auth token create --name ci [--scope read|full] [--ttl 30d]
2273gitbay auth token list
2274gitbay auth token revoke ci [--created]
2275#+end_src
2276
2277Tokens and keys record the token they were created through. =token
2278revoke= prints what the token created, at any depth; with =--created=
2279it revokes those too, and their SSH connections close. Without it they
2280stay and the link is dropped.
2281```
2282
2283`Threat-Model.org`, in Trust boundaries, replace "so a bearer token is worth exactly its scope and no more." with "so a bearer token is worth exactly its scope and no more, and a credential with an expiry cannot create one that outlives it."
2284
2285`05-Identity-and-Access.org`: the API token row's Scope cell becomes `=read= (default) or =full=; with an expiry, no credential-minting command`, and its Revocation cell `=token revoke [--created]=`.
2286
2287`09-Controls.org`:
2288
2289```
2290| Delegation bounded by the delegating credential | in place | expiring tokens refused on =MintsCredential= commands; credentials record their creating token (=internal/control/control.go=) |
2291```
2292
2293`10-Known-Gaps.org`: delete the `#257` row and the "#257 refuses credential creation ..." sentence, leaving the paragraph out if nothing remains in it.
2294
2295`Parity.org`, after the `API token mint` row:
2296
2297```
2298| API token revoke with what it created | yes | no  | no  |
2299```
2300
2301`account.html:194`: `gitbay auth token create --name laptop # API tokens, read-only unless --scope full`.
2302
2303`CHANGELOG.org`, under the unreleased heading (see Global constraints):
2304
2305```
2306*Upgrade note.* =token create= makes a =read= token unless given
2307=--scope full=. A script that mints a token and then writes with it
2308must add =--scope full=. Existing tokens keep their scope.
2309
2310- A token with a =--ttl= is refused on every command that creates a
2311  credential: tokens, keys, deploy keys, runner keys, login links,
2312  invites, accounts and verified addresses (#257).
2313- Tokens and SSH keys record the token they were created through.
2314  =token revoke <name>= lists what it created; =--created= revokes
2315  those too.
2316- Removing an SSH key, a deploy key, or disabling an account closes the
2317  connections the key opened, a push in flight included (#256).
2318```
2319
2320(The #256 line belongs to MR 1's changes; add it here if MR 1 did not touch the changelog.)
2321
2322- [ ] **Step 2: Commit, open the MR**
2323
2324```bash
2325git add .gitbay/wiki CHANGELOG.org internal/web/templates/account.html
2326git commit -S -m "wiki: token delegation, read default; release note
2327
2328Closes #257"
2329git push -u origin token-delegation
2330gitbay mr create --source token-delegation --target main --title "token: expiring tokens cannot mint credentials; record creator; default read scope"
2331```
2332
2333---
2334
2335# MR 3: optional expiry for SSH and deploy keys (branch `key-expiry`, #277)
2336
2337The issue says to consider this with #257. An expiring key is treated
2338like an expiring token: `Exec` sets `Ctx.Expires`, so `Dispatch` refuses
2339the minting commands to it (open question 1).
2340
2341### Task 3.1: migration 0061 and the store
2342
2343**Files:**
2344- Create: `internal/store/migrations/0061_ssh_key_expiry.up.sql`, `.down.sql`
2345- Modify: `internal/store/users.go` — `SSHKey`, `KeyOrigin`, `AddSSHKeyFrom`, `SSHKeyByFingerprint` (324-333), `SSHKeyByID` (502-511), `ListSSHKeys`, `ListDeployKeys` (514-532)
2346- Modify: `internal/store/revoke.go` — `LiveSSHKeys`
2347- Test: `internal/store/keyexpiry_test.go` (create)
2348
2349**Interfaces:**
2350- Produces:
2351  - `SSHKey.ExpiresAt *time.Time` — nil when the key never expires; filled by every key query.
2352  - `func (k SSHKey) Expired(now time.Time) bool`
2353  - `KeyOrigin.ExpiresAt *time.Time`
2354  - `LiveSSHKeys` also excludes expired keys.
2355
2356- [ ] **Step 1: Write the failing test**
2357
2358```go
2359package store
2360
2361import (
2362	"testing"
2363	"time"
2364)
2365
2366func TestKeyExpiry(t *testing.T) {
2367	s, uid, _ := revokeFixture(t)
2368	past, future := time.Now().Add(-time.Minute), time.Now().Add(time.Hour)
2369	for fp, exp := range map[string]*time.Time{"SHA256:old": &past, "SHA256:new": &future, "SHA256:ever": nil} {
2370		if err := s.AddSSHKeyFrom(uid, fp, "ssh-ed25519", []byte(fp), "full", "", KeyOrigin{ExpiresAt: exp}); err != nil {
2371			t.Fatal(err)
2372		}
2373	}
2374	now := time.Now()
2375	ids := map[string]int64{}
2376	for _, fp := range []string{"SHA256:old", "SHA256:new", "SHA256:ever"} {
2377		k, err := s.SSHKeyByFingerprint(fp)
2378		if err != nil {
2379			t.Fatal(err)
2380		}
2381		ids[fp] = k.ID
2382		byID, err := s.SSHKeyByID(k.ID)
2383		if err != nil || (byID.ExpiresAt == nil) != (k.ExpiresAt == nil) {
2384			t.Fatalf("%s by id: %+v %v", fp, byID, err)
2385		}
2386		if got, want := k.Expired(now), fp == "SHA256:old"; got != want {
2387			t.Errorf("%s Expired = %v, want %v", fp, got, want)
2388		}
2389	}
2390	live, err := s.LiveSSHKeys([]int64{ids["SHA256:old"], ids["SHA256:new"], ids["SHA256:ever"]})
2391	if err != nil {
2392		t.Fatal(err)
2393	}
2394	if live[ids["SHA256:old"]] || !live[ids["SHA256:new"]] || !live[ids["SHA256:ever"]] {
2395		t.Fatalf("live = %v", live)
2396	}
2397	keys, err := s.ListSSHKeys(uid)
2398	if err != nil || len(keys) != 3 || keys[2].ExpiresAt != nil {
2399		t.Fatalf("list: %+v %v", keys, err)
2400	}
2401}
2402```
2403
2404- [ ] **Step 2: Run it and see it fail**
2405
2406Run: `go test ./internal/store -run TestKeyExpiry -count=1`
2407Expected: FAIL to compile, `unknown field ExpiresAt in struct literal of type KeyOrigin`.
2408
2409- [ ] **Step 3: Migration**
2410
2411`0061_ssh_key_expiry.up.sql`:
2412
2413```sql
2414-- When the key stops authenticating; NULL for never.
2415ALTER TABLE ssh_keys ADD COLUMN expires_at TEXT;
2416```
2417
2418`0061_ssh_key_expiry.down.sql`:
2419
2420```sql
2421ALTER TABLE ssh_keys DROP COLUMN expires_at;
2422```
2423
2424- [ ] **Step 4: Store**
2425
2426`SSHKey` gains, after `CreatedBy`:
2427
2428```go
2429	ExpiresAt   *time.Time // nil when the key never expires
2430```
2431
2432and the method:
2433
2434```go
2435// Expired reports whether the key has lapsed at now.
2436func (k SSHKey) Expired(now time.Time) bool {
2437	return k.ExpiresAt != nil && !k.ExpiresAt.After(now)
2438}
2439```
2440
2441`KeyOrigin`:
2442
2443```go
2444// KeyOrigin is how a key came to be.
2445type KeyOrigin struct {
2446	CreatedByToken int64      // the API token that added it; 0 for none
2447	ExpiresAt      *time.Time // when it stops authenticating; nil for never
2448}
2449```
2450
2451`AddSSHKeyFrom`'s insert:
2452
2453```go
2454	var exp any
2455	if o.ExpiresAt != nil {
2456		exp = fmtTime(*o.ExpiresAt)
2457	}
2458	if _, err := tx.Exec(
2459		"INSERT INTO ssh_keys (user_id, fingerprint, algo, blob, scope, label, created_by_token, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
2460		userID, fingerprint, algo, blob, scope, label, nullID(o.CreatedByToken), exp); err != nil {
2461```
2462
2463`SSHKeyByFingerprint` and `SSHKeyByID` select `expires_at` last and scan it through a `sql.NullString`:
2464
2465```go
2466func (s *Store) SSHKeyByFingerprint(fingerprint string) (SSHKey, error) {
2467	var k SSHKey
2468	var exp sql.NullString
2469	err := s.DB.QueryRow(
2470		"SELECT id, user_id, fingerprint, algo, blob, scope, label, expires_at FROM ssh_keys WHERE fingerprint = ?",
2471		fingerprint).Scan(&k.ID, &k.UserID, &k.Fingerprint, &k.Algo, &k.Blob, &k.Scope, &k.Label, &exp)
2472	if errors.Is(err, sql.ErrNoRows) {
2473		return k, ErrNotFound
2474	}
2475	k.ExpiresAt = parseTime(exp)
2476	return k, err
2477}
2478```
2479
2480`SSHKeyByID` is the same with `WHERE id = ?` and `id`.
2481
2482`ListSSHKeys`: add `k.expires_at` after `COALESCE(t.name, '')`, scan into `var exp sql.NullString` declared per row, then `k.ExpiresAt = parseTime(exp)`.
2483
2484`ListDeployKeys`:
2485
2486```go
2487func (s *Store) ListDeployKeys(repoID int64) ([]SSHKey, error) {
2488	rows, err := s.DB.Query(
2489		`SELECT id, user_id, fingerprint, algo, blob, scope, label, COALESCE(last_used_at, ''), expires_at
2490		 FROM ssh_keys WHERE scope LIKE 'deploy:' || ? || ':%' ORDER BY id`,
2491		repoID)
2492	if err != nil {
2493		return nil, err
2494	}
2495	defer rows.Close()
2496	var keys []SSHKey
2497	for rows.Next() {
2498		var k SSHKey
2499		var exp sql.NullString
2500		if err := rows.Scan(&k.ID, &k.UserID, &k.Fingerprint, &k.Algo, &k.Blob, &k.Scope, &k.Label, &k.LastUsedAt, &exp); err != nil {
2501			return nil, err
2502		}
2503		k.ExpiresAt = parseTime(exp)
2504		keys = append(keys, k)
2505	}
2506	return keys, rows.Err()
2507}
2508```
2509
2510`LiveSSHKeys` in `revoke.go`:
2511
2512```go
2513// LiveSSHKeys reports which of ids still name a registered, unexpired
2514// key on an account that is not disabled.
2515func (s *Store) LiveSSHKeys(ids []int64) (map[int64]bool, error) {
2516	live := map[int64]bool{}
2517	if len(ids) == 0 {
2518		return live, nil
2519	}
2520	args := []any{fmtTime(time.Now())}
2521	for _, id := range ids {
2522		args = append(args, id)
2523	}
2524	rows, err := s.DB.Query(`SELECT k.id FROM ssh_keys k JOIN users u ON u.id = k.user_id
2525		WHERE u.disabled = 0 AND (k.expires_at IS NULL OR k.expires_at > ?)
2526		AND k.id IN (?`+strings.Repeat(", ?", len(ids)-1)+`)`, args...)
2527	if err != nil {
2528		return nil, err
2529	}
2530	defer rows.Close()
2531	for rows.Next() {
2532		var id int64
2533		if err := rows.Scan(&id); err != nil {
2534			return nil, err
2535		}
2536		live[id] = true
2537	}
2538	return live, rows.Err()
2539}
2540```
2541
2542Add `"time"` to `revoke.go`'s imports.
2543
2544- [ ] **Step 5: Run the tests**
2545
2546Run: `go test ./internal/store -count=1`
2547Expected: PASS.
2548
2549- [ ] **Step 6: Commit**
2550
2551```bash
2552git add internal/store
2553git commit -S -m "store: ssh_keys.expires_at; expired keys are not live
2554
2555Ref #277"
2556```
2557
2558### Task 3.2: expired keys refused at authentication, per exec, and in system mode
2559
2560**Files:**
2561- Modify: `internal/sshd/sshd.go` — `authenticate` (after line 148), `runExec`, `Exec` (the `Ctx` literal)
2562- Modify: `cmd/gitbayd/system.go:45-48`, `:80-84`
2563- Test: `internal/sshd/revoke_test.go` (append)
2564
2565**Interfaces:**
2566- Consumes: `SSHKey.Expired`, `SSHKey.ExpiresAt` (Task 3.1); `Ctx.Expires` (MR 2); `newTestServer`, `execStatus`, `waitClosed` (MR 1).
2567
2568- [ ] **Step 1: Write the failing tests**
2569
2570Append to `internal/sshd/revoke_test.go`:
2571
2572```go
2573// A key that expires while connected: the next exec is refused, and
2574// the sweep closes the connection.
2575func TestExpiredKeyRefusedAndCut(t *testing.T) {
2576	ts := newTestServer(t)
2577	past := time.Now().Add(-time.Second).UTC().Format("2006-01-02T15:04:05.000Z")
2578	if _, err := ts.st.DB.Exec("UPDATE ssh_keys SET expires_at = ? WHERE id = ?", past, ts.keyID); err != nil {
2579		t.Fatal(err)
2580	}
2581	if code, errOut := execStatus(ts.client, "whoami"); code != 4 || !strings.Contains(errOut, "expired") {
2582		t.Fatalf("whoami with an expired key: %d %q", code, errOut)
2583	}
2584	ts.srv.sweepOnce()
2585	waitClosed(t, ts.client)
2586}
2587
2588// An expiring key may not mint.
2589func TestExpiringKeyCannotMint(t *testing.T) {
2590	ts := newTestServer(t)
2591	future := time.Now().Add(time.Hour).UTC().Format("2006-01-02T15:04:05.000Z")
2592	if _, err := ts.st.DB.Exec("UPDATE ssh_keys SET expires_at = ? WHERE id = ?", future, ts.keyID); err != nil {
2593		t.Fatal(err)
2594	}
2595	if code, errOut := execStatus(ts.client, "token create --name x"); code != 4 || !strings.Contains(errOut, "expires") {
2596		t.Fatalf("token create with an expiring key: %d %q", code, errOut)
2597	}
2598}
2599```
2600
2601And a handshake test, which needs its own key: add to `revoke_test.go`
2602
2603```go
2604func TestExpiredKeyRefusedAtAuth(t *testing.T) {
2605	ts := newTestServer(t)
2606	_, priv, err := ed25519.GenerateKey(rand.Reader)
2607	if err != nil {
2608		t.Fatal(err)
2609	}
2610	signer, err := ssh.NewSignerFromKey(priv)
2611	if err != nil {
2612		t.Fatal(err)
2613	}
2614	pub := signer.PublicKey()
2615	past := time.Now().Add(-time.Minute)
2616	if err := ts.st.AddSSHKeyFrom(ts.uid, ssh.FingerprintSHA256(pub), pub.Type(), pub.Marshal(), "full", "", store.KeyOrigin{ExpiresAt: &past}); err != nil {
2617		t.Fatal(err)
2618	}
2619	_, err = ssh.Dial("tcp", ts.client.RemoteAddr().String(), &ssh.ClientConfig{
2620		User:            "git",
2621		Auth:            []ssh.AuthMethod{ssh.PublicKeys(signer)},
2622		HostKeyCallback: ssh.InsecureIgnoreHostKey(),
2623		Timeout:         5 * time.Second,
2624	})
2625	if err == nil {
2626		t.Fatal("an expired key authenticated")
2627	}
2628}
2629```
2630
2631with `"crypto/ed25519"`, `"crypto/rand"` and `"gitbay.org/gitbay/internal/store"` in the imports.
2632
2633- [ ] **Step 2: Run them and see them fail**
2634
2635Run: `go test ./internal/sshd -run 'TestExpiredKey|TestExpiringKeyCannotMint' -count=1`
2636Expected: FAIL: the expired key authenticates and whoami exits 0.
2637
2638- [ ] **Step 3: sshd**
2639
2640In `authenticate`, after the `if err != nil { ... }` block that handles unknown keys and before `s.authLimiter.success(ip)`:
2641
2642```go
2643	if key.Expired(time.Now()) {
2644		s.st.Audit(key.UserID, "auth.expired", map[string]any{"ip": ip, "fingerprint": fp})
2645		return nil, fmt.Errorf("key %s has expired", fp)
2646	}
2647```
2648
2649In `runExec`, after the lookup's error handling and before `UserByID`:
2650
2651```go
2652	if key.Expired(time.Now()) {
2653		fmt.Fprintln(ch.Stderr(), "this key has expired; remove it and add a new one")
2654		return protocol.ExitDenied
2655	}
2656```
2657
2658In `Exec`, the `Ctx` literal gains `Expires: key.ExpiresAt,`.
2659
2660- [ ] **Step 4: System mode**
2661
2662`cmd/gitbayd/system.go`, `authorized-keys`:
2663
2664```go
2665			key, err := st.SSHKeyByFingerprint(ssh.FingerprintSHA256(pub))
2666			if err != nil || key.Expired(time.Now()) {
2667				return nil // unknown or expired key: no output, auth fails
2668			}
2669```
2670
2671`shell`, after the `SSHKeyByID` error check:
2672
2673```go
2674			if key.Expired(time.Now()) {
2675				fmt.Fprintln(os.Stderr, "this key has expired; remove it and add a new one")
2676				os.Exit(protocol.ExitDenied)
2677			}
2678```
2679
2680Add `"time"` to the imports.
2681
2682- [ ] **Step 5: Run the tests**
2683
2684Run: `go build ./... && go test ./internal/sshd -count=1`
2685Expected: PASS.
2686
2687- [ ] **Step 6: Commit**
2688
2689```bash
2690git add internal/sshd cmd/gitbayd/system.go
2691git commit -S -m "sshd: refuse expired keys at auth and per exec; expiring keys cannot mint
2692
2693Ref #277"
2694```
2695
2696### Task 3.3: `--ttl` on `keys add` and `repo deploy-key add`; lists show last use and expiry
2697
2698**Files:**
2699- Modify: `internal/control/token.go` (add `ttlFlag` after `parseTTL`)
2700- Modify: `internal/control/identity.go` — `keys add` registration (33-44), `runKeysList`, `runKeysAdd`
2701- Modify: `internal/control/deploykey.go` — registration (16-23), `runDeployKeyAdd` (36-80), `runDeployKeyList` (82-115)
2702- Modify: `e2e/ssh_test.go:267`, `:276`
2703- Test: `internal/control/keyexpiry_test.go` (create)
2704
2705**Interfaces:**
2706- Produces:
2707  - `func (c *Ctx) ttlFlag(f flags) (*time.Time, int)` — nil when `--ttl` is absent; code -1 when the caller may go on.
2708  - `func (c *Ctx) usedText(ts string) string`, `func expiresText(t *time.Time, now time.Time) string`
2709
2710- [ ] **Step 1: Write the failing test**
2711
2712`internal/control/keyexpiry_test.go`:
2713
2714```go
2715package control
2716
2717import (
2718	"bytes"
2719	"crypto/ed25519"
2720	"crypto/rand"
2721	"strings"
2722	"testing"
2723	"time"
2724
2725	"golang.org/x/crypto/ssh"
2726
2727	"gitbay.org/gitbay/internal/protocol"
2728	"gitbay.org/gitbay/internal/store"
2729)
2730
2731// authorizedKey is a fresh public key as an authorized_keys line.
2732func authorizedKey(t *testing.T, comment string) string {
2733	t.Helper()
2734	pub, _, err := ed25519.GenerateKey(rand.Reader)
2735	if err != nil {
2736		t.Fatal(err)
2737	}
2738	sp, err := ssh.NewPublicKey(pub)
2739	if err != nil {
2740		t.Fatal(err)
2741	}
2742	return strings.TrimSpace(string(ssh.MarshalAuthorizedKey(sp))) + " " + comment + "\n"
2743}
2744
2745func TestKeysAddTTLAndList(t *testing.T) {
2746	st, repo, uid := newQueueTestRepo(t)
2747	user := store.User{ID: uid, Username: "alice"}
2748	run := func(stdin string, argv ...string) (string, string, int) {
2749		c, errOut := pruneCtx(st, t.TempDir(), user)
2750		c.Cfg.Limits.WriteRate = -1
2751		c.Stdin = strings.NewReader(stdin)
2752		code := Dispatch(c, argv)
2753		return c.Stdout.(*bytes.Buffer).String(), errOut.String(), code
2754	}
2755	if _, errOut, code := run(authorizedKey(t, "laptop"), "keys", "add", "--ttl", "1h"); code != protocol.ExitOK {
2756		t.Fatalf("keys add --ttl: %d %s", code, errOut)
2757	}
2758	if _, errOut, code := run(authorizedKey(t, "ci"), "repo", "deploy-key", "add", repo.Path(), "--ttl", "2d"); code != protocol.ExitOK {
2759		t.Fatalf("deploy-key add --ttl: %d %s", code, errOut)
2760	}
2761	if _, _, code := run(authorizedKey(t, "x"), "keys", "add", "--ttl", "soon"); code != protocol.ExitUsage {
2762		t.Fatalf("bad ttl: exit %d", code)
2763	}
2764
2765	keys, err := st.ListSSHKeys(uid)
2766	if err != nil || len(keys) != 2 {
2767		t.Fatalf("keys: %+v %v", keys, err)
2768	}
2769	for _, k := range keys {
2770		if k.ExpiresAt == nil || k.ExpiresAt.Before(time.Now()) || k.ExpiresAt.After(time.Now().Add(49*time.Hour)) {
2771			t.Errorf("%s expires %v", k.Label, k.ExpiresAt)
2772		}
2773	}
2774	out, _, _ := run("", "keys", "list")
2775	if !strings.Contains(out, "\tlaptop\tnever used\texpires ") {
2776		t.Fatalf("keys list:\n%s", out)
2777	}
2778	out, _, _ = run("", "repo", "deploy-key", "list", repo.Path())
2779	if !strings.Contains(out, "\tci\tnever used\texpires ") {
2780		t.Fatalf("deploy-key list:\n%s", out)
2781	}
2782}
2783```
2784
2785- [ ] **Step 2: Run it and see it fail**
2786
2787Run: `go test ./internal/control -run TestKeysAddTTLAndList -count=1`
2788Expected: FAIL, `keys add --ttl` exits 2 (unknown flag).
2789
2790- [ ] **Step 3: Helpers**
2791
2792In `internal/control/token.go` after `parseTTL`:
2793
2794```go
2795// ttlFlag reads --ttl as an expiry; nil when the flag is absent. The
2796// code is -1 when the caller may go on.
2797func (c *Ctx) ttlFlag(f flags) (*time.Time, int) {
2798	if !f.Has("--ttl") {
2799		return nil, -1
2800	}
2801	d, err := parseTTL(f.Value("--ttl"))
2802	if err != nil || d <= 0 {
2803		return nil, c.fail(protocol.ExitUsage, "bad ttl %q: give a duration such as 30d or 720h", f.Value("--ttl"))
2804	}
2805	t := time.Now().Add(d)
2806	return &t, -1
2807}
2808```
2809
2810In `internal/control/identity.go` after `keyLabel`:
2811
2812```go
2813// usedText is a key's last use as a list shows it.
2814func (c *Ctx) usedText(ts string) string {
2815	switch {
2816	case ts == "":
2817		return "never used"
2818	case c.Term.Cols == 0:
2819		return "used " + stamp(ts)
2820	}
2821	return "used " + relAge(ts, termNow())
2822}
2823
2824// expiresText is a credential's expiry as a list shows it. It is
2825// absolute at a terminal too: relAge reads only the past.
2826func expiresText(t *time.Time, now time.Time) string {
2827	if t == nil {
2828		return "never expires"
2829	}
2830	s := stamp(t.UTC().Format(time.RFC3339Nano))
2831	if !t.After(now) {
2832		return "expired " + s
2833	}
2834	return "expires " + s
2835}
2836```
2837
2838Add `"time"` to `identity.go`'s imports.
2839
2840- [ ] **Step 4: `keys add --ttl`**
2841
2842Registration:
2843
2844```go
2845	register(Command{
2846		Path:    []string{"keys", "add"},
2847		Summary: "register an SSH public key (authorized_keys format)",
2848		Usage:   "keys add [--scope full|git|runner] [--label <text>] [--ttl 30d|720h] < key.pub",
2849		Flags: []Flag{
2850			{"--scope", "full|git|runner", "what the key may do", "full"},
2851			{"--label", "<text>", "a name for the key", ""},
2852			{"--ttl", "30d|720h", "how long the key authenticates; an expiring key cannot mint credentials", "never expires"},
2853		},
2854		Examples:        []string{"keys add --label laptop < key.pub", "keys add --scope git --ttl 90d < ci.pub"},
2855		ReadsStdin:      true,
2856		MintsCredential: true,
2857		Run:             runKeysAdd,
2858	})
2859```
2860
2861In `runKeysAdd`: `parseFlags(args, flagSpec{Values: []string{"--scope", "--label", "--ttl"}, MaxPos: 0, Usage: c.Cmd.Usage})`; after the scope check:
2862
2863```go
2864	expires, code := c.ttlFlag(f)
2865	if code >= 0 {
2866		return code
2867	}
2868```
2869
2870the store call:
2871
2872```go
2873	if err := c.Store.AddSSHKeyFrom(c.User.ID, fp, pub.Type(), pub.Marshal(), scope, label, store.KeyOrigin{CreatedByToken: c.TokenID, ExpiresAt: expires}); err != nil {
2874```
2875
2876and the output:
2877
2878```go
2879	type out struct {
2880		Fingerprint string     `json:"fingerprint"`
2881		Scope       string     `json:"scope"`
2882		Label       string     `json:"label"`
2883		ExpiresAt   *time.Time `json:"expires_at,omitempty"`
2884	}
2885	d := out{fp, scope, label, expires}
2886	return c.emit(d, func(w io.Writer) {
2887		line := fmt.Sprintf("added %s (%s)", d.Fingerprint, d.Scope)
2888		if d.Label != "" {
2889			line += " " + d.Label
2890		}
2891		if d.ExpiresAt != nil {
2892			line += ", " + expiresText(d.ExpiresAt, time.Now())
2893		}
2894		fmt.Fprintln(w, line)
2895	})
2896```
2897
2898- [ ] **Step 5: `keys list` columns**
2899
2900```go
2901	type out struct {
2902		Fingerprint string     `json:"fingerprint"`
2903		Algo        string     `json:"algo"`
2904		Scope       string     `json:"scope"`
2905		Label       string     `json:"label"`
2906		CreatedBy   string     `json:"created_by,omitempty"`
2907		LastUsedAt  string     `json:"last_used_at,omitempty"`
2908		ExpiresAt   *time.Time `json:"expires_at,omitempty"`
2909	}
2910	var ds []out
2911	for _, k := range keys {
2912		ds = append(ds, out{k.Fingerprint, k.Algo, k.Scope, k.Label, k.CreatedBy, k.LastUsedAt, k.ExpiresAt})
2913	}
2914	now := time.Now()
2915	return c.emit(ds, func(w io.Writer) {
2916		tb := c.table(w, "FINGERPRINT", "ALGO", "SCOPE", "LABEL", "USED", "EXPIRES")
2917		for _, d := range ds {
2918			tb.row(cFlex(d.Fingerprint), cText(d.Algo), cState(d.Scope), cText(d.Label),
2919				cText(c.usedText(d.LastUsedAt)), cText(expiresText(d.ExpiresAt, now)))
2920		}
2921		tb.flush()
2922	})
2923```
2924
2925- [ ] **Step 6: `repo deploy-key add --ttl`, list columns**
2926
2927Registration:
2928
2929```go
2930	register(Command{Path: []string{"repo", "deploy-key", "add"},
2931		Summary: "bind a read-only (or --rw) key to one repository",
2932		Usage:   "repo deploy-key add <owner/name> [--rw] [--ttl 30d|720h] < key.pub",
2933		Flags: []Flag{
2934			{"--rw", "", "the key may push, not just fetch", ""},
2935			{"--ttl", "30d|720h", "how long the key authenticates", "never expires"},
2936		},
2937		Examples:        []string{"repo deploy-key add krz/gitbay < key.pub", "repo deploy-key add krz/gitbay --ttl 30d < key.pub"},
2938		ReadsStdin:      true,
2939		MintsCredential: true,
2940		Run:             runDeployKeyAdd})
2941```
2942
2943`runDeployKeyAdd`, replacing its argument loop (lines 37-52):
2944
2945```go
2946func runDeployKeyAdd(c *Ctx, args []string) int {
2947	f, err := parseFlags(args, flagSpec{Values: []string{"--ttl"}, Bools: []string{"--rw"}, MaxPos: 1, Usage: c.Cmd.Usage})
2948	if err != nil {
2949		return c.fail(protocol.ExitUsage, "%v", err)
2950	}
2951	path := f.pos(0)
2952	if path == "" {
2953		return c.usage()
2954	}
2955	mode := "ro"
2956	if f.Has("--rw") {
2957		mode = "rw"
2958	}
2959	expires, code := c.ttlFlag(f)
2960	if code >= 0 {
2961		return code
2962	}
2963	repo, code := resolveRepo(c, path, policy.CanAdmin)
2964	if code >= 0 {
2965		return code
2966	}
2967```
2968
2969The rest is as before except the store call and output:
2970
2971```go
2972	if err := c.Store.AddSSHKeyFrom(c.User.ID, fp, pub.Type(), pub.Marshal(), scope, label, store.KeyOrigin{CreatedByToken: c.TokenID, ExpiresAt: expires}); err != nil {
2973		if errors.Is(err, store.ErrDuplicateKey) {
2974			return c.failErr(err)
2975		}
2976		return c.fail(protocol.ExitFailure, "%v", err)
2977	}
2978	d := map[string]any{"fingerprint": fp, "mode": mode}
2979	if expires != nil {
2980		d["expires_at"] = expires
2981	}
2982	return c.emit(d, func(w io.Writer) {
2983		line := fmt.Sprintf("deploy key %s (%s) bound to %s", fp, mode, repo.Path())
2984		if expires != nil {
2985			line += ", " + expiresText(expires, time.Now())
2986		}
2987		fmt.Fprintln(w, line)
2988	})
2989```
2990
2991`runDeployKeyList`:
2992
2993```go
2994	type out struct {
2995		Fingerprint string     `json:"fingerprint"`
2996		Algo        string     `json:"algo"`
2997		Mode        string     `json:"mode"`
2998		Label       string     `json:"label"`
2999		LastUsedAt  string     `json:"last_used_at,omitempty"`
3000		ExpiresAt   *time.Time `json:"expires_at,omitempty"`
3001	}
3002	var ds []out
3003	for _, k := range keys {
3004		mode := "ro"
3005		if policy.DeployScopeAllows(k.Scope, repo.ID, true) {
3006			mode = "rw"
3007		}
3008		ds = append(ds, out{k.Fingerprint, k.Algo, mode, k.Label, k.LastUsedAt, k.ExpiresAt})
3009	}
3010	now := time.Now()
3011	return c.emit(ds, func(w io.Writer) {
3012		tb := c.table(w, "FINGERPRINT", "ALGO", "MODE", "LABEL", "USED", "EXPIRES")
3013		for _, d := range ds {
3014			tb.row(cFlex(d.Fingerprint), cText(d.Algo), cState(d.Mode), cText(d.Label),
3015				cText(c.usedText(d.LastUsedAt)), cText(expiresText(d.ExpiresAt, now)))
3016		}
3017		tb.flush()
3018	})
3019```
3020
3021Add `"time"` to `deploykey.go`'s imports.
3022
3023- [ ] **Step 7: The e2e rows that end at the label**
3024
3025`e2e/ssh_test.go:267`: `if !strings.Contains(out, "\tgit\talice2\t") {`
3026`e2e/ssh_test.go:276`: `if !strings.Contains(out, "\tgit\tbuild box\t") {`
3027
3028- [ ] **Step 8: Run the tests**
3029
3030Run: `go build ./... && go vet ./... && go test ./internal/control ./internal/store ./internal/sshd -count=1 && go test ./e2e -run TestSSHControlPlane -count=1`
3031
3032(Check the name of the test holding `e2e/ssh_test.go:255-276` with `grep -n "^func Test" e2e/ssh_test.go` and run that one.)
3033Expected: PASS.
3034
3035- [ ] **Step 9: Commit**
3036
3037```bash
3038git add internal/control e2e/ssh_test.go
3039git commit -S -m "keys: --ttl on keys add and repo deploy-key add; lists show last use and expiry
3040
3041Ref #277"
3042```
3043
3044### Task 3.4: docs
3045
3046**Files:**
3047- Modify: `.gitbay/wiki/Users.org` (keys block ~61-66 and the scopes paragraph), `.gitbay/wiki/Architecture/05-Identity-and-Access.org:17-18`, `09-Controls.org:27`, `10-Known-Gaps.org`, `.gitbay/wiki/Parity.org` (Accounts), `.gitbay/wiki/API.org` (the paragraph added in MR 2), `CHANGELOG.org`
3048
3049- [ ] **Step 1: Edit the pages**
3050
3051`Users.org`, add to the keys code block:
3052
3053```
3054gitbay auth keys add --scope git --ttl 90d < ~/.ssh/ci_key.pub
3055```
3056
3057and after the labels paragraph:
3058
3059```
3060=--ttl 90d= (or any Go duration, =720h=) makes a key stop
3061authenticating after that long; =repo deploy-key add= takes the same
3062flag. An expiring key cannot create credentials: tokens, keys, login
3063links. =keys list= shows when each key was last used and when it
3064expires, so a key nobody uses is easy to spot.
3065```
3066
3067`05-Identity-and-Access.org`: SSH user key and deploy key Expiry cells become `optional =--ttl=, refused at auth`.
3068
3069`09-Controls.org`:
3070
3071```
3072| Credential expiry                           | in place | optional =--ttl= on API tokens, SSH and deploy keys; checked at auth and per exec |
3073```
3074
3075`10-Known-Gaps.org`: delete the `#277` row.
3076
3077`Parity.org`, after `SSH key label`:
3078
3079```
3080| SSH key expiry and last use | yes | no  | no  |
3081```
3082
3083`API.org`: in the paragraph MR 2 added, "a token with a =--ttl= cannot run" becomes "a token or SSH key with a =--ttl= cannot run".
3084
3085`CHANGELOG.org`:
3086
3087```
3088- =keys add= and =repo deploy-key add= take =--ttl=; an expired key is
3089  refused at authentication, and an open connection on it closes within
3090  15 seconds. An expiring key cannot create credentials, like an
3091  expiring token. =keys list= and =repo deploy-key list= gain =USED= and
3092  =EXPIRES= columns, after the label (#277).
3093```
3094
3095- [ ] **Step 2: Commit, open the MR**
3096
3097```bash
3098git add .gitbay/wiki CHANGELOG.org
3099git commit -S -m "wiki: key expiry
3100
3101Closes #277"
3102git push -u origin key-expiry
3103gitbay mr create --source key-expiry --target main --title "keys: optional expiry for SSH and deploy keys"
3104```
3105
3106---
3107
3108# MR 4: idle timeout for browser sessions (branch `session-idle`, #276)
3109
3110A session lapses after 12 hours without a request and after seven
3111days regardless. `expires_at` holds the sliding expiry, so the auth
3112query and the retention sweep (`expires_at <= ?`,
3113`internal/store/retention.go:51`) need no change;
3114`absolute_expires_at` holds the cap. Renewal writes at most once a
3115minute per session. The cookie's `MaxAge` stays seven days.
3116
3117### Task 4.1: migration 0062 and the store
3118
3119**Files:**
3120- Create: `internal/store/migrations/0062_web_session_idle.up.sql`, `.down.sql`
3121- Modify: `internal/store/sessions.go:67-121`
3122- Test: `internal/store/sessions_test.go` (append)
3123
3124**Interfaces:**
3125- Produces:
3126  - `const WebSessionIdle = 12 * time.Hour`
3127  - `CreateWebSession(hash string, userID int64, ttl time.Duration) error` — unchanged signature; `ttl` is now the absolute cap.
3128  - `WebSessionUser(hash string) (User, error)` — unchanged signature; renews.
3129  - `WebSession.LastUsedAt string `json:"last_used_at"``
3130
3131- [ ] **Step 1: Write the failing tests**
3132
3133Append to `internal/store/sessions_test.go`:
3134
3135```go
3136func sessionFixture(t *testing.T) (*Store, int64) {
3137	t.Helper()
3138	s := open(t)
3139	if err := s.MigrateUp(); err != nil {
3140		t.Fatal(err)
3141	}
3142	uid, err := s.CreateUser("cmc", false)
3143	if err != nil {
3144		t.Fatal(err)
3145	}
3146	return s, uid
3147}
3148
3149func sessionTimes(t *testing.T, s *Store, hash string) (expires, absolute time.Time) {
3150	t.Helper()
3151	var e, a string
3152	if err := s.DB.QueryRow("SELECT expires_at, absolute_expires_at FROM web_sessions WHERE token_hash = ?", hash).Scan(&e, &a); err != nil {
3153		t.Fatal(err)
3154	}
3155	return *parseTime(sql.NullString{String: e, Valid: true}), *parseTime(sql.NullString{String: a, Valid: true})
3156}
3157
3158func TestWebSessionIdleExpiry(t *testing.T) {
3159	s, uid := sessionFixture(t)
3160	if err := s.CreateWebSession("h", uid, 7*24*time.Hour); err != nil {
3161		t.Fatal(err)
3162	}
3163	exp, abs := sessionTimes(t, s, "h")
3164	if d := time.Until(exp); d < WebSessionIdle-time.Minute || d > WebSessionIdle {
3165		t.Fatalf("a new session expires in %s, want %s", d, WebSessionIdle)
3166	}
3167	if d := time.Until(abs); d < 7*24*time.Hour-time.Minute {
3168		t.Fatalf("absolute cap in %s", d)
3169	}
3170	// Idle past the window: gone.
3171	old := fmtTime(time.Now().Add(-time.Second))
3172	s.DB.Exec("UPDATE web_sessions SET expires_at = ? WHERE token_hash = 'h'", old)
3173	if _, err := s.WebSessionUser("h"); err != ErrNotFound {
3174		t.Fatalf("idle session: %v", err)
3175	}
3176}
3177
3178func TestWebSessionRenewsUpToTheCap(t *testing.T) {
3179	s, uid := sessionFixture(t)
3180	if err := s.CreateWebSession("h", uid, 7*24*time.Hour); err != nil {
3181		t.Fatal(err)
3182	}
3183	// Last used two minutes ago, one minute left: a request renews it.
3184	s.DB.Exec("UPDATE web_sessions SET last_used_at = ?, expires_at = ? WHERE token_hash = 'h'",
3185		fmtTime(time.Now().Add(-2*time.Minute)), fmtTime(time.Now().Add(time.Minute)))
3186	if _, err := s.WebSessionUser("h"); err != nil {
3187		t.Fatal(err)
3188	}
3189	if exp, _ := sessionTimes(t, s, "h"); time.Until(exp) < WebSessionIdle-time.Minute {
3190		t.Fatalf("not renewed: expires in %s", time.Until(exp))
3191	}
3192	// Near the cap, renewal stops at it.
3193	capAt := time.Now().Add(time.Hour)
3194	s.DB.Exec("UPDATE web_sessions SET last_used_at = ?, absolute_expires_at = ? WHERE token_hash = 'h'",
3195		fmtTime(time.Now().Add(-2*time.Minute)), fmtTime(capAt))
3196	if _, err := s.WebSessionUser("h"); err != nil {
3197		t.Fatal(err)
3198	}
3199	if exp, _ := sessionTimes(t, s, "h"); exp.After(capAt) {
3200		t.Fatalf("renewed past the cap: %s > %s", exp, capAt)
3201	}
3202	list, err := s.ListWebSessions(uid)
3203	if err != nil || len(list) != 1 || list[0].LastUsedAt == "" {
3204		t.Fatalf("list: %+v %v", list, err)
3205	}
3206}
3207```
3208
3209Add `"database/sql"` to the file's imports.
3210
3211- [ ] **Step 2: Run them and see them fail**
3212
3213Run: `go test ./internal/store -run 'TestWebSession' -count=1`
3214Expected: FAIL to compile, `undefined: WebSessionIdle`.
3215
3216- [ ] **Step 3: Migration**
3217
3218`0062_web_session_idle.up.sql`:
3219
3220```sql
3221-- expires_at slides forward on use, never past absolute_expires_at.
3222-- Sessions open now keep their cap and get a full idle window from here.
3223ALTER TABLE web_sessions ADD COLUMN absolute_expires_at TEXT;
3224ALTER TABLE web_sessions ADD COLUMN last_used_at TEXT;
3225UPDATE web_sessions SET
3226    absolute_expires_at = expires_at,
3227    last_used_at = strftime('%Y-%m-%dT%H:%M:%fZ','now'),
3228    expires_at = min(expires_at, strftime('%Y-%m-%dT%H:%M:%fZ','now','+12 hours'));
3229```
3230
3231`0062_web_session_idle.down.sql`:
3232
3233```sql
3234UPDATE web_sessions SET expires_at = absolute_expires_at;
3235ALTER TABLE web_sessions DROP COLUMN last_used_at;
3236ALTER TABLE web_sessions DROP COLUMN absolute_expires_at;
3237```
3238
3239- [ ] **Step 4: Store**
3240
3241Replace `CreateWebSession` and `WebSessionUser`:
3242
3243```go
3244// WebSessionIdle is how long a browser session lasts without a request.
3245// Each use moves its expiry this far ahead, never past the cap it was
3246// created with. Migration 0062 repeats the value for sessions it
3247// converts.
3248const WebSessionIdle = 12 * time.Hour
3249
3250// CreateWebSession stores a session that lapses after WebSessionIdle
3251// without use, and after ttl regardless.
3252func (s *Store) CreateWebSession(hash string, userID int64, ttl time.Duration) error {
3253	now := time.Now()
3254	_, err := s.DB.Exec(
3255		"INSERT INTO web_sessions (token_hash, user_id, expires_at, absolute_expires_at, last_used_at) VALUES (?, ?, ?, ?, ?)",
3256		hash, userID, fmtTime(now.Add(min(ttl, WebSessionIdle))), fmtTime(now.Add(ttl)), fmtTime(now))
3257	return err
3258}
3259
3260// WebSessionUser resolves a session cookie hash to its user and renews
3261// the session's idle expiry. A session is written at most once a
3262// minute, so a burst of requests costs one UPDATE.
3263func (s *Store) WebSessionUser(hash string) (User, error) {
3264	now := time.Now()
3265	var userID int64
3266	err := s.DB.QueryRow(
3267		"SELECT user_id FROM web_sessions WHERE token_hash = ? AND expires_at > ?",
3268		hash, fmtTime(now)).Scan(&userID)
3269	if errors.Is(err, sql.ErrNoRows) {
3270		return User{}, ErrNotFound
3271	}
3272	if err != nil {
3273		return User{}, err
3274	}
3275	s.DB.Exec(`UPDATE web_sessions SET last_used_at = ?, expires_at = min(absolute_expires_at, ?)
3276		WHERE token_hash = ? AND last_used_at < ?`,
3277		fmtTime(now), fmtTime(now.Add(WebSessionIdle)), hash, fmtTime(now.Add(-time.Minute)))
3278	return s.UserByID(userID)
3279}
3280```
3281
3282`WebSession` and `ListWebSessions`:
3283
3284```go
3285type WebSession struct {
3286	ID         string `json:"id"`
3287	CreatedAt  string `json:"created_at"`
3288	ExpiresAt  string `json:"expires_at"`
3289	LastUsedAt string `json:"last_used_at"`
3290}
3291
3292// ListWebSessions lists the user's unexpired browser sessions, newest first.
3293func (s *Store) ListWebSessions(userID int64) ([]WebSession, error) {
3294	rows, err := s.DB.Query(`SELECT substr(token_hash, 1, 12), created_at, expires_at, COALESCE(last_used_at, created_at)
3295		FROM web_sessions WHERE user_id = ? AND expires_at > ? ORDER BY created_at DESC`,
3296		userID, fmtTime(time.Now()))
3297	if err != nil {
3298		return nil, err
3299	}
3300	defer rows.Close()
3301	var out []WebSession
3302	for rows.Next() {
3303		var ws WebSession
3304		if err := rows.Scan(&ws.ID, &ws.CreatedAt, &ws.ExpiresAt, &ws.LastUsedAt); err != nil {
3305			return nil, err
3306		}
3307		out = append(out, ws)
3308	}
3309	return out, rows.Err()
3310}
3311```
3312
3313- [ ] **Step 5: Run the tests**
3314
3315Run: `go test ./internal/store -count=1`
3316Expected: PASS, including `TestSweepRemovesExpiredSessionsAndTokens` (a `-time.Hour` ttl still makes a dead session).
3317
3318- [ ] **Step 6: Commit**
3319
3320```bash
3321git add internal/store
3322git commit -S -m "store: web sessions lapse after 12 hours idle, under the absolute cap
3323
3324Ref #276"
3325```
3326
3327### Task 4.2: `web sessions list` shows last use; docs
3328
3329**Files:**
3330- Modify: `internal/control/web.go:33-54`
3331- Modify: `internal/httpd/accounts.go:147` (comment only)
3332- Modify: `.gitbay/wiki/Users.org:672-678`, `.gitbay/wiki/Architecture/05-Identity-and-Access.org:21`, `:36-38`, `09-Controls.org:26`, `10-Known-Gaps.org`, `CHANGELOG.org`
3333
3334- [ ] **Step 1: The list**
3335
3336```go
3337	return c.emit(sessions, func(w io.Writer) {
3338		tb := c.table(w, "ID", "SINCE", "UNTIL", "USED")
3339		for _, s := range sessions {
3340			since, until, used := s.CreatedAt, s.ExpiresAt, s.LastUsedAt
3341			if c.Term.Cols == 0 {
3342				since, until, used = stamp(since), stamp(until), stamp(used)
3343			} else {
3344				since, until, used = relAge(since, termNow()), relAge(until, termNow()), relAge(used, termNow())
3345			}
3346			tb.row(cRef(s.ID), cText("since "+since), cText("until "+until), cText("used "+used))
3347		}
3348		tb.flush()
3349	})
3350```
3351
3352- [ ] **Step 2: The call site says what the ttl is**
3353
3354`internal/httpd/accounts.go`, above line 147:
3355
3356```go
3357	// Seven days is the cap; the store ends it sooner after
3358	// store.WebSessionIdle without a request.
3359```
3360
3361- [ ] **Step 3: Run the tests**
3362
3363Run: `go build ./... && go test ./internal/control ./internal/httpd -count=1 && go test ./e2e -run TestWebSessionsListRevoke -count=1`
3364Expected: PASS.
3365
3366- [ ] **Step 4: Docs**
3367
3368`Users.org`, the Browser sessions paragraph:
3369
3370```
3371=gitbay web login= mints a one-time URL; the session it opens ends
3372after twelve hours without a request, and after seven days in any case.
3373=gitbay web sessions list= shows each of yours by a short id with its
3374creation, expiry and last use, and =gitbay web sessions revoke <id>=
3375or =--all= ends them from the terminal, which is where a lost laptop is
3376handled.
3377```
3378
3379`05-Identity-and-Access.org`: the Web session Expiry cell becomes `12 h idle, 7 days absolute`; in the paragraph under the table, "=MaxAge= 7 days" becomes "=MaxAge= 7 days (the session itself also ends after 12 hours idle)".
3380
3381`09-Controls.org`:
3382
3383```
3384| Session lifetime                            | in place | 12 hours idle, 7 days absolute (=internal/store/sessions.go=)            |
3385```
3386
3387`10-Known-Gaps.org`: delete the `#276` row.
3388
3389`CHANGELOG.org`:
3390
3391```
3392- Browser sessions end after twelve hours without a request, and after
3393  seven days as before. Sessions open at upgrade get a fresh twelve
3394  hours. =web sessions list= shows when each was last used (#276).
3395```
3396
3397- [ ] **Step 5: Commit, open the MR**
3398
3399```bash
3400git add internal/control/web.go internal/httpd/accounts.go .gitbay/wiki CHANGELOG.org
3401git commit -S -m "web: sessions list shows last use; docs for the idle timeout
3402
3403Closes #276"
3404git push -u origin session-idle
3405gitbay mr create --source session-idle --target main --title "web: idle timeout for browser sessions"
3406```
3407
3408---
3409
3410# MR 5: `web login` over SSH spends the login-link budget (branch `weblogin-limit`, #278)
3411
3412### Task 5.1: the limit in `runWebLogin`
3413
3414**Files:**
3415- Modify: `internal/control/web.go:80-99`
3416- Modify: `internal/control/loginlink.go:12-19` (comment)
3417- Test: `internal/control/weblogin_test.go` (create)
3418- Modify: `.gitbay/wiki/Architecture/10-Known-Gaps.org`, `CHANGELOG.org`
3419
3420**Interfaces:**
3421- Consumes: `maxLoginLinksPerHour` (`loginlink.go:19`), `Store.CountLoginTokensSince`.
3422
3423- [ ] **Step 1: Write the failing test**
3424
3425```go
3426package control
3427
3428import (
3429	"strings"
3430	"testing"
3431
3432	"gitbay.org/gitbay/internal/protocol"
3433	"gitbay.org/gitbay/internal/store"
3434)
3435
3436// web login over SSH counts against the same hourly bound as the
3437// mailed links, since both insert into login_tokens (#278).
3438func TestWebLoginSharesTheLoginLinkLimit(t *testing.T) {
3439	st, _, uid := newQueueTestRepo(t)
3440	for i := 0; i <= maxLoginLinksPerHour; i++ {
3441		c, errOut := pruneCtx(st, t.TempDir(), store.User{ID: uid, Username: "alice"})
3442		c.Cfg.Web.Mode = "accounts"
3443		c.Cfg.Server.SiteURL = "https://gitbay.test"
3444		c.Cfg.Limits.WriteRate = -1
3445		code := Dispatch(c, []string{"web", "login"})
3446		switch {
3447		case i < maxLoginLinksPerHour && code != protocol.ExitOK:
3448			t.Fatalf("link %d: exit %d %s", i+1, code, errOut)
3449		case i == maxLoginLinksPerHour && (code != protocol.ExitDenied || !strings.Contains(errOut.String(), "login links")):
3450			t.Fatalf("link %d: exit %d %q, want refused", i+1, code, errOut)
3451		}
3452	}
3453}
3454```
3455
3456- [ ] **Step 2: Run it and see it fail**
3457
3458Run: `go test ./internal/control -run TestWebLoginSharesTheLoginLinkLimit -count=1`
3459Expected: FAIL, the sixth link exits 0.
3460
3461- [ ] **Step 3: Implement**
3462
3463In `runWebLogin`, after the web-mode check:
3464
3465```go
3466	n, err := c.Store.CountLoginTokensSince(c.User.ID, time.Now().Add(-time.Hour))
3467	if err != nil {
3468		return c.fail(protocol.ExitFailure, "%v", err)
3469	}
3470	if n >= maxLoginLinksPerHour {
3471		return c.fail(protocol.ExitDenied,
3472			"%d login links in the last hour is the most an account gets; use one of those, or wait", maxLoginLinksPerHour)
3473	}
3474```
3475
3476`loginlink.go`, the comment above `maxLoginLinksPerHour`:
3477
3478```go
3479// maxLoginLinksPerHour bounds what one account's address can be made to
3480// receive. It matches maxEmailAddsPerHour: enough for a person who mistypes
3481// and retries, nothing for a script. CountLoginTokensSince counts every row
3482// in login_tokens, so links minted with "web login" over SSH and links
3483// mailed from the login page share the budget, and both refuse past it.
3484```
3485
3486- [ ] **Step 4: Run the tests**
3487
3488Run: `go test ./internal/control -count=1`
3489Expected: PASS. Then `grep -c '\.login(t\|loginBrowser(t\|"web", "login"' e2e/*.go` and confirm no single e2e test logs one account in more than five times; `TestMRWebReviewLoop` logs alice in once and carol once, and each e2e test runs its own instance.
3490
3491- [ ] **Step 5: Docs**
3492
3493`10-Known-Gaps.org`: delete the `#278` row.
3494
3495`CHANGELOG.org`:
3496
3497```
3498- =web login= over SSH refuses a sixth link in an hour, the same bound
3499  the login page's mailed links have (#278).
3500```
3501
3502- [ ] **Step 6: Commit, open the MR**
3503
3504```bash
3505git add internal/control/web.go internal/control/loginlink.go internal/control/weblogin_test.go .gitbay/wiki CHANGELOG.org
3506git commit -S -m "web: login over SSH applies the login-link limit
3507
3508Closes #278"
3509git push -u origin weblogin-limit
3510gitbay mr create --source weblogin-limit --target main --title "web login over SSH applies the login-link rate limit"
3511```
3512
3513---
3514
3515## Decisions (2026-09-28)
3516
35171. **Expiring SSH keys and minting.** Confirmed: an expiring key is
3518   refused the minting commands like an expiring token (MR 3,
3519   Task 3.2).
35202. **Which commands mint.** Confirmed: the issue's five plus
3521   `repo runner add`, `admin user create`, `email verify` and
3522   `admin email verify`, pinned by `TestMintingCommandsMarked`.
35233. **LFS transfer tokens.** Filed as #285; not in this plan.
35244. **Removing the key you are on.** Confirmed: the session's own
3525   connection is cut after the removal commits.
35265. **Idle window.** A constant (`store.WebSessionIdle`, 12 h); make it
3527   configurable only when someone needs another value.
3528
3529Documented limits, stated on the Threat-Model page in MR 1:
3530
3531- With `ssh.mode = "system"` each exec is its own forced-command
3532  process; the per-exec check applies, a running one is not cut. bay1
3533  runs embedded.
3534- A control command already inside its store write when the key is
3535  revoked finishes the write and its output is lost; git transports are
3536  killed and commands watching `Done` stop.
3537
3538The `token list` future-expiry display is #286.
3539
3540## Self-review
3541
3542- **Coverage.** #256: per-exec re-read (Task 1.3 `runExec`), git
3543  transport session re-read (same path; `Exec` dispatches git),
3544  connection tracking and closing on keys remove / deploy-key remove /
3545  disable / delete (1.1, 1.3), cancelling running commands and pushes
3546  (1.2, 1.3), interrupted receive-pack moves no ref (1.4), e2e with a
3547  multiplexed connection (1.4). #257: `MintsCredential` checked in
3548  `Dispatch` (2.2), migration recording the creator for tokens and keys
3549  (2.1), `token revoke` listing and revoking with `--created` (2.1,
3550  2.3), default `--scope read` and release note (2.3, 2.5), API and
3551  Threat-Model pages (2.5). #277: `--ttl` on both add commands (3.3),
3552  enforced at authentication (3.2), last use in `keys list` (3.3),
3553  considered with #257 (3.2, open question 1), migration designed with
3554  0060 (both nullable `ADD COLUMN`, one insert path via `KeyOrigin`).
3555  #276: idle timeout with renewal, absolute cap kept, last use listed
3556  (4.1, 4.2). #278: limit applied, comment corrected (5.1).
3557- **Placeholders.** None; every code step carries the code. Two steps
3558  ask the executor to confirm a name with grep (`nullID`, the
3559  `ssh_test.go` test name) because they were not unique facts to pin.
3560- **Types.** `Revoked{KeyIDs []int64; UserID int64}`, `OnRevoke`,
3561  `announce`, `LiveSSHKeys([]int64) (map[int64]bool, error)` are used
3562  with those shapes in 1.1, 1.3, 2.1, 3.1. `Exec(..., key store.SSHKey,
3563  ..., done, stopping, revoked)` matches in 1.3, 3.2 and `system.go`.
3564  `APITokenUser` returns `(User, APIToken, error)` in 2.1, 2.2 and the
3565  tests. `KeyOrigin{CreatedByToken, ExpiresAt}` is introduced in 2.1
3566  and extended in 3.1. `Ctx.TokenID int64`, `Ctx.Expires *time.Time`
3567  match across 2.2, 2.3, 3.2, 3.3. `SSHKey.CreatedBy` (name) and
3568  `KeyOrigin.CreatedByToken` (id) are distinct on purpose.