diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index b740b14062f488d398aeb68d9803c6bd169ed37f..ecbc293549dc62d20cc228c9175d77dbe7a656fd 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -31,16 +31,32 @@
       - run: cargo --version
       - run: cargo test --locked --all-targets --all-features
       - run: cargo test --locked --release --test auth --test ssh --test account_lifecycle --test web_session --test repository_policy
-      - run: cargo test --locked --release --test git_repository --test git_http --test git_ssh --test public_routes --test serve
+      - run: cargo test --locked --release --test git_repository --test git_http --test git_ssh --test public_routes
       - run: cargo test --locked --release --test git_reads measures_bounded_search_without_an_index -- --ignored --nocapture
       - run: cargo test --locked --release --test sqlite_workload -- --ignored --nocapture
       - run: cargo build --locked --release
       - run: wc -c target/release/tit
+      - run: ./scripts/package-release target/release/tit dist
+      - name: Verify the release artifact
+        run: |
+          set -- dist/*.tar.gz
+          ./scripts/verify-release-artifact "$1" "$1.sha256"
+          mkdir release-test
+          tar -xzf "$1" -C release-test
+          set -- release-test/tit-*/bin/tit
+          TIT_RELEASE_BINARY="$1" cargo test --locked --release --test serve
+      - name: Store the release artifact
+        uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
+        with:
+          name: tit-${{ runner.os }}-${{ runner.arch }}
+          path: dist/*
+          if-no-files-found: error
 
   bsd:
     name: release (${{ matrix.platform.name }})
     runs-on: ubuntu-latest
     timeout-minutes: 60
+    continue-on-error: ${{ matrix.platform.experimental }}
     strategy:
       fail-fast: false
       matrix:
@@ -49,14 +65,20 @@
             os: freebsd
             version: '15.1'
             install: sudo pkg install -y git rust
+            experimental: false
           - name: OpenBSD 7.9
             os: openbsd
             version: '7.9'
             install: sudo pkg_add git rust
+            experimental: true
           - name: NetBSD 10.1
             os: netbsd
             version: '10.1'
             install: sudo pkgin -y install git-base rust
+            experimental: false
+    env:
+      CARGO_INCREMENTAL: 0
+      CARGO_PROFILE_TEST_DEBUG: 0
     defaults:
       run:
         shell: cpa.sh {0}
@@ -74,6 +96,39 @@
       - name: Install build and test tools
         run: ${{ matrix.platform.install }}
       - run: cargo --version
-      - run: cargo test --locked --all-targets --all-features
+      - run: cargo test --locked --release --all-targets --all-features
       - run: cargo build --locked --release
       - run: wc -c target/release/tit
+      - run: ./scripts/package-release target/release/tit dist
+      - name: Verify the release artifact
+        run: |
+          set -- dist/*.tar.gz
+          ./scripts/verify-release-artifact "$1" "$1.sha256"
+      - name: Store the release artifact
+        uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
+        with:
+          name: tit-${{ matrix.platform.os }}-X64
+          path: dist/*
+          if-no-files-found: error
+
+  publish:
+    if: startsWith(github.ref, 'refs/tags/v')
+    needs: [quality, release, bsd]
+    runs-on: ubuntu-latest
+    permissions:
+      contents: write
+    steps:
+      - name: Download release artifacts
+        uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
+        with:
+          path: release-artifacts
+      - name: Publish the GitHub release
+        env:
+          GH_TOKEN: ${{ github.token }}
+        run: |
+          set -- release-artifacts/tit-Linux-*/*.tar.gz
+          case "$(basename "$1")" in
+            "tit-${GITHUB_REF_NAME#v}-"*) ;;
+            *) echo "The tag does not match the package version." >&2; exit 1 ;;
+          esac
+          gh release create "$GITHUB_REF_NAME" release-artifacts/*/* --verify-tag --generate-notes
diff --git a/README.md b/README.md
index 2066b25c35f75e57bbe5b99ac80217772ec90452..35e7ccbc5c50a319c0665ce3e6c200ac4020456a 100644
--- a/README.md
+++ b/README.md
@@ -7,6 +7,11 @@
 Read [PLAN.md](PLAN.md) for the product design and implementation gates. Read
 [CONTRIBUTING.md](CONTRIBUTING.md) before you change code.
 
+For a release installation, use the [installation procedure](docs/install.md).
+Also keep the [upgrade procedure](docs/upgrade.md) and the
+[disaster-recovery exercise](docs/disaster-recovery.md) with the instance
+operations documentation.
+
 ## Build
 
 Install the Rust toolchain that `rust-toolchain.toml` specifies. Then, run:
@@ -193,6 +198,9 @@
 ```text
 ssh -p 2222 tit.example repo create project
 ```
+
+Run `ssh -p 2222 tit.example help` to list all SSH commands. A command that is
+not valid returns a nonzero status and tells the user to run `help`.
 
 The account that owns the SSH key becomes the repository owner. The SSH login
 name does not select the owner. New repositories use SHA-1 unless the command
@@ -665,3 +673,17 @@
 operation IDs, fixed metrics, audit records, and secret redaction. Read the
 [observability architectural decision record](docs/adr/0032-observability.md)
 for the event and redaction contracts.
+
+## Milestone 6.6 gate
+
+Install stock Git, OpenSSH, and `ssh-keygen`. Then, run the release gate:
+
+```text
+./scripts/check-m6-6
+```
+
+This command runs the quality gate, creates and verifies the native release
+archive, damages and restores a disposable instance, and runs the process
+security and recovery tests with the packaged executable. Read the
+[release packaging architectural decision record](docs/adr/0033-release-packaging.md)
+for the artifact and platform gate contracts.
diff --git a/docs/adr/0033-release-packaging.md b/docs/adr/0033-release-packaging.md
new file mode 100644
index 0000000000000000000000000000000000000000..fa1d828e6b5bb4d1fada99e5245cca258f4abaf1
--- /dev/null
+++ b/docs/adr/0033-release-packaging.md
@@ -1,0 +1,40 @@
+# 0033: Release packages use native executable archives
+
+- Status: accepted
+- Date: 2026-07-23
+
+## Context
+
+An operator must be able to install and recover `tit` without a Rust build
+environment. A release must also show which operating systems passed their
+native gates.
+
+## Decision
+
+Each supported release job builds one native `tit` executable. The job puts the
+executable, documentation, native service examples, a Caddy example, the
+manual, and shell completions in a versioned archive. The job also makes a
+SHA-256 checksum.
+
+The release verification runs the packaged executable with an empty `PATH`.
+It starts an instance, runs `doctor`, makes and checks a backup, damages a
+disposable database, restores the backup to an empty directory, and runs
+`doctor` again. The process security and recovery tests also use the executable
+from the archive.
+
+Linux and macOS are release gates. FreeBSD, OpenBSD, and NetBSD become release
+gates when the current native Rust toolchain and the application pass on that
+system. OpenBSD 7.9 is not a release gate while its native Rust package is older
+than the `rust-version` in `Cargo.toml`.
+
+GitHub Actions stores each successful archive and checksum. A `v` tag publishes
+the stored files only after the required jobs pass.
+
+## Consequences
+
+The archive does not need Git, OpenSSH, or a Rust build environment at run
+time. The operating system can still supply its normal C and system libraries.
+
+An operating-system name in the release files means that the native build,
+test, package, and clean-`PATH` verification passed. A platform that does not
+pass its gate does not produce a release file.
diff --git a/docs/disaster-recovery.md b/docs/disaster-recovery.md
new file mode 100644
index 0000000000000000000000000000000000000000..e6773262db177b4b5de68e05cac65024ea8987ef
--- /dev/null
+++ b/docs/disaster-recovery.md
@@ -1,0 +1,42 @@
+# Disaster-recovery exercise
+
+Do this exercise on a disposable copy. Do not damage the active instance.
+The commands below use `/srv`. On macOS, use a test directory in
+`/usr/local/var`.
+
+1. Stop the disposable service. Copy its configuration and data to a private
+   test directory.
+2. Start the disposable service with unused listener ports. Make a backup:
+
+   ```text
+   tit --config /srv/tit-exercise/config.toml backup /var/backups/tit-exercise.tar
+   ```
+
+3. Check the archive:
+
+   ```text
+   tit --config /srv/tit-exercise/config.toml doctor --backup /var/backups/tit-exercise.tar
+   ```
+
+4. Stop the disposable service. Damage its database. For example, move
+   `tit.sqlite3` out of the instance directory.
+5. Run `doctor` and confirm that it returns a failure. Do not use a repair
+   command for this exercise.
+6. Create a different, empty directory with mode 700. Restore the archive:
+
+   ```text
+   install -d -m 700 /srv/tit-exercise-restored
+   tit restore /var/backups/tit-exercise.tar /srv/tit-exercise-restored
+   tit --config /srv/tit-exercise-restored/config.toml doctor
+   ```
+
+7. Change the restored configuration to unused listener ports. Start the
+   restored service.
+8. Confirm that `/healthz` returns status 200. Confirm that a public repository
+   page works. Complete an SSH fetch and an administrator login.
+9. Stop the restored service. Remove the two disposable directories only after
+   you record the exercise result.
+
+Record the release version, backup checksum, damage operation, `doctor` error,
+restore duration, and validation result. Repeat the exercise after a storage,
+service, or backup procedure change.
diff --git a/docs/install.md b/docs/install.md
new file mode 100644
index 0000000000000000000000000000000000000000..02f5e35f6f59889bbfddde7605aba725a5f4cc19
--- /dev/null
+++ b/docs/install.md
@@ -1,0 +1,128 @@
+# Install and remove tit
+
+This procedure installs one `tit` instance. The default instance directory is
+`/srv/tit`. The release package contains the executable, configuration
+example, service examples, Caddy example, manual, shell completions, upgrade
+procedure, and disaster-recovery exercise.
+
+## Verify the release package
+
+Download one archive and its adjacent `.sha256` file from the same release.
+Use the archive for your operating system and architecture. Then, compare its
+SHA-256 checksum:
+
+```text
+sha256sum -c tit-VERSION-TARGET.tar.gz.sha256
+```
+
+On macOS, use this command:
+
+```text
+shasum -a 256 -c tit-VERSION-TARGET.tar.gz.sha256
+```
+
+The checksum detects a damaged download. Confirm that you got the checksum
+from the expected release before you use the archive.
+
+## Install the files
+
+Extract the archive. Select the instance directory and service account. Use
+`/srv/tit` and `tit` on Linux, FreeBSD, and NetBSD. Use `/srv/tit` and `_tit`
+on OpenBSD. Use `/usr/local/var/tit` and `_tit` on macOS because `/usr/local`
+is on the writable macOS data volume.
+
+Run these commands as `root` after you set `instance` and `account`:
+
+```text
+instance=/srv/tit
+account=tit
+install -m 755 tit-VERSION-TARGET/bin/tit /usr/local/bin/tit
+install -d -m 700 "$instance"
+chown "$account" "$instance"
+install -m 600 tit-VERSION-TARGET/etc/tit/config.toml "$instance/config.toml"
+chown "$account" "$instance/config.toml"
+```
+
+Create a dedicated system account before these commands. Give the account no
+login shell and no password. Use `tit` on Linux, FreeBSD, and NetBSD. Use
+`_tit` on macOS and OpenBSD. The supplied service examples use these names.
+Use the native account administration command for the operating system. Do not
+give the account an interactive login or an administrator role.
+
+Edit the installed `config.toml`. Set `public_url`, the listener addresses, and
+the public SSH hostname and port. Keep the file and directory readable only by
+the service account.
+
+Install the applicable files from `share`:
+
+- Linux: install `share/doc/tit/examples/tit.service` in
+  `/etc/systemd/system/tit.service`.
+- macOS: install `share/doc/tit/examples/com.tit-cde.tit.plist` in
+  `/Library/LaunchDaemons/com.tit-cde.tit.plist`.
+- FreeBSD: install `share/doc/tit/examples/tit.freebsd` in
+  `/usr/local/etc/rc.d/tit`.
+- OpenBSD: install `share/doc/tit/examples/tit.openbsd` in `/etc/rc.d/tit`.
+- NetBSD: install `share/doc/tit/examples/tit.netbsd` in `/etc/rc.d/tit`.
+- Caddy: copy the supplied `Caddyfile` content into the site configuration and
+  change `tit.example` to the public hostname.
+
+The package also supplies a `tit.1` manual and completions for Bash, Zsh, and
+Fish. Copy them to the matching system directories if the package manager does
+not do this operation.
+
+## Configure the first administrator
+
+The commands below use `/srv/tit`. On macOS, use `/usr/local/var/tit`.
+
+Run the setup command as the service account:
+
+```text
+tit --config /srv/tit/config.toml setup admin USERNAME "SSH_PUBLIC_KEY"
+```
+
+Store the printed recovery credential offline. The command prints it one time.
+Then, run the read-only check:
+
+```text
+tit --config /srv/tit/config.toml doctor
+```
+
+The first `doctor` check requires the SSH host key. Start and stop the service
+one time if the file does not exist. Then, run `doctor` again.
+
+## Start the service
+
+Use the native service manager:
+
+```text
+systemctl enable --now tit
+```
+
+On macOS, use `launchctl bootstrap system
+/Library/LaunchDaemons/com.tit-cde.tit.plist`. On a BSD system, enable `tit`
+in the applicable system configuration:
+
+- FreeBSD: run `sysrc tit_enable=YES` and `service tit start`.
+- OpenBSD: run `rcctl enable tit` and `rcctl start tit`.
+- NetBSD: add `tit=YES` to `/etc/rc.conf` and run `service tit start`.
+
+Request `/healthz` through Caddy. Status 200 shows that the HTTP and SSH
+listeners are ready.
+
+## Remove the service
+
+Make a final backup before removal. Stop and disable the native service:
+
+- Linux: run `systemctl disable --now tit`.
+- macOS: run `launchctl bootout system/com.tit-cde.tit`.
+- FreeBSD: run `service tit stop` and `sysrc tit_enable=NO`.
+- OpenBSD: run `rcctl stop tit` and `rcctl disable tit`.
+- NetBSD: run `service tit stop` and remove `tit=YES` from `/etc/rc.conf`.
+
+Remove the service file, `/usr/local/bin/tit`, the manual, and the shell
+completions. Reload the service manager when it requires this operation.
+
+Keep `/srv/tit` if you can need its repositories, credentials, or audit
+history. To remove all instance data, remove `/srv/tit` only after you verify
+the final backup and its storage location. This operation cannot be reversed
+without a backup.
diff --git a/docs/upgrade.md b/docs/upgrade.md
new file mode 100644
index 0000000000000000000000000000000000000000..00a26a7b520ff61ae0486e3d91f46fdd8530cd2b
--- /dev/null
+++ b/docs/upgrade.md
@@ -1,0 +1,40 @@
+# Upgrade tit
+
+Use this procedure for an upgrade to a new release. Do not replace a running
+executable before you make a backup.
+
+The commands below use `/srv/tit`. On macOS, use `/usr/local/var/tit`.
+
+1. Read the release notes and the new `config.example.toml`.
+2. Download the archive and its `.sha256` file. Verify the checksum as
+   specified in `install.md`.
+3. Make an online backup:
+
+   ```text
+   tit --config /srv/tit/config.toml backup /var/backups/tit-before-upgrade.tar
+   ```
+
+4. Check the active instance and the backup:
+
+   ```text
+   tit --config /srv/tit/config.toml doctor --backup /var/backups/tit-before-upgrade.tar
+   ```
+
+5. Stop the native service. Keep a copy of the old executable outside
+   `/srv/tit`.
+6. Install the new `bin/tit` with mode 755. Do not replace the configuration
+   with the example.
+7. Run the new read-only check:
+
+   ```text
+   /usr/local/bin/tit --config /srv/tit/config.toml doctor
+   ```
+
+8. Start the service. Request `/healthz` and complete one HTTP login and one
+   SSH fetch.
+
+If the new executable does not accept the current configuration or data, stop
+it. Restore the old executable. If the new release changed the instance before
+it failed, restore the backup to a new, empty directory. Do not restore into
+the damaged directory. Activate the restored directory only after `doctor`
+passes.
diff --git a/release/completions/_tit b/release/completions/_tit
new file mode 100644
index 0000000000000000000000000000000000000000..ca607da6e543484712a011a165b35e6924ff2bdf
--- /dev/null
+++ b/release/completions/_tit
@@ -1,0 +1,44 @@
+#compdef tit
+
+_tit()
+{
+  local -a commands
+  commands=(
+    'serve:Start the HTTP and SSH servers'
+    'invite-code:Create a single-use signup invitation'
+    'doctor:Check the instance without changing it'
+    'inspect:Show one typed instance record as JSON'
+    'dump:Write all SQLite rows as deterministic JSON Lines'
+    'repair:Run an explicit repair operation'
+    'backup:Create a backup archive'
+    'restore:Restore a backup archive to an empty instance directory'
+    'setup:Set up an uninitialized instance'
+    'admin:Run an offline administrator command'
+  )
+
+  _arguments \
+    '--config[Read configuration from a file]:configuration file:_files' \
+    '--user[Read configuration from the XDG data directory]' \
+    '--public-url[Override the canonical public URL]:URL:' \
+    '--http-listen[Override the HTTP listener address]:address:' \
+    '--ssh-listen[Override the SSH listener address]:address:' \
+    '--ssh-public-host[Override the advertised SSH hostname]:host:' \
+    '--ssh-public-port[Override the public SSH port]:port:' \
+    '(-h --help)'{-h,--help}'[Print help]' \
+    '(-V --version)'{-V,--version}'[Print the version]' \
+    '1:command:->command' \
+    '*::argument:->arguments'
+
+  case $state in
+    command)
+      _describe 'command' commands
+      ;;
+    arguments)
+      case $words[1] in
+        backup|restore) _files ;;
+      esac
+      ;;
+  esac
+}
+
+_tit "$@"
diff --git a/release/completions/tit.bash b/release/completions/tit.bash
new file mode 100644
index 0000000000000000000000000000000000000000..803085db73ca392f92c5bf4b57859c1b296e0a28
--- /dev/null
+++ b/release/completions/tit.bash
@@ -1,0 +1,25 @@
+_tit()
+{
+    local cur prev commands
+    COMPREPLY=()
+    cur=${COMP_WORDS[COMP_CWORD]}
+    prev=${COMP_WORDS[COMP_CWORD-1]}
+    commands="serve invite-code doctor inspect dump repair backup restore setup admin help"
+
+    case "$prev" in
+        --config|--backup|backup|restore)
+            COMPREPLY=( $(compgen -f -- "$cur") )
+            return
+            ;;
+        --public-url|--http-listen|--ssh-listen|--ssh-public-host|--ssh-public-port)
+            return
+            ;;
+    esac
+
+    if [[ "$cur" == -* ]]; then
+        COMPREPLY=( $(compgen -W "--config --user --public-url --http-listen --ssh-listen --ssh-public-host --ssh-public-port --help --version" -- "$cur") )
+    elif [ "$COMP_CWORD" -eq 1 ]; then
+        COMPREPLY=( $(compgen -W "$commands" -- "$cur") )
+    fi
+}
+complete -F _tit tit
diff --git a/release/completions/tit.fish b/release/completions/tit.fish
new file mode 100644
index 0000000000000000000000000000000000000000..7cebc56b2c4387c11f4e19c3a791cc311f6a6ad2
--- /dev/null
+++ b/release/completions/tit.fish
@@ -1,0 +1,20 @@
+complete -c tit -f
+complete -c tit -l config -r -d 'Read configuration from a file'
+complete -c tit -l user -d 'Read configuration from the XDG data directory'
+complete -c tit -l public-url -r -d 'Override the canonical public URL'
+complete -c tit -l http-listen -r -d 'Override the HTTP listener address'
+complete -c tit -l ssh-listen -r -d 'Override the SSH listener address'
+complete -c tit -l ssh-public-host -r -d 'Override the advertised SSH hostname'
+complete -c tit -l ssh-public-port -r -d 'Override the public SSH port'
+complete -c tit -s h -l help -d 'Print help'
+complete -c tit -s V -l version -d 'Print the version'
+complete -c tit -n '__fish_use_subcommand' -a serve -d 'Start the HTTP and SSH servers'
+complete -c tit -n '__fish_use_subcommand' -a invite-code -d 'Create a single-use signup invitation'
+complete -c tit -n '__fish_use_subcommand' -a doctor -d 'Check the instance without changing it'
+complete -c tit -n '__fish_use_subcommand' -a inspect -d 'Show one typed instance record as JSON'
+complete -c tit -n '__fish_use_subcommand' -a dump -d 'Write all SQLite rows as deterministic JSON Lines'
+complete -c tit -n '__fish_use_subcommand' -a repair -d 'Run an explicit repair operation'
+complete -c tit -n '__fish_use_subcommand' -a backup -d 'Create a backup archive'
+complete -c tit -n '__fish_use_subcommand' -a restore -d 'Restore a backup archive'
+complete -c tit -n '__fish_use_subcommand' -a setup -d 'Set up an uninitialized instance'
+complete -c tit -n '__fish_use_subcommand' -a admin -d 'Run an offline administrator command'
diff --git a/release/examples/Caddyfile b/release/examples/Caddyfile
new file mode 100644
index 0000000000000000000000000000000000000000..3e2b0b884ada49f31adee1b199c210202068360b
--- /dev/null
+++ b/release/examples/Caddyfile
@@ -1,0 +1,3 @@
+tit.example {
+	reverse_proxy 127.0.0.1:3000
+}
diff --git a/release/examples/com.tit-cde.tit.plist b/release/examples/com.tit-cde.tit.plist
new file mode 100644
index 0000000000000000000000000000000000000000..4a7cb9e1f284d7d71c844e39d4db3d93faf9935a
--- /dev/null
+++ b/release/examples/com.tit-cde.tit.plist
@@ -1,0 +1,26 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+  <key>Label</key>
+  <string>com.tit-cde.tit</string>
+  <key>ProgramArguments</key>
+  <array>
+    <string>/usr/local/bin/tit</string>
+    <string>--config</string>
+    <string>/usr/local/var/tit/config.toml</string>
+    <string>serve</string>
+  </array>
+  <key>RunAtLoad</key>
+  <true/>
+  <key>KeepAlive</key>
+  <dict>
+    <key>SuccessfulExit</key>
+    <false/>
+  </dict>
+  <key>ProcessType</key>
+  <string>Background</string>
+  <key>UserName</key>
+  <string>_tit</string>
+</dict>
+</plist>
diff --git a/release/examples/tit.freebsd b/release/examples/tit.freebsd
new file mode 100755
index 0000000000000000000000000000000000000000..329c2f2829d02e9914e1b5bf24e7fecf7a154886
--- /dev/null
+++ b/release/examples/tit.freebsd
@@ -1,0 +1,21 @@
+#!/bin/sh
+
+# PROVIDE: tit
+# REQUIRE: LOGIN NETWORKING
+# KEYWORD: shutdown
+
+. /etc/rc.subr
+
+name="tit"
+rcvar="tit_enable"
+load_rc_config "$name"
+
+: "${tit_enable:=NO}"
+: "${tit_config:=/srv/tit/config.toml}"
+: "${tit_user:=tit}"
+
+command="/usr/sbin/daemon"
+command_args="-r -S -T tit -u ${tit_user} -p /var/run/tit.pid -f /usr/local/bin/tit --config ${tit_config} serve"
+pidfile="/var/run/tit.pid"
+
+run_rc_command "$1"
diff --git a/release/examples/tit.netbsd b/release/examples/tit.netbsd
new file mode 100755
index 0000000000000000000000000000000000000000..e589f1071ed9069d208bdcc6346fe05580bd6511
--- /dev/null
+++ b/release/examples/tit.netbsd
@@ -1,0 +1,17 @@
+#!/bin/sh
+
+# PROVIDE: tit
+# REQUIRE: DAEMON NETWORKING
+# KEYWORD: shutdown
+
+$_rc_subr_loaded . /etc/rc.subr
+
+name="tit"
+rcvar="$name"
+command="/usr/local/bin/tit"
+command_args="--config /srv/tit/config.toml serve"
+command_user="tit"
+required_files="/srv/tit/config.toml"
+
+load_rc_config "$name"
+run_rc_command "$1"
diff --git a/release/examples/tit.openbsd b/release/examples/tit.openbsd
new file mode 100755
index 0000000000000000000000000000000000000000..998bb6b0c3e473d7962a03d4519d7d11ca245f20
--- /dev/null
+++ b/release/examples/tit.openbsd
@@ -1,0 +1,9 @@
+#!/bin/ksh
+
+daemon="/usr/local/bin/tit"
+daemon_user="_tit"
+daemon_flags="--config /srv/tit/config.toml serve"
+
+. /etc/rc.d/rc.subr
+
+rc_cmd "$1"
diff --git a/release/examples/tit.service b/release/examples/tit.service
new file mode 100644
index 0000000000000000000000000000000000000000..ccf7b70268dc5719469fa7887f046a9272ba6f58
--- /dev/null
+++ b/release/examples/tit.service
@@ -1,0 +1,20 @@
+[Unit]
+Description=tit collaborative development environment
+After=network-online.target
+Wants=network-online.target
+
+[Service]
+Type=simple
+User=tit
+Group=tit
+ExecStart=/usr/local/bin/tit --config /srv/tit/config.toml serve
+Restart=on-failure
+RestartSec=5s
+NoNewPrivileges=true
+PrivateTmp=true
+ProtectHome=true
+ProtectSystem=strict
+ReadWritePaths=/srv/tit
+
+[Install]
+WantedBy=multi-user.target
diff --git a/release/man/tit.1 b/release/man/tit.1
new file mode 100644
index 0000000000000000000000000000000000000000..da34821ee57cf017d560a42ca46d1aa12cedde6d
--- /dev/null
+++ b/release/man/tit.1
@@ -1,0 +1,100 @@
+.TH TIT 1 "2026-07-23" "tit 0.1.0" "User Commands"
+.SH NAME
+tit \- run a small self-hosted collaborative development environment
+.SH SYNOPSIS
+.B tit
+[\fIOPTIONS\fR] \fICOMMAND\fR
+.SH DESCRIPTION
+.B tit
+runs one HTTP server, one SSH server, Git transport, collaboration workflows,
+backup, restore, and diagnostics in one executable.
+It does not require a Git executable at run time.
+.SH OPTIONS
+.TP
+.BR \-\-config " " \fIFILE\fR
+Read configuration from FILE.
+.TP
+.B \-\-user
+Read configuration from the XDG data directory.
+.TP
+.BR \-\-public\-url " " \fIURL\fR
+Override the canonical public URL.
+.TP
+.BR \-\-http\-listen " " \fIADDRESS\fR
+Override the HTTP listener address.
+.TP
+.BR \-\-ssh\-listen " " \fIADDRESS\fR
+Override the SSH listener address.
+.TP
+.BR \-\-ssh\-public\-host " " \fIHOST\fR
+Override the advertised SSH hostname.
+.TP
+.BR \-\-ssh\-public\-port " " \fIPORT\fR
+Override the public SSH port.
+.TP
+.BR \-h ", " \-\-help
+Print help.
+.TP
+.BR \-V ", " \-\-version
+Print the version.
+.SH COMMANDS
+.TP
+.B serve
+Start the HTTP and SSH servers.
+.TP
+.B invite-code
+Create a single-use signup invitation.
+.TP
+.B doctor
+Check the instance without changing it.
+Use
+.B \-\-backup FILE
+to also check a backup.
+.TP
+.B inspect
+Show an account, repository, or Git operation intent as JSON.
+.TP
+.B dump
+Write all SQLite rows as deterministic JSON Lines.
+.TP
+.B repair
+Run an explicit intent or quarantine repair operation.
+.TP
+.BR backup " " \fIFILE\fR
+Create an owner-only backup archive.
+.TP
+.BR restore " " \fIARCHIVE DIRECTORY\fR
+Restore an archive to an empty private directory.
+.TP
+.B setup
+Create the initial administrator.
+.TP
+.B admin
+Run an offline account, repository, or audit command.
+.SH SSH INTERFACE
+Run
+.B ssh -p 2222 tit.example help
+to show the commands that the built-in SSH server accepts.
+An invalid command returns a nonzero status and tells the user to run
+.BR help .
+.SH FILES
+.TP
+.B /srv/tit/config.toml
+Default system configuration.
+.TP
+.B /srv/tit/tit.sqlite3
+SQLite metadata database.
+.TP
+.B /srv/tit/repositories
+Bare Git repositories.
+.TP
+.B /srv/tit/ssh_host_ed25519_key
+Built-in SSH server host key.
+.SH SECURITY
+The instance directory and configuration must have owner-only permissions.
+Backups contain credentials.
+Store them as secrets.
+.SH SEE ALSO
+The release package contains install, upgrade, and disaster-recovery
+procedures in
+.BR share/doc/tit .
diff --git a/scripts/check-m6-6 b/scripts/check-m6-6
new file mode 100755
index 0000000000000000000000000000000000000000..805ddbf7536e625fcfa7d7346e2bc4535b056fa9
--- /dev/null
+++ b/scripts/check-m6-6
@@ -1,0 +1,25 @@
+#!/bin/sh
+set -eu
+
+project_dir=$(CDPATH= cd "$(dirname "$0")/.." && pwd)
+work=$(mktemp -d "${TMPDIR:-/tmp}/tit-m6-6.XXXXXX")
+trap 'rm -rf "$work"' EXIT HUP INT TERM
+
+cd "$project_dir"
+./scripts/check
+./scripts/package-release target/release/tit "$work/dist" >"$work/package.list"
+
+archive=$(sed -n '1p' "$work/package.list")
+checksum=$(sed -n '2p' "$work/package.list")
+./scripts/verify-release-artifact "$archive" "$checksum"
+
+mkdir "$work/package"
+tar -xzf "$archive" -C "$work/package"
+set -- "$work"/package/tit-*/bin/tit
+if [ "$#" -ne 1 ] || [ ! -x "$1" ]; then
+    echo "tit: the release test executable is not available" >&2
+    exit 1
+fi
+
+TIT_RELEASE_BINARY=$1 \
+    cargo test --locked --release --test serve
diff --git a/scripts/package-release b/scripts/package-release
new file mode 100755
index 0000000000000000000000000000000000000000..2464317f8e8adfc083f0c452328145067caaf2d2
--- /dev/null
+++ b/scripts/package-release
@@ -1,0 +1,112 @@
+#!/bin/sh
+set -eu
+
+project_dir=$(CDPATH= cd "$(dirname "$0")/.." && pwd)
+binary=${1:-"$project_dir/target/release/tit"}
+output_dir=${2:-"$project_dir/dist"}
+
+case "$binary" in
+    /*) ;;
+    *) binary="$project_dir/$binary" ;;
+esac
+
+if [ ! -x "$binary" ]; then
+    echo "tit: the release executable does not exist: $binary" >&2
+    exit 1
+fi
+
+set -- $("$binary" --version)
+if [ "$#" -ne 2 ] || [ "$1" != "tit" ]; then
+    echo "tit: the release executable has invalid version output" >&2
+    exit 1
+fi
+version=$2
+case "$version" in
+    *[!0-9A-Za-z.-]* | "") echo "tit: the release version is not valid" >&2; exit 1 ;;
+esac
+
+target=$(rustc -vV | awk '/^host: / { print $2 }')
+case "$target" in
+    *[!0-9A-Za-z_.-]* | "") echo "tit: the Rust host target is not valid" >&2; exit 1 ;;
+esac
+
+mkdir -p "$output_dir"
+output_dir=$(CDPATH= cd "$output_dir" && pwd)
+package="tit-$version-$target"
+archive="$package.tar.gz"
+checksum="$archive.sha256"
+if [ -e "$output_dir/$archive" ] || [ -e "$output_dir/$checksum" ]; then
+    echo "tit: the release output already exists for $target" >&2
+    exit 1
+fi
+
+stage=$(mktemp -d "${TMPDIR:-/tmp}/tit-package.XXXXXX")
+trap 'rm -rf "$stage"' EXIT HUP INT TERM
+root="$stage/$package"
+
+install -d -m 755 \
+    "$root/bin" \
+    "$root/etc/tit" \
+    "$root/share/doc/tit/adr" \
+    "$root/share/doc/tit/examples" \
+    "$root/share/man/man1" \
+    "$root/share/bash-completion/completions" \
+    "$root/share/zsh/site-functions" \
+    "$root/share/fish/vendor_completions.d"
+install -m 755 "$binary" "$root/bin/tit"
+install -m 644 "$project_dir/LICENSE" "$root/LICENSE"
+install -m 644 "$project_dir/README.md" "$root/README.md"
+install -m 600 "$project_dir/config.example.toml" "$root/etc/tit/config.toml"
+install -m 644 "$project_dir/docs/install.md" "$root/share/doc/tit/install.md"
+install -m 644 "$project_dir/docs/upgrade.md" "$root/share/doc/tit/upgrade.md"
+install -m 644 \
+    "$project_dir/docs/disaster-recovery.md" \
+    "$root/share/doc/tit/disaster-recovery.md"
+install -m 644 \
+    "$project_dir/docs/adr/0029-backup-and-restore.md" \
+    "$root/share/doc/tit/adr/0029-backup-and-restore.md"
+install -m 644 \
+    "$project_dir/docs/adr/0033-release-packaging.md" \
+    "$root/share/doc/tit/adr/0033-release-packaging.md"
+install -m 644 "$project_dir/release/examples/Caddyfile" "$root/share/doc/tit/examples/Caddyfile"
+install -m 644 \
+    "$project_dir/release/examples/tit.service" \
+    "$root/share/doc/tit/examples/tit.service"
+install -m 644 \
+    "$project_dir/release/examples/com.tit-cde.tit.plist" \
+    "$root/share/doc/tit/examples/com.tit-cde.tit.plist"
+install -m 755 \
+    "$project_dir/release/examples/tit.freebsd" \
+    "$root/share/doc/tit/examples/tit.freebsd"
+install -m 755 \
+    "$project_dir/release/examples/tit.openbsd" \
+    "$root/share/doc/tit/examples/tit.openbsd"
+install -m 755 \
+    "$project_dir/release/examples/tit.netbsd" \
+    "$root/share/doc/tit/examples/tit.netbsd"
+install -m 644 "$project_dir/release/man/tit.1" "$root/share/man/man1/tit.1"
+install -m 644 \
+    "$project_dir/release/completions/tit.bash" \
+    "$root/share/bash-completion/completions/tit"
+install -m 644 \
+    "$project_dir/release/completions/_tit" \
+    "$root/share/zsh/site-functions/_tit"
+install -m 644 \
+    "$project_dir/release/completions/tit.fish" \
+    "$root/share/fish/vendor_completions.d/tit.fish"
+
+(CDPATH= cd "$stage" && tar -czf "$output_dir/$archive" "$package")
+
+if command -v sha256sum >/dev/null 2>&1; then
+    (CDPATH= cd "$output_dir" && sha256sum "$archive" >"$checksum")
+elif command -v shasum >/dev/null 2>&1; then
+    (CDPATH= cd "$output_dir" && shasum -a 256 "$archive" >"$checksum")
+elif command -v sha256 >/dev/null 2>&1; then
+    digest=$(sha256 -q "$output_dir/$archive")
+    printf '%s  %s\n' "$digest" "$archive" >"$output_dir/$checksum"
+else
+    echo "tit: no SHA-256 command is available" >&2
+    exit 1
+fi
+
+printf '%s\n%s\n' "$output_dir/$archive" "$output_dir/$checksum"
diff --git a/scripts/verify-release-artifact b/scripts/verify-release-artifact
new file mode 100755
index 0000000000000000000000000000000000000000..398d14f853cd8ef73839df42a34d5867301f793b
--- /dev/null
+++ b/scripts/verify-release-artifact
@@ -1,0 +1,150 @@
+#!/bin/sh
+set -eu
+
+if [ "$#" -ne 2 ]; then
+    echo "usage: verify-release-artifact ARCHIVE CHECKSUM" >&2
+    exit 2
+fi
+
+archive=$1
+checksum=$2
+archive_dir=$(CDPATH= cd "$(dirname "$archive")" && pwd)
+archive_name=$(basename "$archive")
+checksum_name=$(basename "$checksum")
+
+if command -v sha256sum >/dev/null 2>&1; then
+    (CDPATH= cd "$archive_dir" && sha256sum -c "$checksum_name")
+elif command -v shasum >/dev/null 2>&1; then
+    (CDPATH= cd "$archive_dir" && shasum -a 256 -c "$checksum_name")
+elif command -v sha256 >/dev/null 2>&1; then
+    expected=$(awk '{ print $1 }' "$checksum")
+    actual=$(sha256 -q "$archive")
+    [ "$actual" = "$expected" ] || {
+        echo "tit: the release checksum does not match" >&2
+        exit 1
+    }
+else
+    echo "tit: no SHA-256 command is available" >&2
+    exit 1
+fi
+
+work=$(mktemp -d "${TMPDIR:-/tmp}/tit-verify.XXXXXX")
+trap 'rm -rf "$work"' EXIT HUP INT TERM
+list="$work/archive.list"
+tar -tzf "$archive" >"$list"
+root=$(sed -n '1{s:/*$::;p;}' "$list")
+case "$root" in
+    tit-*-*) ;;
+    *) echo "tit: the release archive root is not valid" >&2; exit 1 ;;
+esac
+case "$root" in
+    *[!0-9A-Za-z_.-]*) echo "tit: the release archive root is not valid" >&2; exit 1 ;;
+esac
+while IFS= read -r path; do
+    case "$path" in
+        "$root" | "$root"/*) ;;
+        *) echo "tit: the release archive has an unsafe path" >&2; exit 1 ;;
+    esac
+    case "$path" in
+        /* | *"/../"* | *"/.." | ../*) echo "tit: the release archive has an unsafe path" >&2; exit 1 ;;
+    esac
+done <"$list"
+tar -xzf "$archive" -C "$work"
+package="$work/$root"
+binary="$package/bin/tit"
+
+for required in \
+    "$binary" \
+    "$package/LICENSE" \
+    "$package/README.md" \
+    "$package/etc/tit/config.toml" \
+    "$package/share/doc/tit/install.md" \
+    "$package/share/doc/tit/upgrade.md" \
+    "$package/share/doc/tit/disaster-recovery.md" \
+    "$package/share/doc/tit/examples/Caddyfile" \
+    "$package/share/man/man1/tit.1" \
+    "$package/share/bash-completion/completions/tit" \
+    "$package/share/zsh/site-functions/_tit" \
+    "$package/share/fish/vendor_completions.d/tit.fish"
+do
+    [ -f "$required" ] || {
+        echo "tit: a required release file is missing: $required" >&2
+        exit 1
+    }
+done
+[ -x "$binary" ] || {
+    echo "tit: the release executable is not executable" >&2
+    exit 1
+}
+
+empty_path="$work/empty-path"
+home="$work/home"
+instance="$work/instance"
+restored="$work/restored"
+mkdir -m 700 "$empty_path" "$home" "$instance" "$restored"
+config="$instance/config.toml"
+http_port=$((30000 + ($$ % 10000)))
+ssh_port=$((http_port + 1))
+cat >"$config" <<EOF
+version = 1
+public_url = "http://127.0.0.1:$http_port/"
+
+[http]
+listen = "127.0.0.1:$http_port"
+
+[ssh]
+listen = "127.0.0.1:$ssh_port"
+public_host = "127.0.0.1"
+public_port = $ssh_port
+EOF
+chmod 600 "$config"
+
+key='ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJgOfb9397gwZRfsz2A6DN/VVw/+bdjMdVsJ89JDLAC0 tit-release-verification'
+setup_output="$work/setup.out"
+env -i PATH="$empty_path" HOME="$home" TMPDIR="$work" \
+    "$binary" --config "$config" setup admin release-test "$key" >"$setup_output"
+grep -q '^Recovery code: tit-recovery-v1:' "$setup_output"
+
+server_log="$work/server.log"
+env -i PATH="$empty_path" HOME="$home" TMPDIR="$work" \
+    "$binary" --config "$config" serve 2>"$server_log" &
+server_pid=$!
+sleep 1
+if ! kill -0 "$server_pid" 2>/dev/null; then
+    wait "$server_pid" || true
+    echo "tit: the release server did not start" >&2
+    sed -n '1,80p' "$server_log" >&2
+    exit 1
+fi
+kill -TERM "$server_pid"
+wait "$server_pid"
+
+env -i PATH="$empty_path" HOME="$home" TMPDIR="$work" \
+    "$binary" --config "$config" doctor
+backup="$work/tit-backup.tar"
+env -i PATH="$empty_path" HOME="$home" TMPDIR="$work" \
+    "$binary" --config "$config" backup "$backup"
+env -i PATH="$empty_path" HOME="$home" TMPDIR="$work" \
+    "$binary" --config "$config" doctor --backup "$backup"
+
+: >"$instance/tit.sqlite3"
+if env -i PATH="$empty_path" HOME="$home" TMPDIR="$work" \
+    "$binary" --config "$config" doctor >/dev/null 2>&1
+then
+    echo "tit: doctor accepted the damaged disposable instance" >&2
+    exit 1
+fi
+
+env -i PATH="$empty_path" HOME="$home" TMPDIR="$work" \
+    "$binary" restore "$backup" "$restored"
+env -i PATH="$empty_path" HOME="$home" TMPDIR="$work" \
+    "$binary" --config "$restored/config.toml" doctor
+
+recovery=$(sed -n 's/^Recovery code: //p' "$setup_output")
+if grep -F "$recovery" "$server_log" >/dev/null; then
+    echo "tit: the server log contains a recovery credential" >&2
+    exit 1
+fi
+
+version=$("$binary" --version)
+printf 'verified %s from %s without commands in PATH\n' "$version" "$archive_name"
diff --git a/src/http/feeds.rs b/src/http/feeds.rs
index dbec8adff1c2715860c47bcef49b5fe9e21543b9..6cd94355ff005bce33b2e9b990a1c30141d6ef0c 100644
--- a/src/http/feeds.rs
+++ b/src/http/feeds.rs
@@ -65,6 +65,7 @@
             StatusCode::OK,
             &FeedTokensTemplate {
                 request_id: &request_id.0,
+                signed_in: true,
                 csrf: &csrf,
                 tokens: tokens.iter().map(token_view).collect(),
             },
@@ -244,6 +245,7 @@
             StatusCode::CREATED,
             &IssuedFeedTokenTemplate {
                 request_id,
+                signed_in: true,
                 token: &issued.token,
                 scope: scope_label(&issued.record.scope),
                 target: token_target(&issued.record),
@@ -354,6 +356,7 @@
 #[template(path = "feed-tokens.html")]
 struct FeedTokensTemplate<'a> {
     request_id: &'a str,
+    signed_in: bool,
     csrf: &'a str,
     tokens: Vec<FeedTokenView<'a>>,
 }
@@ -362,6 +365,7 @@
 #[template(path = "feed-token-issued.html")]
 struct IssuedFeedTokenTemplate<'a> {
     request_id: &'a str,
+    signed_in: bool,
     token: &'a str,
     scope: &'static str,
     target: String,
diff --git a/src/http/issues.rs b/src/http/issues.rs
index 2637940e6f76419c16a717099bda16be50af8dee..0c098a44c454793129e628ffe3e2c0da038c1993 100644
--- a/src/http/issues.rs
+++ b/src/http/issues.rs
@@ -77,6 +77,7 @@
                 StatusCode::OK,
                 &IssueListTemplate {
                     request_id: &request_id.0,
+                    signed_in: authenticated,
                     owner: &record.owner,
                     repository: &record.slug,
                     issues: issues
@@ -360,6 +361,7 @@
         StatusCode::OK,
         &IssueTemplate {
             request_id,
+            signed_in: !csrf.is_empty(),
             owner: &detail.repository.owner,
             repository: &detail.repository.slug,
             number: detail.issue.number,
@@ -508,6 +510,7 @@
 #[template(path = "issues.html")]
 struct IssueListTemplate<'a> {
     request_id: &'a str,
+    signed_in: bool,
     owner: &'a str,
     repository: &'a str,
     issues: Vec<IssueListItem<'a>>,
@@ -527,6 +530,7 @@
 #[template(path = "issue.html")]
 struct IssueTemplate<'a> {
     request_id: &'a str,
+    signed_in: bool,
     owner: &'a str,
     repository: &'a str,
     number: i64,
diff --git a/src/http/metadata_search.rs b/src/http/metadata_search.rs
index 223673f2bc368659f3ac7d42467db3f6da29b442..021d0b835bd75f9df24bfecad9b5fe21bb346677 100644
--- a/src/http/metadata_search.rs
+++ b/src/http/metadata_search.rs
@@ -21,8 +21,9 @@
     Extension(actor): Extension<RequestActor>,
     Query(query): Query<SearchQuery>,
 ) -> Response {
+    let signed_in = actor.0.is_some();
     let Some(value) = query.q else {
-        return search_page(&request_id.0, "", None);
+        return search_page(&request_id.0, "", None, signed_in);
     };
     let Some(service) = state.search.clone() else {
         return search_internal(&request_id.0);
@@ -33,7 +34,7 @@
     })
     .await;
     match result {
-        Ok(outcome) => search_page(&request_id.0, &value, Some(outcome)),
+        Ok(outcome) => search_page(&request_id.0, &value, Some(outcome), signed_in),
         Err(MetadataSearchError::InvalidQuery | MetadataSearchError::Auth(_)) => render_error(
             StatusCode::BAD_REQUEST,
             &request_id.0,
@@ -67,6 +68,7 @@
     request_id: &str,
     query: &str,
     outcome: Option<crate::search::MetadataSearchOutcome>,
+    signed_in: bool,
 ) -> Response {
     let searched = outcome.is_some();
     let (rows_scanned, bytes_scanned, truncated, results) = outcome.map_or_else(
@@ -85,6 +87,7 @@
         StatusCode::OK,
         &MetadataSearchTemplate {
             request_id,
+            signed_in,
             query,
             searched,
             rows_scanned,
@@ -131,6 +134,7 @@
 #[template(path = "metadata-search.html")]
 struct MetadataSearchTemplate<'a> {
     request_id: &'a str,
+    signed_in: bool,
     query: &'a str,
     searched: bool,
     rows_scanned: usize,
diff --git a/src/http/mod.rs b/src/http/mod.rs
index 0a4221f4d548bc43090fd28b9e4ee16a1c753918..f3af1d63554f97d8cf304b8c9ad1b36ee6fd0ec0 100644
--- a/src/http/mod.rs
+++ b/src/http/mod.rs
@@ -306,11 +306,7 @@
         .merge(watches::routes())
         .merge(issues::routes())
         .merge(pull_requests::routes())
-        .merge(public::routes())
-        .layer(middleware::from_fn_with_state(
-            state.clone(),
-            repository_actor,
-        ));
+        .merge(public::routes());
     Router::new()
         .route("/", get(home))
         .route("/healthz", get(health))
@@ -349,7 +345,9 @@
         )
         .route(
             "/logout",
-            axum::routing::post(logout).layer(DefaultBodyLimit::max(1024)),
+            get(logout_form)
+                .post(logout)
+                .layer(DefaultBodyLimit::max(1024)),
         )
         .route("/assets/style.css", get(style))
         .merge(feeds::routes())
@@ -357,6 +355,7 @@
         .fallback(not_found)
         .method_not_allowed_fallback(method_not_allowed)
         .layer(RequestBodyLimitLayer::new(max_request_bytes))
+        .layer(middleware::from_fn_with_state(state.clone(), request_actor))
         .layer(middleware::from_fn_with_state(state.clone(), request_guard))
         .layer(middleware::from_fn_with_state(
             state.clone(),
@@ -426,7 +425,7 @@
     state.login_attempts.allow(peer.0)
 }
 
-async fn repository_actor(
+async fn request_actor(
     State(state): State<WebState>,
     mut request: Request,
     next: Next,
@@ -442,8 +441,51 @@
     next.run(request).await
 }
 
-async fn home(Extension(request_id): Extension<RequestId>) -> Response {
-    render_home(StatusCode::OK, &request_id.0, "", "", "")
+async fn home(
+    State(state): State<WebState>,
+    Extension(request_id): Extension<RequestId>,
+    Extension(actor): Extension<RequestActor>,
+) -> Response {
+    let signed_in = actor.0.is_some();
+    let username = actor.0.clone().unwrap_or_default();
+    if state.repositories.is_none() {
+        return render_home(
+            StatusCode::OK,
+            &request_id.0,
+            HomePage {
+                owner: "",
+                repository: "",
+                error: "",
+                signed_in,
+                username: &username,
+                repositories: &[],
+            },
+        );
+    }
+    let result = repository_job(state, move |repositories| {
+        repositories.home(actor.0.as_deref())
+    })
+    .await;
+    match result {
+        Ok(repositories) => render_home(
+            StatusCode::OK,
+            &request_id.0,
+            HomePage {
+                owner: "",
+                repository: "",
+                error: "",
+                signed_in,
+                username: &username,
+                repositories: &repositories,
+            },
+        ),
+        Err(_) => render_error(
+            StatusCode::INTERNAL_SERVER_ERROR,
+            &request_id.0,
+            "Home error",
+            "The repository overview could not be completed.",
+        ),
+    }
 }
 
 async fn health(State(state): State<WebState>) -> Response {
@@ -466,6 +508,7 @@
 
 async fn go_to_repository(
     Extension(request_id): Extension<RequestId>,
+    Extension(actor): Extension<RequestActor>,
     RawQuery(query): RawQuery,
 ) -> Response {
     match parse_location_query(query.as_deref()) {
@@ -481,18 +524,35 @@
         Err(LocationQueryError { owner, repository }) => render_home(
             StatusCode::BAD_REQUEST,
             &request_id.0,
-            &owner,
-            &repository,
-            "Enter a valid lowercase owner and repository.",
+            HomePage {
+                owner: &owner,
+                repository: &repository,
+                error: "Enter a valid lowercase owner and repository.",
+                signed_in: actor.0.is_some(),
+                username: actor.0.as_deref().unwrap_or_default(),
+                repositories: &[],
+            },
         ),
     }
 }
 
-async fn signup_form(Extension(request_id): Extension<RequestId>) -> Response {
+async fn signup_form(
+    Extension(request_id): Extension<RequestId>,
+    Extension(actor): Extension<RequestActor>,
+) -> Response {
+    if actor.0.is_some() {
+        return account_redirect();
+    }
     render_account_form(&request_id.0, AccountFormKind::Signup, "", "")
 }
 
-async fn recovery_form(Extension(request_id): Extension<RequestId>) -> Response {
+async fn recovery_form(
+    Extension(request_id): Extension<RequestId>,
+    Extension(actor): Extension<RequestActor>,
+) -> Response {
+    if actor.0.is_some() {
+        return account_redirect();
+    }
     render_account_form(&request_id.0, AccountFormKind::Recovery, "", "")
 }
 
@@ -560,7 +620,13 @@
     account_result(result, &request_id.0, AccountFormKind::Recovery, &username)
 }
 
-async fn login_form(Extension(request_id): Extension<RequestId>) -> Response {
+async fn login_form(
+    Extension(request_id): Extension<RequestId>,
+    Extension(actor): Extension<RequestActor>,
+) -> Response {
+    if actor.0.is_some() {
+        return account_redirect();
+    }
     render(
         StatusCode::OK,
         &LoginTemplate {
@@ -568,6 +634,7 @@
             username: "",
             error: "",
             has_error: false,
+            signed_in: false,
         },
     )
 }
@@ -604,6 +671,7 @@
                     public_key: &challenge.public_key,
                     challenge: &challenge.challenge,
                     login_csrf: &challenge.login_csrf,
+                    signed_in: false,
                 },
             );
             append_cookie(
@@ -865,6 +933,7 @@
                 username: &session.username,
                 administrator: session.is_administrator,
                 csrf: &csrf,
+                signed_in: true,
             },
         ),
         Err(_) => login_redirect(true),
@@ -964,6 +1033,35 @@
 
 fn repository_form_error(request_id: &str, status: StatusCode, message: &str) -> Response {
     render_error(status, request_id, "Repository error", message)
+}
+
+async fn logout_form(
+    State(state): State<WebState>,
+    Extension(request_id): Extension<RequestId>,
+    headers: HeaderMap,
+) -> Response {
+    let Some(session_token) = cookie(&headers, SESSION_COOKIE) else {
+        return login_redirect(false);
+    };
+    let Some(csrf) = cookie(&headers, CSRF_COOKIE) else {
+        return login_redirect(true);
+    };
+    let csrf_for_auth = csrf.clone();
+    let result = login_job(state, move |login| {
+        login.authenticate(&session_token, Some(&csrf_for_auth))
+    })
+    .await;
+    match result {
+        Ok(_) => render(
+            StatusCode::OK,
+            &LogoutTemplate {
+                request_id: &request_id.0,
+                csrf: &csrf,
+                signed_in: true,
+            },
+        ),
+        Err(_) => login_redirect(true),
+    }
 }
 
 async fn logout(
@@ -1155,6 +1253,15 @@
     response
 }
 
+fn account_redirect() -> Response {
+    Response::builder()
+        .status(StatusCode::SEE_OTHER)
+        .header(header::LOCATION, "/account")
+        .header(header::CACHE_CONTROL, "no-store")
+        .body(Body::empty())
+        .expect("the account redirect is valid")
+}
+
 fn login_error(request_id: &str, username: &str, error: &str) -> Response {
     render(
         StatusCode::BAD_REQUEST,
@@ -1163,6 +1270,7 @@
             username,
             error,
             has_error: true,
+            signed_in: false,
         },
     )
 }
@@ -1222,6 +1330,7 @@
             &RecoveryCodeTemplate {
                 request_id,
                 recovery: &recovery,
+                signed_in: false,
             },
         ),
         Err(AccountError::Store(StoreError::UsernameUnavailable(_))) => render_account_error(
@@ -1310,6 +1419,7 @@
             error,
             has_error: !error.is_empty(),
             recovery: matches!(kind, AccountFormKind::Recovery),
+            signed_in: false,
         },
     )
 }
@@ -1355,8 +1465,8 @@
         "This page does not accept the request method.",
     );
     let allow = match uri.path() {
-        "/signup" | "/recover" | "/login" => "GET, HEAD, POST",
-        "/login/verify" | "/login/verify-file" | "/logout" => "POST",
+        "/signup" | "/recover" | "/login" | "/logout" => "GET, HEAD, POST",
+        "/login/verify" | "/login/verify-file" => "POST",
         path if path.ends_with("/git-upload-pack") => "POST",
         _ => "GET, HEAD",
     };
@@ -1446,23 +1556,38 @@
     }
 }
 
-fn render_home(
-    status: StatusCode,
-    request_id: &str,
-    owner: &str,
-    repository: &str,
-    error: &str,
-) -> Response {
+fn render_home(status: StatusCode, request_id: &str, page: HomePage<'_>) -> Response {
     render(
         status,
         &HomeTemplate {
             request_id,
-            owner,
-            repository,
-            error,
-            has_error: !error.is_empty(),
+            owner: page.owner,
+            repository: page.repository,
+            error: page.error,
+            has_error: !page.error.is_empty(),
+            signed_in: page.signed_in,
+            username: page.username,
+            repositories: page
+                .repositories
+                .iter()
+                .map(|repository| HomeRepositoryView {
+                    owner: &repository.owner,
+                    slug: &repository.slug,
+                    visibility: &repository.visibility,
+                    updated_at: repository.updated_at,
+                })
+                .collect(),
         },
     )
+}
+
+struct HomePage<'a> {
+    owner: &'a str,
+    repository: &'a str,
+    error: &'a str,
+    signed_in: bool,
+    username: &'a str,
+    repositories: &'a [crate::store::HomeRepositoryRecord],
 }
 
 fn render_error(status: StatusCode, request_id: &str, heading: &str, message: &str) -> Response {
@@ -1472,6 +1597,7 @@
             request_id,
             status: heading,
             message,
+            signed_in: false,
         },
     )
 }
@@ -1550,6 +1676,16 @@
     repository: &'a str,
     error: &'a str,
     has_error: bool,
+    signed_in: bool,
+    username: &'a str,
+    repositories: Vec<HomeRepositoryView<'a>>,
+}
+
+struct HomeRepositoryView<'a> {
+    owner: &'a str,
+    slug: &'a str,
+    visibility: &'a str,
+    updated_at: i64,
 }
 
 #[derive(Template)]
@@ -1558,6 +1694,7 @@
     request_id: &'a str,
     status: &'a str,
     message: &'a str,
+    signed_in: bool,
 }
 
 #[derive(Template)]
@@ -1568,6 +1705,7 @@
     error: &'a str,
     has_error: bool,
     recovery: bool,
+    signed_in: bool,
 }
 
 #[derive(Template)]
@@ -1575,6 +1713,7 @@
 struct RecoveryCodeTemplate<'a> {
     request_id: &'a str,
     recovery: &'a str,
+    signed_in: bool,
 }
 
 #[derive(Template)]
@@ -1584,6 +1723,7 @@
     username: &'a str,
     error: &'a str,
     has_error: bool,
+    signed_in: bool,
 }
 
 #[derive(Template)]
@@ -1594,6 +1734,7 @@
     public_key: &'a str,
     challenge: &'a str,
     login_csrf: &'a str,
+    signed_in: bool,
 }
 
 #[derive(Template)]
@@ -1603,6 +1744,15 @@
     username: &'a str,
     administrator: bool,
     csrf: &'a str,
+    signed_in: bool,
+}
+
+#[derive(Template)]
+#[template(path = "logout.html")]
+struct LogoutTemplate<'a> {
+    request_id: &'a str,
+    csrf: &'a str,
+    signed_in: bool,
 }
 
 #[derive(Debug, Error)]
diff --git a/src/http/public.rs b/src/http/public.rs
index 795cd40715cc1da364e3ea495e9e2accd733197f..5a8c593a84f909aaf9092abe55bf0f05276d01c1 100644
--- a/src/http/public.rs
+++ b/src/http/public.rs
@@ -445,6 +445,7 @@
     let Some(web) = state.public else {
         return route_error(RouteError::NotFound, &request_id.0);
     };
+    let signed_in = actor.0.is_some();
     let clone_urls = web.clone_urls(&path.owner, &path.repository);
     let result = web
         .read(
@@ -475,7 +476,7 @@
             },
         )
         .await;
-    render_page(result, &request_id.0)
+    render_page(result, &request_id.0, signed_in)
 }
 
 async fn refs(
@@ -487,6 +488,7 @@
     let Some(web) = state.public else {
         return route_error(RouteError::NotFound, &request_id.0);
     };
+    let signed_in = actor.0.is_some();
     let result = web
         .read(actor.0, path.owner, path.repository, |record, service| {
             let cancellation = ReadCancellation::default();
@@ -494,7 +496,7 @@
             Ok(RepositoryPage::refs(record, references))
         })
         .await;
-    render_page(result, &request_id.0)
+    render_page(result, &request_id.0, signed_in)
 }
 
 async fn search(
@@ -512,6 +514,7 @@
     }) {
         return route_error(RouteError::InvalidRequest, &request_id.0);
     }
+    let signed_in = actor.0.is_some();
     let result = web
         .read(
             actor.0,
@@ -538,7 +541,7 @@
             },
         )
         .await;
-    render_page(result, &request_id.0)
+    render_page(result, &request_id.0, signed_in)
 }
 
 async fn commit(
@@ -554,6 +557,7 @@
         Ok(id) => id,
         Err(error) => return route_error(error, &request_id.0),
     };
+    let signed_in = actor.0.is_some();
     let result = web
         .read(
             actor.0,
@@ -567,7 +571,7 @@
             },
         )
         .await;
-    render_page(result, &request_id.0)
+    render_page(result, &request_id.0, signed_in)
 }
 
 async fn diff(
@@ -583,6 +587,7 @@
         (Ok(old), Ok(new)) => (old, new),
         _ => return route_error(RouteError::NotFound, &request_id.0),
     };
+    let signed_in = actor.0.is_some();
     let result = web
         .read(
             actor.0,
@@ -597,7 +602,7 @@
             },
         )
         .await;
-    render_page(result, &request_id.0)
+    render_page(result, &request_id.0, signed_in)
 }
 
 async fn tree_root(
@@ -636,6 +641,7 @@
         Ok(id) => id,
         Err(error) => return route_error(error, &request_id.0),
     };
+    let signed_in = actor.0.is_some();
     let result = web
         .read(
             actor.0,
@@ -649,7 +655,7 @@
             },
         )
         .await;
-    render_page(result, &request_id.0)
+    render_page(result, &request_id.0, signed_in)
 }
 
 async fn blob(
@@ -669,6 +675,7 @@
         Ok(id) => id,
         Err(error) => return route_error(error, &request_id.0),
     };
+    let signed_in = actor.0.is_some();
     let result = web
         .read(
             actor.0,
@@ -682,7 +689,7 @@
             },
         )
         .await;
-    render_page(result, &request_id.0)
+    render_page(result, &request_id.0, signed_in)
 }
 
 async fn raw(
@@ -751,6 +758,7 @@
         Ok(id) => id,
         Err(error) => return route_error(error, &request_id.0),
     };
+    let signed_in = actor.0.is_some();
     let result = web
         .read(
             actor.0,
@@ -767,7 +775,7 @@
             },
         )
         .await;
-    render_page(result, &request_id.0)
+    render_page(result, &request_id.0, signed_in)
 }
 
 async fn archive(
@@ -896,10 +904,15 @@
     }
 }
 
-fn render_page(result: Result<RepositoryPage, RouteError>, request_id: &str) -> Response {
+fn render_page(
+    result: Result<RepositoryPage, RouteError>,
+    request_id: &str,
+    signed_in: bool,
+) -> Response {
     match result {
         Ok(mut page) => {
             page.request_id = request_id.to_owned();
+            page.signed_in = signed_in;
             match page.render() {
                 Ok(body) => Response::builder()
                     .status(StatusCode::OK)
@@ -1232,6 +1245,7 @@
 #[template(path = "repository.html")]
 struct RepositoryPage {
     request_id: String,
+    signed_in: bool,
     owner: String,
     repository: String,
     object_format: String,
@@ -1271,6 +1285,7 @@
     fn base(record: RepositoryRecord, page_kind: &'static str, title: String) -> Self {
         Self {
             request_id: String::new(),
+            signed_in: false,
             owner: record.owner,
             repository: record.slug,
             object_format: record.object_format,
diff --git a/src/http/pull_requests.rs b/src/http/pull_requests.rs
index 7258de976227e94469f41653a91ab26fed4bb7ed..d8c57d419770461cf14ac285619741b100e783a9 100644
--- a/src/http/pull_requests.rs
+++ b/src/http/pull_requests.rs
@@ -55,6 +55,7 @@
     };
     let owner = path.owner.clone();
     let repository = path.repository.clone();
+    let signed_in = actor.0.is_some();
     let result = job(state, move || {
         service.list(&owner, &repository, actor.0.as_deref())
     })
@@ -66,6 +67,7 @@
                 StatusCode::OK,
                 &PullRequestListTemplate {
                     request_id: &request_id.0,
+                    signed_in,
                     owner: &record.owner,
                     repository: &record.slug,
                     pull_requests: pull_requests
@@ -100,6 +102,7 @@
     };
     let owner = path.owner.clone();
     let repository = path.repository.clone();
+    let signed_in = actor.0.is_some();
     let result = job(state, move || {
         service.compare(
             &owner,
@@ -119,6 +122,7 @@
                 StatusCode::OK,
                 &PullRequestTemplate {
                     request_id: &request_id.0,
+                    signed_in,
                     owner: &detail.repository.owner,
                     repository: &detail.repository.slug,
                     pull_request,
@@ -505,6 +509,7 @@
 #[template(path = "pull_requests.html")]
 struct PullRequestListTemplate<'a> {
     request_id: &'a str,
+    signed_in: bool,
     owner: &'a str,
     repository: &'a str,
     pull_requests: Vec<PullRequestListItem<'a>>,
@@ -524,6 +529,7 @@
 #[template(path = "pull_request.html")]
 struct PullRequestTemplate<'a> {
     request_id: &'a str,
+    signed_in: bool,
     owner: &'a str,
     repository: &'a str,
     pull_request: &'a crate::store::PullRequestRecord,
diff --git a/src/http/watches.rs b/src/http/watches.rs
index 7400db49440eb9c0ab94c0c06117779b6a8b2d51..012a82e0512de901f1e0b8cbe163d6f5f3dc78d3 100644
--- a/src/http/watches.rs
+++ b/src/http/watches.rs
@@ -59,6 +59,7 @@
                 StatusCode::OK,
                 &WatchTemplate {
                     request_id: &request_id.0,
+                    signed_in: authenticated,
                     owner: &record.owner,
                     repository: &record.slug,
                     csrf: &csrf,
@@ -198,6 +199,7 @@
 #[template(path = "watch.html")]
 struct WatchTemplate<'a> {
     request_id: &'a str,
+    signed_in: bool,
     owner: &'a str,
     repository: &'a str,
     csrf: &'a str,
diff --git a/src/repository.rs b/src/repository.rs
index b17083f5db657090b6a65002b06b696174b9c3ea..bfd8fb2bf008744122aad3457490862f5591606a 100644
--- a/src/repository.rs
+++ b/src/repository.rs
@@ -11,8 +11,11 @@
 use crate::git::repository::{GitRepository, GitRepositoryError};
 use crate::maintenance::MaintenanceGate;
 use crate::store::{
-    NewAuditEvent, NewRepository, RepositoryOrigin, RepositoryRecord, Store, StoreError,
+    HomeRepositoryRecord, NewAuditEvent, NewRepository, RepositoryOrigin, RepositoryRecord, Store,
+    StoreError,
 };
+
+const HOME_REPOSITORY_LIMIT: usize = 20;
 
 #[derive(Clone)]
 pub(crate) struct RepositoryService {
@@ -46,6 +49,13 @@
         correlation_id: &str,
     ) -> Result<RepositoryRecord, RepositoryServiceError> {
         self.create(actor, actor, slug, object_format, correlation_id)
+    }
+
+    pub(crate) fn home(
+        &self,
+        owner: Option<&str>,
+    ) -> Result<Vec<HomeRepositoryRecord>, RepositoryServiceError> {
+        Ok(Store::open(&self.database)?.home_repositories(owner, HOME_REPOSITORY_LIMIT)?)
     }
 
     pub(crate) fn create_for_administrator(
diff --git a/src/ssh.rs b/src/ssh.rs
index 5406871dd4515bb8a1eb70bf4eda5a791d1d6d19..5af100db1f04e4aaa3a877eeb5d82696316d0f58 100644
--- a/src/ssh.rs
+++ b/src/ssh.rs
@@ -28,6 +28,7 @@
 use crate::telemetry::Telemetry;
 
 const VERSION_COMMAND: &[u8] = b"tit --version";
+const HELP_COMMAND: &[u8] = b"help";
 const GIT_PROTOCOL_VARIABLE: &str = "GIT_PROTOCOL";
 const MAX_RECEIVE_PACK_BYTES: u64 = 128 * 1024 * 1024;
 const MAX_REPOSITORY_COMMAND_BYTES: usize = 512;
@@ -42,6 +43,18 @@
 const ISSUE_CREATE_USAGE: &str = "issue create OWNER/REPOSITORY [--output human|json]";
 const PULL_REQUEST_CHECKOUT_USAGE: &str =
     "pr checkout OWNER/REPOSITORY NUMBER [--output human|json]";
+const HELP_TEXT: &str = "\
+Available tit SSH commands:
+  help
+  tit --version
+  repo create NAME [--object-format sha1|sha256] [--output human|json]
+  issue list OWNER/REPOSITORY [--output human|json]
+  issue create OWNER/REPOSITORY [--output human|json]
+  pr checkout OWNER/REPOSITORY NUMBER [--output human|json]
+
+Git clients can also use git-upload-pack and git-receive-pack.
+";
+const HELP_GUIDANCE: &str = "Use 'help' to list the available commands.";
 
 pub(crate) struct RunningSshServer {
     address: SocketAddr,
@@ -580,6 +593,11 @@
             session.exit_status_request(channel, 0)?;
             session.eof(channel)?;
             session.close(channel)?;
+        } else if command == HELP_COMMAND {
+            self.audit.accepted_exec.fetch_add(1, Ordering::Relaxed);
+            session.channel_success(channel)?;
+            session.data(channel, HELP_TEXT.as_bytes())?;
+            finish_git_channel(channel, 0, session)?;
         } else if is_repository_command(command) {
             self.audit.accepted_exec.fetch_add(1, Ordering::Relaxed);
             session.channel_success(channel)?;
@@ -702,9 +720,25 @@
                     }
                 }
             } else {
-                self.audit.rejected_exec.fetch_add(1, Ordering::Relaxed);
-                session.channel_failure(channel)?;
-                session.close(channel)?;
+                self.audit.accepted_exec.fetch_add(1, Ordering::Relaxed);
+                session.channel_success(channel)?;
+                if requests_json(command) {
+                    session.data(
+                        channel,
+                        json_line(serde_json::json!({
+                            "version": 1,
+                            "status": "error",
+                            "error": { "code": "invalid-command" },
+                        })),
+                    )?;
+                } else {
+                    session.extended_data(
+                        channel,
+                        1,
+                        format!("tit: The command is not valid.\n{HELP_GUIDANCE}\n").into_bytes(),
+                    )?;
+                }
+                finish_git_channel(channel, 1, session)?;
             }
         }
         Ok(())
@@ -1083,7 +1117,9 @@
 
 fn repository_command_error_message(error: &RepositoryCommandError) -> String {
     match repository_command_error_code(error) {
-        "invalid-command" => format!("usage: {REPOSITORY_CREATE_USAGE}"),
+        "invalid-command" => {
+            format!("usage: {REPOSITORY_CREATE_USAGE}\n{HELP_GUIDANCE}")
+        }
         "repository-exists" => "A repository with this name already exists.".to_owned(),
         "invalid-name" => "The repository name is not valid.".to_owned(),
         "account-unavailable" => "The account is not active.".to_owned(),
@@ -1430,7 +1466,9 @@
 
 fn issue_command_error_message(error: &IssueCommandError) -> String {
     match issue_command_error_code(error) {
-        "invalid-command" => format!("usage: {ISSUE_LIST_USAGE} or {ISSUE_CREATE_USAGE}"),
+        "invalid-command" => {
+            format!("usage: {ISSUE_LIST_USAGE} or {ISSUE_CREATE_USAGE}\n{HELP_GUIDANCE}")
+        }
         "invalid-input" => {
             "The first input line must be a valid title. The remaining input is the body."
                 .to_owned()
@@ -1604,7 +1642,9 @@
 
 fn pull_request_command_error_message(error: &PullRequestCommandError) -> String {
     match pull_request_command_error_code(error) {
-        "invalid-command" => format!("usage: {PULL_REQUEST_CHECKOUT_USAGE}"),
+        "invalid-command" => {
+            format!("usage: {PULL_REQUEST_CHECKOUT_USAGE}\n{HELP_GUIDANCE}")
+        }
         "pull-request-unavailable" => "The pull request is not available.".to_owned(),
         "invalid-target" => "The pull-request target is not valid.".to_owned(),
         "service-unavailable" => "The pull-request service is not available.".to_owned(),
diff --git a/src/store/mod.rs b/src/store/mod.rs
index 82e53b0820df06c26a32a6687a695260b4291899..3dd39f7efb26d22d4ce148083e5ea4d17fc68766 100644
--- a/src/store/mod.rs
+++ b/src/store/mod.rs
@@ -3190,6 +3190,39 @@
             .map_err(Into::into)
     }
 
+    pub(crate) fn home_repositories(
+        &self,
+        owner: Option<&str>,
+        limit: usize,
+    ) -> Result<Vec<HomeRepositoryRecord>, StoreError> {
+        let limit = i64::try_from(limit).expect("the home repository limit fits in SQLite");
+        let mut statement = self.connection.prepare(
+            "SELECT account.username, repository.slug, repository.visibility,
+                    COALESCE(MAX(repository_event.created_at), repository.created_at)
+             FROM repository
+             JOIN account ON account.id = repository.owner_account_id
+             LEFT JOIN repository_event ON repository_event.repository_id = repository.id
+             WHERE repository.state = 'active'
+               AND ((?1 IS NULL AND repository.visibility = 'public')
+                    OR account.username = ?1)
+             GROUP BY repository.id, account.username, repository.slug,
+                      repository.visibility, repository.created_at
+             ORDER BY 4 DESC, account.username, repository.slug
+             LIMIT ?2",
+        )?;
+        statement
+            .query_map(rusqlite::params![owner, limit], |row| {
+                Ok(HomeRepositoryRecord {
+                    owner: row.get(0)?,
+                    slug: row.get(1)?,
+                    visibility: row.get(2)?,
+                    updated_at: row.get(3)?,
+                })
+            })?
+            .collect::<Result<Vec<_>, _>>()
+            .map_err(Into::into)
+    }
+
     #[allow(
         dead_code,
         reason = "some integration tests import storage without public event pages"
@@ -3407,6 +3440,14 @@
     pub(crate) object_format: String,
     pub(crate) created_at: i64,
     pub(crate) archived_at: Option<i64>,
+}
+
+#[derive(Debug, Eq, PartialEq)]
+pub(crate) struct HomeRepositoryRecord {
+    pub(crate) owner: String,
+    pub(crate) slug: String,
+    pub(crate) visibility: String,
+    pub(crate) updated_at: i64,
 }
 
 #[derive(Debug, Serialize)]
diff --git a/templates/base.html b/templates/base.html
index 250279dbab5823374566312a9f8374a37a4fec66..f7808adf24b9cbafdc3ad8d12308c252cb295964 100644
--- a/templates/base.html
+++ b/templates/base.html
@@ -13,9 +13,14 @@
     <nav aria-label="Primary">
       <a href="/">Home</a>
       <a href="/search">Search</a>
+{% if signed_in %}
+      <a href="/account">Account</a>
+      <a href="/logout">Log out</a>
+{% else %}
       <a href="/signup">Create account</a>
       <a href="/recover">Recover account</a>
       <a href="/login">Log in</a>
+{% endif %}
     </nav>
   </header>
   <main id="main">
diff --git a/templates/home.html b/templates/home.html
index 95483a140113aa6b1e7cccbae219dfadbec36584..da0d81525b540e037d170df73b1d7850efa12640 100644
--- a/templates/home.html
+++ b/templates/home.html
@@ -1,7 +1,23 @@
 {% extends "base.html" %}
-{% block title %}Repositories · tit{% endblock %}
+{% block title %}{% if signed_in %}{{ username }}{% else %}Repositories{% endif %} · tit{% endblock %}
 {% block content %}
-  <h1>Repositories</h1>
+{% if signed_in %}
+  <h1>{{ username }}</h1>
+  <p><a href="/account">Open your account profile</a>.</p>
+  <h2>Your repositories</h2>
+{% else %}
+  <h1>Recently updated public repositories</h1>
+{% endif %}
+{% if repositories.is_empty() %}
+  <p>No repositories are available.</p>
+{% else %}
+  <ul>
+  {% for item in repositories %}
+    <li><a href="/{{ item.owner }}/{{ item.slug }}">{{ item.owner }}/{{ item.slug }}</a> · {{ item.visibility }} · updated <time>{{ item.updated_at }}</time></li>
+  {% endfor %}
+  </ul>
+{% endif %}
+  <h2>Open a repository</h2>
   <p>Enter an owner and a repository to open its public page.</p>
 {% if has_error %}
   <p class="error" role="alert">{{ error }}</p>
diff --git a/templates/issue.html b/templates/issue.html
index ba864efceb8193de0facd26f1b6d91a614e11dc5..3494e7a6aed33d76640db5784b0adfbbdfe9dbe3 100644
--- a/templates/issue.html
+++ b/templates/issue.html
@@ -3,15 +3,7 @@
 {% block content %}
   <header class="repository-header">
     <h1><a href="/{{ owner }}/{{ repository }}">{{ owner }}/{{ repository }}</a></h1>
-    <nav aria-label="Repository">
-      <a href="/{{ owner }}/{{ repository }}">Summary</a>
-      <a href="/{{ owner }}/{{ repository }}/refs">Refs</a>
-      <a href="/{{ owner }}/{{ repository }}/issues">Issues</a>
-      <a href="/{{ owner }}/{{ repository }}/pulls">Pull requests</a>
-      <a href="/{{ owner }}/{{ repository }}/watch">Watch</a>
-      <a href="/{{ owner }}/{{ repository }}/atom.xml">Atom</a>
-      <a href="/{{ owner }}/{{ repository }}/rss.xml">RSS</a>
-    </nav>
+{% include "repository-nav.html" %}
   </header>
 
   <article>
diff --git a/templates/issues.html b/templates/issues.html
index eac929adcef64630a986c0a11501cf88c6ec897b..eeb63fd6779e7f82d65c6b29038eb197cb28c48c 100644
--- a/templates/issues.html
+++ b/templates/issues.html
@@ -3,20 +3,11 @@
 {% block content %}
   <header class="repository-header">
     <h1><a href="/{{ owner }}/{{ repository }}">{{ owner }}/{{ repository }}</a></h1>
-    <nav aria-label="Repository">
-      <a href="/{{ owner }}/{{ repository }}">Summary</a>
-      <a href="/{{ owner }}/{{ repository }}/refs">Refs</a>
-      <a href="/{{ owner }}/{{ repository }}/issues">Issues</a>
-      <a href="/{{ owner }}/{{ repository }}/pulls">Pull requests</a>
-      <a href="/{{ owner }}/{{ repository }}/watch">Watch</a>
-      <a href="/{{ owner }}/{{ repository }}/issues/atom.xml">Issue Atom</a>
-      <a href="/{{ owner }}/{{ repository }}/issues/rss.xml">Issue RSS</a>
-      <a href="/{{ owner }}/{{ repository }}/atom.xml">Activity Atom</a>
-      <a href="/{{ owner }}/{{ repository }}/rss.xml">Activity RSS</a>
-    </nav>
+{% include "repository-nav.html" %}
   </header>
 
   <h2>Issues</h2>
+  <p><a href="/{{ owner }}/{{ repository }}/issues/atom.xml">Issue Atom</a> · <a href="/{{ owner }}/{{ repository }}/issues/rss.xml">Issue RSS</a></p>
 {% if issues.is_empty() %}
   <p>This repository has no issues.</p>
 {% else %}
diff --git a/templates/logout.html b/templates/logout.html
new file mode 100644
index 0000000000000000000000000000000000000000..b915f353613d9ded748c02a949b5b627de3ce105
--- /dev/null
+++ b/templates/logout.html
@@ -1,0 +1,10 @@
+{% extends "base.html" %}
+{% block title %}Log out · tit{% endblock %}
+{% block content %}
+  <h1>Log out</h1>
+  <p>Log out of all browser sessions for this account?</p>
+  <form method="post" action="/logout">
+    <input type="hidden" name="csrf" value="{{ csrf }}">
+    <button type="submit">Log out</button>
+  </form>
+{% endblock %}
diff --git a/templates/pull_request.html b/templates/pull_request.html
index a4c51e881f4be771967fce91de60c8d40df8aa26..ae8ff3c78b58a53359d2d35ed807e010ead55ddb 100644
--- a/templates/pull_request.html
+++ b/templates/pull_request.html
@@ -3,13 +3,7 @@
 {% block content %}
   <header class="repository-header">
     <h1><a href="/{{ owner }}/{{ repository }}">{{ owner }}/{{ repository }}</a></h1>
-    <nav aria-label="Repository">
-      <a href="/{{ owner }}/{{ repository }}">Summary</a>
-      <a href="/{{ owner }}/{{ repository }}/refs">Refs</a>
-      <a href="/{{ owner }}/{{ repository }}/issues">Issues</a>
-      <a href="/{{ owner }}/{{ repository }}/pulls">Pull requests</a>
-      <a href="/{{ owner }}/{{ repository }}/watch">Watch</a>
-    </nav>
+{% include "repository-nav.html" %}
   </header>
 
   <article>
diff --git a/templates/pull_requests.html b/templates/pull_requests.html
index 8a8f2a52666adf2a89c384ea5b9f75d3d143c538..15154b9a98d2673109eee73285a46415f1530cd6 100644
--- a/templates/pull_requests.html
+++ b/templates/pull_requests.html
@@ -3,13 +3,7 @@
 {% block content %}
   <header class="repository-header">
     <h1><a href="/{{ owner }}/{{ repository }}">{{ owner }}/{{ repository }}</a></h1>
-    <nav aria-label="Repository">
-      <a href="/{{ owner }}/{{ repository }}">Summary</a>
-      <a href="/{{ owner }}/{{ repository }}/refs">Refs</a>
-      <a href="/{{ owner }}/{{ repository }}/issues">Issues</a>
-      <a href="/{{ owner }}/{{ repository }}/pulls">Pull requests</a>
-      <a href="/{{ owner }}/{{ repository }}/watch">Watch</a>
-    </nav>
+{% include "repository-nav.html" %}
   </header>
 
   <h2>Pull requests</h2>
diff --git a/templates/repository-nav.html b/templates/repository-nav.html
new file mode 100644
index 0000000000000000000000000000000000000000..22e8d70cf83761229ab8f1a69d66ac9c8f1fc6b4
--- /dev/null
+++ b/templates/repository-nav.html
@@ -1,0 +1,10 @@
+    <nav aria-label="Repository">
+      <a href="/{{ owner }}/{{ repository }}">Summary</a>
+      <a href="/{{ owner }}/{{ repository }}/refs">Refs</a>
+      <a href="/{{ owner }}/{{ repository }}/issues">Issues</a>
+      <a href="/{{ owner }}/{{ repository }}/pulls">Pull requests</a>
+      <a href="/{{ owner }}/{{ repository }}/watch">Watch</a>
+      <a href="/{{ owner }}/{{ repository }}/atom.xml">Atom</a>
+      <a href="/{{ owner }}/{{ repository }}/rss.xml">RSS</a>
+      <a href="/{{ owner }}/{{ repository }}/search">Search</a>
+    </nav>
diff --git a/templates/repository.html b/templates/repository.html
index 3ac49b6e683a3a351211e5ea7ac03e114428fffe..c893168b491c1bf44d6ef59e8cede7be0161c2ab 100644
--- a/templates/repository.html
+++ b/templates/repository.html
@@ -3,22 +3,12 @@
 {% block content %}
   <header class="repository-header">
     <h1><a href="/{{ owner }}/{{ repository }}">{{ owner }}/{{ repository }}</a></h1>
-    <nav aria-label="Repository">
-      <a href="/{{ owner }}/{{ repository }}">Summary</a>
-      <a href="/{{ owner }}/{{ repository }}/refs">Refs</a>
-      <a href="/{{ owner }}/{{ repository }}/issues">Issues</a>
-      <a href="/{{ owner }}/{{ repository }}/pulls">Pull requests</a>
-      <a href="/{{ owner }}/{{ repository }}/watch">Watch</a>
-      <a href="/{{ owner }}/{{ repository }}/atom.xml">Atom</a>
-      <a href="/{{ owner }}/{{ repository }}/rss.xml">RSS</a>
-      <a href="/{{ owner }}/{{ repository }}/search">Search</a>
-{% if has_head %}
-      <a href="/{{ owner }}/{{ repository }}/tree/{{ commit_id }}">Tree</a>
-      <a href="/{{ owner }}/{{ repository }}/commit/{{ commit_id }}">Commit</a>
-      <a href="/{{ owner }}/{{ repository }}/archive/{{ commit_id }}.tar">Archive</a>
-{% endif %}
-    </nav>
+{% include "repository-nav.html" %}
   </header>
+
+{% if has_head %}
+  <p><a href="/{{ owner }}/{{ repository }}/tree/{{ commit_id }}">Browse tree</a> · <a href="/{{ owner }}/{{ repository }}/commit/{{ commit_id }}">Show commit</a> · <a href="/{{ owner }}/{{ repository }}/archive/{{ commit_id }}.tar">Download archive</a></p>
+{% endif %}
 
 {% if page_kind == "summary" %}
   <section aria-labelledby="clone-heading">
diff --git a/templates/watch.html b/templates/watch.html
index 149f5b9092c3d8aa11784535f44c538eebc8dd1b..895db9ce7cae563ea191aec7fe86ed02cd9798dc 100644
--- a/templates/watch.html
+++ b/templates/watch.html
@@ -3,15 +3,7 @@
 {% block content %}
   <header class="repository-header">
     <h1><a href="/{{ owner }}/{{ repository }}">{{ owner }}/{{ repository }}</a></h1>
-    <nav aria-label="Repository">
-      <a href="/{{ owner }}/{{ repository }}">Summary</a>
-      <a href="/{{ owner }}/{{ repository }}/refs">Refs</a>
-      <a href="/{{ owner }}/{{ repository }}/issues">Issues</a>
-      <a href="/{{ owner }}/{{ repository }}/pulls">Pull requests</a>
-      <a href="/{{ owner }}/{{ repository }}/watch">Watch</a>
-      <a href="/{{ owner }}/{{ repository }}/atom.xml">Atom</a>
-      <a href="/{{ owner }}/{{ repository }}/rss.xml">RSS</a>
-    </nav>
+{% include "repository-nav.html" %}
   </header>
 
   <h2>Watch preferences</h2>
diff --git a/tests/public_routes.rs b/tests/public_routes.rs
index 176d62bd750873cf3a6475c752279e2683633559..899df2e2e7d7891c382afe197eb3fc4b94d89f76 100644
--- a/tests/public_routes.rs
+++ b/tests/public_routes.rs
@@ -109,6 +109,7 @@
         let summary = request(server.address(), "GET", "/alice/example", &[], &[]);
         assert_eq!(summary.status, 200);
         assert_html_policy(&summary);
+        assert_repository_navigation(&summary, "alice", "example");
         let summary_text = summary.text();
         assert!(summary_text.contains("<h1><a href=\"/alice/example\">alice/example</a></h1>"));
         assert!(summary_text.contains("https://tit.example/alice/example"));
@@ -127,7 +128,6 @@
         assert!(summary_text.contains("/alice/example/atom.xml"));
         assert!(summary_text.contains("/alice/example/rss.xml"));
         assert!(summary_text.contains("/alice/example/search"));
-
         let mut feed_entry_ids = Vec::new();
         for (path, content_type) in [
             (
@@ -631,6 +631,7 @@
 
     let anonymous = request(server.address(), "GET", "/alice/example/issues", &[], &[]);
     assert_eq!(anonymous.status, 200);
+    assert_repository_navigation(&anonymous, "alice", "example");
     assert!(anonymous.text().contains("This repository has no issues."));
     assert!(!anonymous.text().contains("Create an issue</h2>"));
 
@@ -750,6 +751,7 @@
     let anonymous_pull_requests =
         request(server.address(), "GET", "/alice/example/pulls", &[], &[]);
     assert_eq!(anonymous_pull_requests.status, 200);
+    assert_repository_navigation(&anonymous_pull_requests, "alice", "example");
     assert!(
         anonymous_pull_requests
             .text()
@@ -990,6 +992,7 @@
 
     let anonymous_watch = request(server.address(), "GET", "/alice/example/watch", &[], &[]);
     assert_eq!(anonymous_watch.status, 200);
+    assert_repository_navigation(&anonymous_watch, "alice", "example");
     assert!(
         anonymous_watch
             .text()
@@ -1510,6 +1513,26 @@
     assert_eq!(response.header("referrer-policy"), "no-referrer");
     assert_eq!(response.header("cache-control"), "no-store");
     assert_eq!(response.header("x-request-id").len(), 32);
+}
+
+fn assert_repository_navigation(response: &HttpResponse, owner: &str, repository: &str) {
+    let text = response.text();
+    for suffix in [
+        "",
+        "/refs",
+        "/issues",
+        "/pulls",
+        "/watch",
+        "/atom.xml",
+        "/rss.xml",
+        "/search",
+    ] {
+        let link = format!("/{owner}/{repository}{suffix}");
+        assert!(
+            text.contains(&format!("href=\"{link}\"")),
+            "repository navigation is missing {link}"
+        );
+    }
 }
 
 struct HttpResponse {
diff --git a/tests/serve.rs b/tests/serve.rs
index aa9ce9ead824052cf011ab76f7e5d8d619fac673..60d47e121da08ee7eec97356283e6fbe8e7ba141 100644
--- a/tests/serve.rs
+++ b/tests/serve.rs
@@ -4,6 +4,7 @@
 )]
 mod support;
 
+use std::env;
 use std::fs;
 use std::io::{Read, Write};
 use std::net::{SocketAddr, TcpStream};
@@ -79,7 +80,7 @@
 
     let backup_directory = TempDir::new().expect("create a backup directory");
     let backup = backup_directory.path().join("instance.tar");
-    let backup_output = Command::new(env!("CARGO_BIN_EXE_tit"))
+    let backup_output = Command::new(tit_binary())
         .args([
             "--config",
             config.to_str().expect("a UTF-8 configuration path"),
@@ -106,7 +107,7 @@
     let restored = TempDir::new().expect("create a restore target");
     fs::set_permissions(restored.path(), fs::Permissions::from_mode(0o700))
         .expect("make the restore target private");
-    let restore_output = Command::new(env!("CARGO_BIN_EXE_tit"))
+    let restore_output = Command::new(tit_binary())
         .args([
             "restore",
             backup.to_str().expect("a UTF-8 backup path"),
@@ -144,7 +145,7 @@
     assert!(restored_readme.status.success());
     assert_eq!(restored_readme.stdout, b"serve fixture\n");
 
-    let second = Command::new(env!("CARGO_BIN_EXE_tit"))
+    let second = Command::new(tit_binary())
         .args([
             "--config",
             config.to_str().expect("a UTF-8 configuration path"),
@@ -164,6 +165,17 @@
             & 0o777,
         0o600
     );
+
+    let anonymous_home = http_get(http, "/");
+    assert!(
+        anonymous_home.contains("Recently updated public repositories"),
+        "anonymous home response:\n{anonymous_home}"
+    );
+    assert!(anonymous_home.contains(">alice/example</a>"));
+    assert!(anonymous_home.contains("<a href=\"/signup\">Create account</a>"));
+    assert!(anonymous_home.contains("<a href=\"/recover\">Recover account</a>"));
+    assert!(anonymous_home.contains("<a href=\"/login\">Log in</a>"));
+    assert!(!anonymous_home.contains("<a href=\"/account\">Account</a>"));
 
     let login_challenge = http_form(
         http,
@@ -213,8 +225,31 @@
     let account = http_get_with_headers(http, "/account", &[("Cookie", &cookies)]);
     assert!(account.starts_with("HTTP/1.1 200"));
     assert!(account.contains("<dd>alice</dd>"));
+    assert!(account.contains("<a href=\"/account\">Account</a>"));
+    assert!(account.contains("<a href=\"/logout\">Log out</a>"));
+    assert!(!account.contains("<a href=\"/login\">Log in</a>"));
+    assert!(!account.contains("<a href=\"/signup\">Create account</a>"));
+    assert!(!account.contains("<a href=\"/recover\">Recover account</a>"));
     assert!(account.contains("action=\"/account/repositories\""));
+    for path in ["/login", "/signup", "/recover"] {
+        let response = http_get_with_headers(http, path, &[("Cookie", &cookies)]);
+        assert!(response.starts_with("HTTP/1.1 303"));
+        assert_eq!(response_header(&response, "location"), "/account");
+    }
+    let signed_in_home = http_get_with_headers(http, "/", &[("Cookie", &cookies)]);
+    assert!(signed_in_home.contains("<h1>alice</h1>"));
+    assert!(signed_in_home.contains("<h2>Your repositories</h2>"));
+    assert!(signed_in_home.contains(">alice/example</a>"));
+    assert!(signed_in_home.contains("<a href=\"/account\">Account</a>"));
+    assert!(signed_in_home.contains("<a href=\"/logout\">Log out</a>"));
+    assert!(!signed_in_home.contains("<a href=\"/signup\">Create account</a>"));
+    assert!(!signed_in_home.contains("<a href=\"/recover\">Recover account</a>"));
+    assert!(!signed_in_home.contains("<a href=\"/login\">Log in</a>"));
     let csrf = cookie_value(&cookies, "tit-csrf");
+    let logout_page = http_get_with_headers(http, "/logout", &[("Cookie", &cookies)]);
+    assert!(logout_page.starts_with("HTTP/1.1 200"));
+    assert!(logout_page.contains("<form method=\"post\" action=\"/logout\">"));
+    assert!(logout_page.contains(&format!("name=\"csrf\" value=\"{csrf}\"")));
     let rejected_repository = http_form_with_headers(
         http,
         "/account/repositories",
@@ -326,7 +361,7 @@
         .expect("make the repository public");
     drop(database);
 
-    let invitation_output = Command::new(env!("CARGO_BIN_EXE_tit"))
+    let invitation_output = Command::new(tit_binary())
         .args([
             "--config",
             config.to_str().expect("a UTF-8 configuration path"),
@@ -485,7 +520,7 @@
         b"serve fixture\n"
     );
 
-    let locked = Command::new(env!("CARGO_BIN_EXE_tit"))
+    let locked = Command::new(tit_binary())
         .args([
             "--config",
             config.to_str().expect("a UTF-8 configuration path"),
@@ -1335,7 +1370,7 @@
 }
 
 fn spawn_server(config: &Path) -> ChildGuard {
-    let child = Command::new(env!("CARGO_BIN_EXE_tit"))
+    let child = Command::new(tit_binary())
         .args([
             "--config",
             config.to_str().expect("a UTF-8 configuration path"),
@@ -1391,9 +1426,9 @@
 
 fn command<const N: usize>(directory: &Path, arguments: [&str; N]) {
     let executable = if matches!(arguments.first(), Some(&"--config")) {
-        env!("CARGO_BIN_EXE_tit")
+        tit_binary()
     } else {
-        "git"
+        "git".into()
     };
     let output = Command::new(executable)
         .args(arguments)
@@ -1405,6 +1440,12 @@
         "fixture command failed: {}",
         String::from_utf8_lossy(&output.stderr)
     );
+}
+
+fn tit_binary() -> std::path::PathBuf {
+    env::var_os("TIT_RELEASE_BINARY")
+        .map(Into::into)
+        .unwrap_or_else(|| env!("CARGO_BIN_EXE_tit").into())
 }
 
 fn wait_for_listener(address: SocketAddr, server: &mut ChildGuard) {
diff --git a/tests/snapshots/web/bad-request.html b/tests/snapshots/web/bad-request.html
index ff6cd3e014500a7e0c0098d3d22f17ed2df5f222..93583b049d5f782da0df4ef2f1db41dfb10c05a7 100644
--- a/tests/snapshots/web/bad-request.html
+++ b/tests/snapshots/web/bad-request.html
@@ -13,14 +13,22 @@
     <nav aria-label="Primary">
       <a href="/">Home</a>
       <a href="/search">Search</a>
+
       <a href="/signup">Create account</a>
       <a href="/recover">Recover account</a>
       <a href="/login">Log in</a>
+
     </nav>
   </header>
   <main id="main">
 
-  <h1>Repositories</h1>
+
+  <h1>Recently updated public repositories</h1>
+
+
+  <p>No repositories are available.</p>
+
+  <h2>Open a repository</h2>
   <p>Enter an owner and a repository to open its public page.</p>
 
   <p class="error" role="alert">Enter a valid lowercase owner and repository.</p>
diff --git a/tests/snapshots/web/home.html b/tests/snapshots/web/home.html
index 1a9666ebe846ca596595a8385c1347a7bdaa2ed2..5a39ae763ebaa2a1386b3baf9942c8430fb51638 100644
--- a/tests/snapshots/web/home.html
+++ b/tests/snapshots/web/home.html
@@ -13,14 +13,22 @@
     <nav aria-label="Primary">
       <a href="/">Home</a>
       <a href="/search">Search</a>
+
       <a href="/signup">Create account</a>
       <a href="/recover">Recover account</a>
       <a href="/login">Log in</a>
+
     </nav>
   </header>
   <main id="main">
 
-  <h1>Repositories</h1>
+
+  <h1>Recently updated public repositories</h1>
+
+
+  <p>No repositories are available.</p>
+
+  <h2>Open a repository</h2>
   <p>Enter an owner and a repository to open its public page.</p>
 
   <form action="/go" method="get">
diff --git a/tests/snapshots/web/method-not-allowed.html b/tests/snapshots/web/method-not-allowed.html
index b7bab2ebc3c5afb37034e0cca83d2c77749883b8..5619be0017f8be07798d321659a116096728adfa 100644
--- a/tests/snapshots/web/method-not-allowed.html
+++ b/tests/snapshots/web/method-not-allowed.html
@@ -13,9 +13,11 @@
     <nav aria-label="Primary">
       <a href="/">Home</a>
       <a href="/search">Search</a>
+
       <a href="/signup">Create account</a>
       <a href="/recover">Recover account</a>
       <a href="/login">Log in</a>
+
     </nav>
   </header>
   <main id="main">
diff --git a/tests/snapshots/web/not-found.html b/tests/snapshots/web/not-found.html
index 804649ae1202ca2aff0a9e74286ee3d7a57c7982..5b1c09fd1ec8bc385f113bfd45e32935165017ad 100644
--- a/tests/snapshots/web/not-found.html
+++ b/tests/snapshots/web/not-found.html
@@ -13,9 +13,11 @@
     <nav aria-label="Primary">
       <a href="/">Home</a>
       <a href="/search">Search</a>
+
       <a href="/signup">Create account</a>
       <a href="/recover">Recover account</a>
       <a href="/login">Log in</a>
+
     </nav>
   </header>
   <main id="main">
diff --git a/tests/ssh.rs b/tests/ssh.rs
index 401c758568f6a76d0f3b5259c9f3983158e12900..4393063a9fe4157f4548fcedaba44691a085e566 100644
--- a/tests/ssh.rs
+++ b/tests/ssh.rs
@@ -88,6 +88,55 @@
 }
 
 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
+async fn reports_available_commands_and_explains_invalid_commands() {
+    let directory = TempDir::new().expect("create a key directory");
+    let private_key = directory.path().join("ed25519");
+    generate_key(&private_key, KeyFixture::Ed25519);
+    let server = start(&[parse_public_key(&private_key)]).await;
+
+    let help = ssh(&server, &private_key, "alice", &["help"]);
+    assert!(
+        help.status.success(),
+        "help failed: {}",
+        String::from_utf8_lossy(&help.stderr)
+    );
+    let help_text = String::from_utf8(help.stdout).expect("read the help output");
+    assert!(help_text.contains("Available tit SSH commands:"));
+    assert!(help_text.contains("repo create NAME"));
+    assert!(help_text.contains("issue list OWNER/REPOSITORY"));
+    assert!(help_text.contains("pr checkout OWNER/REPOSITORY NUMBER"));
+
+    let invalid = ssh(&server, &private_key, "alice", &["not-a-command"]);
+    assert!(!invalid.status.success());
+    assert_eq!(
+        String::from_utf8(invalid.stderr).expect("read the invalid-command error"),
+        "tit: The command is not valid.\nUse 'help' to list the available commands.\n"
+    );
+
+    let malformed = ssh(&server, &private_key, "alice", &["repo create"]);
+    assert!(!malformed.status.success());
+    let malformed_error =
+        String::from_utf8(malformed.stderr).expect("read the malformed-command error");
+    assert!(malformed_error.contains("usage: repo create NAME"));
+    assert!(malformed_error.contains("Use 'help' to list the available commands."));
+
+    let invalid_json = ssh(
+        &server,
+        &private_key,
+        "alice",
+        &["not-a-command --output json"],
+    );
+    assert!(!invalid_json.status.success());
+    let response: serde_json::Value =
+        serde_json::from_slice(&invalid_json.stdout).expect("parse the JSON error");
+    assert_eq!(response["version"], 1);
+    assert_eq!(response["status"], "error");
+    assert_eq!(response["error"]["code"], "invalid-command");
+
+    server.shutdown().await.expect("stop the SSH server");
+}
+
+#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
 async fn rejects_unknown_keys_and_forced_rsa_sha1_authentication() {
     let directory = TempDir::new().expect("create a key directory");
     let authorized_private = directory.path().join("authorized");
@@ -190,7 +239,7 @@
 
     let audit = server.audit();
     assert!(audit.rejected_shell >= 1);
-    assert!(audit.rejected_exec >= 2);
+    assert!(audit.accepted_exec >= 2);
     assert!(audit.rejected_pty >= 1);
     assert!(audit.rejected_agent >= 1);
     assert!(audit.rejected_forward >= 1);
