michal/tit

Browse tree · Show commit · Download archive

Diff

c24a185aa5d6c8914a8b9b65

.github/workflows/ci.yml

Mode 100644100644; object d181d2a0a5af9d0d0500710b

@@ -16,7 +16,11 @@
           persist-credentials: false
       - run: cargo --version
       - run: cargo install --locked cargo-deny --version 0.20.2
-      - run: ./scripts/check
+      - run: cargo fmt --check
+      - run: cargo clippy --all-targets --all-features -- -D warnings
+      - run: cargo test --locked --all-targets --all-features
+      - run: cargo deny check advisories licenses sources
+      - run: cargo build --locked --release
 
   release:
     strategy:

AGENTS.md

Mode 100644100644; object 302f755cd64ec417d7187849

@@ -1,7 +1,5 @@
 # Agent instructions
 
-Before you change code, read all of `PLAN.md` and `CONTRIBUTING.md`.
+Before you change code, read all of `CONTRIBUTING.md`.
 Obey all rules in `CONTRIBUTING.md`.
-Only work on the current milestone in `PLAN.md`.
-Do not start the next milestone before the current acceptance gate passes.
 For all documentation, use ASD-STE100 Simplified Technical English, Issue 9.

CONTRIBUTING.md

Mode 100644100644; object 207b77e39fd5ff9633249768

@@ -16,8 +16,7 @@
 
 ## Scope
 
-- Read `PLAN.md` before you change code.
-- Work only on the current milestone and its specified acceptance gate.
+- Work only on the requested change and its necessary validation.
 - Do not implement subsequent features, compatibility layers, extension points,
   or configuration settings for subsequent milestones.
 - Make a small completed change. Do not make a large framework that is not
@@ -55,8 +54,8 @@
 ## Documentation
 
 Write all project documentation in ASD-STE100 Simplified Technical English,
-Issue 9. This rule includes `AGENTS.md`, `CONTRIBUTING.md`, `PLAN.md`, the
-README, manuals, help text, configuration descriptions, and Rust documentation.
+Issue 9. This rule includes `AGENTS.md`, `CONTRIBUTING.md`, the README, manuals,
+help text, configuration descriptions, and Rust documentation.
 
 Use the approved STE dictionary and writing rules. Treat established Git, Rust,
 protocol, command, and project terms as technical names or technical verbs only
@@ -79,8 +78,9 @@
   environment, feature, host, identity, implementation, and invariant.
 - license, metadata, milestone, model, owner, permission, platform, portability,
   priority, scope, state, validation, version, and visibility.
-- browser, description, design, documentation, enhancement, form, library,
-  reader, rule, sanitizer, subset, summary, user, and writer.
+- bio, browser, contact email, description, design, documentation, enhancement,
+  form, library, profile, reader, rule, sanitizer, subset, summary, user, and
+  writer.
 - agent and contributor.
 - action, admin, blob, checkout, collaboration, default, development, download,
   encoding, federation, filesystem, frontend, installation, link, organization,
@@ -91,12 +91,11 @@
   request, push, ref, tag, and tree.
 - Cargo, Git, OpenSSH, Rust, SQLite, and the exact names of crates, commands,
   types, files, paths, and configuration fields.
-- acceptance gate, audit event, collaborator role, domain event, feed token,
-  invite code, login approval, login nonce, recovery credential, and security
-  boundary.
-- architectural decision record, built-in SSH server, canonical URL,
-  client-side router, progressive enhancement, recoverable runtime failure,
-  server-rendered HTML, server-side validation, symbolic logo, and tit bird.
+- audit event, collaborator role, domain event, feed token, invite code, login
+  approval, login nonce, recovery credential, and security boundary.
+- built-in SSH server, canonical URL, client-side router, progressive
+  enhancement, recoverable runtime failure, server-rendered HTML, server-side
+  validation, symbolic logo, and tit bird.
 - first-class feed, operating-system library, platform-specific code, read-only
   repository, self-hosted CDE, shared library, and plain-text Markdown.
 - blocking job, cgit-like interface, laziness rule, portable code, semantic
@@ -216,8 +215,7 @@
   placeholder implementations, or flags that do nothing.
 - Comments explain constraints and non-obvious reasons, not what the syntax
   already says.
-- Update `PLAN.md` when a product or architectural decision changes. Do not
-  rewrite it to support an easy but incorrect method.
+- Update the README and applicable tests when a public behavior changes.
 - When two designs are correct, select the design with minimum state and
   dependencies. Also select the design with the minimum number of parts and a
   recovery process that is clear.

PLAN.md

Mode 100644100644; object fb83b5c6666486d622876ca9

@@ -1,942 +1,31 @@
-# tit
-
-`tit` (tiny git) is a small, self-hosted collaborative development environment
-(CDE) for Git. It is written in Rust. Its logo is a small symbolic tit bird.
-Its interface is equivalent to a small cgit interface. It also supplies a full
-collaboration workflow: browse, clone, discuss, review, merge, publish, and subscribe.
-
-The MIT License applies to `tit`.
-The Cargo package is `tit-cde`, short for “the tit collaborative development
-environment”, and it installs the `tit` executable.
-
-Portability and security have the same highest priority. First, make sure that a
-design has these properties and the necessary behavior. Then, select the design
-with minimum code, state, dependencies, and operational surface.
-
-All project documentation must conform to ASD-STE100 Simplified Technical
-English, Issue 9. Git, Rust, protocol, command, and project terms are technical
-terms where the standard lets writers use them.
-
-The deployed application must be one executable with no necessary runtime
-services, project-specific shared libraries, Git executable, or independently installed static
-assets. `tit` can use Rust crate dependencies at build time. It can also use
-statically linked native code and operating-system libraries. Repositories and
-application data stay as standard files. An operator can make a backup of these files
-while the application does not run.
-
-System installations keep the full instance in `/srv/tit` by default.
-This includes `config.toml`, the metadata database, bare repositories, SSH host
-keys, instance secrets, and recoverable operational state.
-The metadata database name is `tit.sqlite3`.
-
-## Product boundaries
-
-The initial users are persons and small groups. They want a CDE that is clear
-and that they can operate. The primary features are an SSH-native
-identity, a small cgit-like web interface, first-class feeds, and interoperable
-Git workflows.
-
-The first release will not include organizations, CI or actions, package
-registries, project boards, chat, OAuth, or federation. These features will
-increase the permission model and operational surface before the core CDE
-passes its tests.
-
-Accounts own repositories directly. New repositories use `main` as their
-initial branch and are public by default. An owner can make a repository
-private.
-
-## Interfaces
-
-### Web
-
-The full web interface operates without JavaScript. It also operates in
-browsers that do not implement JavaScript. Each operation uses server-rendered
-semantic HTML, normal links, and HTML forms. The server validates all input.
-
-Subsequent JavaScript is an optional progressive enhancement. It must not own
-canonical state, give the only validation path, or gate a workflow. Use the
-minimum HTML and embedded CSS for an interface that is clear and easy to use.
-Version 1 has no frontend framework, client-side router, or asset pipeline.
-
-Repository views must include:
-
-- repository summary and rendered README.
-- trees, blobs, raw files, commits, diffs, blame, branches, and tags.
-- read-only repository search.
-- archive downloads.
-- stable clone, raw-file, issue, pull-request, and feed URLs.
-- correct `GET` and `HEAD` behavior, redirects, cache validators, and content
-  types.
-
-The database stores user content as the plain-text Markdown that the user
-supplied. The documentation specifies a small Markdown subset. An HTML sanitizer
-must remove active content from the rendered HTML.
-
-### SSH
-
-The built-in SSH server gives Git transport without an external `git` process.
-It will subsequently supply a small CDE command interface. This interface
-uses the same SSH identity:
-
-```text
-ssh -p 2222 tit.example repo create NAME
-ssh -p 2222 tit.example issue list OWNER/REPO
-ssh -p 2222 tit.example issue create OWNER/REPO
-ssh -p 2222 tit.example pr checkout OWNER/REPO NUMBER
-```
-
-Command output must be stable for scripts. A specified machine-readable format
-supplies this output. Scripts must not use terminal text as data.
-
-Repository clone endpoints accept these two formats:
-
-- `https://host/owner/repo{,.git}`.
-- `ssh://host[:port]/owner/repo{,.git}`.
-
-The server ignores the SSH username for
-identity and authorization. The public key identifies the account. Thus,
-canonical URLs do not include a username. The built-in SSH service listens on
-port 2222 by default. Thus,
-
-`ssh://host:2222/owner/repo` is the normal clone URL. A configured port 22 does
-not occur in the URL.
-
-`ssh.public_host` sets the hostname in advertised SSH clone URLs. By default,
-it uses the host from `public_url`. It accepts a DNS hostname, an IP address, or
-an `.onion` hostname. `tit` validates and normalizes the hostname without
-resolving it. A Tor client supplies the necessary SSH `ProxyCommand`
-configuration.
-
-### Git transport
-
-Bare Git repositories are the canonical source-code storage. The server
-publishes pull-request heads as ordinary refs, such as `refs/pull/42/head`.
-Standard Git tools can fetch and review these refs when the web interface is
-not available.
-
-Git object handling is hash-agile from the first release and supports SHA-1 and
-SHA-256 repositories without hard-coded object-ID lengths. New repositories
-initially default to SHA-1 for compatibility, while repository creation and
-instance configuration can select SHA-256. Version 1 serves public repositories
-through HTTPS and all authorized repositories through SSH. Private Git clone and fetch
-use SSH. Version 1 does not support Git push through HTTP.
-
-## Accounts and authentication
-
-Account creation asks for a username and an SSH public key. Users can register
-multiple keys, label them, inspect their fingerprints and last use, and revoke
-them. The server normalizes the case of each username and reserves it
-permanently. Thus, it cannot assign links and attribution for that username to
-a new account.
-
-Signup must have a random, single-use invitation from `tit invite-code`.
-The server stores only the code hash, applies an expiry, and consumes the code in
-the same transaction that creates the account.
-
-This offline command creates the first administrator:
-
-```text
-tit setup admin <username> "<ssh-public-key>"
-```
-
-The command is valid only for an uninitialized instance, creates exactly one
-administrator, and prints its recovery code once.
-
-Web login uses the built-in SSH service as the primary approval path. The
-browser creates a one-time approval, and an authenticated `ssh` command binds
-the approval to the account and active key. The fallback uses the standard SSH
-signature envelope rather than a custom cryptographic format. It presents a
-challenge and an exact command with `ssh-keygen -Y sign` and the dedicated
-`tit-auth` namespace. The challenge contains these minimum items:
-
-- protocol version and purpose.
-- canonical CDE origin.
-- normalized username.
-- cryptographically random nonce.
-- issue and expiry times.
-
-The server stores only a hash of each short-lived nonce and atomically consumes
-it after successful verification. An attacker can replay a stateless challenge
-before its expiry. Thus, the server keeps this small state. Successful login
-issues a short-lived `Secure`, `HttpOnly`, `SameSite` session cookie. The server
-changes this cookie regularly. State-changing requests also require CSRF
-protection.
-
-The loss of the sole SSH key must not require a manual database change. Initial
-recovery uses a one-time offline recovery code. The server generates this code
-during signup. The audit log records recovery, key addition, key removal, and
-session revocation. A subsequent version can support SSH certificates. The
-first release does not use them.
-
-Authorization is independent from authentication. Repository visibility,
-collaborator roles, protected branches, force pushes, and pull-request merges
-have explicit rules. The server enforces these rules on the HTTP and SSH paths.
-
-## Collaboration model
-
-Issues support a title, Markdown body, open or closed state, author, assignees,
-labels, comments, and an append-only timeline. Keep labels repository-local. Do
-not add milestones or project boards initially.
-
-Pull requests add base and head refs, review comments, approvals or requested
-changes, mergeability, and merge state. Review comments must anchor to a
-commit and file position so subsequent pushes cannot silently change what a comment
-referred to. The initial merge strategies are merge commit and fast-forward.
-Add squash and rebase subsequently only if their attribution and audit semantics
-are clear.
-
-Metadata mutations append their domain event in the same database transaction.
-Git mutations use the durable intent protocol because Git refs and SQLite
-cannot share a transaction. Feeds, audit history, notifications, and subsequent
-webhooks derive from completed events rather than each feature inventing a
-second history.
-
-## Feeds and notifications
-
-Atom is the canonical feed model, and the same events are also rendered as RSS.
-Stable feeds must include:
-
-- repository activity.
-- branch or tag updates.
-- issues and issue comments.
-- pull requests, reviews, and merges.
-- user activity.
-- personalized activity such as assignments and mentions.
-
-Watching a repository is granular: pushes, issues, pull requests, or everything.
-Public feeds require no authentication. Private and personalized
-feeds use random, scoped, revocable bearer URLs and must never expose unrelated
-private events. The UI warns that feed URLs are credentials.
-
-Version 1 does not include an in-application notification inbox. Add an inbox
-only if feeds do not give sufficient information.
-Webhooks can subsequently consume the same event stream, with signed deliveries,
-bounded retries, and per-repository scopes.
-
-## Storage
-
-Application metadata belongs in one SQLite database. `tit` accesses it through
-`rusqlite`. Source code belongs in bare Git repositories. Do not store issues
-and reviews as Git refs or commits. Their transactions, indexes, permissions,
-and stable IDs belong to the CDE.
-
-Application-level IDs must not expose reusable storage keys. Public objects
-receive stable, non-reassignable identifiers. Relations such as issue comments,
-repository collaborators, and pull-request reviews use explicit IDs. SQLite
-foreign keys enforce record relationships.
-
-`UNIQUE`, `CHECK`, and `NOT NULL` constraints enforce applicable record rules.
-Related mutations use one write transaction. `tit` enables foreign keys on
-every database connection.
-
-Metadata search starts with bounded scans or explicit, derived term-index
-records. If that stops being adequate, add an embedded full-text index that can
-be rebuilt entirely from canonical SQLite tables. Source search remains an
-isolated bounded traversal of Git objects. Treat a subsequent index as
-derivable state.
-
-### Rust persistence layer
-
-Use **`rusqlite` with bundled SQLite**. Enable only the necessary `bundled` and
-`backup` crate features. This choice gives each supported operating system the
-same SQLite version without a runtime shared-library dependency.
-
-Do not add an ORM, query builder, connection pool, or migration framework at
-this time. Keep SQL, row conversion, transactions, and connection setup inside
-the `store` module. Use prepared statements and explicit column lists. Return
-domain types from the module instead of database rows or `rusqlite` types.
-
-Use WAL mode, `synchronous=FULL`, a bounded busy timeout, and foreign-key
-enforcement. Apply these settings to each connection and verify their effective
-values. The first feasibility spike must prove these properties:
-
-- crash recovery.
-- concurrent reads and writes.
-- online backup and restore.
-- transactional migrations across historical schemas.
-- constraint and index consistency.
-- restoration into the next application version.
-
-Alternatives considered:
-
-- **`native_db` with `redb`** avoids SQL, but it adds a young abstraction and a
-  private data format. It also makes relationship checks and external inspection
-  application responsibilities.
-- **Diesel** supplies compile-time query checks, but its query DSL and generated
-  types add a second persistence model.
-- **SQLx** supplies asynchronous integration and query checks, but SQLite is
-  synchronous and the application must still own its SQL and migrations.
-- **System SQLite** decreases binary size, but behavior and enabled features can
-  differ between supported operating systems. Bundled SQLite prevents this drift.
-
-Schema migrations are numbered SQL files embedded in the executable. Apply all
-pending migrations in one `BEGIN EXCLUSIVE` transaction. Update
-`PRAGMA user_version` in that transaction. Keep historical migration files
-unchanged. An unsupported migration gap stops startup and gives a clear
-diagnostic.
-
-Before an automatic migration, use the SQLite online backup API to create a
-recoverable copy. Destructive or long migrations are explicit
-`tit admin migrate` operations with a status view and a dry-run where possible.
-
-## Packaging and operation
-
-The executable embeds templates, CSS, model definitions, and other static
-resources.
-It gives these minimum command groups:
-
-```text
-tit serve
-tit setup
-tit invite-code
-tit admin
-tit backup
-tit restore
-tit doctor
-```
-
-Administrative commands that must mutate a running instance, such as
-`tit invite-code`, use an owner-only Unix control socket beneath `/srv/tit`.
-They never open the live SQLite database from a second process.
-An offline command must first acquire the exclusive instance lock. If
-`tit serve` owns that lock, the command uses the control socket. If this is not
-possible, the command stops and gives a clear instruction.
-
-`serve` runs HTTP and SSH listeners from one process, with each listener
-independently configurable. HTTP binds to `127.0.0.1:3000` by default and uses a
-necessary canonical `public_url`. A reverse proxy usually terminates TLS.
-The system configuration path is `/srv/tit/config.toml`, alongside all other
-instance data. Command-line overrides and environment variables for secrets are
-supported, and precedence must be deterministic.
-
-`backup` briefly establishes a consistent boundary. It uses the SQLite online
-backup API. It copies or bundles all repositories and necessary configuration
-into an archive with a specified format. `restore` verifies archive format, database
-compatibility, and repository integrity before replacing live state. `doctor`
-does checks of database records and indexes, schema versions, repository object
-integrity, filesystem permissions, key configuration, and listener readiness.
-
-The application produces structured logs without external infrastructure. It
-keeps a durable audit trail for security operations. Access logs and metrics are
-operational data. An operator can redirect or disable them.
-
-## Remaining decisions
-
-- exact recovery-code and key-rotation flows.
-- Git library after the protocol spike.
-- the exact release architectures and the meaning of a standalone executable on
-  Linux, macOS, FreeBSD, OpenBSD, and NetBSD.
-
-## Implementation plan
-
-Work proceeds through milestone gates. Do not start a milestone before the
-preceding gate passes. If a feasibility gate fails, change the design before
-you continue the feature work.
-
-**Current milestone:** Milestone 7.1 — Priority 0 security remediation.
-Update this marker only after the current milestone gate passes.
-
-### Architecture
-
-Start as one Cargo package, not a workspace of small crates. Module boundaries
-isolate the code without making each internal change a cross-crate API:
-
-```text
-src/
-  main.rs             process startup and top-level error reporting
-  cli.rs              command-line contract
-  config.rs           configuration loading and validation
-  domain/             IDs, entities, invariants, and domain operations
-  store/              SQLite schema, queries, migrations, and repositories
-  git/                object access, refs, wire protocol, and quarantine
-  auth/               SSH keys, SSHSIG challenges, sessions, and recovery
-  http/               routes, handlers, templates, and response policy
-  ssh/                SSH server, authentication, and command dispatch
-  control.rs          local administrative control socket
-  feeds/              event projection into Atom and RSS
-  ops/                backup, restore, doctor, and maintenance
-  telemetry.rs        structured logs and request correlation
-templates/            compile-time embedded HTML templates
-assets/               compile-time embedded CSS and optional small images
-tests/                 black-box and protocol integration tests
-```
-
-HTTP handlers, SSH handlers, and CLI commands call application services. They
-must not open SQLite, manipulate repository paths, or update refs directly.
-The `store` and `git` modules expose narrow interfaces so neither their concrete
-crates nor generated types become part of the rest of the application.
-
-`rusqlite` is synchronous. Thus, storage operations run as bounded blocking
-jobs. A database transaction cannot stay active across an `.await`.
-Begin with `tokio::task::spawn_blocking` behind a semaphore. Replace it with a
-dedicated storage executor only if measurements show scheduling overhead or
-write starvation.
-
-#### Cross-store mutations
-
-Git refs and SQLite cannot participate in one atomic transaction. Each
-operation that changes Git refs and SQLite uses a durable intent record:
-
-1. Validate authorization and write an operation intent containing repository,
-   initial refs, proposed refs, actor, and event payload.
-2. Receive objects into quarantine and validate size, object format,
-   connectivity, and proposed ref policy.
-3. Promote validated objects into the repository while they stay unreachable.
-4. Atomically update all affected Git refs.
-5. In one database transaction, mark the intent completed and append its domain
-   and audit events.
-6. During startup, compare incomplete intents with the actual refs. Finalize a
-   full update. Abandon an update that did not start. During maintenance,
-   prune unreachable promoted objects. Stop startup if the ref state is mixed.
-
-Tests must inject process termination after each boundary. Feeds and webhooks
-consume only completed events. Thus, recovery cannot announce a push that did
-not make refs reachable.
-
-### Technical choices to prove, not assume
-
-Tentative dependencies are Tokio, Axum, `rusqlite`, `ssh-key`, Russh, Askama,
-`pulldown-cmark`, an HTML sanitizer, and selected `gix-*` crates. Pin the exact
-version in `Cargo.lock` when you add a dependency.
-
-Two capabilities are already plausible but require interoperability tests at
-this time. `ssh-key` can parse and verify OpenSSH SSHSIG envelopes. Russh gives
-an asynchronous SSH server. Git serving is the blocking uncertainty. Current
-`gix-protocol` functionality is client-oriented. libgit2 does not give a full
-server-side replacement for `git-upload-pack` and `git-receive-pack`.
-
-Consequently, milestone 1 must implement a small standards-based server that
-uses reusable Git primitives. Initially, the server can advertise only the
-capabilities that it fully supports. Current Git clients must clone, fetch, and
-push without special configuration. If the spike cannot do this safely in its
-time limit, stop the work. Select a larger Git implementation or remove the
-no-Git-executable constraint.
-
-Do not silently introduce a subprocess. Do not declare that a partial protocol
-is ready for production.
-
-### Core data model
-
-All persistent records carry a creation time and stable typed ID. Serialized
-event payloads also carry a version. Use random, non-reassignable IDs internally.
-Use per-repository increasing issue and pull-request numbers for human-facing
-URLs. Normalize usernames and repository slugs once at the domain boundary.
-Store the canonical form and display form where necessary.
-
-Initial record families are:
-
-- identity: `Account`, `SshKey`, `RecoveryCredential`, `InviteCode`,
-  `LoginNonce`, `Session`.
-- repositories: `Repository`, `Collaborator`, `BranchRule`, `RepoCounter`.
-- issues: `Issue`, `IssueComment`, `Label`, `IssueLabel`, `Watch`.
-- pull requests: `PullRequest`, `Review`, `ReviewComment`.
-- delivery and history: `DomainEvent`, `FeedToken`, `AuditEvent`,
-  `GitOperationIntent`.
-
-Usernames are lowercase ASCII and match
-`[a-z0-9](?:[a-z0-9-]{0,37}[a-z0-9])?`. Repository slugs are lowercase ASCII,
-one to 100 characters, can additionally contain internal `.`, `_`, and `-`, and
-must not end in `.git`. Reject case variants rather than silently aliasing them.
-Reserve route and operational names such as `admin`, `api`, `assets`, `feeds`,
-`issues`, and `setup`.
-
-Name each access pattern before you add its index. The store needs these unique
-indexes:
-
-- a normalized username.
-- an SSH fingerprint.
-- an owner and repository slug.
-- a repository and issue number.
-- a repository and pull-request number.
-
-Secondary indexes cover child records, open items, events, active sessions, and
-incomplete operation intents. Invite-code hashes are unique and indexed. The
-store does not keep the clear text.
-
-SQLite foreign keys use restrictive delete actions unless the schema specifies
-a different action. Initially, do not delete an account or repository. Suspend
-an account. Archive a repository. Do not reassign comments, events, usernames,
-or public numbers.
-
-`tit doctor` runs `PRAGMA integrity_check` and `PRAGMA foreign_key_check`. It
-also checks domain invariants that SQL constraints cannot express. It reports
-the exact record IDs in each inconsistency.
-
-Schema migrations stay in source control. Each release fixture includes a
-database created by the prior stable version. CI restores and migrates it before
-running integrity checks.
-
-### Milestones
-
-#### Milestone 0 — repository and engineering baseline
-
-Goal: establish a small codebase with repeatable quality gates before protocol
-experiments create permanent structure.
-
-- **M0.1 Bootstrap Cargo.** Create the `tit-cde` binary package with the MIT
-  License, producing the `tit` executable. Pin the Rust toolchain, commit
-  `Cargo.lock`, add `.gitignore` and `LICENSE`, and a minimum README that points
-  to `PLAN.md` and `CONTRIBUTING.md`.
-- **M0.2 Establish the CLI.** Implement `tit --version` and useful `--help`, then
-  define stable exit-code, standard-output, and standard-error conventions.
-  Add each operational command only in the milestone that implements it. Do not
-  ship placeholder subcommands. Reserve the bootstrap syntax in this document:
-  `tit setup admin <username> "<ssh-public-key>"` and invitation syntax
-  `tit invite-code` without adding inert handlers.
-- **M0.3 Establish configuration.** Define a versioned TOML configuration with
-  CLI overrides. System installations use `/srv/tit/config.toml` and keep each
-  other instance file beneath `/srv/tit`. Per-user installations keep the same
-  self-contained layout beneath the platform's XDG data directory. Validate the
-  necessary canonical `public_url`, instance directory, listener addresses,
-  signup policy, limits, and proxy trust explicitly. Reject unknown fields.
-  Bind HTTP to `127.0.0.1:3000` and SSH to port 2222 by default. Generate clone
-  URLs from the effective externally advertised addresses rather than assuming
-  they match the bind addresses.
-- **M0.4 Establish CI.** Require locked builds, `cargo fmt --check`,
-  `cargo clippy --all-targets --all-features -- -D warnings`, and unit and
-  integration tests. Require checks for dependency licenses and advisories.
-  Require release builds on each available operating-system runner. Keep each
-  operating-system exclusion explicit and temporary.
-- **M0.5 Establish test infrastructure.** Add temporary data-directory helpers,
-  real-process tests, fixture repositories, free-port allocation, and structured
-  log capture. Use the stock `git`, `ssh`, and `ssh-keygen` clients as external
-  test drivers. These clients are absent at runtime.
-- **M0.6 Establish contribution checks.** Require `cargo fmt --check` and
-  `cargo clippy --all-targets --all-features -- -D warnings` before each
-  commit. Require commit messages to use `type(module): short description` as
-  specified in `CONTRIBUTING.md`. Review all project documentation for
-  conformance with ASD-STE100 Issue 9. Record the approved project-specific
-  technical terms that the documentation uses.
-
-Gate: a clean checkout passes each check with one command in the documentation. The
-release binary starts when no Git executable is on its `PATH`. All documentation
-passes the ASD-STE100 review.
-
-#### Milestone 1 — feasibility and architectural gate
-
-Goal: remove the risks that can make the single-binary design invalid.
-
-##### M1A — SQLite durability
-
-- Add `rusqlite` with only the `bundled` and `backup` features.
-- Define two small fixture tables with primary keys, foreign keys, constraints,
-  and unique and non-unique indexes.
-- Prove insert, update, delete, indexed range scans, concurrent reads,
-  serialized writes, busy handling, rollback, WAL checkpoint, and vacuum behavior.
-- Prove online backup and restore while reads and writes continue.
-- Generate earlier database fixtures with committed fixture programs. Migrate
-  through a minimum of two schema versions with transactional SQL migrations.
-- Kill a child process during representative writes and migrations. Open the
-  database again. Make sure that it has the initial or new full state.
-- Verify locking, `synchronous=FULL`, WAL recovery, and backup behavior on each
-  supported filesystem and operating system. Do not infer BSD behavior from
-  Linux or macOS results.
-- Require a local filesystem unless a platform gate proves a different
-  filesystem has correct WAL locking and shared-memory behavior.
-- Use a workload of 10,000 issues and 1,000,000 events. Measure database growth,
-  backup time, migration time, and request latency.
-
-Gate: restored and migrated fixtures pass SQLite integrity checks and `doctor`.
-No handler needs direct access to a `rusqlite` type or SQL statement.
-
-The M1A evidence and limits are in
-`docs/adr/0001-sqlite-storage.md`. The decision stays provisional until the
-Linux and macOS CI workload gates pass.
-
-##### M1B — SSH identity
-
-- Parse and normalize supported OpenSSH public keys and fingerprints.
-- Support Ed25519 first. Add ECDSA P-256 and RSA-SHA2 only after interoperability
-  tests. Reject DSA, RSA-SHA1, malformed keys, and undersized RSA keys.
-- Produce a versioned canonical challenge, verify a real
-  `ssh-keygen -Y sign -n tit-auth` signature, enforce origin and namespace, and
-  atomically consume the nonce.
-- Start a Russh server with a generated host key. Authenticate a stock OpenSSH
-  client. Restrict sessions to recognized Git or `tit` commands. Reject
-  shells, PTYs, forwarding, agents, and arbitrary exec. Reject environment
-  requests except for a strictly parsed `GIT_PROTOCOL` value necessary to negotiate
-  supported Git protocol versions.
-
-Gate: positive interoperability tests and negative tests for replay, expiry,
-wrong origin, wrong namespace, wrong key, malformed envelopes, and unsupported
-algorithms all pass.
-
-##### M1C — read-side Git protocol
-
-- Open, create, and inspect bare SHA-1 and SHA-256 repositories without invoking
-  Git. Parameterize object IDs, empty-tree IDs, ref parsing, packet fields, and
-  display formatting by repository object format.
-- Implement packet-line parsing with strict length and allocation limits.
-- Implement the minimum upload-pack negotiation and pack generation necessary
-  for ordinary clone and fetch. First, establish SHA-1 interoperability with
-  protocol v0/v1. Then, implement SHA-256 protocol negotiation with independent
-  fixtures before this milestone passes.
-- Expose the same service through SSH exec channels and smart HTTP, with identical
-  repository resolution and authorization decisions.
-- Use stock Git clients to do tests of empty repositories, branches, tags, deltas, and
-  repeated fetches. Also do tests of limited large blobs, non-ASCII filenames, and
-  damaged repositories.
-
-Gate: HTTP and SSH clones reproduce each SHA-1 and SHA-256 fixture ref and
-object. Fetches do the same. Malformed and oversized requests do not cause
-excessive memory growth. Process instrumentation proves that the `tit` server
-does not invoke Git. The black-box test driver can invoke Git.
-
-##### M1D — write-side Git protocol
-
-- Implement receive-pack command parsing for SHA-1 and SHA-256 repositories and
-  advertise only supported capabilities.
-- Stream incoming objects into a per-push quarantine directory with byte,
-  object-count, depth, and wall-clock limits.
-- Resolve deltas. Validate object hashes, types, reachability, connectivity, ref
-  names, initial object IDs, fast-forward policy, and permissions. Do these
-  checks before clients can see the refs.
-- Promote validated objects, update multiple refs atomically, produce accurate
-  per-ref status, and clean each abandoned quarantine.
-- Exercise durable operation intents and kill/restart reconciliation at each
-  cross-store boundary.
-
-Gate: stock Git can create, update, delete, and reject refs correctly through SSH
-for the two object formats. After a failed push, clients cannot see new objects or
-partial refs.
-The server rejects non-fast-forward and unauthorized updates. Fuzzed packet and
-pack inputs do not cause a panic.
-
-##### Milestone 1 decision record
-
-Write an architecture decision record. Include the protocol versions,
-capabilities, selected crates, custom protocol code, binary size, resource
-measurements, and known omissions. Continue the project only if all four gates
-pass. Otherwise, change the product constraints.
-
-#### Milestone 2 — read-only CDE
-
-Goal: ship a useful cgit-like browser before multi-user accounts and
-collaboration expand the security model.
-
-- **M2.1 Instance bootstrap.** Implement
-  `tit setup admin <username> "<ssh-public-key>"` for an uninitialized instance.
-  Validate and normalize the username and key. Create one administrator. Store
-  a hashed recovery credential. Show the recovery code one time. Do not run this
-  command after bootstrap.
-- **M2.2 Repository administration.** Implement local admin commands to create,
-  import, rename, archive, and inspect repositories. Canonicalize each path and
-  keep filesystem names derived from immutable repository IDs, not user input.
-  Accounts own repositories. New repositories use `main` as the initial branch
-  and are public by default. Version 1 has no organizations.
-- **M2.3 Repository reads.** Implement bounded services for refs, commits,
-  history, trees, blobs, raw content, diffs, blame, README selection, and
-  archive streaming. Expensive traversals receive limits and cancellation.
-- **M2.4 HTTP shell.** Add semantic templates, embedded CSS, consistent
-  navigation, and easy-to-use forms. Add security headers, request IDs, useful
-  404/405 responses, and correct `GET`/`HEAD` behavior. Run the full black-box Web
-  UI suite without JavaScript. Each acceptance test must operate without a
-  script engine.
-- **M2.5 Public routes.** Add repository summary, tree, blob, raw, commit, diff,
-  blame, refs, archive, and clone-discovery routes with stable canonical URLs.
-- **M2.6 Markdown.** Render the subset specified in the documentation. Sanitize
-  links and HTML. Prevent repository content from injecting active markup into
-  CDE pages.
-- **M2.7 Public feeds.** Create Atom and RSS entries for repository creation,
-  imports, refs, tags, and pushes. Give stable IDs, timestamps, pagination, and
-  conditional requests. Validate each format with an independent feed parser.
-- **M2.8 Source search.** Implement bounded search over the selected ref without
-  a permanent index first. Enforce file-count, byte, result, and time limits.
-  Add a derivable index only when measured repository sizes require it.
-
-Gate: an operator can import a public repository. A user can browse, download,
-and search it. A user can clone it through HTTP and SSH. Atom and RSS show its
-events. Route and rendering snapshots cover empty, binary, large, malformed,
-and non-UTF-8 repository content.
-
-#### Milestone 3 — accounts, sessions, and authenticated push
-
-Goal: establish one identity and authorization model shared by the web and SSH
-interfaces.
-
-- **M3.1 Account lifecycle.** Implement `/srv/tit/control.sock` with owner-only
-  permissions, refusing symlink and non-socket replacements. `tit invite-code`
-  submits an owner-authorized request to the running process through that
-  socket. The server creates a random, single-use, expiring signup code, prints
-  it once, and stores only its hash. Signup must have a valid code, username, and
-  first SSH key. Successful signup atomically consumes the invitation, stores a
-  hashed recovery credential, and presents the recovery code once. Implement
-  use of recovery credentials, key addition and revocation, account suspension,
-  and username reservation. Open signup is outside version 1.
-- **M3.2 Web login.** Implement challenge display and upload/paste of the SSHSIG
-  envelope. Consume each nonce one time. Store opaque session hashes on the
-  server. Change sessions after privilege changes. Add CSRF tokens and a
-  function that ends all sessions.
-- **M3.3 Repository authorization.** Add public/private visibility and the
-  `owner`, `maintainer`, `writer`, and `reader` roles. One policy service must
-  answer HTTP and SSH decisions.
-- **M3.4 Authenticated Git.** Bind SSH public-key authentication to accounts.
-  Enforce read/write/ref policy before Git services start and before ref
-  updates. HTTP remains read-only in the first stable release. A separate
-  credential design can change this rule after project approval.
-- **M3.5 Audit history.** Record login, recovery, key, collaborator, repository,
-  visibility, and ref-policy mutations. Record the actor, target, outcome, time,
-  and request correlation ID. Do not store challenge responses or secrets.
-- **M3.6 SSH repository commands.** Implement `repo create` through the same
-  application service and authorization policy as the Web UI. Give stable
-  human-readable output plus an explicit machine-readable mode.
-
-Gate: the browser and SSH map the same key to the same account. Private
-repositories never appear in anonymous pages, feeds, archives, or Git discovery.
-The full account recovery and revocation flows pass black-box tests.
-
-#### Milestone 4 — issues, events, and subscriptions
-
-Goal: add the smallest full issue workflow and make events the durable
-source for subscriptions.
-
-- **M4.1 Event service.** Assign a monotonically increasing repository event
-  sequence and append each event in the same transaction as its metadata
-  mutation. Define stable event types and versioned payloads.
-- **M4.2 Issues.** Implement create, edit, comment, close, reopen, label, assign,
-  and timeline operations. Each mutation validates repository visibility and
-  role. It preserves the Markdown that the user supplied and emits one coherent event.
-- **M4.3 Watches.** Store granular watch preferences for pushes, issues, and
-  pull requests. Do not build an inbox or background mailer.
-- **M4.4 Feeds.** Add public issue feeds plus scoped private and personalized
-  Atom and RSS feeds for watched activity, assignments, and mentions. Store only
-  token hashes, show each token once, permit rotation and revocation, and keep
-  secrets out of logs and referrers.
-- **M4.5 Search.** Begin with bounded metadata scans behind a search interface.
-  Add a derivable embedded index only after representative benchmarks exceed a
-  specified latency or memory threshold.
-- **M4.6 SSH issue commands.** Implement `issue list` and `issue create` through
-  the same issue service used by HTTP. Include the machine-readable output
-  contract and identical authorization tests.
-
-Gate: the issue state and its event are in one transaction. Public tokens cannot
-retrieve private events. A token cannot retrieve events outside its scope. Feed
-order stays stable after edits and restarts.
-
-#### Milestone 5 — pull requests and review
-
-Goal: complete the collaboration loop without turning the CDE into a project
-management suite.
-
-- **M5.1 Pull-request refs.** Create immutable numbered PR records and maintain
-  `refs/pull/<number>/head`. Record base and head object IDs for each revision
-  so reviews keep the context of their initial revision.
-- **M5.2 Comparison.** Compute merge bases, commit ranges, diffs, changed paths,
-  and mergeability with specified work and output limits. Cache only values
-  that Git and PR records can regenerate.
-- **M5.3 Review.** Add general comments, line comments anchored to commit/path/
-  side/line, approvals, requested changes, outdated-comment display, and a
-  chronological timeline.
-- **M5.4 Merge.** Implement fast-forward first. Add server-created merge commits
-  only after a worktree-free merge spike. The spike must handle renames, modes,
-  conflicts, attribution, deterministic parents, and concurrent base changes.
-  It must use the same intent recovery as a push.
-- **M5.5 Branch rules.** Enforce protected refs, fast-forward-only policy,
-  force-push prohibition, deletion prohibition, and merge permissions in the
-  common ref-policy service.
-- **M5.6 SSH pull-request commands.** Implement `pr checkout` as a stable
-  command. Return the standard Git fetch and checkout instructions for the
-  pull-request ref. Add a machine-readable mode.
-
-Gate: a contributor can push a branch, open a PR, revise it, receive anchored
-review, and merge it. Concurrent updates do not cause different refs and
-metadata. Injected process termination does not cause this difference.
-
-#### Milestone 6 — operations and stable release
-
-Goal: make failure recovery as deliberate as normal operation.
-
-- **M6.1 Process lifecycle.** Add graceful shutdown, bounded drain, and listener
-  readiness. Lock the data directory without a PID. Do not start a second
-  writer.
-- **M6.2 Backup and restore.** First, give an offline backup procedure. Then
-  add online backup. Take the global write and maintenance gate. Make a
-  SQLite online backup. Pause ref mutations and repacking while you copy the
-  repositories. Copy the configuration, keys, and secrets into a checksummed
-  manifest.
-  Create archives with owner-only permissions and state plainly that they
-  contain credentials. Restore always targets an empty instance directory
-  before an explicit activation step.
-- **M6.3 Doctor.** Do checks of configuration, permissions, schema versions,
-  record relations, indexes, and incomplete intents. Do checks of Git refs, reachable objects,
-  quarantine debris, host keys, and backup manifests. Repair is a different,
-  explicit command and never the default behavior. Add typed read-only inspect
-  commands. Add a deterministic JSON Lines dump. These tools let operators
-  examine SQLite records without application-specific decoding.
-- **M6.4 Limits and abuse resistance.** Enforce request sizes, timeouts,
-  concurrency, and login and SSH attempt rates. Enforce pack, diff, archive, and
-  Markdown limits. Use safe defaults for slow clients.
-- **M6.5 Observability.** Emit structured logs, request and operation IDs,
-  bounded metrics, and audit records. Sensitive values, authorization headers,
-  cookies, feed tokens, recovery codes, and raw signatures are always redacted.
-- **M6.6 Release packaging.** Produce checksummed Linux and macOS binaries first,
-  followed by FreeBSD, OpenBSD, and NetBSD as their platform gates pass. Include
-  applicable native service examples and a minimum Caddy example. Include shell
-  completions, a man page, an upgrade guide, and a disaster-recovery exercise.
-  Verify each artifact on a clean host without Git or development libraries.
-
-Gate: a new operator can use only the supplied documentation to install,
-configure, make a backup, restore, upgrade, and remove `tit`. The operator can
-also cause damage to a disposable copy and restore it. All security and recovery
-tests use the release artifact, not a debug build.
-
-##### M6.7 — Streamlined Web login
-
-Status: Complete.
-
-- Make browser login through the built-in SSH server the primary login
-  workflow. The browser creates a short-lived, one-time secret and supplies an
-  exact `ssh` command. The SSH connection identifies the account from its key
-  and approves only that browser login request.
-- Bind each login approval to the browser login CSRF cookie. Store only secret
-  hashes. Show the canonical origin and authenticated username in SSH output.
-  Atomically consume the approval when the server creates the Web session.
-- Keep the SSHSIG workflow as a fallback. Ask for a username, but do not ask the
-  user to paste a public key. Read the signing key from the SSHSIG envelope and
-  require an active key for the account.
-- Supply the fallback challenge as a downloadable file and show an exact
-  `ssh-keygen -Y sign` command. Make signature-file upload the primary fallback
-  response. Keep signature text input as a secondary response.
-- After an invalid fallback signature, show the same challenge and error while
-  the login nonce remains valid. Do not make the user create a new challenge
-  unless it expires or is consumed.
-- Keep both workflows complete without JavaScript. Apply the existing login
-  rate limit, audit behavior, redaction rules, session rules, and generic
-  authentication errors.
-
-Gate: stock OpenSSH approves a browser login through the built-in SSH server,
-and stock `ssh-keygen` completes the fallback workflow. Black-box tests cover
-approval replay, expiry, a different browser cookie, a changed secret, a
-revoked key, a suspended account, restart, and concurrent approval. Fallback
-tests cover an unknown key, an invalid signature, retry with the same challenge,
-challenge download bytes, upload, paste, replay, and expiry. Both workflows
-create the same session type and operate without JavaScript. Do not start M7.1
-before this gate passes.
-
-Gate evidence: `./scripts/check-m3-2` passes with stock OpenSSH and
-`ssh-keygen` on 2026-07-24. The production-process test covers SSH approval,
-fallback download, upload, paste, retry, session creation, and secret
-redaction. Session tests cover approval restart, expiry, replay, changed
-secrets, browser binding, revoked keys, suspended accounts, unknown signing
-keys, and concurrent completion.
-
-#### Milestone 7 — adversarial review remediation
-
-Goal: correct the security, resource, and operational integrity findings from
-the adversarial review of commit
-`a58aa376e7e435198caa3618242b2ceffa7d27ab` before public deployment.
-
-##### M7.1 — Priority 0 security remediation
-
-- Return `private, no-store` for an archive from a private repository. Add a
-  shared cache test that proves that an anonymous user cannot receive a cached
-  private archive.
-- Make `gix-pack` enforce the 64 MiB object limit during pack indexing and
-  object decoding. Upgrade or patch the crate if its current API cannot enforce
-  this limit before memory allocation. Test an oversized compressed object with
-  a strict memory limit.
-- Limit and remove duplicate upload-pack `want` and `have` lines. Use a
-  `HashSet` for missing root lookup. Stream the generated pack without multiple
-  full in-memory copies. Add cooperative cancellation and concurrency tests.
-- Add connection and global limits for SSH Git channels. Add inactivity,
-  wall-clock, and total byte limits for each channel. Acquire maintenance locks
-  only during final validation and promotion.
-
-Gate: all Priority 0 regression tests pass. Resource tests prove the configured
-memory, concurrency, time, and byte limits. Do not start M7.2 before this gate
-passes.
-
-##### M7.2 — Priority 1 security and abuse resistance
-
-- Revoke all active feed tokens during account recovery and suspension that is
-  related to account compromise.
-- Apply rate limits to signup and recovery. Aggregate or sample repeated
-  anonymous audit failures. Specify audit retention and monitoring behavior.
-- Replace recursive receive-pack delta depth calculation with an iterative,
-  bounded walk. Apply one deadline to indexing, validation, policy checks, and
-  promotion.
-- Replace recursive archive traversal with an explicit stack. Enforce a maximum
-  tree depth.
-- Apply a streaming 64 KiB route limit to `/login/verify-file`. Limit the
-  multipart form field count, each field size, and the total decoded size.
-- Implement trusted `Forwarded` or `X-Forwarded-For` handling, or remove the
-  unused trusted-proxy configuration. Add tests for spoofed headers and multiple
-  trusted proxies.
-
-Gate: all Priority 1 regression and black-box tests pass. Recovery and
-suspension leave no active feed token. Signup, recovery, receive-pack, archive,
-multipart form, and proxy inputs stop at their specified limits. Do not start
-M7.3 before this gate passes.
-
-##### M7.3 — Priority 2 operational integrity
-
-- Add a cleanup guard after the final repository import rename. The guard must
-  remove an unmanaged final directory if a later import operation fails. Add a
-  fault-injection test for each failure point after the rename.
-
-Gate: all Milestone 7 regression tests pass. Run resource tests with constrained
-memory, disk space, and file descriptor limits. Run
-`cargo fmt --check`,
-`cargo clippy --all-targets --all-features -- -D warnings`,
-`cargo test --locked --all-targets --all-features`, and
-`cargo deny check advisories licenses sources`.
-
-### Test strategy
-
-Unit tests cover canonicalization, policies, model conversions, challenge
-encoding, event projection, packet framing, and ref rules. Property tests cover
-path and ref parsing, packet round-trips, state-machine transitions, and model
-conversion invariants. Fuzz targets cover these unauthenticated parsers:
-
-- HTTP input that the framework processes.
-- SSH commands.
-- SSHSIG envelopes.
-- Git packet lines.
-- pack indexes and streams.
-- refs.
-- Markdown.
-
-Integration tests start the actual binary with temporary storage and drive it
-through HTTP, OpenSSH, `ssh-keygen`, and stock Git. They assert results that a user can see,
-filesystem state, database records through public diagnostics, logs, exit
-status, and restart behavior. No integration test substitutes a handler call
-for the process boundary for which it is a test.
-
-Security tests use an access matrix for each resource type and transport. The
-matrix includes each role, suspended accounts, revoked keys, expired sessions,
-and scoped feed tokens. Add each new route and SSH command to this matrix.
-
-Performance tests prevent regressions. Use fixed fixtures to measure idle
-memory, binary size, startup time, and page latency. Also measure clone
-throughput, push memory, feed latency, backup time, and model-upgrade time.
-
-### Definition of done for each task
-
-A task is done only when the documentation describes its public behavior. The
-applicable domain layer enforces its invariants. Errors have types and stable
-transport results. Limits are explicit. Applicable unit tests and black-box
-tests pass.
-`cargo fmt --check`,
-`cargo clippy --all-targets --all-features -- -D warnings`, locked tests,
-dependency checks, and the release build must pass.
-
-Changes to persistence, authentication, authorization, Git refs, backup, or
-recovery also require a failure-path test and an entry in the threat model or
-operations documentation. Review generated snapshots as assertions. Do not
-accept them mechanically.
-
-### Initial execution order
-
-The first implementation cycle must complete only these tasks:
-
-1. M0.1 through M0.6.
-2. M1A. It gives the first SQLite decision record and fixtures.
-3. M1B. It gives real OpenSSH and SSHSIG interoperability tests.
-4. M1C before any HTML repository browser is built.
-5. M1D before account, issue, or pull-request work begins.
-6. The milestone 1 decision record and an explicit go/no-go review.
-
-This order starts with the highest risks. Web and issue work has less technical
-risk than replacement of `git-upload-pack` and `git-receive-pack`. Do a test of
-the write protocol first. This shows a design that cannot operate before the
-CDE shell is completed.
+# CI/CD plan
+
+This work starts after version 0.1.0. It is not part of the version 0.1.0
+release.
+
+## Goal
+
+Add a small first-party runner for repository checks and deployments. Keep the
+runner in the `tit` executable. Do not add a second control service.
+
+## Design
+
+- Store the workflow configuration in each repository.
+- Use the normal `tit` SSH and repository interfaces to get source and report
+  status.
+- Run each job in an isolated operating-system process with explicit time,
+  memory, disk, and output limits.
+- Publish a concise job log and one status for each commit.
+- Keep secrets outside repository content and remove them from logs.
+- Make interrupted jobs recoverable after a server restart.
+- Keep release and artifact signing out of the first runner version.
+
+## Acceptance gate
+
+- A repository owner can add one workflow that runs a command after a push.
+- A contributor can see the queued, active, passed, failed, and cancelled
+  states.
+- A failed or hostile job cannot change another repository or the `tit`
+  instance.
+- A server restart does not lose the final state of a completed job.
+- Linux and macOS tests pass with no external control service.

README.md

Mode 100644100644; object 7e4bd367db5f01bda513ad51

@@ -1,691 +1,278 @@
 # tit
 
-`tit` is a small, self-hosted collaborative development environment (CDE) for
-Git. The current implementation has a read-only Web UI, HTTP and SSH clone
-services, authenticated SSH push, public feeds, and bounded source search.
+`tit` is a small, self-hosted collaborative development environment for Git.
+One executable provides:
 
-Read [PLAN.md](PLAN.md) for the product design and implementation gates. Read
-[CONTRIBUTING.md](CONTRIBUTING.md) before you change code.
+- a server-rendered Web UI;
+- public HTTP clone and authenticated SSH clone and push;
+- accounts that use SSH keys;
+- repositories, issues, pull requests, reviews, and a protected default branch;
+- repository and issue RSS feeds;
+- repository name and source search;
+- public user profiles;
+- backup, restore, diagnostics, audit, and repair commands.
 
-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.
+The server does not require Git, OpenSSH, JavaScript, or an external database
+at run time. SQLite stores application data. Bare repository directories store
+Git objects and refs.
 
 ## Build
 
-Install the Rust toolchain that `rust-toolchain.toml` specifies. Then, run:
+Install the Rust toolchain that `rust-toolchain.toml` specifies. Build the
+release executable:
 
 ```text
-cargo build --locked
+cargo build --locked --release
 ```
 
-## Run
+The executable is `target/release/tit`.
 
-Create the first administrator and import a bare repository before you start
-the server:
+## Configure
+
+Create a private instance directory and copy the example configuration:
+
+```text
+install -d -m 700 /srv/tit
+install -m 600 config.example.toml /srv/tit/config.toml
+```
+
+Set `public_url`, the HTTP listener, and the SSH listener and advertised
+hostname in `/srv/tit/config.toml`. Use HTTPS for a public instance. The
+included files in `release/examples/` contain service definitions and a Caddy
+example for Linux, macOS, FreeBSD, OpenBSD, and NetBSD.
+
+Create the first administrator:
 
 ```text
 tit --config /srv/tit/config.toml setup admin alice "SSH_PUBLIC_KEY"
-tit --config /srv/tit/config.toml admin repository import alice example /absolute/path/example.git
+```
+
+The command prints one recovery credential. Store it offline.
+
+Start the HTTP and SSH servers:
+
+```text
 tit --config /srv/tit/config.toml serve
 ```
 
-The `serve` command starts the HTTP and SSH listeners in one process. It creates
-`ssh_host_ed25519_key` with mode 600 during the first start and uses the same
-host key during subsequent starts. Keep this file with the instance data.
+The server creates and preserves an Ed25519 SSH host key in the instance
+directory. It holds an instance lock until shutdown. Send SIGINT or SIGTERM for
+a controlled shutdown.
 
-The server owns the instance lock until it receives SIGINT or SIGTERM. It does
-not put a process ID in the lock file. Stop the server before you run an offline
-administrator command.
+Use `GET /healthz` as a readiness check. Use `GET /metrics` for the fixed,
+unlabelled process counters.
 
-Use `GET /healthz` or `HEAD /healthz` as the readiness probe. The endpoint
-returns status 200 only after the HTTP and SSH listeners are ready. During
-shutdown, the server gives active connections 10 seconds to finish. It then
-cancels unfinished connections.
+## Accounts and login
 
-Create a signup code through the control socket while the server runs:
+Create a one-time signup code while the server runs:
 
 ```text
 tit --config /srv/tit/config.toml invite-code
 ```
 
-The code is valid for one signup during the next 24 hours. Open `/signup` to
-create the account. Store the recovery credential offline when the Web UI shows
-it. Open `/recover` to replace all account keys with a new key.
+Open `/signup` and submit the code with an SSH public key. The Web UI displays
+the new recovery credential one time.
 
-Stop the server before you change repository access with an offline
-administrator command:
+Web login uses SSH approval. Start login in the browser, then run the displayed
+`auth` SSH command. The fallback flow signs a short-lived challenge with
+`ssh-keygen -Y sign`. Account recovery replaces the account keys, revokes
+sessions and feed tokens, and issues a new recovery credential.
 
-```text
-tit --config /srv/tit/config.toml admin repository visibility alice example private
-tit --config /srv/tit/config.toml admin repository collaborator-set alice example bob writer
-tit --config /srv/tit/config.toml admin repository collaborator-remove alice example bob
-```
+Each account has a public `/<username>` profile. The account can publish a
+plain-text bio and contact email. The profile lists only public repositories.
+The account page lists active and revoked SSH keys. A fresh SSH `auth`
+approval is required to add or revoke a key.
 
-The policy permits a reader to read and a writer to write. It permits a
-maintainer to change repository settings and collaborators. Only the owner can
-change ownership. An owner or collaborator can read a private repository in the
-Web UI after login. The built-in SSH server binds the supplied key to its
-account. The SSH username does not select the account. An owner, maintainer, or
-writer can push branches and tags. A reader cannot push. HTTP Git access stays
-read-only.
+## Repositories
 
-The `[limits]` configuration sets the maximum HTTP request size and the maximum
-concurrent HTTP requests and SSH sessions. The default values are 1 MiB and
-1024. A route can use a smaller request limit. Each HTTP request has a
-30-second time limit. An HTTP request can wait for one second for a concurrency
-permit.
-
-The server permits 10 Web login attempts per client address in one minute. It
-permits 30 SSH authentication attempts per client address in one minute. It
-keeps attempt state for a maximum of 4096 client addresses. SSH also permits a
-maximum of three public-key authentication attempts on one connection and
-closes an inactive connection after 30 seconds.
-
-Git reads have byte, object-count, entry-count, and 30-second limits. Git
-receive-pack has byte, object-count, delta-depth, and processing-time limits.
-Markdown rendering accepts a maximum of 256 KiB of source and 1 MiB of rendered
-HTML. The Web UI shows a limit message instead of content that exceeds a
-Markdown limit.
-
-Stop the server and show the newest audit events with this command:
+An authenticated account can create a repository through SSH:
 
 ```text
-tit --config /srv/tit/config.toml admin audit --limit 100
+ssh -p 2222 tit.example repo create project
 ```
 
-Each event shows its action, actor, target, outcome, time, and correlation ID.
-The history does not store recovery credentials, login challenges, signatures,
-session tokens, or SSH private keys.
+List all SSH commands:
+
+```text
+ssh -p 2222 tit.example help
+```
+
+An invalid SSH command returns a nonzero status and directs the user to
+`help`.
+
+Clone a public repository through HTTP or SSH:
+
+```text
+git clone https://tit.example/alice/project.git
+git clone ssh://tit@tit.example:2222/alice/project.git
+```
+
+HTTP Git access is read-only. SSH permits repository readers to clone and
+writers, maintainers, and owners to push. The SSH login name does not select
+the account; the SSH key does.
+
+An administrator can import an existing bare repository while the server is
+stopped:
+
+```text
+tit --config /srv/tit/config.toml admin repository import \
+  alice project /absolute/path/project.git
+```
+
+Owners and maintainers can change a repository description, visibility, and
+collaborators on the repository settings page. They can select an existing
+branch as the default branch and archive the repository. The owner can rename
+or unarchive the repository. A rename does not move the bare repository
+directory.
+
+The ref policy is fixed. `tit` rejects force updates, prevents deletion of the
+default branch, and permits only owners and maintainers to update it.
+
+Use the offline administrator commands to import or rename repositories and
+to administer accounts. Run the applicable command with `--help` for its exact
+arguments:
+
+```text
+tit admin repository --help
+tit admin account --help
+```
+
+## Web UI
+
+Anonymous users can browse recently updated public repositories and public
+profiles. Authenticated users get an account overview and their repositories.
+
+A repository page provides refs, commits, trees, blobs, blame, archives,
+source search, issues, pull requests, watch state, and RSS. The recent commit
+list shows ten commits and links to the complete commit view. Forms and
+navigation operate without JavaScript.
+
+Issue and pull-request comments use bounded Markdown. Raw HTML and unsafe links
+do not render.
+
+Authenticated accounts can open `/activity` to see recent events from watched
+repositories. A commit page can download a `.patch` file. Each pull-request
+revision has an immutable `.patch` download. These files work with
+`git apply`.
+
+The SSH interface can list and create issues and pull requests. It can also
+comment on, close, and reopen issues, and close and reopen pull requests.
+Commands support human output and versioned JSON output. Run
+`ssh -p 2222 tit.example help` for the exact syntax.
 
 ## Backup and restore
 
-For an offline backup, stop the server and run:
+Create a backup while the server is stopped or active:
 
 ```text
-tit --config /srv/tit/config.toml backup /var/backups/tit-2026-07-23.tar
+tit --config /srv/tit/config.toml backup /var/backups/tit-backup.tar
 ```
 
-For an online backup, leave the server active and run the same command. The CLI
-sends the request through the private control socket. The server pauses Git
-ref mutations while it makes the SQLite backup and copies the repositories,
-configuration, and SSH host key.
+An online backup uses the private control socket and pauses Git ref mutations
+while it copies consistent state. A backup contains credentials. Store it as a
+secret.
 
-The output file must be an absolute path outside `/srv/tit`. The command creates
-a new mode-0600 file and does not replace an existing file. The backup contains
-credentials. Store it as a secret.
-
-Restore always uses a different, empty instance directory:
+Restore to a new, empty instance directory:
 
 ```text
 install -d -m 700 /srv/tit-restored
-tit restore /var/backups/tit-2026-07-23.tar /srv/tit-restored
+tit restore /var/backups/tit-backup.tar /srv/tit-restored
 tit --config /srv/tit-restored/config.toml doctor
 ```
 
-Restore checks the manifest, all checksums, the database, and all repositories.
-It does not activate the restored instance. To activate it, stop the old server
-and explicitly start `tit --config /srv/tit-restored/config.toml serve`.
+Restore does not activate the new instance. Stop the old server before you
+start the restored instance.
 
-## Diagnostics and repair
+## Diagnostics
 
-Run the read-only instance checks with:
+Check an instance without changing it:
 
 ```text
 tit --config /srv/tit/config.toml doctor
+tit --config /srv/tit/config.toml doctor \
+  --backup /var/backups/tit-backup.tar
 ```
 
-Also check one or more backup archives with:
+Inspect records or write deterministic JSON Lines:
 
 ```text
-tit --config /srv/tit/config.toml doctor --backup /var/backups/tit-2026-07-23.tar
+tit --config /srv/tit/config.toml inspect account alice
+tit --config /srv/tit/config.toml inspect repository alice project
+tit --config /srv/tit/config.toml dump >tit-dump.jsonl
 ```
 
-Doctor checks configuration, private permissions, the schema, record
-relations, indexes, incomplete intents, Git refs and reachable objects,
-quarantine debris, the SSH host key, and each supplied backup. It does not
-change the instance.
+The dump can contain credential and token hashes. Store it as a secret.
 
-Use the separate repair commands only after you review the doctor error:
+Run a repair command only after `doctor` reports the applicable problem and
+only while the server is stopped:
 
 ```text
 tit --config /srv/tit/config.toml repair intents
 tit --config /srv/tit/config.toml repair quarantine
 ```
 
-Stop the server before repair. Intent repair uses the normal recovery rules.
-Quarantine repair refuses to run while an incomplete intent exists.
-
-Inspect one typed record as JSON:
-
-```text
-tit --config /srv/tit/config.toml inspect account alice
-tit --config /srv/tit/config.toml inspect repository alice example
-tit --config /srv/tit/config.toml inspect intent 0123456789abcdef0123456789abcdef
-```
-
-Write all SQLite rows as deterministic JSON Lines:
+Run maintenance while the server is stopped. This command removes expired or
+revoked workflow records and old audit events, then compacts SQLite. The
+default retention period is 365 days:
 
 ```text
-tit --config /srv/tit/config.toml dump >tit-dump.jsonl
+tit --config /srv/tit/config.toml maintenance
+tit --config /srv/tit/config.toml maintenance --retention-days 90
 ```
 
-The dump can contain credential hashes, session hashes, token hashes, and SSH
-public keys. Store it as a secret. The dump is for inspection and comparison;
-it is not a restore format.
+## Upgrade
 
-## Observability
+Make and verify a backup before an upgrade. Stop the server, replace the
+executable, and start the server. `tit` applies forward-only SQLite migrations
+during startup. Run `doctor` after startup.
 
-The `serve` command writes one JSON object per line to standard error. HTTP
-request events contain a request ID, method, status, and duration. SSH
-connection, authentication, and command events contain an operation ID. Server
-lifecycle events record start, readiness, and shutdown.
+To go back to an older version, restore the pre-upgrade backup into a new
+instance directory. Do not run an older executable against a newer database.
 
-Logs do not contain URLs, request headers, request bodies, public keys, or
-client addresses. Thus, they do not contain authorization headers, cookies,
-feed tokens, recovery credentials, login challenges, raw signatures, or SSH
-private keys. Audit history continues to record durable security and mutation
-events in SQLite.
+## Release packages
 
-`GET /metrics` returns six fixed process counters as plain text. The counters
-cover HTTP requests, HTTP errors, active HTTP requests, SSH connections,
-rejected SSH authentication, and SSH operations. The endpoint has no labels
-and does not contain repository, account, path, or client data.
-
-An authenticated account can create a repository with SSH:
+Build a release archive for the current host:
 
 ```text
-ssh -p 2222 tit.example repo create project
+cargo build --locked --release
+./scripts/package-release target/release/tit dist
 ```
 
-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
-selects SHA-256:
+Verify an archive and its checksum:
 
 ```text
-ssh -p 2222 tit.example repo create project --object-format sha256
+./scripts/verify-release-artifact \
+  dist/tit-VERSION-TARGET.tar.gz \
+  dist/tit-VERSION-TARGET.tar.gz.sha256
 ```
 
-Use the versioned JSON mode for scripts:
+The package contains the executable, example service files, shell
+completions, the manual page, the example configuration, this README, and the
+license.
+
+## Development
+
+Read [CONTRIBUTING.md](CONTRIBUTING.md) before you change code. Run the quality
+gate directly:
 
 ```text
-ssh -p 2222 tit.example repo create project --output json
+cargo fmt --check
+cargo clippy --all-targets --all-features -- -D warnings
+cargo test --locked --all-targets --all-features
+cargo deny check advisories licenses sources
+cargo build --locked --release
 ```
 
-The complete command is `repo create NAME [--object-format sha1|sha256]
-[--output human|json]`. Human output can change in a later release. The JSON
-object has `version`, `status`, and `repository` fields after success. It has
-`version`, `status`, and `error.code` fields after failure. The command returns
-zero after success and nonzero after failure.
-
-## Quality gate
-
-Install `cargo-deny` version 0.20.2. Then, run this command from the repository
-root:
+The ignored workload tests are explicit performance checks:
 
 ```text
-./scripts/check
+cargo test --locked --release --test git_reads \
+  measures_bounded_search_without_an_index -- --ignored --nocapture
+cargo test --locked --release --test metadata_search \
+  measures_bounded_repository_name_search_without_an_index \
+  -- --ignored --nocapture
+cargo test --locked --release --test sqlite_workload \
+  -- --ignored --nocapture
 ```
 
-This command formats, lints, tests, audits, and builds the release executable.
-
-## Milestone 1A gate
-
-Run the SQLite durability gate on a local filesystem:
-
-```text
-./scripts/check-m1a
-```
-
-This command also creates and measures a release database with 10,000 issue
-records and 1,000,000 event records. Read the SQLite
-[architectural decision record](docs/adr/0001-sqlite-storage.md) for the limits
-and current platform evidence.
-
-## Milestone 1B gate
-
-Install stock OpenSSH. Then, run the SSH identity gate:
-
-```text
-./scripts/check-m1b
-```
-
-This command uses stock `ssh`, `ssh-agent`, `ssh-add`, and `ssh-keygen` to do
-tests of public-key authentication, SSH request restrictions, and SSHSIG login
-challenges. Read the SSH identity
-[architectural decision record](docs/adr/0002-ssh-identity.md) for the supported
-algorithms, limits, and current platform evidence.
-
-## Milestone 1C gate
-
-Install stock Git and OpenSSH. Then, run the read-side Git protocol gate:
-
-```text
-./scripts/check-m1c
-```
-
-This command uses stock Git to clone and fetch SHA-1 and SHA-256 repositories
-through smart HTTP and SSH. Read the read-side Git
-[architectural decision record](docs/adr/0003-read-side-git.md) for the protocol
-versions, limits, known omissions, and current platform evidence.
-
-## Database check
-
-An initialized instance keeps its metadata in `tit.sqlite3`. Check an existing
-database with this command:
-
-```text
-tit --config /absolute/path/to/tit/config.toml doctor
-```
-
-The command does not create or migrate a database. Successful validation writes
-no output and returns exit code 0.
-
-## Configuration validation
-
-Copy `config.example.toml` into an empty instance directory. Change
-`public_url` to the canonical HTTPS URL of the instance. Then, run:
-
-```text
-tit --config /absolute/path/to/tit/config.toml
-```
-
-Successful validation writes no output and returns exit code 0. A configuration
-error writes a diagnostic to standard error and returns exit code 1. A CLI
-syntax error returns exit code 2. Help and version output use standard output.
-
-The directory that contains `config.toml` is the instance directory. HTTP clone
-URLs use `public_url`. SSH clone URLs use `ssh.public_host` and
-`ssh.public_port`. By default, `ssh.public_host` uses the host from `public_url`.
-Listener addresses do not change public clone URLs.
-
-`ssh.public_host` accepts a DNS hostname, an IP address, or an `.onion`
-hostname. `tit` validates and normalizes this value without resolving it. A Tor
-client supplies the necessary SSH `ProxyCommand` configuration.
-
-Use `--user` instead of `--config` to read the configuration from
-`$XDG_DATA_HOME/tit/config.toml`. If `XDG_DATA_HOME` is not set, `tit` uses
-`$HOME/.local/share/tit/config.toml`.
-
-The executable does not run Git or OpenSSH. Tests can use stock clients as
-external test drivers.
-
-## Milestone 2 gate
-
-Install stock Git and OpenSSH. Then, run the read-only CDE gate:
-
-```text
-./scripts/check-m2-8
-```
-
-This command runs the quality gate, measures source search without an index,
-and tests the public routes with SHA-1 and SHA-256 repositories. Read the
-[source search architectural decision record](docs/adr/0008-bounded-source-search.md)
-for the search limits and current measurement.
-
-## Milestone 3.1 gate
-
-Install stock Git and OpenSSH. Then, run the account lifecycle gate:
-
-```text
-./scripts/check-m3-1
-```
-
-This command tests invitation, signup, recovery, key revocation, account
-suspension, and the owner-only control socket. Read the
-[account lifecycle architectural decision record](docs/adr/0010-account-lifecycle.md)
-for the credential and failure behavior.
-
-## Milestone 3.2 gate
-
-Install stock OpenSSH. Then, run the Web login gate:
-
-```text
-./scripts/check-m3-2
-```
-
-Open `/login` and select **Log in with SSH**. Run the exact command, check the
-origin and account in its output, and continue in the browser. If the SSH
-service is not available, create a fallback challenge and sign its exact
-content with the `tit-auth` SSHSIG namespace. The Web UI accepts the signature
-file and creates an opaque session. Read the
-[Web login architectural decision record](docs/adr/0011-web-login-sessions.md)
-for the session and CSRF behavior.
-
-## Milestone 3.3 gate
-
-Run the repository authorization gate:
-
-```text
-./scripts/check-m3-3
-```
-
-This command tests public and private visibility, each collaborator role,
-suspended accounts, archived repositories, and anonymous HTTP routes. Read the
-[repository authorization architectural decision record](docs/adr/0012-repository-authorization.md)
-for the complete access matrix.
-
-## Milestone 3.4 gate
-
-Install stock Git and OpenSSH. Then, run the authenticated Git gate:
-
-```text
-./scripts/check-m3-4
-```
-
-This command tests account-bound SSH keys, each repository role, key revocation,
-account suspension, role removal, push permission, and ref policy. Read the
-[authenticated Git architectural decision record](docs/adr/0013-authenticated-git.md)
-for the service and ref-update checks.
-
-## Milestone 3.5 gate
-
-Run the audit history gate:
-
-```text
-./scripts/check-m3-5
-```
-
-This command tests successful and failed account, login, repository,
-collaborator, and ref audit events. It also tests correlation IDs and secret
-exclusion. Read the
-[audit history architectural decision record](docs/adr/0014-audit-history.md)
-for the transaction and recovery rules.
-
-## Milestone 3.6 gate
-
-Run the SSH repository command gate:
-
-```text
-./scripts/check-m3-6
-```
-
-This command tests repository creation from the Web UI and SSH. It tests
-account ownership, both object formats, human output, JSON output, failure
-codes, audit events, and clone access. Read the
-[SSH repository command architectural decision record](docs/adr/0015-ssh-repository-commands.md)
-for the command and authorization rules.
-
-## Milestone 3 gate
-
-Run the complete account and authorization gate:
-
-```text
-./scripts/check-m3
-```
-
-This command tests one Web and SSH identity, account recovery, key revocation,
-sessions, repository roles, private route isolation, push policy, audit history,
-and repository creation from the Web UI and SSH.
-
-## Milestone 4.1 gate
-
-Run the repository event service gate:
-
-```text
-./scripts/check-m4-1
-```
-
-This command tests event migration, random public IDs, repository sequences,
-versioned JSON payloads, atomic metadata and event writes, Git operation
-recovery, feed parsing, and sequence pagination. Read the
-[repository event service architectural decision record](docs/adr/0016-repository-event-service.md)
-for the event type and payload contracts.
-
-## Milestone 4.2 gate
-
-Run the issue workflow gate:
-
-```text
-./scripts/check-m4-2
-```
-
-This command tests issue numbers, raw Markdown storage, safe rendering, roles,
-comments, state, labels, assignees, the event timeline, transaction rollback,
-sessions, CSRF checks, and forms that operate without JavaScript. Read the
-[issue workflow architectural decision record](docs/adr/0017-issue-workflow.md)
-for the permission and event contracts.
-
-## Milestone 4.3 gate
-
-Run the repository watch gate:
-
-```text
-./scripts/check-m4-3
-```
-
-This command tests granular push, issue, and pull-request preferences, the
-“everything” selection, stable watch IDs, permission checks, removal, private
-preference handling, CSRF checks, and forms that operate without JavaScript.
-Read the [repository watches architectural decision record](docs/adr/0018-repository-watches.md)
-for the storage and privacy contracts.
-
-## Milestone 4.4 gate
-
-Run the scoped feed gate:
-
-```text
-./scripts/check-m4-4
-```
-
-This command tests public issue feeds, hash-only feed tokens, one-time token
-display, repository and personalized scopes, current private-repository access,
-rotation, revocation, stable event selection, and Atom and RSS parsing. Read the
-[scoped feeds architectural decision record](docs/adr/0019-scoped-feeds.md) for
-the token, authorization, and ordering contracts.
-
-## Milestone 4.5 gate
-
-Run the metadata search gate:
-
-```text
-./scripts/check-m4-5
-```
-
-This command tests bounded repository and issue metadata search, public and
-private visibility, current collaborator permission, stable result identity,
-query validation, and the representative index workload. Read the
-[bounded metadata search architectural decision record](docs/adr/0020-bounded-metadata-search.md)
-for the limits, authorization, and index decision.
-
-## Milestone 4.6 gate
-
-Install stock OpenSSH. Then, run the SSH issue command gate:
-
-```text
-./scripts/check-m4-6
-```
-
-This command tests issue creation and listing with human and JSON output. It
-tests owner and reader permission, hidden private repositories, suspended
-account access, invalid input, raw Markdown storage, and atomic issue events.
-Read the [SSH issue command architectural decision record](docs/adr/0021-ssh-issue-commands.md)
-for the input, output, authorization, and error contracts.
-
-## Milestone 5.1 gate
-
-Install stock Git. Then, run the pull-request ref gate:
-
-```text
-./scripts/check-m5-1
-```
-
-This command tests increasing pull-request numbers, SHA-1 and SHA-256 refs,
-immutable revision object IDs, concurrent opens, intent recovery before and
-after a ref change, the Web forms, and historical schema migration. Read the
-[pull-request ref architectural decision record](docs/adr/0022-pull-request-refs.md)
-for the record, ref, permission, and recovery contracts.
-
-## Milestone 5.2 gate
-
-Run the pull-request comparison gate:
-
-```text
-./scripts/check-m5-2
-```
-
-This command tests SHA-1 and SHA-256 merge bases, commit ranges, changed paths,
-diffs, immutable revision selection, mergeability states, unrelated histories,
-work limits, and Web output. Read the
-[pull-request comparison architectural decision record](docs/adr/0023-pull-request-comparison.md)
-for the computation, limit, and cache contracts.
-
-## Milestone 5.3 gate
-
-Run the pull-request review gate:
-
-```text
-./scripts/check-m5-3
-```
-
-This command tests general comments, line comments, approvals, change requests,
-immutable byte-path anchors, outdated display, repository permission, atomic
-events, the chronological timeline, no-JavaScript Web forms, and historical
-schema migration. Read the
-[pull-request review architectural decision record](docs/adr/0024-pull-request-review.md)
-for the anchor, permission, event, and outdated-state contracts.
-
-## Milestone 5.4 gate
-
-Run the pull-request merge gate:
-
-```text
-./scripts/check-m5-4
-```
-
-This command tests fast-forward and server-created merge commits, SHA-1 and
-SHA-256 repositories, rename and mode preservation, deterministic parents,
-attribution, conflict and stale-ref rejection, intent recovery, concurrent base
-updates, atomic events, the no-JavaScript Web form, and historical schema
-migration. Read the
-[pull-request merge architectural decision record](docs/adr/0025-pull-request-merge.md)
-for the merge, permission, intent, and recovery contracts.
-
-## Milestone 5.5 gate
-
-Run the branch-rule gate:
-
-```text
-./scripts/check-m5-5
-```
-
-This command tests protected-ref access, fast-forward-only branches,
-force-push rejection, protected-ref deletion rejection, topic-branch access,
-and pull-request merge permission. Read the
-[branch-rule architectural decision record](docs/adr/0026-branch-rules.md) for
-the ref, role, transport, and merge contracts.
-
-## Milestone 5.6 gate
-
-Run the SSH pull-request command gate:
-
-```text
-./scripts/check-m5-6
-```
-
-This command tests bounded command parsing, exact human output, versioned JSON
-output, repository read permission, and the returned stock Git fetch and
-checkout commands. Read the
-[SSH pull-request checkout architectural decision record](docs/adr/0027-ssh-pull-request-checkout.md)
-for the command, output, permission, and error contracts.
-
-## Milestone 5 gate
-
-Install stock Git and stock OpenSSH. Then, run the collaboration gate:
-
-```text
-./scripts/check-m5
-```
-
-This command tests branch pushes, pull-request revisions, anchored reviews,
-merges, concurrent ref updates, and recovery after an interrupted ref update.
-It also tests the Web workflow, the SSH checkout command, and historical schema
-migration.
-
-## Milestone 6.1 gate
-
-Run the process lifecycle gate:
-
-```text
-./scripts/check-m6-1
-```
-
-This command tests listener readiness, SIGTERM shutdown, the bounded connection
-drain, the empty advisory lock file, and rejection of a second server process.
-Read the
-[process lifecycle architectural decision record](docs/adr/0028-process-lifecycle.md)
-for the readiness, shutdown, and instance-lock contracts.
-
-## Milestone 6.2 gate
-
-Install stock Git. Then, run the backup and restore gate:
-
-```text
-./scripts/check-m6-2
-```
-
-This command tests offline and online backup, owner-only archive permissions,
-the global maintenance gate, manifest checksum failures, empty restore targets,
-database validation, and restored Git object access. Read the
-[backup and restore architectural decision record](docs/adr/0029-backup-and-restore.md)
-for the archive, gate, credential, and activation contracts.
-
-## Milestone 6.3 gate
-
-Install stock Git and stock `ssh-keygen`. Then, run the diagnostics gate:
-
-```text
-./scripts/check-m6-3
-```
-
-This command tests read-only doctor checks, explicit repair, typed inspection,
-deterministic JSON Lines output, damaged Git state, incomplete intents,
-quarantine debris, missing indexes, unsafe permissions, and changed backup
-data. Read the
-[read-only diagnostics architectural decision record](docs/adr/0030-read-only-diagnostics.md)
-for the check, repair, inspection, and dump contracts.
-
-## Milestone 6.4 gate
-
-Install stock OpenSSH. Then, run the limits gate:
-
-```text
-./scripts/check-m6-4
-```
-
-This command tests HTTP request and login-attempt limits, SSH authentication
-limits, Markdown limits, Git pack limits, and repository diff and archive
-limits. Read the
-[limits and abuse resistance architectural decision record](docs/adr/0031-limits-and-abuse-resistance.md)
-for the limit values and failure behavior.
-
-## Milestone 6.5 gate
-
-Install stock Git and OpenSSH. Then, run the observability gate:
-
-```text
-./scripts/check-m6-5
-```
-
-This command tests structured HTTP, SSH, and lifecycle logs, request and
-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.
+`tit` is licensed under the MIT License. See [LICENSE](LICENSE).

assets/style.css

Mode 100644100644; object 9f1e642e7a1f5830091e4871

@@ -179,6 +179,24 @@
   border-bottom: 1px solid GrayText;
 }
 
+.two-column {
+  display: grid;
+  grid-template-columns: minmax(0, 1fr) minmax(0, 1.6fr);
+  gap: 2rem;
+  align-items: start;
+}
+
+.profile-bio {
+  white-space: pre-wrap;
+  overflow-wrap: anywhere;
+}
+
+@media (max-width: 44rem) {
+  .two-column {
+    grid-template-columns: 1fr;
+  }
+}
+
 .pagination {
   display: flex;
   flex-wrap: wrap;

config.example.toml

Mode 100644100644; object bbb06a4f6afd2134e947e98d

@@ -15,6 +15,3 @@
 [limits]
 max_request_bytes = 1048576
 max_connections = 1024
-
-[proxy]
-trusted_addresses = []

docs/adr/0001-sqlite-storage.md

Mode 100644; object 9e0aeee9fe2c

@@ -1,71 +1,0 @@
-# Architectural decision record 0001: SQLite storage
-
-Status: Accepted
-
-Date: 2026-07-22
-
-## Context
-
-`tit` needs one inspectable metadata database in the instance directory. The
-database must have transactions, constraints, indexes, online backup, and a
-clear recovery procedure. The executable must not need an installed database
-server or an installed SQLite shared library.
-
-## Decision
-
-Use `rusqlite` with the `bundled` and `backup` features. Keep SQL and
-`rusqlite` types in the `store` module. Use numbered SQL migrations and
-`PRAGMA user_version`. Apply all pending migrations in one exclusive
-transaction. Create an online backup before an automatic migration of an
-existing database.
-
-Use WAL mode, `synchronous=FULL`, a five-second busy timeout, and foreign-key
-enforcement on each connection. Verify each setting. Use `tit.sqlite3` as the
-metadata database name. Require a local filesystem until a platform gate proves
-that a different filesystem has correct WAL behavior.
-
-`tit doctor` opens an existing database without the create flag. It does not
-migrate the database. It verifies the schema version, SQLite integrity, and
-foreign keys.
-
-## Evidence
-
-The local gate used Rust 1.96.0 on arm64 macOS 27.0 and a local APFS filesystem.
-It killed child processes during writes and schema migrations. After each kill,
-the database contained the initial full state or the new full state. Tests also
-proved constraints, indexed scans, concurrent reads, serialized writes, busy
-handling, rollback, WAL checkpoint, vacuum, online backup, restore, and
-migration from each committed fixture.
-
-The release workload used 10,000 parent records as issues and 1,000,000 child
-records as events. The measured database size was 113,926,144 bytes. A safe
-migration, including its required backup, took 774 ms. A subsequent online
-backup took 509 ms. For 1,000 indexed queries that each read 25 events, the 50th,
-95th, and 99th percentile times were 4, 5, and 8 microseconds.
-
-The workload gate has these limits:
-
-- the database must not be larger than 1 GiB.
-- migration and backup must each complete in 120 seconds.
-- the 99th percentile query time must not be more than 250 ms.
-
-GitHub Actions run
-[29964179974](https://github.com/mchalunderscore/tit/actions/runs/29964179974)
-passed all quality checks and both hosted workload gates. The Ubuntu 24.04.4
-LTS runner used Rust 1.96.0. Its migration took 1,528 ms, its backup took 954
-ms, and its 99th percentile query time was 18 microseconds. The macOS 26.4
-arm64 runner used Rust 1.96.0. Its migration took 1,774 ms, its backup took
-1,333 ms, and its 99th percentile query time was 22 microseconds. Each database
-was 113,926,144 bytes. BSD and non-local filesystem support stay outside the
-current supported platform set.
-
-## Consequences
-
-SQLite adds a small amount of explicit SQL, but it gives standard inspection
-and recovery tools and a stable file format. Bundled SQLite increases the
-executable size, but it removes variation in the runtime SQLite version.
-
-The application has one serialized SQLite writer. WAL lets readers continue
-during a write. If the measured workload becomes too large for this model, the
-project must review the evidence before it adds a database server, a connection
-pool, or another persistence abstraction.

docs/adr/0002-ssh-identity.md

Mode 100644; object 709e7ded2b21

@@ -1,108 +1,0 @@
-# Architectural decision record 0002: SSH identity
-
-Status: Accepted
-
-Date: 2026-07-22
-
-## Context
-
-`tit` needs one SSH identity for Git transport, SSH commands, and Web login.
-The SSH username must not select the account. The SSH public key must select
-the account. Web login must use the standard SSHSIG format and must prevent
-replay.
-
-## Decision
-
-Use `ssh-key` version 0.7.0-rc.11 for OpenSSH public-key parsing,
-fingerprints, and SSHSIG verification. Russh version 0.62.4 uses this exact
-version, which prevents two SSH key models in the executable. Use Russh with
-the `ring` feature for the built-in SSH server. Do not enable compression or
-the Russh RSA feature. Use Tokio for the asynchronous listener. Use `rand` for
-operating-system random bytes and `sha2` for login nonce hashes.
-
-Accept Ed25519 and ECDSA P-256 public keys. Remove the public-key comment during
-normalization and use its SHA-256 fingerprint. Reject DSA, other ECDSA curves,
-security keys, certificates, unknown algorithms, and RSA. Report an RSA key
-that is smaller than 3,072 bits as undersized before the general RSA rejection.
-
-Russh advertises RSA-SHA2, but its server handler does not receive the RSA
-signature algorithm that the client selected. Thus, the application cannot
-independently reject a custom client that uses RSA-SHA1. Do not accept RSA keys
-until this boundary can enforce RSA-SHA2. A stock OpenSSH client that is forced
-to use `ssh-rsa` cannot authenticate to the M1B server.
-
-The first feasibility gate uses this canonical login challenge:
-
-```text
-tit-auth-v1
-purpose=web-login
-origin=ORIGIN
-username=USERNAME
-fingerprint=FINGERPRINT
-nonce=NONCE
-issued-at=ISSUED_TIME
-expires-at=EXPIRY_TIME
-```
-
-The trailing newline is necessary. Sign the exact bytes with the `tit-auth`
-SSHSIG namespace. A challenge is valid for a maximum of five minutes. The
-challenge is at most 4 KiB. The SSHSIG envelope and SSH public key are each at
-most 16 KiB.
-
-Keep only the SHA-256 hash and expiry of each active login nonce. Keep a maximum
-of 1,024 active challenges and remove expired entries when a new challenge is
-issued. Consume the hash under one lock only after all context and signature
-checks pass. A process restart invalidates all challenges in this feasibility
-implementation. Milestone 3 puts nonce hashes in SQLite. Milestone 6.7 replaces
-the Web fallback challenge with `tit-auth-v2`. That format removes the
-fingerprint field because the SSHSIG envelope contains the signing key.
-
-The SSH server accepts public-key authentication only. It ignores the SSH
-username. It accepts session channels and the exact `tit --version` command.
-It rejects shells, PTYs, subsystems, agents, forwarding, and all other exec
-requests. It accepts only `GIT_PROTOCOL` environment requests with the exact
-values `version=0`, `version=1`, or `version=2`.
-
-## Threats and controls
-
-A copied SSHSIG envelope cannot start a second session because the first valid
-verification removes its nonce hash. Concurrent verification can have only one
-successful result. A signer cannot change the origin, username, fingerprint,
-issue time, or expiry because these fields are in the signed bytes. The verifier
-also compares these fields with its own origin, username, selected key, clock,
-and stored expiry.
-
-The key and signature parsers have input limits before cryptographic work. The
-server has only public-key authentication, three authentication attempts, a
-30-second inactivity limit, and a 32-KiB packet limit from Russh. Rejected SSH
-requests do not start a shell or a process.
-
-## Evidence
-
-The local macOS gate uses stock `ssh`, `ssh-agent`, `ssh-add`, and `ssh-keygen`.
-It verifies Ed25519 and ECDSA P-256 authentication with two different SSH
-usernames. It verifies SSHSIG envelopes that stock `ssh-keygen -Y sign` creates.
-It also verifies the negative cases for replay, concurrent use, expiry, origin,
-namespace, key, malformed input, DSA, ECDSA P-384, RSA, RSA-SHA1, an unknown
-key, shells, PTYs, subsystems, agents, forwarding, arbitrary exec requests, and
-environment values.
-
-GitHub Actions run
-[29965784237](https://github.com/mchalunderscore/tit/actions/runs/29965784237)
-passed all quality checks and both hosted M1B gates. The Ubuntu 24.04.4 runner
-and the macOS 26.4 arm64 runner used Rust 1.96.0. Each release job passed all
-seven authentication tests and all four SSH server tests in debug and release
-modes. The SSH tests used the stock OpenSSH client and tools on each runner.
-
-## Consequences
-
-The selected crates add cryptographic and asynchronous code to the executable,
-but they remove custom SSH cryptography and transport code. The initial key set
-is smaller because Russh does not give the application the information that it
-needs to enforce RSA-SHA2. This omission is safer than acceptance of RSA-SHA1.
-
-The server boundary is not connected to `tit serve` in M1B because the account
-store and instance host-key lifecycle do not exist. The server accepts an
-explicit set of normalized keys. Subsequent milestones must supply that set
-through the account and authorization services without changing the transport
-policy.

docs/adr/0003-read-side-git.md

Mode 100644; object 04e0903e2cf3

@@ -1,99 +1,0 @@
-# Architectural decision record 0003: read-side Git protocol
-
-Status: Accepted
-
-Date: 2026-07-22
-
-## Context
-
-`tit` must serve Git repositories without the Git executable or another runtime
-service. Stock Git clients must clone and fetch SHA-1 and SHA-256 repositories
-through SSH and smart HTTP. The same repository path rules and object access
-rules must apply to each transport.
-
-## Decision
-
-Use `gix` version 0.84.0 to open bare repositories, read references, decode
-loose and packed objects, and identify each repository object format. Use
-`gix-pack` version 0.71.0 to write pack version 2 with the repository hash
-algorithm. Enable SHA-1, SHA-256, pack generation, and the thread-safe `gix`
-mode. Use Axum version 0.8.9 for the smart HTTP adapter. Keep Russh version
-0.62.4 for the SSH adapter.
-
-Use one upload-pack service behind the HTTP and SSH adapters. Resolve
-`owner/repository{,.git}` below one canonical repository root. Reject invalid
-names, additional path components, commands other than `git-upload-pack`, and
-paths that resolve outside the root. The SSH username does not select the
-repository or account.
-
-Support Git protocol versions 0, 1, and 2. Protocol versions 0 and 1 advertise
-`agent` and `object-format`. Protocol version 1 also sends its version packet.
-Protocol version 2 advertises `agent`, `object-format`,
-`ls-refs` with `symrefs` and `peel`, and `fetch` with `wait-for-done`. Smart
-HTTP uses the standard discovery and result content types and disables response
-caching. SSH and HTTP use the same reference and pack functions.
-
-The packet-line parser accepts data, flush, delimiter, and response-end
-packets. It rejects an invalid hexadecimal length, reserved length, incomplete
-header, incomplete payload, and oversized input. The server validates each
-wanted object against its advertised references. It walks commits, trees, and
-tags and does not follow a Gitlink into another repository. It removes objects
-that are reachable from a client `have` line.
-
-Generate a complete pack without deltas. This choice gives a small and
-inspectable feasibility implementation and works when the source repository
-stores objects in loose or delta form. It uses more CPU and network bytes than
-an optimized pack. Milestone 2 must measure normal repositories before it adds
-delta selection or bitmap traversal.
-
-Run repository reads and pack generation as blocking jobs. Keep a maximum of
-four blocking Git jobs for one repository root. The async HTTP and SSH loops do
-not do object traversal or compression.
-
-## Limits
-
-- A packet-line is at most 65,520 bytes.
-- One request is at most 1 MiB.
-- One generated pack contains at most 100,000 reachable objects.
-- One decoded object is at most 64 MiB.
-- The total decoded data and the generated pack are each at most 256 MiB.
-- One repository service runs at most four blocking Git jobs at the same time.
-
-The service does not advertise shallow fetches, partial clone filters, or
-side-band support in protocol versions 0 and 1. Protocol version 2 uses its
-necessary pack side band. The service does not support hidden references,
-replacement objects, or alternates policy in this milestone.
-
-## Evidence
-
-The local macOS gate uses stock Git 2.54.0 and Rust 1.96.0. Stock Git clones
-and repeatedly fetches SHA-1 and SHA-256 repositories through smart HTTP and
-SSH with protocol versions 1 and 2. SHA-1 tests also use protocol version 0. The
-fixtures include an empty repository, branches, an annotated tag, non-ASCII
-filenames, a 2 MiB blob, and packed delta objects. The tests compare the
-received references and objects with the source.
-
-Negative tests cover a damaged reachable object, an unadvertised want, the
-wrong object format, an invalid repository path, an invalid SSH command, an
-invalid HTTP service query, an invalid content type, an invalid protocol
-version, malformed packet lines, and oversized requests. A separate test
-process gives the server a `git` sentinel that fails and records use. An
-absolute stock Git client clones through that server. The clone succeeds and
-the sentinel is not used.
-
-The stripped local arm64 macOS release executable is 2,863,648 bytes. Hosted
-[CI run 29968062175](https://github.com/mchalunderscore/tit/actions/runs/29968062175)
-passed on 2026-07-23. It used Cargo 1.96.0 on Ubuntu 24.04 and arm64 macOS 26.
-The release executables were 3,239,192 bytes on Ubuntu and 2,847,136 bytes on
-macOS.
-
-## Consequences
-
-The selected `gix` crates remove custom repository, object, and pack-file
-decoders. The application still owns a small server-side upload-pack state
-machine because the available `gix-protocol` API is client-oriented.
-
-The initial pack generator holds the generated pack in memory and sends no
-deltas. The explicit 256 MiB limit prevents unbounded allocation, but this
-design is for the feasibility gate. A subsequent change can stream pack chunks
-after it keeps the same request, object, time, and concurrency limits.

docs/adr/0004-milestone-1-feasibility.md

Mode 100644; object 9b09d42c46db

@@ -1,125 +1,0 @@
-# Architectural decision record 0004: Milestone 1 feasibility
-
-Status: Accepted
-
-Date: 2026-07-23
-
-## Context
-
-`tit` must use one executable and must not use Git, OpenSSH, a database server,
-or a shared SQLite library at runtime. Milestone 1 had to prove SQLite storage,
-SSH identity, Git clone and fetch, and Git push before account and Web UI work
-could start.
-
-## Decision
-
-Continue the project with the selected design. Use bundled SQLite through
-`rusqlite` for metadata. Use `ssh-key` for SSH public keys and SSHSIG. Use
-Russh for the built-in SSH server. Use `gix` and `gix-pack` for repository,
-object, and pack operations. Use Axum for smart HTTP. Use Tokio for bounded
-asynchronous adapters and blocking jobs.
-
-Support Git protocol versions 0, 1, and 2 for upload-pack. Smart HTTP and SSH
-use the same upload-pack service. Receive-pack uses the standard SSH protocol
-and supports SHA-1 and SHA-256 repositories. It advertises only
-`report-status`, `report-status-v2`, `delete-refs`, `atomic`, `ofs-delta`,
-`object-format`, and `agent`.
-
-Keep the custom Git protocol code small. The application owns packet-line
-parsing, upload-pack request state, receive-pack command parsing, reference
-policy, quarantine control, and status output. The selected crates own Git
-object decoding, hash verification, delta resolution, pack indexing, and
-reference transactions.
-
-Use a durable SQLite intent for each push. Store the actor, repository path,
-initial refs, proposed refs, event data, quarantine path, and state. Validate
-the complete push before a ref update. Promote validated objects while they
-are unreachable. Update all requested refs in one reference transaction. Mark
-the intent complete and add its event in one SQLite transaction.
-
-At startup, compare each incomplete intent with the repository refs. Abandon
-an operation that did not update refs. Complete an operation that updated all
-refs. Stop startup if the refs have a mixed state. Serialize push validation
-and ref updates for one configured repository root. Reads can continue during
-a push.
-
-## Limits
-
-- One packet-line request is at most 1 MiB.
-- The SSH adapter accepts at most 128 MiB of pack input.
-- Receive-pack accepts at most 256 ref commands and 100,000 objects.
-- One decoded object is at most 64 MiB.
-- A received pack is at most 256 MiB after thin-pack resolution.
-- A delta chain has at most 64 levels.
-- Object validation walks at most 500,000 objects.
-- Pack indexing has a 30-second time limit and uses at most two threads.
-- One repository root runs one push validation at a time and at most four
-  blocking Git jobs at a time.
-
-## Evidence
-
-The storage gate kills processes during writes and each migration boundary. It
-tests constraints, indexes, concurrent access, backup, restore, and all schema
-fixtures. The current release workload has 10,000 issue-like rows and 1,000,000
-event-like rows. Hosted run
-[29970079293](https://github.com/mchalunderscore/tit/actions/runs/29970079293)
-measured a 113,946,624-byte database. Ubuntu completed migration in 1,001 ms,
-backup in 407 ms, and its 99th percentile query in 23 microseconds. macOS
-completed migration in 2,660 ms, backup in 2,069 ms, and its 99th percentile
-query in 35 microseconds. These results pass the storage limits by a large
-margin.
-
-The identity gate uses stock OpenSSH tools. It accepts Ed25519 and ECDSA P-256,
-uses the SSH public-key fingerprint as identity, verifies stock SSHSIG
-envelopes, prevents nonce replay, and rejects unsafe SSH capabilities. The
-server does not start a shell, agent, subsystem, forward, or arbitrary command.
-
-The read gate uses stock Git through smart HTTP and SSH. It clones empty and
-non-empty SHA-1 and SHA-256 repositories, then does repeated fetches. The
-fixtures include branches, an annotated tag, non-ASCII filenames, a 2 MiB
-blob, and stored delta objects. A process test proves that only the external
-test driver starts Git.
-
-The write gate uses stock Git through SSH for SHA-1 and SHA-256. It creates and
-updates branches, creates and deletes multiple refs atomically, deletes tags,
-and reports per-ref status. It rejects a branch that targets a blob, a
-non-fast-forward update, an atomic group with one invalid update, and a push
-from a read-only key. Rejected objects and partial refs are not visible. Tests
-kill the process after intent creation, object promotion, ref update, and event
-completion. Startup recovery gives one full initial or proposed state. A
-deterministic input set sends malformed packet-line and pack data to each
-parser without a panic.
-
-Run 29970079293 used Cargo 1.96.0. The quality job passed formatting, Clippy,
-tests, dependency policy, and the release build. The Ubuntu 24.04 release job
-completed in 4 minutes 26 seconds. The arm64 macOS 26 release job completed in
-5 minutes 20 seconds. The stripped release executable is 3,239,288 bytes on
-Ubuntu and 2,847,104 bytes on macOS.
-
-## Known omissions
-
-The upload-pack generator makes a complete pack without deltas and holds that
-pack in memory. It does not support shallow fetches, partial clone filters,
-hidden refs, replacement objects, or repository alternates policy.
-Receive-pack is available through SSH only. It permits writes only to branches
-and tags. It enforces fast-forward policy for branches and does not yet have an
-account or collaborator-role source for permissions.
-
-The feasibility server uses explicit key and repository inputs and is not
-connected to `tit serve`. Login nonces are in process memory. Push events have
-only the ref-change data that the feasibility gate needs. A process stop during
-the two pack-file rename operations can leave an unreachable pack index, which
-later maintenance must remove.
-
-BSD remains outside the accepted platform set. Linux and macOS are the current
-supported platforms. The project must add a platform gate before it claims
-FreeBSD, OpenBSD, or NetBSD support.
-
-## Consequences
-
-All four Milestone 1 gates pass. The one-executable design and the selected
-crates are feasible for the current product constraints. Milestone 2 can start.
-
-The next work must connect these proven boundaries to instance bootstrap,
-repository ownership, and read-only CDE routes. It must preserve the protocol,
-security, resource, and recovery behavior that this record accepts.

docs/adr/0005-public-http-routes.md

Mode 100644; object 404c50804caa

@@ -1,66 +1,0 @@
-# Architectural decision record 0005: public HTTP routes
-
-Status: Accepted
-
-Date: 2026-07-22
-
-## Context
-
-`tit` needs stable public routes for repository content and Git clone
-discovery. The routes must operate without JavaScript. Repository names can
-change, refs can move, and Git paths can contain bytes that are not UTF-8.
-Private, archived, and missing repositories must give the same public result.
-
-## Decision
-
-Use `/{owner}/{repository}` as the canonical repository summary and HTTP clone
-URL. Accept `/{owner}/{repository}.git` for Git clients. Redirect a browser
-request for that alias to the canonical summary. Use a full commit ID in tree,
-blob, raw, commit, diff, blame, and archive URLs. Do not use a moving ref in a
-content URL.
-
-Encode each Git path byte with URL percent encoding when the byte is not an
-ASCII letter, a digit, `-`, `.`, `_`, `~`, or `/`. Decode the path to bytes
-before a repository read.
-Do not convert a Git path to UTF-8 for object lookup. Display a replacement
-character only when HTML must show a path that is not UTF-8.
-
-Use Askama version 0.16.0 for compile-time HTML templates. Its automatic HTML
-escaping keeps repository text out of active markup. Use `tokio-stream`
-version 0.1.19 only for the interface between the bounded archive channel and
-an Axum response body. This interface lets the archive writer send content
-without storing the complete archive in memory.
-
-Open repository records through the `store` module. Select only records with
-public visibility and active state. Resolve the filesystem path from the
-immutable repository ID. Require the canonical repository path to have the
-canonical repository directory as its direct parent.
-
-Run SQLite access, Git reads, archive generation, and upload-pack operations as
-blocking jobs. Permit a maximum of eight public Web blocking jobs at one time.
-Apply the read limits from milestone 2.3. Stream archive data through a bounded
-channel. Stop archive work when the HTTP client closes the channel.
-
-## Evidence
-
-The black-box test starts one public server for each Git object format. It uses
-real SQLite records and immutable repository paths. Raw HTTP requests browse
-summary, refs, commit, diff, tree, blob, raw, blame, and archive routes. The
-test verifies GET and HEAD behavior, percent-encoded paths, binary content,
-security headers, cache policy, useful 404 pages, and archive content with the
-system `tar` reader.
-
-Stock Git protocol version 2 clones through the same public server. The test
-also verifies that private and archived repositories do not appear in the
-summary, raw, or Git discovery routes. Unit tests cover path bytes that are not
-UTF-8 and malformed percent encoding.
-
-## Consequences
-
-A content URL identifies one immutable commit and does not change when a branch
-moves. A repository rename changes its owner-and-repository URL, but the
-filesystem name and repository ID do not change.
-
-HTML repository views use server-rendered templates and embedded CSS. Raw files
-use `application/octet-stream`. Archives use a streamed ustar body. ADR 0006
-specifies Markdown rendering and sanitization for README content.

docs/adr/0006-safe-markdown.md

Mode 100644; object 284bb8692871

@@ -1,61 +1,0 @@
-# Architectural decision record 0006: safe Markdown
-
-Status: Accepted
-
-Date: 2026-07-23
-
-## Context
-
-`tit` stores Markdown source from users and repositories. A public page must not
-let this source add active HTML, load a remote image, or use an unsafe link.
-The same rules must apply to README files and later CDE text fields.
-
-## Decision
-
-Support these CommonMark elements:
-
-- paragraphs and line breaks;
-- headings from level 1 through level 6;
-- strong text and emphasized text;
-- block quotes;
-- ordered and unordered lists;
-- inline code and fenced or indented code blocks;
-- thematic breaks; and
-- links.
-
-Do not support raw HTML, images, tables, task lists, footnotes, heading
-attributes, or other extensions. Show the alternative text of an image as
-plain text. Ignore a code-block language value.
-
-Permit relative links and absolute `http`, `https`, and `mailto` links. Reject
-scheme-relative links, backslashes, control characters, surrounding spaces,
-and all other URL schemes. Add `rel="nofollow noopener noreferrer"` to each
-link.
-
-Parse Markdown with `pulldown-cmark` version 0.13.4. Remove unsupported parser
-events before HTML generation. Clean the generated HTML with `ammonia` version
-4.1.4 and an exact allowlist. Permit only the tags that the supported subset
-can generate. Permit `href` and `title` on links, and permit `start` on ordered
-lists. Do not permit generic attributes.
-
-Ammonia uses `cssparser` version 0.37.0 and `dtoa-short` version 0.3.5. These
-two crates use the MPL-2.0 license. Add a license exception for each exact crate
-version. Do not add MPL-2.0 to the general license allowlist.
-
-Return rendered content in a separate Rust type. The Askama template can mark
-only this type as safe HTML. Continue to use normal Askama escaping for all
-other repository content.
-
-## Evidence
-
-Unit tests render each supported element. They also use raw scripts, raw
-images, Markdown images, active URL schemes, scheme-relative URLs, mixed-case
-schemes, controls, and backslashes. The public-route test reads hostile content
-from a real Git repository and checks the complete server-rendered page.
-
-## Consequences
-
-README files have a useful small format without an active-content path. Remote
-images cannot track a page view. A future milestone can use the same renderer
-for issue, pull-request, and comment bodies. A change to the subset or URL
-policy needs a change to this record and its hostile-content tests.

docs/adr/0007-public-repository-feeds.md

Mode 100644; object 9c6d1984b5b1

@@ -1,60 +1,0 @@
-# Architectural decision record 0007: public repository feeds
-
-Status: Superseded by architectural decision record 0016
-
-Date: 2026-07-23
-
-## Context
-
-`tit` must publish public repository events as Atom and RSS. Feed entries must
-not describe a push before its Git refs are reachable. Feed pages need stable
-entry IDs, bounded pagination, and HTTP cache validators.
-
-## Decision
-
-Add an append-only `repository_event` table. Insert the repository creation or
-import event in the transaction that inserts the repository. For an import,
-also insert one event for each initial branch and tag.
-
-Insert a push event and one event for each changed branch or tag in the
-transaction that completes a Git operation intent. Do not insert these events
-for a pending, promoted, or abandoned intent. Use the immutable repository ID
-to connect an event to a repository. Use the event row ID in the public entry
-ID.
-
-Migration 006 adds the event table. It also adds one creation event for each
-repository that already exists. It cannot determine whether an old repository
-was created or imported, so it uses the creation event type.
-
-Publish `/{owner}/{repository}/atom.xml` and
-`/{owner}/{repository}/rss.xml`. Return at most 20 entries. Accept a positive
-`before` event ID and return older events. Add a `next` link when more events
-exist. Do not publish feeds for private or archived repositories.
-
-Use Atom entry IDs in this form:
-
-```text
-urn:tit:event:REPOSITORY_ID:EVENT_ID
-```
-
-Use SHA-256 over the response body for the ETag. Also return Last-Modified.
-Honor If-None-Match before If-Modified-Since. Return 304 without a response
-body when a validator matches.
-
-Generate XML with a small escaping function. Use `feed-rs` version 2.4.0 as an
-independent test parser for both output formats. Use `jiff` version 0.2.34 for
-Atom dates and `httpdate` version 1.0.3 for RSS and HTTP dates.
-
-## Evidence
-
-Storage tests cover imports, initial branches and tags, completed pushes,
-branch creation, and migration from schema version 5. The public-route test
-parses Atom and RSS, checks stable entry IDs, follows pagination, checks GET and
-HEAD, checks conditional requests, and hides feeds for private and archived
-repositories.
-
-## Consequences
-
-A completed event is immutable and has a stable ID. A repository rename changes
-the feed URL but does not change an entry ID. A feed reader can use the next
-link to read old events without an unbounded database query.

docs/adr/0008-bounded-source-search.md

Mode 100644; object f734b4af0d8e

@@ -1,59 +1,0 @@
-# Architectural decision record 0008: bounded source search
-
-Status: Accepted
-
-Date: 2026-07-23
-
-## Context
-
-`tit` must search the source at a selected ref. The first implementation must
-not have a permanent index. A search must have file, byte, result, and time
-limits.
-
-## Decision
-
-Add a fixed-text, case-sensitive search to the repository read service. Resolve
-the selected ref to a commit before the search starts. Search the tree of this
-commit in Git path order. Do not search a submodule. Do not search a blob that
-contains a null byte.
-
-Use these default limits:
-
-- Search a maximum of 10,000 files.
-- Read a maximum total of 64 MiB of blob content.
-- Return a maximum of 500 matching lines.
-- Accept a maximum of 256 query bytes.
-- Stop a search after 5 seconds.
-
-The file, byte, and time limits stop the request with an error. The result
-limit returns the first 500 matching lines and tells the user that more results
-exist. Check the cancellation signal and the time limit during the tree and
-line scans.
-
-Publish `/{owner}/{repository}/search`. Use an HTTP GET form that operates
-without JavaScript. The form supplies `q` and `ref` parameters. Accept only an
-exact ref name that the repository read service returns. Use the resolved full
-commit ID in each result link. Do not publish the search route for a private or
-archived repository.
-
-Do not add an index. The release-mode workload has 2,000 unique files of 4 KiB
-each. It searches 8,192,000 bytes and the last file. On the development host,
-the search took 18.8 ms on 2026-07-23. This result does not require the state,
-migration, and update work of an index. Keep the workload in
-`tests/git_reads.rs` so that a contributor can measure it again.
-
-## Evidence
-
-Repository read tests cover SHA-1, SHA-256, binary content, malformed UTF-8
-content, cancellation, and each search limit. The public-route test covers the
-form, exact ref selection, immutable result links, malformed UTF-8 content,
-large content, HTML escaping, GET, HEAD, empty repositories, and hidden
-repositories.
-
-## Consequences
-
-A search reads Git objects for each request. It does not create state that can
-become out of date. Large repositories can reach a limit and must use more
-specific search text or an external checkout. Add a derivable index only when
-repeat measurements show that repositories inside the specified limits cannot
-meet the search time limit.

docs/adr/0009-operational-server.md

Mode 100644; object 52be60387537

@@ -1,46 +1,0 @@
-# Architectural decision record 0009: operational server
-
-Status: Accepted
-
-Date: 2026-07-23
-
-## Context
-
-The Milestone 2 services had test interfaces, but the `tit` executable did not
-start them. The Milestone 2 gate requires an operator to import a repository and
-let users browse and clone it through HTTP and SSH.
-
-## Decision
-
-Add `tit serve`. Hold the exclusive instance lock for the complete server run.
-Start the Axum HTTP listener and the Russh SSH listener in one Tokio process.
-Stop both listeners after SIGINT or SIGTERM. If one listener cannot start, stop
-the listener that already started and return an error.
-
-At startup, read the active SSH public keys and the active public repositories
-from SQLite. Reload the key map after a successful account signup or recovery.
-Give the SSH transport a fixed map from each owner and repository
-slug to its immutable repository ID. Resolve the final path below the canonical
-repository directory. The instance lock prevents an offline command from
-changing this map during the server run.
-
-Create an Ed25519 SSH host key in `ssh_host_ed25519_key` during the first start.
-Use mode 600 and refuse a symbolic link, a non-file path, unsafe permissions,
-an encrypted key, a different key algorithm, and an oversized key file. Read
-the same key during subsequent starts.
-
-## Evidence
-
-The executable test creates an administrator, imports a repository, and starts
-`tit serve`. It browses and clones the managed repository through HTTP. It also
-uses the administrator key and stock Git and OpenSSH to clone through SSH. The
-test confirms that an offline command cannot acquire the instance lock. It
-sends SIGTERM, confirms a clean stop, starts the server again, and confirms that
-the SSH host key did not change.
-
-## Consequences
-
-The Web UI and both Git transports now use the repository layout that the admin
-command creates. An operator must stop the server to use an offline admin
-command. Milestone 3.1 adds the owner-only control socket for specified
-online operations.

docs/adr/0010-account-lifecycle.md

Mode 100644; object 792cf2221faa

@@ -1,65 +1,0 @@
-# Architectural decision record 0010: account lifecycle
-
-Status: Accepted
-
-Date: 2026-07-22
-
-## Context
-
-Account creation must use an invite code. An operator must create the code while
-the server owns the instance lock. Recovery must remove access from keys that a
-user can no longer control. The database must not contain an invite code or a
-recovery credential in plain text.
-
-## Decision
-
-Add the owner-only `control.sock` below the instance directory. The server
-refuses a symbolic link, a non-socket path, and an existing socket. The server
-creates the socket with mode 600. `tit invite-code` sends one bounded request to
-this socket. An invite code is valid for 24 hours and one signup. The database
-stores its SHA-256 hash.
-
-Signup requires an invite code, a valid username, and a supported SSH public
-key. One SQLite transaction consumes the invitation and creates the account,
-the first key, and the recovery credential hash. A failed transaction does not
-consume the invitation. The Web UI shows the recovery credential one time.
-
-Recovery requires the username, the current recovery credential, and a new SSH
-public key. One transaction revokes all old keys, activates the new key, and
-stores the hash of a new recovery credential. The Web UI shows the new
-credential one time. The old credential cannot be used again. The running SSH
-server reloads active keys after a successful signup or recovery.
-
-An administrator can add and revoke keys, suspend accounts, and restore
-accounts with offline `tit admin account` commands. The last active key cannot
-be revoked through the key-revocation command. An account row is never deleted.
-Thus, its unique username stays reserved after suspension.
-
-## Failure and threat cases
-
-The control protocol has a small fixed request and a five-second time limit.
-The client refuses a symbolic link, a non-socket path, and group or other
-permissions. Shutdown removes the socket only when its device and inode still
-identify the socket that this process created. Thus, shutdown does not remove a
-replacement path.
-
-Signup and recovery errors do not put a credential in a log or an error page.
-Malformed input has a fixed size limit. The account service parses SSH keys
-before a transaction starts. SQLite constraints preserve the invitation,
-username, key, and account-state invariants.
-
-## Evidence
-
-Storage tests cover one-time and expired invitations, transaction rollback,
-username reservation, key addition and revocation, recovery rotation, and
-account suspension. Control-socket tests cover permissions, cleanup, files, and
-symbolic links. The executable test uses `tit invite-code`, creates an account
-through the Web UI, uses its key through stock Git and OpenSSH, recovers the
-account, and confirms that the old key stops before the server restarts.
-
-## Consequences
-
-Open signup is not available. An operator must remove a stale `control.sock`
-after an unclean server stop, but the server does not automatically remove a
-filesystem entry that it did not create. This choice makes the safe owner
-action explicit.

docs/adr/0011-web-login-sessions.md

Mode 100644; object 1d7df11a1741

@@ -1,87 +1,0 @@
-# Architectural decision record 0011: Web login sessions
-
-Status: Accepted
-
-Date: 2026-07-22
-
-Amended: 2026-07-24
-
-## Context
-
-The Web UI must use the same SSH key identity as the SSH server. A login
-challenge must not be reusable. A database copy must not contain a session
-token or a CSRF token that a user can use directly.
-
-## Decision
-
-The primary login form creates a five-minute SSH approval. The Web UI shows an
-exact `ssh` command with a random one-time secret. The browser stores a
-different login CSRF value in an `HttpOnly` cookie. SQLite stores only the
-SHA-256 hashes of these two values.
-
-The user runs the command against the built-in SSH server. Public-key
-authentication selects the account and active key. The `login` command binds
-the pending approval to that account and key in one transaction. Its output
-shows the Web origin and account. The browser supplies the one-time secret and
-the matching login CSRF value when it completes the login. In one SQLite
-transaction, the server consumes the approval and creates a seven-day session.
-
-The fallback form requires only a username. The server creates a five-minute
-`tit-auth-v2` challenge and stores only the SHA-256 hash of its random nonce.
-The user downloads and signs the exact challenge with the `tit-auth` SSHSIG
-namespace. The SSHSIG envelope contains the signing public key. The server
-verifies that this key is active on the named account. The user can upload the
-signature file or paste the complete envelope. An HTML form changes challenge
-line endings to CRLF. The HTTP interface changes these line endings back to LF
-before signature verification. A failed signature returns the same challenge
-page so that the user can try again before it expires.
-
-The response stores an opaque session token in an `HttpOnly` cookie and a CSRF
-token in a second cookie. Both cookies use `SameSite=Strict`. HTTPS responses
-also use `Secure`. SQLite stores only SHA-256 hashes of both values.
-
-Challenge creation also sets a five-minute, `HttpOnly` login CSRF cookie. The
-verification form must submit the matching value, and the nonce row stores only
-its hash. Thus, a different site cannot complete a login in the user's browser.
-
-Each state-changing account form must compare its submitted CSRF token with the
-CSRF cookie and the stored hash. The initial account page supplies a logout
-form that uses this rule. Logout ends all sessions for the account. Recovery,
-key addition, key revocation, suspension, and restoration also end all account
-sessions in the same transaction as the privilege change.
-
-## Failure and threat cases
-
-Login and signature forms have fixed body limits. Axum limits the request body,
-and `multra` parses the uploaded form as a stream within the 64-KiB limit.
-Challenge and SSHSIG parsing have the
-authentication limits from architectural decision record 0002. A bad
-identity, signature, expired challenge, and consumed challenge use a common Web
-UI error. The response does not disclose which input failed.
-
-The server keeps a maximum of 1,024 active login nonces and 1,024 active SSH
-approvals. It removes consumed or expired records before it creates one.
-Cookie parsing rejects duplicate, empty, and oversized values. A session is
-not valid after its expiry, account suspension, logout, or a privilege change.
-A CSRF failure does not end the session because an external request must not
-be able to log out a user.
-
-## Evidence
-
-The session test signs with stock `ssh-keygen`, restarts the login service
-between issue and verification, rejects challenge replay and a bad CSRF token,
-checks the stored hashes, invalidates a session after key addition, and tests
-the function that ends all sessions. It also verifies approval binding and
-concurrent one-time consumption.
-
-The executable test creates an approval through HTTP, approves it with stock
-`ssh`, and completes the browser session. It also completes the fallback with
-stock `ssh-keygen` and browser CRLF line endings. It rejects an incorrect
-upload content type and malformed multipart content. It confirms CSRF
-rejection and session invalidation.
-
-## Consequences
-
-The primary flow needs access to the built-in SSH service. The fallback works
-when the user cannot connect to that service. Neither flow asks the user to
-copy a public key.

docs/adr/0012-repository-authorization.md

Mode 100644; object ed50e9ec295f

@@ -1,70 +1,0 @@
-# Architectural decision record 0012: Repository authorization
-
-Status: Accepted
-
-Date: 2026-07-23
-
-## Context
-
-A repository can be public or private. A private repository must not enter an
-anonymous HTTP or SSH result. HTTP and SSH must not use different role rules.
-
-## Decision
-
-Keep the owner account ID in the repository row. Derive the `owner` role from
-this row. Store only the `maintainer`, `writer`, and `reader` roles in the
-`repository_collaborator` table. This design prevents a second account from
-getting the `owner` role through a collaborator update.
-
-Use one `RepositoryPolicy` service for repository decisions. Give this service
-an optional active account, a repository, and an operation. Use these rules:
-
-- A public, active repository permits read access to all users.
-- A private, active repository permits read access to its owner and
-  collaborators.
-- The `owner`, `maintainer`, and `writer` roles permit write access.
-- The `owner` and `maintainer` roles permit maintenance access.
-- Only the `owner` role permits ownership access.
-- A suspended account and an archived repository permit no transport access.
-
-The repository-route middleware validates an optional Web session and supplies
-its account to the policy. The public Web routes ask the policy before they open
-repository metadata or Git objects. An owner or collaborator can use the Web
-UI to read a private repository. Private raw responses and feeds use a private
-no-store cache policy.
-
-The SSH repository catalog also comes from the policy. Thus, an HTTP request
-and an SSH Git request use the same read rule. Authenticated SSH Git supplies
-the account and operation to this service before it starts a Git service.
-
-Offline administrator commands set visibility, set a collaborator role, and
-remove a collaborator. The instance lock requires the administrator to stop
-the server before these commands change repository access.
-
-## Failure and threat cases
-
-Each policy query gets the account state and collaborator role in the same
-SQLite query as the repository record. A suspended account cannot keep access
-through an old collaborator row. A missing account has the same permissions as
-an anonymous user. A private or archived repository returns the same HTTP 404
-result as a missing repository to an anonymous user.
-
-The schema permits only the three non-owner collaborator roles and one role per
-account and repository. The application rejects an owner as a collaborator.
-Repository ownership does not change when a collaborator role changes.
-
-## Evidence
-
-The policy access-matrix test covers anonymous users, each role, a stranger, a
-suspended collaborator, a missing account, both visibility values, and an
-archived repository. It also tests role change and removal. The server test
-confirms that a Web session can read its private repository and that anonymous
-HTTP and SSH Git discovery cannot find it. The public-route test confirms that
-private repositories do not appear in summary, raw, feed, search, archive, or
-Git discovery routes.
-
-## Consequences
-
-Role and visibility changes take effect on the next policy query. The policy
-does not cache authorization state. Architectural decision record 0013 applies
-this policy to authenticated Git and ref updates.

docs/adr/0013-authenticated-git.md

Mode 100644; object 87e3821b98a3

@@ -1,76 +1,0 @@
-# Architectural decision record 0013: Authenticated Git
-
-Status: Accepted
-
-Date: 2026-07-22
-
-## Context
-
-The built-in SSH server must use the same account and repository policy as the
-Web UI. A key removal, account suspension, or role change must stop subsequent
-Git access. A change during a push must not permit a ref update after access is
-removed.
-
-HTTP Git access stays read-only in the first stable release.
-
-## Decision
-
-Load each active, non-revoked SSH key with its account username. The key selects
-the account. The username that an SSH client supplies does not select the
-account. SQLite continues to own the account and key records.
-
-Before `git-upload-pack` starts, verify that the key is still active and ask
-`RepositoryPolicy` for read permission. Before `git-receive-pack` starts, do
-the same key check and ask for write permission. Resolve the repository path
-from its stable repository ID only after authorization.
-
-Before a receive operation changes refs, verify the key-to-account binding
-again and ask `RepositoryPolicy` for write permission again. Do these checks
-while the server holds the one-push permit and immediately before
-`ReceivePack::finish` applies the ref changes. `ReceivePack` then permits only
-branch and tag refs. It rejects a non-fast-forward branch update and a ref that
-was changed after the initial advertisement. It validates the pack and Git
-objects before it changes a ref.
-
-The Web recovery and key operations reload the active SSH key map after a
-successful account change. Repository policy reads the current database state
-for each decision. Offline repository access commands continue to require the
-instance lock.
-
-## Failure and threat cases
-
-A revoked key and a key for a suspended account cannot authenticate after the
-active key map reloads. A connection that authenticated before a key reload
-cannot start a new Git service with the removed key. If removal occurs during a
-receive operation, the check before the ref update rejects the push.
-
-A removed or reduced collaborator role rejects a new Git service. If the role
-changes during a receive operation, the second policy check rejects the ref
-update. A reader can clone a private repository but cannot start
-`git-receive-pack`. An account without a role cannot discover a private
-repository.
-
-An unsupported ref, a non-fast-forward branch update, an invalid pack, and a
-concurrent ref change return a rejected push. These failures do not change a
-ref. HTTP does not start `git-receive-pack` and has no write route.
-
-## Evidence
-
-The production server test uses stock Git and OpenSSH. It tests the owner,
-maintainer, writer, reader, and account-without-role cases against a private
-repository. It also tests a suspended account, a revoked key, a public
-repository, a successful writer push, a rejected reader push, role removal, an
-unsupported ref, and a non-fast-forward update.
-
-The receive-pack tests test SHA-1 and SHA-256 pushes, malformed packs, invalid
-objects, concurrent ref changes, process termination, and restart recovery.
-
-## Consequences
-
-SSH Git requires an account key, including reads of a public repository. This
-keeps SSH identity unambiguous. Anonymous users can continue to clone a public
-repository through HTTP.
-
-Each Git service start uses a SQLite policy query. A push uses one additional
-policy query before ref updates. This cost keeps authorization current and
-prevents a server-side permission cache from extending access.

docs/adr/0014-audit-history.md

Mode 100644; object 26ae16622711

@@ -1,79 +1,0 @@
-# Architectural decision record 0014: Audit history
-
-Status: Accepted
-
-Date: 2026-07-22
-
-## Context
-
-Account and repository changes need a durable security history. An operator
-must be able to relate a Web result to its audit event. A successful mutation
-must not exist without its success event. A failed mutation must not change its
-target state.
-
-The audit history must not become a second store for credentials or login
-data.
-
-## Decision
-
-Store audit events in the SQLite `audit_event` table. Each row has an action,
-actor, target, outcome, creation time, and correlation ID. The outcome is
-`success` or `failure`. Keep the target as a bounded identifier. Do not put a
-request body or error text in the row.
-
-Insert a success audit event in the same transaction as its account, session,
-repository, collaborator, or ref mutation. If the mutation transaction fails,
-roll it back and insert a failure audit event in a new transaction. If the
-failure event cannot be stored, return the audit storage error. Do not report
-the mutation as complete.
-
-Use the HTTP request ID as the correlation ID for Web login, signup, and
-recovery. Generate a random correlation ID for each offline administrator
-mutation. Use the durable Git operation ID for a ref update. This rule lets an
-operator relate a Web response, an administrator operation, or a Git push to
-one audit history item.
-
-For a Git push, insert the success audit event when the durable Git operation
-becomes complete. Record a failed receive operation after protocol, pack,
-object, or ref validation rejects it. If restart recovery finds that refs were
-changed, it completes the operation and keeps only the success event. If refs
-were not changed, the server can record the failure event.
-
-The offline `tit admin audit` command shows as many as 1,000 recent events in
-reverse creation order. The default limit is 100. The command uses the instance
-lock and does not change the audit history.
-
-## Failure and threat cases
-
-The schema limits the length of each text field and permits only the two outcome
-values. An index supports the newest-event query. A second index supports a
-correlation-ID query. The first command does not expose a filter because the
-bounded newest-event query is sufficient for this milestone.
-
-Do not store invite codes, recovery credentials, login challenges, signatures,
-session tokens, CSRF tokens, SSH private keys, or HTTP request bodies. A failed
-login stores only a valid username or the value `invalid-account`. A key audit
-target can contain a public-key fingerprint because the fingerprint is an
-identifier and is not a credential.
-
-The audit history is append-only through the application interface. This
-milestone does not add an audit deletion or update command.
-
-## Evidence
-
-The schema migration tests migrate each historical fixture and test a killed
-migration. The storage tests verify bounded history order and successful Git
-and repository events. Account tests verify successful and failed key and
-recovery events.
-
-The production server tests verify Web request correlation, successful and
-failed login events, recovery events, successful and rejected ref updates, and
-secret exclusion. The administrator CLI test verifies successful and failed
-repository and collaborator events through `tit admin audit`.
-
-## Consequences
-
-Security mutations add one SQLite row. Failed attempts add a separate short
-transaction. The audit table grows until a subsequent operations milestone
-defines backup retention and storage policy. No automatic deletion occurs in
-this milestone.

docs/adr/0015-ssh-repository-commands.md

Mode 100644; object 20f6038bd52f

@@ -1,99 +1,0 @@
-# Architectural decision record 0015: SSH repository commands
-
-Status: Accepted
-
-Date: 2026-07-22
-
-## Context
-
-An account needs to create a repository without a browser. The SSH command
-must use the account that the SSH public key selects. It must not start a shell
-or parse a shell language. A script needs output that does not depend on the
-human text.
-
-Repository creation is also available in the account Web page. Both transports
-must apply the same name rules, owner rules, storage steps, and audit rules.
-
-## Decision
-
-Use one `RepositoryService` for the Web form, the SSH command, and the offline
-administrator create command. The Web and SSH methods set the owner to the
-authenticated account. The administrator method can name an owner because it
-runs only while the offline instance lock is held. The store rejects an owner
-account that does not exist or is not active.
-
-Accept this SSH command:
-
-```text
-repo create NAME [--object-format sha1|sha256] [--output human|json]
-```
-
-The default object format is SHA-1. The default output is human text. Options
-can occur in either order, but each option can occur one time. Limit the full
-command to 512 ASCII bytes. Reject control characters, unknown words, missing
-values, repeated options, invalid names, and unsupported values. Do not use a
-shell tokenizer or pass command data to a process.
-
-The human success output has two lines. The first line identifies the owner and
-repository. The second line identifies the object format. Human output is for
-a person and is not a script interface.
-
-JSON output is one UTF-8 line. A successful result has this form:
-
-```json
-{"version":1,"status":"success","repository":{"owner":"alice","name":"project","object_format":"sha1"}}
-```
-
-A failed result has this form:
-
-```json
-{"version":1,"status":"error","error":{"code":"repository-exists"}}
-```
-
-Version 1 error codes are `invalid-command`, `invalid-name`,
-`repository-exists`, `account-unavailable`, `service-unavailable`, and
-`repository-create-failed`. Send JSON results on standard output. Send human
-errors on standard error. Return exit status zero for success and one for
-failure.
-
-Create a bare repository in a random pending path under the managed repository
-root. Rename it to its random final path before the SQLite insert. If a later
-step fails, remove the path that this request created. Insert the repository,
-repository event, and successful audit event in one SQLite transaction. Record
-a failed audit event in a separate transaction after a rejected create
-operation.
-
-## Failure and threat cases
-
-The SSH username does not select the account or owner. The authenticated public
-key selects the account. A current active-account check occurs again in the
-repository transaction. This check rejects a key if its account became inactive
-after SSH authentication.
-
-Repository names use the common domain validator. They cannot contain a path
-separator, begin or end with punctuation, use a reserved route name, or end in
-`.git`. JSON strings contain only values that this validator and the account
-validator accept, so command data cannot add JSON syntax.
-
-The SSH server continues to reject shells, terminal allocation, forwarding,
-subsystems, agent forwarding, and arbitrary commands. The repository command
-uses the existing bounded worker pool. It does not run Git or OpenSSH at
-runtime.
-
-## Evidence
-
-The production server test creates repositories as a non-administrator and as
-an administrator. It compares the exact human and JSON output, checks a stable
-JSON failure, clones the new empty repository with stock Git, and verifies the
-stored owner, object format, and audit outcomes.
-
-The Web production test creates a SHA-256 repository from the account page. It
-checks the CSRF failure, redirect, public route, authenticated owner, and HTTP
-request correlation ID. The existing SSH security tests continue to reject all
-other exec commands and SSH features.
-
-## Consequences
-
-Future SSH issue and pull-request commands can use the same bounded parser and
-versioned output rules. A later JSON version can add fields without changing
-version 1. Scripts select JSON explicitly and do not parse human text.

docs/adr/0016-repository-event-service.md

Mode 100644; object d17a00a4512b

@@ -1,100 +1,0 @@
-# Architectural decision record 0016: Repository event service
-
-Status: Accepted
-
-Date: 2026-07-22
-
-## Context
-
-Repository changes need one durable event source for feeds and subsequent
-subscriptions. A database row ID is not a public identifier. A global row order
-also does not give a repository-local cursor.
-
-Each event payload needs a version before issue and pull-request events add new
-schemas. An event must not exist without its metadata change. A metadata change
-must not exist without its event.
-
-## Decision
-
-Keep `repository_event` as the one canonical repository event table. Do not add
-a parallel event history. Give each event these identifiers:
-
-- `event_id` is a random 128-bit lowercase hexadecimal public ID.
-- `sequence` is an integer that increases by one inside each repository.
-- `id` stays as a private SQLite row key.
-
-Allocate the next sequence in the same immediate transaction that inserts the
-event. A unique constraint on `(repository_id, sequence)` prevents reuse. Read
-event pages through the `(repository_id, sequence DESC)` index. Use the
-repository sequence as the `before` cursor.
-
-The stable version 1 event types are `repository-created`,
-`repository-imported`, `push`, `ref-created`, `ref-updated`, `ref-deleted`,
-`tag-created`, `tag-updated`, and `tag-deleted`.
-
-Architectural decision record 0017 adds the version 1 issue event types to this
-service.
-
-Store `payload_version` as an explicit schema value and store `version` in each
-JSON payload. The schema accepts only a JSON object whose inner version equals
-the column value. Limit a payload to 1 MiB. Version 1 repository payloads have
-`owner`, `repository`, and `object_format`. Push payloads have `operation_id`.
-Ref and tag payloads have `name_hex`, `old_target`, and `new_target`. Hexadecimal
-ref names preserve Git ref bytes without a text conversion.
-
-Use one insertion helper for repository creation, repository import, initial
-refs and tags, a completed push, and changed refs and tags. The helper runs in
-the transaction that owns the related metadata mutation. If event insertion
-fails, roll back the metadata change. A Git operation stays incomplete if its
-completion events cannot be inserted.
-
-Migration 011 assigns a random public ID and a repository sequence to each old
-event. It creates a version 1 payload from the old typed columns. The old row ID
-does not become the public ID.
-
-Atom and RSS entry IDs now use this form:
-
-```text
-urn:tit:event:EVENT_ID
-```
-
-The value stays stable after a restart, repository rename, or later event
-insert. Existing feed pages use the repository sequence for pagination.
-
-## Failure and threat cases
-
-Database constraints reject an invalid public ID, repeated sequence, unknown
-event type, unsupported payload version, malformed JSON, non-object JSON,
-missing inner version, mismatched version, oversized payload, invalid ref
-shape, and invalid Git operation source.
-
-The event payload is application output. Repository and account names pass the
-domain validators. Ref names use hexadecimal text. Object IDs and operation IDs
-use their validated canonical values. Event payload generation does not accept
-an arbitrary JSON string from an HTTP or SSH request.
-
-Git refs and SQLite still use the durable Git operation intent. The server
-inserts push events only when it marks the intent complete. Restart recovery
-uses the same completion transaction, so a feed cannot announce a push whose
-refs are not reachable.
-
-## Evidence
-
-Migration tests upgrade each historical schema, including schema version 10.
-They also kill a migration before and after commit. Storage tests verify random
-public IDs, consecutive repository sequences, versioned JSON schemas, indexed
-pagination, stable IDs after reopen, duplicate-sequence rejection, payload
-rejection, and backfill.
-
-Injected trigger failures prove that repository creation rolls back when its
-event fails. They also prove that a Git operation stays incomplete and publishes
-no events when its completion event fails. Production feed tests parse Atom and
-RSS and follow sequence pagination. Git crash tests continue to stop the process
-at each cross-store boundary.
-
-## Consequences
-
-Issue and pull-request milestones can add event types and payload versions to
-this service. Consumers use a public event ID for identity and a repository
-sequence for order. The private SQLite row key can change without changing
-either contract.

docs/adr/0017-issue-workflow.md

Mode 100644; object 840a5bc5aef2

@@ -1,88 +1,0 @@
-# Architectural decision record 0017: Issue workflow
-
-Status: Accepted
-
-Date: 2026-07-22
-
-## Context
-
-An issue needs a stable repository number, a Markdown source, comments, labels,
-assignees, state changes, and one chronological timeline. An issue mutation and
-its event must not have different results.
-
-The repository roles do not have a separate triage role. The issue workflow
-must use the existing roles and must not add a second permission system.
-
-## Decision
-
-Store issues and comments in SQLite. Give each issue and comment a random,
-non-reassignable ID. Allocate an increasing issue number from one repository
-counter. Use the issue number in the public URL:
-
-```text
-/OWNER/REPOSITORY/issues/NUMBER
-```
-
-Store the exact Markdown source from the request. Render the supported subset
-only when the server makes the HTML page. Limit an issue title to 200 bytes.
-Limit an issue body or comment to 256 KiB. Reject control characters except
-tab and line terminators in Markdown bodies.
-
-Use these permissions:
-
-- An authenticated account that can read the repository can create an issue
-  and add a comment.
-- The issue author, an owner, a maintainer, or a writer can edit, close, or
-  reopen the issue.
-- An owner or maintainer can add or remove labels and assignees.
-- An assignee must be active and must be able to read the repository.
-
-Run the permission query, metadata mutation, and event insert in one immediate
-SQLite transaction. Use the repository event sequence as the issue timeline
-order. Do not add a separate timeline table.
-
-Add these version 1 event types: `issue-created`, `issue-edited`,
-`issue-commented`, `issue-closed`, `issue-reopened`, `issue-labeled`,
-`issue-unlabeled`, `issue-assigned`, and `issue-unassigned`. Each event payload
-has the issue ID and issue number. An event also has the data that identifies
-its change, such as the comment ID, label, assignee, state, title, or body.
-
-Keep labels in one repository. Compare label names without ASCII case
-differences. Keep a label record after its last removal so a subsequent use has
-the same identity.
-
-The Web interface uses server-rendered pages and normal HTML forms. Each
-mutation requires an active session and a matching CSRF value. Private issue
-reads use the same repository read rule as Git and repository pages.
-
-## Failure and threat cases
-
-An invalid repository, issue number, title, body, label, state, or assignee does
-not change the database. A suspended account cannot mutate an issue. An
-unauthorized account cannot learn whether an issue exists in a private
-repository from a read response.
-
-A database constraint rejects a repeated issue number, an invalid state, an
-invalid ID, an invalid label relation, or an event without its issue. If event
-insertion fails, SQLite rolls back the issue mutation. The event payload limit
-is larger than the permitted Markdown source after JSON escaping of accepted
-characters.
-
-## Evidence
-
-Storage tests run the complete workflow with reader, writer, maintainer, owner,
-stranger, and suspended-account boundaries. They verify increasing numbers,
-exact Markdown source, labels, assignees, comments, state, event payloads, and
-timeline order. An injected event failure proves that a comment and its event
-roll back together.
-
-A production HTTP test creates, reads, edits, comments on, labels, assigns,
-closes, and reopens an issue. It also verifies CSRF rejection, safe Markdown
-rendering, the no-JavaScript form flow, and the repository Atom projection.
-
-## Consequences
-
-Issue pages and later SSH commands can use one issue service. Watches and
-private feeds can select issue events from the repository event stream. A
-subsequent triage role requires an explicit change to the common permission
-contract.

docs/adr/0018-repository-watches.md

Mode 100644; object 80e476f2da8c

@@ -1,70 +1,0 @@
-# Architectural decision record 0018: Repository watches
-
-Status: Accepted
-
-Date: 2026-07-22
-
-## Context
-
-An account must be able to select repository activity for later personalized
-feeds. The first release does not have an inbox or a background mail process.
-Watch data is private preference data. It must not appear in the public
-repository event stream.
-
-## Decision
-
-Store one optional watch record for an account and repository. Give the record
-a random, non-reassignable ID. Store three independent Boolean preferences:
-
-- pushes;
-- issues;
-- pull requests.
-
-All three selected values mean “everything.” No selected value means “stop
-watching.” Delete the watch record in that case. Do not store a record that has
-no selected activity.
-
-An active account can change a watch only when it can read the repository. Use
-the common repository visibility and role data for this check. Anonymous users
-can read the watch page for a public repository, but the page does not show
-private preference data.
-
-Use one upsert for a preference change. Keep the watch ID and creation time when
-the account changes its selections. Add an index on account and repository for
-the personalized feed query in Milestone 4.4.
-
-Do not append a repository event for a watch change. A public event would expose
-private preference data and could cause a watch to notify itself. The watch row
-is the canonical preference state.
-
-The Web page uses three normal HTML select controls. The page and form operate
-without JavaScript. The form requires an active session and a matching CSRF
-value.
-
-## Failure and threat cases
-
-A database constraint rejects an invalid ID, a non-Boolean value, a repeated
-account and repository pair, or a row with no selected activity. A suspended
-account and an account that cannot read a private repository cannot read or
-change its watch through the service.
-
-A private watch is selected only after the service checks current repository
-access. A subsequent feed request must check access again. Thus, a retained
-watch cannot restore access after a role is removed.
-
-## Evidence
-
-Storage tests select everything, change to issue-only activity, preserve the
-watch ID and creation time, reject unauthorized accounts, reject an empty row,
-and delete the row when all selections are clear. They also prove that watch
-changes do not append repository events.
-
-A production HTTP test reads the anonymous page, sets all preferences with an
-authenticated form, reads the selected values, and clears the watch. The common
-Web workflow test continues to verify CSRF rejection.
-
-## Consequences
-
-Milestone 4.4 can select watched event kinds without a schema change. A later
-inbox or mail process can use the same preferences, but this milestone does not
-add either process.

docs/adr/0019-scoped-feeds.md

Mode 100644; object a4113024d740

@@ -1,87 +1,0 @@
-# Architectural decision record 0019: Scoped feeds
-
-Status: Accepted
-
-Date: 2026-07-23
-
-## Context
-
-Public issue activity needs Atom and RSS feeds. An account also needs feeds for
-one private repository, watched activity, assignments, and mentions. A feed
-reader cannot use the interactive Web login. Thus, a private feed URL is a
-credential and must have a narrow authority.
-
-## Decision
-
-Add public issue feeds at these paths:
-
-```text
-/{owner}/{repository}/issues/atom.xml
-/{owner}/{repository}/issues/rss.xml
-```
-
-Select only issue events from the canonical repository event stream. Keep the
-entry ID and repository sequence unchanged. Thus, edits and restarts do not
-change the order or identity of an entry.
-
-Use a random 256-bit token for each private feed. Store only its SHA-256 hash.
-Give each token one immutable scope:
-
-- one repository;
-- watched activity;
-- assignments;
-- mentions.
-
-A repository token also stores one repository ID. A personalized token has no
-repository ID. The database constraints enforce these combinations. The token
-URL is `/feeds/{token}/atom.xml` or `/feeds/{token}/rss.xml`. The URL does not
-accept a second scope value, so a token cannot request a different scope.
-
-Show a new token only in the response that creates or rotates it. The token
-list shows the stable token record ID, scope, target, time, and state, but it
-does not show a token or its hash. Rotation revokes the old record and creates
-the replacement in one immediate transaction. Revocation cannot be reversed.
-An account can have at most 32 active tokens. The management page returns at
-most 100 token records.
-
-Check the account state and current repository access for each feed read. A
-repository token returns only events from its repository. A watched feed
-applies the stored push, issue, and pull-request selections. An assignment feed
-selects assignment events for the token account. A mention feed selects exact
-`@username` references from issue and comment bodies. A bounded scan of at most
-1,000 recent issue events supplies at most 20 mention entries.
-
-Return private feeds with `Cache-Control: private, no-store`. All Web responses
-use `Referrer-Policy: no-referrer`. The HTTP layer does not log request URLs or
-form bodies, so it does not put a feed token in an application log.
-
-## Failure and threat cases
-
-An invalid, unknown, rotated, revoked, or suspended-account token gets the same
-not-found response. Token creation stops at the active-token limit. A public
-feed cannot read a private repository. A private feed query checks current
-visibility, ownership, and collaborator data, so a removed role cannot
-continue to return private events.
-
-The event queries sort by immutable repository sequence for one repository and
-by immutable creation time plus event ID for multi-repository feeds. An edit
-does not change these values. The queries have explicit limits.
-
-The token value appears in the one-time result and in the feed URL because the
-feed reader must send it. It does not appear in a later management page, an
-error page, a referrer, or the database.
-
-## Evidence
-
-Storage tests cover all four scopes, exact mention matching, issue-only event
-selection, access denial, hash lookup, rotation, revocation, and scope
-separation. The production HTTP test parses public issue feeds and each private
-feed format. It makes a repository private, proves that the public feed is
-hidden, reads it with its repository token, verifies hash-only storage, proves
-one-time display, rotates the token, and revokes the replacement.
-
-## Consequences
-
-Version 1 has feed delivery without an inbox or a background process. A person
-must protect a private feed URL as they protect a password. A later inbox can
-consume the same canonical events and current authorization data.

docs/adr/0020-bounded-metadata-search.md

Mode 100644; object 03093d87506f

@@ -1,64 +1,0 @@
-# Architectural decision record 0020: Bounded metadata search
-
-Status: Accepted
-
-Date: 2026-07-23
-
-## Context
-
-An account must be able to search repository and issue metadata that it can
-read. An anonymous user must be able to search public metadata. A permanent
-index adds derived state, schema work, and update work. The first search
-implementation must show that this additional state is necessary.
-
-## Decision
-
-Add a fixed-text, case-insensitive metadata search at `/search`. Search active
-repositories, issue titles and bodies, and issue comment bodies. Return one
-result for an issue when its issue record or one of its comments matches. Use a
-stable repository or issue URL for each result.
-
-Apply the repository authorization query before the metadata scan. An
-anonymous search can read only public repositories. An authenticated search
-can also read private repositories that the account owns or has permission to
-read. Do not show an archived repository. Check current account and
-collaborator state for each search.
-
-Use these limits for one search:
-
-- Accept a maximum of 256 query bytes.
-- Scan a maximum of 10,000 metadata records.
-- Read a maximum of 8 MiB of title and body content.
-- Return a maximum of 100 results.
-- Stop the application scan after 500 ms.
-
-Return the completed prefix and tell the user when a limit stops the search.
-Run the synchronous SQLite work as a bounded blocking job. Use a GET form that
-operates without JavaScript. Read one candidate at a time. Limit each title and
-body field to 8 MiB plus one byte in the SQL result. The additional byte shows
-that the content limit was reached. Thus, candidate text uses a maximum of 16
-MiB plus two bytes before the application stops the scan.
-
-Do not add an index. The release-mode workload has one public repository and
-9,999 issues. It searches 10,000 records and 449,972 bytes for text in the last
-issue. On the development host, the search took 14.4 ms on 2026-07-23. The
-index threshold is 250 ms for this workload. Keep the workload in
-`tests/metadata_search.rs` so that a contributor can measure it again.
-The retained candidate text threshold is 32 MiB. The field limits keep the
-implementation below this threshold.
-
-## Evidence
-
-The storage test proves that the authorization query filters private metadata.
-The search test covers public and private metadata, collaborator permission,
-case-insensitive matching, comment result deduplication, query validation, and
-stable result identity after a restart. The public-route test covers the form,
-anonymous search, authenticated private search, hidden private results, and an
-invalid query. The release-mode workload records the index decision.
-
-## Consequences
-
-Each request reads canonical SQLite metadata, so search results cannot become
-out of date. A large instance can return an incomplete result when it reaches a
-limit. Add a derivable embedded index only when repeat measurements exceed the
-250 ms time threshold or the 32 MiB retained candidate text threshold.

docs/adr/0021-ssh-issue-commands.md

Mode 100644; object 9625696ed942

@@ -1,109 +1,0 @@
-# Architectural decision record 0021: SSH issue commands
-
-Status: Accepted
-
-Date: 2026-07-23
-
-## Context
-
-An account needs to list and create issues without a browser. The SSH public
-key must select the account. The SSH commands must use the same issue service
-and authorization rules as the Web UI. A script also needs output that does not
-depend on human text.
-
-## Decision
-
-Accept these SSH commands:
-
-```text
-issue list OWNER/REPOSITORY [--output human|json]
-issue create OWNER/REPOSITORY [--output human|json]
-```
-
-Limit each command to 512 ASCII bytes. Reject control characters, unknown
-words, missing values, repeated options, invalid targets, and unsupported
-output values. Do not use a shell tokenizer or pass command data to a process.
-
-The create command reads UTF-8 content from standard input. The first line is
-the issue title. All content after the first newline is the plain-text Markdown
-body. A title can have a maximum of 200 bytes. A body can have a maximum of 256
-KiB. Limit the complete input before UTF-8 parsing. The body can be empty.
-
-The list command returns a maximum of 1,000 issues in decreasing issue-number
-order. Human output has one issue on each line:
-
-```text
-#12 open Correct the backup check
-```
-
-The create command returns this human output:
-
-```text
-Created issue alice/project#12.
-```
-
-JSON output is one UTF-8 line. A list result has this form:
-
-```json
-{"version":1,"status":"success","repository":{"owner":"alice","name":"project"},"issues":[{"number":12,"title":"Correct the backup check","state":"open","author":"alice","created_at":1784800000,"updated_at":1784800000}]}
-```
-
-A create result has this form:
-
-```json
-{"version":1,"status":"success","repository":{"owner":"alice","name":"project"},"issue":{"number":12,"title":"Correct the backup check","state":"open","author":"alice","created_at":1784800000,"updated_at":1784800000}}
-```
-
-A failed result has this form:
-
-```json
-{"version":1,"status":"error","error":{"code":"permission-denied"}}
-```
-
-Version 1 error codes are `invalid-command`, `invalid-input`, `invalid-target`,
-`repository-unavailable`, `permission-denied`, `account-unavailable`,
-`service-unavailable`, and `issue-command-failed`. Send JSON results on
-standard output. Send human errors on standard error. Return exit status zero
-for success and one for failure.
-
-Call `IssueService::list` and `IssueService::create` from a bounded blocking
-job. The service applies the common validation and repository authorization.
-Issue creation stores the issue and its `issue-created` event in one SQLite
-transaction.
-
-## Failure and threat cases
-
-The SSH username does not select the account. The authenticated public key
-selects the account. Check the current key identity before the create operation
-starts. The store also checks the current account state and repository role.
-
-Return `repository-unavailable` for a missing repository and for a repository
-that the account cannot read. Thus, the command does not disclose a private
-repository. A reader can list and create issues because the Web UI gives the
-same permission to this role. A suspended account cannot use its repository
-role or create an issue.
-
-Read create input only for a valid create command. Stop and close the channel
-when the input exceeds its limit. Do not interpret title or body content as a
-shell language. Serialize JSON with `serde_json` so user content cannot change
-the output structure.
-
-## Evidence
-
-The production server test uses stock OpenSSH. It creates an issue as an owner,
-lists it in human and JSON modes, and creates an issue as a reader. It proves
-that an unrelated account cannot list a private repository and that a suspended
-account cannot list it. It also covers invalid create input and the versioned
-error output.
-
-The database assertions prove that both commands use the canonical issue
-records. They prove exact Markdown preservation, the authenticated authors,
-monotonic issue numbers, and one `issue-created` event for each successful
-create operation.
-
-## Consequences
-
-The SSH interface has useful issue operations without a shell or a second
-authorization implementation. A client must send the title and body on
-standard input. A later command version can add fields without changing
-version 1.

docs/adr/0022-pull-request-refs.md

Mode 100644; object 1924f911aa3e

@@ -1,84 +1,0 @@
-# Architectural decision record 0022: Pull-request refs
-
-Status: Accepted
-
-Date: 2026-07-23
-
-## Context
-
-A pull request needs a stable repository number and a Git ref that a standard
-Git client can fetch. Each revision must keep its original base and head object
-IDs. Git refs and SQLite records cannot use one transaction. A process stop
-must not leave the current pull-request ref and its metadata with different
-heads.
-
-## Decision
-
-Store each pull request in SQLite with a random permanent ID and an increasing
-repository number. Store its title, Markdown body, author, state, base branch,
-head branch, current base object ID, and current head object ID. Store each
-revision in an append-only record. A revision contains its own random ID,
-revision number, author, base object ID, head object ID, and creation time.
-
-Accept only full branch names that start with `refs/heads/`. Resolve each base
-and head to a commit before the metadata mutation starts. Support the object-ID
-length of the repository, so SHA-1 and SHA-256 repositories use the same code.
-
-Publish the current head as this ordinary Git ref:
-
-```text
-refs/pull/<number>/head
-```
-
-Do not let Git push create or change this ref. The pull-request service changes
-it with an expected old object ID. Thus, a concurrent change cannot overwrite
-an unknown value.
-
-Use a durable `pull_request_ref_intent` record for each open or revision
-operation. The operation has these steps:
-
-1. Store the pending intent and reserve the pull-request number.
-2. Change the Git ref with the expected old value.
-3. In one SQLite transaction, store the pull request or revision, append its
-   repository event, and complete the intent.
-
-At process startup, inspect each pending intent. If the ref has its old value,
-apply the proposed value and complete the metadata. If the ref has the proposed
-value, complete the metadata. Stop startup if the ref has another value. The
-server uses one in-process lock for these operations. The instance lock stops a
-second server process from changing the same stores.
-
-Opening and revision require an active owner, maintainer, or writer. Anonymous
-users can read a public pull request. A private pull request uses the current
-repository read policy. A reader cannot open or revise a pull request.
-
-## Failure and threat cases
-
-Allocate a number before the Git ref changes. Do not reuse a number if the
-operation fails. Stable URLs must not identify a different object later.
-
-Reject a missing branch, a non-commit branch target, invalid content, an
-archived repository, and a role that cannot write. Recheck the account and role
-in the transaction that creates the intent. A change between initial Git
-resolution and this transaction cannot bypass authorization.
-
-The intent stores the expected and proposed object IDs. Recovery does not infer
-state from branch names or from current source content. A third ref value is a
-consistency error and requires operator inspection.
-
-## Evidence
-
-The pull-request integration test uses SHA-1 and SHA-256 repositories. It opens
-and revises a pull request, checks the fetch ref, and proves that the first
-revision keeps its original object IDs. It recovers once before the ref change
-and once after the ref change. It also opens two pull requests concurrently and
-proves that their numbers and refs are different.
-
-The Web integration test opens and reads a pull request without JavaScript. The
-migration test upgrades each historical schema and runs integrity checks.
-
-## Consequences
-
-Review code can use the immutable revision object IDs in the next milestone.
-The current ref remains useful to stock Git clients. The intent table adds
-recovery work, but it prevents a ref-only or metadata-only pull-request update.

docs/adr/0023-pull-request-comparison.md

Mode 100644; object bedd45ef76e1

@@ -1,85 +1,0 @@
-# Architectural decision record 0023: Pull-request comparison
-
-Status: Accepted
-
-Date: 2026-07-22
-
-## Context
-
-A pull-request revision stores immutable base and head commit IDs. A comparison
-must use these IDs so a later branch update cannot change an old review. The
-repository can contain large or damaged object graphs. Mergeability analysis
-must not change the repository or depend on a Git executable.
-
-## Decision
-
-Compute each comparison from the selected pull-request revision and the bare
-repository. Do not store a comparison cache. Git objects and the immutable
-revision record can reproduce all comparison values.
-
-Walk the base and head histories in breadth-first order. Compute the best Git
-merge base after the bounded walks finish. The commit range contains each head
-ancestor that is not a base ancestor. Compute the changed paths and unified
-diff from the merge-base tree to the head tree. If the histories are unrelated,
-use the empty tree for the diff and report that the histories are unrelated.
-
-Classify mergeability as follows:
-
-- `already merged` means that the head is an ancestor of the base.
-- `fast-forward` means that the base is an ancestor of the head.
-- `clean merge` means that an in-memory three-way merge has no unresolved
-  conflict.
-- `conflicts` means that the three-way merge has an unresolved conflict.
-- `unrelated histories` means that Git cannot find a merge base.
-
-Use `gix` for merge-base selection and the worktree-free three-way merge. Enable
-an in-memory object overlay before the merge. Thus, temporary merge objects do
-not enter the bare object database. Rename detection checks a maximum of
-1,000,000 fuzzy candidate pairs. Stop the tree merge after its first unresolved
-conflict.
-
-## Work and output limits
-
-One comparison has one 30-second deadline. It stops after a combined total of
-10,000 visited base and head commits. A commit object can contain a maximum of
-1 MiB. All returned commit messages can contain a maximum total of 64 MiB.
-
-A path can contain a maximum of 4,096 bytes. A tree object can contain a
-maximum of 16 MiB. A flattened tree can contain a maximum of 100,000 entries.
-A blob can contain a maximum of 16 MiB. The diff limit is 64 MiB and includes
-the old content, new content, and generated hunks. These limits apply before
-the Web template creates output. Apply the diff limits to each side before a
-divergent three-way merge starts.
-
-The comparison API also accepts a cancellation signal. A caller can stop work
-between bounded object operations.
-
-## Failure and threat cases
-
-Reject a missing object, a non-commit object, damaged object data, and an
-invalid revision number. Apply the current repository read policy before object
-access. Do not resolve the stored commit IDs through branch names.
-
-An unrelated history is a valid result. Show all files in its head tree as
-changes from the empty tree. Do not attempt an unrelated-history merge.
-
-The merge implementation can read repository attributes. A pushed attribute
-cannot define a process by itself because custom merge drivers require trusted
-repository configuration. The service does not run Git or OpenSSH.
-
-## Evidence
-
-The pull-request integration test uses SHA-1 and SHA-256 repositories. It
-checks the merge base, commit range, changed paths, diff, immutable revision
-selection, fast-forward, clean merge, conflict, already-merged state, unrelated
-histories, and a history-limit failure.
-
-The Web integration test selects the current and first revisions without
-JavaScript. It checks that the page shows comparison state and changed content.
-
-## Consequences
-
-Each request pays the object-read and merge-analysis cost, but it cannot read or
-return unbounded data. A later cache can store only these reproducible values.
-Review code can select an immutable revision and use its displayed paths and
-commit IDs as comment anchors.

docs/adr/0024-pull-request-review.md

Mode 100644; object 4f9b5e24ba5b

@@ -1,85 +1,0 @@
-# Architectural decision record 0024: Pull-request review
-
-Status: Accepted
-
-Date: 2026-07-23
-
-## Context
-
-A review must keep its original meaning after a branch update. General comments
-need Markdown content. A line comment also needs an exact Git anchor. Approvals
-and change requests must remain in the history. Each metadata mutation must
-have one repository event in the same SQLite transaction.
-
-## Decision
-
-Store each review action as an append-only `pull_request_review` record with a
-random permanent ID. Every action refers to one immutable pull-request
-revision and one active account. Use these action kinds:
-
-- `comment` stores a general comment.
-- `line-comment` stores a line comment.
-- `approved` stores an approval.
-- `changes-requested` stores a change request.
-
-A line comment stores the exact commit ID, path bytes, side, and one-based line
-number. The side is `base` or `head`. The commit ID must equal that side of the
-selected revision. The path must be a changed, non-binary file in that
-revision. The selected side must contain the path and line. Perform the Git
-checks with the bounded comparison and blob reader. Recheck the revision and
-commit relation in the write transaction.
-
-Store raw Markdown with a maximum size of 256 KiB. Use the common safe Markdown
-renderer for Web output. A comment and a change request require nonempty text.
-An approval can have empty text.
-
-An active account that can read the repository can review. Thus, a repository
-reader can comment, approve, or request changes. Apply the current repository
-policy again in the write transaction. Do not accept review actions on a pull
-request that is not open.
-
-Append one specific repository event for each review action. The event payload
-contains the review ID, pull-request number, revision number, body, and optional
-line anchor. Repository event sequence gives one chronological timeline for
-creation, revisions, and review actions.
-
-## Outdated comments
-
-A line comment is current only while its revision is the current pull-request
-revision. Show `Outdated` after the pull request gets a later revision. Keep the
-original commit, path, side, line, and Markdown visible. General comments,
-approvals, and change requests stay in the timeline and do not get the
-`Outdated` label.
-
-This rule is conservative. It does not move an anchor to a similar line in a
-later commit. Automatic line mapping can change the meaning of a comment, so it
-needs a separate design and tests before use.
-
-## Failure and threat cases
-
-Reject an unknown revision, invalid kind, invalid or oversized body, missing
-path, binary file, unchanged path, absent side, absent line, line outside the
-file, and commit mismatch. Store path data as bytes so a Git path does not need
-UTF-8. The no-JavaScript Web form sends the path as hexadecimal text.
-
-If review insertion or event insertion fails, roll back both records. A later
-permission change can hide existing review data because reads use the current
-repository policy.
-
-## Evidence
-
-The integration test uses SHA-1 and SHA-256 repositories. A reader adds a
-general comment, approval, and line comment. An owner requests changes. The
-test checks the exact line anchor, rejects a missing line, records a later
-revision, verifies event order, and denies the reader after private access is
-removed.
-
-The Web integration test submits review actions without JavaScript, renders
-safe Markdown, shows the review events, and marks a line comment as outdated
-after a later revision. The migration test upgrades every committed schema.
-
-## Consequences
-
-Review history is durable and reproducible from SQLite and immutable Git IDs.
-The service does not silently move line comments. A later branch-rule milestone
-can consume approval and change-request records without changing their history.

docs/adr/0025-pull-request-merge.md

Mode 100644; object 2e5f90ba68b0

@@ -1,94 +1,0 @@
-# Architectural decision record 0025: Pull-request merge
-
-Status: Accepted
-
-Date: 2026-07-23
-
-## Context
-
-A pull-request merge changes a Git ref and SQLite metadata. These two stores
-cannot use one transaction. A concurrent push can also move the base ref after
-the server checks mergeability. A server-created merge commit must not need a
-worktree, index, Git executable, or OpenSSH.
-
-## Decision
-
-Support `fast-forward` and `merge-commit` methods. The current pull-request
-revision must match the current base and head refs. A fast-forward requires the
-base commit to be an ancestor of the head commit. A merge commit requires a
-clean three-way merge. Reject unrelated histories, conflicts, already-merged
-heads, stale revisions, and a method that does not match the mergeability
-state.
-
-Only a repository owner or maintainer can merge. The common repository policy
-service enforces this rule.
-
-Create a merge commit without a worktree. Use `gix` rename detection and tree
-merge code. Keep Git modes and byte paths. Use the base commit as the first
-parent and the head commit as the second parent. Use the acting username for
-the author and committer names. Use `<username>@users.tit` for both email
-fields. Use UTC and the operation time. Use this message format:
-
-```text
-Merge pull request #<number> from <head-ref>
-
-<title>
-```
-
-First, create the complete merge result in an in-memory object database. This
-step gives the deterministic target ID and does not change the bare
-repository. Then write the durable Git operation intent. Create the same merge
-objects in the bare object database and require the same target ID. The new
-objects stay unreachable until the ref transaction succeeds.
-
-The merge intent extends the push intent. It stores the pull request, exact
-revision, method, base ref, old target, and new target. The operation uses these
-boundaries:
-
-1. Write the intent and its pull-request data in one SQLite transaction.
-2. Write the merge objects, if the method needs them.
-3. Mark the objects as promoted.
-4. Update the base ref only if it still has the expected old target.
-5. Complete the Git intent, set the pull request to `merged`, and append the
-   push, ref, pull-request, and audit events in one SQLite transaction.
-
-Recovery uses the push intent state and the actual ref. Complete metadata when
-the ref has the proposed target. Abandon the operation when its one ref still
-has the initial target. A different target means that a concurrent ref update
-won before this merge changed the ref, so recovery also abandons the merge.
-Delete the pull-request extension for an abandoned intent so a later attempt
-can start. Unreachable loose merge objects stay available for later object
-maintenance.
-
-## Failure and threat cases
-
-Recheck repository access, the open state, the latest revision, and all stored
-object IDs in the intent transaction. Use an expected-old ref update so a
-concurrent base change cannot be overwritten. A unique pull-request merge
-intent prevents two active merge operations for one pull request.
-
-Do not run a custom merge command from repository content. The merge uses the
-server-controlled bare repository configuration. Stop before the intent if the
-bounded comparison fails. Abandon the intent if actual merge output is not the
-prepared target.
-
-## Evidence
-
-The integration test fast-forwards SHA-1 and SHA-256 repositories. It checks
-owner and reader permission, ref state, merged state, intent completion, and
-event order. The worktree-free merge test checks SHA-1 and SHA-256, rename and
-mode preservation, deterministic output, parent order, attribution, and the
-absence of a bare index. Other tests reject conflicts and stale base refs.
-
-The recovery test completes metadata after an interrupted ref update. It also
-changes the base after object promotion and verifies that recovery keeps the
-concurrent target, abandons the intent, and leaves the pull request open. The
-Web integration test submits a fast-forward merge without JavaScript.
-
-## Consequences
-
-Each clean merge runs once in memory and once against the object database. This
-cost gives the intent its exact proposed target before persistent object writes.
-The base ref and pull-request state cannot disagree after recovery. A later
-maintenance milestone must prune unreachable loose objects from abandoned
-merge attempts.

docs/adr/0026-branch-rules.md

Mode 100644; object 351bbd8c559c

@@ -1,75 +1,0 @@
-# Architectural decision record 0026: Branch rules
-
-Status: Accepted
-
-Date: 2026-07-23
-
-## Context
-
-Git transport and pull-request merge code had separate authorization checks.
-This made it possible for the two paths to apply different rules. A repository
-writer also had permission to update `refs/heads/main` directly.
-
-## Decision
-
-Use `RepositoryPolicy` as the common service for ref changes and pull-request
-merges. The service reads the current repository state and collaborator role
-for each operation. Do not cache this decision.
-
-Apply these rules:
-
-- `refs/heads/main` is the protected ref.
-- An owner or maintainer can create or fast-forward the protected ref.
-- A writer cannot change the protected ref.
-- No role can delete the protected ref.
-- A branch update must be a fast-forward. No role can force-push a branch.
-- An owner, maintainer, or writer can create, fast-forward, or delete a topic
-  branch.
-- An owner, maintainer, or writer can create, update, or delete a tag.
-- Only an owner or maintainer can merge a pull request.
-
-The receive-pack service classifies each proposed ref change after it validates
-the objects and before it writes an intent or makes an object visible. It sends
-each classification to `RepositoryPolicy`. An unmanaged feasibility test
-service keeps its original branch fast-forward rule.
-
-The pull-request service asks `RepositoryPolicy` for merge permission before it
-prepares a merge. The SQLite intent transaction checks the current role again.
-This second check prevents a merge when a collaborator loses permission during
-merge preparation.
-
-This milestone does not add configurable branch-rule records. The first stable
-release has one protected default ref, and all repositories use
-`refs/heads/main` as that ref. A subsequent milestone must add stored rule
-configuration before it lets an administrator select a different protected
-ref.
-
-## Failure and threat cases
-
-Reject a policy failure before object promotion and before intent creation.
-Return an accurate non-fast-forward status for a forced branch update. Return a
-reference-policy status for a protected-ref or role failure.
-
-Check all commands in an atomic push before an update starts. One rejected
-command rejects the complete push. Read the account, repository, and
-collaborator state from SQLite for the operation so a suspended account,
-archived repository, or removed role takes effect immediately.
-
-## Evidence
-
-The policy test checks owner, maintainer, and writer behavior for the protected
-ref, a topic branch, tags, force-push, deletion, and merge permission.
-
-The production SSH test uses a stock Git client. It checks that a writer can
-create, update, and delete a topic branch but cannot update the protected ref.
-It checks that an owner can fast-forward the protected ref but cannot delete it
-or force-push a topic branch. Existing receive-pack tests check SHA-1 and
-SHA-256 fast-forward rules and atomic rejection. Pull-request tests check that
-a writer cannot merge and that an owner can merge.
-
-## Consequences
-
-All production ref changes and pull-request merges use one policy service.
-Repositories cannot yet select a protected ref other than
-`refs/heads/main`. Tags remain mutable, and users with write permission can
-delete topic branches and tags.

docs/adr/0027-ssh-pull-request-checkout.md

Mode 100644; object 79eb2b9f67d1

@@ -1,71 +1,0 @@
-# Architectural decision record 0027: SSH pull-request checkout
-
-Status: Accepted
-
-Date: 2026-07-23
-
-## Context
-
-A contributor needs a stable command to fetch the current pull-request head.
-The command must use the same SSH authentication and repository read policy as
-the other SSH commands. Scripts also need output that does not depend on human
-text.
-
-## Decision
-
-Add this SSH command:
-
-```text
-pr checkout OWNER/REPOSITORY NUMBER [--output human|json]
-```
-
-The default output is exactly two Git commands:
-
-```text
-git fetch origin refs/pull/<number>/head:refs/heads/pr-<number>
-git checkout pr-<number>
-```
-
-The fetch command creates or updates the local `pr-<number>` branch from the
-server-owned pull-request ref. The checkout command selects that branch.
-
-The JSON output has version `1`. It contains the repository owner and name,
-the pull-request number, the remote ref, the local branch, and the two complete
-Git commands. A JSON error contains version `1`, status `error`, and one stable
-error code.
-
-Read the pull request through `Store::pull_request`. This method applies the
-current account, repository, visibility, and collaborator policy. The server
-recovers incomplete pull-request ref intents before it starts the SSH
-listener, so the command cannot observe an unrecovered ref intent.
-
-## Failure and threat cases
-
-Accept only ASCII command input with a bounded size. Require one repository
-target, one positive decimal number, and at most one output option. Reject
-extra input and duplicate options.
-
-Use the same `pull-request-unavailable` error for a missing pull request and
-one that the actor cannot read. This prevents private repository discovery.
-Return errors on standard error for human output and on standard output for
-JSON output.
-
-Do not run Git or OpenSSH in the server. The output is text for the client to
-run. The integration test uses stock Git only as an external test oracle.
-
-## Evidence
-
-The parser test checks valid human and JSON commands, invalid numbers, unsafe
-repository targets, duplicate options, extra input, and the size limit.
-
-The production SSH test creates a pull-request ref and metadata, checks the
-exact human output, and checks each JSON field. It then runs the returned fetch
-and checkout instructions with stock Git and verifies the selected object ID.
-The test also makes the repository private and verifies that an unauthorized
-account receives `pull-request-unavailable`.
-
-## Consequences
-
-The output contract is suitable for people and scripts. The local branch name
-is fixed to `pr-<number>`. A subsequent version must add a new JSON version or
-a new explicit option if it changes this naming contract.

docs/adr/0028-process-lifecycle.md

Mode 100644; object 804b631e7346

@@ -1,53 +1,0 @@
-# Architectural decision record 0028: process lifecycle
-
-Status: Accepted
-
-Date: 2026-07-23
-
-## Context
-
-The server already held one instance lock and stopped its listeners after
-SIGINT or SIGTERM. An active HTTP connection could stop shutdown for an
-unlimited time. An operator also had no endpoint that showed when both public
-listeners were ready.
-
-## Decision
-
-Keep the advisory lock in the empty `tit.lock` file for the full server run.
-Do not write a process ID to this file. Refuse an unsafe lock-file path and
-refuse a second process that requests the lock.
-
-Add `GET /healthz` and `HEAD /healthz`. Return status 503 until the HTTP and SSH
-listeners are bound. Then, return status 200 with `ready`. Set the state to not
-ready before shutdown starts. Always send `Cache-Control: no-store`.
-
-After SIGINT or SIGTERM, stop all listeners at the same time. Give HTTP, SSH,
-and control-socket tasks 10 seconds to finish. Cancel a task that does not
-finish during this time. Write one diagnostic to standard error if shutdown
-cancels an unfinished connection.
-
-## Failure and threat cases
-
-A process ID can be stale and can identify an unrelated process after PID
-reuse. The advisory file lock is the ownership authority, so the file stays
-empty.
-
-The readiness endpoint does not report ready while only HTTP is available. It
-does not use a cached response after shutdown starts. A slow or incomplete
-client cannot keep the process alive after the drain limit.
-
-## Evidence
-
-The production server test starts both listeners and checks the readiness
-response. It starts a second server for the same instance and checks that the
-instance lock rejects it. It also checks clean SIGTERM shutdown and restart.
-
-The Web server test holds an incomplete HTTP request open. It uses a short test
-drain limit and checks that shutdown cancels the connection after that limit.
-The instance-lock test checks that the lock file is empty.
-
-## Consequences
-
-Supervisors can use `/healthz` as the readiness probe. Shutdown permits current
-work to finish, but it has a fixed upper bound. A request that exceeds the
-bound can be canceled.

docs/adr/0029-backup-and-restore.md

Mode 100644; object 4a12f21b0c12

@@ -1,95 +1,0 @@
-# Architectural decision record 0029: backup and restore
-
-Status: Accepted
-
-Date: 2026-07-23
-
-## Context
-
-An instance stores metadata and secrets in SQLite. It stores Git objects and
-refs in bare repositories. It also stores the configuration and the SSH host
-private key as files. A copy of only one storage type can contain a ref state
-that does not agree with the database state.
-
-The server must continue to operate during an online backup. A restore must not
-replace an active instance or write outside the restore target.
-
-## Decision
-
-Add `tit backup FILE`. FILE must be a clean absolute path outside the instance
-directory. The command creates a new mode-0600 tar archive and never replaces
-an existing file. The archive contains:
-
-- `manifest.json`;
-- the configuration as `config.toml`;
-- a SQLite online backup as `tit.sqlite3`;
-- `ssh_host_ed25519_key`, if it exists; and
-- the complete `repositories` directory.
-
-The JSON manifest has version 1. It records the byte count, SHA-256 checksum,
-entry type, and hexadecimal filesystem-path bytes for each file and directory.
-The path encoding preserves paths that are not UTF-8. The `tar` crate supplies
-the archive implementation and removes the need for a runtime tar command.
-
-For an offline backup, the command takes the instance lock. If the server owns
-the lock, the command sends the request through the mode-0600 control socket.
-The server takes the global maintenance gate. This gate stops repository
-creation, pull-request ref operations, receive-pack ref operations, and future
-maintenance operations for the duration of the snapshot. The server first
-makes the SQLite online backup. It then copies the repositories, configuration,
-and host key while it continues to hold the gate.
-
-Add `tit restore ARCHIVE DIRECTORY`. DIRECTORY must be an existing, empty,
-private directory. Restore first reads and validates the complete archive. It
-rejects an unknown version, an unknown entry, a duplicate entry, an unsafe
-path, an unsupported entry type, a missing entry, and a checksum mismatch. It
-then creates private directories and files without following symbolic links.
-After extraction, it runs the database checks and validates each bare
-repository, object format, ref, and reachable Git object. A validation failure
-removes all extracted content.
-
-Restore does not start a server and does not replace the source instance. The
-operator must use the restored `config.toml` in a separate `serve` command to
-activate the restored instance.
-
-## Failure and threat cases
-
-The archive contains account credentials, session hashes, feed-token hashes,
-recovery-credential hashes, SSH public keys, and the SSH host private key.
-The CLI and the manifest state that the archive contains credentials. The
-archive has owner-only permissions.
-
-An output path inside the instance can enter its own repository copy and can
-make the backup unstable. The command rejects this path. `create_new` prevents
-replacement through an existing file or symbolic link.
-
-A tar archive can contain absolute paths, parent components, symbolic links,
-hard links, devices, and entries that are not in its manifest. Restore rejects
-these items before it writes a file. Restore always targets a separate empty
-directory, so it cannot partly replace an active instance.
-
-The online backup does not hold a borrowed lock guard across an asynchronous
-wait. It moves the owned gate guard into the blocking backup job. This keeps the
-gate active until all filesystem reads finish.
-
-## Evidence
-
-Unit tests create and restore an offline backup, check owner-only permissions,
-reject an existing output, reject a nonempty target, and reject changed archive
-data. A control-socket test holds a receive-pack mutation permit and checks that
-an online backup waits.
-
-The production server test imports a repository, starts the real server,
-requests an online backup through the CLI, restores the archive through the
-CLI, reads the restored SQLite record, and uses stock Git to read the restored
-commit.
-
-## Consequences
-
-The archive is intentionally not compressed. This keeps restore simple and
-makes each manifest checksum apply to the stored content. Operators can
-compress an encrypted copy after the backup completes.
-
-Online backup pauses Git writes while it copies repositories. Large
-repositories increase this pause. The implementation favors a coherent
-snapshot over write availability.

docs/adr/0030-read-only-diagnostics.md

Mode 100644; object 6881e40b8104

@@ -1,95 +1,0 @@
-# Architectural decision record 0030: read-only diagnostics
-
-Status: Accepted
-
-Date: 2026-07-23
-
-## Context
-
-The initial `tit doctor` command checked only the SQLite schema version,
-database pages, and foreign keys. It opened a read-write connection and applied
-the normal connection configuration. It did not check the filesystem, Git
-state, incomplete cross-store operations, or backup archives.
-
-An operator also needed stable ways to examine records. Direct SQLite queries
-require knowledge of the schema and can accidentally change the database.
-
-## Decision
-
-`tit doctor` opens SQLite with `SQLITE_OPEN_READ_ONLY`. It does not take the
-instance lock, migrate a schema, recover an intent, remove quarantine data, or
-create a file. It checks:
-
-- the instance, configuration, database, repository-root, and SSH host-key
-  types and permissions;
-- the current schema version, SQLite integrity, foreign keys, and all required
-  indexes;
-- incomplete Git operation intents and pull-request ref intents;
-- the database record for each repository and each repository directory;
-- the Git object format, refs, and all reachable Git objects;
-- unknown repository-root entries and quarantine debris; and
-- each backup archive supplied with `--backup`.
-
-The configuration parser runs before these checks, so configuration syntax and
-values are part of the doctor result. A missing SSH host key is an error because
-the instance is not ready to preserve its SSH identity.
-
-Add separate `repair intents` and `repair quarantine` commands. Each repair
-command takes the instance lock. Intent repair uses the normal recovery logic.
-Quarantine repair refuses to run while an incomplete intent exists. Doctor
-never calls either repair command.
-
-Add typed `inspect account`, `inspect repository`, and `inspect intent`
-commands. Each command returns one version-independent JSON object for the
-selected record type. Account inspection does not return the recovery hash or
-canonical public-key text.
-
-Add `tit dump`. It streams one JSON object for each SQLite row in deterministic
-table and primary-key order. Each object identifies the table and gives each
-column name, SQLite value type, and value. BLOB values and invalid UTF-8 text
-use lowercase hexadecimal. Valid UTF-8 text stays text. Real values use a
-deterministic 17-digit exponential form.
-
-The dump is a raw operational artifact. It can contain credential hashes,
-session hashes, feed-token hashes, invitation hashes, and SSH public keys.
-Operators must store it as a secret.
-
-## Failure and threat cases
-
-Opening a normal `Store` can migrate an old schema and can create a migration
-backup. Diagnostics use a separate read-only constructor, so a doctor,
-inspection, or dump cannot do these operations.
-
-Incomplete intents and quarantine directories can be necessary for recovery.
-Doctor reports them and leaves them unchanged. The explicit repair commands
-serialize with the server through the instance lock.
-
-A repository directory can be a symbolic link or can have an identifier that
-has no database record. Doctor rejects both cases. It also opens the repository
-without a runtime Git command and walks reachable objects with the established
-object-count limit.
-
-A damaged backup can change between validation passes. Backup validation checks
-the manifest entry type, size, and checksum. Restore checks them again while it
-extracts each file.
-
-## Evidence
-
-The CLI tests run doctor on a correct instance and on instances with a foreign
-key violation, a missing index, an incomplete intent, quarantine debris, an
-invalid Git ref, unsafe permissions, and a changed backup. The test confirms
-that doctor leaves the incomplete intent unchanged. It then runs each explicit
-repair command and checks the result.
-
-The same test checks typed account, repository, and intent JSON. It runs the
-JSON Lines dump two times and checks byte-for-byte equality and valid typed
-rows.
-
-## Consequences
-
-Doctor can run while the server is active because it does not change state.
-The result is a point-in-time sequence of checks, not a global snapshot. Use an
-online backup when one coherent cross-store snapshot is necessary.
-
-The JSON Lines dump exposes storage details intentionally. It is suitable for
-external inspection and comparison, but it is not a restore format.

docs/adr/0031-limits-and-abuse-resistance.md

Mode 100644; object a3fd2f65a7fc

@@ -1,81 +1,0 @@
-# Architectural decision record 0031: limits and abuse resistance
-
-Status: Accepted
-
-Date: 2026-07-23
-
-## Context
-
-HTTP, SSH, Git, and Markdown process data from clients and repositories. A
-client can keep connections active, send large input, or start expensive Git
-operations. A repository can also contain content that is expensive to render.
-
-The configuration already has `max_request_bytes` and `max_connections`.
-Before this decision, the server validated these fields but did not apply them
-at each listener.
-
-## Decision
-
-Apply `max_request_bytes` as a hard limit to the complete HTTP request body.
-A route can apply a smaller limit. The default is 1 MiB. Reject a request that
-exceeds the limit with status 413. The `tower-http` request-body limit supplies
-streaming enforcement and removes the need for a custom HTTP body wrapper.
-
-Apply `max_connections` to concurrent HTTP requests and SSH sessions. The
-default is 1024. An HTTP request can wait for one second for a permit. Reject it
-with status 503 if no permit becomes available. Give each HTTP request 30
-seconds, including body reads and handler work. Return status 408 after this
-time. Russh closes an inactive SSH connection after 30 seconds.
-
-Permit 10 Web login attempts from one client address in one minute. Permit 30
-SSH public-key authentication attempts from one client address in one minute.
-Track a maximum of 4096 client addresses for each interface. Reject a new
-address when the table is full. Remove an address after its attempt window is
-empty. These in-memory limits reset after a server restart. Russh also permits
-only three authentication attempts on one connection.
-
-Keep the existing Git limits. Upload-pack and receive-pack limit packet-line
-input, pack bytes, object bytes, object counts, and generated pack bytes.
-Receive-pack also limits command counts, delta depth, reachable-object walks,
-and processing time. Repository views limit refs, history, paths, trees, blobs,
-diffs, archives, searches, and operation time.
-
-Accept a maximum of 256 KiB of Markdown source and 1 MiB of sanitized HTML.
-Return a fixed safe message when a rendering limit is exceeded. Issue, comment,
-pull-request, and review validation applies the same source limit before
-storage. The Markdown limit also protects README rendering from a large Git
-blob.
-
-## Failure and threat cases
-
-The rate tables use client network addresses, not usernames. Thus, an attacker
-cannot use a victim username to prevent login by that user. A shared network
-address shares one limit. This can delay users behind the same proxy, but it
-also bounds work from that proxy. A subsequent change can use a trusted proxy
-address only after the server has a complete forwarded-address policy.
-
-The HTTP concurrency limit counts requests instead of TCP connections. An idle
-HTTP connection does not consume an application permit, but the 30-second
-request limit bounds an incomplete active request after request processing
-starts. Operating-system file-descriptor limits remain a separate deployment
-boundary.
-
-## Evidence
-
-Unit tests prove the attempt-window and tracked-address limits. Web server tests
-send more than 10 login attempts and a request larger than 1 MiB. Markdown
-tests exceed the source and rendered-output limits. Existing Git tests exceed
-packet-line, pack, object, diff, archive, search, and operation limits. Stock
-OpenSSH tests prove the per-connection authentication and inactivity settings.
-
-The complete quality gate runs all tests and the release build. The hosted gate
-runs on Linux and macOS.
-
-## Consequences
-
-The limits are process-local and use safe fixed defaults. The administrator can
-decrease the HTTP request and concurrency limits, but cannot configure values
-larger than 256 MiB and 100000 connections.
-
-Repository read limits remain code constants because they are product safety
-boundaries. They are not tuning settings.

docs/adr/0032-observability.md

Mode 100644; object e39a07c50de7

@@ -1,89 +1,0 @@
-# Architectural decision record 0032: observability
-
-Status: Accepted
-
-Date: 2026-07-23
-
-## Context
-
-An operator must identify slow requests, rejected access, active work, and
-process lifecycle changes. Audit history supplies durable security and mutation
-records, but it is not a process log or a metrics interface.
-
-Observability data must not copy credentials or unbounded user values. URLs can
-contain feed tokens. Headers can contain authorization values, cookies, and
-signatures. Request bodies can contain recovery credentials, login challenges,
-and raw signatures.
-
-## Decision
-
-Write structured JSON Lines events to standard error. Each event has a
-millisecond Unix timestamp, a fixed level, and a fixed event name. An HTTP event
-also has a random 128-bit request ID, normalized method, status, and duration in
-milliseconds. The response includes the request ID in `X-Request-ID`.
-
-An SSH connection gets a random 128-bit connection operation ID. Authentication
-events use this ID. Each SSH exec request gets a new random 128-bit operation
-ID. The server records that an operation started, but it does not record the
-command, repository, username, public key, fingerprint, or client address.
-
-Record lifecycle events for process start, listener readiness, shutdown start,
-and shutdown completion. Keep the existing SQLite audit history for durable
-account, login, repository, collaborator, issue, pull-request, and ref
-mutations. Audit records keep their existing correlation IDs.
-
-Add `GET /metrics`. It returns these fixed counters without labels:
-
-- total HTTP requests;
-- total HTTP responses with status 400 or higher;
-- active HTTP requests;
-- total SSH connections;
-- total rejected SSH authentication attempts; and
-- total SSH exec operations.
-
-The metrics response uses `no-store`. It does not contain account, repository,
-path, ref, client, or credential data. Fixed counter names prevent unbounded
-metric dimensions.
-
-The logging interface accepts only fixed event, outcome, and HTTP method values
-plus generated IDs, numeric status values, and numeric durations. It does not
-accept a URL, header, body, key, username, error string, or network address.
-This API boundary supplies redaction instead of a list that can miss a new
-secret field.
-
-## Failure and threat cases
-
-A feed token in a URL must not enter a log. HTTP logs do not contain the URL or
-query. Authorization headers, cookies, recovery credentials, login challenges,
-raw signatures, and SSH keys must not enter a log. The logger does not receive
-these values.
-
-Metrics can reveal the amount of process activity. They cannot identify a user
-or repository. An operator that does not want public activity counters must
-restrict `/metrics` in the reverse proxy.
-
-Log output can fail when standard error is closed. A log failure does not stop
-request processing or a security operation. Durable audit events remain part of
-their SQLite transactions.
-
-## Evidence
-
-The production server test performs Web login, account recovery, a private
-session, an invitation, HTTP repository access, and an SSH clone. It checks the
-metrics response and parses each server log line as JSON. It requires HTTP
-request IDs, SSH operation IDs, and completed lifecycle events. It also proves
-that supplied authorization, cookie, feed-token, recovery, invitation,
-challenge, session, and signature values do not occur in the logs.
-
-The Milestone 3.5 gate continues to prove successful and failed audit events,
-correlation IDs, and secret exclusion. Unit tests prove that the metrics output
-has only the six fixed counters.
-
-## Consequences
-
-The implementation uses `serde_json`, which is already a dependency. It does
-not add a logging framework or a metrics registry.
-
-The process writes one line for each HTTP request, SSH connection, SSH
-authentication result, SSH operation, and lifecycle change. A later release
-can add sampling only if volume becomes an operational problem.

docs/adr/0033-release-packaging.md

Mode 100644; object fa1d828e6b5b

@@ -1,40 +1,0 @@
-# 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.

docs/disaster-recovery.md

Mode 100644; object e6773262db17

@@ -1,42 +1,0 @@
-# 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.

docs/install.md

Mode 100644; object 02f5e35f6f59

@@ -1,128 +1,0 @@
-# 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.

docs/upgrade.md

Mode 100644; object 00a26a7b520f

@@ -1,40 +1,0 @@
-# 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.

release/completions/_tit

Mode 100644100644; object ca607da6e543d0bab10f4ee1

@@ -10,10 +10,12 @@
     'inspect:Show one typed instance record as JSON'
     'dump:Write all SQLite rows as deterministic JSON Lines'
     'repair:Run an explicit repair operation'
+    'maintenance:Prune old terminal records and compact the database'
     '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'
+    'help:Print help for a command'
   )
 
   _arguments \
@@ -36,6 +38,7 @@
     arguments)
       case $words[1] in
         backup|restore) _files ;;
+        maintenance) _arguments '--retention-days[Set the retention period]:days:' ;;
       esac
       ;;
   esac

release/completions/tit.bash

Mode 100644100644; object 803085db73caac5ab35a867e

@@ -4,7 +4,7 @@
     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"
+    commands="serve invite-code doctor inspect dump repair maintenance backup restore setup admin help"
 
     case "$prev" in
         --config|--backup|backup|restore)
@@ -12,6 +12,9 @@
             return
             ;;
         --public-url|--http-listen|--ssh-listen|--ssh-public-host|--ssh-public-port)
+            return
+            ;;
+        --retention-days)
             return
             ;;
     esac

release/completions/tit.fish

Mode 100644100644; object 7cebc56b2c434440551d6dde

@@ -14,7 +14,10 @@
 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 maintenance -d 'Prune old terminal records and compact the database'
+complete -c tit -n '__fish_seen_subcommand_from maintenance' -l retention-days -r -d 'Set the retention period in days'
 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'
+complete -c tit -n '__fish_use_subcommand' -a help -d 'Print help for a command'

release/man/tit.1

Mode 100644100644; object da34821ee57c6f9086375fed

@@ -1,4 +1,4 @@
-.TH TIT 1 "2026-07-23" "tit 0.1.0" "User Commands"
+.TH TIT 1 "2026-07-24" "tit 0.1.0" "User Commands"
 .SH NAME
 tit \- run a small self-hosted collaborative development environment
 .SH SYNOPSIS
@@ -60,6 +60,14 @@
 .B repair
 Run an explicit intent or quarantine repair operation.
 .TP
+.B maintenance
+Remove terminal workflow records that are older than the retention period,
+then compact SQLite.
+The default retention period is 365 days.
+Use
+.B \-\-retention\-days DAYS
+to set another period.
+.TP
 .BR backup " " \fIFILE\fR
 Create an owner-only backup archive.
 .TP
@@ -77,6 +85,30 @@
 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 .
+.PP
+The SSH server accepts these collaboration commands:
+.PP
+.nf
+auth ONE-TIME-SECRET
+repo create NAME [--output human|json]
+issue list OWNER/REPOSITORY [--output human|json]
+issue create OWNER/REPOSITORY [--output human|json]
+issue comment OWNER/REPOSITORY NUMBER [--output human|json]
+issue close OWNER/REPOSITORY NUMBER [--output human|json]
+issue reopen OWNER/REPOSITORY NUMBER [--output human|json]
+pr list OWNER/REPOSITORY [--state open|closed|merged|all] [--output human|json]
+pr create OWNER/REPOSITORY BASE HEAD [--output human|json]
+pr close OWNER/REPOSITORY NUMBER [--output human|json]
+pr reopen OWNER/REPOSITORY NUMBER [--output human|json]
+pr checkout OWNER/REPOSITORY NUMBER [--output human|json]
+.fi
+.SH WEB INTERFACE
+The Web interface does not require JavaScript.
+It provides account SSH key management, repository settings, issues, pull
+requests, watched repository activity, RSS feeds, and public profiles.
+Commit and pull-request revision pages provide patch downloads that work with
+.BR "git apply" .
+Repository owners can rename and unarchive repositories.
 .SH FILES
 .TP
 .B /srv/tit/config.toml
@@ -95,6 +127,5 @@
 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 .
+The release package contains the README, example service files, shell
+completions, and this manual page.

scripts/check

Mode 100755; object 54a57395cbc1

@@ -1,8 +1,0 @@
-#!/bin/sh
-set -eu
-
-cargo fmt --check
-cargo clippy --all-targets --all-features -- -D warnings
-cargo test --locked --all-targets --all-features
-cargo deny check advisories licenses sources
-cargo build --locked --release

scripts/check-m1a

Mode 100755; object 60dbb96e2c8e

@@ -1,5 +1,0 @@
-#!/bin/sh
-set -eu
-
-./scripts/check
-cargo test --locked --release --test sqlite_workload -- --ignored --nocapture

scripts/check-m1b

Mode 100755; object 6b238181687a

@@ -1,5 +1,0 @@
-#!/bin/sh
-set -eu
-
-./scripts/check
-cargo test --locked --release --test auth --test ssh

scripts/check-m1c

Mode 100755; object 09a275e9fb30

@@ -1,5 +1,0 @@
-#!/bin/sh
-set -eu
-
-./scripts/check
-cargo test --locked --release --test git_repository --test git_http --test git_ssh

scripts/check-m1d

Mode 100755; object ecc0269bbeab

@@ -1,5 +1,0 @@
-#!/bin/sh
-set -eu
-
-./scripts/check
-cargo test --locked --release --test git_push_ssh

scripts/check-m2-1

Mode 100755; object e4dfc424ccfd

@@ -1,6 +1,0 @@
-#!/bin/sh
-set -eu
-
-./scripts/check
-cargo test --locked --release --test cli --test sqlite
-cargo test --locked --release --test sqlite_workload -- --ignored --nocapture

scripts/check-m2-2

Mode 100755; object fa4b116c3131

@@ -1,6 +1,0 @@
-#!/bin/sh
-set -eu
-
-./scripts/check
-cargo test --locked --release --test cli --test git_repository --test sqlite
-cargo test --locked --release --test sqlite_workload -- --ignored --nocapture

scripts/check-m2-3

Mode 100755; object a692cff9b2fb

@@ -1,5 +1,0 @@
-#!/bin/sh
-set -eu
-
-./scripts/check
-cargo test --locked --release --test git_reads --test git_repository

scripts/check-m2-4

Mode 100755; object fc26afee2a83

@@ -1,5 +1,0 @@
-#!/bin/sh
-set -eu
-
-./scripts/check
-cargo test --locked --release --test web_shell

scripts/check-m2-5

Mode 100755; object 32928b414855

@@ -1,5 +1,0 @@
-#!/bin/sh
-set -eu
-
-./scripts/check
-cargo test --locked --release --test public_routes

scripts/check-m2-6

Mode 100755; object 40558b3d579e

@@ -1,6 +1,0 @@
-#!/bin/sh
-set -eu
-
-./scripts/check
-cargo test --locked --release --bin tit markdown::tests
-cargo test --locked --release --test public_routes browses_and_clones_public_repositories_for_both_hash_formats

scripts/check-m2-7

Mode 100755; object b905f0dbacf2

@@ -1,7 +1,0 @@
-#!/bin/sh
-set -eu
-
-./scripts/check
-cargo test --locked --release --test sqlite creates_renames_archives_and_reads_owned_repositories
-cargo test --locked --release --test sqlite backfills_repository_events_when_version_five_is_migrated
-cargo test --locked --release --test public_routes browses_and_clones_public_repositories_for_both_hash_formats

scripts/check-m2-8

Mode 100755; object 5e809e5f5fcb

@@ -1,7 +1,0 @@
-#!/bin/sh
-set -eu
-
-./scripts/check
-cargo test --locked --release --test git_reads measures_bounded_search_without_an_index -- --ignored --nocapture
-cargo test --locked --release --test public_routes browses_and_clones_public_repositories_for_both_hash_formats
-cargo test --locked --release --test serve serves_an_imported_repository_through_http_and_ssh

scripts/check-m3

Mode 100755; object 4d514a1869bd

@@ -1,7 +1,0 @@
-#!/bin/sh
-set -eu
-
-./scripts/check
-cargo test --locked --release --test auth --test account_lifecycle --test web_session --test repository_policy
-cargo test --locked --release --test git_push_ssh
-cargo test --locked --release --test serve

scripts/check-m3-1

Mode 100755; object 38ebe9cb44f8

@@ -1,6 +1,0 @@
-#!/bin/sh
-set -eu
-
-./scripts/check
-cargo test --locked --release --test account_lifecycle
-cargo test --locked --release --test serve serves_an_imported_repository_through_http_and_ssh

scripts/check-m3-2

Mode 100755; object 61a9631093ed

@@ -1,6 +1,0 @@
-#!/bin/sh
-set -eu
-
-./scripts/check
-cargo test --locked --release --test auth --test web_session
-cargo test --locked --release --test serve serves_an_imported_repository_through_http_and_ssh

scripts/check-m3-3

Mode 100755; object b0aaee3a65d6

@@ -1,8 +1,0 @@
-#!/bin/sh
-set -eu
-
-./scripts/check
-cargo test --locked --release --test repository_policy
-cargo test --locked --release --test public_routes browses_and_clones_public_repositories_for_both_hash_formats
-cargo test --locked --release --test serve keeps_private_git_hidden_from_http_but_allows_its_owner_over_ssh
-cargo test --locked --release --test cli administers_repository_visibility_and_collaborators

scripts/check-m3-4

Mode 100755; object 73cc88b3797f

@@ -1,6 +1,0 @@
-#!/bin/sh
-set -eu
-
-./scripts/check
-cargo test --locked --release --test serve enforces_account_roles_and_ref_policy_through_the_production_ssh_server
-cargo test --locked --release --test git_push_ssh

scripts/check-m3-5

Mode 100755; object 6e83fa305881

@@ -1,9 +1,0 @@
-#!/bin/sh
-set -eu
-
-./scripts/check
-cargo test --locked --release --test account_lifecycle
-cargo test --locked --release --test web_session
-cargo test --locked --release --test cli administers_repository_visibility_and_collaborators
-cargo test --locked --release --test serve serves_an_imported_repository_through_http_and_ssh
-cargo test --locked --release --test serve enforces_account_roles_and_ref_policy_through_the_production_ssh_server

scripts/check-m3-6

Mode 100755; object 385bdfb18c01

@@ -1,8 +1,0 @@
-#!/bin/sh
-set -eu
-
-./scripts/check
-cargo test --locked --release --test cli administers_repositories_with_immutable_canonical_paths
-cargo test --locked --release --test ssh
-cargo test --locked --release --test serve serves_an_imported_repository_through_http_and_ssh
-cargo test --locked --release --test serve creates_owned_repositories_with_stable_ssh_command_output

scripts/check-m4-1

Mode 100755; object c1fd5d8567df

@@ -1,7 +1,0 @@
-#!/bin/sh
-set -eu
-
-./scripts/check
-cargo test --locked --release --test sqlite
-cargo test --locked --release --test public_routes browses_and_clones_public_repositories_for_both_hash_formats
-cargo test --locked --release --test git_push_ssh reconciles_process_termination_at_each_cross_store_boundary

scripts/check-m4-2

Mode 100755; object 1e5e6d6b38e5

@@ -1,6 +1,0 @@
-#!/bin/sh
-set -eu
-
-./scripts/check
-cargo test --locked --release --test sqlite runs_the_issue_workflow_with_atomic_events_and_repository_roles
-cargo test --locked --release --test public_routes runs_the_complete_issue_workflow_without_javascript

scripts/check-m4-3

Mode 100755; object 1e5e6d6b38e5

@@ -1,6 +1,0 @@
-#!/bin/sh
-set -eu
-
-./scripts/check
-cargo test --locked --release --test sqlite runs_the_issue_workflow_with_atomic_events_and_repository_roles
-cargo test --locked --release --test public_routes runs_the_complete_issue_workflow_without_javascript

scripts/check-m4-4

Mode 100755; object 1e5e6d6b38e5

@@ -1,6 +1,0 @@
-#!/bin/sh
-set -eu
-
-./scripts/check
-cargo test --locked --release --test sqlite runs_the_issue_workflow_with_atomic_events_and_repository_roles
-cargo test --locked --release --test public_routes runs_the_complete_issue_workflow_without_javascript

scripts/check-m4-5

Mode 100755; object 1bde6dc58aad

@@ -1,7 +1,0 @@
-#!/bin/sh
-set -eu
-
-./scripts/check
-cargo test --locked --release --test metadata_search
-cargo test --locked --release --test metadata_search measures_bounded_metadata_search_without_an_index -- --ignored --nocapture
-cargo test --locked --release --test public_routes runs_the_complete_issue_workflow_without_javascript

scripts/check-m4-6

Mode 100755; object 395179287d47

@@ -1,5 +1,0 @@
-#!/bin/sh
-set -eu
-
-./scripts/check
-cargo test --locked --release --test serve creates_owned_repositories_with_stable_ssh_command_output

scripts/check-m5

Mode 100755; object 3ef8f5246fcf

@@ -1,9 +1,0 @@
-#!/bin/sh
-set -eu
-
-./scripts/check
-cargo test --locked --release --test git_push_ssh
-cargo test --locked --release --test pull_requests
-cargo test --locked --release --test public_routes runs_the_complete_issue_workflow_without_javascript
-cargo test --locked --release --test serve creates_owned_repositories_with_stable_ssh_command_output
-cargo test --locked --release --test sqlite migrates_each_committed_historical_fixture

scripts/check-m5-1

Mode 100755; object 305f497f83a6

@@ -1,7 +1,0 @@
-#!/bin/sh
-set -eu
-
-./scripts/check
-cargo test --locked --release --test pull_requests
-cargo test --locked --release --test public_routes runs_the_complete_issue_workflow_without_javascript
-cargo test --locked --release --test sqlite migrates_each_committed_historical_fixture

scripts/check-m5-2

Mode 100755; object 0893e1ecb66b

@@ -1,6 +1,0 @@
-#!/bin/sh
-set -eu
-
-./scripts/check
-cargo test --locked --release --test pull_requests
-cargo test --locked --release --test public_routes runs_the_complete_issue_workflow_without_javascript

scripts/check-m5-3

Mode 100755; object 305f497f83a6

@@ -1,7 +1,0 @@
-#!/bin/sh
-set -eu
-
-./scripts/check
-cargo test --locked --release --test pull_requests
-cargo test --locked --release --test public_routes runs_the_complete_issue_workflow_without_javascript
-cargo test --locked --release --test sqlite migrates_each_committed_historical_fixture

scripts/check-m5-4

Mode 100755; object 305f497f83a6

@@ -1,7 +1,0 @@
-#!/bin/sh
-set -eu
-
-./scripts/check
-cargo test --locked --release --test pull_requests
-cargo test --locked --release --test public_routes runs_the_complete_issue_workflow_without_javascript
-cargo test --locked --release --test sqlite migrates_each_committed_historical_fixture

scripts/check-m5-5

Mode 100755; object 1fc0fe943e64

@@ -1,8 +1,0 @@
-#!/bin/sh
-set -eu
-
-./scripts/check
-cargo test --locked --release --test repository_policy
-cargo test --locked --release --test git_push_ssh
-cargo test --locked --release --test serve enforces_account_roles_and_ref_policy_through_the_production_ssh_server
-cargo test --locked --release --test pull_requests fast_forwards_pull_requests_with_one_durable_merge_event_for_both_hashes

scripts/check-m5-6

Mode 100755; object 4a02f0250c2e

@@ -1,6 +1,0 @@
-#!/bin/sh
-set -eu
-
-./scripts/check
-cargo test --locked --release --bin tit parses_only_bounded_pull_request_checkout_commands
-cargo test --locked --release --test serve creates_owned_repositories_with_stable_ssh_command_output

scripts/check-m6-1

Mode 100755; object c11196a46aa8

@@ -1,7 +1,0 @@
-#!/bin/sh
-set -eu
-
-./scripts/check
-cargo test --locked --release --bin tit instance::tests::serializes_instance_owners_and_rejects_unsafe_files
-cargo test --locked --release --test web_shell cancels_a_connection_after_the_shutdown_drain_limit
-cargo test --locked --release --test serve serves_an_imported_repository_through_http_and_ssh

scripts/check-m6-2

Mode 100755; object 77d6f7a0f3ec

@@ -1,7 +1,0 @@
-#!/bin/sh
-set -eu
-
-./scripts/check
-cargo test --locked --release --bin tit backup::tests
-cargo test --locked --release --bin tit control::tests::online_backup_waits_for_a_git_mutation
-cargo test --locked --release --test serve serves_an_imported_repository_through_http_and_ssh

scripts/check-m6-3

Mode 100755; object b3a82c6d5cc1

@@ -1,5 +1,0 @@
-#!/bin/sh
-set -eu
-
-./scripts/check
-cargo test --locked --release --test cli doctor_

scripts/check-m6-4

Mode 100755; object b6ee35c03a83

@@ -1,11 +1,0 @@
-#!/bin/sh
-set -eu
-
-./scripts/check
-cargo test --locked --release --test web_shell enforces_request_and_login_attempt_limits
-cargo test --locked --release --test ssh limits_authentication_attempts_by_client_address
-cargo test --locked --release --bin tit markdown::tests::bounds_source_and_rendered_content
-cargo test --locked --release --bin tit rate_limit::tests::bounds_attempts_and_tracked_keys
-cargo test --locked --release --test git_reads enforces_each_read_boundary_and_rejects_unsafe_paths
-cargo test --locked --release --test git_reads produces_bounded_diffs_blame_and_streaming_archives
-cargo test --locked --release --test git_repository reads_sorted_refs_and_generates_complete_packs_for_both_hashes

scripts/check-m6-5

Mode 100755; object 2dec90573051

@@ -1,8 +1,0 @@
-#!/bin/sh
-set -eu
-
-./scripts/check
-cargo test --locked --release --test account_lifecycle
-cargo test --locked --release --test web_session
-cargo test --locked --release --test serve serves_an_imported_repository_through_http_and_ssh
-cargo test --locked --release --bin tit telemetry::tests::emits_only_fixed_bounded_metrics

scripts/check-m6-6

Mode 100755; object 805ddbf7536e

@@ -1,25 +1,0 @@
-#!/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

scripts/package-release

Mode 100755100755; object 2464317f8e8a004ba92e9d7a

@@ -47,7 +47,6 @@
 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" \
@@ -57,17 +56,6 @@
 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" \

scripts/verify-release-artifact

Mode 100755100755; object 398d14f853cd47ddac347cc0

@@ -58,9 +58,6 @@
     "$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" \

src/account.rs

Mode 100644100644; object 6a886e6466828eca2830f84d

@@ -6,12 +6,20 @@
 use thiserror::Error;
 
 use crate::auth::{AuthError, SshPublicKey, validate_username};
-use crate::store::{AccountRecovery, InvitedAccount, NewAuditEvent, NewSshKey, Store, StoreError};
+use crate::store::{
+    AccountKeyAuthorization, AccountRecovery, InvitedAccount, KeyInspection, NewAuditEvent,
+    NewSshKey, PublicProfile, Store, StoreError,
+};
 
 const INVITATION_PREFIX: &str = "tit-invite-v1:";
 const RECOVERY_PREFIX: &str = "tit-recovery-v1:";
 const SECRET_BYTES: usize = 32;
 const INVITATION_LIFETIME_SECONDS: i64 = 24 * 60 * 60;
+#[allow(
+    dead_code,
+    reason = "the account integration test imports this module without the Web profile route"
+)]
+const PROFILE_REPOSITORY_PAGE_SIZE: usize = 20;
 
 #[derive(Clone)]
 pub(crate) struct AccountService {
@@ -178,6 +186,141 @@
         Ok(())
     }
 
+    pub(crate) fn profile(&self, username: &str) -> Result<PublicProfile, AccountError> {
+        validate_username(username)?;
+        Store::open(&self.database)?
+            .public_profile(username)
+            .map_err(Into::into)
+    }
+
+    #[allow(
+        dead_code,
+        reason = "the account integration test imports this module without the Web profile route"
+    )]
+    pub(crate) fn profile_page(
+        &self,
+        username: &str,
+        page: usize,
+    ) -> Result<PublicProfile, AccountError> {
+        validate_username(username)?;
+        if page == 0 || page > 10_000 {
+            return Err(AccountError::InvalidProfile);
+        }
+        let profile = Store::open(&self.database)?
+            .public_profile_page(username, page, PROFILE_REPOSITORY_PAGE_SIZE)
+            .map_err(AccountError::from)?;
+        if page > 1 && profile.repositories.is_empty() {
+            return Err(AccountError::InvalidProfile);
+        }
+        Ok(profile)
+    }
+
+    pub(crate) fn update_profile(
+        &self,
+        username: &str,
+        bio: &str,
+        contact_email: &str,
+    ) -> Result<(), AccountError> {
+        validate_username(username)?;
+        validate_profile(bio, contact_email)?;
+        Store::open(&self.database)?
+            .update_profile(username, bio, contact_email)
+            .map_err(Into::into)
+    }
+
+    #[allow(
+        dead_code,
+        reason = "the account integration test imports this module without Web account routes"
+    )]
+    pub(crate) fn keys(&self, username: &str) -> Result<Vec<KeyInspection>, AccountError> {
+        validate_username(username)?;
+        Ok(Store::open(&self.database)?.inspect_account(username)?.keys)
+    }
+
+    #[allow(
+        dead_code,
+        reason = "the account integration test imports this module without Web account routes"
+    )]
+    pub(crate) fn complete_key_add(
+        &self,
+        request: &AccountKeyRequest<'_>,
+        label: &str,
+        public_key: &str,
+    ) -> Result<String, AccountError> {
+        let changed_at = now()?;
+        let result = (|| {
+            validate_username(request.username)?;
+            validate_token(request.session)?;
+            validate_token(request.csrf)?;
+            validate_token(request.secret)?;
+            validate_label(label)?;
+            let key = SshPublicKey::parse(public_key)?;
+            Store::open(&self.database)?.complete_account_key_add(
+                &AccountKeyAuthorization {
+                    username: request.username,
+                    session_hash: &hash(request.session),
+                    csrf_hash: &hash(request.csrf),
+                    secret_hash: &hash(request.secret),
+                    changed_at,
+                    correlation_id: request.correlation_id,
+                },
+                &new_key(&key, label),
+            )?;
+            Ok(key.fingerprint().to_owned())
+        })();
+        if result.is_err() {
+            self.audit_failure(
+                "key.add",
+                request.username,
+                request.username,
+                request.correlation_id,
+                changed_at,
+            )?;
+        }
+        result
+    }
+
+    #[allow(
+        dead_code,
+        reason = "the account integration test imports this module without Web account routes"
+    )]
+    pub(crate) fn complete_key_revoke(
+        &self,
+        request: &AccountKeyRequest<'_>,
+        fingerprint: &str,
+    ) -> Result<(), AccountError> {
+        let changed_at = now()?;
+        let result = (|| {
+            validate_username(request.username)?;
+            validate_token(request.session)?;
+            validate_token(request.csrf)?;
+            validate_token(request.secret)?;
+            Store::open(&self.database)?.complete_account_key_revoke(
+                &AccountKeyAuthorization {
+                    username: request.username,
+                    session_hash: &hash(request.session),
+                    csrf_hash: &hash(request.csrf),
+                    secret_hash: &hash(request.secret),
+                    changed_at,
+                    correlation_id: request.correlation_id,
+                },
+                fingerprint,
+            )?;
+            Ok(())
+        })();
+        if result.is_err() {
+            let target = format!("{}:{fingerprint}", request.username);
+            self.audit_failure(
+                "key.revoke",
+                request.username,
+                bounded_audit_target(&target),
+                request.correlation_id,
+                changed_at,
+            )?;
+        }
+        result
+    }
+
     fn audit_failure(
         &self,
         action: &str,
@@ -206,6 +349,14 @@
     }
 }
 
+pub(crate) struct AccountKeyRequest<'a> {
+    pub(crate) username: &'a str,
+    pub(crate) session: &'a str,
+    pub(crate) csrf: &'a str,
+    pub(crate) secret: &'a str,
+    pub(crate) correlation_id: &'a str,
+}
+
 fn new_key<'a>(key: &'a SshPublicKey, label: &'a str) -> NewSshKey<'a> {
     NewSshKey {
         canonical_key: key.canonical(),
@@ -222,6 +373,26 @@
     }
 }
 
+fn validate_profile(bio: &str, contact_email: &str) -> Result<(), AccountError> {
+    if bio.len() > 512
+        || bio
+            .chars()
+            .any(|character| character.is_control() && character != '\n')
+    {
+        return Err(AccountError::InvalidProfile);
+    }
+    if contact_email.len() > 254
+        || contact_email.chars().any(char::is_control)
+        || (!contact_email.is_empty()
+            && (contact_email.matches('@').count() != 1
+                || contact_email.starts_with('@')
+                || contact_email.ends_with('@')))
+    {
+        return Err(AccountError::InvalidProfile);
+    }
+    Ok(())
+}
+
 fn validate_label(label: &str) -> Result<(), AccountError> {
     if label.is_empty()
         || label.len() > 80
@@ -229,6 +400,17 @@
         || label.chars().any(char::is_control)
     {
         return Err(AccountError::InvalidLabel);
+    }
+    Ok(())
+}
+
+#[allow(
+    dead_code,
+    reason = "the account integration test imports this module without Web account routes"
+)]
+fn validate_token(token: &str) -> Result<(), AccountError> {
+    if token.len() != SECRET_BYTES * 2 || !token.bytes().all(|byte| byte.is_ascii_hexdigit()) {
+        return Err(AccountError::InvalidSecret);
     }
     Ok(())
 }
@@ -278,6 +460,8 @@
     Store(#[from] StoreError),
     #[error("key label is not valid")]
     InvalidLabel,
+    #[error("profile is not valid")]
+    InvalidProfile,
     #[error("credential format is not valid")]
     InvalidSecret,
     #[error("cannot create a random credential")]

src/admin.rs

Mode 100644100644; object c4d2cc97f3f49773e8d9a778

@@ -18,6 +18,24 @@
 
 const ADMIN_ACTOR: &str = "admin-cli";
 
+pub(crate) fn maintain(
+    instance_dir: &Path,
+    retention_days: u32,
+) -> Result<crate::store::MaintenanceResult, AdminError> {
+    if retention_days == 0 {
+        return Err(AdminError::Retention);
+    }
+    let _lock = InstanceLock::acquire(instance_dir)?;
+    let database = prepare_database(instance_dir)?;
+    let retention_seconds = i64::from(retention_days)
+        .checked_mul(24 * 60 * 60)
+        .ok_or(AdminError::Retention)?;
+    let cutoff = timestamp()?
+        .checked_sub(retention_seconds)
+        .ok_or(AdminError::Retention)?;
+    Store::open(&database)?.maintain(cutoff).map_err(Into::into)
+}
+
 pub(crate) fn create_repository(
     instance_dir: &Path,
     owner: &str,
@@ -277,20 +295,29 @@
         path: final_path.clone(),
         source,
     })?;
+    let mut cleanup = RepositoryCleanup::new(final_path.clone());
+    import_fault("after-rename")?;
     let canonical_path =
         fs::canonicalize(&final_path).map_err(|source| AdminError::Canonicalize {
             path: final_path.clone(),
             source,
         })?;
+    cleanup.path = canonical_path.clone();
+    import_fault("after-canonicalize")?;
     if canonical_path.parent() != Some(root.as_path()) {
         remove_created_repository(&canonical_path)?;
         return Err(AdminError::PathEscape(canonical_path));
     }
 
     let object_format = object_format_name(object_format)?;
+    import_fault("after-object-format")?;
     let git = GitRepository::open(&canonical_path)?;
-    let initial_references = git
-        .references()?
+    import_fault("after-open")?;
+    let default_branch = git
+        .default_branch()?
+        .unwrap_or_else(|| "refs/heads/main".to_owned());
+    let references = git.references()?;
+    let initial_references = references
         .into_iter()
         .filter(|reference| {
             reference.name.starts_with(b"refs/heads/") || reference.name.starts_with(b"refs/tags/")
@@ -300,11 +327,13 @@
             target: reference.target.to_string(),
         })
         .collect::<Vec<_>>();
+    import_fault("after-references")?;
     if let Err(error) = store.create_repository(&NewRepository {
         id: &id,
         owner,
         slug,
         object_format,
+        default_branch: &default_branch,
         created_at,
         origin,
         initial_references: &initial_references,
@@ -315,7 +344,45 @@
         record_failure(&store, action, &audit_target, &correlation_id, created_at)?;
         return Err(error.into());
     }
+    cleanup.disarm();
     store.repository(owner, slug).map_err(Into::into)
+}
+
+struct RepositoryCleanup {
+    path: PathBuf,
+    armed: bool,
+}
+
+impl RepositoryCleanup {
+    fn new(path: PathBuf) -> Self {
+        Self { path, armed: true }
+    }
+
+    fn disarm(&mut self) {
+        self.armed = false;
+    }
+}
+
+impl Drop for RepositoryCleanup {
+    fn drop(&mut self) {
+        if self.armed {
+            let _ = fs::remove_dir_all(&self.path);
+        }
+    }
+}
+
+#[cfg(debug_assertions)]
+fn import_fault(point: &'static str) -> Result<(), AdminError> {
+    if std::env::var("TIT_TEST_IMPORT_FAIL").as_deref() == Ok(point) {
+        Err(AdminError::InjectedFailure(point))
+    } else {
+        Ok(())
+    }
+}
+
+#[cfg(not(debug_assertions))]
+fn import_fault(_: &'static str) -> Result<(), AdminError> {
+    Ok(())
 }
 
 fn inspect_with_store(
@@ -427,8 +494,13 @@
     Random,
     #[error("system clock is before the Unix epoch")]
     Clock,
+    #[error("retention days must be greater than zero")]
+    Retention,
     #[error("repository object format does not match the database")]
     ObjectFormatMismatch,
     #[error("repository object format is not supported")]
     UnsupportedObjectFormat,
+    #[cfg(debug_assertions)]
+    #[error("injected repository import failure after {0}")]
+    InjectedFailure(&'static str),
 }

src/backup.rs

Mode 100644100644; object 9caba392fa2c1b4e679f5af0

@@ -859,6 +859,50 @@
     #[test]
     fn creates_and_restores_a_private_offline_backup() {
         let (instance, config) = source_instance();
+        let mut store =
+            Store::open(&instance.path().join(DATABASE_FILE)).expect("open the source database");
+        store
+            .create_initial_administrator(&crate::store::InitialAdministrator {
+                username: "alice",
+                canonical_key: "ssh-ed25519 AAAAbackup",
+                fingerprint: "SHA256:backup",
+                recovery_hash: &[7; 32],
+                created_at: 1,
+            })
+            .expect("create a backup account");
+        let repository_id = "00112233445566778899aabbccddeeff";
+        store
+            .create_repository(&crate::store::NewRepository {
+                id: repository_id,
+                owner: "alice",
+                slug: "before",
+                object_format: "sha1",
+                default_branch: "refs/heads/main",
+                created_at: 2,
+                origin: crate::store::RepositoryOrigin::Created,
+                initial_references: &[],
+                actor: "alice",
+                correlation_id: "backup-create",
+            })
+            .expect("create a backup repository");
+        store
+            .rename_repository_for_owner("alice", "before", "after", "alice", 3, "backup-rename")
+            .expect("rename the backup repository");
+        store
+            .archive_repository_for_actor("alice", "after", "alice", 4, "backup-archive")
+            .expect("archive the backup repository");
+        store
+            .unarchive_repository_for_owner("alice", "after", "alice", 5, "backup-unarchive")
+            .expect("unarchive the backup repository");
+        drop(store);
+        crate::git::repository::GitRepository::create_bare(
+            &instance
+                .path()
+                .join(REPOSITORY_DIRECTORY)
+                .join(format!("{repository_id}.git")),
+            gix::hash::Kind::Sha1,
+        )
+        .expect("create the backup repository directory");
         let output_directory = private_directory();
         let archive = output_directory.path().join("instance.tar");
         create_offline(instance.path(), &config, &archive).expect("create a backup");
@@ -879,6 +923,12 @@
             fs::read(config).expect("read the source configuration")
         );
         crate::store::doctor(target.path()).expect("check the restored database");
+        let restored = Store::open(&target.path().join(DATABASE_FILE))
+            .expect("open the restored lifecycle database")
+            .repository("alice", "after")
+            .expect("read the restored lifecycle repository");
+        assert_eq!(restored.state, "active");
+        assert_eq!(restored.archived_at, None);
     }
 
     #[test]

src/cli.rs

Mode 100644100644; object 28c8a816c43c9cb318bbe306

@@ -68,6 +68,12 @@
         #[command(subcommand)]
         command: RepairCommand,
     },
+    /// Prune old terminal records and compact the database
+    Maintenance {
+        /// Retain terminal records for this number of days
+        #[arg(long, default_value_t = 365)]
+        retention_days: u32,
+    },
     /// Create a backup archive
     Backup {
         /// Write the backup archive to FILE
@@ -151,12 +157,7 @@
 #[derive(Clone, Debug, Subcommand)]
 pub(crate) enum RepositoryCommand {
     /// Create an empty repository
-    Create {
-        owner: String,
-        slug: String,
-        #[arg(long, value_enum, default_value_t = ObjectFormat::Sha1)]
-        object_format: ObjectFormat,
-    },
+    Create { owner: String, slug: String },
     /// Import a bare repository
     Import {
         owner: String,
@@ -205,13 +206,6 @@
     Maintainer,
     Writer,
     Reader,
-}
-
-#[derive(Clone, Copy, Debug, Default, ValueEnum)]
-pub(crate) enum ObjectFormat {
-    #[default]
-    Sha1,
-    Sha256,
 }
 
 #[derive(Clone, Debug, Subcommand)]

src/config.rs

Mode 100644100644; object bc1971664d646f01dd9655cd

@@ -1,4 +1,3 @@
-use std::collections::HashSet;
 use std::env;
 use std::fs;
 use std::net::{IpAddr, SocketAddr};
@@ -27,7 +26,6 @@
     pub(crate) signup_policy: SignupPolicy,
     pub(crate) max_request_bytes: u64,
     pub(crate) max_connections: u32,
-    pub(crate) trusted_addresses: Vec<IpAddr>,
 }
 
 impl Config {
@@ -72,15 +70,6 @@
             return Err(ConfigError::UnsupportedSignupPolicy);
         }
 
-        let mut addresses = HashSet::new();
-        for address in &self.trusted_addresses {
-            if address.is_unspecified() || address.is_multicast() {
-                return Err(ConfigError::InvalidTrustedAddress(*address));
-            }
-            if !addresses.insert(address) {
-                return Err(ConfigError::DuplicateTrustedAddress(*address));
-            }
-        }
         self.clone_urls("owner", "repository")?;
         Ok(())
     }
@@ -127,8 +116,6 @@
     signup: SignupConfig,
     #[serde(default)]
     limits: LimitsConfig,
-    #[serde(default)]
-    proxy: ProxyConfig,
 }
 
 #[derive(Debug, Deserialize)]
@@ -197,12 +184,6 @@
     }
 }
 
-#[derive(Debug, Default, Deserialize)]
-#[serde(default, deny_unknown_fields)]
-struct ProxyConfig {
-    trusted_addresses: Vec<IpAddr>,
-}
-
 #[derive(Debug, Error)]
 pub(crate) enum ConfigError {
     #[error("cannot determine the user data directory")]
@@ -254,10 +235,6 @@
     LimitTooLarge(&'static str),
     #[error("signup policy is not supported")]
     UnsupportedSignupPolicy,
-    #[error("trusted proxy address is not a unicast address: {0}")]
-    InvalidTrustedAddress(IpAddr),
-    #[error("trusted proxy address occurs more than once: {0}")]
-    DuplicateTrustedAddress(IpAddr),
     #[error("advertised endpoint cannot be a base URL")]
     CloneUrlBase,
     #[error("cannot generate an SSH clone URL: {0}")]
@@ -312,7 +289,6 @@
         signup_policy: file.signup.policy,
         max_request_bytes: file.limits.max_request_bytes,
         max_connections: file.limits.max_connections,
-        trusted_addresses: file.proxy.trusted_addresses,
     };
     config.validate(&path)?;
     Ok(config)
@@ -421,8 +397,6 @@
 [signup]
 policy = "invite"
 
-[proxy]
-trusted_addresses = []
 "#,
         );
 
@@ -440,7 +414,6 @@
         assert_eq!(config.signup_policy, SignupPolicy::Invite);
         assert_eq!(config.max_request_bytes, 1_048_576);
         assert_eq!(config.max_connections, 1_024);
-        assert!(config.trusted_addresses.is_empty());
     }
 
     #[test]
@@ -618,24 +591,6 @@
         assert!(matches!(
             load(&cli(&path)),
             Err(ConfigError::LimitTooLarge("max_connections"))
-        ));
-    }
-
-    #[test]
-    fn rejects_an_unspecified_trusted_proxy() {
-        let (_directory, path) = write_config(
-            r#"
-version = 1
-public_url = "https://tit.example/"
-
-[proxy]
-trusted_addresses = ["0.0.0.0"]
-"#,
-        );
-
-        assert!(matches!(
-            load(&cli(&path)),
-            Err(ConfigError::InvalidTrustedAddress(address)) if address.is_unspecified()
         ));
     }
 }

src/diagnostics.rs

Mode 100644100644; object d174dcc8980e8b66ae30277d

@@ -21,11 +21,8 @@
     "feed_token_account_active",
     "feed_token_repository_active",
     "git_operation_intent_incomplete",
-    "issue_assignee_account",
     "issue_comment_history",
-    "issue_label_label",
     "issue_repository_state",
-    "label_repository_name",
     "login_nonce_active",
     "m1a_child_state_parent",
     "m1a_parent_created_at",
@@ -89,7 +86,8 @@
 ) -> Result<RepositoryInspection, DiagnosticError> {
     let store = Store::open_read_only(&config.instance_dir.join(DATABASE_FILE))?;
     let repository = store.repository(owner, slug)?;
-    check_repository(&config.instance_dir, &repository)?;
+    let default_branch = store.repository_default_branch(owner, slug)?;
+    check_repository(&config.instance_dir, &repository, &default_branch)?;
     Ok(repository.into())
 }
 
@@ -118,7 +116,9 @@
         .map(|repository| format!("{}.git", repository.id))
         .collect();
     for repository in &repositories {
-        check_repository(instance_dir, repository)?;
+        let default_branch =
+            store.repository_default_branch(&repository.owner, &repository.slug)?;
+        check_repository(instance_dir, repository, &default_branch)?;
     }
     for entry in fs::read_dir(&root).map_err(|source| DiagnosticError::Io {
         path: root.clone(),
@@ -141,6 +141,7 @@
 fn check_repository(
     instance_dir: &Path,
     repository: &crate::store::RepositoryRecord,
+    default_branch: &str,
 ) -> Result<(), DiagnosticError> {
     let path = instance_dir
         .join(REPOSITORY_DIRECTORY)
@@ -160,6 +161,9 @@
     };
     if format != repository.object_format {
         return Err(DiagnosticError::ObjectFormat(repository.id.clone()));
+    }
+    if git.default_branch()?.as_deref() != Some(default_branch) {
+        return Err(DiagnosticError::DefaultBranch(repository.id.clone()));
     }
     git.integrity_check()?;
 
@@ -245,6 +249,8 @@
     QuarantineDebris(PathBuf),
     #[error("repository object format does not match its database record: {0}")]
     ObjectFormat(String),
+    #[error("repository default branch does not match its database record: {0}")]
+    DefaultBranch(String),
     #[error("repository object format is not supported")]
     UnsupportedObjectFormat,
     #[error("diagnostic I/O failed for {path}: {source}")]

src/feed.rs

Mode 100644100644; object d6e892eb73c5757e4af327da

@@ -7,11 +7,6 @@
 
 pub(crate) const PAGE_SIZE: usize = 20;
 
-pub(crate) enum FeedFormat {
-    Atom,
-    Rss,
-}
-
 #[derive(Clone, Copy)]
 pub(crate) enum RepositoryFeedKind {
     Activity,
@@ -29,49 +24,7 @@
 }
 
 impl FeedPage<'_> {
-    pub(crate) fn render(&self, format: FeedFormat) -> Result<String, FeedError> {
-        match format {
-            FeedFormat::Atom => self.atom(),
-            FeedFormat::Rss => self.rss(),
-        }
-    }
-
-    fn atom(&self) -> Result<String, FeedError> {
-        let repository_url = self.repository_url();
-        let updated = self
-            .events
-            .iter()
-            .map(|event| event.created_at)
-            .max()
-            .unwrap_or(self.repository.created_at);
-        let mut output = String::from(
-            "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<feed xmlns=\"http://www.w3.org/2005/Atom\">\n",
-        );
-        element(&mut output, "id", &self.feed_id())?;
-        element(&mut output, "title", &self.feed_title())?;
-        element(&mut output, "updated", &atom_date(updated)?)?;
-        empty_link(&mut output, "self", self.self_url)?;
-        empty_link(&mut output, "alternate", &repository_url)?;
-        if let Some(next) = self.next_url() {
-            empty_link(&mut output, "next", &next)?;
-        }
-        for event in self.events {
-            output.push_str("<entry>\n");
-            element(&mut output, "id", &event_id(&event.event_id))?;
-            element(&mut output, "title", &event_title(event))?;
-            element(&mut output, "updated", &atom_date(event.created_at)?)?;
-            empty_link(&mut output, "alternate", &repository_url)?;
-            output.push_str("<author>");
-            element(&mut output, "name", &event.actor)?;
-            output.push_str("</author>\n<content type=\"text\">");
-            escape_xml(&event_description(event), &mut output)?;
-            output.push_str("</content>\n</entry>\n");
-        }
-        output.push_str("</feed>\n");
-        Ok(output)
-    }
-
-    fn rss(&self) -> Result<String, FeedError> {
+    pub(crate) fn render(&self) -> Result<String, FeedError> {
         let repository_url = self.repository_url();
         let updated = self
             .events
@@ -109,14 +62,6 @@
         Ok(output)
     }
 
-    fn feed_id(&self) -> String {
-        let suffix = match self.kind {
-            RepositoryFeedKind::Activity => "events",
-            RepositoryFeedKind::Issues => "issues",
-        };
-        format!("urn:tit:repository:{}:{suffix}", self.repository.id)
-    }
-
     fn feed_title(&self) -> String {
         let suffix = match self.kind {
             RepositoryFeedKind::Activity => "events",
@@ -144,46 +89,12 @@
 pub(crate) struct ActivityFeedPage<'a> {
     pub(crate) base_url: &'a str,
     pub(crate) self_url: &'a str,
-    pub(crate) scope: &'a str,
     pub(crate) username: &'a str,
-    pub(crate) target: Option<&'a str>,
     pub(crate) events: &'a [ActivityEventRecord],
 }
 
 impl ActivityFeedPage<'_> {
-    pub(crate) fn render(&self, format: FeedFormat) -> Result<String, FeedError> {
-        match format {
-            FeedFormat::Atom => self.atom(),
-            FeedFormat::Rss => self.rss(),
-        }
-    }
-
-    fn atom(&self) -> Result<String, FeedError> {
-        let mut output = String::from(
-            "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<feed xmlns=\"http://www.w3.org/2005/Atom\">\n",
-        );
-        element(&mut output, "id", &self.feed_id())?;
-        element(&mut output, "title", &self.feed_title())?;
-        element(&mut output, "updated", &atom_date(self.updated())?)?;
-        empty_link(&mut output, "self", self.self_url)?;
-        for record in self.events {
-            let link = activity_link(self.base_url, record);
-            output.push_str("<entry>\n");
-            element(&mut output, "id", &event_id(&record.event.event_id))?;
-            element(&mut output, "title", &activity_title(record))?;
-            element(&mut output, "updated", &atom_date(record.event.created_at)?)?;
-            empty_link(&mut output, "alternate", &link)?;
-            output.push_str("<author>");
-            element(&mut output, "name", &record.event.actor)?;
-            output.push_str("</author>\n<content type=\"text\">");
-            escape_xml(&event_description(&record.event), &mut output)?;
-            output.push_str("</content>\n</entry>\n");
-        }
-        output.push_str("</feed>\n");
-        Ok(output)
-    }
-
-    fn rss(&self) -> Result<String, FeedError> {
+    pub(crate) fn render(&self) -> Result<String, FeedError> {
         let mut output = String::from(
             "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<rss version=\"2.0\" xmlns:atom=\"http://www.w3.org/2005/Atom\">\n<channel>\n",
         );
@@ -212,24 +123,8 @@
         Ok(output)
     }
 
-    fn feed_id(&self) -> String {
-        format!(
-            "urn:tit:account:{}:{}:{}",
-            self.username,
-            self.scope,
-            self.target.unwrap_or(self.username)
-        )
-    }
-
     fn feed_title(&self) -> String {
-        let title = match self.scope {
-            "repository" => "repository activity",
-            "watched" => "watched activity",
-            "assignments" => "assignments",
-            "mentions" => "mentions",
-            _ => "activity",
-        };
-        format!("{} {title}", self.username)
+        format!("{} watched activity", self.username)
     }
 
     fn updated(&self) -> i64 {
@@ -241,7 +136,7 @@
     }
 }
 
-fn activity_title(record: &ActivityEventRecord) -> String {
+pub(crate) fn activity_title(record: &ActivityEventRecord) -> String {
     format!(
         "{}/{}: {}",
         record.repository.owner,
@@ -250,7 +145,7 @@
     )
 }
 
-fn activity_link(base_url: &str, record: &ActivityEventRecord) -> String {
+pub(crate) fn activity_link(base_url: &str, record: &ActivityEventRecord) -> String {
     let repository_url = format!(
         "{}/{}/{}",
         base_url, record.repository.owner, record.repository.slug
@@ -290,10 +185,6 @@
         "issue-commented" => issue_title(event, "commented on"),
         "issue-closed" => issue_title(event, "closed"),
         "issue-reopened" => issue_title(event, "reopened"),
-        "issue-labeled" => issue_value_title(event, "added label", "label"),
-        "issue-unlabeled" => issue_value_title(event, "removed label", "label"),
-        "issue-assigned" => issue_value_title(event, "assigned", "assignee"),
-        "issue-unassigned" => issue_value_title(event, "unassigned", "assignee"),
         "pull-request-created" => pull_request_title(event, "opened"),
         "pull-request-revised" => pull_request_title(event, "revised"),
         "pull-request-commented" => pull_request_title(event, "commented on"),
@@ -311,22 +202,6 @@
         .map(|number| format!("#{number}"))
         .unwrap_or_else(|| "Issue".to_owned());
     format!("{} {action} {number}", event.actor)
-}
-
-fn issue_value_title(event: &RepositoryEventRecord, action: &str, field: &str) -> String {
-    let Some(payload) = issue_payload(event) else {
-        return issue_title(event, action);
-    };
-    let number = payload
-        .get("number")
-        .and_then(serde_json::Value::as_i64)
-        .map(|number| format!("#{number}"))
-        .unwrap_or_else(|| "issue".to_owned());
-    let value = payload
-        .get(field)
-        .and_then(serde_json::Value::as_str)
-        .unwrap_or("unknown");
-    format!("{} {action} {value} on {number}", event.actor)
 }
 
 fn issue_payload(event: &RepositoryEventRecord) -> Option<serde_json::Value> {
@@ -366,12 +241,6 @@
     String::from_utf8_lossy(short).into_owned()
 }
 
-fn atom_date(timestamp: i64) -> Result<String, FeedError> {
-    Ok(jiff::Timestamp::from_second(timestamp)
-        .map_err(|_| FeedError::Timestamp)?
-        .to_string())
-}
-
 fn rss_date(timestamp: i64) -> Result<String, FeedError> {
     let seconds = u64::try_from(timestamp).map_err(|_| FeedError::Timestamp)?;
     let time = UNIX_EPOCH
@@ -384,15 +253,6 @@
     write!(output, "<{name}>")?;
     escape_xml(value, output)?;
     writeln!(output, "</{name}>")?;
-    Ok(())
-}
-
-fn empty_link(output: &mut String, relation: &str, href: &str) -> Result<(), FeedError> {
-    write!(output, "<link rel=\"")?;
-    escape_xml(relation, output)?;
-    output.push_str("\" href=\"");
-    escape_xml(href, output)?;
-    output.push_str("\" />\n");
     Ok(())
 }
 

src/feed_token.rs

Mode 100644100644; object cfdbe5eb7126149f16773513

@@ -6,8 +6,9 @@
 use thiserror::Error;
 
 use crate::auth::{AuthError, validate_username};
-use crate::domain::repository::{RepositoryNameError, validate_slug};
-use crate::store::{FeedTokenRecord, Store, StoreError, TokenFeedPage};
+use crate::store::{
+    ActivityCursor, ActivityPage, FeedTokenRecord, Store, StoreError, TokenFeedPage,
+};
 
 const TOKEN_BYTES: usize = 32;
 
@@ -30,31 +31,11 @@
             .map_err(Into::into)
     }
 
-    pub(crate) fn issue(
-        &self,
-        actor: &str,
-        scope: &str,
-        owner: Option<&str>,
-        repository: Option<&str>,
-    ) -> Result<IssuedFeedToken, FeedTokenError> {
+    pub(crate) fn issue(&self, actor: &str) -> Result<IssuedFeedToken, FeedTokenError> {
         validate_username(actor)?;
-        let target = match (scope, owner, repository) {
-            ("repository", Some(owner), Some(repository)) => {
-                validate_username(owner)?;
-                validate_slug(repository)?;
-                Some((owner, repository))
-            }
-            ("watched" | "assignments" | "mentions", None, None) => None,
-            _ => return Err(FeedTokenError::InvalidScope),
-        };
         let token = random_token()?;
-        let record = Store::open(&self.database)?.create_feed_token(
-            actor,
-            scope,
-            target,
-            &hash(&token),
-            now()?,
-        )?;
+        let record =
+            Store::open(&self.database)?.create_feed_token(actor, &hash(&token), now()?)?;
         Ok(IssuedFeedToken { record, token })
     }
 
@@ -79,6 +60,18 @@
         validate_token(token)?;
         Store::open(&self.database)?
             .token_feed_events(&hash(token), limit)
+            .map_err(Into::into)
+    }
+
+    pub(crate) fn activity(
+        &self,
+        actor: &str,
+        before: Option<&ActivityCursor>,
+        limit: usize,
+    ) -> Result<ActivityPage, FeedTokenError> {
+        validate_username(actor)?;
+        Store::open(&self.database)?
+            .watched_activity_page(actor, before, limit)
             .map_err(Into::into)
     }
 }
@@ -137,11 +130,7 @@
     #[error(transparent)]
     Auth(#[from] AuthError),
     #[error(transparent)]
-    RepositoryName(#[from] RepositoryNameError),
-    #[error(transparent)]
     Store(#[from] StoreError),
-    #[error("feed token scope is not valid")]
-    InvalidScope,
     #[error("feed token is not valid")]
     InvalidToken,
     #[error("cannot create random feed token data")]

src/git/mod.rs

Mode 100644100644; object ab75bca6a05cccea7ef8c31f

@@ -1,5 +1,6 @@
 pub(crate) mod http;
 pub(crate) mod packetline;
+pub(crate) mod patch;
 #[allow(
     dead_code,
     reason = "some protocol tests import Git without the public HTTP routes"

src/git/patch.rs

Mode 100644; object 8f7e5cad35ec

@@ -1,0 +1,215 @@
+use std::io::Write;
+
+use gix::hash::ObjectId;
+
+use super::read::{DiffFile, ReadError};
+
+const MAX_PATCH_BYTES: usize = 32 * 1024 * 1024;
+const BASE85: &[u8; 85] =
+    b"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz!#$%&()*+-;<=>?@^_`{|}~";
+
+pub(crate) fn write_patch(files: &[DiffFile], output: &mut impl Write) -> Result<usize, ReadError> {
+    let mut output = PatchWriter {
+        inner: output,
+        written: 0,
+    };
+    for file in files {
+        write_file(file, &mut output)?;
+    }
+    Ok(output.written)
+}
+
+fn write_file(file: &DiffFile, output: &mut PatchWriter<'_, impl Write>) -> Result<(), ReadError> {
+    let old_path = patch_path(b'a', &file.path);
+    let new_path = patch_path(b'b', &file.path);
+    writeln_bytes(
+        output,
+        &[
+            b"diff --git ".as_slice(),
+            &old_path,
+            b" ".as_slice(),
+            &new_path,
+        ],
+    )?;
+    match (file.old_mode, file.new_mode) {
+        (None, Some(mode)) => writeln!(output, "new file mode {mode:06o}")?,
+        (Some(mode), None) => writeln!(output, "deleted file mode {mode:06o}")?,
+        (Some(old), Some(new)) if old != new => {
+            write!(output, "old mode {old:06o}\nnew mode {new:06o}\n")?;
+        }
+        _ => {}
+    }
+    let hash_length = file
+        .old_id
+        .or(file.new_id)
+        .map_or(40, |id| id.kind().len_in_hex());
+    let old_id = object_name(file.old_id, hash_length);
+    let new_id = object_name(file.new_id, hash_length);
+    match file.new_mode.or(file.old_mode) {
+        Some(mode) if file.old_mode == file.new_mode => {
+            writeln!(output, "index {old_id}..{new_id} {mode:06o}")?;
+        }
+        _ => writeln!(output, "index {old_id}..{new_id}")?,
+    }
+    if file.binary {
+        writeln_bytes(
+            output,
+            &[
+                b"Binary files ".as_slice(),
+                &old_path,
+                b" and ",
+                &new_path,
+                b" differ",
+            ],
+        )?;
+        output.write_all(b"GIT binary patch\n")?;
+        write_binary_literal(file.new_data.as_deref().unwrap_or_default(), output)?;
+        return Ok(());
+    }
+    if file.old_id == file.new_id {
+        return Ok(());
+    }
+    if file.old_id.is_some() {
+        writeln_bytes(output, &[b"--- ".as_slice(), &old_path])?;
+    } else {
+        output.write_all(b"--- /dev/null\n")?;
+    }
+    if file.new_id.is_some() {
+        writeln_bytes(output, &[b"+++ ".as_slice(), &new_path])?;
+    } else {
+        output.write_all(b"+++ /dev/null\n")?;
+    }
+    output.write_all(&file.hunks)?;
+    Ok(())
+}
+
+fn write_binary_literal(
+    data: &[u8],
+    output: &mut PatchWriter<'_, impl Write>,
+) -> Result<(), ReadError> {
+    writeln!(output, "literal {}", data.len())?;
+    let compressed = zlib_store(data);
+    for chunk in compressed.chunks(52) {
+        output.write_all(&[encoded_length(chunk.len())])?;
+        let mut padded = [0_u8; 52];
+        padded[..chunk.len()].copy_from_slice(chunk);
+        for word in padded[..chunk.len().div_ceil(4) * 4].chunks_exact(4) {
+            let mut value = u32::from_be_bytes(word.try_into().expect("four bytes"));
+            let mut encoded = [0_u8; 5];
+            for byte in encoded.iter_mut().rev() {
+                *byte = BASE85[(value % 85) as usize];
+                value /= 85;
+            }
+            output.write_all(&encoded)?;
+        }
+        output.write_all(b"\n")?;
+    }
+    output.write_all(b"\n")?;
+    Ok(())
+}
+
+fn zlib_store(data: &[u8]) -> Vec<u8> {
+    let mut output = vec![0x78, 0x01];
+    if data.is_empty() {
+        output.extend_from_slice(&[1, 0, 0, 0xff, 0xff]);
+    } else {
+        for (index, chunk) in data.chunks(u16::MAX as usize).enumerate() {
+            output.push(u8::from(
+                index + 1 == data.len().div_ceil(u16::MAX as usize),
+            ));
+            let length = u16::try_from(chunk.len()).expect("stored block length");
+            output.extend_from_slice(&length.to_le_bytes());
+            output.extend_from_slice(&(!length).to_le_bytes());
+            output.extend_from_slice(chunk);
+        }
+    }
+    output.extend_from_slice(&adler32(data).to_be_bytes());
+    output
+}
+
+fn adler32(data: &[u8]) -> u32 {
+    const MODULUS: u32 = 65_521;
+    let mut first = 1_u32;
+    let mut second = 0_u32;
+    for byte in data {
+        first = (first + u32::from(*byte)) % MODULUS;
+        second = (second + first) % MODULUS;
+    }
+    (second << 16) | first
+}
+
+fn encoded_length(length: usize) -> u8 {
+    match length {
+        1..=26 => b'A' + u8::try_from(length - 1).expect("base85 line length"),
+        27..=52 => b'a' + u8::try_from(length - 27).expect("base85 line length"),
+        _ => unreachable!("binary patch lines contain between 1 and 52 bytes"),
+    }
+}
+
+fn object_name(id: Option<ObjectId>, length: usize) -> String {
+    id.map_or_else(|| "0".repeat(length), |id| id.to_string())
+}
+
+fn patch_path(prefix: u8, path: &[u8]) -> Vec<u8> {
+    let mut prefixed = Vec::with_capacity(path.len() + 2);
+    prefixed.push(prefix);
+    prefixed.push(b'/');
+    prefixed.extend_from_slice(path);
+    if prefixed
+        .iter()
+        .all(|byte| matches!(byte, b'!'..=b'~') && !matches!(byte, b'"' | b'\\'))
+    {
+        return prefixed;
+    }
+    let mut quoted = vec![b'"'];
+    for byte in prefixed {
+        match byte {
+            b'"' | b'\\' => {
+                quoted.push(b'\\');
+                quoted.push(byte);
+            }
+            b'\t' => quoted.extend_from_slice(b"\\t"),
+            b'\n' => quoted.extend_from_slice(b"\\n"),
+            b'\r' => quoted.extend_from_slice(b"\\r"),
+            b' '..=b'~' => quoted.push(byte),
+            _ => quoted.extend_from_slice(format!("\\{byte:03o}").as_bytes()),
+        }
+    }
+    quoted.push(b'"');
+    quoted
+}
+
+fn writeln_bytes(
+    output: &mut PatchWriter<'_, impl Write>,
+    parts: &[&[u8]],
+) -> Result<(), ReadError> {
+    for part in parts {
+        output.write_all(part)?;
+    }
+    output.write_all(b"\n")?;
+    Ok(())
+}
+
+struct PatchWriter<'a, W> {
+    inner: &'a mut W,
+    written: usize,
+}
+
+impl<W: Write> Write for PatchWriter<'_, W> {
+    fn write(&mut self, buffer: &[u8]) -> std::io::Result<usize> {
+        let next = self
+            .written
+            .checked_add(buffer.len())
+            .ok_or_else(|| std::io::Error::other("patch output is too large"))?;
+        if next > MAX_PATCH_BYTES {
+            return Err(std::io::Error::other("patch output is too large"));
+        }
+        self.inner.write_all(buffer)?;
+        self.written = next;
+        Ok(buffer.len())
+    }
+
+    fn flush(&mut self) -> std::io::Result<()> {
+        self.inner.flush()
+    }
+}

src/git/read.rs

Mode 100644100644; object 744d0b8c4b860b228705d69e

@@ -24,6 +24,7 @@
     pub(crate) max_diff_bytes: usize,
     pub(crate) max_archive_entries: usize,
     pub(crate) max_archive_bytes: usize,
+    pub(crate) max_archive_depth: usize,
     pub(crate) max_search_files: usize,
     pub(crate) max_search_bytes: usize,
     pub(crate) max_search_results: usize,
@@ -45,6 +46,7 @@
             max_diff_bytes: 64 * 1024 * 1024,
             max_archive_entries: 100_000,
             max_archive_bytes: 256 * 1024 * 1024,
+            max_archive_depth: 128,
             max_search_files: 10_000,
             max_search_bytes: 64 * 1024 * 1024,
             max_search_results: 500,
@@ -120,6 +122,8 @@
     pub(crate) new_mode: Option<u16>,
     pub(crate) binary: bool,
     pub(crate) hunks: Vec<u8>,
+    pub(crate) old_data: Option<Vec<u8>>,
+    pub(crate) new_data: Option<Vec<u8>>,
 }
 
 #[derive(Clone, Copy, Debug, Eq, PartialEq)]
@@ -371,6 +375,23 @@
         self.diff_with_budget(old_commit, new_commit, &budget)
     }
 
+    pub(crate) fn commit_diff(
+        &self,
+        commit_id: ObjectId,
+        cancellation: &ReadCancellation,
+    ) -> Result<Vec<DiffFile>, ReadError> {
+        let budget = self.budget(cancellation);
+        let commit = self.read_commit(commit_id, &budget)?;
+        match commit.parents.first() {
+            Some(parent) => self.diff_with_budget(*parent, commit_id, &budget),
+            None => self.diff_trees_with_budget(
+                ObjectId::empty_tree(self.repository.object_hash()),
+                commit.tree,
+                &budget,
+            ),
+        }
+    }
+
     pub(crate) fn comparison(
         &self,
         base_commit: ObjectId,
@@ -521,6 +542,8 @@
                 new_mode: new.map(|file| file.mode.value()),
                 binary,
                 hunks,
+                old_data: old.map(|_| old_data),
+                new_data: new.map(|_| new_data),
             });
         }
         Ok(files)
@@ -596,7 +619,6 @@
         let mut entries = 0_usize;
         let result = self.archive_tree(
             commit.tree,
-            &[],
             commit.committed_at.max(0) as u64,
             &budget,
             &mut writer,
@@ -926,60 +948,63 @@
     fn archive_tree(
         &self,
         tree_id: ObjectId,
-        prefix: &[u8],
         modified_at: u64,
         budget: &ReadBudget<'_>,
         writer: &mut BoundedWriter<'_, impl Write>,
         entries: &mut usize,
     ) -> Result<(), ReadError> {
-        let tree = self.read_tree(tree_id, budget)?;
-        for entry in tree.entries {
+        let mut pending = vec![(tree_id, Vec::new(), 0_usize)];
+        while let Some((tree_id, prefix, depth)) = pending.pop() {
             budget.check()?;
-            *entries += 1;
-            if *entries > self.limits.max_archive_entries {
-                return Err(ReadError::Limit("archive entries"));
+            if depth > self.limits.max_archive_depth {
+                return Err(ReadError::Limit("archive tree depth"));
             }
-            let path = join_git_path(prefix, &entry.filename, self.limits.max_path_bytes)?;
-            if entry.mode.is_tree() {
-                let mut directory = path.clone();
-                directory.push(b'/');
-                write_tar_header(writer, &directory, 0o755, 0, modified_at, b'5')?;
-                self.archive_tree(
-                    entry.oid.to_owned(),
-                    &path,
-                    modified_at,
-                    budget,
-                    writer,
-                    entries,
-                )?;
-            } else if entry.mode.is_blob_or_symlink() {
-                let blob = self.read_blob(entry.oid.to_owned(), entry.mode, budget)?;
-                // Store symbolic-link content as a regular file. Extraction cannot create a link outside its destination.
-                let mode = if entry.mode.is_executable() {
-                    0o755
-                } else {
-                    0o644
-                };
-                write_tar_header(
-                    writer,
-                    &path,
-                    mode,
-                    u64::try_from(blob.data.len())
-                        .map_err(|_| ReadError::Limit("archive bytes"))?,
-                    modified_at,
-                    b'0',
-                )?;
-                writer.write_all(&blob.data).map_err(ReadError::Output)?;
-                let padding = (512 - blob.data.len() % 512) % 512;
-                if padding != 0 {
-                    writer
-                        .write_all(&[0_u8; 512][..padding])
-                        .map_err(ReadError::Output)?;
+            let tree = self.read_tree(tree_id, budget)?;
+            let mut child_trees = Vec::new();
+            for entry in tree.entries {
+                budget.check()?;
+                *entries += 1;
+                if *entries > self.limits.max_archive_entries {
+                    return Err(ReadError::Limit("archive entries"));
                 }
-            } else if entry.mode.is_commit() {
-                let mut directory = path;
-                directory.push(b'/');
-                write_tar_header(writer, &directory, 0o755, 0, modified_at, b'5')?;
+                let path = join_git_path(&prefix, &entry.filename, self.limits.max_path_bytes)?;
+                if entry.mode.is_tree() {
+                    let mut directory = path.clone();
+                    directory.push(b'/');
+                    write_tar_header(writer, &directory, 0o755, 0, modified_at, b'5')?;
+                    child_trees.push((entry.oid.to_owned(), path, depth + 1));
+                } else if entry.mode.is_blob_or_symlink() {
+                    let blob = self.read_blob(entry.oid.to_owned(), entry.mode, budget)?;
+                    // Store symbolic-link content as a regular file. Extraction cannot create a link outside its destination.
+                    let mode = if entry.mode.is_executable() {
+                        0o755
+                    } else {
+                        0o644
+                    };
+                    write_tar_header(
+                        writer,
+                        &path,
+                        mode,
+                        u64::try_from(blob.data.len())
+                            .map_err(|_| ReadError::Limit("archive bytes"))?,
+                        modified_at,
+                        b'0',
+                    )?;
+                    writer.write_all(&blob.data).map_err(ReadError::Output)?;
+                    let padding = (512 - blob.data.len() % 512) % 512;
+                    if padding != 0 {
+                        writer
+                            .write_all(&[0_u8; 512][..padding])
+                            .map_err(ReadError::Output)?;
+                    }
+                } else if entry.mode.is_commit() {
+                    let mut directory = path;
+                    directory.push(b'/');
+                    write_tar_header(writer, &directory, 0o755, 0, modified_at, b'5')?;
+                }
+            }
+            for child in child_trees.into_iter().rev() {
+                pending.push(child);
             }
         }
         Ok(())
@@ -1286,5 +1311,5 @@
     #[error("repository archive metadata is too large")]
     ArchiveMetadata,
     #[error("cannot write repository content: {0}")]
-    Output(std::io::Error),
+    Output(#[from] std::io::Error),
 }

src/git/receive_pack.rs

Mode 100644100644; object 95ffa93966b9e28b0255ac19

@@ -4,7 +4,7 @@
 use std::path::{Path, PathBuf};
 use std::sync::Arc;
 use std::sync::atomic::{AtomicBool, Ordering};
-use std::time::{Duration, SystemTime, UNIX_EPOCH};
+use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
 
 use gix::bstr::ByteSlice;
 use gix::hash::{Kind, ObjectId};
@@ -280,17 +280,20 @@
         repository: &gix::Repository,
         commands: &[RefCommand],
     ) -> Result<Option<PathBuf>, ReceivePackError> {
+        let deadline = Instant::now() + MAX_PROCESSING_TIME;
         let metadata = fs::metadata(&self.incoming_pack)?;
         if metadata.len() == 0 || metadata.len() > MAX_PACK_BYTES {
             return Err(ReceivePackError::PackLimit);
         }
+        validate_pack_allocation_limits(&self.incoming_pack, self.object_format)?;
         let mut reader = BufReader::new(File::open(&self.incoming_pack)?);
         let mut progress = gix::progress::Discard;
         let interrupt = Arc::new(AtomicBool::new(false));
         let timer_interrupt = Arc::clone(&interrupt);
         let (done_sender, done_receiver) = std::sync::mpsc::channel();
+        let remaining = deadline.saturating_duration_since(Instant::now());
         let timer = std::thread::spawn(move || {
-            if done_receiver.recv_timeout(MAX_PROCESSING_TIME).is_err() {
+            if done_receiver.recv_timeout(remaining).is_err() {
                 timer_interrupt.store(true, Ordering::Relaxed);
             }
         });
@@ -319,9 +322,12 @@
         if outcome.index.num_objects > MAX_OBJECTS as u32 {
             return Err(ReceivePackError::ObjectLimit);
         }
+        check_deadline(deadline)?;
         if outcome.index.num_objects == 0 {
             let changes = validate_proposed_objects(repository, None, commands)?;
+            check_deadline(deadline)?;
             self.validate_policy(commands, &changes)?;
+            check_deadline(deadline)?;
             if let Some(path) = outcome.data_path {
                 let _ = fs::remove_file(path);
             }
@@ -333,13 +339,17 @@
             }
             return Ok(None);
         }
-        let bundle = outcome
+        let mut bundle = outcome
             .to_bundle()
             .ok_or(ReceivePackError::MissingPack)?
             .map_err(|error| ReceivePackError::Pack(error.to_string()))?;
+        bundle.pack = bundle.pack.with_alloc_limit_bytes(Some(MAX_OBJECT_BYTES));
         validate_delta_depth(&bundle)?;
+        check_deadline(deadline)?;
         let changes = validate_proposed_objects(repository, Some(&bundle), commands)?;
+        check_deadline(deadline)?;
         self.validate_policy(commands, &changes)?;
+        check_deadline(deadline)?;
 
         let source_pack = outcome.data_path.ok_or(ReceivePackError::MissingPack)?;
         let source_index = outcome.index_path.ok_or(ReceivePackError::MissingPack)?;
@@ -348,6 +358,7 @@
         }
         let destination = self.repository_path.join("objects/pack");
         fs::create_dir_all(&destination)?;
+        check_deadline(deadline)?;
         let destination_pack =
             destination.join(source_pack.file_name().ok_or(ReceivePackError::Path)?);
         let destination_index =
@@ -375,6 +386,7 @@
             let _ = fs::remove_file(keep);
         }
         sync_directory(&destination)?;
+        check_deadline(deadline)?;
         Ok(promoted.then_some(destination_pack))
     }
 
@@ -414,6 +426,76 @@
     }
 }
 
+fn validate_pack_allocation_limits(
+    path: &Path,
+    object_format: Kind,
+) -> Result<(), ReceivePackError> {
+    let pack = gix_pack::data::File::at(path, object_format)
+        .map_err(|error| ReceivePackError::Pack(error.to_string()))?
+        .with_alloc_limit_bytes(Some(MAX_OBJECT_BYTES));
+    if pack.num_objects() > MAX_OBJECTS as u32 {
+        return Err(ReceivePackError::ObjectLimit);
+    }
+    let entries = pack
+        .streaming_iter()
+        .map_err(|error| ReceivePackError::Pack(error.to_string()))?;
+    let mut inflate = gix::features::zlib::Inflate::default();
+    for entry in entries {
+        let entry = entry.map_err(|error| ReceivePackError::Pack(error.to_string()))?;
+        if entry.decompressed_size > MAX_OBJECT_BYTES as u64 {
+            return Err(ReceivePackError::ObjectLimit);
+        }
+        if !matches!(
+            entry.header,
+            gix_pack::data::entry::Header::OfsDelta { .. }
+                | gix_pack::data::entry::Header::RefDelta { .. }
+        ) {
+            continue;
+        }
+        let pack_entry = pack
+            .entry(entry.pack_offset)
+            .map_err(|error| ReceivePackError::Pack(error.to_string()))?;
+        let compressed_end = pack_entry
+            .data_offset
+            .checked_add(entry.compressed_size)
+            .ok_or(ReceivePackError::PackLimit)?;
+        let compressed = pack
+            .entry_slice(pack_entry.data_offset..compressed_end)
+            .ok_or(ReceivePackError::PackLimit)?;
+        inflate.reset();
+        let mut header = [0_u8; 20];
+        let (_, _, written) = inflate
+            .once(compressed, &mut header)
+            .map_err(|error| ReceivePackError::Pack(error.to_string()))?;
+        let (_, base_end) = decode_delta_size(&header[..written])?;
+        let (result_size, _) = decode_delta_size(&header[base_end..written])?;
+        if result_size > MAX_OBJECT_BYTES as u64 {
+            return Err(ReceivePackError::ObjectLimit);
+        }
+    }
+    Ok(())
+}
+
+fn decode_delta_size(input: &[u8]) -> Result<(u64, usize), ReceivePackError> {
+    let mut size = 0_u64;
+    let mut shift = 0_u32;
+    for (index, byte) in input.iter().copied().enumerate() {
+        if shift >= u64::BITS {
+            return Err(ReceivePackError::Pack(
+                "delta object size is invalid".to_owned(),
+            ));
+        }
+        size |= u64::from(byte & 0x7f) << shift;
+        if byte & 0x80 == 0 {
+            return Ok((size, index + 1));
+        }
+        shift += 7;
+    }
+    Err(ReceivePackError::Pack(
+        "delta object size is incomplete".to_owned(),
+    ))
+}
+
 fn validate_delta_depth(bundle: &gix_pack::Bundle) -> Result<(), ReceivePackError> {
     let entries = bundle.index.iter().collect::<Vec<_>>();
     let offsets_by_id = entries
@@ -431,9 +513,8 @@
         })
         .collect::<Result<HashMap<_, _>, _>>()?;
     let mut depths = HashMap::new();
-    let mut visiting = HashSet::new();
     for offset in headers.keys().copied() {
-        let depth = delta_depth(offset, &headers, &offsets_by_id, &mut depths, &mut visiting)?;
+        let depth = delta_depth(offset, &headers, &offsets_by_id, &mut depths)?;
         if depth > MAX_DELTA_DEPTH {
             return Err(ReceivePackError::DeltaDepthLimit);
         }
@@ -446,34 +527,66 @@
     headers: &HashMap<u64, gix_pack::data::entry::Header>,
     offsets_by_id: &HashMap<ObjectId, u64>,
     depths: &mut HashMap<u64, usize>,
-    visiting: &mut HashSet<u64>,
 ) -> Result<usize, ReceivePackError> {
     if let Some(depth) = depths.get(&offset) {
         return Ok(*depth);
     }
-    if !visiting.insert(offset) {
-        return Err(ReceivePackError::DeltaDepthLimit);
-    }
-    let header = headers.get(&offset).ok_or(ReceivePackError::RecoveryData)?;
-    let depth = match header {
-        gix_pack::data::entry::Header::OfsDelta { base_distance } => {
-            let base = offset
-                .checked_sub(*base_distance)
-                .ok_or(ReceivePackError::DeltaDepthLimit)?;
-            delta_depth(base, headers, offsets_by_id, depths, visiting)? + 1
+    let mut path = Vec::new();
+    let mut seen = HashSet::new();
+    let mut current = offset;
+    let base_depth = loop {
+        if let Some(depth) = depths.get(&current) {
+            break *depth;
         }
-        gix_pack::data::entry::Header::RefDelta { base_id } => {
-            if let Some(base) = offsets_by_id.get(base_id) {
-                delta_depth(*base, headers, offsets_by_id, depths, visiting)? + 1
-            } else {
-                1
+        if !seen.insert(current) || path.len() > MAX_DELTA_DEPTH {
+            return Err(ReceivePackError::DeltaDepthLimit);
+        }
+        path.push(current);
+        let header = headers
+            .get(&current)
+            .ok_or(ReceivePackError::RecoveryData)?;
+        match header {
+            gix_pack::data::entry::Header::OfsDelta { base_distance } => {
+                current = current
+                    .checked_sub(*base_distance)
+                    .ok_or(ReceivePackError::DeltaDepthLimit)?;
             }
+            gix_pack::data::entry::Header::RefDelta { base_id } => {
+                let Some(base) = offsets_by_id.get(base_id) else {
+                    break 0;
+                };
+                current = *base;
+            }
+            _ => break 0,
         }
-        _ => 0,
     };
-    visiting.remove(&offset);
-    depths.insert(offset, depth);
-    Ok(depth)
+    let mut depth = base_depth;
+    for item in path.into_iter().rev() {
+        let header = headers.get(&item).ok_or(ReceivePackError::RecoveryData)?;
+        if matches!(
+            header,
+            gix_pack::data::entry::Header::OfsDelta { .. }
+                | gix_pack::data::entry::Header::RefDelta { .. }
+        ) {
+            depth += 1;
+        }
+        if depth > MAX_DELTA_DEPTH {
+            return Err(ReceivePackError::DeltaDepthLimit);
+        }
+        depths.insert(item, depth);
+    }
+    depths
+        .get(&offset)
+        .copied()
+        .ok_or(ReceivePackError::RecoveryData)
+}
+
+fn check_deadline(deadline: Instant) -> Result<(), ReceivePackError> {
+    if Instant::now() >= deadline {
+        Err(ReceivePackError::WallClockLimit)
+    } else {
+        Ok(())
+    }
 }
 
 impl Drop for ReceivePack {
@@ -1009,5 +1122,56 @@
             _ if self.is_unpack_error() => "pack validation failed",
             _ => "push validation failed",
         }
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn rejects_a_compressed_object_before_large_allocation() {
+        let directory = tempfile::TempDir::new().expect("create a pack directory");
+        let path = directory.path().join("oversized.pack");
+        let object_size = MAX_OBJECT_BYTES + 1;
+        let mut pack = Vec::new();
+        pack.extend_from_slice(b"PACK");
+        pack.extend_from_slice(&2_u32.to_be_bytes());
+        pack.extend_from_slice(&1_u32.to_be_bytes());
+        pack.extend_from_slice(&pack_object_header(3, object_size as u64));
+
+        let mut compressed = gix::features::zlib::stream::deflate::Write::new(Vec::new());
+        let block = [0_u8; 64 * 1024];
+        for _ in 0..(object_size / block.len()) {
+            compressed.write_all(&block).expect("compress a zero block");
+        }
+        compressed
+            .write_all(&block[..object_size % block.len()])
+            .expect("compress the final zero block");
+        compressed.flush().expect("finish the compressed object");
+        pack.extend_from_slice(&compressed.into_inner());
+
+        let mut hasher = gix::hash::hasher(Kind::Sha1);
+        hasher.update(&pack);
+        let checksum = hasher.try_finalize().expect("hash the pack");
+        pack.extend_from_slice(checksum.as_bytes());
+        assert!(pack.len() < 1024 * 1024);
+        fs::write(&path, pack).expect("write the compressed pack");
+
+        assert!(matches!(
+            validate_pack_allocation_limits(&path, Kind::Sha1),
+            Err(ReceivePackError::ObjectLimit)
+        ));
+    }
+
+    fn pack_object_header(kind: u8, mut size: u64) -> Vec<u8> {
+        let mut header = vec![((kind & 0x07) << 4) | (size as u8 & 0x0f)];
+        size >>= 4;
+        while size != 0 {
+            *header.last_mut().expect("the header has one byte") |= 0x80;
+            header.push((size as u8) & 0x7f);
+            size >>= 7;
+        }
+        header
     }
 }

src/git/repository.rs

Mode 100644100644; object 00eea6aeefaf6a1ef5c21725

@@ -1,6 +1,8 @@
 use std::collections::HashSet;
 use std::fs;
+use std::io::Write;
 use std::path::{Path, PathBuf};
+use std::sync::atomic::{AtomicBool, Ordering};
 
 use gix::hash::{Kind, ObjectId};
 use gix::objs::{Commit, Data, Kind as ObjectKind, tree::EntryKind};
@@ -132,6 +134,34 @@
         Ok(references)
     }
 
+    pub(crate) fn default_branch(&self) -> Result<Option<String>, GitRepositoryError> {
+        let name = self
+            .repository
+            .head_name()
+            .map_err(|error| GitRepositoryError::References(error.to_string()))?;
+        Ok(name
+            .filter(|name| name.as_bstr().starts_with(b"refs/heads/"))
+            .and_then(|name| std::str::from_utf8(name.as_bstr()).ok().map(str::to_owned)))
+    }
+
+    pub(crate) fn set_default_branch(&self, name: &str) -> Result<(), GitRepositoryError> {
+        self.resolve_branch(name)?;
+        let target = FullName::try_from(gix::bstr::BString::from(name.as_bytes()))
+            .map_err(|_| GitRepositoryError::InvalidBranch)?;
+        self.repository
+            .edit_reference(RefEdit {
+                change: Change::Update {
+                    log: Default::default(),
+                    expected: PreviousValue::Any,
+                    new: Target::Symbolic(target),
+                },
+                name: "HEAD".try_into().expect("HEAD is a valid reference name"),
+                deref: false,
+            })
+            .map_err(|error| GitRepositoryError::References(error.to_string()))?;
+        Ok(())
+    }
+
     pub(crate) fn resolve_branch(&self, name: &str) -> Result<ObjectId, GitRepositoryError> {
         if !name.starts_with("refs/heads/") || name.len() > 1024 {
             return Err(GitRepositoryError::InvalidBranch);
@@ -235,6 +265,18 @@
         wants: &[ObjectId],
         haves: &[ObjectId],
     ) -> Result<Vec<u8>, GitRepositoryError> {
+        let mut output = Vec::new();
+        self.write_pack(wants, haves, &mut output, &AtomicBool::new(false))?;
+        Ok(output)
+    }
+
+    pub(crate) fn write_pack(
+        &self,
+        wants: &[ObjectId],
+        haves: &[ObjectId],
+        output: impl Write,
+        cancelled: &AtomicBool,
+    ) -> Result<(), GitRepositoryError> {
         if wants.iter().any(|want| want.kind() != self.object_format())
             || haves.iter().any(|have| have.kind() != self.object_format())
         {
@@ -249,9 +291,10 @@
         if wants.is_empty() || wants.iter().any(|want| !advertised.contains(want)) {
             return Err(GitRepositoryError::UnadvertisedWant);
         }
-        let excluded = self.walk_reachable(haves, true)?;
+        let permitted_missing_roots = haves.iter().copied().collect::<HashSet<_>>();
+        let excluded = self.walk_reachable(haves, Some(&permitted_missing_roots), cancelled)?;
         let mut objects: Vec<_> = self
-            .walk_reachable(wants, false)?
+            .walk_reachable(wants, None, cancelled)?
             .into_iter()
             .filter(|id| !excluded.contains(id))
             .collect();
@@ -259,30 +302,18 @@
 
         let object_count =
             u32::try_from(objects.len()).map_err(|_| GitRepositoryError::ObjectLimit)?;
-        let mut entries = Vec::with_capacity(objects.len());
         let mut total_object_bytes = 0_usize;
-        for id in objects {
-            let object = self.find_object(id)?;
-            total_object_bytes = total_object_bytes
-                .checked_add(object.data.len())
-                .ok_or(GitRepositoryError::ObjectLimit)?;
-            if object.data.len() > MAX_OBJECT_BYTES || total_object_bytes > MAX_PACK_BYTES {
-                return Err(GitRepositoryError::ObjectLimit);
+        let chunks = objects.into_iter().map(|id| {
+            if cancelled.load(Ordering::Relaxed) {
+                return Err(std::io::Error::other("pack generation was cancelled"));
             }
-            let count = Count::from_data(id, None);
-            let data = Data::new(&object.data, object.kind, self.object_format());
-            entries.push(
-                Entry::from_data(&count, &data)
-                    .map_err(|error| GitRepositoryError::Pack(error.to_string()))?,
-            );
-        }
-
-        let chunks = entries
-            .into_iter()
-            .map(|entry| Ok::<_, std::io::Error>(vec![entry]));
+            self.pack_entry(id, &mut total_object_bytes)
+                .map(|entry| vec![entry])
+                .map_err(std::io::Error::other)
+        });
         let mut writer = FromEntriesIter::new(
             chunks,
-            Vec::new(),
+            CountingWriter::new(output, MAX_PACK_BYTES),
             object_count,
             Version::V2,
             self.object_format(),
@@ -290,11 +321,27 @@
         for result in writer.by_ref() {
             result.map_err(|error| GitRepositoryError::Pack(error.to_string()))?;
         }
-        let pack = writer.into_write();
-        if pack.len() > MAX_PACK_BYTES {
-            return Err(GitRepositoryError::PackLimit);
+        writer
+            .into_write()
+            .finish()
+            .map_err(|error| GitRepositoryError::Pack(error.to_string()))
+    }
+
+    fn pack_entry(
+        &self,
+        id: ObjectId,
+        total_object_bytes: &mut usize,
+    ) -> Result<Entry, GitRepositoryError> {
+        let object = self.find_object(id)?;
+        *total_object_bytes = total_object_bytes
+            .checked_add(object.data.len())
+            .ok_or(GitRepositoryError::ObjectLimit)?;
+        if object.data.len() > MAX_OBJECT_BYTES || *total_object_bytes > MAX_PACK_BYTES {
+            return Err(GitRepositoryError::ObjectLimit);
         }
-        Ok(pack)
+        let count = Count::from_data(id, None);
+        let data = Data::new(&object.data, object.kind, self.object_format());
+        Entry::from_data(&count, &data).map_err(|error| GitRepositoryError::Pack(error.to_string()))
     }
 
     pub(crate) fn integrity_check(&self) -> Result<(), GitRepositoryError> {
@@ -304,18 +351,22 @@
             .flat_map(|reference| [Some(reference.target), reference.peeled])
             .flatten()
             .collect();
-        self.walk_reachable(&roots, false)?;
+        self.walk_reachable(&roots, None, &AtomicBool::new(false))?;
         Ok(())
     }
 
     fn walk_reachable(
         &self,
         roots: &[ObjectId],
-        permit_missing_roots: bool,
+        permitted_missing_roots: Option<&HashSet<ObjectId>>,
+        cancelled: &AtomicBool,
     ) -> Result<HashSet<ObjectId>, GitRepositoryError> {
         let mut seen = HashSet::new();
         let mut pending = roots.to_vec();
         while let Some(id) = pending.pop() {
+            if cancelled.load(Ordering::Relaxed) {
+                return Err(GitRepositoryError::Cancelled);
+            }
             if !seen.insert(id) {
                 continue;
             }
@@ -324,7 +375,10 @@
             }
             let object = match self.repository.try_find_object(id) {
                 Ok(Some(object)) => object,
-                Ok(None) if permit_missing_roots && roots.contains(&id) => {
+                Ok(None)
+                    if permitted_missing_roots
+                        .is_some_and(|missing_roots| missing_roots.contains(&id)) =>
+                {
                     seen.remove(&id);
                     continue;
                 }
@@ -397,6 +451,47 @@
             });
         }
         Ok(id)
+    }
+}
+
+struct CountingWriter<W> {
+    inner: W,
+    written: usize,
+    limit: usize,
+}
+
+impl<W> CountingWriter<W> {
+    fn new(inner: W, limit: usize) -> Self {
+        Self {
+            inner,
+            written: 0,
+            limit,
+        }
+    }
+}
+
+impl<W: Write> CountingWriter<W> {
+    fn finish(mut self) -> std::io::Result<()> {
+        self.inner.flush()
+    }
+}
+
+impl<W: Write> Write for CountingWriter<W> {
+    fn write(&mut self, data: &[u8]) -> std::io::Result<usize> {
+        let total = self
+            .written
+            .checked_add(data.len())
+            .ok_or_else(|| std::io::Error::other("pack byte limit exceeded"))?;
+        if total > self.limit {
+            return Err(std::io::Error::other("pack byte limit exceeded"));
+        }
+        self.inner.write_all(data)?;
+        self.written = total;
+        Ok(data.len())
+    }
+
+    fn flush(&mut self) -> std::io::Result<()> {
+        self.inner.flush()
     }
 }
 
@@ -499,6 +594,8 @@
     ObjectLimit,
     #[error("generated Git pack exceeds the limit")]
     PackLimit,
+    #[error("Git pack generation was cancelled")]
+    Cancelled,
     #[error("cannot generate Git pack: {0}")]
     Pack(String),
 }

src/git/upload_pack.rs

Mode 100644100644; object a2144ada0b39fa3304db424c

@@ -1,4 +1,7 @@
+use std::collections::HashSet;
+use std::io::Write;
 use std::path::Path;
+use std::sync::atomic::AtomicBool;
 
 use gix::hash::{Kind, ObjectId};
 use thiserror::Error;
@@ -9,6 +12,7 @@
 use super::repository::{GitReference, GitRepository, GitRepositoryError};
 
 const AGENT: &str = concat!("tit/", env!("CARGO_PKG_VERSION"));
+const MAX_NEGOTIATION_IDS: usize = 256;
 
 #[derive(Clone, Copy, Debug, Eq, PartialEq)]
 pub(crate) enum ProtocolVersion {
@@ -51,10 +55,19 @@
         version: ProtocolVersion,
         request: &[u8],
     ) -> Result<Vec<u8>, UploadPackError> {
+        self.respond_with_cancellation(version, request, &AtomicBool::new(false))
+    }
+
+    pub(crate) fn respond_with_cancellation(
+        &self,
+        version: ProtocolVersion,
+        request: &[u8],
+        cancelled: &AtomicBool,
+    ) -> Result<Vec<u8>, UploadPackError> {
         let packets = decode(request)?;
         match version {
-            ProtocolVersion::V0 | ProtocolVersion::V1 => self.respond_v1(&packets),
-            ProtocolVersion::V2 => self.respond_v2(&packets),
+            ProtocolVersion::V0 | ProtocolVersion::V1 => self.respond_v1(&packets, cancelled),
+            ProtocolVersion::V2 => self.respond_v2(&packets, cancelled),
         }
     }
 
@@ -126,9 +139,15 @@
         Ok(())
     }
 
-    fn respond_v1(&self, packets: &[Packet]) -> Result<Vec<u8>, UploadPackError> {
+    fn respond_v1(
+        &self,
+        packets: &[Packet],
+        cancelled: &AtomicBool,
+    ) -> Result<Vec<u8>, UploadPackError> {
         let mut wants = Vec::new();
         let mut haves = Vec::new();
+        let mut unique_wants = HashSet::new();
+        let mut unique_haves = HashSet::new();
         let mut done = false;
         for packet in packets {
             let Packet::Data(line) = packet else {
@@ -137,27 +156,40 @@
             let line = trim_line(line);
             if let Some(value) = line.strip_prefix(b"want ") {
                 let id = value.split(|byte| *byte == b' ').next().unwrap_or_default();
-                wants.push(self.parse_id(id)?);
+                let id = self.parse_id(id)?;
+                if unique_wants.insert(id) {
+                    wants.push(id);
+                }
             } else if let Some(value) = line.strip_prefix(b"have ") {
-                haves.push(self.parse_id(value)?);
+                let id = self.parse_id(value)?;
+                if unique_haves.insert(id) {
+                    haves.push(id);
+                }
             } else if line == b"done" {
                 done = true;
             } else {
                 return Err(UploadPackError::UnsupportedRequest);
+            }
+            if wants.len() > MAX_NEGOTIATION_IDS || haves.len() > MAX_NEGOTIATION_IDS {
+                return Err(UploadPackError::NegotiationLimit);
             }
         }
         if !done || wants.is_empty() {
             return Err(UploadPackError::IncompleteNegotiation);
         }
 
-        let pack = self.repository.make_pack(&wants, &haves)?;
-        let mut output = Vec::with_capacity(pack.len() + 8);
+        let mut output = Vec::new();
         encode_data(b"NAK\n", &mut output)?;
-        output.extend_from_slice(&pack);
+        self.repository
+            .write_pack(&wants, &haves, &mut output, cancelled)?;
         Ok(output)
     }
 
-    fn respond_v2(&self, packets: &[Packet]) -> Result<Vec<u8>, UploadPackError> {
+    fn respond_v2(
+        &self,
+        packets: &[Packet],
+        cancelled: &AtomicBool,
+    ) -> Result<Vec<u8>, UploadPackError> {
         let delimiter = packets
             .iter()
             .position(|packet| *packet == Packet::Delimiter)
@@ -166,7 +198,7 @@
         let arguments = &packets[delimiter + 1..];
         match command {
             b"ls-refs" => self.respond_ls_refs(arguments),
-            b"fetch" => self.respond_fetch(arguments),
+            b"fetch" => self.respond_fetch(arguments, cancelled),
             _ => Err(UploadPackError::UnsupportedRequest),
         }
     }
@@ -211,18 +243,30 @@
         Ok(output)
     }
 
-    fn respond_fetch(&self, packets: &[Packet]) -> Result<Vec<u8>, UploadPackError> {
+    fn respond_fetch(
+        &self,
+        packets: &[Packet],
+        cancelled: &AtomicBool,
+    ) -> Result<Vec<u8>, UploadPackError> {
         let mut wants = Vec::new();
         let mut haves = Vec::new();
+        let mut unique_wants = HashSet::new();
+        let mut unique_haves = HashSet::new();
         let mut done = false;
         for packet in packets {
             match packet {
                 Packet::Data(line) => {
                     let line = trim_line(line);
                     if let Some(value) = line.strip_prefix(b"want ") {
-                        wants.push(self.parse_id(value)?);
+                        let id = self.parse_id(value)?;
+                        if unique_wants.insert(id) {
+                            wants.push(id);
+                        }
                     } else if let Some(value) = line.strip_prefix(b"have ") {
-                        haves.push(self.parse_id(value)?);
+                        let id = self.parse_id(value)?;
+                        if unique_haves.insert(id) {
+                            haves.push(id);
+                        }
                     } else if line == b"done" {
                         done = true;
                     } else if !matches!(
@@ -230,6 +274,9 @@
                         b"thin-pack" | b"no-progress" | b"include-tag" | b"ofs-delta"
                     ) {
                         return Err(UploadPackError::UnsupportedRequest);
+                    }
+                    if wants.len() > MAX_NEGOTIATION_IDS || haves.len() > MAX_NEGOTIATION_IDS {
+                        return Err(UploadPackError::NegotiationLimit);
                     }
                 }
                 Packet::Flush => {}
@@ -249,10 +296,16 @@
             return Ok(output);
         }
 
-        let pack = self.repository.make_pack(&wants, &haves)?;
-        let mut output = Vec::with_capacity(pack.len() + 32);
+        let mut output = Vec::new();
         encode_data(b"packfile\n", &mut output)?;
-        encode_sideband(&pack, &mut output)?;
+        self.repository.write_pack(
+            &wants,
+            &haves,
+            SidebandWriter {
+                output: &mut output,
+            },
+            cancelled,
+        )?;
         encode_flush(&mut output);
         Ok(output)
     }
@@ -263,6 +316,21 @@
             return Err(UploadPackError::InvalidObjectId);
         }
         Ok(id)
+    }
+}
+
+struct SidebandWriter<'a> {
+    output: &'a mut Vec<u8>,
+}
+
+impl Write for SidebandWriter<'_> {
+    fn write(&mut self, data: &[u8]) -> std::io::Result<usize> {
+        encode_sideband(data, self.output).map_err(std::io::Error::other)?;
+        Ok(data.len())
+    }
+
+    fn flush(&mut self) -> std::io::Result<()> {
+        Ok(())
     }
 }
 
@@ -328,6 +396,8 @@
     UnsupportedRequest,
     #[error("upload-pack object ID is not valid for this repository")]
     InvalidObjectId,
+    #[error("upload-pack negotiation exceeds the object ID limit")]
+    NegotiationLimit,
     #[error("upload-pack negotiation is incomplete")]
     IncompleteNegotiation,
 }

src/http/feeds.rs

Mode 100644100644; object fd6989160393c5745f3016a6

@@ -1,21 +1,21 @@
 use askama::Template;
 use axum::Router;
 use axum::body::Bytes;
-use axum::extract::{DefaultBodyLimit, Extension, Path, State};
+use axum::extract::{DefaultBodyLimit, Extension, Path, Query, State};
 use axum::http::{HeaderMap, StatusCode, header};
 use axum::response::Response;
 use axum::routing::{get, post};
 use serde::Deserialize;
 
-use crate::feed::{ActivityFeedPage, FeedFormat, PAGE_SIZE};
+use crate::feed::{ActivityFeedPage, PAGE_SIZE, activity_link, activity_title};
 use crate::feed_token::{FeedTokenError, IssuedFeedToken};
-use crate::store::{FeedTokenRecord, StoreError};
+use crate::store::{ActivityCursor, FeedTokenRecord, StoreError};
 
 use super::filters;
 use super::public::conditional_feed;
 use super::{
     CSRF_COOKIE, RequestId, SESSION_COOKIE, WebState, authenticate_mutation, cookie, login_job,
-    login_redirect, parse_named_form, render, render_error,
+    login_redirect, parse_named_form, render, render_error, render_error_with_auth,
 };
 
 pub(super) fn routes() -> Router<WebState> {
@@ -33,8 +33,107 @@
             "/feeds/tokens/{id}/revoke",
             post(revoke_token).layer(DefaultBodyLimit::max(1024)),
         )
-        .route("/feeds/{token}/atom.xml", get(atom_feed))
         .route("/feeds/{token}/rss.xml", get(rss_feed))
+        .route("/activity", get(activity))
+}
+
+async fn activity(
+    State(state): State<WebState>,
+    Extension(request_id): Extension<RequestId>,
+    Query(query): Query<ActivityQuery>,
+    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 actor = match login_job(state.clone(), move |login| {
+        login.authenticate(&session_token, Some(&csrf_for_auth))
+    })
+    .await
+    {
+        Ok(session) => session.username,
+        Err(_) => return login_redirect(true),
+    };
+    let before = match (query.before_time, query.before_id) {
+        (None, None) => None,
+        (Some(created_at), Some(event_id))
+            if created_at >= 0
+                && event_id.len() == 32
+                && event_id.bytes().all(|byte| byte.is_ascii_hexdigit()) =>
+        {
+            Some(ActivityCursor {
+                created_at,
+                event_id,
+            })
+        }
+        _ => return activity_bad_request(&request_id.0),
+    };
+    let Some(service) = state.feeds.clone() else {
+        return activity_internal(&request_id.0);
+    };
+    let result = feed_job(state.clone(), move || {
+        service.activity(&actor, before.as_ref(), PAGE_SIZE)
+    })
+    .await;
+    let page = match result {
+        Ok(page) => page,
+        Err(_) => return activity_internal(&request_id.0),
+    };
+    let base_url = state
+        .public
+        .as_ref()
+        .map(|public| public.http_clone_base())
+        .unwrap_or_default();
+    render(
+        StatusCode::OK,
+        &ActivityTemplate {
+            request_id: &request_id.0,
+            signed_in: true,
+            events: page
+                .events
+                .iter()
+                .map(|record| ActivityView {
+                    event_id: record.event.event_id.clone(),
+                    title: activity_title(record),
+                    link: activity_link(base_url, record),
+                    created_at: record.event.created_at,
+                })
+                .collect(),
+            next_before_time: page
+                .next_before
+                .as_ref()
+                .map_or(0, |cursor| cursor.created_at),
+            next_before_id: page
+                .next_before
+                .as_ref()
+                .map_or("", |cursor| cursor.event_id.as_str()),
+            has_next: page.next_before.is_some(),
+        },
+    )
+}
+
+fn activity_bad_request(request_id: &str) -> Response {
+    render_error_with_auth(
+        StatusCode::BAD_REQUEST,
+        request_id,
+        "Activity error",
+        "The activity page cursor is not valid.",
+        true,
+    )
+}
+
+fn activity_internal(request_id: &str) -> Response {
+    render_error_with_auth(
+        StatusCode::INTERNAL_SERVER_ERROR,
+        request_id,
+        "Activity error",
+        "The activity page could not be read.",
+        true,
+    )
 }
 
 async fn feed_tokens(
@@ -81,8 +180,7 @@
     headers: HeaderMap,
     body: Bytes,
 ) -> Response {
-    let fields = match parse_named_form(&headers, &body, &["csrf", "scope", "owner", "repository"])
-    {
+    let fields = match parse_named_form(&headers, &body, &["csrf"]) {
         Ok(fields) => fields,
         Err(()) => return feed_bad_request(&request_id.0),
     };
@@ -94,13 +192,7 @@
     let Some(service) = state.feeds.clone() else {
         return feed_internal(&request_id.0);
     };
-    let scope = fields[1].clone();
-    let owner = nonempty(fields[2].clone());
-    let repository = nonempty(fields[3].clone());
-    let result = feed_job(state, move || {
-        service.issue(&actor, &scope, owner.as_deref(), repository.as_deref())
-    })
-    .await;
+    let result = feed_job(state, move || service.issue(&actor)).await;
     issued_response(result, &request_id.0)
 }
 
@@ -168,22 +260,13 @@
         .expect("the feed token redirect is valid")
 }
 
-async fn atom_feed(
-    State(state): State<WebState>,
-    Extension(request_id): Extension<RequestId>,
-    Path(path): Path<TokenPath>,
-    headers: HeaderMap,
-) -> Response {
-    token_feed(state, request_id, path.token, headers, FeedFormat::Atom).await
-}
-
 async fn rss_feed(
     State(state): State<WebState>,
     Extension(request_id): Extension<RequestId>,
     Path(path): Path<TokenPath>,
     headers: HeaderMap,
 ) -> Response {
-    token_feed(state, request_id, path.token, headers, FeedFormat::Rss).await
+    token_feed(state, request_id, path.token, headers).await
 }
 
 async fn token_feed(
@@ -191,7 +274,6 @@
     request_id: RequestId,
     token: String,
     headers: HeaderMap,
-    format: FeedFormat,
 ) -> Response {
     let Some(service) = state.feeds.clone() else {
         return feed_not_found(&request_id.0);
@@ -206,10 +288,7 @@
         Ok(page) => page,
         Err(_) => return feed_not_found(&request_id.0),
     };
-    let name = match format {
-        FeedFormat::Atom => "atom.xml",
-        FeedFormat::Rss => "rss.xml",
-    };
+    let name = "rss.xml";
     let self_url = format!("{base_url}/feeds/{token_for_url}/{name}");
     let newest = page
         .events
@@ -220,17 +299,15 @@
     let body = match (ActivityFeedPage {
         base_url: &base_url,
         self_url: &self_url,
-        scope: &page.scope,
         username: &page.username,
-        target: page.target.as_deref(),
         events: &page.events,
     })
-    .render(format)
+    .render()
     {
         Ok(body) => body,
         Err(_) => return feed_internal(&request_id.0),
     };
-    conditional_feed(&headers, name, body, newest, false)
+    conditional_feed(&headers, body, newest, false)
 }
 
 async fn feed_job<T: Send + 'static>(
@@ -259,7 +336,6 @@
                 signed_in: true,
                 token: &issued.token,
                 scope: scope_label(&issued.record.scope),
-                target: token_target(&issued.record),
             },
         ),
         Err(error) => feed_management_error(error, request_id),
@@ -270,43 +346,23 @@
     FeedTokenView {
         id: &record.id,
         scope: scope_label(&record.scope),
-        target: token_target(record),
         created_at: record.created_at,
         active: record.revoked_at.is_none(),
     }
 }
 
-fn token_target(record: &FeedTokenRecord) -> String {
-    match (&record.owner, &record.repository) {
-        (Some(owner), Some(repository)) => format!("{owner}/{repository}"),
-        _ => "Your account".to_owned(),
-    }
-}
-
 fn scope_label(scope: &str) -> &'static str {
     match scope {
-        "repository" => "Repository activity",
         "watched" => "Watched activity",
-        "assignments" => "Assignments",
-        "mentions" => "Mentions",
         _ => "Unknown",
     }
 }
 
-fn nonempty(value: String) -> Option<String> {
-    (!value.is_empty()).then_some(value)
-}
-
 fn feed_management_error(error: FeedTokenError, request_id: &str) -> Response {
     match error {
-        FeedTokenError::InvalidScope
-        | FeedTokenError::InvalidToken
-        | FeedTokenError::Auth(_)
-        | FeedTokenError::RepositoryName(_) => feed_bad_request(request_id),
+        FeedTokenError::InvalidToken | FeedTokenError::Auth(_) => feed_bad_request(request_id),
         FeedTokenError::Store(
-            StoreError::FeedTokenDenied
-            | StoreError::FeedTokenNotFound
-            | StoreError::RepositoryNotFound(_, _),
+            StoreError::FeedTokenNotFound | StoreError::RepositoryNotFound(_, _),
         ) => feed_not_found(request_id),
         FeedTokenError::Store(StoreError::FeedTokenLimit) => render_error(
             StatusCode::TOO_MANY_REQUESTS,
@@ -355,10 +411,34 @@
     id: String,
 }
 
+#[derive(Default, Deserialize)]
+#[serde(rename_all = "kebab-case")]
+struct ActivityQuery {
+    before_time: Option<i64>,
+    before_id: Option<String>,
+}
+
+struct ActivityView {
+    event_id: String,
+    title: String,
+    link: String,
+    created_at: i64,
+}
+
+#[derive(Template)]
+#[template(path = "activity.html")]
+struct ActivityTemplate<'a> {
+    request_id: &'a str,
+    signed_in: bool,
+    events: Vec<ActivityView>,
+    next_before_time: i64,
+    next_before_id: &'a str,
+    has_next: bool,
+}
+
 struct FeedTokenView<'a> {
     id: &'a str,
     scope: &'static str,
-    target: String,
     created_at: i64,
     active: bool,
 }
@@ -379,5 +459,4 @@
     signed_in: bool,
     token: &'a str,
     scope: &'static str,
-    target: String,
 }

src/http/issues.rs

Mode 100644100644; object 9de454ffeb879552def48e4e

@@ -1,7 +1,7 @@
 use askama::Template;
 use axum::Router;
 use axum::body::Bytes;
-use axum::extract::{DefaultBodyLimit, Extension, Path, State};
+use axum::extract::{DefaultBodyLimit, Extension, Path, Query, State};
 use axum::http::{HeaderMap, StatusCode, header};
 use axum::response::Response;
 use axum::routing::{get, post};
@@ -38,14 +38,6 @@
             "/{owner}/{repository}/issues/{number}/state",
             post(change_state),
         )
-        .route(
-            "/{owner}/{repository}/issues/{number}/labels",
-            post(change_label),
-        )
-        .route(
-            "/{owner}/{repository}/issues/{number}/assignees",
-            post(change_assignee),
-        )
         .layer(DefaultBodyLimit::max(MAX_ISSUE_REQUEST_BYTES))
 }
 
@@ -54,6 +46,7 @@
     Extension(request_id): Extension<RequestId>,
     Extension(actor): Extension<RequestActor>,
     Path(path): Path<RepositoryPath>,
+    Query(query): Query<ListQuery>,
     headers: HeaderMap,
 ) -> Response {
     let Some(service) = state.issues.clone() else {
@@ -67,12 +60,21 @@
     let owner = path.owner.clone();
     let repository = path.repository.clone();
     let authenticated = actor.0.is_some();
+    let state_filter = query.state.unwrap_or_else(|| "open".to_owned());
+    let page_number = query.page.unwrap_or(1);
+    let state_for_job = state_filter.clone();
     let result = issue_job(state, move || {
-        service.list(&owner, &repository, actor.0.as_deref())
+        service.list_page(
+            &owner,
+            &repository,
+            actor.0.as_deref(),
+            &state_for_job,
+            page_number,
+        )
     })
     .await;
     match result {
-        Ok((record, issues)) => {
+        Ok((record, page)) => {
             let csrf = cookie(&headers, CSRF_COOKIE).unwrap_or_default();
             render(
                 StatusCode::OK,
@@ -81,7 +83,8 @@
                     signed_in: authenticated,
                     owner: &record.owner,
                     repository: &record.slug,
-                    issues: issues
+                    issues: page
+                        .items
                         .iter()
                         .map(|issue| IssueListItem {
                             number: issue.number,
@@ -93,6 +96,14 @@
                         .collect(),
                     csrf: &csrf,
                     can_create: authenticated && !csrf.is_empty(),
+                    state: &state_filter,
+                    state_all: state_filter == "all",
+                    state_open: state_filter == "open",
+                    state_closed: state_filter == "closed",
+                    has_previous: page.page > 1,
+                    has_next: page.has_next,
+                    previous_page: page.page.saturating_sub(1),
+                    next_page: page.page.saturating_add(1),
                 },
             )
         }
@@ -105,6 +116,7 @@
     Extension(request_id): Extension<RequestId>,
     Extension(actor): Extension<RequestActor>,
     Path(path): Path<IssuePath>,
+    Query(query): Query<ActivityQuery>,
     headers: HeaderMap,
 ) -> Response {
     let Some(service) = state.issues.clone() else {
@@ -118,8 +130,17 @@
     let owner = path.owner.clone();
     let repository = path.repository.clone();
     let number = path.number;
+    let comments_page = query.comments_page.unwrap_or(1);
+    let timeline_page = query.timeline_page.unwrap_or(1);
     let result = issue_job(state, move || {
-        service.get(&owner, &repository, number, actor.0.as_deref())
+        service.get_page(
+            &owner,
+            &repository,
+            number,
+            actor.0.as_deref(),
+            comments_page,
+            timeline_page,
+        )
     })
     .await;
     match result {
@@ -246,72 +267,6 @@
     .await
 }
 
-async fn change_label(
-    State(state): State<WebState>,
-    Extension(request_id): Extension<RequestId>,
-    Path(path): Path<IssuePath>,
-    headers: HeaderMap,
-    body: Bytes,
-) -> Response {
-    let fields = match parse_named_form(&headers, &body, &["csrf", "label", "operation"]) {
-        Ok(fields) => fields,
-        Err(()) => return issue_bad_request(&request_id.0),
-    };
-    let present = match operation(&fields[2]) {
-        Ok(present) => present,
-        Err(()) => return issue_bad_request(&request_id.0),
-    };
-    let actor =
-        match authenticate_mutation(state.clone(), &headers, &fields[0], &request_id.0).await {
-            Ok(actor) => actor,
-            Err(response) => return response,
-        };
-    mutate(state, request_id, path, move |service, path| {
-        service.set_label(
-            &path.owner,
-            &path.repository,
-            path.number,
-            &actor,
-            &fields[1],
-            present,
-        )
-    })
-    .await
-}
-
-async fn change_assignee(
-    State(state): State<WebState>,
-    Extension(request_id): Extension<RequestId>,
-    Path(path): Path<IssuePath>,
-    headers: HeaderMap,
-    body: Bytes,
-) -> Response {
-    let fields = match parse_named_form(&headers, &body, &["csrf", "assignee", "operation"]) {
-        Ok(fields) => fields,
-        Err(()) => return issue_bad_request(&request_id.0),
-    };
-    let present = match operation(&fields[2]) {
-        Ok(present) => present,
-        Err(()) => return issue_bad_request(&request_id.0),
-    };
-    let actor =
-        match authenticate_mutation(state.clone(), &headers, &fields[0], &request_id.0).await {
-            Ok(actor) => actor,
-            Err(response) => return response,
-        };
-    mutate(state, request_id, path, move |service, path| {
-        service.set_assignee(
-            &path.owner,
-            &path.repository,
-            path.number,
-            &actor,
-            &fields[1],
-            present,
-        )
-    })
-    .await
-}
-
 async fn mutate(
     state: WebState,
     request_id: RequestId,
@@ -348,14 +303,6 @@
     .map_err(|_| IssueError::Store(StoreError::Integrity("issue worker failed".to_owned())))?
 }
 
-fn operation(value: &str) -> Result<bool, ()> {
-    match value {
-        "add" => Ok(true),
-        "remove" => Ok(false),
-        _ => Err(()),
-    }
-}
-
 fn render_issue(request_id: &str, headers: &HeaderMap, detail: &IssueDetail) -> Response {
     let csrf = cookie(headers, CSRF_COOKIE).unwrap_or_default();
     render(
@@ -373,8 +320,6 @@
             author: &detail.issue.author,
             created_at: detail.issue.created_at,
             updated_at: detail.issue.updated_at,
-            labels: &detail.labels,
-            assignees: &detail.assignees,
             comments: detail
                 .comments
                 .iter()
@@ -398,8 +343,17 @@
             csrf: &csrf,
             can_comment: detail.can_comment && !csrf.is_empty(),
             can_edit: detail.can_edit && !csrf.is_empty(),
-            can_maintain: detail.can_maintain && !csrf.is_empty(),
             is_open: detail.issue.state == "open",
+            comments_page: detail.comments_page,
+            comments_has_previous: detail.comments_page > 1,
+            comments_has_next: detail.comments_has_next,
+            comments_previous_page: detail.comments_page.saturating_sub(1),
+            comments_next_page: detail.comments_page.saturating_add(1),
+            timeline_page: detail.timeline_page,
+            timeline_has_previous: detail.timeline_page > 1,
+            timeline_has_next: detail.timeline_has_next,
+            timeline_previous_page: detail.timeline_page.saturating_sub(1),
+            timeline_next_page: detail.timeline_page.saturating_add(1),
         },
     )
 }
@@ -420,6 +374,7 @@
             "Not found",
             "The issue was not found.",
         ),
+        IssueError::State => issue_bad_request(request_id),
         _ => issue_internal(request_id),
     }
 }
@@ -431,19 +386,14 @@
         | IssueError::Number
         | IssueError::Title
         | IssueError::Body
-        | IssueError::State
-        | IssueError::Label => issue_bad_request(request_id),
+        | IssueError::State => issue_bad_request(request_id),
         IssueError::Store(StoreError::IssueDenied) => render_error(
             StatusCode::FORBIDDEN,
             request_id,
             "Forbidden",
             "The issue change is not authorized.",
         ),
-        IssueError::Store(
-            StoreError::IssueState(_)
-            | StoreError::IssueLabelState
-            | StoreError::IssueAssigneeState,
-        ) => render_error(
+        IssueError::Store(StoreError::IssueState(_)) => render_error(
             StatusCode::CONFLICT,
             request_id,
             "Issue conflict",
@@ -452,7 +402,6 @@
         IssueError::Store(
             StoreError::RepositoryNotFound(_, _)
             | StoreError::IssueNotFound(_, _, _)
-            | StoreError::IssueAssigneeNotFound(_)
             | StoreError::IssueHidden,
         ) => render_error(
             StatusCode::NOT_FOUND,
@@ -507,6 +456,18 @@
     number: i64,
 }
 
+#[derive(Default, Deserialize)]
+struct ListQuery {
+    state: Option<String>,
+    page: Option<usize>,
+}
+
+#[derive(Default, Deserialize)]
+struct ActivityQuery {
+    comments_page: Option<usize>,
+    timeline_page: Option<usize>,
+}
+
 #[derive(Template)]
 #[template(path = "issues.html")]
 struct IssueListTemplate<'a> {
@@ -517,6 +478,14 @@
     issues: Vec<IssueListItem<'a>>,
     csrf: &'a str,
     can_create: bool,
+    state: &'a str,
+    state_all: bool,
+    state_open: bool,
+    state_closed: bool,
+    has_previous: bool,
+    has_next: bool,
+    previous_page: usize,
+    next_page: usize,
 }
 
 struct IssueListItem<'a> {
@@ -542,15 +511,22 @@
     author: &'a str,
     created_at: i64,
     updated_at: i64,
-    labels: &'a [String],
-    assignees: &'a [String],
     comments: Vec<CommentView<'a>>,
     timeline: Vec<TimelineView<'a>>,
     csrf: &'a str,
     can_comment: bool,
     can_edit: bool,
-    can_maintain: bool,
     is_open: bool,
+    comments_page: usize,
+    comments_has_previous: bool,
+    comments_has_next: bool,
+    comments_previous_page: usize,
+    comments_next_page: usize,
+    timeline_page: usize,
+    timeline_has_previous: bool,
+    timeline_has_next: bool,
+    timeline_previous_page: usize,
+    timeline_next_page: usize,
 }
 
 struct CommentView<'a> {

src/http/metadata_search.rs

Mode 100644100644; object 021d0b835bd7044fd88dda41

@@ -39,7 +39,7 @@
             StatusCode::BAD_REQUEST,
             &request_id.0,
             "Search error",
-            "The metadata search query is not valid.",
+            "The repository search query is not valid.",
         ),
         Err(_) => search_internal(&request_id.0),
     }
@@ -113,7 +113,7 @@
         StatusCode::INTERNAL_SERVER_ERROR,
         request_id,
         "Search error",
-        "The metadata search could not be completed.",
+        "The repository search could not be completed.",
     )
 }
 

src/http/mod.rs

Mode 100644100644; object f25578c1a4d4e763efffaaf7

@@ -3,6 +3,7 @@
 mod metadata_search;
 mod public;
 mod pull_requests;
+mod repository_settings;
 mod watches;
 
 use std::net::{IpAddr, Ipv4Addr, SocketAddr};
@@ -15,7 +16,7 @@
 use axum::Router;
 use axum::body::{Body, Bytes, HttpBody};
 use axum::extract::{
-    ConnectInfo, DefaultBodyLimit, Extension, OriginalUri, RawQuery, Request, State,
+    ConnectInfo, DefaultBodyLimit, Extension, OriginalUri, Path, Query, RawQuery, Request, State,
 };
 use axum::http::{HeaderMap, HeaderName, HeaderValue, Method, StatusCode, header};
 use axum::middleware::{self, Next};
@@ -27,7 +28,7 @@
 use tokio::task::JoinHandle;
 use tower_http::limit::RequestBodyLimitLayer;
 
-use crate::account::{AccountError, AccountService};
+use crate::account::{AccountError, AccountKeyRequest, AccountService};
 use crate::auth::validate_username;
 use crate::domain::repository::validate_slug;
 use crate::feed_token::FeedTokenService;
@@ -52,6 +53,9 @@
 const CONCURRENCY_WAIT: Duration = Duration::from_secs(1);
 const LOGIN_ATTEMPTS_PER_MINUTE: usize = 10;
 const MAX_LOGIN_CLIENTS: usize = 4096;
+const MAX_LOGIN_MULTIPART_FIELDS: usize = 4;
+const MAX_LOGIN_MULTIPART_FIELD_BYTES: usize = 48 * 1024;
+const MAX_LOGIN_MULTIPART_DECODED_BYTES: usize = 60 * 1024;
 const SESSION_COOKIE: &str = "tit-session";
 const CSRF_COOKIE: &str = "tit-csrf";
 const LOGIN_CSRF_COOKIE: &str = "tit-login-csrf";
@@ -82,12 +86,11 @@
             "issue-commented" => "added a comment",
             "issue-opened" | "issue-reopened" => "reopened the issue",
             "issue-closed" => "closed the issue",
-            "issue-labeled" => "added a label",
-            "issue-unlabeled" => "removed a label",
-            "issue-assigned" => "assigned the issue",
-            "issue-unassigned" => "removed an assignee",
             "pull-request-opened" => "opened the pull request",
             "pull-request-revised" => "recorded a new revision",
+            "pull-request-edited" => "edited the pull request",
+            "pull-request-closed" => "closed the pull request",
+            "pull-request-reopened" => "reopened the pull request",
             "pull-request-commented" => "added a review comment",
             "pull-request-approved" => "approved the pull request",
             "pull-request-changes-requested" => "requested changes",
@@ -98,6 +101,13 @@
     }
 }
 
+fn format_time(timestamp: i64) -> String {
+    jiff::Timestamp::from_second(timestamp).map_or_else(
+        |_| "Invalid time".to_owned(),
+        |timestamp| timestamp.strftime("%Y-%m-%d %H:%M UTC").to_string(),
+    )
+}
+
 #[derive(Clone)]
 struct WebState {
     public: Option<PublicWeb>,
@@ -105,6 +115,7 @@
     jobs: Arc<Semaphore>,
     requests: Arc<Semaphore>,
     login_attempts: AttemptLimiter<IpAddr>,
+    account_attempts: AttemptLimiter<IpAddr>,
     max_request_bytes: usize,
     telemetry: Telemetry,
     key_reloader: Option<AccountKeyReloader>,
@@ -129,7 +140,7 @@
 impl SshLoginTarget {
     fn command(&self, secret: &str) -> String {
         format!(
-            "ssh -p {} {} login {}",
+            "ssh -p {} {} auth {}",
             self.port,
             shell_word(&self.host),
             secret
@@ -189,6 +200,7 @@
                 jobs: Arc::new(Semaphore::new(MAX_BLOCKING_WEB_JOBS)),
                 requests: Arc::new(Semaphore::new(1024)),
                 login_attempts: login_attempt_limiter(),
+                account_attempts: login_attempt_limiter(),
                 max_request_bytes: 1024 * 1024,
                 telemetry: Telemetry::default(),
                 key_reloader: None,
@@ -276,6 +288,7 @@
                 jobs,
                 requests,
                 login_attempts: login_attempt_limiter(),
+                account_attempts: login_attempt_limiter(),
                 max_request_bytes,
                 telemetry,
                 key_reloader,
@@ -348,6 +361,7 @@
         jobs: Arc::new(Semaphore::new(MAX_BLOCKING_WEB_JOBS)),
         requests: Arc::new(Semaphore::new(1024)),
         login_attempts: login_attempt_limiter(),
+        account_attempts: login_attempt_limiter(),
         max_request_bytes: 1024 * 1024,
         telemetry: Telemetry::default(),
         key_reloader: None,
@@ -370,6 +384,7 @@
         .merge(watches::routes())
         .merge(issues::routes())
         .merge(pull_requests::routes())
+        .merge(repository_settings::routes())
         .merge(public::routes());
     Router::new()
         .route("/", get(home))
@@ -416,8 +431,28 @@
         )
         .route("/account", get(account_page))
         .route(
+            "/account/profile",
+            axum::routing::post(update_profile).layer(DefaultBodyLimit::max(2048)),
+        )
+        .route(
             "/account/repositories",
             axum::routing::post(create_repository).layer(DefaultBodyLimit::max(4 * 1024)),
+        )
+        .route(
+            "/account/keys/add",
+            axum::routing::post(begin_key_add).layer(DefaultBodyLimit::max(32 * 1024)),
+        )
+        .route(
+            "/account/keys/add/complete",
+            axum::routing::post(complete_key_add).layer(DefaultBodyLimit::max(32 * 1024)),
+        )
+        .route(
+            "/account/keys/revoke",
+            axum::routing::post(begin_key_revoke).layer(DefaultBodyLimit::max(4 * 1024)),
+        )
+        .route(
+            "/account/keys/revoke/complete",
+            axum::routing::post(complete_key_revoke).layer(DefaultBodyLimit::max(4 * 1024)),
         )
         .route(
             "/logout",
@@ -428,6 +463,7 @@
         .route("/assets/style.css", get(style))
         .merge(feeds::routes())
         .merge(repository_routes)
+        .route("/{username}", get(public_profile))
         .fallback(not_found)
         .method_not_allowed_fallback(method_not_allowed)
         .layer(RequestBodyLimitLayer::new(max_request_bytes))
@@ -526,6 +562,10 @@
     state.login_attempts.allow(peer.0)
 }
 
+fn allow_account_attempt(state: &WebState, peer: ClientAddress) -> bool {
+    state.account_attempts.allow(peer.0)
+}
+
 async fn request_actor(
     State(state): State<WebState>,
     mut request: Request,
@@ -546,9 +586,11 @@
     State(state): State<WebState>,
     Extension(request_id): Extension<RequestId>,
     Extension(actor): Extension<RequestActor>,
+    headers: HeaderMap,
 ) -> Response {
     let signed_in = actor.0.is_some();
     let username = actor.0.clone().unwrap_or_default();
+    let csrf = cookie(&headers, CSRF_COOKIE).unwrap_or_default();
     if state.repositories.is_none() {
         return render_home(
             StatusCode::OK,
@@ -559,6 +601,7 @@
                 error: "",
                 signed_in,
                 username: &username,
+                csrf: &csrf,
                 repositories: &[],
             },
         );
@@ -577,6 +620,7 @@
                 error: "",
                 signed_in,
                 username: &username,
+                csrf: &csrf,
                 repositories: &repositories,
             },
         ),
@@ -631,6 +675,7 @@
                 error: "Enter a valid lowercase owner and repository.",
                 signed_in: actor.0.is_some(),
                 username: actor.0.as_deref().unwrap_or_default(),
+                csrf: "",
                 repositories: &[],
             },
         ),
@@ -659,10 +704,17 @@
 
 async fn signup(
     State(state): State<WebState>,
+    Extension(peer): Extension<ClientAddress>,
     Extension(request_id): Extension<RequestId>,
     headers: HeaderMap,
     body: Bytes,
 ) -> Response {
+    if !allow_account_attempt(&state, peer) {
+        return limit_response(
+            StatusCode::TOO_MANY_REQUESTS,
+            "Account attempt limit exceeded.\n",
+        );
+    }
     let fields = match parse_account_form(&headers, &body, "invitation") {
         Ok(fields) => fields,
         Err(()) => {
@@ -691,10 +743,17 @@
 
 async fn recover(
     State(state): State<WebState>,
+    Extension(peer): Extension<ClientAddress>,
     Extension(request_id): Extension<RequestId>,
     headers: HeaderMap,
     body: Bytes,
 ) -> Response {
+    if !allow_account_attempt(&state, peer) {
+        return limit_response(
+            StatusCode::TOO_MANY_REQUESTS,
+            "Account attempt limit exceeded.\n",
+        );
+    }
     let fields = match parse_account_form(&headers, &body, "recovery") {
         Ok(fields) => fields,
         Err(()) => {
@@ -1005,8 +1064,10 @@
     let mut challenge = None;
     let mut signature = None;
     let mut login_csrf = None;
+    let mut field_count = 0_usize;
+    let mut decoded_bytes = 0_usize;
     loop {
-        let field = match multipart.next_field().await {
+        let mut field = match multipart.next_field().await {
             Ok(Some(field)) => field,
             Ok(None) => break,
             Err(_) => {
@@ -1019,11 +1080,28 @@
                 .await;
             }
         };
+        field_count += 1;
+        if field_count > MAX_LOGIN_MULTIPART_FIELDS {
+            return rejected_login(state, &request_id.0, "", "The login response is not valid.")
+                .await;
+        }
+        let value = match read_login_field(&mut field, &mut decoded_bytes).await {
+            Ok(value) => value,
+            Err(()) => {
+                return rejected_login(
+                    state,
+                    &request_id.0,
+                    "",
+                    "The login response is not valid.",
+                )
+                .await;
+            }
+        };
         match field.name() {
-            Some("username") if username.is_none() => username = field.text().await.ok(),
-            Some("challenge") if challenge.is_none() => challenge = field.text().await.ok(),
-            Some("signature-file") if signature.is_none() => signature = field.text().await.ok(),
-            Some("login-csrf") if login_csrf.is_none() => login_csrf = field.text().await.ok(),
+            Some("username") if username.is_none() => username = Some(value),
+            Some("challenge") if challenge.is_none() => challenge = Some(value),
+            Some("signature-file") if signature.is_none() => signature = Some(value),
+            Some("login-csrf") if login_csrf.is_none() => login_csrf = Some(value),
             _ => {
                 return rejected_login(
                     state,
@@ -1058,6 +1136,28 @@
         login_csrf,
     )
     .await
+}
+
+async fn read_login_field(
+    field: &mut multra::Field<'_>,
+    decoded_bytes: &mut usize,
+) -> Result<String, ()> {
+    let mut bytes = Vec::new();
+    while let Some(chunk) = field.chunk().await.map_err(|_| ())? {
+        if bytes
+            .len()
+            .checked_add(chunk.len())
+            .is_none_or(|size| size > MAX_LOGIN_MULTIPART_FIELD_BYTES)
+        {
+            return Err(());
+        }
+        *decoded_bytes = decoded_bytes.checked_add(chunk.len()).ok_or(())?;
+        if *decoded_bytes > MAX_LOGIN_MULTIPART_DECODED_BYTES {
+            return Err(());
+        }
+        bytes.extend_from_slice(&chunk);
+    }
+    String::from_utf8(bytes).map_err(|_| ())
 }
 
 async fn complete_login(
@@ -1157,22 +1257,385 @@
         return login_redirect(true);
     };
     let csrf_for_auth = csrf.clone();
-    match login_job(state, move |login| {
+    match login_job(state.clone(), move |login| {
         login.authenticate(&session_token, Some(&csrf_for_auth))
     })
     .await
     {
-        Ok(session) => render(
+        Ok(session) => {
+            let username = session.username.clone();
+            let details = account_job(state, move |accounts| {
+                let profile = accounts.profile(&username)?;
+                let keys = accounts.keys(&username)?;
+                Ok((profile, keys))
+            })
+            .await;
+            match details {
+                Ok((profile, keys)) => render(
+                    StatusCode::OK,
+                    &AccountTemplate {
+                        request_id: &request_id.0,
+                        username: &session.username,
+                        administrator: session.is_administrator,
+                        csrf: &csrf,
+                        bio: &profile.bio,
+                        contact_email: &profile.contact_email,
+                        keys: keys
+                            .iter()
+                            .map(|key| AccountKeyView {
+                                label: &key.label,
+                                fingerprint: &key.fingerprint,
+                                created_at: format_time(key.created_at),
+                                last_used_at: key
+                                    .last_used_at
+                                    .map(format_time)
+                                    .unwrap_or_else(|| "Never".to_owned()),
+                                active: key.revoked_at.is_none(),
+                            })
+                            .collect(),
+                        active_key_count: keys
+                            .iter()
+                            .filter(|key| key.revoked_at.is_none())
+                            .count(),
+                        signed_in: true,
+                    },
+                ),
+                Err(_) => render_error_with_auth(
+                    StatusCode::INTERNAL_SERVER_ERROR,
+                    &request_id.0,
+                    "Account error",
+                    "The account profile could not be read.",
+                    true,
+                ),
+            }
+        }
+        Err(_) => login_redirect(true),
+    }
+}
+
+async fn begin_key_add(
+    State(state): State<WebState>,
+    Extension(request_id): Extension<RequestId>,
+    headers: HeaderMap,
+    body: Bytes,
+) -> Response {
+    let fields = match parse_named_form(&headers, &body, &["csrf", "label", "public-key"]) {
+        Ok(fields) => fields,
+        Err(()) => return key_error(&request_id.0, "The key request is not valid."),
+    };
+    let username =
+        match authenticate_mutation(state.clone(), &headers, &fields[0], &request_id.0).await {
+            Ok(username) => username,
+            Err(response) => return response,
+        };
+    let Some(target) = state.ssh_login_target.clone() else {
+        return key_error(&request_id.0, "SSH authentication is not available.");
+    };
+    let csrf = fields[0].clone();
+    let approval = login_job(state, {
+        let username = username.clone();
+        let csrf = csrf.clone();
+        move |login| login.issue_account_approval(&username, &csrf)
+    })
+    .await;
+    match approval {
+        Ok(approval) => {
+            let command = target.command(&approval.secret);
+            render(
+                StatusCode::OK,
+                &AccountKeyAuthTemplate {
+                    request_id: &request_id.0,
+                    heading: "Add SSH key",
+                    action: "/account/keys/add/complete",
+                    command: &command,
+                    secret: &approval.secret,
+                    csrf: &csrf,
+                    label: &fields[1],
+                    public_key: &fields[2],
+                    fingerprint: "",
+                    adding: true,
+                    signed_in: true,
+                },
+            )
+        }
+        Err(_) => key_error(
+            &request_id.0,
+            "The authentication request could not be created.",
+        ),
+    }
+}
+
+async fn complete_key_add(
+    State(state): State<WebState>,
+    Extension(request_id): Extension<RequestId>,
+    headers: HeaderMap,
+    body: Bytes,
+) -> Response {
+    let fields = match parse_named_form(&headers, &body, &["csrf", "secret", "label", "public-key"])
+    {
+        Ok(fields) => fields,
+        Err(()) => return key_error(&request_id.0, "The key request is not valid."),
+    };
+    let username =
+        match authenticate_mutation(state.clone(), &headers, &fields[0], &request_id.0).await {
+            Ok(username) => username,
+            Err(response) => return response,
+        };
+    let Some(session) = cookie(&headers, SESSION_COOKIE) else {
+        return login_redirect(false);
+    };
+    let correlation_id = request_id.0.clone();
+    let result = account_job(state, move |accounts| {
+        accounts.complete_key_add(
+            &AccountKeyRequest {
+                username: &username,
+                session: &session,
+                csrf: &fields[0],
+                secret: &fields[1],
+                correlation_id: &correlation_id,
+            },
+            &fields[2],
+            &fields[3],
+        )
+    })
+    .await;
+    key_change_result(result.map(|_| ()), &request_id.0)
+}
+
+async fn begin_key_revoke(
+    State(state): State<WebState>,
+    Extension(request_id): Extension<RequestId>,
+    headers: HeaderMap,
+    body: Bytes,
+) -> Response {
+    let fields = match parse_named_form(&headers, &body, &["csrf", "fingerprint"]) {
+        Ok(fields) => fields,
+        Err(()) => return key_error(&request_id.0, "The key request is not valid."),
+    };
+    let username =
+        match authenticate_mutation(state.clone(), &headers, &fields[0], &request_id.0).await {
+            Ok(username) => username,
+            Err(response) => return response,
+        };
+    let Some(target) = state.ssh_login_target.clone() else {
+        return key_error(&request_id.0, "SSH authentication is not available.");
+    };
+    let csrf = fields[0].clone();
+    let approval = login_job(state, {
+        let username = username.clone();
+        let csrf = csrf.clone();
+        move |login| login.issue_account_approval(&username, &csrf)
+    })
+    .await;
+    match approval {
+        Ok(approval) => {
+            let command = target.command(&approval.secret);
+            render(
+                StatusCode::OK,
+                &AccountKeyAuthTemplate {
+                    request_id: &request_id.0,
+                    heading: "Revoke SSH key",
+                    action: "/account/keys/revoke/complete",
+                    command: &command,
+                    secret: &approval.secret,
+                    csrf: &csrf,
+                    label: "",
+                    public_key: "",
+                    fingerprint: &fields[1],
+                    adding: false,
+                    signed_in: true,
+                },
+            )
+        }
+        Err(_) => key_error(
+            &request_id.0,
+            "The authentication request could not be created.",
+        ),
+    }
+}
+
+async fn complete_key_revoke(
+    State(state): State<WebState>,
+    Extension(request_id): Extension<RequestId>,
+    headers: HeaderMap,
+    body: Bytes,
+) -> Response {
+    let fields = match parse_named_form(&headers, &body, &["csrf", "secret", "fingerprint"]) {
+        Ok(fields) => fields,
+        Err(()) => return key_error(&request_id.0, "The key request is not valid."),
+    };
+    let username =
+        match authenticate_mutation(state.clone(), &headers, &fields[0], &request_id.0).await {
+            Ok(username) => username,
+            Err(response) => return response,
+        };
+    let Some(session) = cookie(&headers, SESSION_COOKIE) else {
+        return login_redirect(false);
+    };
+    let correlation_id = request_id.0.clone();
+    let result = account_job(state, move |accounts| {
+        accounts.complete_key_revoke(
+            &AccountKeyRequest {
+                username: &username,
+                session: &session,
+                csrf: &fields[0],
+                secret: &fields[1],
+                correlation_id: &correlation_id,
+            },
+            &fields[2],
+        )
+    })
+    .await;
+    key_change_result(result, &request_id.0)
+}
+
+fn key_change_result(result: Result<(), AccountError>, request_id: &str) -> Response {
+    match result {
+        Ok(()) => account_redirect(),
+        Err(AccountError::Store(StoreError::LastKey)) => key_error(
+            request_id,
+            "An account must keep at least one active SSH key.",
+        ),
+        Err(AccountError::Store(StoreError::KeyExists)) => {
+            key_error(request_id, "The key or active key label already exists.")
+        }
+        Err(
+            AccountError::Auth(_)
+            | AccountError::InvalidLabel
+            | AccountError::InvalidSecret
+            | AccountError::Store(StoreError::InvalidLoginApproval)
+            | AccountError::Store(StoreError::KeyNotFound),
+        ) => key_error(
+            request_id,
+            "The key request is invalid, expired, or already used.",
+        ),
+        Err(_) => key_error(request_id, "The key request could not be completed."),
+    }
+}
+
+fn key_error(request_id: &str, message: &str) -> Response {
+    render_error_with_auth(
+        StatusCode::BAD_REQUEST,
+        request_id,
+        "SSH key error",
+        message,
+        true,
+    )
+}
+
+async fn public_profile(
+    State(state): State<WebState>,
+    Extension(request_id): Extension<RequestId>,
+    Extension(actor): Extension<RequestActor>,
+    Path(username): Path<String>,
+    Query(query): Query<ProfileQuery>,
+) -> Response {
+    let signed_in = actor.0.is_some();
+    if validate_username(&username).is_err() || state.accounts.is_none() {
+        return render_error_with_auth(
+            StatusCode::NOT_FOUND,
+            &request_id.0,
+            "Page not found",
+            "The requested page does not exist.",
+            signed_in,
+        );
+    }
+    let page_number = query.page.unwrap_or(1);
+    let result = account_job(state, move |accounts| {
+        accounts.profile_page(&username, page_number)
+    })
+    .await;
+    match result {
+        Ok(profile) => render(
             StatusCode::OK,
-            &AccountTemplate {
+            &PublicProfileTemplate {
                 request_id: &request_id.0,
-                username: &session.username,
-                administrator: session.is_administrator,
-                csrf: &csrf,
-                signed_in: true,
+                signed_in,
+                username: &profile.username,
+                bio: &profile.bio,
+                contact_email: &profile.contact_email,
+                repositories: profile
+                    .repositories
+                    .iter()
+                    .map(|repository| HomeRepositoryView {
+                        owner: &repository.owner,
+                        slug: &repository.slug,
+                        visibility: &repository.visibility,
+                        state: &repository.state,
+                        description: &repository.description,
+                        updated_at: repository.updated_at,
+                    })
+                    .collect(),
+                has_previous: profile.page > 1,
+                has_next: profile.has_next,
+                previous_page: profile.page.saturating_sub(1),
+                next_page: profile.page.saturating_add(1),
             },
         ),
-        Err(_) => login_redirect(true),
+        Err(AccountError::Auth(_) | AccountError::Store(StoreError::AccountNotFound(_))) => {
+            render_error_with_auth(
+                StatusCode::NOT_FOUND,
+                &request_id.0,
+                "Not found",
+                "The profile was not found.",
+                signed_in,
+            )
+        }
+        Err(_) => render_error_with_auth(
+            StatusCode::INTERNAL_SERVER_ERROR,
+            &request_id.0,
+            "Profile error",
+            "The profile could not be read.",
+            signed_in,
+        ),
+    }
+}
+
+async fn update_profile(
+    State(state): State<WebState>,
+    Extension(request_id): Extension<RequestId>,
+    headers: HeaderMap,
+    body: Bytes,
+) -> Response {
+    let fields = match parse_named_form(&headers, &body, &["csrf", "bio", "contact-email"]) {
+        Ok(fields) => fields,
+        Err(()) => {
+            return render_error_with_auth(
+                StatusCode::BAD_REQUEST,
+                &request_id.0,
+                "Profile error",
+                "The profile request is not valid.",
+                true,
+            );
+        }
+    };
+    let actor =
+        match authenticate_mutation(state.clone(), &headers, &fields[0], &request_id.0).await {
+            Ok(actor) => actor,
+            Err(response) => return response,
+        };
+    let bio = fields[1].clone();
+    let email = fields[2].clone();
+    match account_job(state, move |accounts| {
+        accounts.update_profile(&actor, &bio, &email)
+    })
+    .await
+    {
+        Ok(()) => account_redirect(),
+        Err(AccountError::InvalidProfile) => render_error_with_auth(
+            StatusCode::BAD_REQUEST,
+            &request_id.0,
+            "Profile error",
+            "The bio or contact email is not valid.",
+            true,
+        ),
+        Err(_) => render_error_with_auth(
+            StatusCode::INTERNAL_SERVER_ERROR,
+            &request_id.0,
+            "Profile error",
+            "The profile could not be saved.",
+            true,
+        ),
     }
 }
 
@@ -1806,6 +2269,7 @@
             has_error: !page.error.is_empty(),
             signed_in: page.signed_in,
             username: page.username,
+            csrf: page.csrf,
             repositories: page
                 .repositories
                 .iter()
@@ -1813,6 +2277,8 @@
                     owner: &repository.owner,
                     slug: &repository.slug,
                     visibility: &repository.visibility,
+                    state: &repository.state,
+                    description: &repository.description,
                     updated_at: repository.updated_at,
                 })
                 .collect(),
@@ -1826,6 +2292,7 @@
     error: &'a str,
     signed_in: bool,
     username: &'a str,
+    csrf: &'a str,
     repositories: &'a [crate::store::HomeRepositoryRecord],
 }
 
@@ -1917,6 +2384,11 @@
     repository: String,
 }
 
+#[derive(Default, serde::Deserialize)]
+struct ProfileQuery {
+    page: Option<usize>,
+}
+
 #[derive(Template)]
 #[template(path = "home.html")]
 struct HomeTemplate<'a> {
@@ -1927,6 +2399,7 @@
     has_error: bool,
     signed_in: bool,
     username: &'a str,
+    csrf: &'a str,
     repositories: Vec<HomeRepositoryView<'a>>,
 }
 
@@ -1934,6 +2407,8 @@
     owner: &'a str,
     slug: &'a str,
     visibility: &'a str,
+    state: &'a str,
+    description: &'a str,
     updated_at: i64,
 }
 
@@ -2006,7 +2481,50 @@
     username: &'a str,
     administrator: bool,
     csrf: &'a str,
+    bio: &'a str,
+    contact_email: &'a str,
+    keys: Vec<AccountKeyView<'a>>,
+    active_key_count: usize,
     signed_in: bool,
+}
+
+struct AccountKeyView<'a> {
+    label: &'a str,
+    fingerprint: &'a str,
+    created_at: String,
+    last_used_at: String,
+    active: bool,
+}
+
+#[derive(Template)]
+#[template(path = "account-key-auth.html")]
+struct AccountKeyAuthTemplate<'a> {
+    request_id: &'a str,
+    heading: &'a str,
+    action: &'a str,
+    command: &'a str,
+    secret: &'a str,
+    csrf: &'a str,
+    label: &'a str,
+    public_key: &'a str,
+    fingerprint: &'a str,
+    adding: bool,
+    signed_in: bool,
+}
+
+#[derive(Template)]
+#[template(path = "profile.html")]
+struct PublicProfileTemplate<'a> {
+    request_id: &'a str,
+    signed_in: bool,
+    username: &'a str,
+    bio: &'a str,
+    contact_email: &'a str,
+    repositories: Vec<HomeRepositoryView<'a>>,
+    has_previous: bool,
+    has_next: bool,
+    previous_page: usize,
+    next_page: usize,
 }
 
 #[derive(Template)]

src/http/public.rs

Mode 100644100644; object 5548a45f83b0235f0a42eeb1

@@ -22,8 +22,9 @@
 
 use crate::auth::validate_username;
 use crate::domain::repository::validate_slug;
-use crate::feed::{FeedFormat, FeedPage, PAGE_SIZE, RepositoryFeedKind};
+use crate::feed::{FeedPage, PAGE_SIZE, RepositoryFeedKind};
 use crate::git::packetline::MAX_REQUEST_BYTES;
+use crate::git::patch::write_patch;
 use crate::git::read::{
     BlameHunk, CommitInfo, DiffFile, ReadCancellation, ReadError, ReadLimits,
     RepositoryReadService, SearchOutcome, TreeEntryInfo,
@@ -242,14 +243,14 @@
         owner: String,
         repository: String,
         id: ObjectId,
-    ) -> Result<Body, RouteError> {
-        let path = self
+    ) -> Result<(bool, Body), RouteError> {
+        let (is_public, path) = self
             .path_job(actor, owner, repository, move |record, path| {
                 require_id_format(id, &record)?;
                 let service = RepositoryReadService::open(&path, ReadLimits::default())?;
                 let cancellation = ReadCancellation::default();
                 service.commit(id, &cancellation)?;
-                Ok(path)
+                Ok((record.visibility == "public", path))
             })
             .await?;
         let permit = self
@@ -272,7 +273,7 @@
                 let _ = sender.blocking_send(Err(std::io::Error::other(error.to_string())));
             }
         });
-        Ok(Body::from_stream(ReceiverStream::new(receiver)))
+        Ok((is_public, Body::from_stream(ReceiverStream::new(receiver))))
     }
 }
 
@@ -284,12 +285,7 @@
             post(git_upload_pack).layer(DefaultBodyLimit::max(MAX_REQUEST_BYTES)),
         )
         .route("/{owner}/{repository}/refs", get(refs))
-        .route("/{owner}/{repository}/atom.xml", get(atom_feed))
         .route("/{owner}/{repository}/rss.xml", get(rss_feed))
-        .route(
-            "/{owner}/{repository}/issues/atom.xml",
-            get(issue_atom_feed),
-        )
         .route("/{owner}/{repository}/issues/rss.xml", get(issue_rss_feed))
         .route("/{owner}/{repository}/search", get(search))
         .route("/{owner}/{repository}/commits", get(commits))
@@ -302,26 +298,6 @@
         .route("/{owner}/{repository}/blame/{commit}/{*path}", get(blame))
         .route("/{owner}/{repository}/archive/{archive}", get(archive))
         .route("/{owner}/{repository}", get(summary))
-}
-
-async fn atom_feed(
-    State(state): State<WebState>,
-    Extension(request_id): Extension<RequestId>,
-    Extension(actor): Extension<RequestActor>,
-    AxumPath(path): AxumPath<RepositoryPath>,
-    Query(query): Query<FeedQuery>,
-    headers: HeaderMap,
-) -> Response {
-    feed_response(
-        state,
-        request_id,
-        actor,
-        path,
-        query,
-        headers,
-        (FeedFormat::Atom, RepositoryFeedKind::Activity),
-    )
-    .await
 }
 
 async fn rss_feed(
@@ -339,27 +315,7 @@
         path,
         query,
         headers,
-        (FeedFormat::Rss, RepositoryFeedKind::Activity),
-    )
-    .await
-}
-
-async fn issue_atom_feed(
-    State(state): State<WebState>,
-    Extension(request_id): Extension<RequestId>,
-    Extension(actor): Extension<RequestActor>,
-    AxumPath(path): AxumPath<RepositoryPath>,
-    Query(query): Query<FeedQuery>,
-    headers: HeaderMap,
-) -> Response {
-    feed_response(
-        state,
-        request_id,
-        actor,
-        path,
-        query,
-        headers,
-        (FeedFormat::Atom, RepositoryFeedKind::Issues),
+        RepositoryFeedKind::Activity,
     )
     .await
 }
@@ -379,7 +335,7 @@
         path,
         query,
         headers,
-        (FeedFormat::Rss, RepositoryFeedKind::Issues),
+        RepositoryFeedKind::Issues,
     )
     .await
 }
@@ -391,9 +347,8 @@
     path: RepositoryPath,
     query: FeedQuery,
     headers: HeaderMap,
-    feed: (FeedFormat, RepositoryFeedKind),
+    kind: RepositoryFeedKind,
 ) -> Response {
-    let (format, kind) = feed;
     if matches!(query.before, Some(before) if before <= 0) {
         return route_error(RouteError::InvalidRequest, &request_id.0);
     }
@@ -420,10 +375,7 @@
     let next_before = has_next
         .then(|| events.last().map(|event| event.sequence))
         .flatten();
-    let name = match format {
-        FeedFormat::Atom => "atom.xml",
-        FeedFormat::Rss => "rss.xml",
-    };
+    let name = "rss.xml";
     let feed_url = format!("{}/{owner}/{repository}/{name}", web.http_clone_base);
     let self_url = query.before.map_or_else(
         || feed_url.clone(),
@@ -443,12 +395,12 @@
         next_before,
         kind,
     })
-    .render(format)
+    .render()
     {
         Ok(body) => body,
         Err(_) => return route_error(RouteError::Internal, &request_id.0),
     };
-    conditional_feed(&headers, name, body, newest, record.visibility == "public")
+    conditional_feed(&headers, body, newest, record.visibility == "public")
 }
 
 async fn summary(
@@ -468,17 +420,21 @@
     };
     let signed_in = actor.0.is_some();
     let clone_urls = web.clone_urls(&path.owner, &path.repository);
+    let database = web.database.clone();
     let result = web
         .read(
             actor.0,
             path.owner,
             path.repository,
             move |record, service| {
+                let description = Store::open(&database)?.repository_description(&record.id)?;
                 let cancellation = ReadCancellation::default();
                 let references = service.references(&cancellation)?;
+                let default_branch = Store::open(&database)?
+                    .repository_default_branch(&record.owner, &record.slug)?;
                 let head = references
                     .iter()
-                    .find(|reference| reference.name == b"HEAD")
+                    .find(|reference| reference.name == default_branch.as_bytes())
                     .map(|reference| reference.target);
                 let (history, readme) = match head {
                     Some(head) => (
@@ -489,6 +445,7 @@
                 };
                 Ok(RepositoryPage::summary(
                     record,
+                    description,
                     clone_urls,
                     head,
                     history,
@@ -536,6 +493,7 @@
         return route_error(RouteError::NotFound, &request_id.0);
     };
     let signed_in = actor.0.is_some();
+    let database = web.database.clone();
     let page = query.page.unwrap_or(1);
     if page == 0 {
         return route_error(RouteError::InvalidRequest, &request_id.0);
@@ -548,9 +506,11 @@
             move |record, service| {
                 let cancellation = ReadCancellation::default();
                 let references = service.references(&cancellation)?;
+                let default_branch = Store::open(&database)?
+                    .repository_default_branch(&record.owner, &record.slug)?;
                 let head = references
                     .iter()
-                    .find(|reference| reference.name == b"HEAD")
+                    .find(|reference| reference.name == default_branch.as_bytes())
                     .map(|reference| reference.target);
                 let history = match head {
                     Some(head) => service.history(head, &cancellation)?,
@@ -579,6 +539,7 @@
         return route_error(RouteError::InvalidRequest, &request_id.0);
     }
     let signed_in = actor.0.is_some();
+    let database = web.database.clone();
     let result = web
         .read(
             actor.0,
@@ -587,7 +548,10 @@
             move |record, service| {
                 let cancellation = ReadCancellation::default();
                 let references = service.references(&cancellation)?;
-                let selected = select_search_ref(&references, query.reference.as_deref())?;
+                let default_branch = Store::open(&database)?
+                    .repository_default_branch(&record.owner, &record.slug)?;
+                let selected =
+                    select_search_ref(&references, query.reference.as_deref(), &default_branch)?;
                 let outcome = match (&query.query, selected.as_ref()) {
                     (Some(query), Some((_, commit))) => {
                         Some(service.search(*commit, query.as_bytes(), &cancellation)?)
@@ -614,6 +578,9 @@
     Extension(actor): Extension<RequestActor>,
     AxumPath(path): AxumPath<CommitPath>,
 ) -> Response {
+    if path.commit.ends_with(".patch") {
+        return commit_patch(state, request_id, actor, path).await;
+    }
     let Some(web) = state.public else {
         return route_error(RouteError::NotFound, &request_id.0);
     };
@@ -636,6 +603,52 @@
         )
         .await;
     render_page(result, &request_id.0, signed_in)
+}
+
+async fn commit_patch(
+    state: WebState,
+    request_id: RequestId,
+    actor: RequestActor,
+    path: CommitPath,
+) -> Response {
+    let Some(commit) = path.commit.strip_suffix(".patch") else {
+        return route_error(RouteError::NotFound, &request_id.0);
+    };
+    let id = match parse_id(commit) {
+        Ok(id) => id,
+        Err(error) => return route_error(error, &request_id.0),
+    };
+    let Some(web) = state.public else {
+        return route_error(RouteError::NotFound, &request_id.0);
+    };
+    let result = web
+        .read(
+            actor.0,
+            path.owner,
+            path.repository,
+            move |record, service| {
+                require_id_format(id, &record)?;
+                let files = service.commit_diff(id, &ReadCancellation::default())?;
+                Ok((record.visibility == "public", files))
+            },
+        )
+        .await;
+    let (is_public, files) = match result {
+        Ok(result) => result,
+        Err(error) => return route_error(error, &request_id.0),
+    };
+    let body = match stream_patch(state.jobs.clone(), files).await {
+        Ok(body) => body,
+        Err(()) => return route_error(RouteError::Unavailable, &request_id.0),
+    };
+    patch_response(
+        body,
+        &format!(
+            "{}.patch",
+            id.to_string().chars().take(12).collect::<String>()
+        ),
+        is_public,
+    )
 }
 
 async fn diff(
@@ -860,10 +873,17 @@
     };
     let result = web.archive(actor.0, path.owner, path.repository, id).await;
     match result {
-        Ok(body) => Response::builder()
+        Ok((is_public, body)) => Response::builder()
             .status(StatusCode::OK)
             .header(header::CONTENT_TYPE, "application/x-tar")
-            .header(header::CACHE_CONTROL, "public, max-age=31536000, immutable")
+            .header(
+                header::CACHE_CONTROL,
+                if is_public {
+                    "public, max-age=31536000, immutable"
+                } else {
+                    "private, no-store"
+                },
+            )
             .header(
                 header::CONTENT_DISPOSITION,
                 HeaderValue::from_static("attachment; filename=repository.tar"),
@@ -993,7 +1013,6 @@
 
 pub(super) fn conditional_feed(
     headers: &HeaderMap,
-    name: &str,
     body: String,
     timestamp: i64,
     is_public: bool,
@@ -1019,11 +1038,7 @@
     } else {
         StatusCode::OK
     };
-    let content_type = if name == "atom.xml" {
-        "application/atom+xml; charset=utf-8"
-    } else {
-        "application/rss+xml; charset=utf-8"
-    };
+    let content_type = "application/rss+xml; charset=utf-8";
     Response::builder()
         .status(status)
         .header(header::CONTENT_TYPE, content_type)
@@ -1247,6 +1262,36 @@
     sender: &'a mpsc::Sender<Result<Bytes, std::io::Error>>,
 }
 
+pub(super) async fn stream_patch(jobs: Arc<Semaphore>, files: Vec<DiffFile>) -> Result<Body, ()> {
+    let permit = jobs.acquire_owned().await.map_err(|_| ())?;
+    let (sender, receiver) = mpsc::channel(8);
+    tokio::task::spawn_blocking(move || {
+        let _permit = permit;
+        if let Err(error) = write_patch(&files, &mut ChannelWriter { sender: &sender }) {
+            let _ = sender.blocking_send(Err(std::io::Error::other(error.to_string())));
+        }
+    });
+    Ok(Body::from_stream(ReceiverStream::new(receiver)))
+}
+
+pub(super) fn patch_response(body: Body, filename: &str, is_public: bool) -> Response {
+    let disposition = format!("attachment; filename=\"{filename}\"");
+    Response::builder()
+        .status(StatusCode::OK)
+        .header(header::CONTENT_TYPE, "text/x-diff; charset=utf-8")
+        .header(
+            header::CACHE_CONTROL,
+            if is_public {
+                "public, max-age=31536000, immutable"
+            } else {
+                "private, no-store"
+            },
+        )
+        .header(header::CONTENT_DISPOSITION, disposition)
+        .body(body)
+        .expect("the patch response is valid")
+}
+
 impl Write for ChannelWriter<'_> {
     fn write(&mut self, buffer: &[u8]) -> std::io::Result<usize> {
         self.sender
@@ -1327,6 +1372,7 @@
     owner: String,
     repository: String,
     created_at: i64,
+    description: String,
     page_title: String,
     page_kind: &'static str,
     commit_id: String,
@@ -1370,6 +1416,7 @@
             owner: record.owner,
             repository: record.slug,
             created_at: record.created_at,
+            description: String::new(),
             page_title: title,
             page_kind,
             commit_id: String::new(),
@@ -1408,6 +1455,7 @@
 
     fn summary(
         record: RepositoryRecord,
+        description: String,
         clone_urls: (String, String),
         head: Option<ObjectId>,
         history: Vec<CommitInfo>,
@@ -1415,6 +1463,7 @@
     ) -> Self {
         let title = format!("{}/{}", record.owner, record.slug);
         let mut page = Self::base(record, "summary", title);
+        page.description = description;
         page.http_clone_url = clone_urls.0;
         page.ssh_clone_url = clone_urls.1;
         page.has_head = head.is_some();
@@ -1797,6 +1846,7 @@
 fn select_search_ref(
     references: &[crate::git::read::RefInfo],
     requested: Option<&str>,
+    default_branch: &str,
 ) -> Result<Option<(Vec<u8>, ObjectId)>, RouteError> {
     if requested.is_some_and(|name| name.is_empty() || name.len() > 4096) {
         return Err(RouteError::InvalidRequest);
@@ -1808,7 +1858,7 @@
             .ok_or(RouteError::NotFound)?,
         None => match references
             .iter()
-            .find(|reference| reference.name == b"HEAD")
+            .find(|reference| reference.name == default_branch.as_bytes())
         {
             Some(reference) => reference,
             None => match references.iter().find(|reference| {

src/http/pull_requests.rs

Mode 100644100644; object 4eaa560a0473dc3b69b51f7b

@@ -12,6 +12,7 @@
 use crate::store::StoreError;
 
 use super::filters;
+use super::public::{patch_response, stream_patch};
 use super::{
     CSRF_COOKIE, RequestActor, RequestId, WebState, authenticate_mutation, cookie,
     parse_named_form, render, render_error,
@@ -34,6 +35,18 @@
             post(revise_pull_request),
         )
         .route(
+            "/{owner}/{repository}/pulls/{number}/revisions/{revision}",
+            get(download_revision_patch),
+        )
+        .route(
+            "/{owner}/{repository}/pulls/{number}/edit",
+            post(edit_pull_request),
+        )
+        .route(
+            "/{owner}/{repository}/pulls/{number}/state",
+            post(change_pull_request_state),
+        )
+        .route(
             "/{owner}/{repository}/pulls/{number}/reviews",
             post(create_review),
         )
@@ -44,11 +57,61 @@
         .layer(DefaultBodyLimit::max(MAX_PULL_REQUEST_BYTES))
 }
 
+async fn download_revision_patch(
+    State(state): State<WebState>,
+    Extension(request_id): Extension<RequestId>,
+    Extension(actor): Extension<RequestActor>,
+    Path(path): Path<PullRequestRevisionPath>,
+) -> Response {
+    let Some(service) = state.pull_requests.clone() else {
+        return internal(&request_id.0);
+    };
+    let Some(revision) = path.revision.strip_suffix(".patch") else {
+        return bad_request(&request_id.0);
+    };
+    let Ok(revision) = revision.parse::<i64>() else {
+        return bad_request(&request_id.0);
+    };
+    let owner = path.owner.clone();
+    let repository = path.repository.clone();
+    let number = path.number;
+    let result = job(state.clone(), move || {
+        service.compare_page(
+            &owner,
+            &repository,
+            number,
+            Some(revision),
+            actor.0.as_deref(),
+            1,
+            1,
+        )
+    })
+    .await;
+    let comparison = match result {
+        Ok(comparison) => comparison,
+        Err(error) => return read_error(error, &request_id.0),
+    };
+    let is_public = comparison.detail.repository.visibility == "public";
+    let body = match stream_patch(state.jobs.clone(), comparison.comparison.files).await {
+        Ok(body) => body,
+        Err(()) => return internal(&request_id.0),
+    };
+    patch_response(
+        body,
+        &format!(
+            "{}-{}-pr-{}-revision-{}.patch",
+            path.owner, path.repository, path.number, revision
+        ),
+        is_public,
+    )
+}
+
 async fn pull_request_list(
     State(state): State<WebState>,
     Extension(request_id): Extension<RequestId>,
     Extension(actor): Extension<RequestActor>,
     Path(path): Path<RepositoryPath>,
+    Query(query): Query<ListQuery>,
     headers: HeaderMap,
 ) -> Response {
     let Some(service) = state.pull_requests.clone() else {
@@ -59,12 +122,21 @@
     let signed_in = actor.0.is_some();
     let actor_name = actor.0;
     let actor_for_list = actor_name.clone();
+    let state_filter = query.state.unwrap_or_else(|| "open".to_owned());
+    let page_number = query.page.unwrap_or(1);
+    let state_for_job = state_filter.clone();
     let result = job(state.clone(), move || {
-        service.list(&owner, &repository, actor_for_list.as_deref())
+        service.list_page(
+            &owner,
+            &repository,
+            actor_for_list.as_deref(),
+            &state_for_job,
+            page_number,
+        )
     })
     .await;
     match result {
-        Ok((record, pull_requests, can_create)) => {
+        Ok((record, page, can_create)) => {
             let csrf = cookie(&headers, CSRF_COOKIE).unwrap_or_default();
             let branches = match state.public.as_ref() {
                 Some(public) => public
@@ -73,6 +145,13 @@
                     .unwrap_or_default(),
                 None => Vec::new(),
             };
+            let default_owner = record.owner.clone();
+            let default_repository = record.slug.clone();
+            let default_branch = super::repository_job(state, move |repositories| {
+                repositories.default_branch(&default_owner, &default_repository)
+            })
+            .await
+            .unwrap_or_else(|_| "refs/heads/main".to_owned());
             render(
                 StatusCode::OK,
                 &PullRequestListTemplate {
@@ -80,7 +159,8 @@
                     signed_in,
                     owner: &record.owner,
                     repository: &record.slug,
-                    pull_requests: pull_requests
+                    pull_requests: page
+                        .items
                         .iter()
                         .map(|pull_request| PullRequestListItem {
                             number: pull_request.number,
@@ -93,6 +173,16 @@
                     csrf: &csrf,
                     can_create: can_create && !csrf.is_empty(),
                     branches,
+                    default_branch: &default_branch,
+                    state: &state_filter,
+                    state_all: state_filter == "all",
+                    state_open: state_filter == "open",
+                    state_closed: state_filter == "closed",
+                    state_merged: state_filter == "merged",
+                    has_previous: page.page > 1,
+                    has_next: page.has_next,
+                    previous_page: page.page.saturating_sub(1),
+                    next_page: page.page.saturating_add(1),
                 },
             )
         }
@@ -115,12 +205,14 @@
     let repository = path.repository.clone();
     let signed_in = actor.0.is_some();
     let result = job(state, move || {
-        service.compare(
+        service.compare_page(
             &owner,
             &repository,
             path.number,
             query.revision,
             actor.0.as_deref(),
+            query.reviews_page.unwrap_or(1),
+            query.timeline_page.unwrap_or(1),
         )
     })
     .await;
@@ -184,10 +276,87 @@
                     can_merge: detail.can_merge
                         && !csrf.is_empty()
                         && result.revision.number == pull_request_revision(detail),
+                    can_edit: detail.can_edit && !csrf.is_empty(),
+                    can_change_state: detail.can_change_state && !csrf.is_empty(),
+                    is_open: pull_request.state == "open",
+                    reviews_page: detail.reviews_page,
+                    reviews_has_previous: detail.reviews_page > 1,
+                    reviews_has_next: detail.reviews_has_next,
+                    reviews_previous_page: detail.reviews_page.saturating_sub(1),
+                    reviews_next_page: detail.reviews_page.saturating_add(1),
+                    timeline_page: detail.timeline_page,
+                    timeline_has_previous: detail.timeline_page > 1,
+                    timeline_has_next: detail.timeline_has_next,
+                    timeline_previous_page: detail.timeline_page.saturating_sub(1),
+                    timeline_next_page: detail.timeline_page.saturating_add(1),
                 },
             )
         }
         Err(error) => read_error(error, &request_id.0),
+    }
+}
+
+async fn edit_pull_request(
+    State(state): State<WebState>,
+    Extension(request_id): Extension<RequestId>,
+    Path(path): Path<PullRequestPath>,
+    headers: HeaderMap,
+    body: Bytes,
+) -> Response {
+    let fields = match parse_named_form(&headers, &body, &["csrf", "title", "body"]) {
+        Ok(fields) => fields,
+        Err(()) => return bad_request(&request_id.0),
+    };
+    let actor =
+        match authenticate_mutation(state.clone(), &headers, &fields[0], &request_id.0).await {
+            Ok(actor) => actor,
+            Err(response) => return response,
+        };
+    let Some(service) = state.pull_requests.clone() else {
+        return internal(&request_id.0);
+    };
+    let owner = path.owner.clone();
+    let repository = path.repository.clone();
+    let number = path.number;
+    let result = job(state, move || {
+        service.edit(&owner, &repository, number, &actor, &fields[1], &fields[2])
+    })
+    .await;
+    match result {
+        Ok(()) => redirect(&path.owner, &path.repository, path.number),
+        Err(error) => mutation_error(error, &request_id.0),
+    }
+}
+
+async fn change_pull_request_state(
+    State(state): State<WebState>,
+    Extension(request_id): Extension<RequestId>,
+    Path(path): Path<PullRequestPath>,
+    headers: HeaderMap,
+    body: Bytes,
+) -> Response {
+    let fields = match parse_named_form(&headers, &body, &["csrf", "state"]) {
+        Ok(fields) => fields,
+        Err(()) => return bad_request(&request_id.0),
+    };
+    let actor =
+        match authenticate_mutation(state.clone(), &headers, &fields[0], &request_id.0).await {
+            Ok(actor) => actor,
+            Err(response) => return response,
+        };
+    let Some(service) = state.pull_requests.clone() else {
+        return internal(&request_id.0);
+    };
+    let owner = path.owner.clone();
+    let repository = path.repository.clone();
+    let number = path.number;
+    let result = job(state, move || {
+        service.set_state(&owner, &repository, number, &actor, &fields[1])
+    })
+    .await;
+    match result {
+        Ok(()) => redirect(&path.owner, &path.repository, path.number),
+        Err(error) => mutation_error(error, &request_id.0),
     }
 }
 
@@ -399,6 +568,7 @@
             "The pull request was not found.",
         ),
         PullRequestError::Number
+        | PullRequestError::State
         | PullRequestError::Revision
         | PullRequestError::Auth(_)
         | PullRequestError::RepositoryName(_) => bad_request(request_id),
@@ -429,6 +599,7 @@
         | PullRequestError::Body
         | PullRequestError::Branch
         | PullRequestError::Number
+        | PullRequestError::State
         | PullRequestError::Unchanged
         | PullRequestError::Revision
         | PullRequestError::ReviewKind
@@ -514,9 +685,25 @@
     number: i64,
 }
 
+#[derive(Clone, Deserialize)]
+struct PullRequestRevisionPath {
+    owner: String,
+    repository: String,
+    number: i64,
+    revision: String,
+}
+
 #[derive(Clone, Default, Deserialize)]
 struct RevisionQuery {
     revision: Option<i64>,
+    reviews_page: Option<usize>,
+    timeline_page: Option<usize>,
+}
+
+#[derive(Clone, Default, Deserialize)]
+struct ListQuery {
+    state: Option<String>,
+    page: Option<usize>,
 }
 
 #[derive(Template)]
@@ -530,6 +717,16 @@
     csrf: &'a str,
     can_create: bool,
     branches: Vec<String>,
+    default_branch: &'a str,
+    state: &'a str,
+    state_all: bool,
+    state_open: bool,
+    state_closed: bool,
+    state_merged: bool,
+    has_previous: bool,
+    has_next: bool,
+    previous_page: usize,
+    next_page: usize,
 }
 
 struct PullRequestListItem<'a> {
@@ -558,6 +755,19 @@
     can_revise: bool,
     can_review: bool,
     can_merge: bool,
+    can_edit: bool,
+    can_change_state: bool,
+    is_open: bool,
+    reviews_page: usize,
+    reviews_has_previous: bool,
+    reviews_has_next: bool,
+    reviews_previous_page: usize,
+    reviews_next_page: usize,
+    timeline_page: usize,
+    timeline_has_previous: bool,
+    timeline_has_next: bool,
+    timeline_previous_page: usize,
+    timeline_next_page: usize,
 }
 
 struct ReviewView<'a> {

src/http/repository_settings.rs

Mode 100644; object 26c76f1dbc38

@@ -1,0 +1,422 @@
+use askama::Template;
+use axum::Router;
+use axum::body::{Body, Bytes};
+use axum::extract::{DefaultBodyLimit, Extension, Path, State};
+use axum::http::{HeaderMap, StatusCode, header};
+use axum::response::Response;
+use axum::routing::{get, post};
+use serde::Deserialize;
+
+use crate::repository::{RepositoryService, RepositoryServiceError};
+use crate::store::{RepositoryCollaboratorRecord, StoreError};
+
+use super::{
+    RequestActor, RequestId, WebState, authenticate_mutation, parse_named_form, render,
+    render_error_with_auth,
+};
+
+const MAX_SETTINGS_REQUEST_BYTES: usize = 4 * 1024;
+
+pub(super) fn routes() -> Router<WebState> {
+    Router::new()
+        .route("/{owner}/{repository}/settings", get(settings))
+        .route(
+            "/{owner}/{repository}/settings/general",
+            post(update_general),
+        )
+        .route(
+            "/{owner}/{repository}/settings/collaborators",
+            post(update_collaborator),
+        )
+        .route(
+            "/{owner}/{repository}/settings/default-branch",
+            post(update_default_branch),
+        )
+        .route("/{owner}/{repository}/settings/rename", post(rename))
+        .route("/{owner}/{repository}/settings/archive", post(archive))
+        .route("/{owner}/{repository}/settings/unarchive", post(unarchive))
+        .layer(DefaultBodyLimit::max(MAX_SETTINGS_REQUEST_BYTES))
+}
+
+async fn settings(
+    State(state): State<WebState>,
+    Extension(request_id): Extension<RequestId>,
+    Extension(actor): Extension<RequestActor>,
+    Path(path): Path<RepositoryPath>,
+    headers: HeaderMap,
+) -> Response {
+    let Some(actor) = actor.0 else {
+        return denied(&request_id.0, false);
+    };
+    if state.repositories.is_none() {
+        return internal(&request_id.0);
+    }
+    let is_owner = actor == path.owner;
+    let owner = path.owner.clone();
+    let repository = path.repository.clone();
+    match job(state, move |service| {
+        service.settings(&owner, &repository, &actor)
+    })
+    .await
+    {
+        Ok(settings) => {
+            let csrf = super::cookie(&headers, super::CSRF_COOKIE).unwrap_or_default();
+            render(
+                StatusCode::OK,
+                &RepositorySettingsTemplate {
+                    request_id: &request_id.0,
+                    signed_in: true,
+                    owner: &settings.repository.owner,
+                    repository: &settings.repository.slug,
+                    description: &settings.description,
+                    visibility: &settings.repository.visibility,
+                    collaborators: &settings.collaborators,
+                    default_branch: &settings.default_branch,
+                    branches: &settings.branches,
+                    is_owner,
+                    csrf: &csrf,
+                },
+            )
+        }
+        Err(RepositoryServiceError::Store(StoreError::PullRequestDenied)) => {
+            denied(&request_id.0, true)
+        }
+        Err(RepositoryServiceError::Store(StoreError::RepositoryNotFound(_, _))) => {
+            not_found(&request_id.0, true)
+        }
+        Err(_) => internal(&request_id.0),
+    }
+}
+
+async fn rename(
+    State(state): State<WebState>,
+    Extension(request_id): Extension<RequestId>,
+    Path(path): Path<RepositoryPath>,
+    headers: HeaderMap,
+    body: Bytes,
+) -> Response {
+    let fields = match parse_named_form(&headers, &body, &["csrf", "new-name"]) {
+        Ok(fields) => fields,
+        Err(()) => return bad_request(&request_id.0),
+    };
+    let actor =
+        match authenticate_mutation(state.clone(), &headers, &fields[0], &request_id.0).await {
+            Ok(actor) => actor,
+            Err(response) => return response,
+        };
+    let owner = path.owner.clone();
+    let repository = path.repository.clone();
+    let new_name = fields[1].clone();
+    match job(state, move |service| {
+        service.rename_for_owner(&owner, &repository, &new_name, &actor)
+    })
+    .await
+    {
+        Ok(()) => Response::builder()
+            .status(StatusCode::SEE_OTHER)
+            .header(
+                header::LOCATION,
+                format!("/{}/{}/settings", path.owner, fields[1]),
+            )
+            .body(Body::empty())
+            .expect("the repository rename redirect is valid"),
+        Err(RepositoryServiceError::Store(StoreError::PullRequestDenied)) => {
+            denied(&request_id.0, true)
+        }
+        Err(
+            RepositoryServiceError::RepositoryName(_)
+            | RepositoryServiceError::Store(StoreError::RepositoryExists(_, _)),
+        ) => bad_request(&request_id.0),
+        Err(RepositoryServiceError::Store(StoreError::RepositoryNotFound(_, _))) => {
+            not_found(&request_id.0, true)
+        }
+        Err(_) => internal(&request_id.0),
+    }
+}
+
+async fn update_general(
+    State(state): State<WebState>,
+    Extension(request_id): Extension<RequestId>,
+    Path(path): Path<RepositoryPath>,
+    headers: HeaderMap,
+    body: Bytes,
+) -> Response {
+    let fields = match parse_named_form(&headers, &body, &["csrf", "description", "visibility"]) {
+        Ok(fields) => fields,
+        Err(()) => return bad_request(&request_id.0),
+    };
+    let actor =
+        match authenticate_mutation(state.clone(), &headers, &fields[0], &request_id.0).await {
+            Ok(actor) => actor,
+            Err(response) => return response,
+        };
+    let owner = path.owner.clone();
+    let repository = path.repository.clone();
+    let result = job(state, move |service| {
+        service.update_settings(&owner, &repository, &actor, &fields[1], &fields[2])
+    })
+    .await;
+    mutation_result(result, &request_id.0, &path)
+}
+
+async fn update_collaborator(
+    State(state): State<WebState>,
+    Extension(request_id): Extension<RequestId>,
+    Path(path): Path<RepositoryPath>,
+    headers: HeaderMap,
+    body: Bytes,
+) -> Response {
+    let fields = match parse_named_form(&headers, &body, &["csrf", "username", "role", "action"]) {
+        Ok(fields) => fields,
+        Err(()) => return bad_request(&request_id.0),
+    };
+    let actor =
+        match authenticate_mutation(state.clone(), &headers, &fields[0], &request_id.0).await {
+            Ok(actor) => actor,
+            Err(response) => return response,
+        };
+    let role = match fields[3].as_str() {
+        "set" => Some(fields[2].clone()),
+        "remove" => None,
+        _ => return bad_request(&request_id.0),
+    };
+    let owner = path.owner.clone();
+    let repository = path.repository.clone();
+    let username = fields[1].clone();
+    let result = job(state, move |service| {
+        service.update_collaborator(&owner, &repository, &actor, &username, role.as_deref())
+    })
+    .await;
+    mutation_result(result, &request_id.0, &path)
+}
+
+async fn update_default_branch(
+    State(state): State<WebState>,
+    Extension(request_id): Extension<RequestId>,
+    Path(path): Path<RepositoryPath>,
+    headers: HeaderMap,
+    body: Bytes,
+) -> Response {
+    let fields = match parse_named_form(&headers, &body, &["csrf", "default-branch"]) {
+        Ok(fields) => fields,
+        Err(()) => return bad_request(&request_id.0),
+    };
+    let actor =
+        match authenticate_mutation(state.clone(), &headers, &fields[0], &request_id.0).await {
+            Ok(actor) => actor,
+            Err(response) => return response,
+        };
+    let owner = path.owner.clone();
+    let repository = path.repository.clone();
+    let result = job(state, move |service| {
+        service.update_default_branch(&owner, &repository, &actor, &fields[1])
+    })
+    .await;
+    mutation_result(result, &request_id.0, &path)
+}
+
+async fn archive(
+    State(state): State<WebState>,
+    Extension(request_id): Extension<RequestId>,
+    Path(path): Path<RepositoryPath>,
+    headers: HeaderMap,
+    body: Bytes,
+) -> Response {
+    let fields = match parse_named_form(&headers, &body, &["csrf", "confirm"]) {
+        Ok(fields) => fields,
+        Err(()) => return bad_request(&request_id.0),
+    };
+    let actor =
+        match authenticate_mutation(state.clone(), &headers, &fields[0], &request_id.0).await {
+            Ok(actor) => actor,
+            Err(response) => return response,
+        };
+    if fields[1] != "yes" {
+        return redirect(&path);
+    }
+    let owner = path.owner.clone();
+    let repository = path.repository.clone();
+    match job(state, move |service| {
+        service.archive_for_actor(&owner, &repository, &actor)
+    })
+    .await
+    {
+        Ok(()) => Response::builder()
+            .status(StatusCode::SEE_OTHER)
+            .header(header::LOCATION, "/")
+            .body(Body::empty())
+            .expect("the archive redirect is valid"),
+        Err(RepositoryServiceError::Store(StoreError::PullRequestDenied)) => {
+            denied(&request_id.0, true)
+        }
+        Err(_) => internal(&request_id.0),
+    }
+}
+
+async fn unarchive(
+    State(state): State<WebState>,
+    Extension(request_id): Extension<RequestId>,
+    Path(path): Path<RepositoryPath>,
+    headers: HeaderMap,
+    body: Bytes,
+) -> Response {
+    let fields = match parse_named_form(&headers, &body, &["csrf", "confirm"]) {
+        Ok(fields) => fields,
+        Err(()) => return bad_request(&request_id.0),
+    };
+    let actor =
+        match authenticate_mutation(state.clone(), &headers, &fields[0], &request_id.0).await {
+            Ok(actor) => actor,
+            Err(response) => return response,
+        };
+    if fields[1] != "yes" {
+        return Response::builder()
+            .status(StatusCode::SEE_OTHER)
+            .header(header::LOCATION, "/")
+            .body(Body::empty())
+            .expect("the unarchive cancellation redirect is valid");
+    }
+    let owner = path.owner.clone();
+    let repository = path.repository.clone();
+    match job(state, move |service| {
+        service.unarchive_for_owner(&owner, &repository, &actor)
+    })
+    .await
+    {
+        Ok(()) => Response::builder()
+            .status(StatusCode::SEE_OTHER)
+            .header(
+                header::LOCATION,
+                format!("/{}/{}", path.owner, path.repository),
+            )
+            .body(Body::empty())
+            .expect("the unarchive redirect is valid"),
+        Err(RepositoryServiceError::Store(StoreError::PullRequestDenied)) => {
+            denied(&request_id.0, true)
+        }
+        Err(RepositoryServiceError::Store(StoreError::RepositoryNotFound(_, _))) => {
+            not_found(&request_id.0, true)
+        }
+        Err(_) => bad_request(&request_id.0),
+    }
+}
+
+async fn job<T: Send + 'static>(
+    state: WebState,
+    operation: impl FnOnce(RepositoryService) -> Result<T, RepositoryServiceError> + Send + 'static,
+) -> Result<T, RepositoryServiceError> {
+    let service = state.repositories.clone().ok_or_else(|| {
+        RepositoryServiceError::Store(StoreError::Integrity(
+            "repository service is unavailable".to_owned(),
+        ))
+    })?;
+    let permit = state.jobs.acquire_owned().await.map_err(|_| {
+        RepositoryServiceError::Store(StoreError::Integrity("Web work queue is closed".to_owned()))
+    })?;
+    tokio::task::spawn_blocking(move || {
+        let _permit = permit;
+        operation(service)
+    })
+    .await
+    .map_err(|_| {
+        RepositoryServiceError::Store(StoreError::Integrity("Web task stopped".to_owned()))
+    })?
+}
+
+fn mutation_result(
+    result: Result<(), RepositoryServiceError>,
+    request_id: &str,
+    path: &RepositoryPath,
+) -> Response {
+    match result {
+        Ok(()) => redirect(path),
+        Err(RepositoryServiceError::Store(StoreError::PullRequestDenied)) => {
+            denied(request_id, true)
+        }
+        Err(
+            RepositoryServiceError::Description
+            | RepositoryServiceError::Auth(_)
+            | RepositoryServiceError::Git(_)
+            | RepositoryServiceError::Store(
+                StoreError::InvalidRepositoryVisibility
+                | StoreError::InvalidCollaboratorRole
+                | StoreError::CollaboratorNotFound(_)
+                | StoreError::OwnerCollaborator,
+            ),
+        ) => bad_request(request_id),
+        Err(_) => internal(request_id),
+    }
+}
+
+fn redirect(path: &RepositoryPath) -> Response {
+    Response::builder()
+        .status(StatusCode::SEE_OTHER)
+        .header(
+            header::LOCATION,
+            format!("/{}/{}/settings", path.owner, path.repository),
+        )
+        .body(Body::empty())
+        .expect("the repository settings redirect is valid")
+}
+
+fn denied(request_id: &str, signed_in: bool) -> Response {
+    render_error_with_auth(
+        StatusCode::FORBIDDEN,
+        request_id,
+        "Settings denied",
+        "You cannot change this repository.",
+        signed_in,
+    )
+}
+
+fn not_found(request_id: &str, signed_in: bool) -> Response {
+    render_error_with_auth(
+        StatusCode::NOT_FOUND,
+        request_id,
+        "Repository not found",
+        "The repository does not exist.",
+        signed_in,
+    )
+}
+
+fn bad_request(request_id: &str) -> Response {
+    render_error_with_auth(
+        StatusCode::BAD_REQUEST,
+        request_id,
+        "Settings error",
+        "The repository settings are not valid.",
+        true,
+    )
+}
+
+fn internal(request_id: &str) -> Response {
+    render_error_with_auth(
+        StatusCode::INTERNAL_SERVER_ERROR,
+        request_id,
+        "Settings error",
+        "The repository settings could not be read.",
+        true,
+    )
+}
+
+#[derive(Deserialize)]
+struct RepositoryPath {
+    owner: String,
+    repository: String,
+}
+
+#[derive(Template)]
+#[template(path = "repository-settings.html")]
+struct RepositorySettingsTemplate<'a> {
+    request_id: &'a str,
+    signed_in: bool,
+    owner: &'a str,
+    repository: &'a str,
+    description: &'a str,
+    visibility: &'a str,
+    collaborators: &'a [RepositoryCollaboratorRecord],
+    default_branch: &'a str,
+    branches: &'a [String],
+    is_owner: bool,
+    csrf: &'a str,
+}

src/http/watches.rs

Mode 100644100644; object 012a82e0512db6b596449317

@@ -7,7 +7,7 @@
 use axum::routing::get;
 use serde::Deserialize;
 
-use crate::store::{StoreError, WatchPreferences};
+use crate::store::StoreError;
 use crate::watch::WatchError;
 
 use super::{
@@ -44,17 +44,7 @@
     match result {
         Ok((record, watch)) => {
             let csrf = cookie(&headers, CSRF_COOKIE).unwrap_or_default();
-            let preferences = watch
-                .map(|record| WatchPreferences {
-                    pushes: record.pushes,
-                    issues: record.issues,
-                    pull_requests: record.pull_requests,
-                })
-                .unwrap_or(WatchPreferences {
-                    pushes: false,
-                    issues: false,
-                    pull_requests: false,
-                });
+            let watching = watch.is_some();
             render(
                 StatusCode::OK,
                 &WatchTemplate {
@@ -64,10 +54,7 @@
                     repository: &record.slug,
                     csrf: &csrf,
                     can_change: authenticated && !csrf.is_empty(),
-                    pushes: preferences.pushes,
-                    issues: preferences.issues,
-                    pull_requests: preferences.pull_requests,
-                    watching: preferences.any(),
+                    watching,
                 },
             )
         }
@@ -82,24 +69,13 @@
     headers: HeaderMap,
     body: Bytes,
 ) -> Response {
-    let fields = match parse_named_form(
-        &headers,
-        &body,
-        &["csrf", "pushes", "issues", "pull-requests"],
-    ) {
+    let fields = match parse_named_form(&headers, &body, &["csrf", "state"]) {
         Ok(fields) => fields,
         Err(()) => return watch_bad_request(&request_id.0),
     };
-    let preferences = match (
-        preference(&fields[1]),
-        preference(&fields[2]),
-        preference(&fields[3]),
-    ) {
-        (Ok(pushes), Ok(issues), Ok(pull_requests)) => WatchPreferences {
-            pushes,
-            issues,
-            pull_requests,
-        },
+    let watching = match fields[1].as_str() {
+        "watch" => true,
+        "unwatch" => false,
         _ => return watch_bad_request(&request_id.0),
     };
     let actor =
@@ -113,7 +89,7 @@
     let owner = path.owner.clone();
     let repository = path.repository.clone();
     let result = watch_job(state, move || {
-        service.set(&owner, &repository, &actor, preferences)
+        service.set(&owner, &repository, &actor, watching)
     })
     .await;
     match result {
@@ -145,14 +121,6 @@
     })
     .await
     .map_err(|_| WatchError::Store(StoreError::Integrity("watch worker failed".to_owned())))?
-}
-
-fn preference(value: &str) -> Result<bool, ()> {
-    match value {
-        "0" => Ok(false),
-        "1" => Ok(true),
-        _ => Err(()),
-    }
 }
 
 fn watch_error(error: WatchError, request_id: &str) -> Response {
@@ -204,8 +172,5 @@
     repository: &'a str,
     csrf: &'a str,
     can_change: bool,
-    pushes: bool,
-    issues: bool,
-    pull_requests: bool,
     watching: bool,
 }

src/issue.rs

Mode 100644100644; object fb9d63952072430e2843e7d9

@@ -6,12 +6,13 @@
 use crate::auth::{AuthError, validate_username};
 use crate::domain::repository::{RepositoryNameError, validate_slug};
 use crate::store::{
-    IssueChange, IssueDetail, IssueRecord, NewIssue, RepositoryRecord, Store, StoreError,
+    IssueChange, IssueDetail, IssueRecord, NewIssue, RecordPage, RepositoryRecord, Store,
+    StoreError,
 };
 
 pub(crate) const MAX_TITLE_BYTES: usize = 200;
 pub(crate) const MAX_BODY_BYTES: usize = 256 * 1024;
-const MAX_LABEL_BYTES: usize = 80;
+pub(crate) const PAGE_SIZE: usize = 50;
 
 #[derive(Clone)]
 pub(crate) struct IssueService {
@@ -61,18 +62,55 @@
             .map_err(Into::into)
     }
 
-    pub(crate) fn get(
+    pub(crate) fn list_page(
+        &self,
+        owner: &str,
+        repository: &str,
+        actor: Option<&str>,
+        state: &str,
+        page: usize,
+    ) -> Result<(RepositoryRecord, RecordPage<IssueRecord>), IssueError> {
+        validate_context(owner, repository, actor)?;
+        validate_list(state, page)?;
+        let result = Store::open(&self.database)?
+            .issue_page(owner, repository, actor, state, page, PAGE_SIZE)
+            .map_err(IssueError::from)?;
+        if page > 1 && result.1.items.is_empty() {
+            return Err(IssueError::State);
+        }
+        Ok(result)
+    }
+
+    pub(crate) fn get_page(
         &self,
         owner: &str,
         repository: &str,
         number: i64,
         actor: Option<&str>,
+        comments_page: usize,
+        timeline_page: usize,
     ) -> Result<IssueDetail, IssueError> {
         validate_context(owner, repository, actor)?;
         validate_number(number)?;
-        Store::open(&self.database)?
-            .issue_detail(owner, repository, number, actor)
-            .map_err(Into::into)
+        validate_page(comments_page)?;
+        validate_page(timeline_page)?;
+        let detail = Store::open(&self.database)?
+            .issue_detail(
+                owner,
+                repository,
+                number,
+                actor,
+                comments_page,
+                timeline_page,
+                PAGE_SIZE,
+            )
+            .map_err(IssueError::from)?;
+        if (comments_page > 1 && detail.comments.is_empty())
+            || (timeline_page > 1 && detail.timeline.is_empty())
+        {
+            return Err(IssueError::Number);
+        }
+        Ok(detail)
     }
 
     pub(crate) fn edit(
@@ -136,60 +174,6 @@
             .set_issue_state(owner, repository, number, actor, state, timestamp()?)
             .map_err(Into::into)
     }
-
-    pub(crate) fn set_label(
-        &self,
-        owner: &str,
-        repository: &str,
-        number: i64,
-        actor: &str,
-        label: &str,
-        present: bool,
-    ) -> Result<(), IssueError> {
-        validate_context(owner, repository, Some(actor))?;
-        validate_number(number)?;
-        validate_label(label)?;
-        Store::open(&self.database)?
-            .set_issue_label(
-                &IssueChange {
-                    owner,
-                    repository,
-                    number,
-                    actor,
-                    changed_at: timestamp()?,
-                },
-                label,
-                present,
-            )
-            .map_err(Into::into)
-    }
-
-    pub(crate) fn set_assignee(
-        &self,
-        owner: &str,
-        repository: &str,
-        number: i64,
-        actor: &str,
-        assignee: &str,
-        present: bool,
-    ) -> Result<(), IssueError> {
-        validate_context(owner, repository, Some(actor))?;
-        validate_number(number)?;
-        validate_username(assignee)?;
-        Store::open(&self.database)?
-            .set_issue_assignee(
-                &IssueChange {
-                    owner,
-                    repository,
-                    number,
-                    actor,
-                    changed_at: timestamp()?,
-                },
-                assignee,
-                present,
-            )
-            .map_err(Into::into)
-    }
 }
 
 fn validate_context(owner: &str, repository: &str, actor: Option<&str>) -> Result<(), IssueError> {
@@ -203,6 +187,20 @@
 
 fn validate_number(number: i64) -> Result<(), IssueError> {
     if number < 1 {
+        return Err(IssueError::Number);
+    }
+    Ok(())
+}
+
+fn validate_list(state: &str, page: usize) -> Result<(), IssueError> {
+    if !matches!(state, "all" | "open" | "closed") || page == 0 || page > 10_000 {
+        return Err(IssueError::State);
+    }
+    Ok(())
+}
+
+fn validate_page(page: usize) -> Result<(), IssueError> {
+    if page == 0 || page > 10_000 {
         return Err(IssueError::Number);
     }
     Ok(())
@@ -231,17 +229,6 @@
     Ok(())
 }
 
-fn validate_label(label: &str) -> Result<(), IssueError> {
-    if label.is_empty()
-        || label.len() > MAX_LABEL_BYTES
-        || label.trim() != label
-        || label.chars().any(char::is_control)
-    {
-        return Err(IssueError::Label);
-    }
-    Ok(())
-}
-
 fn timestamp() -> Result<i64, IssueError> {
     let seconds = SystemTime::now()
         .duration_since(UNIX_EPOCH)
@@ -266,8 +253,6 @@
     Body,
     #[error("issue state is not valid")]
     State,
-    #[error("issue label is not valid")]
-    Label,
     #[error("system time is not valid")]
     Clock,
 }

src/main.rs

Mode 100644100644; object c0f22b60f8266f26ca429386

@@ -42,8 +42,8 @@
 use clap::Parser;
 
 use crate::cli::{
-    AccountCommand, AdminCommand, Cli, CollaboratorRole, Command, InspectCommand, ObjectFormat,
-    RepairCommand, RepositoryCommand, RepositoryVisibility, SetupCommand,
+    AccountCommand, AdminCommand, Cli, CollaboratorRole, Command, InspectCommand, RepairCommand,
+    RepositoryCommand, RepositoryVisibility, SetupCommand,
 };
 
 #[tokio::main]
@@ -102,6 +102,25 @@
                 };
                 match result {
                     Ok(()) => ExitCode::SUCCESS,
+                    Err(error) => {
+                        eprintln!("tit: {error}");
+                        ExitCode::FAILURE
+                    }
+                }
+            }
+            Some(Command::Maintenance { retention_days }) => {
+                match admin::maintain(&config.instance_dir, retention_days) {
+                    Ok(result) => match writeln!(
+                        io::stdout().lock(),
+                        "Pruned {} terminal records.",
+                        result.deleted
+                    ) {
+                        Ok(()) => ExitCode::SUCCESS,
+                        Err(error) => {
+                            eprintln!("tit: cannot write maintenance information: {error}");
+                            ExitCode::FAILURE
+                        }
+                    },
                     Err(error) => {
                         eprintln!("tit: {error}");
                         ExitCode::FAILURE
@@ -352,19 +371,9 @@
 
 fn run_repository_command(instance_dir: &std::path::Path, command: RepositoryCommand) -> ExitCode {
     let result = match command {
-        RepositoryCommand::Create {
-            owner,
-            slug,
-            object_format,
-        } => admin::create_repository(
-            instance_dir,
-            &owner,
-            &slug,
-            match object_format {
-                ObjectFormat::Sha1 => gix::hash::Kind::Sha1,
-                ObjectFormat::Sha256 => gix::hash::Kind::Sha256,
-            },
-        ),
+        RepositoryCommand::Create { owner, slug } => {
+            admin::create_repository(instance_dir, &owner, &slug, gix::hash::Kind::Sha1)
+        }
         RepositoryCommand::Import {
             owner,
             slug,
@@ -435,7 +444,6 @@
                 .and_then(|()| writeln!(output, "slug={}", repository.slug))
                 .and_then(|()| writeln!(output, "visibility={}", repository.visibility))
                 .and_then(|()| writeln!(output, "state={}", repository.state))
-                .and_then(|()| writeln!(output, "object-format={}", repository.object_format))
                 .and_then(|()| writeln!(output, "created-at={}", repository.created_at))
                 .and_then(|()| writeln!(output, "archived-at={archived_at}"))
                 .and_then(|()| writeln!(output, "path={}", path.display()));

src/policy.rs

Mode 100644100644; object 56332a733de9f82876ac41f3

@@ -40,15 +40,13 @@
         ref_name: &[u8],
         change: RefChange,
     ) -> Result<RepositoryRecord, PolicyError> {
-        let record = Store::open(&self.database)?.repository_authorization(
-            owner,
-            repository,
-            Some(actor),
-        )?;
+        let store = Store::open(&self.database)?;
+        let record = store.repository_authorization(owner, repository, Some(actor))?;
         if !allows(&record, RepositoryOperation::Write)? {
             return Err(PolicyError::Denied);
         }
-        let protected = ref_name == b"refs/heads/main";
+        let default_branch = store.repository_default_branch(owner, repository)?;
+        let protected = ref_name == default_branch.as_bytes();
         if matches!(change, RefChange::Force)
             || (protected && matches!(change, RefChange::Delete))
             || (protected && !allows(&record, RepositoryOperation::Maintain)?)

src/pull_request.rs

Mode 100644100644; object 3925f75a7a876bc529256155

@@ -18,12 +18,13 @@
 use crate::store::{
     GitOperationIntent, NewPullRequestMerge, NewPullRequestRefIntent, NewPullRequestReview,
     PullRequestDetail, PullRequestRecord, PullRequestRefIntentRecord, PullRequestRevisionRecord,
-    Store, StoreError,
+    RecordPage, RepositoryRecord, Store, StoreError,
 };
 
 pub(crate) const MAX_TITLE_BYTES: usize = 200;
 pub(crate) const MAX_BODY_BYTES: usize = 256 * 1024;
 pub(crate) const MAX_REVIEW_BODY_BYTES: usize = 256 * 1024;
+pub(crate) const PAGE_SIZE: usize = 50;
 
 #[derive(Clone)]
 pub(crate) struct PullRequestService {
@@ -137,6 +138,9 @@
         let current = Store::open(&self.database)?
             .pull_request(owner, repository, number, Some(actor))?
             .pull_request;
+        if current.state != "open" {
+            return Err(StoreError::PullRequestState.into());
+        }
         let authorization = Store::open(&self.database)?.repository_authorization(
             owner,
             repository,
@@ -180,6 +184,88 @@
 
     #[allow(
         dead_code,
+        reason = "the pull-request integration test imports this module without the Web list route"
+    )]
+    pub(crate) fn list_page(
+        &self,
+        owner: &str,
+        repository: &str,
+        actor: Option<&str>,
+        state: &str,
+        page: usize,
+    ) -> Result<(RepositoryRecord, RecordPage<PullRequestRecord>, bool), PullRequestError> {
+        validate_username(owner)?;
+        validate_slug(repository)?;
+        if let Some(actor) = actor {
+            validate_username(actor)?;
+        }
+        if !matches!(state, "all" | "open" | "closed" | "merged") || page == 0 || page > 10_000 {
+            return Err(PullRequestError::State);
+        }
+        let _maintenance = self.maintenance.mutation();
+        let _operation = self
+            .operations
+            .lock()
+            .unwrap_or_else(std::sync::PoisonError::into_inner);
+        self.recover_inner()?;
+        let result = Store::open(&self.database)?
+            .pull_request_page(owner, repository, actor, state, page, PAGE_SIZE)
+            .map_err(PullRequestError::from)?;
+        if page > 1 && result.1.items.is_empty() {
+            return Err(PullRequestError::State);
+        }
+        Ok(result)
+    }
+
+    #[allow(
+        dead_code,
+        reason = "the pull-request integration test imports this module without the Web edit route"
+    )]
+    pub(crate) fn edit(
+        &self,
+        owner: &str,
+        repository: &str,
+        number: i64,
+        actor: &str,
+        title: &str,
+        body: &str,
+    ) -> Result<(), PullRequestError> {
+        validate_context(owner, repository, actor)?;
+        if number < 1 {
+            return Err(PullRequestError::Number);
+        }
+        validate_content(title, body)?;
+        Store::open(&self.database)?
+            .edit_pull_request(owner, repository, number, actor, title, body, timestamp()?)
+            .map_err(Into::into)
+    }
+
+    #[allow(
+        dead_code,
+        reason = "the pull-request integration test imports this module without the Web state route"
+    )]
+    pub(crate) fn set_state(
+        &self,
+        owner: &str,
+        repository: &str,
+        number: i64,
+        actor: &str,
+        state: &str,
+    ) -> Result<(), PullRequestError> {
+        validate_context(owner, repository, actor)?;
+        if number < 1 {
+            return Err(PullRequestError::Number);
+        }
+        if !matches!(state, "open" | "closed") {
+            return Err(PullRequestError::State);
+        }
+        Store::open(&self.database)?
+            .set_pull_request_state(owner, repository, number, actor, state, timestamp()?)
+            .map_err(Into::into)
+    }
+
+    #[allow(
+        dead_code,
         reason = "integration tests and later non-Web callers read pull requests without comparison"
     )]
     pub(crate) fn get(
@@ -208,6 +294,10 @@
             .map_err(Into::into)
     }
 
+    #[allow(
+        dead_code,
+        reason = "the binary uses the paged Web comparison and integration tests use the full comparison"
+    )]
     pub(crate) fn compare(
         &self,
         owner: &str,
@@ -216,12 +306,58 @@
         revision: Option<i64>,
         actor: Option<&str>,
     ) -> Result<PullRequestComparison, PullRequestError> {
+        self.compare_with_activity(owner, repository, number, revision, actor, None)
+    }
+
+    #[allow(
+        clippy::too_many_arguments,
+        dead_code,
+        reason = "the pull-request integration test imports this module without the Web detail route"
+    )]
+    pub(crate) fn compare_page(
+        &self,
+        owner: &str,
+        repository: &str,
+        number: i64,
+        revision: Option<i64>,
+        actor: Option<&str>,
+        reviews_page: usize,
+        timeline_page: usize,
+    ) -> Result<PullRequestComparison, PullRequestError> {
+        self.compare_with_activity(
+            owner,
+            repository,
+            number,
+            revision,
+            actor,
+            Some((reviews_page, timeline_page)),
+        )
+    }
+
+    #[allow(
+        clippy::too_many_arguments,
+        reason = "the paged comparison includes repository identity and independent activity pages"
+    )]
+    fn compare_with_activity(
+        &self,
+        owner: &str,
+        repository: &str,
+        number: i64,
+        revision: Option<i64>,
+        actor: Option<&str>,
+        activity: Option<(usize, usize)>,
+    ) -> Result<PullRequestComparison, PullRequestError> {
         validate_username(owner)?;
         validate_slug(repository)?;
         if let Some(actor) = actor {
             validate_username(actor)?;
         }
-        if number < 1 || revision.is_some_and(|number| number < 1) {
+        if number < 1
+            || revision.is_some_and(|number| number < 1)
+            || activity.is_some_and(|(reviews, timeline)| {
+                reviews == 0 || reviews > 10_000 || timeline == 0 || timeline > 10_000
+            })
+        {
             return Err(PullRequestError::Number);
         }
         let _maintenance = self.maintenance.mutation();
@@ -230,7 +366,25 @@
             .lock()
             .unwrap_or_else(std::sync::PoisonError::into_inner);
         self.recover_inner()?;
-        let detail = Store::open(&self.database)?.pull_request(owner, repository, number, actor)?;
+        let store = Store::open(&self.database)?;
+        let detail = match activity {
+            Some((reviews_page, timeline_page)) => store.pull_request_detail_page(
+                owner,
+                repository,
+                number,
+                actor,
+                reviews_page,
+                timeline_page,
+                PAGE_SIZE,
+            )?,
+            None => store.pull_request(owner, repository, number, actor)?,
+        };
+        if activity.is_some_and(|(reviews_page, timeline_page)| {
+            (reviews_page > 1 && detail.reviews.is_empty())
+                || (timeline_page > 1 && detail.timeline.is_empty())
+        }) {
+            return Err(PullRequestError::Number);
+        }
         let revision = match revision {
             Some(number) => detail
                 .revisions
@@ -710,6 +864,12 @@
     Branch,
     #[error("pull-request number is not valid")]
     Number,
+    #[error("pull-request list state is not valid")]
+    #[allow(
+        dead_code,
+        reason = "the pull-request integration test imports this module without Web list state"
+    )]
+    State,
     #[error("pull-request revision does not exist")]
     Revision,
     #[error("pull-request review kind is not valid")]

src/repository.rs

Mode 100644100644; object bfd8fb2bf0086295db17cd3d

@@ -11,11 +11,12 @@
 use crate::git::repository::{GitRepository, GitRepositoryError};
 use crate::maintenance::MaintenanceGate;
 use crate::store::{
-    HomeRepositoryRecord, NewAuditEvent, NewRepository, RepositoryOrigin, RepositoryRecord, Store,
-    StoreError,
+    HomeRepositoryRecord, NewAuditEvent, NewRepository, RepositoryOrigin, RepositoryRecord,
+    RepositorySettings, Store, StoreError,
 };
 
 const HOME_REPOSITORY_LIMIT: usize = 20;
+pub(crate) const MAX_DESCRIPTION_BYTES: usize = 512;
 
 #[derive(Clone)]
 pub(crate) struct RepositoryService {
@@ -56,6 +57,181 @@
         owner: Option<&str>,
     ) -> Result<Vec<HomeRepositoryRecord>, RepositoryServiceError> {
         Ok(Store::open(&self.database)?.home_repositories(owner, HOME_REPOSITORY_LIMIT)?)
+    }
+
+    pub(crate) fn default_branch(
+        &self,
+        owner: &str,
+        repository: &str,
+    ) -> Result<String, RepositoryServiceError> {
+        validate_username(owner)?;
+        validate_slug(repository)?;
+        Store::open(&self.database)?
+            .repository_default_branch(owner, repository)
+            .map_err(Into::into)
+    }
+
+    pub(crate) fn settings(
+        &self,
+        owner: &str,
+        repository: &str,
+        actor: &str,
+    ) -> Result<RepositorySettings, RepositoryServiceError> {
+        validate_username(owner)?;
+        validate_slug(repository)?;
+        validate_username(actor)?;
+        let mut settings = Store::open(&self.database)?
+            .repository_settings(owner, repository, actor)
+            .map_err(RepositoryServiceError::from)?;
+        settings.branches =
+            GitRepository::open(&self.root.join(format!("{}.git", settings.repository.id)))?
+                .references()?
+                .into_iter()
+                .filter(|reference| reference.name.starts_with(b"refs/heads/"))
+                .filter_map(|reference| String::from_utf8(reference.name).ok())
+                .collect();
+        Ok(settings)
+    }
+
+    pub(crate) fn update_default_branch(
+        &self,
+        owner: &str,
+        repository: &str,
+        actor: &str,
+        default_branch: &str,
+    ) -> Result<(), RepositoryServiceError> {
+        validate_username(owner)?;
+        validate_slug(repository)?;
+        validate_username(actor)?;
+        let _maintenance = self.maintenance.mutation();
+        let mut store = Store::open(&self.database)?;
+        let settings = store.repository_settings(owner, repository, actor)?;
+        let git = GitRepository::open(&self.root.join(format!("{}.git", settings.repository.id)))?;
+        let previous = settings.default_branch;
+        git.set_default_branch(default_branch)?;
+        let result = store.update_repository_default_branch(
+            owner,
+            repository,
+            actor,
+            default_branch,
+            timestamp()?,
+            &random_id()?,
+        );
+        if result.is_err() {
+            let _ = git.set_default_branch(&previous);
+        }
+        result?;
+        Ok(())
+    }
+
+    pub(crate) fn update_settings(
+        &self,
+        owner: &str,
+        repository: &str,
+        actor: &str,
+        description: &str,
+        visibility: &str,
+    ) -> Result<(), RepositoryServiceError> {
+        validate_username(owner)?;
+        validate_slug(repository)?;
+        validate_username(actor)?;
+        validate_description(description)?;
+        let mut store = Store::open(&self.database)?;
+        store.update_repository_settings(
+            owner,
+            repository,
+            actor,
+            description,
+            visibility,
+            timestamp()?,
+            &random_id()?,
+        )?;
+        Ok(())
+    }
+
+    pub(crate) fn update_collaborator(
+        &self,
+        owner: &str,
+        repository: &str,
+        actor: &str,
+        username: &str,
+        role: Option<&str>,
+    ) -> Result<(), RepositoryServiceError> {
+        validate_username(owner)?;
+        validate_slug(repository)?;
+        validate_username(actor)?;
+        validate_username(username)?;
+        let mut store = Store::open(&self.database)?;
+        store.update_repository_collaborator(
+            owner,
+            repository,
+            actor,
+            username,
+            role,
+            timestamp()?,
+            &random_id()?,
+        )?;
+        Ok(())
+    }
+
+    pub(crate) fn archive_for_actor(
+        &self,
+        owner: &str,
+        repository: &str,
+        actor: &str,
+    ) -> Result<(), RepositoryServiceError> {
+        validate_username(owner)?;
+        validate_slug(repository)?;
+        validate_username(actor)?;
+        Store::open(&self.database)?.archive_repository_for_actor(
+            owner,
+            repository,
+            actor,
+            timestamp()?,
+            &random_id()?,
+        )?;
+        Ok(())
+    }
+
+    pub(crate) fn rename_for_owner(
+        &self,
+        owner: &str,
+        repository: &str,
+        new_name: &str,
+        actor: &str,
+    ) -> Result<(), RepositoryServiceError> {
+        validate_username(owner)?;
+        validate_slug(repository)?;
+        validate_slug(new_name)?;
+        validate_username(actor)?;
+        Store::open(&self.database)?.rename_repository_for_owner(
+            owner,
+            repository,
+            new_name,
+            actor,
+            timestamp()?,
+            &random_id()?,
+        )?;
+        Ok(())
+    }
+
+    pub(crate) fn unarchive_for_owner(
+        &self,
+        owner: &str,
+        repository: &str,
+        actor: &str,
+    ) -> Result<(), RepositoryServiceError> {
+        validate_username(owner)?;
+        validate_slug(repository)?;
+        validate_username(actor)?;
+        Store::open(&self.database)?.unarchive_repository_for_owner(
+            owner,
+            repository,
+            actor,
+            timestamp()?,
+            &random_id()?,
+        )?;
+        Ok(())
     }
 
     pub(crate) fn create_for_administrator(
@@ -156,6 +332,7 @@
             owner,
             slug,
             object_format: object_format_name,
+            default_branch: "refs/heads/main",
             created_at,
             origin: RepositoryOrigin::Created,
             initial_references: &[],
@@ -193,6 +370,17 @@
         .as_secs()
         .try_into()
         .map_err(|_| RepositoryServiceError::Clock)
+}
+
+fn validate_description(description: &str) -> Result<(), RepositoryServiceError> {
+    if description.len() > MAX_DESCRIPTION_BYTES
+        || description
+            .chars()
+            .any(|character| character.is_control() && character != '\n')
+    {
+        return Err(RepositoryServiceError::Description);
+    }
+    Ok(())
 }
 
 fn object_format_name(kind: Kind) -> Result<&'static str, RepositoryServiceError> {
@@ -238,6 +426,8 @@
     PathEscape(PathBuf),
     #[error("random repository ID collision")]
     IdentifierCollision,
+    #[error("repository description is not valid")]
+    Description,
     #[error("cannot create a random repository ID")]
     Random,
     #[error("system clock is before the Unix epoch")]

src/search.rs

Mode 100644100644; object bafe8d66532ebfa525ac6c74

@@ -46,11 +46,8 @@
         let mut seen = BTreeSet::new();
         let mut results = Vec::new();
         let mut candidate_error = None;
-        let truncated = store.visit_metadata_search_candidates(
-            actor,
-            MAX_SCAN_ROWS,
-            MAX_SCAN_BYTES,
-            |candidate| {
+        let truncated =
+            store.visit_metadata_search_candidates(actor, MAX_SCAN_ROWS, |candidate| {
                 if started.elapsed() >= MAX_DURATION {
                     return false;
                 }
@@ -86,8 +83,7 @@
                 }
                 results.push(result);
                 true
-            },
-        )?;
+            })?;
         if let Some(error) = candidate_error {
             return Err(error);
         }
@@ -109,30 +105,13 @@
         String::from_utf8(candidate.title).map_err(|_| MetadataSearchError::StoredCandidate)?;
     let body =
         String::from_utf8(candidate.body).map_err(|_| MetadataSearchError::StoredCandidate)?;
-    let (kind, url, title) = match candidate.kind.as_str() {
-        "repository" => (
-            "Repository",
-            format!("/{}/{}", candidate.owner, candidate.repository),
-            title,
-        ),
-        "issue" | "issue-comment" => {
-            let number = candidate
-                .issue_number
-                .ok_or(MetadataSearchError::StoredCandidate)?;
-            (
-                "Issue",
-                format!(
-                    "/{}/{}/issues/{number}",
-                    candidate.owner, candidate.repository
-                ),
-                format!(
-                    "{}/{} #{number}: {}",
-                    candidate.owner, candidate.repository, title
-                ),
-            )
-        }
-        _ => return Err(MetadataSearchError::StoredCandidate),
-    };
+    if candidate.kind != "repository" {
+        return Err(MetadataSearchError::StoredCandidate);
+    }
+    let (kind, url) = (
+        "Repository",
+        format!("/{}/{}", candidate.owner, candidate.repository),
+    );
     let summary = body
         .split_whitespace()
         .collect::<Vec<_>>()

src/session.rs

Mode 100644100644; object 6249182fd43896fec71f8170

@@ -72,10 +72,44 @@
         Store::open(&self.database)?.create_login_approval(&NewLoginApproval {
             secret_hash: &hash(secret.as_bytes()),
             csrf_hash: &hash(login_csrf.as_bytes()),
+            purpose: "login",
+            expected_username: None,
             created_at,
             expires_at,
         })?;
         Ok(IssuedLoginApproval { secret, login_csrf })
+    }
+
+    #[allow(
+        dead_code,
+        reason = "the Web session integration test imports this module without account routes"
+    )]
+    pub(crate) fn issue_account_approval(
+        &self,
+        username: &str,
+        csrf: &str,
+    ) -> Result<IssuedLoginApproval, SessionError> {
+        crate::auth::validate_username(username)?;
+        validate_token(csrf)?;
+        let created_at = now()?;
+        let expires_at = created_at
+            .checked_add(
+                i64::try_from(CHALLENGE_LIFETIME_SECONDS).map_err(|_| SessionError::Clock)?,
+            )
+            .ok_or(SessionError::Clock)?;
+        let secret = encode_hex(&random_bytes()?);
+        Store::open(&self.database)?.create_login_approval(&NewLoginApproval {
+            secret_hash: &hash(secret.as_bytes()),
+            csrf_hash: &hash(csrf.as_bytes()),
+            purpose: "account-key",
+            expected_username: Some(username),
+            created_at,
+            expires_at,
+        })?;
+        Ok(IssuedLoginApproval {
+            secret,
+            login_csrf: csrf.to_owned(),
+        })
     }
 
     pub(crate) fn approve(

src/ssh.rs

Mode 100644100644; object 7e917cbf9a6379f7d0100527

@@ -1,9 +1,9 @@
 use std::borrow::Cow;
 use std::collections::{HashMap, HashSet};
 use std::net::{IpAddr, Ipv4Addr, SocketAddr};
-use std::sync::atomic::{AtomicUsize, Ordering};
+use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
 use std::sync::{Arc, RwLock};
-use std::time::Duration;
+use std::time::{Duration, Instant};
 
 use rand::rng;
 use russh::server::{Auth, ChannelOpenHandle, Handler, Msg, Server, Session};
@@ -22,6 +22,7 @@
 use crate::git::upload_pack::{ProtocolVersion, UploadPack, UploadPackError};
 use crate::issue::{IssueError, IssueService, MAX_BODY_BYTES, MAX_TITLE_BYTES};
 use crate::policy::RepositoryOperation;
+use crate::pull_request::{PullRequestError, PullRequestService};
 use crate::rate_limit::AttemptLimiter;
 use crate::repository::{RepositoryService, RepositoryServiceError};
 use crate::store::{Store, StoreError};
@@ -29,7 +30,7 @@
 
 const VERSION_COMMAND: &[u8] = b"tit --version";
 const HELP_COMMAND: &[u8] = b"help";
-const LOGIN_COMMAND_PREFIX: &[u8] = b"login ";
+const AUTH_COMMAND_PREFIX: &[u8] = b"auth ";
 const GIT_PROTOCOL_VARIABLE: &str = "GIT_PROTOCOL";
 const MAX_RECEIVE_PACK_BYTES: u64 = 128 * 1024 * 1024;
 const MAX_REPOSITORY_COMMAND_BYTES: usize = 512;
@@ -38,19 +39,40 @@
 const MAX_ISSUE_INPUT_BYTES: usize = MAX_TITLE_BYTES + 1 + MAX_BODY_BYTES;
 const SSH_ATTEMPTS_PER_MINUTE: usize = 30;
 const MAX_SSH_CLIENTS: usize = 4096;
+const MAX_GIT_CHANNELS_PER_CONNECTION: usize = 4;
+const MAX_GIT_CHANNELS_GLOBAL: usize = 64;
+const GIT_CHANNEL_INACTIVITY_LIMIT: Duration = Duration::from_secs(30);
+const GIT_CHANNEL_WALL_CLOCK_LIMIT: Duration = Duration::from_secs(120);
+const MAX_GIT_CHANNEL_BYTES: u64 = 257 * 1024 * 1024;
 const REPOSITORY_CREATE_USAGE: &str = "repo create NAME [--output human|json]";
 const ISSUE_LIST_USAGE: &str = "issue list OWNER/REPOSITORY [--output human|json]";
 const ISSUE_CREATE_USAGE: &str = "issue create OWNER/REPOSITORY [--output human|json]";
+const ISSUE_COMMENT_USAGE: &str = "issue comment OWNER/REPOSITORY NUMBER [--output human|json]";
+const ISSUE_CLOSE_USAGE: &str = "issue close OWNER/REPOSITORY NUMBER [--output human|json]";
+const ISSUE_REOPEN_USAGE: &str = "issue reopen OWNER/REPOSITORY NUMBER [--output human|json]";
+const PULL_REQUEST_LIST_USAGE: &str =
+    "pr list OWNER/REPOSITORY [--state open|closed|merged|all] [--output human|json]";
+const PULL_REQUEST_CREATE_USAGE: &str =
+    "pr create OWNER/REPOSITORY BASE HEAD [--output human|json]";
+const PULL_REQUEST_CLOSE_USAGE: &str = "pr close OWNER/REPOSITORY NUMBER [--output human|json]";
+const PULL_REQUEST_REOPEN_USAGE: &str = "pr reopen OWNER/REPOSITORY NUMBER [--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
-  login ONE-TIME-SECRET
+  auth ONE-TIME-SECRET
   repo create NAME [--output human|json]
   issue list OWNER/REPOSITORY [--output human|json]
   issue create OWNER/REPOSITORY [--output human|json]
+  issue comment OWNER/REPOSITORY NUMBER [--output human|json]
+  issue close OWNER/REPOSITORY NUMBER [--output human|json]
+  issue reopen OWNER/REPOSITORY NUMBER [--output human|json]
+  pr list OWNER/REPOSITORY [--state open|closed|merged|all] [--output human|json]
+  pr create OWNER/REPOSITORY BASE HEAD [--output human|json]
+  pr close OWNER/REPOSITORY NUMBER [--output human|json]
+  pr reopen OWNER/REPOSITORY NUMBER [--output human|json]
   pr checkout OWNER/REPOSITORY NUMBER [--output human|json]
 
 Git clients can also use git-upload-pack and git-receive-pack.
@@ -66,8 +88,8 @@
     login: Option<LoginApprover>,
 }
 
-fn parse_login_command(command: &[u8]) -> Option<String> {
-    let secret = command.strip_prefix(LOGIN_COMMAND_PREFIX)?;
+fn parse_auth_command(command: &[u8]) -> Option<String> {
+    let secret = command.strip_prefix(AUTH_COMMAND_PREFIX)?;
     if secret.len() != 64
         || !secret
             .iter()
@@ -290,6 +312,7 @@
             repositories: repositories.map(Arc::new),
             login: runtime.login,
             connections: Arc::new(Semaphore::new(runtime.max_connections)),
+            git_channels: Arc::new(Semaphore::new(MAX_GIT_CHANNELS_GLOBAL)),
             attempts: AttemptLimiter::new(
                 SSH_ATTEMPTS_PER_MINUTE,
                 Duration::from_secs(60),
@@ -381,6 +404,7 @@
     repositories: Option<Arc<GitRepositories>>,
     login: Option<LoginApprover>,
     connections: Arc<Semaphore>,
+    git_channels: Arc<Semaphore>,
     attempts: AttemptLimiter<IpAddr>,
     telemetry: Telemetry,
 }
@@ -407,6 +431,7 @@
                 .unwrap_or(IpAddr::V4(Ipv4Addr::LOCALHOST)),
             attempts: self.attempts.clone(),
             _connection_permit: self.connections.clone().try_acquire_owned().ok(),
+            git_channels: Arc::clone(&self.git_channels),
             telemetry: self.telemetry.clone(),
             connection_id,
         }
@@ -427,6 +452,7 @@
     peer_address: IpAddr,
     attempts: AttemptLimiter<IpAddr>,
     _connection_permit: Option<OwnedSemaphorePermit>,
+    git_channels: Arc<Semaphore>,
     telemetry: Telemetry,
     connection_id: String,
 }
@@ -434,13 +460,30 @@
 enum ExecChannel {
     Upload(Box<UploadChannel>),
     Receive(Box<ReceiveChannel>),
-    IssueCreate(IssueCreateChannel),
+    IssueInput(IssueInputChannel),
+    PullRequestCreate(PullRequestCreateChannel),
 }
 
-struct IssueCreateChannel {
+enum IssueInputKind {
+    Create,
+    Comment(i64),
+}
+
+struct IssueInputChannel {
     actor: String,
     owner: String,
     repository: String,
+    output: CommandOutput,
+    kind: IssueInputKind,
+    input: Vec<u8>,
+}
+
+struct PullRequestCreateChannel {
+    actor: String,
+    owner: String,
+    repository: String,
+    base: String,
+    head: String,
     output: CommandOutput,
     input: Vec<u8>,
 }
@@ -448,6 +491,11 @@
 struct UploadChannel {
     service: UploadPack,
     request: Vec<u8>,
+    started_at: Instant,
+    last_activity: Instant,
+    transferred: u64,
+    cancelled: Arc<AtomicBool>,
+    _global_permit: OwnedSemaphorePermit,
 }
 
 struct ReceiveChannel {
@@ -461,7 +509,43 @@
     commands_complete: bool,
     pack: tokio::fs::File,
     pack_bytes: u64,
-    maintenance: tokio::sync::OwnedRwLockReadGuard<()>,
+    started_at: Instant,
+    last_activity: Instant,
+    transferred: u64,
+    _global_permit: OwnedSemaphorePermit,
+}
+
+impl UploadChannel {
+    fn expired(&self) -> bool {
+        git_channel_expired(self.started_at, self.last_activity)
+    }
+}
+
+impl Drop for UploadChannel {
+    fn drop(&mut self) {
+        self.cancelled.store(true, Ordering::Relaxed);
+    }
+}
+
+impl ReceiveChannel {
+    fn expired(&self) -> bool {
+        git_channel_expired(self.started_at, self.last_activity)
+    }
+}
+
+fn git_channel_expired(started_at: Instant, last_activity: Instant) -> bool {
+    started_at.elapsed() > GIT_CHANNEL_WALL_CLOCK_LIMIT
+        || last_activity.elapsed() > GIT_CHANNEL_INACTIVITY_LIMIT
+}
+
+fn reserve_git_channel(
+    active_channels: usize,
+    global_channels: &Arc<Semaphore>,
+) -> Option<OwnedSemaphorePermit> {
+    if active_channels >= MAX_GIT_CHANNELS_PER_CONNECTION {
+        return None;
+    }
+    global_channels.clone().try_acquire_owned().ok()
 }
 
 impl Handler for SshSession {
@@ -630,11 +714,11 @@
             session.channel_success(channel)?;
             session.data(channel, HELP_TEXT.as_bytes())?;
             finish_git_channel(channel, 0, session)?;
-        } else if command.starts_with(LOGIN_COMMAND_PREFIX) {
+        } else if command.starts_with(AUTH_COMMAND_PREFIX) {
             self.audit.accepted_exec.fetch_add(1, Ordering::Relaxed);
             session.channel_success(channel)?;
             let result = match (
-                parse_login_command(command),
+                parse_auth_command(command),
                 self.active_identity(),
                 self.login.clone(),
             ) {
@@ -653,7 +737,7 @@
                     session.data(
                         channel,
                         format!(
-                            "Browser login approved.\nOrigin: {}\nAccount: {}\nReturn to your browser and select Continue.\n",
+                            "Authentication approved.\nOrigin: {}\nAccount: {}\nReturn to your browser and select Continue.\n",
                             origin, username
                         )
                         .into_bytes(),
@@ -665,7 +749,7 @@
                         channel,
                         1,
                         format!(
-                            "tit: The login approval is invalid or expired.\n{HELP_GUIDANCE}\n"
+                            "tit: The authentication approval is invalid or expired.\n{HELP_GUIDANCE}\n"
                         )
                         .into_bytes(),
                     )?;
@@ -704,14 +788,34 @@
                 (Ok(IssueCommand::Create(command)), Some(identity)) => {
                     self.exec_channels.insert(
                         channel,
-                        ExecChannel::IssueCreate(IssueCreateChannel {
+                        ExecChannel::IssueInput(IssueInputChannel {
                             actor: identity.username,
                             owner: command.owner,
                             repository: command.repository,
                             output: command.output,
+                            kind: IssueInputKind::Create,
                             input: Vec::new(),
                         }),
                     );
+                }
+                (Ok(IssueCommand::Comment(command)), Some(identity)) => {
+                    self.exec_channels.insert(
+                        channel,
+                        ExecChannel::IssueInput(IssueInputChannel {
+                            actor: identity.username,
+                            owner: command.owner,
+                            repository: command.repository,
+                            output: command.output,
+                            kind: IssueInputKind::Comment(command.number),
+                            input: Vec::new(),
+                        }),
+                    );
+                }
+                (Ok(IssueCommand::SetState(command)), Some(identity)) => {
+                    let result =
+                        run_issue_set_state(self.repositories.clone(), identity.username, command)
+                            .await;
+                    send_issue_mutation_result(channel, result, machine_requested, session)?;
                 }
                 (Ok(_), None) => send_issue_error(
                     channel,
@@ -730,15 +834,61 @@
             self.audit.accepted_exec.fetch_add(1, Ordering::Relaxed);
             session.channel_success(channel)?;
             let machine_requested = requests_json(command);
-            let result = match (parse_pull_request_command(command), self.active_identity()) {
-                (Ok(command), Some(identity)) => {
-                    run_pull_request_checkout(self.repositories.clone(), identity.username, command)
-                        .await
+            match (parse_pull_request_command(command), self.active_identity()) {
+                (Ok(PullRequestCommand::Checkout(command)), Some(identity)) => {
+                    let result = run_pull_request_checkout(
+                        self.repositories.clone(),
+                        identity.username,
+                        command,
+                    )
+                    .await;
+                    send_pull_request_checkout_result(channel, result, machine_requested, session)?;
                 }
-                (Ok(_), None) => Err(PullRequestCommandError::Unavailable),
-                (Err(()), _) => Err(PullRequestCommandError::Usage),
-            };
-            send_pull_request_result(channel, result, machine_requested, session)?;
+                (Ok(PullRequestCommand::List(command)), Some(identity)) => {
+                    let result = run_pull_request_list(
+                        self.repositories.clone(),
+                        identity.username,
+                        command,
+                    )
+                    .await;
+                    send_pull_request_list_result(channel, result, machine_requested, session)?;
+                }
+                (Ok(PullRequestCommand::Create(command)), Some(identity)) => {
+                    self.exec_channels.insert(
+                        channel,
+                        ExecChannel::PullRequestCreate(PullRequestCreateChannel {
+                            actor: identity.username,
+                            owner: command.owner,
+                            repository: command.repository,
+                            base: command.base,
+                            head: command.head,
+                            output: command.output,
+                            input: Vec::new(),
+                        }),
+                    );
+                }
+                (Ok(PullRequestCommand::SetState(command)), Some(identity)) => {
+                    let result = run_pull_request_set_state(
+                        self.repositories.clone(),
+                        identity.username,
+                        command,
+                    )
+                    .await;
+                    send_pull_request_mutation_result(channel, result, machine_requested, session)?;
+                }
+                (Ok(_), None) => send_pull_request_error(
+                    channel,
+                    PullRequestCommandError::Unavailable,
+                    machine_requested,
+                    session,
+                )?,
+                (Err(()), _) => send_pull_request_error(
+                    channel,
+                    PullRequestCommandError::Usage,
+                    machine_requested,
+                    session,
+                )?,
+            }
         } else {
             let service = self.open_git_service(command).await;
             if let Some(service) = service {
@@ -748,13 +898,22 @@
                     InitialGitService::Upload {
                         service,
                         advertisement,
+                        global_permit,
                     } => {
+                        let started_at = Instant::now();
+                        let advertisement_bytes =
+                            u64::try_from(advertisement.len()).unwrap_or(u64::MAX);
                         session.data(channel, advertisement)?;
                         self.exec_channels.insert(
                             channel,
                             ExecChannel::Upload(Box::new(UploadChannel {
                                 service: *service,
                                 request: Vec::new(),
+                                started_at,
+                                last_activity: started_at,
+                                transferred: advertisement_bytes,
+                                cancelled: Arc::new(AtomicBool::new(false)),
+                                _global_permit: global_permit,
                             })),
                         );
                     }
@@ -766,8 +925,11 @@
                             repository,
                             identity,
                             public_key,
-                            maintenance,
+                            global_permit,
                         } = *receive;
+                        let started_at = Instant::now();
+                        let advertisement_bytes =
+                            u64::try_from(advertisement.len()).unwrap_or(u64::MAX);
                         session.data(channel, advertisement)?;
                         let pack = tokio::fs::File::create(service.incoming_pack()).await;
                         match pack {
@@ -785,7 +947,10 @@
                                         commands_complete: false,
                                         pack,
                                         pack_bytes: 0,
-                                        maintenance,
+                                        started_at,
+                                        last_activity: started_at,
+                                        transferred: advertisement_bytes,
+                                        _global_permit: global_permit,
                                     })),
                                 );
                             }
@@ -828,7 +993,7 @@
             return Ok(());
         };
         let mut git = match exec {
-            ExecChannel::IssueCreate(mut issue) => {
+            ExecChannel::IssueInput(mut issue) => {
                 if append_issue_input(&mut issue.input, data).is_err() {
                     send_issue_error(
                         channel,
@@ -838,12 +1003,38 @@
                     )?;
                 } else {
                     self.exec_channels
-                        .insert(channel, ExecChannel::IssueCreate(issue));
+                        .insert(channel, ExecChannel::IssueInput(issue));
+                }
+                return Ok(());
+            }
+            ExecChannel::PullRequestCreate(mut pull_request) => {
+                if append_issue_input(&mut pull_request.input, data).is_err() {
+                    send_pull_request_error(
+                        channel,
+                        PullRequestCommandError::Input,
+                        pull_request.output == CommandOutput::Json,
+                        session,
+                    )?;
+                } else {
+                    self.exec_channels
+                        .insert(channel, ExecChannel::PullRequestCreate(pull_request));
                 }
                 return Ok(());
             }
             ExecChannel::Upload(git) => git,
             ExecChannel::Receive(mut git) => {
+                if git.expired() {
+                    fail_git_channel(channel, session)?;
+                    return Ok(());
+                }
+                git.last_activity = Instant::now();
+                git.transferred = git
+                    .transferred
+                    .saturating_add(u64::try_from(data.len()).unwrap_or(u64::MAX));
+                if !git_channel_bytes_within_limit(git.transferred, 0) {
+                    fail_git_channel(channel, session)?;
+                    return Ok(());
+                }
                 if receive_data(&mut git, data).await.is_err() {
                     fail_git_channel(channel, session)?;
                 } else if git.commands_complete
@@ -861,6 +1052,17 @@
                 return Ok(());
             }
         };
+        if git.expired() {
+            fail_git_channel(channel, session)?;
+            return Ok(());
+        }
+        git.last_activity = Instant::now();
+        let bytes = u64::try_from(data.len()).unwrap_or(u64::MAX);
+        git.transferred = git.transferred.saturating_add(bytes);
+        if !git_channel_bytes_within_limit(git.transferred, 0) {
+            fail_git_channel(channel, session)?;
+            return Ok(());
+        }
         if git.request.len().saturating_add(data.len()) > MAX_REQUEST_BYTES {
             fail_git_channel(channel, session)?;
             return Ok(());
@@ -891,7 +1093,11 @@
                 );
                 if done {
                     match respond_git(self.repositories.clone(), self.protocol, git).await {
-                        Some((_, Ok(response))) => {
+                        Some((git, Ok(response))) => {
+                            if !git_response_within_limit(&git, response.len()) {
+                                fail_git_channel(channel, session)?;
+                                return Ok(());
+                            }
                             session.data(channel, response)?;
                             finish_git_channel(channel, 0, session)?;
                         }
@@ -914,6 +1120,13 @@
                 );
                 match respond_git(self.repositories.clone(), self.protocol, git).await {
                     Some((mut git, Ok(response))) => {
+                        if !git_response_within_limit(&git, response.len()) {
+                            fail_git_channel(channel, session)?;
+                            return Ok(());
+                        }
+                        git.transferred = git
+                            .transferred
+                            .saturating_add(u64::try_from(response.len()).unwrap_or(u64::MAX));
                         session.data(channel, response)?;
                         if fetch && done {
                             finish_git_channel(channel, 0, session)?;
@@ -945,24 +1158,36 @@
     ) -> Result<(), Self::Error> {
         match self.exec_channels.remove(&channel) {
             Some(ExecChannel::Receive(git)) => {
-                if !git.commands_complete {
+                if !git.commands_complete || git.expired() {
                     fail_git_channel(channel, session)?;
                     return Ok(());
                 }
                 let result = finish_receive(self.repositories.clone(), git).await;
                 send_receive_result(channel, result, session)?;
             }
-            Some(ExecChannel::IssueCreate(issue)) => {
+            Some(ExecChannel::IssueInput(issue)) => {
                 let active = self
                     .active_identity()
                     .is_some_and(|identity| identity.username == issue.actor);
                 let machine_requested = issue.output == CommandOutput::Json;
                 let result = if active {
-                    run_issue_create(self.repositories.clone(), issue).await
+                    run_issue_input(self.repositories.clone(), issue).await
                 } else {
                     Err(IssueCommandError::Unavailable)
                 };
-                send_issue_create_result(channel, result, machine_requested, session)?;
+                send_issue_mutation_result(channel, result, machine_requested, session)?;
+            }
+            Some(ExecChannel::PullRequestCreate(pull_request)) => {
+                let active = self
+                    .active_identity()
+                    .is_some_and(|identity| identity.username == pull_request.actor);
+                let machine_requested = pull_request.output == CommandOutput::Json;
+                let result = if active {
+                    run_pull_request_create(self.repositories.clone(), pull_request).await
+                } else {
+                    Err(PullRequestCommandError::Unavailable)
+                };
+                send_pull_request_mutation_result(channel, result, machine_requested, session)?;
             }
             Some(ExecChannel::Upload(_)) | None => {}
         }
@@ -1195,6 +1420,8 @@
 enum IssueCommand {
     List(IssueListCommand),
     Create(IssueCreateCommand),
+    Comment(IssueNumberCommand),
+    SetState(IssueStateCommand),
 }
 
 struct IssueListCommand {
@@ -1209,16 +1436,34 @@
     output: CommandOutput,
 }
 
+struct IssueNumberCommand {
+    owner: String,
+    repository: String,
+    number: i64,
+    output: CommandOutput,
+}
+
+struct IssueStateCommand {
+    owner: String,
+    repository: String,
+    number: i64,
+    state: &'static str,
+    output: CommandOutput,
+}
+
 struct IssueListResult {
     repository: crate::store::RepositoryRecord,
     issues: Vec<crate::store::IssueRecord>,
     output: CommandOutput,
 }
 
-struct IssueCreateResult {
+struct IssueMutationResult {
     owner: String,
     repository: String,
-    issue: crate::store::IssueRecord,
+    number: i64,
+    operation: &'static str,
+    issue: Option<crate::store::IssueRecord>,
+    comment_id: Option<String>,
     output: CommandOutput,
 }
 
@@ -1254,18 +1499,50 @@
     if owner.is_empty() || repository.is_empty() || repository.contains('/') {
         return Err(());
     }
-    let output = parse_output_options(tokens)?;
     match operation {
-        "list" => Ok(IssueCommand::List(IssueListCommand {
-            owner: owner.to_owned(),
-            repository: repository.to_owned(),
-            output,
-        })),
-        "create" => Ok(IssueCommand::Create(IssueCreateCommand {
-            owner: owner.to_owned(),
-            repository: repository.to_owned(),
-            output,
-        })),
+        "list" | "create" => {
+            let output = parse_output_options(tokens)?;
+            if operation == "list" {
+                Ok(IssueCommand::List(IssueListCommand {
+                    owner: owner.to_owned(),
+                    repository: repository.to_owned(),
+                    output,
+                }))
+            } else {
+                Ok(IssueCommand::Create(IssueCreateCommand {
+                    owner: owner.to_owned(),
+                    repository: repository.to_owned(),
+                    output,
+                }))
+            }
+        }
+        "comment" | "close" | "reopen" => {
+            let number = tokens.next().ok_or(())?.parse::<i64>().map_err(|_| ())?;
+            if number < 1 {
+                return Err(());
+            }
+            let output = parse_output_options(tokens)?;
+            if operation == "comment" {
+                Ok(IssueCommand::Comment(IssueNumberCommand {
+                    owner: owner.to_owned(),
+                    repository: repository.to_owned(),
+                    number,
+                    output,
+                }))
+            } else {
+                Ok(IssueCommand::SetState(IssueStateCommand {
+                    owner: owner.to_owned(),
+                    repository: repository.to_owned(),
+                    number,
+                    state: if operation == "close" {
+                        "closed"
+                    } else {
+                        "open"
+                    },
+                    output,
+                }))
+            }
+        }
         _ => Err(()),
     }
 }
@@ -1317,11 +1594,77 @@
     .map_err(|_| IssueCommandError::Unavailable)?
 }
 
-async fn run_issue_create(
+async fn run_issue_input(
     repositories: Option<Arc<GitRepositories>>,
-    command: IssueCreateChannel,
-) -> Result<IssueCreateResult, IssueCommandError> {
-    let (title, body) = parse_issue_input(&command.input)?;
+    command: IssueInputChannel,
+) -> Result<IssueMutationResult, IssueCommandError> {
+    let repositories = repositories.ok_or(IssueCommandError::Unavailable)?;
+    let database = repositories
+        .push_database()
+        .ok_or(IssueCommandError::Unavailable)?
+        .to_owned();
+    let permit = repositories
+        .blocking_permit()
+        .await
+        .map_err(|_| IssueCommandError::Unavailable)?;
+    tokio::task::spawn_blocking(move || {
+        let _permit = permit;
+        let service = IssueService::new(&database);
+        match command.kind {
+            IssueInputKind::Create => {
+                let (title, body) = parse_issue_input(&command.input)?;
+                service
+                    .create(
+                        &command.owner,
+                        &command.repository,
+                        &command.actor,
+                        &title,
+                        &body,
+                    )
+                    .map(|issue| IssueMutationResult {
+                        owner: command.owner,
+                        repository: command.repository,
+                        number: issue.number,
+                        operation: "created",
+                        issue: Some(issue),
+                        comment_id: None,
+                        output: command.output,
+                    })
+                    .map_err(IssueCommandError::Service)
+            }
+            IssueInputKind::Comment(number) => {
+                let body =
+                    std::str::from_utf8(&command.input).map_err(|_| IssueCommandError::Input)?;
+                service
+                    .comment(
+                        &command.owner,
+                        &command.repository,
+                        number,
+                        &command.actor,
+                        body,
+                    )
+                    .map(|comment_id| IssueMutationResult {
+                        owner: command.owner,
+                        repository: command.repository,
+                        number,
+                        operation: "commented",
+                        issue: None,
+                        comment_id: Some(comment_id),
+                        output: command.output,
+                    })
+                    .map_err(IssueCommandError::Service)
+            }
+        }
+    })
+    .await
+    .map_err(|_| IssueCommandError::Unavailable)?
+}
+
+async fn run_issue_set_state(
+    repositories: Option<Arc<GitRepositories>>,
+    actor: String,
+    command: IssueStateCommand,
+) -> Result<IssueMutationResult, IssueCommandError> {
     let repositories = repositories.ok_or(IssueCommandError::Unavailable)?;
     let database = repositories
         .push_database()
@@ -1334,17 +1677,24 @@
     tokio::task::spawn_blocking(move || {
         let _permit = permit;
         IssueService::new(&database)
-            .create(
+            .set_state(
                 &command.owner,
                 &command.repository,
-                &command.actor,
-                &title,
-                &body,
+                command.number,
+                &actor,
+                command.state,
             )
-            .map(|issue| IssueCreateResult {
+            .map(|()| IssueMutationResult {
                 owner: command.owner,
                 repository: command.repository,
-                issue,
+                number: command.number,
+                operation: if command.state == "closed" {
+                    "closed"
+                } else {
+                    "reopened"
+                },
+                issue: None,
+                comment_id: None,
                 output: command.output,
             })
             .map_err(IssueCommandError::Service)
@@ -1436,9 +1786,9 @@
     }))
 }
 
-fn send_issue_create_result(
+fn send_issue_mutation_result(
     channel: ChannelId,
-    result: Result<IssueCreateResult, IssueCommandError>,
+    result: Result<IssueMutationResult, IssueCommandError>,
     machine_requested: bool,
     session: &mut Session,
 ) -> Result<(), russh::Error> {
@@ -1446,8 +1796,11 @@
         Ok(result) => {
             let data = match result.output {
                 CommandOutput::Human => format!(
-                    "Created issue {}/{}#{}.\n",
-                    result.owner, result.repository, result.issue.number
+                    "{} issue {}/{}#{}.\n",
+                    sentence_case(result.operation),
+                    result.owner,
+                    result.repository,
+                    result.number
                 )
                 .into_bytes(),
                 CommandOutput::Json => json_line(serde_json::json!({
@@ -1458,19 +1811,29 @@
                         "name": result.repository,
                     },
                     "issue": {
-                        "number": result.issue.number,
-                        "title": result.issue.title,
-                        "state": result.issue.state,
-                        "author": result.issue.author,
-                        "created_at": result.issue.created_at,
-                        "updated_at": result.issue.updated_at,
+                        "number": result.number,
+                        "title": result.issue.as_ref().map(|issue| &issue.title),
+                        "state": result.issue.as_ref().map(|issue| &issue.state),
+                        "author": result.issue.as_ref().map(|issue| &issue.author),
+                        "created_at": result.issue.as_ref().map(|issue| issue.created_at),
+                        "updated_at": result.issue.as_ref().map(|issue| issue.updated_at),
                     },
+                    "operation": result.operation,
+                    "comment_id": result.comment_id,
                 })),
             };
             session.data(channel, data)?;
             finish_git_channel(channel, 0, session)
         }
         Err(error) => send_issue_error(channel, error, machine_requested, session),
+    }
+}
+
+fn sentence_case(value: &str) -> String {
+    let mut characters = value.chars();
+    match characters.next() {
+        Some(first) => first.to_uppercase().chain(characters).collect(),
+        None => String::new(),
     }
 }
 
@@ -1531,7 +1894,10 @@
 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}\n{HELP_GUIDANCE}")
+            format!(
+                "usage: {ISSUE_LIST_USAGE}, {ISSUE_CREATE_USAGE}, {ISSUE_COMMENT_USAGE}, \
+                 {ISSUE_CLOSE_USAGE}, or {ISSUE_REOPEN_USAGE}\n{HELP_GUIDANCE}"
+            )
         }
         "invalid-input" => {
             "The first input line must be a valid title. The remaining input is the body."
@@ -1553,6 +1919,36 @@
     output: CommandOutput,
 }
 
+enum PullRequestCommand {
+    Checkout(PullRequestCheckoutCommand),
+    List(PullRequestListCommand),
+    Create(PullRequestCreateCommand),
+    SetState(PullRequestStateCommand),
+}
+
+struct PullRequestListCommand {
+    owner: String,
+    repository: String,
+    state: String,
+    output: CommandOutput,
+}
+
+struct PullRequestCreateCommand {
+    owner: String,
+    repository: String,
+    base: String,
+    head: String,
+    output: CommandOutput,
+}
+
+struct PullRequestStateCommand {
+    owner: String,
+    repository: String,
+    number: i64,
+    state: &'static str,
+    output: CommandOutput,
+}
+
 struct PullRequestCheckoutResult {
     owner: String,
     repository: String,
@@ -1560,17 +1956,35 @@
     output: CommandOutput,
 }
 
+struct PullRequestListResult {
+    repository: crate::store::RepositoryRecord,
+    pull_requests: Vec<crate::store::PullRequestRecord>,
+    state: String,
+    output: CommandOutput,
+}
+
+struct PullRequestMutationResult {
+    owner: String,
+    repository: String,
+    number: i64,
+    operation: &'static str,
+    pull_request: Option<crate::store::PullRequestRecord>,
+    output: CommandOutput,
+}
+
 enum PullRequestCommandError {
     Usage,
+    Input,
     Unavailable,
     Store(StoreError),
+    Service(PullRequestError),
 }
 
 fn is_pull_request_command(command: &[u8]) -> bool {
     command == b"pr" || command.starts_with(b"pr ")
 }
 
-fn parse_pull_request_command(command: &[u8]) -> Result<PullRequestCheckoutCommand, ()> {
+fn parse_pull_request_command(command: &[u8]) -> Result<PullRequestCommand, ()> {
     if command.len() > MAX_PULL_REQUEST_COMMAND_BYTES || !command.is_ascii() {
         return Err(());
     }
@@ -1582,25 +1996,96 @@
         return Err(());
     }
     let mut tokens = command.split_ascii_whitespace();
-    if tokens.next() != Some("pr") || tokens.next() != Some("checkout") {
+    if tokens.next() != Some("pr") {
         return Err(());
     }
+    let operation = tokens.next().ok_or(())?;
     let target = tokens.next().ok_or(())?;
     let (owner, repository) = target.split_once('/').ok_or(())?;
     if owner.is_empty() || repository.is_empty() || repository.contains('/') {
         return Err(());
     }
-    let number = tokens.next().ok_or(())?.parse::<i64>().map_err(|_| ())?;
-    if number < 1 {
-        return Err(());
+    match operation {
+        "list" => {
+            let (state, output) = parse_pull_request_list_options(tokens)?;
+            Ok(PullRequestCommand::List(PullRequestListCommand {
+                owner: owner.to_owned(),
+                repository: repository.to_owned(),
+                state,
+                output,
+            }))
+        }
+        "create" => {
+            let base = tokens.next().ok_or(())?.to_owned();
+            let head = tokens.next().ok_or(())?.to_owned();
+            let output = parse_output_options(tokens)?;
+            Ok(PullRequestCommand::Create(PullRequestCreateCommand {
+                owner: owner.to_owned(),
+                repository: repository.to_owned(),
+                base,
+                head,
+                output,
+            }))
+        }
+        "checkout" | "close" | "reopen" => {
+            let number = tokens.next().ok_or(())?.parse::<i64>().map_err(|_| ())?;
+            if number < 1 {
+                return Err(());
+            }
+            let output = parse_output_options(tokens)?;
+            if operation == "checkout" {
+                Ok(PullRequestCommand::Checkout(PullRequestCheckoutCommand {
+                    owner: owner.to_owned(),
+                    repository: repository.to_owned(),
+                    number,
+                    output,
+                }))
+            } else {
+                Ok(PullRequestCommand::SetState(PullRequestStateCommand {
+                    owner: owner.to_owned(),
+                    repository: repository.to_owned(),
+                    number,
+                    state: if operation == "close" {
+                        "closed"
+                    } else {
+                        "open"
+                    },
+                    output,
+                }))
+            }
+        }
+        _ => Err(()),
     }
-    let output = parse_output_options(tokens)?;
-    Ok(PullRequestCheckoutCommand {
-        owner: owner.to_owned(),
-        repository: repository.to_owned(),
-        number,
-        output,
-    })
+}
+
+fn parse_pull_request_list_options<'a>(
+    mut tokens: impl Iterator<Item = &'a str>,
+) -> Result<(String, CommandOutput), ()> {
+    let mut state = None;
+    let mut output = None;
+    while let Some(option) = tokens.next() {
+        let value = tokens.next().ok_or(())?;
+        match option {
+            "--state" if state.is_none() => {
+                if !matches!(value, "open" | "closed" | "merged" | "all") {
+                    return Err(());
+                }
+                state = Some(value.to_owned());
+            }
+            "--output" if output.is_none() => {
+                output = Some(match value {
+                    "human" => CommandOutput::Human,
+                    "json" => CommandOutput::Json,
+                    _ => return Err(()),
+                });
+            }
+            _ => return Err(()),
+        }
+    }
+    Ok((
+        state.unwrap_or_else(|| "open".to_owned()),
+        output.unwrap_or(CommandOutput::Human),
+    ))
 }
 
 async fn run_pull_request_checkout(
@@ -1640,7 +2125,129 @@
     .map_err(|_| PullRequestCommandError::Unavailable)?
 }
 
-fn send_pull_request_result(
+async fn run_pull_request_list(
+    repositories: Option<Arc<GitRepositories>>,
+    actor: String,
+    command: PullRequestListCommand,
+) -> Result<PullRequestListResult, PullRequestCommandError> {
+    let repositories = repositories.ok_or(PullRequestCommandError::Unavailable)?;
+    let database = repositories
+        .push_database()
+        .ok_or(PullRequestCommandError::Unavailable)?
+        .to_owned();
+    let root = repositories.repository_root().to_owned();
+    let permit = repositories
+        .blocking_permit()
+        .await
+        .map_err(|_| PullRequestCommandError::Unavailable)?;
+    tokio::task::spawn_blocking(move || {
+        let _permit = permit;
+        PullRequestService::new(&database, &root)
+            .list_page(
+                &command.owner,
+                &command.repository,
+                Some(&actor),
+                &command.state,
+                1,
+            )
+            .map(|(repository, page, _)| PullRequestListResult {
+                repository,
+                pull_requests: page.items,
+                state: command.state,
+                output: command.output,
+            })
+            .map_err(PullRequestCommandError::Service)
+    })
+    .await
+    .map_err(|_| PullRequestCommandError::Unavailable)?
+}
+
+async fn run_pull_request_create(
+    repositories: Option<Arc<GitRepositories>>,
+    command: PullRequestCreateChannel,
+) -> Result<PullRequestMutationResult, PullRequestCommandError> {
+    let (title, body) =
+        parse_issue_input(&command.input).map_err(|_| PullRequestCommandError::Input)?;
+    let repositories = repositories.ok_or(PullRequestCommandError::Unavailable)?;
+    let database = repositories
+        .push_database()
+        .ok_or(PullRequestCommandError::Unavailable)?
+        .to_owned();
+    let root = repositories.repository_root().to_owned();
+    let permit = repositories
+        .blocking_permit()
+        .await
+        .map_err(|_| PullRequestCommandError::Unavailable)?;
+    tokio::task::spawn_blocking(move || {
+        let _permit = permit;
+        PullRequestService::new(&database, &root)
+            .open(
+                &command.owner,
+                &command.repository,
+                &command.actor,
+                &title,
+                &body,
+                &command.base,
+                &command.head,
+            )
+            .map(|pull_request| PullRequestMutationResult {
+                owner: command.owner,
+                repository: command.repository,
+                number: pull_request.number,
+                operation: "created",
+                pull_request: Some(pull_request),
+                output: command.output,
+            })
+            .map_err(PullRequestCommandError::Service)
+    })
+    .await
+    .map_err(|_| PullRequestCommandError::Unavailable)?
+}
+
+async fn run_pull_request_set_state(
+    repositories: Option<Arc<GitRepositories>>,
+    actor: String,
+    command: PullRequestStateCommand,
+) -> Result<PullRequestMutationResult, PullRequestCommandError> {
+    let repositories = repositories.ok_or(PullRequestCommandError::Unavailable)?;
+    let database = repositories
+        .push_database()
+        .ok_or(PullRequestCommandError::Unavailable)?
+        .to_owned();
+    let root = repositories.repository_root().to_owned();
+    let permit = repositories
+        .blocking_permit()
+        .await
+        .map_err(|_| PullRequestCommandError::Unavailable)?;
+    tokio::task::spawn_blocking(move || {
+        let _permit = permit;
+        PullRequestService::new(&database, &root)
+            .set_state(
+                &command.owner,
+                &command.repository,
+                command.number,
+                &actor,
+                command.state,
+            )
+            .map(|()| PullRequestMutationResult {
+                owner: command.owner,
+                repository: command.repository,
+                number: command.number,
+                operation: if command.state == "closed" {
+                    "closed"
+                } else {
+                    "reopened"
+                },
+                pull_request: None,
+                output: command.output,
+            })
+            .map_err(PullRequestCommandError::Service)
+    })
+    .await
+    .map_err(|_| PullRequestCommandError::Unavailable)?
+}
+
+fn send_pull_request_checkout_result(
     channel: ChannelId,
     result: Result<PullRequestCheckoutResult, PullRequestCommandError>,
     machine_requested: bool,
@@ -1669,50 +2276,188 @@
             session.data(channel, data.into_bytes())?;
             finish_git_channel(channel, 0, session)
         }
-        Err(error) => {
-            if machine_requested {
-                session.data(
-                    channel,
-                    format!(
-                        "{{\"version\":1,\"status\":\"error\",\"error\":{{\"code\":\"{}\"}}}}\n",
-                        pull_request_command_error_code(&error)
-                    )
-                    .into_bytes(),
-                )?;
-            } else {
-                session.extended_data(
-                    channel,
-                    1,
-                    format!("tit: {}\n", pull_request_command_error_message(&error)).into_bytes(),
-                )?;
-            }
-            finish_git_channel(channel, 1, session)
-        }
+        Err(error) => send_pull_request_error(channel, error, machine_requested, session),
     }
+}
+
+fn send_pull_request_list_result(
+    channel: ChannelId,
+    result: Result<PullRequestListResult, PullRequestCommandError>,
+    machine_requested: bool,
+    session: &mut Session,
+) -> Result<(), russh::Error> {
+    match result {
+        Ok(result) => {
+            let data = match result.output {
+                CommandOutput::Human => {
+                    if result.pull_requests.is_empty() {
+                        b"No pull requests.\n".to_vec()
+                    } else {
+                        result
+                            .pull_requests
+                            .iter()
+                            .map(|pull_request| {
+                                format!(
+                                    "#{} {} {} ({} <- {})\n",
+                                    pull_request.number,
+                                    pull_request.state,
+                                    pull_request.title,
+                                    pull_request.base_ref,
+                                    pull_request.head_ref
+                                )
+                            })
+                            .collect::<String>()
+                            .into_bytes()
+                    }
+                }
+                CommandOutput::Json => {
+                    let pull_requests = result
+                        .pull_requests
+                        .iter()
+                        .map(pull_request_json)
+                        .collect::<Vec<_>>();
+                    json_line(serde_json::json!({
+                        "version": 1,
+                        "status": "success",
+                        "repository": {
+                            "owner": result.repository.owner,
+                            "name": result.repository.slug,
+                        },
+                        "state": result.state,
+                        "pull_requests": pull_requests,
+                    }))
+                }
+            };
+            session.data(channel, data)?;
+            finish_git_channel(channel, 0, session)
+        }
+        Err(error) => send_pull_request_error(channel, error, machine_requested, session),
+    }
+}
+
+fn send_pull_request_mutation_result(
+    channel: ChannelId,
+    result: Result<PullRequestMutationResult, PullRequestCommandError>,
+    machine_requested: bool,
+    session: &mut Session,
+) -> Result<(), russh::Error> {
+    match result {
+        Ok(result) => {
+            let data = match result.output {
+                CommandOutput::Human => format!(
+                    "{} pull request {}/{}#{}.\n",
+                    sentence_case(result.operation),
+                    result.owner,
+                    result.repository,
+                    result.number
+                )
+                .into_bytes(),
+                CommandOutput::Json => json_line(serde_json::json!({
+                    "version": 1,
+                    "status": "success",
+                    "repository": {
+                        "owner": result.owner,
+                        "name": result.repository,
+                    },
+                    "pull_request": result.pull_request.as_ref().map(pull_request_json)
+                        .unwrap_or_else(|| serde_json::json!({ "number": result.number })),
+                    "operation": result.operation,
+                })),
+            };
+            session.data(channel, data)?;
+            finish_git_channel(channel, 0, session)
+        }
+        Err(error) => send_pull_request_error(channel, error, machine_requested, session),
+    }
+}
+
+fn pull_request_json(pull_request: &crate::store::PullRequestRecord) -> serde_json::Value {
+    serde_json::json!({
+        "number": pull_request.number,
+        "title": pull_request.title,
+        "state": pull_request.state,
+        "author": pull_request.author,
+        "base": pull_request.base_ref,
+        "head": pull_request.head_ref,
+        "created_at": pull_request.created_at,
+        "updated_at": pull_request.updated_at,
+    })
+}
+
+fn send_pull_request_error(
+    channel: ChannelId,
+    error: PullRequestCommandError,
+    machine_requested: bool,
+    session: &mut Session,
+) -> Result<(), russh::Error> {
+    if machine_requested {
+        session.data(
+            channel,
+            json_line(serde_json::json!({
+                "version": 1,
+                "status": "error",
+                "error": { "code": pull_request_command_error_code(&error) },
+            })),
+        )?;
+    } else {
+        session.extended_data(
+            channel,
+            1,
+            format!("tit: {}\n", pull_request_command_error_message(&error)).into_bytes(),
+        )?;
+    }
+    finish_git_channel(channel, 1, session)
 }
 
 fn pull_request_command_error_code(error: &PullRequestCommandError) -> &'static str {
     match error {
         PullRequestCommandError::Usage => "invalid-command",
+        PullRequestCommandError::Input
+        | PullRequestCommandError::Service(
+            PullRequestError::Title | PullRequestError::Body | PullRequestError::Branch,
+        ) => "invalid-input",
         PullRequestCommandError::Unavailable => "service-unavailable",
         PullRequestCommandError::Store(
             StoreError::PullRequestHidden
             | StoreError::PullRequestNotFound(_, _, _)
             | StoreError::RepositoryNotFound(_, _),
         ) => "pull-request-unavailable",
-        PullRequestCommandError::Store(_) => "pull-request-checkout-failed",
+        PullRequestCommandError::Service(PullRequestError::Auth(_))
+        | PullRequestCommandError::Service(PullRequestError::RepositoryName(_)) => "invalid-target",
+        PullRequestCommandError::Service(PullRequestError::Store(
+            StoreError::PullRequestHidden
+            | StoreError::PullRequestNotFound(_, _, _)
+            | StoreError::RepositoryNotFound(_, _),
+        )) => "pull-request-unavailable",
+        PullRequestCommandError::Service(PullRequestError::Store(
+            StoreError::PullRequestDenied,
+        )) => "permission-denied",
+        PullRequestCommandError::Store(_) | PullRequestCommandError::Service(_) => {
+            "pull-request-command-failed"
+        }
     }
 }
 
 fn pull_request_command_error_message(error: &PullRequestCommandError) -> String {
     match pull_request_command_error_code(error) {
         "invalid-command" => {
-            format!("usage: {PULL_REQUEST_CHECKOUT_USAGE}\n{HELP_GUIDANCE}")
+            format!(
+                "usage: {PULL_REQUEST_LIST_USAGE}, {PULL_REQUEST_CREATE_USAGE}, \
+                 {PULL_REQUEST_CLOSE_USAGE}, {PULL_REQUEST_REOPEN_USAGE}, or \
+                 {PULL_REQUEST_CHECKOUT_USAGE}\n{HELP_GUIDANCE}"
+            )
+        }
+        "invalid-input" => {
+            "The refs or input are not valid. The first input line is the title and the remaining input is the body."
+                .to_owned()
         }
         "pull-request-unavailable" => "The pull request is not available.".to_owned(),
         "invalid-target" => "The pull-request target is not valid.".to_owned(),
+        "permission-denied" => {
+            "The account cannot change a pull request in this repository.".to_owned()
+        }
         "service-unavailable" => "The pull-request service is not available.".to_owned(),
-        _ => "The pull-request checkout command could not be completed.".to_owned(),
+        _ => "The pull-request command could not be completed.".to_owned(),
     }
 }
 
@@ -1726,6 +2471,12 @@
     }
 
     async fn open_git_service(&mut self, command: &[u8]) -> Option<InitialGitService> {
+        let active_channels = self
+            .exec_channels
+            .values()
+            .filter(|channel| matches!(channel, ExecChannel::Upload(_) | ExecChannel::Receive(_)))
+            .count();
+        let global_permit = reserve_git_channel(active_channels, &self.git_channels)?;
         let repositories = self.repositories.as_ref()?;
         let identity = self.active_identity()?;
         let service = repositories
@@ -1742,6 +2493,7 @@
                     Ok::<_, UploadPackError>(InitialGitService::Upload {
                         service: Box::new(service),
                         advertisement,
+                        global_permit,
                     })
                 })
                 .await
@@ -1760,7 +2512,6 @@
                 let actor = identity.username.clone();
                 let public_key = self.authenticated_key.clone()?;
                 let uses_policy = repositories.uses_policy();
-                let maintenance = repositories.mutation_permit().await;
                 let permit = repositories.blocking_permit().await.ok()?;
                 tokio::task::spawn_blocking(move || {
                     let _permit = permit;
@@ -1784,7 +2535,7 @@
                             repository,
                             identity,
                             public_key,
-                            maintenance,
+                            global_permit,
                         },
                     )))
                 })
@@ -1807,6 +2558,7 @@
     Upload {
         service: Box<UploadPack>,
         advertisement: Vec<u8>,
+        global_permit: OwnedSemaphorePermit,
     },
     Receive(Box<InitialReceiveService>),
 }
@@ -1818,7 +2570,7 @@
     repository: String,
     identity: SshIdentity,
     public_key: PublicKey,
-    maintenance: tokio::sync::OwnedRwLockReadGuard<()>,
+    global_permit: OwnedSemaphorePermit,
 }
 
 async fn receive_data(git: &mut ReceiveChannel, data: &[u8]) -> Result<(), ()> {
@@ -1860,15 +2612,19 @@
         authorized_keys,
         commands,
         mut pack,
-        maintenance,
+        started_at,
         ..
     } = *git;
+    if started_at.elapsed() > GIT_CHANNEL_WALL_CLOCK_LIMIT - Duration::from_secs(30) {
+        return None;
+    }
     pack.flush().await.ok()?;
     pack.sync_all().await.ok()?;
     drop(pack);
     let repositories = repositories?;
     let push_permit = repositories.push_permit().await.ok()?;
     let permit = repositories.blocking_permit().await.ok()?;
+    let maintenance = repositories.mutation_permit().await;
     tokio::task::spawn_blocking(move || {
         let _permit = permit;
         let _push_permit = push_permit;
@@ -1943,14 +2699,33 @@
     protocol: ProtocolVersion,
     git: Box<UploadChannel>,
 ) -> Option<(Box<UploadChannel>, Result<Vec<u8>, UploadPackError>)> {
+    let remaining = GIT_CHANNEL_WALL_CLOCK_LIMIT.checked_sub(git.started_at.elapsed())?;
+    let cancelled = Arc::clone(&git.cancelled);
     let permit = repositories?.blocking_permit().await.ok()?;
-    tokio::task::spawn_blocking(move || {
+    let cancellation_for_job = Arc::clone(&cancelled);
+    let mut task = tokio::task::spawn_blocking(move || {
         let _permit = permit;
-        let response = git.service.respond(protocol, &git.request);
+        let response =
+            git.service
+                .respond_with_cancellation(protocol, &git.request, &cancellation_for_job);
         (git, response)
-    })
-    .await
-    .ok()
+    });
+    match tokio::time::timeout(remaining, &mut task).await {
+        Ok(result) => result.ok(),
+        Err(_) => {
+            cancelled.store(true, Ordering::Relaxed);
+            task.await.ok()
+        }
+    }
+}
+
+fn git_response_within_limit(git: &UploadChannel, response_bytes: usize) -> bool {
+    let response_bytes = u64::try_from(response_bytes).unwrap_or(u64::MAX);
+    git_channel_bytes_within_limit(git.transferred, response_bytes)
+}
+
+fn git_channel_bytes_within_limit(transferred: u64, additional: u64) -> bool {
+    transferred.saturating_add(additional) <= MAX_GIT_CHANNEL_BYTES
 }
 
 fn valid_git_protocol(value: &str) -> bool {
@@ -2052,22 +2827,71 @@
     }
 
     #[test]
-    fn parses_only_bounded_pull_request_checkout_commands() {
+    fn parses_only_bounded_pull_request_commands() {
         let command = parse_pull_request_command(b"pr checkout alice/project 42 --output json")
             .expect("parse a pull-request checkout command");
+        let PullRequestCommand::Checkout(command) = command else {
+            panic!("expected checkout");
+        };
         assert_eq!(command.owner, "alice");
         assert_eq!(command.repository, "project");
         assert_eq!(command.number, 42);
         assert!(command.output == CommandOutput::Json);
+        assert!(matches!(
+            parse_pull_request_command(
+                b"pr list alice/project --output json --state merged"
+            ),
+            Ok(PullRequestCommand::List(PullRequestListCommand {
+                state,
+                output: CommandOutput::Json,
+                ..
+            })) if state == "merged"
+        ));
+        assert!(matches!(
+            parse_pull_request_command(b"pr create alice/project main topic"),
+            Ok(PullRequestCommand::Create(_))
+        ));
+        assert!(matches!(
+            parse_pull_request_command(b"pr close alice/project 2"),
+            Ok(PullRequestCommand::SetState(_))
+        ));
         for command in [
             b"pr checkout alice/project 0".as_slice(),
             b"pr checkout alice/project not-a-number".as_slice(),
             b"pr checkout alice/project/extra 1".as_slice(),
             b"pr checkout alice/project 1 --output json extra".as_slice(),
             b"pr checkout alice/project 1 --output json --output json".as_slice(),
+            b"pr list alice/project --state invalid".as_slice(),
+            b"pr create alice/project main".as_slice(),
             &[b'x'; MAX_PULL_REQUEST_COMMAND_BYTES + 1],
         ] {
             assert!(parse_pull_request_command(command).is_err());
         }
+    }
+
+    #[test]
+    fn bounds_git_channel_time_bytes_and_concurrency() {
+        let now = Instant::now();
+        assert!(!git_channel_expired(now, now));
+        assert!(git_channel_expired(
+            now - GIT_CHANNEL_WALL_CLOCK_LIMIT - Duration::from_secs(1),
+            now
+        ));
+        assert!(git_channel_expired(
+            now,
+            now - GIT_CHANNEL_INACTIVITY_LIMIT - Duration::from_secs(1)
+        ));
+
+        let global = Arc::new(Semaphore::new(MAX_GIT_CHANNELS_GLOBAL));
+        let permits = (0..MAX_GIT_CHANNELS_GLOBAL)
+            .map(|_| reserve_git_channel(0, &global).expect("reserve a global Git channel"))
+            .collect::<Vec<_>>();
+        assert!(reserve_git_channel(0, &global).is_none());
+        drop(permits);
+        assert!(reserve_git_channel(MAX_GIT_CHANNELS_PER_CONNECTION, &global).is_none());
+
+        let limit = std::hint::black_box(MAX_GIT_CHANNEL_BYTES);
+        assert!(git_channel_bytes_within_limit(limit, 0));
+        assert!(!git_channel_bytes_within_limit(limit, 1));
     }
 }

src/store/event.rs

Mode 100644100644; object 1fc03dcb5320ca8f480c0774

@@ -18,12 +18,11 @@
     IssueCommented,
     IssueClosed,
     IssueReopened,
-    IssueLabeled,
-    IssueUnlabeled,
-    IssueAssigned,
-    IssueUnassigned,
     PullRequestCreated,
     PullRequestRevised,
+    PullRequestEdited,
+    PullRequestClosed,
+    PullRequestReopened,
     PullRequestCommented,
     PullRequestLineCommented,
     PullRequestApproved,
@@ -48,18 +47,45 @@
             Self::IssueCommented => "issue-commented",
             Self::IssueClosed => "issue-closed",
             Self::IssueReopened => "issue-reopened",
-            Self::IssueLabeled => "issue-labeled",
-            Self::IssueUnlabeled => "issue-unlabeled",
-            Self::IssueAssigned => "issue-assigned",
-            Self::IssueUnassigned => "issue-unassigned",
             Self::PullRequestCreated => "pull-request-created",
             Self::PullRequestRevised => "pull-request-revised",
+            Self::PullRequestEdited => "pull-request-edited",
+            Self::PullRequestClosed => "pull-request-closed",
+            Self::PullRequestReopened => "pull-request-reopened",
             Self::PullRequestCommented => "pull-request-commented",
             Self::PullRequestLineCommented => "pull-request-line-commented",
             Self::PullRequestApproved => "pull-request-approved",
             Self::PullRequestChangesRequested => "pull-request-changes-requested",
             Self::PullRequestMerged => "pull-request-merged",
         }
+    }
+}
+
+pub(super) fn pull_request_change(
+    kind: EventKind,
+    pull_request_id: &str,
+    number: i64,
+    title: &str,
+    body: &str,
+    state: &str,
+) -> VersionedEvent {
+    debug_assert!(matches!(
+        kind,
+        EventKind::PullRequestEdited
+            | EventKind::PullRequestClosed
+            | EventKind::PullRequestReopened
+    ));
+    VersionedEvent {
+        kind,
+        payload: json!({
+            "version": PAYLOAD_VERSION,
+            "pull_request_id": pull_request_id,
+            "number": number,
+            "title": title,
+            "body": body,
+            "state": state,
+        })
+        .to_string(),
     }
 }
 
@@ -292,52 +318,6 @@
             "issue_id": issue_id,
             "number": number,
             "state": state,
-        })
-        .to_string(),
-    }
-}
-
-pub(super) fn issue_label(
-    kind: EventKind,
-    issue_id: &str,
-    number: i64,
-    label_id: &str,
-    label: &str,
-) -> VersionedEvent {
-    debug_assert!(matches!(
-        kind,
-        EventKind::IssueLabeled | EventKind::IssueUnlabeled
-    ));
-    VersionedEvent {
-        kind,
-        payload: json!({
-            "version": PAYLOAD_VERSION,
-            "issue_id": issue_id,
-            "number": number,
-            "label_id": label_id,
-            "label": label,
-        })
-        .to_string(),
-    }
-}
-
-pub(super) fn issue_assignee(
-    kind: EventKind,
-    issue_id: &str,
-    number: i64,
-    assignee: &str,
-) -> VersionedEvent {
-    debug_assert!(matches!(
-        kind,
-        EventKind::IssueAssigned | EventKind::IssueUnassigned
-    ));
-    VersionedEvent {
-        kind,
-        payload: json!({
-            "version": PAYLOAD_VERSION,
-            "issue_id": issue_id,
-            "number": number,
-            "assignee": assignee,
         })
         .to_string(),
     }

src/store/migrations/019_product_reduction.sql

Mode 100644; object 4c1029050b7b

@@ -1,0 +1,48 @@
+ALTER TABLE account ADD COLUMN bio TEXT NOT NULL DEFAULT ''
+    CHECK (length(CAST(bio AS BLOB)) <= 512);
+ALTER TABLE account ADD COLUMN contact_email TEXT NOT NULL DEFAULT ''
+    CHECK (length(CAST(contact_email AS BLOB)) <= 254);
+
+DELETE FROM repository_event
+WHERE kind IN (
+    'issue-labeled', 'issue-unlabeled', 'issue-assigned', 'issue-unassigned'
+);
+DROP TABLE issue_label;
+DROP TABLE label;
+DROP TABLE issue_assignee;
+
+ALTER TABLE watch RENAME TO watch_v18;
+CREATE TABLE watch (
+    id TEXT PRIMARY KEY
+        CHECK (
+            length(id) = 32
+            AND id = lower(id)
+            AND id NOT GLOB '*[^0-9a-f]*'
+        ),
+    repository_id TEXT NOT NULL
+        REFERENCES repository (id) ON DELETE RESTRICT,
+    account_id INTEGER NOT NULL
+        REFERENCES account (id) ON DELETE RESTRICT,
+    created_at INTEGER NOT NULL CHECK (created_at >= 0),
+    updated_at INTEGER NOT NULL CHECK (updated_at >= created_at),
+    UNIQUE (repository_id, account_id)
+) STRICT;
+INSERT INTO watch (id, repository_id, account_id, created_at, updated_at)
+SELECT id, repository_id, account_id, created_at, updated_at
+FROM watch_v18;
+DROP TABLE watch_v18;
+CREATE INDEX watch_account_activity
+ON watch (account_id, repository_id);
+
+DELETE FROM feed_token WHERE scope != 'watched';
+UPDATE feed_token
+SET revoked_at = created_at
+WHERE revoked_at IS NULL
+  AND id NOT IN (
+      SELECT max(id) FROM feed_token
+      WHERE revoked_at IS NULL
+      GROUP BY account_id
+  );
+CREATE UNIQUE INDEX feed_token_one_active_watched
+ON feed_token (account_id)
+WHERE revoked_at IS NULL;

src/store/migrations/020_repository_profiles.sql

Mode 100644; object 9192ac392049

@@ -1,0 +1,6 @@
+CREATE TABLE repository_profile (
+    repository_id TEXT PRIMARY KEY
+        REFERENCES repository (id) ON DELETE RESTRICT,
+    description TEXT NOT NULL DEFAULT ''
+        CHECK (length(CAST(description AS BLOB)) <= 512)
+) STRICT;

src/store/migrations/021_pull_request_lifecycle.sql

Mode 100644; object 06a31eb826ca

@@ -1,0 +1,102 @@
+DROP INDEX repository_event_feed;
+DROP INDEX repository_event_issue_timeline;
+DROP INDEX repository_event_pull_request_timeline;
+ALTER TABLE repository_event RENAME TO repository_event_v20;
+
+CREATE TABLE repository_event (
+    id INTEGER PRIMARY KEY,
+    event_id TEXT NOT NULL UNIQUE
+        CHECK (
+            length(event_id) = 32
+            AND event_id = lower(event_id)
+            AND event_id NOT GLOB '*[^0-9a-f]*'
+        ),
+    repository_id TEXT NOT NULL
+        REFERENCES repository (id) ON DELETE RESTRICT,
+    sequence INTEGER NOT NULL CHECK (sequence >= 1),
+    source_intent_id TEXT
+        REFERENCES git_operation_intent (id) ON DELETE RESTRICT,
+    source_ordinal INTEGER CHECK (source_ordinal IS NULL OR source_ordinal >= 0),
+    issue_id TEXT REFERENCES issue (id) ON DELETE RESTRICT,
+    pull_request_id TEXT REFERENCES pull_request (id) ON DELETE RESTRICT,
+    kind TEXT NOT NULL
+        CHECK (kind IN (
+            'repository-created', 'repository-imported', 'push',
+            'ref-created', 'ref-updated', 'ref-deleted',
+            'tag-created', 'tag-updated', 'tag-deleted',
+            'issue-created', 'issue-edited', 'issue-commented',
+            'issue-closed', 'issue-reopened',
+            'issue-labeled', 'issue-unlabeled',
+            'issue-assigned', 'issue-unassigned',
+            'pull-request-created', 'pull-request-revised',
+            'pull-request-edited', 'pull-request-closed',
+            'pull-request-reopened',
+            'pull-request-commented', 'pull-request-line-commented',
+            'pull-request-approved', 'pull-request-changes-requested',
+            'pull-request-merged'
+        )),
+    actor TEXT NOT NULL CHECK (length(actor) BETWEEN 1 AND 256),
+    ref_name BLOB,
+    old_target TEXT,
+    new_target TEXT,
+    payload_version INTEGER NOT NULL CHECK (payload_version = 1),
+    payload TEXT NOT NULL
+        CHECK (
+            length(payload) BETWEEN 1 AND 1048576
+            AND CASE WHEN json_valid(payload) THEN
+                json_type(payload) = 'object'
+                AND coalesce(json_extract(payload, '$.version') = payload_version, 0)
+            ELSE 0 END
+        ),
+    created_at INTEGER NOT NULL CHECK (created_at >= 0),
+    UNIQUE (repository_id, sequence),
+    UNIQUE (source_intent_id, source_ordinal),
+    CHECK (
+        (source_intent_id IS NULL AND source_ordinal IS NULL)
+        OR (source_intent_id IS NOT NULL AND source_ordinal IS NOT NULL)
+    ),
+    CHECK (
+        (kind IN ('repository-created', 'repository-imported', 'push')
+            AND issue_id IS NULL AND pull_request_id IS NULL
+            AND ref_name IS NULL AND old_target IS NULL AND new_target IS NULL)
+        OR
+        ((kind LIKE 'ref-%' OR kind LIKE 'tag-%')
+            AND issue_id IS NULL AND pull_request_id IS NULL
+            AND ref_name IS NOT NULL
+            AND (old_target IS NOT NULL OR new_target IS NOT NULL))
+        OR
+        (kind LIKE 'issue-%'
+            AND issue_id IS NOT NULL AND pull_request_id IS NULL
+            AND source_intent_id IS NULL AND source_ordinal IS NULL
+            AND ref_name IS NULL AND old_target IS NULL AND new_target IS NULL)
+        OR
+        (kind LIKE 'pull-request-%'
+            AND issue_id IS NULL AND pull_request_id IS NOT NULL
+            AND (source_intent_id IS NULL OR kind = 'pull-request-merged')
+            AND ref_name IS NULL AND old_target IS NULL AND new_target IS NULL)
+    )
+) STRICT;
+
+INSERT INTO repository_event
+    (id, event_id, repository_id, sequence, source_intent_id, source_ordinal,
+     issue_id, pull_request_id, kind, actor, ref_name, old_target, new_target,
+     payload_version, payload, created_at)
+SELECT
+    id, event_id, repository_id, sequence, source_intent_id, source_ordinal,
+    issue_id, pull_request_id, kind, actor, ref_name, old_target, new_target,
+    payload_version, payload, created_at
+FROM repository_event_v20
+ORDER BY id;
+
+DROP TABLE repository_event_v20;
+
+CREATE INDEX repository_event_feed
+ON repository_event (repository_id, sequence DESC);
+
+CREATE INDEX repository_event_issue_timeline
+ON repository_event (issue_id, sequence)
+WHERE issue_id IS NOT NULL;
+
+CREATE INDEX repository_event_pull_request_timeline
+ON repository_event (pull_request_id, sequence)
+WHERE pull_request_id IS NOT NULL;

src/store/migrations/022_account_key_management.sql

Mode 100644; object 63941574d6de

@@ -1,0 +1,9 @@
+ALTER TABLE ssh_login_approval
+ADD COLUMN purpose TEXT NOT NULL DEFAULT 'login'
+CHECK (purpose IN ('login', 'account-key'));
+
+ALTER TABLE ssh_login_approval
+ADD COLUMN expected_account_id INTEGER REFERENCES account(id);
+
+ALTER TABLE web_session
+ADD COLUMN ssh_public_key_id INTEGER REFERENCES ssh_public_key(id);

src/store/migrations/023_default_branch.sql

Mode 100644; object 6e221b0b511e

@@ -1,0 +1,12 @@
+CREATE TABLE repository_default_branch (
+    repository_id TEXT PRIMARY KEY
+        REFERENCES repository(id) ON DELETE CASCADE,
+    ref_name TEXT NOT NULL
+        CHECK (
+            length(ref_name) BETWEEN 12 AND 1024
+            AND substr(ref_name, 1, 11) = 'refs/heads/'
+        )
+) STRICT;
+
+INSERT INTO repository_default_branch (repository_id, ref_name)
+SELECT id, 'refs/heads/main' FROM repository;

src/store/mod.rs

Mode 100644100644; object 078d1c75d146e597a1c32db3

@@ -1,4 +1,5 @@
 use std::ffi::OsString;
+use std::fs;
 use std::path::{Path, PathBuf};
 use std::time::Duration;
 
@@ -13,8 +14,8 @@
 
 const BUSY_TIMEOUT: Duration = Duration::from_secs(5);
 const BUSY_TIMEOUT_MILLISECONDS: i64 = 5_000;
-const MAX_ACTIVE_FEED_TOKENS: i64 = 32;
-const SCHEMA_VERSION: i64 = 18;
+const MAX_ACTIVE_FEED_TOKENS: i64 = 1;
+const SCHEMA_VERSION: i64 = 23;
 #[allow(
     dead_code,
     reason = "the integration test imports this module without the CLI operation"
@@ -24,7 +25,7 @@
     dead_code,
     reason = "M1A proves migrations before the M2 server calls them"
 )]
-const MIGRATIONS: [&str; 18] = [
+const MIGRATIONS: [&str; 23] = [
     include_str!("migrations/001_initial.sql"),
     include_str!("migrations/002_state.sql"),
     include_str!("migrations/003_git_intents.sql"),
@@ -43,6 +44,11 @@
     include_str!("migrations/016_pull_request_reviews.sql"),
     include_str!("migrations/017_pull_request_merges.sql"),
     include_str!("migrations/018_streamlined_login.sql"),
+    include_str!("migrations/019_product_reduction.sql"),
+    include_str!("migrations/020_repository_profiles.sql"),
+    include_str!("migrations/021_pull_request_lifecycle.sql"),
+    include_str!("migrations/022_account_key_management.sql"),
+    include_str!("migrations/023_default_branch.sql"),
 ];
 
 #[allow(
@@ -113,6 +119,8 @@
     RepositoryArchived(String, String),
     #[error("repository visibility is not valid")]
     InvalidRepositoryVisibility,
+    #[error("repository default branch is not valid")]
+    InvalidDefaultBranch,
     #[error("collaborator role is not valid")]
     InvalidCollaboratorRole,
     #[error("repository owner cannot be a collaborator")]
@@ -137,12 +145,6 @@
     IssueHidden,
     #[error("issue state is already {0}")]
     IssueState(String),
-    #[error("issue label already has the requested state")]
-    IssueLabelState,
-    #[error("issue assignee already has the requested state")]
-    IssueAssigneeState,
-    #[error("issue assignee does not exist or cannot read the repository: {0}")]
-    IssueAssigneeNotFound(String),
     #[error("pull request does not exist: {0}/{1}#{2}")]
     PullRequestNotFound(String, String, i64),
     #[error("pull-request access is not authorized")]
@@ -159,11 +161,9 @@
     PullRequestIntentState(String),
     #[error("repository watch access is not authorized")]
     WatchDenied,
-    #[error("feed token access is not authorized")]
-    FeedTokenDenied,
     #[error("feed token is invalid or revoked")]
     FeedTokenNotFound,
-    #[error("an account cannot have more than 32 active feed tokens")]
+    #[error("an account cannot have more than one active feed token")]
     FeedTokenLimit,
     #[error("feed token scope is not valid")]
     InvalidFeedScope,
@@ -279,7 +279,55 @@
             after_migration(version);
         }
         transaction.commit()?;
+        if current < 23 {
+            self.backfill_default_branches();
+        }
         Ok(())
+    }
+
+    fn backfill_default_branches(&self) {
+        let Some(database) = self.connection.path() else {
+            return;
+        };
+        let Some(instance) = Path::new(database).parent() else {
+            return;
+        };
+        let Ok(mut statement) = self.connection.prepare("SELECT id FROM repository") else {
+            return;
+        };
+        let Ok(ids) = statement.query_map([], |row| row.get::<_, String>(0)) else {
+            return;
+        };
+        for id in ids.flatten() {
+            let head = instance
+                .join("repositories")
+                .join(format!("{id}.git"))
+                .join("HEAD");
+            let Ok(contents) = fs::read(head) else {
+                continue;
+            };
+            let Some(name) = contents
+                .strip_suffix(b"\n")
+                .unwrap_or(&contents)
+                .strip_prefix(b"ref: ")
+            else {
+                continue;
+            };
+            let candidate = gix::bstr::BString::from(name);
+            if !name.starts_with(b"refs/heads/")
+                || gix::refs::FullName::try_from(candidate).is_err()
+            {
+                continue;
+            }
+            let Ok(name) = std::str::from_utf8(name) else {
+                continue;
+            };
+            let _ = self.connection.execute(
+                "UPDATE repository_default_branch SET ref_name = ?2
+                 WHERE repository_id = ?1",
+                rusqlite::params![id, name],
+            );
+        }
     }
 
     pub(crate) fn schema_version(&self) -> Result<i64, StoreError> {
@@ -315,20 +363,40 @@
         Ok(())
     }
 
-    #[allow(
-        dead_code,
-        reason = "M1A proves maintenance before an operator command calls it"
-    )]
+    pub(crate) fn maintain(&mut self, cutoff: i64) -> Result<MaintenanceResult, StoreError> {
+        let transaction = self
+            .connection
+            .transaction_with_behavior(TransactionBehavior::Immediate)?;
+        let mut deleted = 0_usize;
+        for statement in [
+            "DELETE FROM signup_invitation WHERE expires_at < ?1",
+            "DELETE FROM login_nonce WHERE expires_at < ?1",
+            "DELETE FROM ssh_login_approval WHERE expires_at < ?1",
+            "DELETE FROM web_session
+             WHERE expires_at < ?1 OR (ended_at IS NOT NULL AND ended_at < ?1)",
+            "DELETE FROM feed_token WHERE revoked_at IS NOT NULL AND revoked_at < ?1",
+            "DELETE FROM audit_event WHERE created_at < ?1",
+        ] {
+            deleted = deleted
+                .checked_add(transaction.execute(statement, [cutoff])?)
+                .ok_or(StoreError::EventLimit)?;
+        }
+        transaction.commit()?;
+        self.connection
+            .execute_batch("PRAGMA wal_checkpoint(TRUNCATE); VACUUM;")?;
+        Ok(MaintenanceResult { deleted })
+    }
+
+    #[cfg(test)]
+    #[allow(dead_code, reason = "integration storage tests use this test boundary")]
     pub(crate) fn checkpoint(&self) -> Result<(), StoreError> {
         self.connection
             .execute_batch("PRAGMA wal_checkpoint(TRUNCATE)")?;
         Ok(())
     }
 
-    #[allow(
-        dead_code,
-        reason = "M1A proves maintenance before an operator command calls it"
-    )]
+    #[cfg(test)]
+    #[allow(dead_code, reason = "integration storage tests use this test boundary")]
     pub(crate) fn vacuum(&self) -> Result<(), StoreError> {
         self.connection.execute_batch("VACUUM")?;
         Ok(())
@@ -790,6 +858,7 @@
             rusqlite::params![account_id, recovery.new_recovery_hash, recovery.created_at],
         )?;
         end_sessions(&transaction, account_id, recovery.created_at)?;
+        revoke_feed_tokens(&transaction, account_id, recovery.created_at)?;
         insert_audit_event(
             &transaction,
             &NewAuditEvent {
@@ -888,6 +957,98 @@
         Ok(())
     }
 
+    pub(crate) fn complete_account_key_add(
+        &mut self,
+        authorization: &AccountKeyAuthorization<'_>,
+        key: &NewSshKey<'_>,
+    ) -> Result<(), StoreError> {
+        let transaction = self
+            .connection
+            .transaction_with_behavior(TransactionBehavior::Immediate)?;
+        let (account_id, _) = consume_account_key_authorization(&transaction, authorization)?;
+        let duplicate_label: bool = transaction.query_row(
+            "SELECT EXISTS(
+                SELECT 1 FROM ssh_public_key
+                WHERE account_id = ?1 AND label = ?2 AND revoked_at IS NULL
+             )",
+            rusqlite::params![account_id, key.label],
+            |row| row.get(0),
+        )?;
+        if duplicate_label {
+            return Err(StoreError::KeyExists);
+        }
+        insert_ssh_key(&transaction, account_id, key, authorization.changed_at)?;
+        let target = format!("{}:{}", authorization.username, key.fingerprint);
+        insert_audit_event(
+            &transaction,
+            &NewAuditEvent {
+                action: "key.add",
+                actor: authorization.username,
+                target: &target,
+                outcome: "success",
+                correlation_id: authorization.correlation_id,
+                created_at: authorization.changed_at,
+            },
+        )?;
+        transaction.commit()?;
+        Ok(())
+    }
+
+    pub(crate) fn complete_account_key_revoke(
+        &mut self,
+        authorization: &AccountKeyAuthorization<'_>,
+        fingerprint: &str,
+    ) -> Result<(), StoreError> {
+        let transaction = self
+            .connection
+            .transaction_with_behavior(TransactionBehavior::Immediate)?;
+        let (account_id, session_key_id) =
+            consume_account_key_authorization(&transaction, authorization)?;
+        let active: i64 = transaction.query_row(
+            "SELECT count(*) FROM ssh_public_key
+             WHERE account_id = ?1 AND revoked_at IS NULL",
+            [account_id],
+            |row| row.get(0),
+        )?;
+        if active <= 1 {
+            return Err(StoreError::LastKey);
+        }
+        let key_id = transaction
+            .query_row(
+                "SELECT id FROM ssh_public_key
+                 WHERE account_id = ?1 AND fingerprint = ?2 AND revoked_at IS NULL",
+                rusqlite::params![account_id, fingerprint],
+                |row| row.get::<_, i64>(0),
+            )
+            .optional()?
+            .ok_or(StoreError::KeyNotFound)?;
+        let changed = transaction.execute(
+            "UPDATE ssh_public_key SET revoked_at = ?3
+             WHERE id = ?1 AND account_id = ?2 AND revoked_at IS NULL",
+            rusqlite::params![key_id, account_id, authorization.changed_at],
+        )?;
+        if changed != 1 {
+            return Err(StoreError::KeyNotFound);
+        }
+        if session_key_id.is_none() || session_key_id == Some(key_id) {
+            end_sessions(&transaction, account_id, authorization.changed_at)?;
+        }
+        let target = format!("{}:{fingerprint}", authorization.username);
+        insert_audit_event(
+            &transaction,
+            &NewAuditEvent {
+                action: "key.revoke",
+                actor: authorization.username,
+                target: &target,
+                outcome: "success",
+                correlation_id: authorization.correlation_id,
+                created_at: authorization.changed_at,
+            },
+        )?;
+        transaction.commit()?;
+        Ok(())
+    }
+
     #[allow(
         dead_code,
         reason = "some integration tests compile storage without accounts"
@@ -917,6 +1078,9 @@
             |row| row.get(0),
         )?;
         end_sessions(&transaction, account_id, changed_at)?;
+        if suspended {
+            revoke_feed_tokens(&transaction, account_id, changed_at)?;
+        }
         insert_audit_event(
             &transaction,
             &NewAuditEvent {
@@ -993,9 +1157,9 @@
         let transaction = self
             .connection
             .transaction_with_behavior(TransactionBehavior::Immediate)?;
-        let account_id = transaction
+        let (account_id, key_id) = transaction
             .query_row(
-                "SELECT login_nonce.account_id
+                "SELECT login_nonce.account_id, ssh_public_key.id
                  FROM login_nonce
                  JOIN account ON account.id = login_nonce.account_id
                  JOIN ssh_public_key ON ssh_public_key.account_id = login_nonce.account_id
@@ -1013,7 +1177,7 @@
                     login.fingerprint,
                     login.login_csrf_hash,
                 ],
-                |row| row.get::<_, i64>(0),
+                |row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?)),
             )
             .optional()?
             .ok_or(StoreError::InvalidLoginChallenge)?;
@@ -1027,14 +1191,16 @@
         }
         transaction.execute(
             "INSERT INTO web_session
-             (session_hash, csrf_hash, account_id, created_at, expires_at, ended_at)
-             VALUES (?1, ?2, ?3, ?4, ?5, NULL)",
+             (session_hash, csrf_hash, account_id, created_at, expires_at, ended_at,
+              ssh_public_key_id)
+             VALUES (?1, ?2, ?3, ?4, ?5, NULL, ?6)",
             rusqlite::params![
                 login.session_hash,
                 login.csrf_hash,
                 account_id,
                 login.created_at,
                 login.expires_at,
+                key_id,
             ],
         )?;
         insert_audit_event(
@@ -1071,16 +1237,22 @@
         if active >= 1_024 {
             return Err(StoreError::LoginNonceLimit);
         }
+        let expected_account_id = approval
+            .expected_username
+            .map(|username| active_account_id(&transaction, username))
+            .transpose()?;
         transaction.execute(
             "INSERT INTO ssh_login_approval
              (secret_hash, csrf_hash, account_id, ssh_public_key_id,
-              created_at, expires_at, approved_at, consumed_at)
-             VALUES (?1, ?2, NULL, NULL, ?3, ?4, NULL, NULL)",
+              created_at, expires_at, approved_at, consumed_at, purpose, expected_account_id)
+             VALUES (?1, ?2, NULL, NULL, ?3, ?4, NULL, NULL, ?5, ?6)",
             rusqlite::params![
                 approval.secret_hash,
                 approval.csrf_hash,
                 approval.created_at,
                 approval.expires_at,
+                approval.purpose,
+                expected_account_id,
             ],
         )?;
         transaction.commit()?;
@@ -1108,7 +1280,8 @@
             "UPDATE ssh_login_approval
              SET account_id = ?2, ssh_public_key_id = ?3, approved_at = ?4
              WHERE secret_hash = ?1 AND account_id IS NULL
-               AND approved_at IS NULL AND consumed_at IS NULL AND expires_at >= ?4",
+               AND approved_at IS NULL AND consumed_at IS NULL AND expires_at >= ?4
+               AND (expected_account_id IS NULL OR expected_account_id = ?2)",
             rusqlite::params![
                 approval.secret_hash,
                 account_id,
@@ -1132,13 +1305,16 @@
             .transaction_with_behavior(TransactionBehavior::Immediate)?;
         let approval = transaction
             .query_row(
-                "SELECT account.id, account.username, ssh_login_approval.approved_at
+                "SELECT account.id, account.username, ssh_login_approval.approved_at,
+                        ssh_login_approval.ssh_public_key_id
                  FROM ssh_login_approval
                  LEFT JOIN account ON account.id = ssh_login_approval.account_id
                  LEFT JOIN ssh_public_key
                    ON ssh_public_key.id = ssh_login_approval.ssh_public_key_id
                  WHERE ssh_login_approval.secret_hash = ?1
                    AND ssh_login_approval.csrf_hash = ?2
+                   AND ssh_login_approval.purpose = 'login'
+                   AND ssh_login_approval.expected_account_id IS NULL
                    AND ssh_login_approval.consumed_at IS NULL
                    AND ssh_login_approval.expires_at >= ?3
                    AND (ssh_login_approval.account_id IS NULL
@@ -1149,12 +1325,13 @@
                         row.get::<_, Option<i64>>(0)?,
                         row.get::<_, Option<String>>(1)?,
                         row.get::<_, Option<i64>>(2)?,
+                        row.get::<_, Option<i64>>(3)?,
                     ))
                 },
             )
             .optional()?
             .ok_or(StoreError::InvalidLoginApproval)?;
-        let (Some(account_id), Some(username), Some(_)) = approval else {
+        let (Some(account_id), Some(username), Some(_), Some(key_id)) = approval else {
             return Err(StoreError::LoginApprovalPending);
         };
         let changed = transaction.execute(
@@ -1167,14 +1344,16 @@
         }
         transaction.execute(
             "INSERT INTO web_session
-             (session_hash, csrf_hash, account_id, created_at, expires_at, ended_at)
-             VALUES (?1, ?2, ?3, ?4, ?5, NULL)",
+             (session_hash, csrf_hash, account_id, created_at, expires_at, ended_at,
+              ssh_public_key_id)
+             VALUES (?1, ?2, ?3, ?4, ?5, NULL, ?6)",
             rusqlite::params![
                 login.session_hash,
                 login.csrf_hash,
                 account_id,
                 login.created_at,
                 login.expires_at,
+                key_id,
             ],
         )?;
         insert_audit_event(
@@ -1204,7 +1383,8 @@
     ) -> Result<WebSessionRecord, StoreError> {
         self.connection
             .query_row(
-                "SELECT account.username, account.is_administrator, web_session.expires_at
+                "SELECT account.username, account.is_administrator, web_session.expires_at,
+                        web_session.ssh_public_key_id
                  FROM web_session
                  JOIN account ON account.id = web_session.account_id
                  WHERE web_session.session_hash = ?1 AND web_session.ended_at IS NULL
@@ -1216,6 +1396,7 @@
                         username: row.get(0)?,
                         is_administrator: row.get(1)?,
                         expires_at: row.get(2)?,
+                        ssh_public_key_id: row.get(3)?,
                     })
                 },
             )
@@ -1270,6 +1451,11 @@
         );
         match result {
             Ok(1) => {
+                transaction.execute(
+                    "INSERT INTO repository_default_branch (repository_id, ref_name)
+                     VALUES (?1, ?2)",
+                    rusqlite::params![repository.id, repository.default_branch],
+                )?;
                 let event = event::repository(
                     repository.origin.event_kind(),
                     repository.owner,
@@ -1640,6 +1826,372 @@
             )),
             Err(error) => Err(error.into()),
         }
+    }
+
+    pub(crate) fn repository_settings(
+        &self,
+        owner: &str,
+        slug: &str,
+        actor: &str,
+    ) -> Result<RepositorySettings, StoreError> {
+        let access = repository_issue_access(&self.connection, owner, slug, Some(actor))?;
+        if !access.can_maintain() {
+            return Err(StoreError::PullRequestDenied);
+        }
+        let description = self
+            .connection
+            .query_row(
+                "SELECT description FROM repository_profile WHERE repository_id = ?1",
+                [&access.repository.id],
+                |row| row.get(0),
+            )
+            .optional()?
+            .unwrap_or_default();
+        let mut statement = self.connection.prepare(
+            "SELECT account.username, repository_collaborator.role
+             FROM repository_collaborator
+             JOIN account ON account.id = repository_collaborator.account_id
+             WHERE repository_collaborator.repository_id = ?1
+             ORDER BY account.username",
+        )?;
+        let collaborators = statement
+            .query_map([&access.repository.id], |row| {
+                Ok(RepositoryCollaboratorRecord {
+                    username: row.get(0)?,
+                    role: row.get(1)?,
+                })
+            })?
+            .collect::<Result<Vec<_>, _>>()?;
+        Ok(RepositorySettings {
+            repository: access.repository,
+            description,
+            collaborators,
+            default_branch: self.repository_default_branch(owner, slug)?,
+            branches: Vec::new(),
+        })
+    }
+
+    pub(crate) fn repository_default_branch(
+        &self,
+        owner: &str,
+        slug: &str,
+    ) -> Result<String, StoreError> {
+        self.connection
+            .query_row(
+                "SELECT repository_default_branch.ref_name
+                 FROM repository_default_branch
+                 JOIN repository ON repository.id = repository_default_branch.repository_id
+                 JOIN account ON account.id = repository.owner_account_id
+                 WHERE account.username = ?1 AND repository.slug = ?2",
+                rusqlite::params![owner, slug],
+                |row| row.get(0),
+            )
+            .optional()?
+            .ok_or_else(|| StoreError::RepositoryNotFound(owner.to_owned(), slug.to_owned()))
+    }
+
+    pub(crate) fn update_repository_default_branch(
+        &mut self,
+        owner: &str,
+        slug: &str,
+        actor: &str,
+        default_branch: &str,
+        changed_at: i64,
+        correlation_id: &str,
+    ) -> Result<(), StoreError> {
+        let transaction = self
+            .connection
+            .transaction_with_behavior(TransactionBehavior::Immediate)?;
+        let access = repository_issue_access(&transaction, owner, slug, Some(actor))?;
+        if !access.can_maintain() {
+            return Err(StoreError::PullRequestDenied);
+        }
+        let changed = transaction.execute(
+            "UPDATE repository_default_branch SET ref_name = ?2
+             WHERE repository_id = ?1",
+            rusqlite::params![access.repository.id, default_branch],
+        )?;
+        if changed != 1 {
+            return Err(StoreError::InvalidDefaultBranch);
+        }
+        let target = format!("{owner}/{slug}:{default_branch}");
+        insert_audit_event(
+            &transaction,
+            &NewAuditEvent {
+                action: "repository.default-branch",
+                actor,
+                target: &target,
+                outcome: "success",
+                correlation_id,
+                created_at: changed_at,
+            },
+        )?;
+        transaction.commit()?;
+        Ok(())
+    }
+
+    pub(crate) fn repository_description(&self, repository_id: &str) -> Result<String, StoreError> {
+        self.connection
+            .query_row(
+                "SELECT COALESCE(repository_profile.description, '')
+                 FROM repository
+                 LEFT JOIN repository_profile
+                   ON repository_profile.repository_id = repository.id
+                 WHERE repository.id = ?1",
+                [repository_id],
+                |row| row.get(0),
+            )
+            .optional()?
+            .ok_or_else(|| StoreError::Integrity(format!("repository {repository_id} disappeared")))
+    }
+
+    #[allow(
+        clippy::too_many_arguments,
+        reason = "an audited repository settings change includes repository identity and audit context"
+    )]
+    pub(crate) fn update_repository_settings(
+        &mut self,
+        owner: &str,
+        slug: &str,
+        actor: &str,
+        description: &str,
+        visibility: &str,
+        changed_at: i64,
+        correlation_id: &str,
+    ) -> Result<(), StoreError> {
+        if !matches!(visibility, "public" | "private") {
+            return Err(StoreError::InvalidRepositoryVisibility);
+        }
+        let transaction = self
+            .connection
+            .transaction_with_behavior(TransactionBehavior::Immediate)?;
+        let access = repository_issue_access(&transaction, owner, slug, Some(actor))?;
+        if !access.can_maintain() {
+            return Err(StoreError::PullRequestDenied);
+        }
+        transaction.execute(
+            "INSERT INTO repository_profile (repository_id, description)
+             VALUES (?1, ?2)
+             ON CONFLICT (repository_id) DO UPDATE SET description = excluded.description",
+            rusqlite::params![access.repository.id, description],
+        )?;
+        transaction.execute(
+            "UPDATE repository SET visibility = ?2 WHERE id = ?1",
+            rusqlite::params![access.repository.id, visibility],
+        )?;
+        insert_audit_event(
+            &transaction,
+            &NewAuditEvent {
+                action: "repository.settings",
+                actor,
+                target: &format!("{owner}/{slug}"),
+                outcome: "success",
+                correlation_id,
+                created_at: changed_at,
+            },
+        )?;
+        transaction.commit()?;
+        Ok(())
+    }
+
+    #[allow(
+        clippy::too_many_arguments,
+        reason = "an audited collaborator change includes repository identity and audit context"
+    )]
+    pub(crate) fn update_repository_collaborator(
+        &mut self,
+        owner: &str,
+        slug: &str,
+        actor: &str,
+        username: &str,
+        role: Option<&str>,
+        changed_at: i64,
+        correlation_id: &str,
+    ) -> Result<(), StoreError> {
+        if role.is_some_and(|role| !matches!(role, "maintainer" | "writer" | "reader")) {
+            return Err(StoreError::InvalidCollaboratorRole);
+        }
+        let transaction = self
+            .connection
+            .transaction_with_behavior(TransactionBehavior::Immediate)?;
+        let access = repository_issue_access(&transaction, owner, slug, Some(actor))?;
+        if !access.can_maintain() {
+            return Err(StoreError::PullRequestDenied);
+        }
+        let collaborator_id = match active_account_id(&transaction, username) {
+            Ok(id) => id,
+            Err(StoreError::AccountNotFound(_)) => {
+                return Err(StoreError::CollaboratorNotFound(username.to_owned()));
+            }
+            Err(error) => return Err(error),
+        };
+        if username == owner {
+            return Err(StoreError::OwnerCollaborator);
+        }
+        let changed = match role {
+            Some(role) => transaction.execute(
+                "INSERT INTO repository_collaborator
+                 (repository_id, account_id, role, created_at)
+                 VALUES (?1, ?2, ?3, ?4)
+                 ON CONFLICT (repository_id, account_id) DO UPDATE SET role = excluded.role",
+                rusqlite::params![access.repository.id, collaborator_id, role, changed_at],
+            )?,
+            None => transaction.execute(
+                "DELETE FROM repository_collaborator
+                 WHERE repository_id = ?1 AND account_id = ?2",
+                rusqlite::params![access.repository.id, collaborator_id],
+            )?,
+        };
+        if changed == 0 {
+            return Err(StoreError::CollaboratorNotFound(username.to_owned()));
+        }
+        insert_audit_event(
+            &transaction,
+            &NewAuditEvent {
+                action: if role.is_some() {
+                    "collaborator.set"
+                } else {
+                    "collaborator.remove"
+                },
+                actor,
+                target: &format!("{owner}/{slug}:{username}"),
+                outcome: "success",
+                correlation_id,
+                created_at: changed_at,
+            },
+        )?;
+        transaction.commit()?;
+        Ok(())
+    }
+
+    pub(crate) fn archive_repository_for_actor(
+        &mut self,
+        owner: &str,
+        slug: &str,
+        actor: &str,
+        changed_at: i64,
+        correlation_id: &str,
+    ) -> Result<(), StoreError> {
+        let transaction = self
+            .connection
+            .transaction_with_behavior(TransactionBehavior::Immediate)?;
+        let access = repository_issue_access(&transaction, owner, slug, Some(actor))?;
+        if !access.can_maintain() {
+            return Err(StoreError::PullRequestDenied);
+        }
+        transaction.execute(
+            "UPDATE repository SET state = 'archived', archived_at = ?2 WHERE id = ?1",
+            rusqlite::params![access.repository.id, changed_at],
+        )?;
+        insert_audit_event(
+            &transaction,
+            &NewAuditEvent {
+                action: "repository.archive",
+                actor,
+                target: &format!("{owner}/{slug}"),
+                outcome: "success",
+                correlation_id,
+                created_at: changed_at,
+            },
+        )?;
+        transaction.commit()?;
+        Ok(())
+    }
+
+    pub(crate) fn rename_repository_for_owner(
+        &mut self,
+        owner: &str,
+        old_slug: &str,
+        new_slug: &str,
+        actor: &str,
+        changed_at: i64,
+        correlation_id: &str,
+    ) -> Result<(), StoreError> {
+        let transaction = self
+            .connection
+            .transaction_with_behavior(TransactionBehavior::Immediate)?;
+        let owner_id = active_account_id(&transaction, owner)?;
+        let actor_id = active_account_id(&transaction, actor)?;
+        if actor_id != owner_id {
+            return Err(StoreError::PullRequestDenied);
+        }
+        let result = transaction.execute(
+            "UPDATE repository SET slug = ?3
+             WHERE owner_account_id = ?1 AND slug = ?2 AND state = 'active'",
+            rusqlite::params![owner_id, old_slug, new_slug],
+        );
+        match result {
+            Ok(1) => {}
+            Ok(0) => {
+                return Err(repository_state_error(
+                    &transaction,
+                    owner_id,
+                    owner,
+                    old_slug,
+                )?);
+            }
+            Ok(_) => unreachable!("an owner and slug identify one repository"),
+            Err(error) if is_unique_constraint(&error) => {
+                return Err(StoreError::RepositoryExists(
+                    owner.to_owned(),
+                    new_slug.to_owned(),
+                ));
+            }
+            Err(error) => return Err(error.into()),
+        }
+        insert_audit_event(
+            &transaction,
+            &NewAuditEvent {
+                action: "repository.rename",
+                actor,
+                target: &format!("{owner}/{old_slug}->{new_slug}"),
+                outcome: "success",
+                correlation_id,
+                created_at: changed_at,
+            },
+        )?;
+        transaction.commit()?;
+        Ok(())
+    }
+
+    pub(crate) fn unarchive_repository_for_owner(
+        &mut self,
+        owner: &str,
+        slug: &str,
+        actor: &str,
+        changed_at: i64,
+        correlation_id: &str,
+    ) -> Result<(), StoreError> {
+        let transaction = self
+            .connection
+            .transaction_with_behavior(TransactionBehavior::Immediate)?;
+        let owner_id = active_account_id(&transaction, owner)?;
+        let actor_id = active_account_id(&transaction, actor)?;
+        if actor_id != owner_id {
+            return Err(StoreError::PullRequestDenied);
+        }
+        let changed = transaction.execute(
+            "UPDATE repository
+             SET state = 'active', archived_at = NULL
+             WHERE owner_account_id = ?1 AND slug = ?2 AND state = 'archived'",
+            rusqlite::params![owner_id, slug],
+        )?;
+        if changed != 1 {
+            return Err(repository_state_error(&transaction, owner_id, owner, slug)?);
+        }
+        insert_audit_event(
+            &transaction,
+            &NewAuditEvent {
+                action: "repository.unarchive",
+                actor,
+                target: &format!("{owner}/{slug}"),
+                outcome: "success",
+                correlation_id,
+                created_at: changed_at,
+            },
+        )?;
+        transaction.commit()?;
+        Ok(())
     }
 
     #[allow(
@@ -2051,6 +2603,40 @@
         number: i64,
         actor: Option<&str>,
     ) -> Result<PullRequestDetail, StoreError> {
+        self.pull_request_with_activity(owner, repository, number, actor, None)
+    }
+
+    #[allow(
+        clippy::too_many_arguments,
+        reason = "the pull-request detail has independent review and timeline pages"
+    )]
+    pub(crate) fn pull_request_detail_page(
+        &self,
+        owner: &str,
+        repository: &str,
+        number: i64,
+        actor: Option<&str>,
+        reviews_page: usize,
+        timeline_page: usize,
+        page_size: usize,
+    ) -> Result<PullRequestDetail, StoreError> {
+        self.pull_request_with_activity(
+            owner,
+            repository,
+            number,
+            actor,
+            Some((reviews_page, timeline_page, page_size)),
+        )
+    }
+
+    fn pull_request_with_activity(
+        &self,
+        owner: &str,
+        repository: &str,
+        number: i64,
+        actor: Option<&str>,
+        activity: Option<(usize, usize, usize)>,
+    ) -> Result<PullRequestDetail, StoreError> {
         let access = repository_issue_access(&self.connection, owner, repository, actor)?;
         if !access.can_read() {
             return Err(StoreError::PullRequestHidden);
@@ -2094,7 +2680,7 @@
                 })
             })?
             .collect::<Result<Vec<_>, _>>()?;
-        let reviews = {
+        let (reviews, reviews_page, reviews_has_next) = {
             let mut statement = self.connection.prepare(
                 "SELECT pull_request_review.id, pull_request_revision.number,
                         author.username, pull_request_review.kind,
@@ -2107,20 +2693,47 @@
                  JOIN account AS author
                    ON author.id = pull_request_review.author_account_id
                  WHERE pull_request_review.pull_request_id = ?1
-                 ORDER BY pull_request_review.created_at, pull_request_review.id",
+                 ORDER BY pull_request_review.created_at DESC, pull_request_review.id DESC
+                 LIMIT ?2 OFFSET ?3",
             )?;
-            statement
-                .query_map([&pull_request.id], pull_request_review_from_row)?
-                .collect::<Result<Vec<_>, _>>()?
+            let (page, page_size) =
+                activity.map_or((1, usize::MAX - 1), |value| (value.0, value.2));
+            let limit = i64::try_from(page_size.saturating_add(1)).unwrap_or(i64::MAX);
+            let offset = if activity.is_some() {
+                page_offset(page, page_size)?
+            } else {
+                0
+            };
+            let mut records = statement
+                .query_map(
+                    rusqlite::params![pull_request.id, limit, offset],
+                    pull_request_review_from_row,
+                )?
+                .collect::<Result<Vec<_>, _>>()?;
+            let has_next = activity.is_some() && records.len() > page_size;
+            if activity.is_some() {
+                records.truncate(page_size);
+            }
+            records.reverse();
+            (records, page, has_next)
         };
-        let timeline = {
+        let (timeline, timeline_page, timeline_has_next) = {
             let mut statement = self.connection.prepare(
                 "SELECT sequence, kind, actor, payload, created_at
                  FROM repository_event
-                 WHERE pull_request_id = ?1 ORDER BY sequence",
+                 WHERE pull_request_id = ?1 ORDER BY sequence DESC
+                 LIMIT ?2 OFFSET ?3",
             )?;
-            statement
-                .query_map([&pull_request.id], |row| {
+            let (page, page_size) =
+                activity.map_or((1, usize::MAX - 1), |value| (value.1, value.2));
+            let limit = i64::try_from(page_size.saturating_add(1)).unwrap_or(i64::MAX);
+            let offset = if activity.is_some() {
+                page_offset(page, page_size)?
+            } else {
+                0
+            };
+            let mut records = statement
+                .query_map(rusqlite::params![pull_request.id, limit, offset], |row| {
                     Ok(PullRequestTimelineRecord {
                         sequence: row.get(0)?,
                         kind: row.get(1)?,
@@ -2129,9 +2742,21 @@
                         created_at: row.get(4)?,
                     })
                 })?
-                .collect::<Result<Vec<_>, _>>()?
+                .collect::<Result<Vec<_>, _>>()?;
+            let has_next = activity.is_some() && records.len() > page_size;
+            if activity.is_some() {
+                records.truncate(page_size);
+            }
+            records.reverse();
+            (records, page, has_next)
         };
         let is_open = pull_request.state == "open";
+        let author_account_id: i64 = self.connection.query_row(
+            "SELECT author_account_id FROM pull_request WHERE id = ?1",
+            [&pull_request.id],
+            |row| row.get(0),
+        )?;
+        let can_edit = pull_request.state != "merged" && access.can_write_issue(author_account_id);
         let can_revise = is_open && access.can_write_repository();
         let can_merge = access.can_maintain() && pull_request.state == "open";
         Ok(PullRequestDetail {
@@ -2141,6 +2766,12 @@
             revisions,
             reviews,
             timeline,
+            reviews_page,
+            reviews_has_next,
+            timeline_page,
+            timeline_has_next,
+            can_edit,
+            can_change_state: can_edit,
             can_revise,
             can_merge,
         })
@@ -2293,6 +2924,185 @@
         Ok((access.repository, pull_requests, can_create))
     }
 
+    pub(crate) fn pull_request_page(
+        &self,
+        owner: &str,
+        repository: &str,
+        actor: Option<&str>,
+        state: &str,
+        page: usize,
+        page_size: usize,
+    ) -> Result<(RepositoryRecord, RecordPage<PullRequestRecord>, bool), StoreError> {
+        let access = repository_issue_access(&self.connection, owner, repository, actor)?;
+        if !access.can_read() {
+            return Err(StoreError::PullRequestHidden);
+        }
+        let offset = page
+            .checked_sub(1)
+            .and_then(|page| page.checked_mul(page_size))
+            .ok_or(StoreError::EventLimit)?;
+        let limit = i64::try_from(page_size.checked_add(1).ok_or(StoreError::EventLimit)?)
+            .map_err(|_| StoreError::EventLimit)?;
+        let offset = i64::try_from(offset).map_err(|_| StoreError::EventLimit)?;
+        let mut statement = self.connection.prepare(
+            "SELECT pull_request.id, pull_request.number, pull_request.title,
+                    pull_request.body, pull_request.state, author.username,
+                    pull_request.base_ref, pull_request.head_ref,
+                    pull_request.base_object_id, pull_request.head_object_id,
+                    pull_request.created_at, pull_request.updated_at
+             FROM pull_request
+             JOIN account AS author ON author.id = pull_request.author_account_id
+             WHERE pull_request.repository_id = ?1
+               AND (?2 = 'all' OR pull_request.state = ?2)
+             ORDER BY pull_request.number DESC
+             LIMIT ?3 OFFSET ?4",
+        )?;
+        let mut items = statement
+            .query_map(
+                rusqlite::params![access.repository.id, state, limit, offset],
+                pull_request_from_row,
+            )?
+            .collect::<Result<Vec<_>, _>>()?;
+        let has_next = items.len() > page_size;
+        items.truncate(page_size);
+        let can_create = access.can_write_repository();
+        Ok((
+            access.repository,
+            RecordPage {
+                items,
+                page,
+                has_next,
+            },
+            can_create,
+        ))
+    }
+
+    #[allow(
+        clippy::too_many_arguments,
+        reason = "a pull-request edit includes repository identity, content, actor, and time"
+    )]
+    pub(crate) fn edit_pull_request(
+        &mut self,
+        owner: &str,
+        repository: &str,
+        number: i64,
+        actor: &str,
+        title: &str,
+        body: &str,
+        changed_at: i64,
+    ) -> Result<(), StoreError> {
+        let transaction = self
+            .connection
+            .transaction_with_behavior(TransactionBehavior::Immediate)?;
+        let access = repository_issue_access(&transaction, owner, repository, Some(actor))?;
+        let current = transaction
+            .query_row(
+                "SELECT id, author_account_id, state
+                 FROM pull_request WHERE repository_id = ?1 AND number = ?2",
+                rusqlite::params![access.repository.id, number],
+                |row| {
+                    Ok((
+                        row.get::<_, String>(0)?,
+                        row.get::<_, i64>(1)?,
+                        row.get::<_, String>(2)?,
+                    ))
+                },
+            )
+            .optional()?
+            .ok_or_else(|| {
+                StoreError::PullRequestNotFound(owner.to_owned(), repository.to_owned(), number)
+            })?;
+        if !access.can_write_issue(current.1) {
+            return Err(StoreError::PullRequestDenied);
+        }
+        if current.2 == "merged" {
+            return Err(StoreError::PullRequestState);
+        }
+        transaction.execute(
+            "UPDATE pull_request SET title = ?2, body = ?3, updated_at = ?4 WHERE id = ?1",
+            rusqlite::params![current.0, title, body, changed_at],
+        )?;
+        let event = event::pull_request_change(
+            event::EventKind::PullRequestEdited,
+            &current.0,
+            number,
+            title,
+            body,
+            &current.2,
+        );
+        insert_pull_request_event(
+            &transaction,
+            &access.repository.id,
+            &current.0,
+            actor,
+            changed_at,
+            &event,
+        )?;
+        transaction.commit()?;
+        Ok(())
+    }
+
+    pub(crate) fn set_pull_request_state(
+        &mut self,
+        owner: &str,
+        repository: &str,
+        number: i64,
+        actor: &str,
+        state: &str,
+        changed_at: i64,
+    ) -> Result<(), StoreError> {
+        let transaction = self
+            .connection
+            .transaction_with_behavior(TransactionBehavior::Immediate)?;
+        let access = repository_issue_access(&transaction, owner, repository, Some(actor))?;
+        let current = transaction
+            .query_row(
+                "SELECT id, author_account_id, title, body, state
+                 FROM pull_request WHERE repository_id = ?1 AND number = ?2",
+                rusqlite::params![access.repository.id, number],
+                |row| {
+                    Ok((
+                        row.get::<_, String>(0)?,
+                        row.get::<_, i64>(1)?,
+                        row.get::<_, String>(2)?,
+                        row.get::<_, String>(3)?,
+                        row.get::<_, String>(4)?,
+                    ))
+                },
+            )
+            .optional()?
+            .ok_or_else(|| {
+                StoreError::PullRequestNotFound(owner.to_owned(), repository.to_owned(), number)
+            })?;
+        if !access.can_write_issue(current.1) {
+            return Err(StoreError::PullRequestDenied);
+        }
+        if current.4 == "merged" || current.4 == state {
+            return Err(StoreError::PullRequestState);
+        }
+        transaction.execute(
+            "UPDATE pull_request SET state = ?2, updated_at = ?3 WHERE id = ?1",
+            rusqlite::params![current.0, state, changed_at],
+        )?;
+        let kind = if state == "closed" {
+            event::EventKind::PullRequestClosed
+        } else {
+            event::EventKind::PullRequestReopened
+        };
+        let event =
+            event::pull_request_change(kind, &current.0, number, &current.2, &current.3, state);
+        insert_pull_request_event(
+            &transaction,
+            &access.repository.id,
+            &current.0,
+            actor,
+            changed_at,
+            &event,
+        )?;
+        transaction.commit()?;
+        Ok(())
+    }
+
     pub(crate) fn edit_issue(
         &mut self,
         change: &IssueChange<'_>,
@@ -2422,161 +3232,6 @@
         Ok(())
     }
 
-    pub(crate) fn set_issue_label(
-        &mut self,
-        change: &IssueChange<'_>,
-        label: &str,
-        present: bool,
-    ) -> Result<(), StoreError> {
-        let transaction = self
-            .connection
-            .transaction_with_behavior(TransactionBehavior::Immediate)?;
-        let (access, current) = issue_mutation_context(
-            &transaction,
-            change.owner,
-            change.repository,
-            change.number,
-            change.actor,
-        )?;
-        let actor_id = access.active_actor_id.ok_or(StoreError::IssueDenied)?;
-        if !access.can_maintain() {
-            return Err(StoreError::IssueDenied);
-        }
-        let (label_id, stored_label) = if present {
-            transaction.execute(
-                "INSERT INTO label (id, repository_id, name, created_at)
-                 VALUES (lower(hex(randomblob(16))), ?1, ?2, ?3)
-                 ON CONFLICT DO NOTHING",
-                rusqlite::params![access.repository.id, label, change.changed_at],
-            )?;
-            transaction.query_row(
-                "SELECT id, name FROM label
-                 WHERE repository_id = ?1 AND name = ?2 COLLATE NOCASE",
-                rusqlite::params![access.repository.id, label],
-                |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
-            )?
-        } else {
-            transaction
-                .query_row(
-                    "SELECT id, name FROM label
-                     WHERE repository_id = ?1 AND name = ?2 COLLATE NOCASE",
-                    rusqlite::params![access.repository.id, label],
-                    |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
-                )
-                .optional()?
-                .ok_or(StoreError::IssueLabelState)?
-        };
-        let changed = if present {
-            transaction.execute(
-                "INSERT INTO issue_label (issue_id, label_id, actor_account_id, created_at)
-                 VALUES (?1, ?2, ?3, ?4) ON CONFLICT DO NOTHING",
-                rusqlite::params![current.issue.id, label_id, actor_id, change.changed_at],
-            )?
-        } else {
-            transaction.execute(
-                "DELETE FROM issue_label WHERE issue_id = ?1 AND label_id = ?2",
-                rusqlite::params![current.issue.id, label_id],
-            )?
-        };
-        if changed == 0 {
-            return Err(StoreError::IssueLabelState);
-        }
-        transaction.execute(
-            "UPDATE issue SET updated_at = ?2 WHERE id = ?1",
-            rusqlite::params![current.issue.id, change.changed_at],
-        )?;
-        let kind = if present {
-            event::EventKind::IssueLabeled
-        } else {
-            event::EventKind::IssueUnlabeled
-        };
-        let event = event::issue_label(
-            kind,
-            &current.issue.id,
-            change.number,
-            &label_id,
-            &stored_label,
-        );
-        insert_issue_event(
-            &transaction,
-            &access.repository.id,
-            &current.issue.id,
-            change.actor,
-            change.changed_at,
-            &event,
-        )?;
-        transaction.commit()?;
-        Ok(())
-    }
-
-    pub(crate) fn set_issue_assignee(
-        &mut self,
-        change: &IssueChange<'_>,
-        assignee: &str,
-        present: bool,
-    ) -> Result<(), StoreError> {
-        let transaction = self
-            .connection
-            .transaction_with_behavior(TransactionBehavior::Immediate)?;
-        let (access, current) = issue_mutation_context(
-            &transaction,
-            change.owner,
-            change.repository,
-            change.number,
-            change.actor,
-        )?;
-        let actor_id = access.active_actor_id.ok_or(StoreError::IssueDenied)?;
-        if !access.can_maintain() {
-            return Err(StoreError::IssueDenied);
-        }
-        let assignee_access = repository_issue_access(
-            &transaction,
-            change.owner,
-            change.repository,
-            Some(assignee),
-        )?;
-        let assignee_id = assignee_access
-            .active_actor_id
-            .filter(|_| assignee_access.can_read())
-            .ok_or_else(|| StoreError::IssueAssigneeNotFound(assignee.to_owned()))?;
-        let changed = if present {
-            transaction.execute(
-                "INSERT INTO issue_assignee
-                 (issue_id, account_id, actor_account_id, created_at)
-                 VALUES (?1, ?2, ?3, ?4) ON CONFLICT DO NOTHING",
-                rusqlite::params![current.issue.id, assignee_id, actor_id, change.changed_at],
-            )?
-        } else {
-            transaction.execute(
-                "DELETE FROM issue_assignee WHERE issue_id = ?1 AND account_id = ?2",
-                rusqlite::params![current.issue.id, assignee_id],
-            )?
-        };
-        if changed == 0 {
-            return Err(StoreError::IssueAssigneeState);
-        }
-        transaction.execute(
-            "UPDATE issue SET updated_at = ?2 WHERE id = ?1",
-            rusqlite::params![current.issue.id, change.changed_at],
-        )?;
-        let kind = if present {
-            event::EventKind::IssueAssigned
-        } else {
-            event::EventKind::IssueUnassigned
-        };
-        let event = event::issue_assignee(kind, &current.issue.id, change.number, assignee);
-        insert_issue_event(
-            &transaction,
-            &access.repository.id,
-            &current.issue.id,
-            change.actor,
-            change.changed_at,
-            &event,
-        )?;
-        transaction.commit()?;
-        Ok(())
-    }
-
     pub(crate) fn issues(
         &self,
         owner: &str,
@@ -2602,12 +3257,67 @@
         Ok((access.repository, issues))
     }
 
+    pub(crate) fn issue_page(
+        &self,
+        owner: &str,
+        repository: &str,
+        actor: Option<&str>,
+        state: &str,
+        page: usize,
+        page_size: usize,
+    ) -> Result<(RepositoryRecord, RecordPage<IssueRecord>), StoreError> {
+        let access = repository_issue_access(&self.connection, owner, repository, actor)?;
+        if !access.can_read() {
+            return Err(StoreError::IssueHidden);
+        }
+        let offset = page
+            .checked_sub(1)
+            .and_then(|page| page.checked_mul(page_size))
+            .ok_or(StoreError::EventLimit)?;
+        let limit = i64::try_from(page_size.checked_add(1).ok_or(StoreError::EventLimit)?)
+            .map_err(|_| StoreError::EventLimit)?;
+        let offset = i64::try_from(offset).map_err(|_| StoreError::EventLimit)?;
+        let mut statement = self.connection.prepare(
+            "SELECT issue.id, issue.number, issue.title, issue.body, issue.state,
+                    account.username, issue.created_at, issue.updated_at, issue.closed_at
+             FROM issue
+             JOIN account ON account.id = issue.author_account_id
+             WHERE issue.repository_id = ?1
+               AND (?2 = 'all' OR issue.state = ?2)
+             ORDER BY issue.number DESC
+             LIMIT ?3 OFFSET ?4",
+        )?;
+        let mut items = statement
+            .query_map(
+                rusqlite::params![access.repository.id, state, limit, offset],
+                issue_from_row,
+            )?
+            .collect::<Result<Vec<_>, _>>()?;
+        let has_next = items.len() > page_size;
+        items.truncate(page_size);
+        Ok((
+            access.repository,
+            RecordPage {
+                items,
+                page,
+                has_next,
+            },
+        ))
+    }
+
+    #[allow(
+        clippy::too_many_arguments,
+        reason = "the issue detail has independent comment and timeline pages"
+    )]
     pub(crate) fn issue_detail(
         &self,
         owner: &str,
         repository: &str,
         number: i64,
         actor: Option<&str>,
+        comments_page: usize,
+        timeline_page: usize,
+        page_size: usize,
     ) -> Result<IssueDetail, StoreError> {
         let access = repository_issue_access(&self.connection, owner, repository, actor)?;
         if !access.can_read() {
@@ -2620,67 +3330,74 @@
             repository,
             number,
         )?;
-        let comments = {
+        let limit = i64::try_from(page_size.checked_add(1).ok_or(StoreError::EventLimit)?)
+            .map_err(|_| StoreError::EventLimit)?;
+        let comments_offset = page_offset(comments_page, page_size)?;
+        let timeline_offset = page_offset(timeline_page, page_size)?;
+        let (comments, comments_has_next) = {
             let mut statement = self.connection.prepare(
                 "SELECT issue_comment.id, account.username, issue_comment.body,
                         issue_comment.created_at
                  FROM issue_comment
                  JOIN account ON account.id = issue_comment.author_account_id
                  WHERE issue_comment.issue_id = ?1
-                 ORDER BY issue_comment.created_at, issue_comment.id",
+                 ORDER BY issue_comment.created_at DESC, issue_comment.id DESC
+                 LIMIT ?2 OFFSET ?3",
             )?;
-            statement
-                .query_map([&issue.issue.id], |row| {
-                    Ok(IssueCommentRecord {
-                        id: row.get(0)?,
-                        author: row.get(1)?,
-                        body: row.get(2)?,
-                        created_at: row.get(3)?,
-                    })
-                })?
-                .collect::<Result<Vec<_>, _>>()?
+            let mut records = statement
+                .query_map(
+                    rusqlite::params![issue.issue.id, limit, comments_offset],
+                    |row| {
+                        Ok(IssueCommentRecord {
+                            id: row.get(0)?,
+                            author: row.get(1)?,
+                            body: row.get(2)?,
+                            created_at: row.get(3)?,
+                        })
+                    },
+                )?
+                .collect::<Result<Vec<_>, _>>()?;
+            let has_next = records.len() > page_size;
+            records.truncate(page_size);
+            records.reverse();
+            (records, has_next)
         };
-        let labels = issue_names(
-            &self.connection,
-            "SELECT label.name FROM issue_label
-             JOIN label ON label.id = issue_label.label_id
-             WHERE issue_label.issue_id = ?1 ORDER BY label.name COLLATE NOCASE",
-            &issue.issue.id,
-        )?;
-        let assignees = issue_names(
-            &self.connection,
-            "SELECT account.username FROM issue_assignee
-             JOIN account ON account.id = issue_assignee.account_id
-             WHERE issue_assignee.issue_id = ?1 ORDER BY account.username",
-            &issue.issue.id,
-        )?;
-        let timeline = {
+        let (timeline, timeline_has_next) = {
             let mut statement = self.connection.prepare(
                 "SELECT sequence, kind, actor, payload, created_at
-                 FROM repository_event WHERE issue_id = ?1 ORDER BY sequence",
+                 FROM repository_event WHERE issue_id = ?1 ORDER BY sequence DESC
+                 LIMIT ?2 OFFSET ?3",
             )?;
-            statement
-                .query_map([&issue.issue.id], |row| {
-                    Ok(IssueTimelineRecord {
-                        sequence: row.get(0)?,
-                        kind: row.get(1)?,
-                        actor: row.get(2)?,
-                        payload: row.get(3)?,
-                        created_at: row.get(4)?,
-                    })
-                })?
-                .collect::<Result<Vec<_>, _>>()?
+            let mut records = statement
+                .query_map(
+                    rusqlite::params![issue.issue.id, limit, timeline_offset],
+                    |row| {
+                        Ok(IssueTimelineRecord {
+                            sequence: row.get(0)?,
+                            kind: row.get(1)?,
+                            actor: row.get(2)?,
+                            payload: row.get(3)?,
+                            created_at: row.get(4)?,
+                        })
+                    },
+                )?
+                .collect::<Result<Vec<_>, _>>()?;
+            let has_next = records.len() > page_size;
+            records.truncate(page_size);
+            records.reverse();
+            (records, has_next)
         };
         Ok(IssueDetail {
             can_comment: access.active_actor_id.is_some() && access.can_read(),
             can_edit: access.can_write_issue(issue.author_account_id),
-            can_maintain: access.can_maintain(),
             repository: access.repository,
             issue: issue.issue,
             comments,
-            labels,
-            assignees,
             timeline,
+            comments_page,
+            comments_has_next,
+            timeline_page,
+            timeline_has_next,
         })
     }
 
@@ -2698,17 +3415,14 @@
             Some(actor_id) => self
                 .connection
                 .query_row(
-                    "SELECT id, pushes, issues, pull_requests, created_at, updated_at
+                    "SELECT id, created_at, updated_at
                  FROM watch WHERE repository_id = ?1 AND account_id = ?2",
                     rusqlite::params![access.repository.id, actor_id],
                     |row| {
                         Ok(WatchRecord {
                             id: row.get(0)?,
-                            pushes: row.get(1)?,
-                            issues: row.get(2)?,
-                            pull_requests: row.get(3)?,
-                            created_at: row.get(4)?,
-                            updated_at: row.get(5)?,
+                            created_at: row.get(1)?,
+                            updated_at: row.get(2)?,
                         })
                     },
                 )
@@ -2723,7 +3437,7 @@
         owner: &str,
         repository: &str,
         actor: &str,
-        preferences: WatchPreferences,
+        watching: bool,
         changed_at: i64,
     ) -> Result<Option<WatchRecord>, StoreError> {
         let transaction = self
@@ -2734,7 +3448,7 @@
         if !access.can_read() {
             return Err(StoreError::WatchDenied);
         }
-        if !preferences.any() {
+        if !watching {
             transaction.execute(
                 "DELETE FROM watch WHERE repository_id = ?1 AND account_id = ?2",
                 rusqlite::params![access.repository.id, actor_id],
@@ -2744,35 +3458,21 @@
         }
         transaction.execute(
             "INSERT INTO watch
-             (id, repository_id, account_id, pushes, issues, pull_requests,
-              created_at, updated_at)
-             VALUES (lower(hex(randomblob(16))), ?1, ?2, ?3, ?4, ?5, ?6, ?6)
+             (id, repository_id, account_id, created_at, updated_at)
+             VALUES (lower(hex(randomblob(16))), ?1, ?2, ?3, ?3)
              ON CONFLICT (repository_id, account_id) DO UPDATE SET
-                 pushes = excluded.pushes,
-                 issues = excluded.issues,
-                 pull_requests = excluded.pull_requests,
                  updated_at = excluded.updated_at",
-            rusqlite::params![
-                access.repository.id,
-                actor_id,
-                preferences.pushes,
-                preferences.issues,
-                preferences.pull_requests,
-                changed_at,
-            ],
+            rusqlite::params![access.repository.id, actor_id, changed_at],
         )?;
         let record = transaction.query_row(
-            "SELECT id, pushes, issues, pull_requests, created_at, updated_at
+            "SELECT id, created_at, updated_at
              FROM watch WHERE repository_id = ?1 AND account_id = ?2",
             rusqlite::params![access.repository.id, actor_id],
             |row| {
                 Ok(WatchRecord {
                     id: row.get(0)?,
-                    pushes: row.get(1)?,
-                    issues: row.get(2)?,
-                    pull_requests: row.get(3)?,
-                    created_at: row.get(4)?,
-                    updated_at: row.get(5)?,
+                    created_at: row.get(1)?,
+                    updated_at: row.get(2)?,
                 })
             },
         )?;
@@ -2783,12 +3483,9 @@
     pub(crate) fn create_feed_token(
         &mut self,
         actor: &str,
-        scope: &str,
-        repository: Option<(&str, &str)>,
         token_hash: &[u8; 32],
         created_at: i64,
     ) -> Result<FeedTokenRecord, StoreError> {
-        validate_feed_scope(scope, repository)?;
         let transaction = self
             .connection
             .transaction_with_behavior(TransactionBehavior::Immediate)?;
@@ -2802,35 +3499,16 @@
         if active_tokens >= MAX_ACTIVE_FEED_TOKENS {
             return Err(StoreError::FeedTokenLimit);
         }
-        let repository_id = match repository {
-            Some((owner, repository)) => {
-                let access = repository_issue_access(&transaction, owner, repository, Some(actor))?;
-                if !access.can_read() {
-                    return Err(StoreError::FeedTokenDenied);
-                }
-                Some(access.repository.id)
-            }
-            None => None,
-        };
         transaction.execute(
             "INSERT INTO feed_token
              (id, token_hash, account_id, scope, repository_id, created_at, revoked_at)
-             VALUES (lower(hex(randomblob(16))), ?1, ?2, ?3, ?4, ?5, NULL)",
-            rusqlite::params![
-                token_hash.as_slice(),
-                account_id,
-                scope,
-                repository_id,
-                created_at
-            ],
+             VALUES (lower(hex(randomblob(16))), ?1, ?2, 'watched', NULL, ?3, NULL)",
+            rusqlite::params![token_hash.as_slice(), account_id, created_at],
         )?;
         let record = transaction.query_row(
-            "SELECT feed_token.id, feed_token.scope, owner.username, repository.slug,
-                    feed_token.created_at, feed_token.revoked_at
+            "SELECT id, scope, created_at, revoked_at
              FROM feed_token
-             LEFT JOIN repository ON repository.id = feed_token.repository_id
-             LEFT JOIN account AS owner ON owner.id = repository.owner_account_id
-             WHERE feed_token.rowid = last_insert_rowid()",
+             WHERE rowid = last_insert_rowid()",
             [],
             feed_token_from_row,
         )?;
@@ -2841,13 +3519,10 @@
     pub(crate) fn feed_tokens(&self, actor: &str) -> Result<Vec<FeedTokenRecord>, StoreError> {
         let account_id = active_account_id(&self.connection, actor)?;
         let mut statement = self.connection.prepare(
-            "SELECT feed_token.id, feed_token.scope, owner.username, repository.slug,
-                    feed_token.created_at, feed_token.revoked_at
+            "SELECT id, scope, created_at, revoked_at
              FROM feed_token
-             LEFT JOIN repository ON repository.id = feed_token.repository_id
-             LEFT JOIN account AS owner ON owner.id = repository.owner_account_id
-             WHERE feed_token.account_id = ?1
-             ORDER BY feed_token.created_at DESC, feed_token.id
+             WHERE account_id = ?1
+             ORDER BY created_at DESC, id
              LIMIT 100",
         )?;
         statement
@@ -2867,12 +3542,12 @@
             .connection
             .transaction_with_behavior(TransactionBehavior::Immediate)?;
         let account_id = active_account_id(&transaction, actor)?;
-        let current = transaction
+        transaction
             .query_row(
-                "SELECT scope, repository_id FROM feed_token
+                "SELECT 1 FROM feed_token
                  WHERE id = ?1 AND account_id = ?2 AND revoked_at IS NULL",
                 rusqlite::params![id, account_id],
-                |row| Ok((row.get::<_, String>(0)?, row.get::<_, Option<String>>(1)?)),
+                |_| Ok(()),
             )
             .optional()?
             .ok_or(StoreError::FeedTokenNotFound)?;
@@ -2884,22 +3559,13 @@
         transaction.execute(
             "INSERT INTO feed_token
              (id, token_hash, account_id, scope, repository_id, created_at, revoked_at)
-             VALUES (lower(hex(randomblob(16))), ?1, ?2, ?3, ?4, ?5, NULL)",
-            rusqlite::params![
-                token_hash.as_slice(),
-                account_id,
-                current.0,
-                current.1,
-                changed_at
-            ],
+             VALUES (lower(hex(randomblob(16))), ?1, ?2, 'watched', NULL, ?3, NULL)",
+            rusqlite::params![token_hash.as_slice(), account_id, changed_at],
         )?;
         let record = transaction.query_row(
-            "SELECT feed_token.id, feed_token.scope, owner.username, repository.slug,
-                    feed_token.created_at, feed_token.revoked_at
+            "SELECT id, scope, created_at, revoked_at
              FROM feed_token
-             LEFT JOIN repository ON repository.id = feed_token.repository_id
-             LEFT JOIN account AS owner ON owner.id = repository.owner_account_id
-             WHERE feed_token.rowid = last_insert_rowid()",
+             WHERE rowid = last_insert_rowid()",
             [],
             feed_token_from_row,
         )?;
@@ -2934,8 +3600,7 @@
         let grant = self
             .connection
             .query_row(
-                "SELECT feed_token.scope, feed_token.repository_id,
-                        account.id, account.username
+                "SELECT feed_token.scope, account.id, account.username
                  FROM feed_token
                  JOIN account ON account.id = feed_token.account_id
                  WHERE feed_token.token_hash = ?1 AND feed_token.revoked_at IS NULL
@@ -2944,31 +3609,49 @@
                 |row| {
                     Ok((
                         row.get::<_, String>(0)?,
-                        row.get::<_, Option<String>>(1)?,
-                        row.get::<_, i64>(2)?,
-                        row.get::<_, String>(3)?,
+                        row.get::<_, i64>(1)?,
+                        row.get::<_, String>(2)?,
                     ))
                 },
             )
             .optional()?
             .ok_or(StoreError::FeedTokenNotFound)?;
-        let events = match grant.0.as_str() {
-            "repository" => token_repository_events(
-                &self.connection,
-                grant.1.as_deref().ok_or(StoreError::InvalidFeedScope)?,
-                grant.2,
-                limit,
-            )?,
-            "watched" => watched_feed_events(&self.connection, grant.2, limit)?,
-            "assignments" => assignment_feed_events(&self.connection, grant.2, &grant.3, limit)?,
-            "mentions" => mention_feed_events(&self.connection, grant.2, &grant.3, limit)?,
-            _ => return Err(StoreError::InvalidFeedScope),
-        };
+        if grant.0 != "watched" {
+            return Err(StoreError::InvalidFeedScope);
+        }
+        let events = watched_activity_events(&self.connection, grant.1, None, limit)?;
         Ok(TokenFeedPage {
-            scope: grant.0,
-            username: grant.3,
-            target: grant.1,
+            username: grant.2,
             events,
+        })
+    }
+
+    pub(crate) fn watched_activity_page(
+        &self,
+        actor: &str,
+        before: Option<&ActivityCursor>,
+        limit: usize,
+    ) -> Result<ActivityPage, StoreError> {
+        if limit == 0 || limit > 100 {
+            return Err(StoreError::EventLimit);
+        }
+        let account_id = active_account_id(&self.connection, actor)?;
+        let query_limit = limit.checked_add(1).ok_or(StoreError::EventLimit)?;
+        let query_limit = i64::try_from(query_limit).map_err(|_| StoreError::EventLimit)?;
+        let mut events =
+            watched_activity_events(&self.connection, account_id, before, query_limit)?;
+        let has_next = events.len() > limit;
+        events.truncate(limit);
+        let next_before = has_next.then(|| {
+            let event = &events[events.len() - 1].event;
+            ActivityCursor {
+                created_at: event.created_at,
+                event_id: event.event_id.clone(),
+            }
+        });
+        Ok(ActivityPage {
+            events,
+            next_before,
         })
     }
 
@@ -2976,15 +3659,10 @@
         &self,
         actor: Option<&str>,
         limit: usize,
-        max_field_bytes: usize,
         mut visit: impl FnMut(MetadataSearchCandidate) -> bool,
     ) -> Result<bool, StoreError> {
         let query_limit = limit.checked_add(1).ok_or(StoreError::EventLimit)?;
         let query_limit = i64::try_from(query_limit).map_err(|_| StoreError::EventLimit)?;
-        let max_field_bytes = max_field_bytes
-            .checked_add(1)
-            .and_then(|value| i64::try_from(value).ok())
-            .ok_or(StoreError::EventLimit)?;
         let mut statement = self.connection.prepare(
             "WITH visible_repository AS (
                  SELECT repository.id, owner.username AS owner, repository.slug
@@ -3000,36 +3678,14 @@
                             WHERE repository_collaborator.repository_id = repository.id
                               AND repository_collaborator.account_id = actor.id
                         ))
-             ), candidates AS (
-                 SELECT 'repository' AS kind, visible_repository.id AS record_id,
-                        visible_repository.owner, visible_repository.slug,
-                        NULL AS issue_number,
-                        CAST(visible_repository.owner || '/' || visible_repository.slug AS BLOB)
-                            AS title,
-                        X'' AS body
-                 FROM visible_repository
-                 UNION ALL
-                 SELECT 'issue', issue.id, visible_repository.owner,
-                        visible_repository.slug, issue.number,
-                        COALESCE(substr(CAST(issue.title AS BLOB), 1, ?3), X''),
-                        COALESCE(substr(CAST(issue.body AS BLOB), 1, ?3), X'')
-                 FROM issue
-                 JOIN visible_repository ON visible_repository.id = issue.repository_id
-                 UNION ALL
-                 SELECT 'issue-comment', issue_comment.id, visible_repository.owner,
-                        visible_repository.slug, issue.number,
-                        COALESCE(substr(CAST(issue.title AS BLOB), 1, ?3), X''),
-                        COALESCE(substr(CAST(issue_comment.body AS BLOB), 1, ?3), X'')
-                 FROM issue_comment
-                 JOIN issue ON issue.id = issue_comment.issue_id
-                 JOIN visible_repository ON visible_repository.id = issue.repository_id
              )
-             SELECT kind, record_id, owner, slug, issue_number, title, body
-             FROM candidates
-             ORDER BY owner, slug, kind, issue_number, record_id
+             SELECT 'repository', id, owner, slug,
+                    CAST(owner || '/' || slug AS BLOB), X''
+             FROM visible_repository
+             ORDER BY owner, slug
              LIMIT ?2",
         )?;
-        let mut rows = statement.query(rusqlite::params![actor, query_limit, max_field_bytes])?;
+        let mut rows = statement.query(rusqlite::params![actor, query_limit])?;
         let mut visited = 0_usize;
         while let Some(row) = rows.next()? {
             if visited == limit {
@@ -3041,9 +3697,8 @@
                 record_id: row.get(1)?,
                 owner: row.get(2)?,
                 repository: row.get(3)?,
-                issue_number: row.get(4)?,
-                title: row.get(5)?,
-                body: row.get(6)?,
+                title: row.get(4)?,
+                body: row.get(5)?,
             };
             if !visit(candidate) {
                 return Ok(true);
@@ -3339,17 +3994,20 @@
     ) -> 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,
+            "SELECT account.username, repository.slug, repository.visibility, repository.state,
+                    COALESCE(repository_profile.description, ''),
                     COALESCE(MAX(repository_event.created_at), repository.created_at)
              FROM repository
              JOIN account ON account.id = repository.owner_account_id
+             LEFT JOIN repository_profile ON repository_profile.repository_id = repository.id
              LEFT JOIN repository_event ON repository_event.repository_id = repository.id
-             WHERE repository.state = 'active'
-               AND ((?1 IS NULL AND repository.visibility = 'public')
+             WHERE ((?1 IS NULL AND repository.visibility = 'public'
+                     AND repository.state = 'active')
                     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
+                      repository.visibility, repository.state, repository_profile.description,
+                      repository.created_at
+             ORDER BY 6 DESC, account.username, repository.slug
              LIMIT ?2",
         )?;
         statement
@@ -3358,11 +4016,137 @@
                     owner: row.get(0)?,
                     slug: row.get(1)?,
                     visibility: row.get(2)?,
-                    updated_at: row.get(3)?,
+                    state: row.get(3)?,
+                    description: row.get(4)?,
+                    updated_at: row.get(5)?,
                 })
             })?
             .collect::<Result<Vec<_>, _>>()
             .map_err(Into::into)
+    }
+
+    pub(crate) fn public_profile(&self, username: &str) -> Result<PublicProfile, StoreError> {
+        let profile = self.connection.query_row(
+            "SELECT username, bio, contact_email
+             FROM account
+             WHERE username = ?1 AND state = 'active'",
+            [username],
+            |row| {
+                Ok(PublicProfile {
+                    username: row.get(0)?,
+                    bio: row.get(1)?,
+                    contact_email: row.get(2)?,
+                    repositories: Vec::new(),
+                    page: 1,
+                    has_next: false,
+                })
+            },
+        );
+        let mut profile = match profile {
+            Ok(profile) => profile,
+            Err(rusqlite::Error::QueryReturnedNoRows) => {
+                return Err(StoreError::AccountNotFound(username.to_owned()));
+            }
+            Err(error) => return Err(error.into()),
+        };
+        let mut statement = self.connection.prepare(
+            "SELECT account.username, repository.slug, repository.visibility, repository.state,
+                    COALESCE(repository_profile.description, ''),
+                    COALESCE(MAX(repository_event.created_at), repository.created_at)
+             FROM repository
+             JOIN account ON account.id = repository.owner_account_id
+             LEFT JOIN repository_profile ON repository_profile.repository_id = repository.id
+             LEFT JOIN repository_event ON repository_event.repository_id = repository.id
+             WHERE account.username = ?1
+               AND repository.state = 'active'
+               AND repository.visibility = 'public'
+             GROUP BY repository.id, account.username, repository.slug,
+                      repository.visibility, repository.state, repository_profile.description,
+                      repository.created_at
+             ORDER BY 6 DESC, repository.slug
+             LIMIT 100",
+        )?;
+        profile.repositories = statement
+            .query_map([username], |row| {
+                Ok(HomeRepositoryRecord {
+                    owner: row.get(0)?,
+                    slug: row.get(1)?,
+                    visibility: row.get(2)?,
+                    state: row.get(3)?,
+                    description: row.get(4)?,
+                    updated_at: row.get(5)?,
+                })
+            })?
+            .collect::<Result<Vec<_>, _>>()?;
+        Ok(profile)
+    }
+
+    pub(crate) fn public_profile_page(
+        &self,
+        username: &str,
+        page: usize,
+        page_size: usize,
+    ) -> Result<PublicProfile, StoreError> {
+        let mut profile = self.public_profile(username)?;
+        let offset = page
+            .checked_sub(1)
+            .and_then(|page| page.checked_mul(page_size))
+            .ok_or(StoreError::EventLimit)?;
+        let limit = i64::try_from(page_size.checked_add(1).ok_or(StoreError::EventLimit)?)
+            .map_err(|_| StoreError::EventLimit)?;
+        let offset = i64::try_from(offset).map_err(|_| StoreError::EventLimit)?;
+        let mut statement = self.connection.prepare(
+            "SELECT account.username, repository.slug, repository.visibility, repository.state,
+                    COALESCE(repository_profile.description, ''),
+                    COALESCE(MAX(repository_event.created_at), repository.created_at)
+             FROM repository
+             JOIN account ON account.id = repository.owner_account_id
+             LEFT JOIN repository_profile ON repository_profile.repository_id = repository.id
+             LEFT JOIN repository_event ON repository_event.repository_id = repository.id
+             WHERE account.username = ?1
+               AND repository.state = 'active'
+               AND repository.visibility = 'public'
+             GROUP BY repository.id, account.username, repository.slug,
+                      repository.visibility, repository.state, repository_profile.description,
+                      repository.created_at
+             ORDER BY 6 DESC, repository.slug
+             LIMIT ?2 OFFSET ?3",
+        )?;
+        let mut repositories = statement
+            .query_map(rusqlite::params![username, limit, offset], |row| {
+                Ok(HomeRepositoryRecord {
+                    owner: row.get(0)?,
+                    slug: row.get(1)?,
+                    visibility: row.get(2)?,
+                    state: row.get(3)?,
+                    description: row.get(4)?,
+                    updated_at: row.get(5)?,
+                })
+            })?
+            .collect::<Result<Vec<_>, _>>()?;
+        profile.has_next = repositories.len() > page_size;
+        repositories.truncate(page_size);
+        profile.repositories = repositories;
+        profile.page = page;
+        Ok(profile)
+    }
+
+    pub(crate) fn update_profile(
+        &mut self,
+        username: &str,
+        bio: &str,
+        contact_email: &str,
+    ) -> Result<(), StoreError> {
+        let changed = self.connection.execute(
+            "UPDATE account SET bio = ?2, contact_email = ?3
+             WHERE username = ?1 AND state = 'active'",
+            rusqlite::params![username, bio, contact_email],
+        )?;
+        if changed == 1 {
+            Ok(())
+        } else {
+            Err(StoreError::AccountNotFound(username.to_owned()))
+        }
     }
 
     #[allow(
@@ -3505,8 +4289,19 @@
 pub(crate) struct NewLoginApproval<'a> {
     pub(crate) secret_hash: &'a [u8; 32],
     pub(crate) csrf_hash: &'a [u8; 32],
+    pub(crate) purpose: &'a str,
+    pub(crate) expected_username: Option<&'a str>,
     pub(crate) created_at: i64,
     pub(crate) expires_at: i64,
+}
+
+pub(crate) struct AccountKeyAuthorization<'a> {
+    pub(crate) username: &'a str,
+    pub(crate) session_hash: &'a [u8; 32],
+    pub(crate) csrf_hash: &'a [u8; 32],
+    pub(crate) secret_hash: &'a [u8; 32],
+    pub(crate) changed_at: i64,
+    pub(crate) correlation_id: &'a str,
 }
 
 pub(crate) struct ApproveLogin<'a> {
@@ -3550,6 +4345,7 @@
     pub(crate) username: String,
     pub(crate) is_administrator: bool,
     pub(crate) expires_at: i64,
+    pub(crate) ssh_public_key_id: Option<i64>,
 }
 
 pub(crate) struct NewRepository<'a> {
@@ -3557,11 +4353,22 @@
     pub(crate) owner: &'a str,
     pub(crate) slug: &'a str,
     pub(crate) object_format: &'a str,
+    pub(crate) default_branch: &'a str,
     pub(crate) created_at: i64,
     pub(crate) origin: RepositoryOrigin,
     pub(crate) initial_references: &'a [NewRepositoryReference],
     pub(crate) actor: &'a str,
     pub(crate) correlation_id: &'a str,
+}
+
+pub(crate) struct MaintenanceResult {
+    pub(crate) deleted: usize,
+}
+
+pub(crate) struct RecordPage<T> {
+    pub(crate) items: Vec<T>,
+    pub(crate) page: usize,
+    pub(crate) has_next: bool,
 }
 
 #[derive(Clone, Copy)]
@@ -3612,7 +4419,32 @@
     pub(crate) owner: String,
     pub(crate) slug: String,
     pub(crate) visibility: String,
+    pub(crate) state: String,
+    pub(crate) description: String,
     pub(crate) updated_at: i64,
+}
+
+pub(crate) struct RepositorySettings {
+    pub(crate) repository: RepositoryRecord,
+    pub(crate) description: String,
+    pub(crate) collaborators: Vec<RepositoryCollaboratorRecord>,
+    pub(crate) default_branch: String,
+    pub(crate) branches: Vec<String>,
+}
+
+pub(crate) struct RepositoryCollaboratorRecord {
+    pub(crate) username: String,
+    pub(crate) role: String,
+}
+
+#[derive(Debug, Eq, PartialEq)]
+pub(crate) struct PublicProfile {
+    pub(crate) username: String,
+    pub(crate) bio: String,
+    pub(crate) contact_email: String,
+    pub(crate) repositories: Vec<HomeRepositoryRecord>,
+    pub(crate) page: usize,
+    pub(crate) has_next: bool,
 }
 
 #[derive(Debug, Serialize)]
@@ -3778,12 +4610,13 @@
     pub(crate) repository: RepositoryRecord,
     pub(crate) issue: IssueRecord,
     pub(crate) comments: Vec<IssueCommentRecord>,
-    pub(crate) labels: Vec<String>,
-    pub(crate) assignees: Vec<String>,
     pub(crate) timeline: Vec<IssueTimelineRecord>,
     pub(crate) can_comment: bool,
     pub(crate) can_edit: bool,
-    pub(crate) can_maintain: bool,
+    pub(crate) comments_page: usize,
+    pub(crate) comments_has_next: bool,
+    pub(crate) timeline_page: usize,
+    pub(crate) timeline_has_next: bool,
 }
 
 pub(crate) struct NewIssue<'a> {
@@ -3850,6 +4683,12 @@
     pub(crate) revisions: Vec<PullRequestRevisionRecord>,
     pub(crate) reviews: Vec<PullRequestReviewRecord>,
     pub(crate) timeline: Vec<PullRequestTimelineRecord>,
+    pub(crate) reviews_page: usize,
+    pub(crate) reviews_has_next: bool,
+    pub(crate) timeline_page: usize,
+    pub(crate) timeline_has_next: bool,
+    pub(crate) can_edit: bool,
+    pub(crate) can_change_state: bool,
     pub(crate) can_revise: bool,
     pub(crate) can_review: bool,
     pub(crate) can_merge: bool,
@@ -3947,25 +4786,9 @@
     pub(crate) changed_at: i64,
 }
 
-#[derive(Clone, Copy, Debug, Eq, PartialEq)]
-pub(crate) struct WatchPreferences {
-    pub(crate) pushes: bool,
-    pub(crate) issues: bool,
-    pub(crate) pull_requests: bool,
-}
-
-impl WatchPreferences {
-    pub(crate) fn any(self) -> bool {
-        self.pushes || self.issues || self.pull_requests
-    }
-}
-
 #[derive(Clone, Debug, Eq, PartialEq)]
 pub(crate) struct WatchRecord {
     pub(crate) id: String,
-    pub(crate) pushes: bool,
-    pub(crate) issues: bool,
-    pub(crate) pull_requests: bool,
     pub(crate) created_at: i64,
     pub(crate) updated_at: i64,
 }
@@ -3974,8 +4797,6 @@
 pub(crate) struct FeedTokenRecord {
     pub(crate) id: String,
     pub(crate) scope: String,
-    pub(crate) owner: Option<String>,
-    pub(crate) repository: Option<String>,
     pub(crate) created_at: i64,
     pub(crate) revoked_at: Option<i64>,
 }
@@ -3985,10 +4806,18 @@
     pub(crate) event: RepositoryEventRecord,
 }
 
+pub(crate) struct ActivityCursor {
+    pub(crate) created_at: i64,
+    pub(crate) event_id: String,
+}
+
+pub(crate) struct ActivityPage {
+    pub(crate) events: Vec<ActivityEventRecord>,
+    pub(crate) next_before: Option<ActivityCursor>,
+}
+
 pub(crate) struct TokenFeedPage {
-    pub(crate) scope: String,
     pub(crate) username: String,
-    pub(crate) target: Option<String>,
     pub(crate) events: Vec<ActivityEventRecord>,
 }
 
@@ -3997,7 +4826,6 @@
     pub(crate) record_id: String,
     pub(crate) owner: String,
     pub(crate) repository: String,
-    pub(crate) issue_number: Option<i64>,
     pub(crate) title: Vec<u8>,
     pub(crate) body: Vec<u8>,
 }
@@ -4093,6 +4921,63 @@
         Err(error) => Err(error.into()),
         Ok(_) => unreachable!("an INSERT changes one row"),
     }
+}
+
+fn consume_account_key_authorization(
+    transaction: &rusqlite::Transaction<'_>,
+    authorization: &AccountKeyAuthorization<'_>,
+) -> Result<(i64, Option<i64>), StoreError> {
+    let session = transaction
+        .query_row(
+            "SELECT web_session.account_id, web_session.ssh_public_key_id
+             FROM web_session
+             JOIN account ON account.id = web_session.account_id
+             WHERE web_session.session_hash = ?1 AND web_session.csrf_hash = ?2
+               AND web_session.ended_at IS NULL AND web_session.expires_at >= ?3
+               AND account.username = ?4 AND account.state = 'active'",
+            rusqlite::params![
+                authorization.session_hash,
+                authorization.csrf_hash,
+                authorization.changed_at,
+                authorization.username,
+            ],
+            |row| Ok((row.get::<_, i64>(0)?, row.get::<_, Option<i64>>(1)?)),
+        )
+        .optional()?
+        .ok_or(StoreError::InvalidSession)?;
+    let approval = transaction
+        .query_row(
+            "SELECT ssh_login_approval.account_id
+             FROM ssh_login_approval
+             JOIN ssh_public_key
+               ON ssh_public_key.id = ssh_login_approval.ssh_public_key_id
+             WHERE secret_hash = ?1 AND csrf_hash = ?2
+               AND purpose = 'account-key' AND expected_account_id = ?3
+               AND ssh_login_approval.account_id = ?3 AND approved_at IS NOT NULL
+               AND consumed_at IS NULL AND expires_at >= ?4
+               AND ssh_public_key.revoked_at IS NULL",
+            rusqlite::params![
+                authorization.secret_hash,
+                authorization.csrf_hash,
+                session.0,
+                authorization.changed_at,
+            ],
+            |row| row.get::<_, i64>(0),
+        )
+        .optional()?
+        .ok_or(StoreError::InvalidLoginApproval)?;
+    if approval != session.0 {
+        return Err(StoreError::InvalidLoginApproval);
+    }
+    let changed = transaction.execute(
+        "UPDATE ssh_login_approval SET consumed_at = ?2
+         WHERE secret_hash = ?1 AND consumed_at IS NULL",
+        rusqlite::params![authorization.secret_hash, authorization.changed_at],
+    )?;
+    if changed != 1 {
+        return Err(StoreError::InvalidLoginApproval);
+    }
+    Ok(session)
 }
 
 fn insert_audit_event(
@@ -4312,21 +5197,12 @@
     })
 }
 
-fn validate_feed_scope(scope: &str, repository: Option<(&str, &str)>) -> Result<(), StoreError> {
-    match (scope, repository) {
-        ("repository", Some(_)) | ("watched" | "assignments" | "mentions", None) => Ok(()),
-        _ => Err(StoreError::InvalidFeedScope),
-    }
-}
-
 fn feed_token_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<FeedTokenRecord> {
     Ok(FeedTokenRecord {
         id: row.get(0)?,
         scope: row.get(1)?,
-        owner: row.get(2)?,
-        repository: row.get(3)?,
-        created_at: row.get(4)?,
-        revoked_at: row.get(5)?,
+        created_at: row.get(2)?,
+        revoked_at: row.get(3)?,
     })
 }
 
@@ -4369,41 +5245,13 @@
      JOIN repository ON repository.id = repository_event.repository_id
      JOIN account AS owner ON owner.id = repository.owner_account_id";
 
-fn token_repository_events(
-    connection: &Connection,
-    repository_id: &str,
-    account_id: i64,
-    limit: i64,
-) -> Result<Vec<ActivityEventRecord>, StoreError> {
-    let sql = format!(
-        "{ACTIVITY_SELECT}
-         WHERE repository.id = ?1 AND repository.state = 'active'
-           AND (repository.visibility = 'public'
-                OR repository.owner_account_id = ?2
-                OR EXISTS (
-                    SELECT 1 FROM repository_collaborator
-                    WHERE repository_collaborator.repository_id = repository.id
-                      AND repository_collaborator.account_id = ?2
-                ))
-         ORDER BY repository_event.sequence DESC LIMIT ?3"
-    );
-    let mut statement = connection.prepare(&sql)?;
-    statement
-        .query_map(
-            rusqlite::params![repository_id, account_id, limit],
-            activity_from_row,
-        )?
-        .collect::<Result<Vec<_>, _>>()
-        .map_err(Into::into)
-}
-
-fn watched_feed_events(
+fn watched_activity_events(
     connection: &Connection,
     account_id: i64,
+    before: Option<&ActivityCursor>,
     limit: i64,
 ) -> Result<Vec<ActivityEventRecord>, StoreError> {
-    let sql = format!(
-        "{ACTIVITY_SELECT}
+    let visibility = "
          JOIN watch
            ON watch.repository_id = repository.id AND watch.account_id = ?1
          WHERE repository.state = 'active'
@@ -4413,108 +5261,39 @@
                     SELECT 1 FROM repository_collaborator
                     WHERE repository_collaborator.repository_id = repository.id
                       AND repository_collaborator.account_id = ?1
-                ))
-           AND ((watch.pushes = 1 AND repository_event.kind IN
-                    ('push', 'ref-created', 'ref-updated', 'ref-deleted',
-                     'tag-created', 'tag-updated', 'tag-deleted'))
-                OR (watch.issues = 1 AND repository_event.kind LIKE 'issue-%')
-                OR (watch.pull_requests = 1
-                    AND repository_event.kind LIKE 'pull-request-%'))
-         ORDER BY repository_event.created_at DESC, repository_event.event_id DESC
-         LIMIT ?2"
-    );
-    let mut statement = connection.prepare(&sql)?;
-    statement
-        .query_map(rusqlite::params![account_id, limit], activity_from_row)?
-        .collect::<Result<Vec<_>, _>>()
-        .map_err(Into::into)
-}
-
-fn assignment_feed_events(
-    connection: &Connection,
-    account_id: i64,
-    username: &str,
-    limit: i64,
-) -> Result<Vec<ActivityEventRecord>, StoreError> {
-    let sql = format!(
-        "{ACTIVITY_SELECT}
-         WHERE repository.state = 'active'
-           AND repository_event.kind = 'issue-assigned'
-           AND json_extract(repository_event.payload, '$.assignee') = ?2
-           AND (repository.visibility = 'public'
-                OR repository.owner_account_id = ?1
-                OR EXISTS (
-                    SELECT 1 FROM repository_collaborator
-                    WHERE repository_collaborator.repository_id = repository.id
-                      AND repository_collaborator.account_id = ?1
-                ))
-         ORDER BY repository_event.created_at DESC, repository_event.event_id DESC
-         LIMIT ?3"
-    );
-    let mut statement = connection.prepare(&sql)?;
-    statement
-        .query_map(
-            rusqlite::params![account_id, username, limit],
-            activity_from_row,
-        )?
-        .collect::<Result<Vec<_>, _>>()
-        .map_err(Into::into)
-}
-
-fn mention_feed_events(
-    connection: &Connection,
-    account_id: i64,
-    username: &str,
-    limit: i64,
-) -> Result<Vec<ActivityEventRecord>, StoreError> {
-    let scan_limit = limit.saturating_mul(50).min(1_000);
-    let sql = format!(
-        "{ACTIVITY_SELECT}
-         WHERE repository.state = 'active'
-           AND repository_event.kind IN
-               ('issue-created', 'issue-edited', 'issue-commented')
-           AND (repository.visibility = 'public'
-                OR repository.owner_account_id = ?1
-                OR EXISTS (
-                    SELECT 1 FROM repository_collaborator
-                    WHERE repository_collaborator.repository_id = repository.id
-                      AND repository_collaborator.account_id = ?1
-                ))
-         ORDER BY repository_event.created_at DESC, repository_event.event_id DESC
-         LIMIT ?2"
-    );
-    let mut statement = connection.prepare(&sql)?;
-    let candidates = statement
-        .query_map(rusqlite::params![account_id, scan_limit], activity_from_row)?
-        .collect::<Result<Vec<_>, _>>()?;
-    let limit = usize::try_from(limit).map_err(|_| StoreError::EventLimit)?;
-    Ok(candidates
-        .into_iter()
-        .filter(|record| event_mentions(&record.event, username))
-        .take(limit)
-        .collect())
-}
-
-fn event_mentions(event: &RepositoryEventRecord, username: &str) -> bool {
-    if event.payload_version != event::PAYLOAD_VERSION {
-        return false;
+                ))";
+    match before {
+        Some(before) => {
+            let sql = format!(
+                "{ACTIVITY_SELECT}{visibility}
+                 AND (repository_event.created_at < ?2
+                      OR (repository_event.created_at = ?2
+                          AND repository_event.event_id < ?3))
+                 ORDER BY repository_event.created_at DESC, repository_event.event_id DESC
+                 LIMIT ?4"
+            );
+            let mut statement = connection.prepare(&sql)?;
+            statement
+                .query_map(
+                    rusqlite::params![account_id, before.created_at, before.event_id, limit],
+                    activity_from_row,
+                )?
+                .collect::<Result<Vec<_>, _>>()
+                .map_err(Into::into)
+        }
+        None => {
+            let sql = format!(
+                "{ACTIVITY_SELECT}{visibility}
+                 ORDER BY repository_event.created_at DESC, repository_event.event_id DESC
+                 LIMIT ?2"
+            );
+            let mut statement = connection.prepare(&sql)?;
+            statement
+                .query_map(rusqlite::params![account_id, limit], activity_from_row)?
+                .collect::<Result<Vec<_>, _>>()
+                .map_err(Into::into)
+        }
     }
-    let Ok(payload) = serde_json::from_str::<serde_json::Value>(&event.payload) else {
-        return false;
-    };
-    let Some(body) = payload.get("body").and_then(serde_json::Value::as_str) else {
-        return false;
-    };
-    let mention = format!("@{username}");
-    body.match_indices(&mention).any(|(start, _)| {
-        let before = body[..start].chars().next_back();
-        let after = body[start + mention.len()..].chars().next();
-        !before.is_some_and(is_username_character) && !after.is_some_and(is_username_character)
-    })
-}
-
-fn is_username_character(character: char) -> bool {
-    character.is_ascii_alphanumeric() || character == '-'
 }
 
 fn find_issue(
@@ -4580,18 +5359,6 @@
         updated_at: row.get(7)?,
         closed_at: row.get(8)?,
     })
-}
-
-fn issue_names(
-    connection: &Connection,
-    sql: &str,
-    issue_id: &str,
-) -> Result<Vec<String>, StoreError> {
-    let mut statement = connection.prepare(sql)?;
-    statement
-        .query_map([issue_id], |row| row.get(0))?
-        .collect::<Result<Vec<_>, _>>()
-        .map_err(Into::into)
 }
 
 fn insert_issue_event(
@@ -4789,6 +5556,19 @@
         "UPDATE web_session SET ended_at = ?2
          WHERE account_id = ?1 AND ended_at IS NULL",
         rusqlite::params![account_id, ended_at],
+    )?;
+    Ok(())
+}
+
+fn revoke_feed_tokens(
+    transaction: &rusqlite::Transaction<'_>,
+    account_id: i64,
+    revoked_at: i64,
+) -> Result<(), StoreError> {
+    transaction.execute(
+        "UPDATE feed_token SET revoked_at = ?2
+         WHERE account_id = ?1 AND revoked_at IS NULL",
+        rusqlite::params![account_id, revoked_at],
     )?;
     Ok(())
 }
@@ -5034,6 +5814,13 @@
             if code.extended_code == rusqlite::ffi::SQLITE_CONSTRAINT_UNIQUE
                 || code.extended_code == rusqlite::ffi::SQLITE_CONSTRAINT_PRIMARYKEY
     )
+}
+
+fn page_offset(page: usize, page_size: usize) -> Result<i64, StoreError> {
+    page.checked_sub(1)
+        .and_then(|page| page.checked_mul(page_size))
+        .and_then(|offset| i64::try_from(offset).ok())
+        .ok_or(StoreError::EventLimit)
 }
 
 #[allow(

src/watch.rs

Mode 100644100644; object f0d311b585990788253bde40

@@ -5,7 +5,7 @@
 
 use crate::auth::{AuthError, validate_username};
 use crate::domain::repository::{RepositoryNameError, validate_slug};
-use crate::store::{RepositoryRecord, Store, StoreError, WatchPreferences, WatchRecord};
+use crate::store::{RepositoryRecord, Store, StoreError, WatchRecord};
 
 #[derive(Clone)]
 pub(crate) struct WatchService {
@@ -40,11 +40,11 @@
         owner: &str,
         repository: &str,
         actor: &str,
-        preferences: WatchPreferences,
+        watching: bool,
     ) -> Result<Option<WatchRecord>, WatchError> {
         validate(owner, repository, actor)?;
         Store::open(&self.database)?
-            .set_watch(owner, repository, actor, preferences, timestamp()?)
+            .set_watch(owner, repository, actor, watching, timestamp()?)
             .map_err(Into::into)
     }
 }

templates/account-key-auth.html

Mode 100644; object c26c7f450c31

@@ -1,0 +1,20 @@
+{% extends "base.html" %}
+{% block title %}{{ heading }} · tit{% endblock %}
+{% block content %}
+  <h1>{{ heading }}</h1>
+  <p>Run this command with an active SSH key for your account:</p>
+  <pre><code>{{ command }}</code></pre>
+  <p>Check the origin and account in the SSH response. Then continue.</p>
+  <form action="{{ action }}" method="post">
+    <input type="hidden" name="csrf" value="{{ csrf }}">
+    <input type="hidden" name="secret" value="{{ secret }}">
+    {% if adding %}
+    <input type="hidden" name="label" value="{{ label }}">
+    <input type="hidden" name="public-key" value="{{ public_key }}">
+    {% else %}
+    <input type="hidden" name="fingerprint" value="{{ fingerprint }}">
+    {% endif %}
+    <button type="submit">Continue</button>
+    <a href="/account">Cancel</a>
+  </form>
+{% endblock %}

templates/account-page.html

Mode 100644100644; object 49596bbb9955b911571fa1cf

@@ -8,6 +8,24 @@
     <dt>Administrator</dt>
     <dd>{% if administrator %}Yes{% else %}No{% endif %}</dd>
   </dl>
+  <div class="two-column">
+  <section>
+  <h2>Public profile</h2>
+  <p><a href="/{{ username }}">View your public profile</a></p>
+  <form action="/account/profile" method="post">
+    <input type="hidden" name="csrf" value="{{ csrf }}">
+    <div class="field">
+      <label for="profile-bio">Bio</label>
+      <textarea id="profile-bio" name="bio" maxlength="512" rows="5">{{ bio }}</textarea>
+    </div>
+    <div class="field">
+      <label for="profile-email">Public contact email</label>
+      <input id="profile-email" type="email" name="contact-email" maxlength="254" value="{{ contact_email }}">
+    </div>
+    <button type="submit">Save profile</button>
+  </form>
+  </section>
+  <section>
   <h2>Create repository</h2>
   <form action="/account/repositories" method="post">
     <input type="hidden" name="csrf" value="{{ csrf }}">
@@ -21,4 +39,62 @@
   <p><a href="/feeds">Manage private feed tokens</a></p>
   <h2>Sessions</h2>
   <p><a href="/logout">Log out all sessions</a></p>
+  </section>
+  </div>
+  <section>
+  <h2>SSH keys</h2>
+  <div class="table-scroll">
+  <table>
+    <thead>
+      <tr>
+        <th>Label</th>
+        <th>Fingerprint</th>
+        <th>Created</th>
+        <th>Last used</th>
+        <th>State</th>
+        <th>Action</th>
+      </tr>
+    </thead>
+    <tbody>
+    {% for key in keys %}
+      <tr>
+        <td>{{ key.label }}</td>
+        <td><code>{{ key.fingerprint }}</code></td>
+        <td>{{ key.created_at }}</td>
+        <td>{{ key.last_used_at }}</td>
+        <td>{% if key.active %}Active{% else %}Revoked{% endif %}</td>
+        <td>
+        {% if key.active %}
+          {% if active_key_count > 1 %}
+          <form action="/account/keys/revoke" method="post">
+            <input type="hidden" name="csrf" value="{{ csrf }}">
+            <input type="hidden" name="fingerprint" value="{{ key.fingerprint }}">
+            <button type="submit">Revoke</button>
+          </form>
+          {% else %}
+          Final active key
+          {% endif %}
+        {% else %}
+          —
+        {% endif %}
+        </td>
+      </tr>
+    {% endfor %}
+    </tbody>
+  </table>
+  </div>
+  <h3>Add SSH key</h3>
+  <form action="/account/keys/add" method="post">
+    <input type="hidden" name="csrf" value="{{ csrf }}">
+    <div class="field">
+      <label for="key-label">Label</label>
+      <input id="key-label" name="label" required maxlength="80">
+    </div>
+    <div class="field">
+      <label for="public-key">SSH public key</label>
+      <textarea id="public-key" name="public-key" required rows="4"></textarea>
+    </div>
+    <button type="submit">Add key</button>
+  </form>
+  </section>
 {% endblock %}

templates/activity.html

Mode 100644; object 40691fed695e

@@ -1,0 +1,23 @@
+{% extends "base.html" %}
+{% block title %}Activity · tit{% endblock %}
+{% block content %}
+  <h1>Activity</h1>
+  <p>Events from repositories that you watch.</p>
+{% if events.is_empty() %}
+  <p>No watched activity is available.</p>
+{% else %}
+  <ol class="activity-list">
+{% for event in events %}
+    <li data-event-id="{{ event.event_id }}">
+      <a href="{{ event.link }}">{{ event.title }}</a>
+      <span>{{ event.created_at|human_time }}</span>
+    </li>
+{% endfor %}
+  </ol>
+{% endif %}
+{% if has_next %}
+  <nav class="pagination" aria-label="Activity pages">
+    <a href="/activity?before-time={{ next_before_time }}&amp;before-id={{ next_before_id }}">Older activity</a>
+  </nav>
+{% endif %}
+{% endblock %}

templates/base.html

Mode 100644100644; object be382c05db692f962c1fde84

@@ -14,6 +14,7 @@
       <a href="/">Home</a>
       <a href="/search">Search</a>
 {% if signed_in %}
+      <a href="/activity">Activity</a>
       <a href="/account">Account</a>
       <a href="/logout">Log out</a>
 {% else %}

templates/feed-token-issued.html

Mode 100644100644; object 24d7a06f5ec1f97e04647958

@@ -2,10 +2,8 @@
 {% block title %}New feed token · tit{% endblock %}
 {% block content %}
   <h1>New feed token</h1>
-  <p>This token gives access to {{ scope }} for {{ target }}. Copy the URLs now. tit will not show them again.</p>
+  <p>This token gives access to {{ scope }}. Copy the URL now. tit will not show it again.</p>
   <dl>
-    <dt>Atom</dt>
-    <dd><code>/feeds/{{ token }}/atom.xml</code></dd>
     <dt>RSS</dt>
     <dd><code>/feeds/{{ token }}/rss.xml</code></dd>
   </dl>

templates/feed-tokens.html

Mode 100644100644; object eabcb5f593d40b66bd11db8a

@@ -7,32 +7,7 @@
   <h2>Create token</h2>
   <form method="post" action="/feeds/tokens">
     <input type="hidden" name="csrf" value="{{ csrf }}">
-    <input type="hidden" name="owner" value="">
-    <input type="hidden" name="repository" value="">
-    <div class="field">
-      <label for="feed-scope">Scope</label>
-      <select id="feed-scope" name="scope">
-        <option value="watched">Watched activity</option>
-        <option value="assignments">Assignments</option>
-        <option value="mentions">Mentions</option>
-      </select>
-    </div>
-    <button type="submit">Create feed token</button>
-  </form>
-
-  <h2>Create a repository token</h2>
-  <form method="post" action="/feeds/tokens">
-    <input type="hidden" name="csrf" value="{{ csrf }}">
-    <input type="hidden" name="scope" value="repository">
-    <div class="field">
-      <label for="feed-owner">Repository owner</label>
-      <input id="feed-owner" name="owner" autocomplete="off" autocapitalize="none" spellcheck="false">
-    </div>
-    <div class="field">
-      <label for="feed-repository">Repository name</label>
-      <input id="feed-repository" name="repository" autocomplete="off" autocapitalize="none" spellcheck="false">
-    </div>
-    <button type="submit">Create repository token</button>
+    <button type="submit">Create watched activity RSS token</button>
   </form>
 
   <h2>Existing tokens</h2>
@@ -42,7 +17,7 @@
   <ul>
 {% for token in tokens %}
     <li>
-      <strong>{{ token.scope }}</strong> for {{ token.target }}, created at <time datetime="{{ token.created_at }}">{{ token.created_at|human_time }}</time>.
+      <strong>{{ token.scope }}</strong>, created at <time datetime="{{ token.created_at }}">{{ token.created_at|human_time }}</time>.
 {% if token.active %}
       <form method="post" action="/feeds/tokens/{{ token.id }}/rotate">
         <input type="hidden" name="csrf" value="{{ csrf }}">

templates/home.html

Mode 100644100644; object 0fdd058ee6c93119705c835c

@@ -1,6 +1,8 @@
 {% extends "base.html" %}
 {% block title %}{% if signed_in %}{{ username }}{% else %}Repositories{% endif %} · tit{% endblock %}
 {% block content %}
+  <div class="two-column">
+  <section>
 {% if signed_in %}
   <h1>{{ username }}</h1>
   <p><a href="/account">Open your account profile</a>.</p>
@@ -13,10 +15,23 @@
 {% else %}
   <ul>
   {% for item in repositories %}
-    <li><a href="/{{ item.owner }}/{{ item.slug }}">{{ item.owner }}/{{ item.slug }}</a> · {{ item.visibility }} · updated <time datetime="{{ item.updated_at }}">{{ item.updated_at|human_time }}</time></li>
+{% if item.state == "archived" %}
+    <li>
+      {{ item.owner }}/{{ item.slug }} · archived
+      <form class="inline-form" method="post" action="/{{ item.owner }}/{{ item.slug }}/settings/unarchive">
+        <input type="hidden" name="csrf" value="{{ csrf }}">
+        <input type="hidden" name="confirm" value="yes">
+        <button type="submit">Unarchive repository</button>
+      </form>
+    </li>
+{% else %}
+    <li><a href="/{{ item.owner }}/{{ item.slug }}">{{ item.owner }}/{{ item.slug }}</a> · {{ item.visibility }} · updated <time datetime="{{ item.updated_at }}">{{ item.updated_at|human_time }}</time>{% if item.description.is_empty() %}{% else %}<br>{{ item.description }}{% endif %}</li>
+{% endif %}
   {% endfor %}
   </ul>
 {% endif %}
+  </section>
+  <section>
   <h2>Open a repository</h2>
   <p>Enter an owner and a repository to open its public page.</p>
 {% if has_error %}
@@ -33,4 +48,6 @@
     </div>
     <button type="submit">Open repository</button>
   </form>
+  </section>
+  </div>
 {% endblock %}

templates/issue.html

Mode 100644100644; object 0885c987b1420eef13d6afe1

@@ -9,12 +9,6 @@
   <article>
     <h2>#{{ number }} {{ title }}</h2>
     <p>{{ state }} · opened by {{ author }} at <time datetime="{{ created_at }}">{{ created_at|human_time }}</time> · updated <time datetime="{{ updated_at }}">{{ updated_at|human_time }}</time></p>
-{% if labels.is_empty() %}{% else %}
-    <p>Labels:{% for label in labels %} <span>{{ label }}</span>{% endfor %}</p>
-{% endif %}
-{% if assignees.is_empty() %}{% else %}
-    <p>Assignees:{% for assignee in assignees %} <span>{{ assignee }}</span>{% endfor %}</p>
-{% endif %}
     <div class="markdown">{{ body_html|safe }}</div>
   </article>
 
@@ -30,6 +24,16 @@
     </article>
 {% endfor %}
 {% endif %}
+{% if comments_has_previous || comments_has_next %}
+    <nav aria-label="Comment pages">
+{% if comments_has_previous %}
+      <a href="?comments_page={{ comments_previous_page }}&amp;timeline_page={{ timeline_page }}">Newer comments</a>
+{% endif %}
+{% if comments_has_next %}
+      <a href="?comments_page={{ comments_next_page }}&amp;timeline_page={{ timeline_page }}">Older comments</a>
+{% endif %}
+    </nav>
+{% endif %}
   </section>
 
   <section aria-labelledby="timeline-heading">
@@ -39,6 +43,16 @@
       <li value="{{ event.sequence }}">{{ event.actor }} {{ event.kind|event_name }} at <time datetime="{{ event.created_at }}">{{ event.created_at|human_time }}</time></li>
 {% endfor %}
     </ol>
+{% if timeline_has_previous || timeline_has_next %}
+    <nav aria-label="Timeline pages">
+{% if timeline_has_previous %}
+      <a href="?comments_page={{ comments_page }}&amp;timeline_page={{ timeline_previous_page }}">Newer events</a>
+{% endif %}
+{% if timeline_has_next %}
+      <a href="?comments_page={{ comments_page }}&amp;timeline_page={{ timeline_next_page }}">Older events</a>
+{% endif %}
+    </nav>
+{% endif %}
   </section>
 
 {% if can_comment %}
@@ -83,27 +97,4 @@
   </section>
 {% endif %}
 
-{% if can_maintain %}
-  <section aria-labelledby="organize-heading">
-    <h2 id="organize-heading">Organize this issue</h2>
-    <form method="post" action="/{{ owner }}/{{ repository }}/issues/{{ number }}/labels">
-      <input type="hidden" name="csrf" value="{{ csrf }}">
-      <div class="field">
-        <label for="label-name">Label</label>
-        <input id="label-name" name="label" maxlength="80" required>
-      </div>
-      <button name="operation" value="add" type="submit">Add label</button>
-      <button name="operation" value="remove" type="submit">Remove label</button>
-    </form>
-    <form method="post" action="/{{ owner }}/{{ repository }}/issues/{{ number }}/assignees">
-      <input type="hidden" name="csrf" value="{{ csrf }}">
-      <div class="field">
-        <label for="assignee-name">Assignee</label>
-        <input id="assignee-name" name="assignee" maxlength="39" required>
-      </div>
-      <button name="operation" value="add" type="submit">Assign</button>
-      <button name="operation" value="remove" type="submit">Unassign</button>
-    </form>
-  </section>
-{% endif %}
 {% endblock %}

templates/issues.html

Mode 100644100644; object 16d9339a763645a8606a1897

@@ -7,7 +7,16 @@
   </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>
+  <p><a href="/{{ owner }}/{{ repository }}/issues/rss.xml">Issue RSS</a></p>
+  <form method="get" action="/{{ owner }}/{{ repository }}/issues">
+    <label for="issue-state">State</label>
+    <select id="issue-state" name="state">
+      <option value="open"{% if state_open %} selected{% endif %}>Open</option>
+      <option value="closed"{% if state_closed %} selected{% endif %}>Closed</option>
+      <option value="all"{% if state_all %} selected{% endif %}>All</option>
+    </select>
+    <button type="submit">Filter</button>
+  </form>
 {% if issues.is_empty() %}
   <p>This repository has no issues.</p>
 {% else %}
@@ -16,6 +25,16 @@
     <li><a href="/{{ owner }}/{{ repository }}/issues/{{ issue.number }}">#{{ issue.number }} {{ issue.title }}</a> — {{ issue.state }}, opened by {{ issue.author }}, updated <time datetime="{{ issue.updated_at }}">{{ issue.updated_at|human_time }}</time></li>
 {% endfor %}
   </ol>
+{% if has_previous || has_next %}
+  <nav class="pagination" aria-label="Issue pages">
+{% if has_previous %}
+    <a href="/{{ owner }}/{{ repository }}/issues?state={{ state }}&page={{ previous_page }}">Newer issues</a>
+{% endif %}
+{% if has_next %}
+    <a href="/{{ owner }}/{{ repository }}/issues?state={{ state }}&page={{ next_page }}">Older issues</a>
+{% endif %}
+  </nav>
+{% endif %}
 {% endif %}
 
 {% if can_create %}

templates/metadata-search.html

Mode 100644100644; object 4113edd31490545678702426

@@ -1,19 +1,19 @@
 {% extends "base.html" %}
 {% block title %}Search · tit{% endblock %}
 {% block content %}
-  <h1>Search repositories and issues</h1>
+  <h1>Search repositories</h1>
   <form method="get" action="/search">
     <label for="metadata-query">Search text</label>
     <input id="metadata-query" name="q" value="{{ query }}" maxlength="256" required>
     <button type="submit">Search</button>
   </form>
 {% if searched %}
-  <p>Scanned {{ rows_scanned }} records and {{ bytes_scanned }} bytes.</p>
+  <p>Scanned {{ rows_scanned }} repository names.</p>
 {% if truncated %}
   <p>The result is incomplete because the search reached a limit.</p>
 {% endif %}
 {% if results.is_empty() %}
-  <p>No repository or issue matched the search text.</p>
+  <p>No repository matched the search text.</p>
 {% else %}
   <ol class="search-results">
 {% for result in results %}

templates/profile.html

Mode 100644; object 0c8d9f7afd51

@@ -1,0 +1,39 @@
+{% extends "base.html" %}
+{% block title %}{{ username }} · tit{% endblock %}
+{% block content %}
+  <div class="two-column">
+    <section aria-labelledby="profile-heading">
+      <h1 id="profile-heading">{{ username }}</h1>
+{% if bio.is_empty() %}
+      <p>This user has not added a bio.</p>
+{% else %}
+      <p class="profile-bio">{{ bio }}</p>
+{% endif %}
+{% if contact_email.is_empty() %}{% else %}
+      <p>Contact: <a href="mailto:{{ contact_email }}">{{ contact_email }}</a></p>
+{% endif %}
+    </section>
+    <section aria-labelledby="repositories-heading">
+      <h2 id="repositories-heading">Public repositories</h2>
+{% if repositories.is_empty() %}
+      <p>This user has no public repositories.</p>
+{% else %}
+      <ul class="repository-list">
+{% for repository in repositories %}
+        <li><a href="/{{ repository.owner }}/{{ repository.slug }}">{{ repository.slug }}</a> · updated <time datetime="{{ repository.updated_at }}">{{ repository.updated_at|human_time }}</time>{% if repository.description.is_empty() %}{% else %}<br>{{ repository.description }}{% endif %}</li>
+{% endfor %}
+      </ul>
+{% if has_previous || has_next %}
+      <nav class="pagination" aria-label="Repository pages">
+{% if has_previous %}
+        <a href="/{{ username }}?page={{ previous_page }}">Newer repositories</a>
+{% endif %}
+{% if has_next %}
+        <a href="/{{ username }}?page={{ next_page }}">Older repositories</a>
+{% endif %}
+      </nav>
+{% endif %}
+{% endif %}
+    </section>
+  </div>
+{% endblock %}

templates/pull_request.html

Mode 100644100644; object 335850dc75d742d923807156

@@ -13,6 +13,33 @@
     <p>Head: <code>{{ pull_request.head_ref }}</code> at <code title="{{ pull_request.head_object_id }}">{{ pull_request.head_object_id|short_id }}</code></p>
     <p>Fetch: <code>git fetch origin refs/pull/{{ pull_request.number }}/head</code></p>
     <div class="markdown">{{ body_html|safe }}</div>
+{% if can_edit %}
+    <details>
+      <summary>Edit pull request</summary>
+      <form method="post" action="/{{ owner }}/{{ repository }}/pulls/{{ pull_request.number }}/edit">
+        <input type="hidden" name="csrf" value="{{ csrf }}">
+        <div class="field">
+          <label for="pull-request-title">Title</label>
+          <input id="pull-request-title" name="title" value="{{ pull_request.title }}" maxlength="200" required>
+        </div>
+        <div class="field">
+          <label for="pull-request-body">Description (Markdown)</label>
+          <textarea id="pull-request-body" name="body" maxlength="262144" rows="10">{{ pull_request.body }}</textarea>
+        </div>
+        <button type="submit">Save pull request</button>
+      </form>
+    </details>
+{% endif %}
+{% if can_change_state %}
+    <form method="post" action="/{{ owner }}/{{ repository }}/pulls/{{ pull_request.number }}/state">
+      <input type="hidden" name="csrf" value="{{ csrf }}">
+{% if is_open %}
+      <button type="submit" name="state" value="closed">Close pull request</button>
+{% else %}
+      <button type="submit" name="state" value="open">Reopen pull request</button>
+{% endif %}
+    </form>
+{% endif %}
   </article>
 
   <section aria-labelledby="revisions-heading">
@@ -21,6 +48,7 @@
 {% for revision in revisions %}
       <li value="{{ revision.number }}">
         {{ revision.author }} recorded <code title="{{ revision.head_object_id }}">{{ revision.head_object_id|short_id }}</code> against <code title="{{ revision.base_object_id }}">{{ revision.base_object_id|short_id }}</code> at <time datetime="{{ revision.created_at }}">{{ revision.created_at|human_time }}</time>.
+        <a href="/{{ owner }}/{{ repository }}/pulls/{{ pull_request.number }}/revisions/{{ revision.number }}.patch">Download patch</a>
 {% if revision.number == selected_revision %}
         <strong>Selected</strong>
 {% else %}
@@ -53,6 +81,16 @@
     </article>
 {% endfor %}
 {% endif %}
+{% if reviews_has_previous || reviews_has_next %}
+    <nav aria-label="Review pages">
+{% if reviews_has_previous %}
+      <a href="?revision={{ selected_revision }}&amp;reviews_page={{ reviews_previous_page }}&amp;timeline_page={{ timeline_page }}">Newer reviews</a>
+{% endif %}
+{% if reviews_has_next %}
+      <a href="?revision={{ selected_revision }}&amp;reviews_page={{ reviews_next_page }}&amp;timeline_page={{ timeline_page }}">Older reviews</a>
+{% endif %}
+    </nav>
+{% endif %}
   </section>
 
   <section aria-labelledby="timeline-heading">
@@ -62,6 +100,16 @@
       <li value="{{ event.sequence }}">{{ event.actor }} {{ event.kind|event_name }} at <time datetime="{{ event.created_at }}">{{ event.created_at|human_time }}</time></li>
 {% endfor %}
     </ol>
+{% if timeline_has_previous || timeline_has_next %}
+    <nav aria-label="Timeline pages">
+{% if timeline_has_previous %}
+      <a href="?revision={{ selected_revision }}&amp;reviews_page={{ reviews_page }}&amp;timeline_page={{ timeline_previous_page }}">Newer events</a>
+{% endif %}
+{% if timeline_has_next %}
+      <a href="?revision={{ selected_revision }}&amp;reviews_page={{ reviews_page }}&amp;timeline_page={{ timeline_next_page }}">Older events</a>
+{% endif %}
+    </nav>
+{% endif %}
   </section>
 
   <section aria-labelledby="comparison-heading">

templates/pull_requests.html

Mode 100644100644; object 1cb9c08dfdb9f148b7913197

@@ -7,6 +7,16 @@
   </header>
 
   <h2>Pull requests</h2>
+  <form method="get" action="/{{ owner }}/{{ repository }}/pulls">
+    <label for="pull-request-state">State</label>
+    <select id="pull-request-state" name="state">
+      <option value="open"{% if state_open %} selected{% endif %}>Open</option>
+      <option value="closed"{% if state_closed %} selected{% endif %}>Closed</option>
+      <option value="merged"{% if state_merged %} selected{% endif %}>Merged</option>
+      <option value="all"{% if state_all %} selected{% endif %}>All</option>
+    </select>
+    <button type="submit">Filter</button>
+  </form>
 {% if pull_requests.is_empty() %}
   <p>This repository has no pull requests.</p>
 {% else %}
@@ -15,6 +25,16 @@
     <li><a href="/{{ owner }}/{{ repository }}/pulls/{{ pull_request.number }}">#{{ pull_request.number }} {{ pull_request.title }}</a> — {{ pull_request.state }}, opened by {{ pull_request.author }}, updated <time datetime="{{ pull_request.updated_at }}">{{ pull_request.updated_at|human_time }}</time></li>
 {% endfor %}
   </ol>
+{% if has_previous || has_next %}
+  <nav class="pagination" aria-label="Pull-request pages">
+{% if has_previous %}
+    <a href="/{{ owner }}/{{ repository }}/pulls?state={{ state }}&page={{ previous_page }}">Newer pull requests</a>
+{% endif %}
+{% if has_next %}
+    <a href="/{{ owner }}/{{ repository }}/pulls?state={{ state }}&page={{ next_page }}">Older pull requests</a>
+{% endif %}
+  </nav>
+{% endif %}
 {% endif %}
 
 {% if can_create %}
@@ -32,7 +52,7 @@
       </div>
       <div class="field">
         <label for="pull-request-base">Base branch</label>
-        <input id="pull-request-base" name="base-ref" list="repository-branches" maxlength="1024" value="refs/heads/main" required>
+        <input id="pull-request-base" name="base-ref" list="repository-branches" maxlength="1024" value="{{ default_branch }}" required>
       </div>
       <div class="field">
         <label for="pull-request-head">Head branch</label>

templates/repository-nav.html

Mode 100644100644; object 22e8d70cf8373a5a38a01b14

@@ -4,7 +4,9 @@
       <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 signed_in %}
+      <a href="/{{ owner }}/{{ repository }}/settings">Settings</a>
+{% endif %}
     </nav>

templates/repository-settings.html

Mode 100644; object 0b2dbc6505bc

@@ -1,0 +1,110 @@
+{% extends "base.html" %}
+{% block title %}Settings · {{ owner }}/{{ repository }} · tit{% endblock %}
+{% block content %}
+  <header class="repository-header">
+    <h1><a href="/{{ owner }}/{{ repository }}">{{ owner }}/{{ repository }}</a></h1>
+{% include "repository-nav.html" %}
+  </header>
+
+  <div class="two-column">
+    <section>
+      <h2>Repository settings</h2>
+      <form method="post" action="/{{ owner }}/{{ repository }}/settings/general">
+        <input type="hidden" name="csrf" value="{{ csrf }}">
+        <div class="field">
+          <label for="description">Description</label>
+          <textarea id="description" name="description" maxlength="512" rows="4">{{ description }}</textarea>
+        </div>
+        <div class="field">
+          <label for="visibility">Visibility</label>
+          <select id="visibility" name="visibility">
+            <option value="public"{% if visibility == "public" %} selected{% endif %}>Public</option>
+            <option value="private"{% if visibility == "private" %} selected{% endif %}>Private</option>
+          </select>
+        </div>
+        <button type="submit">Save settings</button>
+      </form>
+      <h3>Default branch</h3>
+      {% if branches.is_empty() %}
+      <p>This repository has no branches. New repositories use <code>{{ default_branch }}</code>.</p>
+      {% else %}
+      <form method="post" action="/{{ owner }}/{{ repository }}/settings/default-branch">
+        <input type="hidden" name="csrf" value="{{ csrf }}">
+        <div class="field">
+          <label for="default-branch">Default branch</label>
+          <select id="default-branch" name="default-branch">
+          {% for branch in branches %}
+            <option value="{{ branch }}"{% if branch == default_branch %} selected{% endif %}>{{ branch }}</option>
+          {% endfor %}
+          </select>
+        </div>
+        <button type="submit">Set default branch</button>
+      </form>
+      {% endif %}
+    </section>
+
+    <section>
+      <h2>Collaborators</h2>
+{% if collaborators.is_empty() %}
+      <p>This repository has no collaborators.</p>
+{% else %}
+      <ul>
+{% for collaborator in collaborators %}
+        <li>
+          {{ collaborator.username }} · {{ collaborator.role }}
+          <form class="inline-form" method="post" action="/{{ owner }}/{{ repository }}/settings/collaborators">
+            <input type="hidden" name="csrf" value="{{ csrf }}">
+            <input type="hidden" name="username" value="{{ collaborator.username }}">
+            <input type="hidden" name="role" value="{{ collaborator.role }}">
+            <button type="submit" name="action" value="remove">Remove</button>
+          </form>
+        </li>
+{% endfor %}
+      </ul>
+{% endif %}
+      <form method="post" action="/{{ owner }}/{{ repository }}/settings/collaborators">
+        <input type="hidden" name="csrf" value="{{ csrf }}">
+        <div class="field">
+          <label for="collaborator-username">Username</label>
+          <input id="collaborator-username" name="username" required maxlength="40">
+        </div>
+        <div class="field">
+          <label for="collaborator-role">Role</label>
+          <select id="collaborator-role" name="role">
+            <option value="reader">Reader</option>
+            <option value="writer">Writer</option>
+            <option value="maintainer">Maintainer</option>
+          </select>
+        </div>
+        <button type="submit" name="action" value="set">Set collaborator</button>
+      </form>
+    </section>
+  </div>
+
+{% if is_owner %}
+  <section>
+    <h2>Rename repository</h2>
+    <form method="post" action="/{{ owner }}/{{ repository }}/settings/rename">
+      <input type="hidden" name="csrf" value="{{ csrf }}">
+      <div class="field">
+        <label for="new-name">New repository name</label>
+        <input id="new-name" name="new-name" value="{{ repository }}" maxlength="100" required>
+      </div>
+      <button type="submit">Rename repository</button>
+    </form>
+  </section>
+{% endif %}
+
+  <section>
+    <h2>Archive repository</h2>
+    <form method="post" action="/{{ owner }}/{{ repository }}/settings/archive">
+      <input type="hidden" name="csrf" value="{{ csrf }}">
+      <fieldset class="confirmation">
+        <legend>Archive this repository?</legend>
+        <label><input type="radio" name="confirm" value="no" checked> No</label>
+        <label><input type="radio" name="confirm" value="yes"> Yes</label>
+      </fieldset>
+      <button type="submit">Archive repository</button>
+    </form>
+  </section>
+{% endblock %}

templates/repository.html

Mode 100644100644; object 68599b844fd66e9095acc965

@@ -11,6 +11,9 @@
 {% endif %}
 
 {% if page_kind == "summary" %}
+{% if description != "" %}
+  <p class="repository-description">{{ description }}</p>
+{% endif %}
   <section aria-labelledby="clone-heading">
     <h2 id="clone-heading">Clone</h2>
     <dl>
@@ -112,6 +115,7 @@
 {% if page_kind == "commit" %}
   <article>
     <h2>Commit <code title="{{ commit.id }}">{{ commit.id|short_id }}</code></h2>
+    <p><a href="/{{ owner }}/{{ repository }}/commit/{{ commit.id }}.patch">Download patch</a></p>
     <dl>
       <dt>Author</dt><dd>{{ commit.author_name }} &lt;{{ commit.author_email }}&gt;</dd>
       <dt>Committed</dt><dd><time datetime="{{ commit.committed_at }}">{{ commit.committed_at|human_time }}</time></dd>

templates/watch.html

Mode 100644100644; object 895db9ce7cae408c08c2af01

@@ -6,39 +6,21 @@
 {% include "repository-nav.html" %}
   </header>
 
-  <h2>Watch preferences</h2>
+  <h2>Watch repository</h2>
 {% if watching %}
-  <p>You watch selected activity in this repository.</p>
+  <p>You watch activity in this repository.</p>
 {% else %}
   <p>You do not watch this repository.</p>
 {% endif %}
 {% if can_change %}
   <form method="post" action="/{{ owner }}/{{ repository }}/watch">
     <input type="hidden" name="csrf" value="{{ csrf }}">
-    <div class="field">
-      <label for="watch-pushes">Pushes</label>
-      <select id="watch-pushes" name="pushes">
-        <option value="0"{% if !pushes %} selected{% endif %}>Do not watch</option>
-        <option value="1"{% if pushes %} selected{% endif %}>Watch</option>
-      </select>
-    </div>
-    <div class="field">
-      <label for="watch-issues">Issues</label>
-      <select id="watch-issues" name="issues">
-        <option value="0"{% if !issues %} selected{% endif %}>Do not watch</option>
-        <option value="1"{% if issues %} selected{% endif %}>Watch</option>
-      </select>
-    </div>
-    <div class="field">
-      <label for="watch-pull-requests">Pull requests</label>
-      <select id="watch-pull-requests" name="pull-requests">
-        <option value="0"{% if !pull_requests %} selected{% endif %}>Do not watch</option>
-        <option value="1"{% if pull_requests %} selected{% endif %}>Watch</option>
-      </select>
-    </div>
-    <button type="submit">Save watch preferences</button>
+{% if watching %}
+    <button type="submit" name="state" value="unwatch">Stop watching</button>
+{% else %}
+    <button type="submit" name="state" value="watch">Watch repository</button>
+{% endif %}
   </form>
-  <p>Set all three values to “Do not watch” to stop watching this repository.</p>
 {% else %}
   <p><a href="/login">Log in</a> to change watch preferences.</p>
 {% endif %}

tests/account_lifecycle.rs

Mode 100644100644; object f6006a4993714e21ed4a8718

@@ -47,6 +47,24 @@
     let recovery = accounts
         .signup(&invitation, "alice", &first_key, "test")
         .expect("create the account");
+    accounts
+        .update_profile(
+            "alice",
+            "A small profile.\nSecond line.",
+            "alice@example.test",
+        )
+        .expect("update the public profile");
+    let profile = accounts.profile("alice").expect("read the public profile");
+    assert_eq!(profile.bio, "A small profile.\nSecond line.");
+    assert_eq!(profile.contact_email, "alice@example.test");
+    assert!(matches!(
+        accounts.update_profile("alice", "profile", "not-an-email"),
+        Err(AccountError::InvalidProfile)
+    ));
+    Store::open(&database)
+        .expect("open the account database")
+        .create_feed_token("alice", &[7; 32], 1)
+        .expect("create a feed token");
     assert!(recovery.starts_with("tit-recovery-v1:"));
     assert!(matches!(
         accounts.signup(&invitation, "bob", &second_key, "test"),
@@ -87,6 +105,11 @@
     let replacement = accounts
         .recover("alice", &recovery, &third_key, "test")
         .expect("recover the account");
+    assert_eq!(
+        active_feed_tokens(&database, "alice"),
+        0,
+        "recovery must revoke active feed tokens"
+    );
     assert!(matches!(
         accounts.recover("alice", &recovery, &first_key, "test"),
         Err(AccountError::Store(StoreError::InvalidRecovery))
@@ -141,6 +164,10 @@
     accounts
         .signup(&first, "alice", &public_key(), "test")
         .expect("create the first account");
+    Store::open(&database)
+        .expect("open the account database")
+        .create_feed_token("alice", &[8; 32], 1)
+        .expect("create a feed token");
 
     let second = accounts.issue_invitation().expect("issue an invitation");
     assert!(matches!(
@@ -153,6 +180,11 @@
     accounts
         .suspend("alice", true, "admin-cli", "test")
         .expect("suspend the account");
+    assert_eq!(
+        active_feed_tokens(&database, "alice"),
+        0,
+        "suspension must revoke active feed tokens"
+    );
     assert_eq!(
         Store::open(&database)
             .expect("open the account database")
@@ -182,6 +214,21 @@
         .expect("read the invitation")
         .flatten();
     assert_eq!(consumed, None);
+}
+
+fn active_feed_tokens(database: &std::path::Path, username: &str) -> i64 {
+    Store::open(database)
+        .expect("open the account database")
+        .connection()
+        .query_row(
+            "SELECT count(*)
+             FROM feed_token
+             JOIN account ON account.id = feed_token.account_id
+             WHERE account.username = ?1 AND feed_token.revoked_at IS NULL",
+            [username],
+            |row| row.get(0),
+        )
+        .expect("count active feed tokens")
 }
 
 #[test]

tests/cli.rs

Mode 100644100644; object 89350e8232df612eb342f7dd

@@ -27,7 +27,12 @@
     include_str!("../src/store/migrations/016_pull_request_reviews.sql"),
     include_str!("../src/store/migrations/017_pull_request_merges.sql"),
     include_str!("../src/store/migrations/018_streamlined_login.sql"),
-    "PRAGMA user_version = 18;\n",
+    include_str!("../src/store/migrations/019_product_reduction.sql"),
+    include_str!("../src/store/migrations/020_repository_profiles.sql"),
+    include_str!("../src/store/migrations/021_pull_request_lifecycle.sql"),
+    include_str!("../src/store/migrations/022_account_key_management.sql"),
+    include_str!("../src/store/migrations/023_default_branch.sql"),
+    "PRAGMA user_version = 23;\n",
 );
 
 #[test]
@@ -398,6 +403,28 @@
 }
 
 #[test]
+fn maintenance_runs_offline_and_rejects_a_zero_retention_period() {
+    let instance = TestInstance::new();
+    create_administrator(&instance, "alice");
+    let config = instance.config().to_str().expect("a UTF-8 path");
+
+    let maintained = instance.run(&["--config", config, "maintenance"]);
+    assert!(
+        maintained.status.success(),
+        "maintenance failed: {}",
+        String::from_utf8_lossy(&maintained.stderr)
+    );
+    assert!(String::from_utf8_lossy(&maintained.stdout).starts_with("Pruned "));
+
+    let rejected = instance.run(&["--config", config, "maintenance", "--retention-days", "0"]);
+    assert_eq!(rejected.status.code(), Some(1));
+    assert!(
+        String::from_utf8_lossy(&rejected.stderr)
+            .contains("retention days must be greater than zero")
+    );
+}
+
+#[test]
 fn administers_repositories_with_immutable_canonical_paths() {
     let instance = TestInstance::new();
     create_administrator(&instance, "alice");
@@ -412,8 +439,6 @@
             "create",
             "alice",
             "project",
-            "--object-format",
-            "sha256",
         ])
         .env("PATH", "")
         .output()
@@ -431,10 +456,7 @@
         Some("public")
     );
     assert_eq!(created.get("state").map(String::as_str), Some("active"));
-    assert_eq!(
-        created.get("object-format").map(String::as_str),
-        Some("sha256")
-    );
+    assert!(!created.contains_key("object-format"));
     let id = created.get("id").expect("read the repository ID");
     assert_eq!(id.len(), 32);
     assert!(id.bytes().all(|byte| byte.is_ascii_hexdigit()));
@@ -647,6 +669,8 @@
     let config = instance.config().to_str().expect("a UTF-8 path");
     let source = instance.path().join("source.git");
     create_bare_git_fixture(&source, "sha1");
+    fs::write(source.join("HEAD"), "ref: refs/heads/trunk\n")
+        .expect("set the imported symbolic HEAD");
 
     let imported = instance.run(&[
         "--config",
@@ -664,11 +688,21 @@
         String::from_utf8_lossy(&imported.stderr)
     );
     let imported = repository_output(&imported.stdout);
-    assert_eq!(
-        imported.get("object-format").map(String::as_str),
-        Some("sha1")
-    );
+    assert!(!imported.contains_key("object-format"));
     assert_ne!(imported.get("path"), Some(&source.display().to_string()));
+    let database = rusqlite::Connection::open(instance.path().join("tit.sqlite3"))
+        .expect("open the imported repository database");
+    let default_branch: String = database
+        .query_row(
+            "SELECT repository_default_branch.ref_name
+             FROM repository_default_branch
+             JOIN repository ON repository.id = repository_default_branch.repository_id
+             WHERE repository.slug = 'imported'",
+            [],
+            |row| row.get(0),
+        )
+        .expect("read the imported default branch");
+    assert_eq!(default_branch, "refs/heads/trunk");
 
     let unsafe_source = instance.path().join("unsafe.git");
     create_bare_git_fixture(&unsafe_source, "sha1");
@@ -708,6 +742,58 @@
         ]);
         assert_eq!(rejected.status.code(), Some(1), "accepted {slug:?}");
         assert!(rejected.stdout.is_empty());
+    }
+}
+
+#[test]
+fn removes_an_import_after_each_post_rename_failure() {
+    for point in [
+        "after-rename",
+        "after-canonicalize",
+        "after-object-format",
+        "after-open",
+        "after-references",
+    ] {
+        let instance = TestInstance::new();
+        create_administrator(&instance, "alice");
+        let config = instance.config().to_str().expect("a UTF-8 path");
+        let source = instance.path().join("source.git");
+        create_bare_git_fixture(&source, "sha1");
+        let output = Command::new(env!("CARGO_BIN_EXE_tit"))
+            .args([
+                "--config",
+                config,
+                "admin",
+                "repository",
+                "import",
+                "alice",
+                "failed",
+                source.to_str().expect("a UTF-8 path"),
+            ])
+            .env("TIT_TEST_IMPORT_FAIL", point)
+            .output()
+            .expect("run the faulted import");
+        assert_eq!(output.status.code(), Some(1), "{point}");
+        assert!(
+            fs::read_dir(instance.path().join("repositories"))
+                .expect("read managed repositories")
+                .all(|entry| entry
+                    .expect("read a managed repository")
+                    .file_name()
+                    .to_string_lossy()
+                    .starts_with('.')),
+            "{point} left an unmanaged repository"
+        );
+        let inspected = instance.run(&[
+            "--config",
+            config,
+            "admin",
+            "repository",
+            "inspect",
+            "alice",
+            "failed",
+        ]);
+        assert_eq!(inspected.status.code(), Some(1), "{point}");
     }
 }
 

tests/git_http.rs

Mode 100644100644; object 447e0a1859e26c66c08e5a66

@@ -41,6 +41,7 @@
 use http::RunningGitHttpServer;
 use tempfile::TempDir;
 use transport::GitRepositories;
+use upload_pack::{ProtocolVersion, UploadPack, UploadPackError};
 
 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
 async fn stock_git_clones_and_fetches_both_hash_formats_over_smart_http() {
@@ -177,6 +178,40 @@
     );
     assert!(oversized.starts_with(b"HTTP/1.1 413"));
     server.shutdown().await.expect("stop the Git HTTP server");
+}
+
+#[test]
+fn upload_pack_deduplicates_and_limits_negotiation_ids() {
+    let directory = TempDir::new().expect("create an upload-pack fixture directory");
+    let worktree = directory.path().join("worktree");
+    let bare = directory.path().join("repository.git");
+    create_fixture(&worktree, &bare, "sha1");
+    let head = rev_parse(&bare, "refs/heads/main");
+    let service = UploadPack::open(&bare).expect("open upload-pack");
+
+    let mut duplicate = Vec::new();
+    for _ in 0..512 {
+        packetline::encode_data(format!("want {head}\n").as_bytes(), &mut duplicate)
+            .expect("encode a duplicate want");
+    }
+    packetline::encode_data(b"done\n", &mut duplicate).expect("encode done");
+    packetline::encode_flush(&mut duplicate);
+    let response = service
+        .respond(ProtocolVersion::V1, &duplicate)
+        .expect("deduplicate negotiation IDs");
+    assert!(response.windows(4).any(|window| window == b"PACK"));
+
+    let mut excessive = Vec::new();
+    for value in 1_u64..=257 {
+        packetline::encode_data(format!("want {value:040x}\n").as_bytes(), &mut excessive)
+            .expect("encode a distinct want");
+    }
+    packetline::encode_data(b"done\n", &mut excessive).expect("encode done");
+    packetline::encode_flush(&mut excessive);
+    assert!(matches!(
+        service.respond(ProtocolVersion::V1, &excessive),
+        Err(UploadPackError::NegotiationLimit)
+    ));
 }
 
 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]

tests/git_push_ssh.rs

Mode 100644100644; object 32acfe58a3f0af2de5b2427a

@@ -22,6 +22,9 @@
 #[allow(dead_code, reason = "the SSH push test does not use repository policy")]
 #[path = "../src/policy.rs"]
 mod policy;
+#[allow(dead_code, reason = "the SSH push test does not use pull requests")]
+#[path = "../src/pull_request.rs"]
+mod pull_request;
 #[path = "../src/rate_limit.rs"]
 mod rate_limit;
 #[allow(dead_code, reason = "the SSH push test does not create repositories")]

tests/git_reads.rs

Mode 100644100644; object 2beef387cfaf10086a706f1c

@@ -1,3 +1,5 @@
+#[path = "../src/git/patch.rs"]
+mod patch;
 #[allow(
     dead_code,
     reason = "the test uses each public read contract selectively"
@@ -7,6 +9,7 @@
 
 use std::collections::BTreeSet;
 use std::fs;
+use std::os::unix::fs::PermissionsExt;
 use std::path::{Path, PathBuf};
 use std::process::Command;
 use std::time::{Duration, Instant};
@@ -17,6 +20,7 @@
 
 struct Fixture {
     _directory: TempDir,
+    worktree: PathBuf,
     bare: PathBuf,
     first: ObjectId,
     second: ObjectId,
@@ -71,6 +75,8 @@
                 b"README.md".as_slice(),
                 b"binary".as_slice(),
                 b"docs".as_slice(),
+                b"mode.sh".as_slice(),
+                b"renamed.txt".as_slice(),
                 b"src".as_slice()
             ]
         );
@@ -154,6 +160,22 @@
         .expect("find the binary diff");
     assert!(binary.binary);
     assert!(binary.hunks.is_empty());
+    let mut patch = Vec::new();
+    patch::write_patch(&diff, &mut patch).expect("write an applicable patch");
+    let patch_path = fixture
+        .bare
+        .parent()
+        .expect("a fixture parent")
+        .join("change.patch");
+    fs::write(&patch_path, &patch).expect("write a patch fixture");
+    run(Command::new("git")
+        .args(["checkout", "-q", "--detach"])
+        .arg(fixture.first.to_string())
+        .current_dir(&fixture.worktree));
+    run(Command::new("git")
+        .args(["apply", "--check"])
+        .arg(&patch_path)
+        .current_dir(&fixture.worktree));
 
     let blame = service
         .blame(fixture.second, b"src/lib.rs", &cancellation)
@@ -296,6 +318,16 @@
     assert!(matches!(
         service.archive(fixture.second, &cancellation, &mut Vec::new()),
         Err(ReadError::Limit("archive bytes"))
+    ));
+
+    let limits = ReadLimits {
+        max_archive_depth: 0,
+        ..ReadLimits::default()
+    };
+    let service = RepositoryReadService::open(&fixture.bare, limits).expect("open the service");
+    assert!(matches!(
+        service.archive(fixture.second, &cancellation, &mut Vec::new()),
+        Err(ReadError::Limit("archive tree depth"))
     ));
 
     let limits = ReadLimits {
@@ -454,6 +486,9 @@
     fs::write(worktree.join("README.md"), b"Tit read service\n").expect("write a README");
     fs::write(worktree.join("src/lib.rs"), b"one\ntwo\n").expect("write source");
     fs::write(worktree.join("binary"), b"old\0binary").expect("write binary content");
+    fs::write(worktree.join("deleted.txt"), b"delete this\n").expect("write deleted content");
+    fs::write(worktree.join("rename-me.txt"), b"renamed content\n").expect("write renamed content");
+    fs::write(worktree.join("mode.sh"), b"#!/bin/sh\n").expect("write mode content");
     run(Command::new("git")
         .args(["add", "."])
         .current_dir(&worktree));
@@ -470,6 +505,11 @@
     fs::create_dir(worktree.join("docs")).expect("create a documentation directory");
     fs::write(worktree.join("src/lib.rs"), b"one\nchanged\nthree\n").expect("change source");
     fs::write(worktree.join("binary"), b"new\0binary").expect("change binary content");
+    fs::remove_file(worktree.join("deleted.txt")).expect("delete content");
+    fs::rename(worktree.join("rename-me.txt"), worktree.join("renamed.txt"))
+        .expect("rename content");
+    fs::set_permissions(worktree.join("mode.sh"), fs::Permissions::from_mode(0o755))
+        .expect("change file mode");
     fs::write(worktree.join("docs/note.txt"), b"note \xff needle\n").expect("write documentation");
     let long_directory = worktree.join("docs").join("a".repeat(60));
     fs::create_dir(&long_directory).expect("create a long archive directory");
@@ -494,6 +534,7 @@
         .arg(&bare));
     Fixture {
         _directory: directory,
+        worktree,
         bare,
         first,
         second,

tests/git_ssh.rs

Mode 100644100644; object 364a380ab5f12bbdc070f0a2

@@ -22,6 +22,9 @@
 #[allow(dead_code, reason = "the SSH Git test does not use repository policy")]
 #[path = "../src/policy.rs"]
 mod policy;
+#[allow(dead_code, reason = "the SSH Git test does not use pull requests")]
+#[path = "../src/pull_request.rs"]
+mod pull_request;
 #[path = "../src/rate_limit.rs"]
 mod rate_limit;
 #[allow(dead_code, reason = "the SSH Git test does not create repositories")]

tests/metadata_search.rs

Mode 100644100644; object 6d18d024bc6ac05fefc46594

@@ -12,11 +12,11 @@
 use std::time::{Duration, Instant};
 
 use search::{MetadataSearchError, MetadataSearchService};
-use store::{InitialAdministrator, NewIssue, NewRepository, RepositoryOrigin, Store};
+use store::{InitialAdministrator, NewRepository, RepositoryOrigin, Store};
 use tempfile::TempDir;
 
 #[test]
-fn searches_only_authorized_repository_and_issue_metadata_with_limits() {
+fn searches_only_authorized_repository_names_with_limits() {
     let directory = TempDir::new().expect("create a search fixture directory");
     let database = directory.path().join("tit.sqlite3");
     let mut store = Store::open(&database).expect("create the search database");
@@ -57,93 +57,39 @@
              VALUES ('22222222222222222222222222222222', 2, 'reader', 2);",
         )
         .expect("make one repository private");
-    store
-        .create_issue(&NewIssue {
-            owner: "alice",
-            repository: "public-project",
-            actor: "alice",
-            title: "Public Needle",
-            body: "The public body contains metadata.",
-            created_at: 3,
-        })
-        .expect("create a public issue");
-    store
-        .comment_issue(
-            "alice",
-            "public-project",
-            1,
-            "alice",
-            "A needle in a comment must not duplicate the issue result.",
-            4,
-        )
-        .expect("create a matching comment");
-    store
-        .create_issue(&NewIssue {
-            owner: "alice",
-            repository: "private-project",
-            actor: "alice",
-            title: "Private Secret",
-            body: "Only a repository reader can find this.",
-            created_at: 5,
-        })
-        .expect("create a private issue");
-    store
-        .connection()
-        .execute_batch(
-            "WITH RECURSIVE sequence(number) AS (
-                 VALUES (2)
-                 UNION ALL
-                 SELECT number + 1 FROM sequence WHERE number < 102
-             )
-             INSERT INTO issue
-                 (id, repository_id, number, title, body, state,
-                  author_account_id, created_at, updated_at, closed_at)
-             SELECT printf('%032x', number + 1000),
-                    '11111111111111111111111111111111',
-                    number, printf('Bulk match %03d', number), '',
-                    'open', 1, number + 10, number + 10, NULL
-             FROM sequence;",
-        )
-        .expect("create enough matches to reach the result limit");
     drop(store);
 
     let service = MetadataSearchService::new(&database);
     let public = service
-        .search(None, "NEEDLE")
-        .expect("search public metadata");
+        .search(None, "PUBLIC-PROJECT")
+        .expect("search public repositories");
     assert_eq!(public.results.len(), 1);
-    assert_eq!(public.query, "NEEDLE");
-    assert_eq!(public.results[0].kind, "Issue");
-    assert_eq!(public.results[0].url, "/alice/public-project/issues/1");
-    assert!(public.results[0].title.contains("Public Needle"));
-    assert!(public.results[0].summary.contains("public body"));
+    assert_eq!(public.query, "PUBLIC-PROJECT");
+    assert_eq!(public.results[0].kind, "Repository");
+    assert_eq!(public.results[0].url, "/alice/public-project");
+    assert!(public.results[0].title.contains("public-project"));
+    assert!(public.results[0].summary.is_empty());
     assert_eq!(public.results[0].stable_id.len(), 32);
     assert!(!public.truncated);
-    assert!(public.rows_scanned >= 3);
+    assert!(public.rows_scanned >= 1);
     assert!(public.bytes_scanned > 0);
 
-    let bounded = service
-        .search(None, "bulk match")
-        .expect("search more results than the output limit");
-    assert_eq!(bounded.results.len(), 100);
-    assert!(bounded.truncated);
-
     let reader = service
-        .search(Some("bob"), "private secret")
-        .expect("search private metadata as a reader");
+        .search(Some("bob"), "private-project")
+        .expect("search private repositories as a reader");
     assert_eq!(reader.results.len(), 1);
-    assert_eq!(reader.results[0].url, "/alice/private-project/issues/1");
+    assert_eq!(reader.results[0].url, "/alice/private-project");
     assert!(
         service
-            .search(Some("stranger"), "private secret")
-            .expect("search private metadata as a stranger")
+            .search(Some("stranger"), "private-project")
+            .expect("search private repositories as a stranger")
             .results
             .is_empty()
     );
 
     let restarted = MetadataSearchService::new(&database)
-        .search(None, "needle")
-        .expect("repeat metadata search after restart");
+        .search(None, "public-project")
+        .expect("repeat repository search after restart");
     assert_eq!(
         restarted
             .results
@@ -169,8 +115,8 @@
 }
 
 #[test]
-#[ignore = "M4.5 representative metadata search measurement"]
-fn measures_bounded_metadata_search_without_an_index() {
+#[ignore = "M4.5 representative repository name search measurement"]
+fn measures_bounded_repository_name_search_without_an_index() {
     let directory = TempDir::new().expect("create a search measurement directory");
     let database = directory.path().join("tit.sqlite3");
     let mut store = Store::open(&database).expect("create the search measurement database");
@@ -196,24 +142,22 @@
                  UNION ALL
                  SELECT number + 1 FROM sequence WHERE number < 9999
              )
-             INSERT INTO issue
-                 (id, repository_id, number, title, body, state,
-                  author_account_id, created_at, updated_at, closed_at)
+             INSERT INTO repository
+                 (id, owner_account_id, slug, visibility, state, object_format,
+                  created_at, archived_at)
              SELECT printf('%032x', number),
-                    '33333333333333333333333333333333',
-                    number,
-                    printf('Issue %05d', number),
-                    printf('Representative metadata body %05d', number),
-                    'open', 1, number + 2, number + 2, NULL
+                    1,
+                    printf('repository-%05d', number),
+                    'public', 'active', 'sha1', number + 2, NULL
              FROM sequence;",
         )
-        .expect("create representative metadata");
+        .expect("create representative repositories");
     drop(store);
 
     let started = Instant::now();
     let outcome = MetadataSearchService::new(&database)
-        .search(None, "representative metadata body 09999")
-        .expect("measure metadata search");
+        .search(None, "repository-09999")
+        .expect("measure repository name search");
     let elapsed = started.elapsed();
     assert_eq!(outcome.results.len(), 1);
     assert_eq!(outcome.rows_scanned, 10_000);
@@ -235,6 +179,7 @@
             owner: "alice",
             slug,
             object_format: "sha1",
+            default_branch: "refs/heads/main",
             created_at: 2,
             origin: RepositoryOrigin::Created,
             initial_references: &[],

tests/public_routes.rs

Mode 100644100644; object d8a875e82b4cc622c042a185

@@ -80,7 +80,7 @@
 use std::fs;
 use std::io::{Read, Write};
 use std::net::{Ipv4Addr, SocketAddr, TcpStream};
-use std::path::Path;
+use std::path::{Path, PathBuf};
 use std::process::Command;
 use std::time::{SystemTime, UNIX_EPOCH};
 
@@ -125,7 +125,7 @@
         assert!(!summary_text.contains("<img"));
         assert!(!summary_text.contains("tracker.example"));
         assert!(summary_text.contains("&#60;script&#62;alert(3)&#60;/script&#62;"));
-        assert!(summary_text.contains("/alice/example/atom.xml"));
+        assert!(!summary_text.contains("/alice/example/atom.xml"));
         assert!(summary_text.contains("/alice/example/rss.xml"));
         assert!(summary_text.contains("/alice/example/search"));
         assert!(summary_text.contains("/alice/example/commits\">View all commits</a>"));
@@ -185,17 +185,9 @@
             .status,
             400
         );
-        let mut feed_entry_ids = Vec::new();
-        for (path, content_type) in [
-            (
-                "/alice/example/atom.xml",
-                "application/atom+xml; charset=utf-8",
-            ),
-            (
-                "/alice/example/rss.xml",
-                "application/rss+xml; charset=utf-8",
-            ),
-        ] {
+        {
+            let path = "/alice/example/rss.xml";
+            let content_type = "application/rss+xml; charset=utf-8";
             let feed = request(server.address(), "GET", path, &[], &[]);
             assert_eq!(feed.status, 200);
             assert_eq!(feed.header("content-type"), content_type);
@@ -205,7 +197,6 @@
             let parsed = feed_rs::parser::parse(feed.body.as_slice()).expect("parse the feed");
             assert_eq!(parsed.entries.len(), 1);
             assert!(parsed.entries[0].id.starts_with("urn:tit:event:"));
-            feed_entry_ids.push(parsed.entries[0].id.clone());
             assert!(feed.text().contains("Repository imported"));
 
             let etag = feed.header("etag");
@@ -234,12 +225,15 @@
             assert!(head.body.is_empty());
             assert_eq!(head.header("etag"), feed.header("etag"));
         }
-        assert_eq!(feed_entry_ids[0], feed_entry_ids[1]);
+        assert_eq!(
+            request(server.address(), "GET", "/alice/example/atom.xml", &[], &[]).status,
+            404
+        );
 
         let invalid_page = request(
             server.address(),
             "GET",
-            "/alice/example/atom.xml?before=0",
+            "/alice/example/rss.xml?before=0",
             &[],
             &[],
         );
@@ -271,7 +265,7 @@
                 .expect("insert a page fixture event");
         }
         drop(feed_store);
-        let first_page = request(server.address(), "GET", "/alice/example/atom.xml", &[], &[]);
+        let first_page = request(server.address(), "GET", "/alice/example/rss.xml", &[], &[]);
         let first_feed =
             feed_rs::parser::parse(first_page.body.as_slice()).expect("parse the first page");
         assert_eq!(first_feed.entries.len(), 20);
@@ -330,6 +324,27 @@
                 response.body.len().to_string()
             );
         }
+        let patch = request(
+            server.address(),
+            "GET",
+            &format!("/alice/example/commit/{}.patch", fixture.head),
+            &[],
+            &[],
+        );
+        assert_eq!(patch.status, 200);
+        assert_eq!(patch.header("content-type"), "text/x-diff; charset=utf-8");
+        assert!(patch.header("content-disposition").contains(".patch"));
+        let patch_path = fixture.instance.path().join("commit.patch");
+        fs::write(&patch_path, &patch.body).expect("write the downloaded patch");
+        run(Command::new("git")
+            .arg("-C")
+            .arg(&fixture.worktree)
+            .args(["checkout", "-q", "--detach", "HEAD^"]));
+        run(Command::new("git")
+            .arg("-C")
+            .arg(&fixture.worktree)
+            .args(["apply", "--check"])
+            .arg(&patch_path));
 
         let search = request(
             server.address(),
@@ -658,6 +673,10 @@
     let csrf = "22".repeat(32);
     let session_hash: [u8; 32] = Sha256::digest(token.as_bytes()).into();
     let csrf_hash: [u8; 32] = Sha256::digest(csrf.as_bytes()).into();
+    let reader_token = "44".repeat(32);
+    let reader_csrf = "55".repeat(32);
+    let reader_session_hash: [u8; 32] = Sha256::digest(reader_token.as_bytes()).into();
+    let reader_csrf_hash: [u8; 32] = Sha256::digest(reader_csrf.as_bytes()).into();
     let now = SystemTime::now()
         .duration_since(UNIX_EPOCH)
         .expect("read current time")
@@ -672,6 +691,34 @@
             rusqlite::params![session_hash, csrf_hash, now, now + 3600,],
         )
         .expect("create a Web session");
+    store
+        .connection()
+        .execute(
+            "INSERT INTO account (username, is_administrator, state, created_at)
+             VALUES ('bob', 0, 'active', ?1)",
+            [now],
+        )
+        .expect("create a reader account");
+    store
+        .connection()
+        .execute(
+            "INSERT INTO web_session
+             (session_hash, csrf_hash, account_id, created_at, expires_at)
+             SELECT ?1, ?2, id, ?3, ?4 FROM account WHERE username = 'bob'",
+            rusqlite::params![reader_session_hash, reader_csrf_hash, now, now + 3600],
+        )
+        .expect("create a reader Web session");
+    store
+        .connection()
+        .execute(
+            "INSERT INTO repository_collaborator
+             (repository_id, account_id, role, created_at)
+             SELECT repository.id, account.id, 'reader', ?1
+             FROM repository, account
+             WHERE repository.slug = 'example' AND account.username = 'bob'",
+            [now],
+        )
+        .expect("create a reader collaborator");
     drop(store);
 
     let server = RunningWebServer::start_public(
@@ -724,7 +771,7 @@
     assert!(detail.text().contains("<strong>safe</strong>"));
     assert!(!detail.text().contains("<script>"));
     assert!(detail.text().contains("Add a comment"));
-    assert!(detail.text().contains("Organize this issue"));
+    assert!(!detail.text().contains("Organize this issue"));
 
     let bad_csrf = form(&[("csrf", &"33".repeat(32)), ("state", "closed")]);
     assert_eq!(
@@ -752,22 +799,6 @@
                 ("csrf", csrf.as_str()),
                 ("title", "Edited issue"),
                 ("body", "Preserved _Markdown_."),
-            ],
-        ),
-        (
-            "/alice/example/issues/1/labels",
-            vec![
-                ("csrf", csrf.as_str()),
-                ("label", "bug"),
-                ("operation", "add"),
-            ],
-        ),
-        (
-            "/alice/example/issues/1/assignees",
-            vec![
-                ("csrf", csrf.as_str()),
-                ("assignee", "alice"),
-                ("operation", "add"),
             ],
         ),
         (
@@ -802,8 +833,8 @@
     assert!(final_text.contains("<em>Markdown</em>"));
     assert!(final_text.contains("<strong>comment</strong>"));
     assert!(final_text.contains("<article class=\"comment-card\""));
-    assert!(final_text.contains("Labels: <span>bug</span>"));
-    assert!(final_text.contains("Assignees: <span>alice</span>"));
+    assert!(!final_text.contains("Labels:"));
+    assert!(!final_text.contains("Assignees:"));
     assert!(final_text.contains("created the issue"));
     assert!(final_text.contains("reopened the issue"));
 
@@ -920,6 +951,19 @@
     assert_eq!(first_revision.status, 200);
     assert!(first_revision.text().contains("Comparison for revision 1"));
     assert!(!first_revision.text().contains("pull-request.txt"));
+    let first_revision_patch = request(
+        server.address(),
+        "GET",
+        "/alice/example/pulls/1/revisions/1.patch",
+        &[],
+        &[],
+    );
+    assert_eq!(first_revision_patch.status, 200);
+    assert_eq!(
+        first_revision_patch.header("content-type"),
+        "text/x-diff; charset=utf-8"
+    );
+    let immutable_revision_patch = first_revision_patch.body.clone();
     for fields in [
         vec![
             ("csrf", csrf.as_str()),
@@ -983,6 +1027,17 @@
     );
     assert_eq!(revised_again.status, 303);
     let outdated = request(server.address(), "GET", "/alice/example/pulls/1", &[], &[]);
+    let first_revision_patch_after_revisions = request(
+        server.address(),
+        "GET",
+        "/alice/example/pulls/1/revisions/1.patch",
+        &[],
+        &[],
+    );
+    assert_eq!(
+        first_revision_patch_after_revisions.body,
+        immutable_revision_patch
+    );
     assert!(outdated.text().contains("<strong>Outdated</strong>"));
     let merge_page = request(
         server.address(),
@@ -1031,37 +1086,20 @@
     );
     let search_page = request(server.address(), "GET", "/search", &[], &[]);
     assert_eq!(search_page.status, 200);
-    assert!(
-        search_page
-            .text()
-            .contains("Search repositories and issues")
-    );
-    let metadata_search = request(
-        server.address(),
-        "GET",
-        "/search?q=Edited%20issue",
-        &[],
-        &[],
-    );
+    assert!(search_page.text().contains("Search repositories"));
+    let metadata_search = request(server.address(), "GET", "/search?q=example", &[], &[]);
     assert_eq!(metadata_search.status, 200);
-    assert!(metadata_search.text().contains("/alice/example/issues/1"));
+    assert!(metadata_search.text().contains("/alice/example"));
     assert_eq!(
         request(server.address(), "GET", "/search?q=", &[], &[]).status,
         400
     );
-    let feed = request(server.address(), "GET", "/alice/example/atom.xml", &[], &[]);
+    let feed = request(server.address(), "GET", "/alice/example/rss.xml", &[], &[]);
     assert_eq!(feed.status, 200);
     assert!(feed.text().contains("alice reopened #1"));
-    for (path, content_type) in [
-        (
-            "/alice/example/issues/atom.xml",
-            "application/atom+xml; charset=utf-8",
-        ),
-        (
-            "/alice/example/issues/rss.xml",
-            "application/rss+xml; charset=utf-8",
-        ),
-    ] {
+    {
+        let path = "/alice/example/issues/rss.xml";
+        let content_type = "application/rss+xml; charset=utf-8";
         let issue_feed = request(server.address(), "GET", path, &[], &[]);
         assert_eq!(issue_feed.status, 200);
         assert_eq!(issue_feed.header("content-type"), content_type);
@@ -1080,6 +1118,9 @@
             .text()
             .contains("Log in</a> to change watch preferences.")
     );
+    let anonymous_activity = request(server.address(), "GET", "/activity", &[], &[]);
+    assert_eq!(anonymous_activity.status, 303);
+    assert_eq!(anonymous_activity.header("location"), "/login");
     let watch_page = request(
         server.address(),
         "GET",
@@ -1093,12 +1134,7 @@
             .text()
             .contains("You do not watch this repository.")
     );
-    let everything = form(&[
-        ("csrf", csrf.as_str()),
-        ("pushes", "1"),
-        ("issues", "1"),
-        ("pull-requests", "1"),
-    ]);
+    let everything = form(&[("csrf", csrf.as_str()), ("state", "watch")]);
     let watched = request(
         server.address(),
         "POST",
@@ -1118,9 +1154,18 @@
     assert!(
         selected
             .text()
-            .contains("You watch selected activity in this repository.")
+            .contains("You watch activity in this repository.")
     );
-    assert_eq!(selected.text().matches("value=\"1\" selected").count(), 3);
+    let activity = request(
+        server.address(),
+        "GET",
+        "/activity",
+        &[("Cookie", cookie.as_str())],
+        &[],
+    );
+    assert_eq!(activity.status, 200);
+    assert!(activity.text().contains("alice/example"));
+    assert!(activity.text().contains("reopened #1"));
 
     let feed_tokens = request(
         server.address(),
@@ -1131,12 +1176,7 @@
     );
     assert_eq!(feed_tokens.status, 200);
     assert!(feed_tokens.text().contains("Feed URLs are credentials."));
-    let issue_repository_token = form(&[
-        ("csrf", csrf.as_str()),
-        ("scope", "repository"),
-        ("owner", "alice"),
-        ("repository", "example"),
-    ]);
+    let issue_repository_token = form(&[("csrf", csrf.as_str())]);
     let issued = request(
         server.address(),
         "POST",
@@ -1145,9 +1185,9 @@
         issue_repository_token.as_bytes(),
     );
     assert_eq!(issued.status, 201);
-    assert!(issued.text().contains("tit will not show them again."));
+    assert!(issued.text().contains("tit will not show it again."));
     let private_token = extract_feed_token(issued.text());
-    let private_path = format!("/feeds/{private_token}/atom.xml");
+    let private_path = format!("/feeds/{private_token}/rss.xml");
     let private_feed = request(server.address(), "GET", &private_path, &[], &[]);
     assert_eq!(private_feed.status, 200);
     assert_eq!(private_feed.header("cache-control"), "private, no-store");
@@ -1159,13 +1199,25 @@
         .connection()
         .query_row(
             "SELECT id, token_hash FROM feed_token
-             WHERE scope = 'repository' AND revoked_at IS NULL",
+             WHERE scope = 'watched' AND revoked_at IS NULL",
             [],
             |row| Ok((row.get(0)?, row.get(1)?)),
         )
         .expect("read the stored feed token hash");
     assert_eq!(stored_hash, private_hash);
     assert_ne!(stored_hash, private_token.as_bytes());
+    let selected_events = token_store
+        .watched_activity_page("alice", None, crate::feed::PAGE_SIZE)
+        .expect("read watched Web activity");
+    assert!(!selected_events.events.is_empty());
+    for event in &selected_events.events {
+        assert!(activity.text().contains(&event.event.event_id));
+        assert!(
+            private_feed
+                .text()
+                .contains(&format!("urn:tit:event:{}", event.event.event_id))
+        );
+    }
     drop(token_store);
     let hidden_token = request(
         server.address(),
@@ -1185,41 +1237,77 @@
         )
         .expect("make the feed repository private");
     drop(private_store);
+    let private_archive_path = format!("/alice/example/archive/{}.tar", fixture.head);
+    let owner_archive = request(
+        server.address(),
+        "GET",
+        &private_archive_path,
+        &[("Cookie", cookie.as_str())],
+        &[],
+    );
+    assert_eq!(owner_archive.status, 200);
+    assert_eq!(owner_archive.header("cache-control"), "private, no-store");
+    let anonymous_archive = request(server.address(), "GET", &private_archive_path, &[], &[]);
+    assert_eq!(anonymous_archive.status, 404);
+    let private_commit_patch_path = format!("/alice/example/commit/{}.patch", fixture.head);
     assert_eq!(
         request(
             server.address(),
             "GET",
-            "/alice/example/issues/atom.xml",
+            &private_commit_patch_path,
+            &[("Cookie", cookie.as_str())],
+            &[],
+        )
+        .status,
+        200
+    );
+    assert_eq!(
+        request(
+            server.address(),
+            "GET",
+            &private_commit_patch_path,
             &[],
             &[],
         )
         .status,
         404
     );
-    let anonymous_private_search = request(
-        server.address(),
-        "GET",
-        "/search?q=Edited%20issue",
-        &[],
-        &[],
+    assert_eq!(
+        request(
+            server.address(),
+            "GET",
+            "/alice/example/pulls/1/revisions/1.patch",
+            &[],
+            &[],
+        )
+        .status,
+        404
     );
+    assert_eq!(
+        request(
+            server.address(),
+            "GET",
+            "/alice/example/issues/rss.xml",
+            &[],
+            &[],
+        )
+        .status,
+        404
+    );
+    let anonymous_private_search = request(server.address(), "GET", "/search?q=example", &[], &[]);
     assert!(
         anonymous_private_search
             .text()
-            .contains("No repository or issue matched")
+            .contains("No repository matched")
     );
     let owner_private_search = request(
         server.address(),
         "GET",
-        "/search?q=Edited%20issue",
+        "/search?q=example",
         &[("Cookie", cookie.as_str())],
         &[],
     );
-    assert!(
-        owner_private_search
-            .text()
-            .contains("/alice/example/issues/1")
-    );
+    assert!(owner_private_search.text().contains("/alice/example"));
     assert_eq!(
         request(server.address(), "GET", &private_path, &[], &[]).status,
         200
@@ -1282,66 +1370,28 @@
         404
     );
 
-    for (scope, expected, excluded, format) in [
-        (
-            "watched",
-            "commented on #1",
-            "no excluded event",
-            "atom.xml",
-        ),
-        (
-            "assignments",
-            "assigned alice on #1",
-            "commented on #1",
-            "rss.xml",
-        ),
-        (
-            "mentions",
-            "commented on #1",
-            "assigned alice on #1",
-            "atom.xml",
-        ),
-    ] {
-        let body = form(&[
-            ("csrf", csrf.as_str()),
-            ("scope", scope),
-            ("owner", ""),
-            ("repository", ""),
-        ]);
-        let issued = request(
-            server.address(),
-            "POST",
-            "/feeds/tokens",
-            &headers,
-            body.as_bytes(),
-        );
-        assert_eq!(issued.status, 201, "did not issue the {scope} token");
-        let token = extract_feed_token(issued.text());
-        let response = request(
-            server.address(),
-            "GET",
-            &format!("/feeds/{token}/{format}"),
-            &[],
-            &[],
-        );
-        assert_eq!(response.status, 200, "did not read the {scope} feed");
-        feed_rs::parser::parse(response.body.as_slice())
-            .unwrap_or_else(|_| panic!("parse the {scope} feed"));
-        assert!(response.text().contains(expected), "wrong {scope} feed");
-        if excluded != "no excluded event" {
-            assert!(
-                !response.text().contains(excluded),
-                "the {scope} token escaped its scope"
-            );
-        }
-    }
+    let body = form(&[("csrf", csrf.as_str())]);
+    let issued = request(
+        server.address(),
+        "POST",
+        "/feeds/tokens",
+        &headers,
+        body.as_bytes(),
+    );
+    assert_eq!(issued.status, 201);
+    let token = extract_feed_token(issued.text());
+    let response = request(
+        server.address(),
+        "GET",
+        &format!("/feeds/{token}/rss.xml"),
+        &[],
+        &[],
+    );
+    assert_eq!(response.status, 200);
+    feed_rs::parser::parse(response.body.as_slice()).expect("parse the watched feed");
+    assert!(response.text().contains("commented on #1"));
 
-    let none = form(&[
-        ("csrf", csrf.as_str()),
-        ("pushes", "0"),
-        ("issues", "0"),
-        ("pull-requests", "0"),
-    ]);
+    let none = form(&[("csrf", csrf.as_str()), ("state", "unwatch")]);
     assert_eq!(
         request(
             server.address(),
@@ -1361,6 +1411,166 @@
             .expect("count cleared watches"),
         0
     );
+
+    let reader_cookie = format!("tit-session={reader_token}; tit-csrf={reader_csrf}");
+    let reader_headers = [
+        ("Content-Type", "application/x-www-form-urlencoded"),
+        ("Cookie", reader_cookie.as_str()),
+    ];
+    let denied_rename = form(&[("csrf", reader_csrf.as_str()), ("new-name", "reader-name")]);
+    assert_eq!(
+        request(
+            server.address(),
+            "POST",
+            "/alice/example/settings/rename",
+            &reader_headers,
+            denied_rename.as_bytes(),
+        )
+        .status,
+        403
+    );
+    let conflicting_rename = form(&[("csrf", csrf.as_str()), ("new-name", "empty")]);
+    assert_eq!(
+        request(
+            server.address(),
+            "POST",
+            "/alice/example/settings/rename",
+            &headers,
+            conflicting_rename.as_bytes(),
+        )
+        .status,
+        400
+    );
+    let rename = form(&[("csrf", csrf.as_str()), ("new-name", "renamed")]);
+    let renamed = request(
+        server.address(),
+        "POST",
+        "/alice/example/settings/rename",
+        &headers,
+        rename.as_bytes(),
+    );
+    assert_eq!(renamed.status, 303);
+    assert_eq!(renamed.header("location"), "/alice/renamed/settings");
+    assert_eq!(
+        request(server.address(), "GET", "/alice/example", &[], &[]).status,
+        404
+    );
+    assert_eq!(
+        request(
+            server.address(),
+            "GET",
+            "/alice/renamed/issues/1",
+            &[("Cookie", cookie.as_str())],
+            &[],
+        )
+        .status,
+        200
+    );
+    assert_eq!(
+        request(
+            server.address(),
+            "GET",
+            "/alice/renamed/pulls/1",
+            &[("Cookie", cookie.as_str())],
+            &[],
+        )
+        .status,
+        200
+    );
+    let bare = fixture
+        .instance
+        .path()
+        .join("repositories")
+        .join(format!("{}.git", fixture.repository_id));
+    let ref_before_archive = rev_parse(&bare, "refs/heads/main");
+    let archive_repository = form(&[("csrf", csrf.as_str()), ("confirm", "yes")]);
+    assert_eq!(
+        request(
+            server.address(),
+            "POST",
+            "/alice/renamed/settings/archive",
+            &headers,
+            archive_repository.as_bytes(),
+        )
+        .status,
+        303
+    );
+    let archived_home = request(
+        server.address(),
+        "GET",
+        "/",
+        &[("Cookie", cookie.as_str())],
+        &[],
+    );
+    assert!(archived_home.text().contains("alice/renamed · archived"));
+    assert!(archived_home.text().contains("Unarchive repository"));
+    let denied_unarchive = form(&[("csrf", reader_csrf.as_str()), ("confirm", "yes")]);
+    assert_eq!(
+        request(
+            server.address(),
+            "POST",
+            "/alice/renamed/settings/unarchive",
+            &reader_headers,
+            denied_unarchive.as_bytes(),
+        )
+        .status,
+        403
+    );
+    let unarchive = form(&[("csrf", csrf.as_str()), ("confirm", "yes")]);
+    assert_eq!(
+        request(
+            server.address(),
+            "POST",
+            "/alice/renamed/settings/unarchive",
+            &headers,
+            unarchive.as_bytes(),
+        )
+        .status,
+        303
+    );
+    assert_eq!(rev_parse(&bare, "refs/heads/main"), ref_before_archive);
+    assert_eq!(
+        request(
+            server.address(),
+            "GET",
+            "/alice/renamed",
+            &[("Cookie", cookie.as_str())],
+            &[],
+        )
+        .status,
+        200
+    );
+    let lifecycle_store = Store::open(&database).expect("open the lifecycle database");
+    let lifecycle: (String, String, i64) = lifecycle_store
+        .connection()
+        .query_row(
+            "SELECT repository.visibility, repository.state,
+                    (SELECT count(*) FROM repository_collaborator
+                     WHERE repository_id = repository.id)
+             FROM repository WHERE repository.slug = 'renamed'",
+            [],
+            |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
+        )
+        .expect("read repository lifecycle state");
+    assert_eq!(lifecycle, ("private".to_owned(), "active".to_owned(), 1));
+    for action in [
+        "repository.rename",
+        "repository.archive",
+        "repository.unarchive",
+    ] {
+        assert_eq!(
+            lifecycle_store
+                .connection()
+                .query_row(
+                    "SELECT count(*) FROM audit_event
+                     WHERE action = ?1 AND actor = 'alice' AND outcome = 'success'",
+                    [action],
+                    |row| row.get::<_, i64>(0),
+                )
+                .expect("count a repository lifecycle audit event"),
+            1
+        );
+    }
 
     server.shutdown().await.expect("stop the issue Web server");
 }
@@ -1396,6 +1606,7 @@
 
 struct Fixture {
     instance: TempDir,
+    worktree: PathBuf,
     repository_id: String,
     head: String,
     parent: String,
@@ -1499,6 +1710,7 @@
                 owner: "alice",
                 slug: "example",
                 object_format: format,
+                default_branch: "refs/heads/main",
                 created_at: 2,
                 origin: RepositoryOrigin::Imported,
                 initial_references: &[],
@@ -1512,6 +1724,7 @@
                 owner: "alice",
                 slug: "empty",
                 object_format: format,
+                default_branch: "refs/heads/main",
                 created_at: 2,
                 origin: RepositoryOrigin::Created,
                 initial_references: &[],
@@ -1523,6 +1736,7 @@
 
         Self {
             instance,
+            worktree,
             repository_id: id.to_owned(),
             head,
             parent,
@@ -1627,14 +1841,7 @@
 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",
+        "", "/refs", "/issues", "/pulls", "/watch", "/rss.xml", "/search",
     ] {
         let link = format!("/{owner}/{repository}{suffix}");
         assert!(

tests/pull_requests.rs

Mode 100644100644; object 3bc4e495a349c02ebdd1b2b8

@@ -73,6 +73,49 @@
         assert_eq!(comparison.comparison.commits.len(), 1);
         assert_eq!(comparison.comparison.changed_paths, [b"feature.txt"]);
         assert_eq!(comparison.comparison.files.len(), 1);
+        service
+            .edit(
+                "alice",
+                "project",
+                1,
+                "alice",
+                "Add the edited feature",
+                "Keep the edited revision context.",
+            )
+            .expect("edit a pull request");
+        service
+            .set_state("alice", "project", 1, "alice", "closed")
+            .expect("close a pull request");
+        assert!(matches!(
+            service.revise("alice", "project", 1, "alice"),
+            Err(PullRequestError::Store(StoreError::PullRequestState))
+        ));
+        service
+            .set_state("alice", "project", 1, "alice", "open")
+            .expect("reopen a pull request");
+        let lifecycle = service
+            .get("alice", "project", 1, Some("alice"))
+            .expect("read pull-request lifecycle changes");
+        assert_eq!(lifecycle.pull_request.title, "Add the edited feature");
+        assert_eq!(lifecycle.pull_request.state, "open");
+        assert!(
+            lifecycle
+                .timeline
+                .iter()
+                .any(|event| event.kind == "pull-request-edited")
+        );
+        assert!(
+            lifecycle
+                .timeline
+                .iter()
+                .any(|event| event.kind == "pull-request-closed")
+        );
+        assert!(
+            lifecycle
+                .timeline
+                .iter()
+                .any(|event| event.kind == "pull-request-reopened")
+        );
         run(
             &fixture.worktree,
             Command::new("git")
@@ -243,6 +286,9 @@
             event_kinds,
             [
                 "pull-request-created",
+                "pull-request-edited",
+                "pull-request-closed",
+                "pull-request-reopened",
                 "pull-request-revised",
                 "pull-request-created",
                 "pull-request-revised",
@@ -819,6 +865,20 @@
         );
 
         let store = Store::open(&fixture.database).expect("open the review policy store");
+        let first_page = store
+            .pull_request_detail_page("alice", "project", 1, Some("bob"), 1, 1, 2)
+            .expect("read the first bounded review page");
+        assert_eq!(first_page.reviews.len(), 2);
+        assert!(first_page.reviews_has_next);
+        assert_eq!(first_page.timeline.len(), 2);
+        assert!(first_page.timeline_has_next);
+        let second_page = store
+            .pull_request_detail_page("alice", "project", 1, Some("bob"), 2, 2, 2)
+            .expect("read the second bounded review page");
+        assert_eq!(second_page.reviews_page, 2);
+        assert_eq!(second_page.reviews.len(), 2);
+        assert_eq!(second_page.timeline_page, 2);
+        assert_eq!(second_page.timeline.len(), 2);
         store
             .connection()
             .execute(

tests/repository_policy.rs

Mode 100644100644; object 3148b123380f99c8bd8bf9c6

@@ -36,6 +36,7 @@
             owner: "owner",
             slug: "project",
             object_format: "sha1",
+            default_branch: "refs/heads/main",
             created_at: 2,
             origin: RepositoryOrigin::Created,
             initial_references: &[],
@@ -111,6 +112,7 @@
             owner: "owner",
             slug: "project",
             object_format: "sha1",
+            default_branch: "refs/heads/main",
             created_at: 2,
             origin: RepositoryOrigin::Created,
             initial_references: &[],
@@ -182,6 +184,7 @@
             owner: "owner",
             slug: "project",
             object_format: "sha1",
+            default_branch: "refs/heads/main",
             created_at: 2,
             origin: RepositoryOrigin::Created,
             initial_references: &[],
@@ -248,6 +251,45 @@
             RefChange::Create,
         )
         .expect("allow a writer topic branch");
+    store
+        .update_repository_default_branch(
+            "owner",
+            "project",
+            "maintainer",
+            "refs/heads/trunk",
+            4,
+            "default-branch",
+        )
+        .expect("change the protected default branch");
+    policy
+        .authorize_ref_change(
+            "writer",
+            "owner",
+            "project",
+            b"refs/heads/main",
+            RefChange::FastForward,
+        )
+        .expect("allow a writer to update the former default branch");
+    assert!(matches!(
+        policy.authorize_ref_change(
+            "writer",
+            "owner",
+            "project",
+            b"refs/heads/trunk",
+            RefChange::FastForward,
+        ),
+        Err(PolicyError::Denied)
+    ));
+    assert!(matches!(
+        policy.authorize_ref_change(
+            "owner",
+            "owner",
+            "project",
+            b"refs/heads/trunk",
+            RefChange::Delete,
+        ),
+        Err(PolicyError::Denied)
+    ));
     policy
         .authorize_ref_change(
             "writer",

tests/serve.rs

Mode 100644100644; object 14b481a32cd032f07c38886e

@@ -71,6 +71,7 @@
             source.to_str().expect("a UTF-8 source path"),
         ],
     );
+    let _settings_member_key = provision_account(instance.path(), "carol", "active", false);
     let mut server = spawn_server(&config);
     wait_for_listener(http, &mut server);
     wait_for_listener(ssh, &mut server);
@@ -176,13 +177,17 @@
     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>"));
+    assert!(http_get(http, "/alice/example").contains("trunk head"));
 
     let login_approval = http_form(http, "/login/ssh", &[]);
     assert!(login_approval.starts_with("HTTP/1.1 200"));
     let secret = between(&login_approval, "name=\"secret\" value=\"", "\">");
     let login_csrf_cookies = response_cookies(&login_approval);
     let login_csrf = cookie_value(&login_csrf_cookies, "tit-login-csrf");
-    let ssh_approval = ssh_exec(ssh, &private_key, &["login", secret]);
+    let removed_login = ssh_exec(ssh, &private_key, &["login", secret]);
+    assert!(!removed_login.status.success());
+    assert!(String::from_utf8_lossy(&removed_login.stderr).contains("help"));
+    let ssh_approval = ssh_exec(ssh, &private_key, &["auth", secret]);
     assert!(ssh_approval.status.success());
     let ssh_approval_output =
         String::from_utf8(ssh_approval.stdout).expect("read SSH approval output");
@@ -236,6 +241,208 @@
     assert!(signed_in_missing.contains("<a href=\"/logout\">Log out</a>"));
     assert!(!signed_in_missing.contains("<a href=\"/login\">Log in</a>"));
     let csrf = cookie_value(&cookies, "tit-csrf");
+    let second_key = instance.path().join("alice-second");
+    create_ssh_key_fixture(&second_key);
+    let second_public_key =
+        fs::read_to_string(second_key.with_extension("pub")).expect("read the second public key");
+    let second_fingerprint = ssh_fingerprint(&second_key);
+    let begin_add = http_form_with_headers(
+        http,
+        "/account/keys/add",
+        &[
+            ("csrf", csrf),
+            ("label", "laptop"),
+            ("public-key", second_public_key.trim()),
+        ],
+        &[("Cookie", &cookies)],
+    );
+    assert!(begin_add.starts_with("HTTP/1.1 200"), "{begin_add}");
+    assert!(begin_add.contains("ssh -p"));
+    assert!(begin_add.contains(" auth "));
+    let add_secret = between(&begin_add, "name=\"secret\" value=\"", "\">");
+    assert!(
+        ssh_exec(ssh, &private_key, &["auth", add_secret])
+            .status
+            .success()
+    );
+    let complete_add = http_form_with_headers(
+        http,
+        "/account/keys/add/complete",
+        &[
+            ("csrf", csrf),
+            ("secret", add_secret),
+            ("label", "laptop"),
+            ("public-key", second_public_key.trim()),
+        ],
+        &[("Cookie", &cookies)],
+    );
+    assert!(complete_add.starts_with("HTTP/1.1 303"), "{complete_add}");
+    let replay_add = http_form_with_headers(
+        http,
+        "/account/keys/add/complete",
+        &[
+            ("csrf", csrf),
+            ("secret", add_secret),
+            ("label", "replay"),
+            ("public-key", second_public_key.trim()),
+        ],
+        &[("Cookie", &cookies)],
+    );
+    assert!(replay_add.starts_with("HTTP/1.1 400"));
+    assert!(ssh_exec(ssh, &second_key, &["help"]).status.success());
+    let account_with_key = http_get_with_headers(http, "/account", &[("Cookie", &cookies)]);
+    assert!(account_with_key.contains("laptop"));
+    assert!(account_with_key.contains(&second_fingerprint));
+
+    let begin_revoke = http_form_with_headers(
+        http,
+        "/account/keys/revoke",
+        &[("csrf", csrf), ("fingerprint", &second_fingerprint)],
+        &[("Cookie", &cookies)],
+    );
+    assert!(begin_revoke.starts_with("HTTP/1.1 200"));
+    let revoke_secret = between(&begin_revoke, "name=\"secret\" value=\"", "\">");
+    assert!(
+        ssh_exec(ssh, &private_key, &["auth", revoke_secret])
+            .status
+            .success()
+    );
+    let complete_revoke = http_form_with_headers(
+        http,
+        "/account/keys/revoke/complete",
+        &[
+            ("csrf", csrf),
+            ("secret", revoke_secret),
+            ("fingerprint", &second_fingerprint),
+        ],
+        &[("Cookie", &cookies)],
+    );
+    assert!(
+        complete_revoke.starts_with("HTTP/1.1 303"),
+        "{complete_revoke}"
+    );
+    assert!(!ssh_exec(ssh, &second_key, &["help"]).status.success());
+
+    let initial_fingerprint = ssh_fingerprint(&private_key);
+    let begin_final_revoke = http_form_with_headers(
+        http,
+        "/account/keys/revoke",
+        &[("csrf", csrf), ("fingerprint", &initial_fingerprint)],
+        &[("Cookie", &cookies)],
+    );
+    let final_secret = between(&begin_final_revoke, "name=\"secret\" value=\"", "\">");
+    assert!(
+        ssh_exec(ssh, &private_key, &["auth", final_secret])
+            .status
+            .success()
+    );
+    let final_revoke = http_form_with_headers(
+        http,
+        "/account/keys/revoke/complete",
+        &[
+            ("csrf", csrf),
+            ("secret", final_secret),
+            ("fingerprint", &initial_fingerprint),
+        ],
+        &[("Cookie", &cookies)],
+    );
+    assert!(final_revoke.starts_with("HTTP/1.1 400"));
+    assert!(final_revoke.contains("must keep at least one active SSH key"));
+
+    let profile_without_contact_email = http_get(http, "/alice");
+    assert!(profile_without_contact_email.starts_with("HTTP/1.1 200"));
+    assert!(!profile_without_contact_email.contains("mailto:"));
+    let profile_update = http_form_with_headers(
+        http,
+        "/account/profile",
+        &[
+            ("csrf", csrf),
+            ("bio", "Builds small systems."),
+            ("contact-email", "alice@example.test"),
+        ],
+        &[("Cookie", &cookies)],
+    );
+    assert!(profile_update.starts_with("HTTP/1.1 303"));
+    let public_profile = http_get(http, "/alice");
+    assert!(public_profile.starts_with("HTTP/1.1 200"));
+    assert!(public_profile.contains("Builds small systems."));
+    assert!(public_profile.contains("mailto:alice@example.test"));
+    assert!(public_profile.contains("href=\"/alice/example\""));
+    let settings = http_get_with_headers(http, "/alice/example/settings", &[("Cookie", &cookies)]);
+    assert!(settings.starts_with("HTTP/1.1 200"));
+    assert!(settings.contains("<h2>Repository settings</h2>"));
+    assert!(settings.contains("<option value=\"refs/heads/trunk\" selected>"));
+    let pulls = http_get_with_headers(http, "/alice/example/pulls", &[("Cookie", &cookies)]);
+    assert!(pulls.contains("value=\"refs/heads/trunk\" required"));
+    for invalid in [
+        "refs/heads/missing",
+        "refs/tags/v1",
+        "main",
+        "refs/heads/../bad",
+    ] {
+        let rejected = http_form_with_headers(
+            http,
+            "/alice/example/settings/default-branch",
+            &[("csrf", csrf), ("default-branch", invalid)],
+            &[("Cookie", &cookies)],
+        );
+        assert!(
+            rejected.starts_with("HTTP/1.1 400"),
+            "{invalid}: {rejected}"
+        );
+    }
+    let default_branch_update = http_form_with_headers(
+        http,
+        "/alice/example/settings/default-branch",
+        &[("csrf", csrf), ("default-branch", "refs/heads/main")],
+        &[("Cookie", &cookies)],
+    );
+    assert!(default_branch_update.starts_with("HTTP/1.1 303"));
+    let updated_pulls =
+        http_get_with_headers(http, "/alice/example/pulls", &[("Cookie", &cookies)]);
+    assert!(updated_pulls.contains("value=\"refs/heads/main\" required"));
+    assert!(!http_get(http, "/alice/example").contains("trunk head"));
+    assert_eq!(
+        fs::read_to_string(
+            instance
+                .path()
+                .join("repositories")
+                .join(format!("{restored_repository}.git"))
+                .join("HEAD")
+        )
+        .expect("read the changed symbolic HEAD"),
+        "ref: refs/heads/main\n"
+    );
+    let settings_update = http_form_with_headers(
+        http,
+        "/alice/example/settings/general",
+        &[
+            ("csrf", csrf),
+            ("description", "A repository with self-service settings."),
+            ("visibility", "public"),
+        ],
+        &[("Cookie", &cookies)],
+    );
+    assert!(settings_update.starts_with("HTTP/1.1 303"));
+    let collaborator_update = http_form_with_headers(
+        http,
+        "/alice/example/settings/collaborators",
+        &[
+            ("csrf", csrf),
+            ("username", "carol"),
+            ("role", "reader"),
+            ("action", "set"),
+        ],
+        &[("Cookie", &cookies)],
+    );
+    assert!(collaborator_update.starts_with("HTTP/1.1 303"));
+    let updated_settings =
+        http_get_with_headers(http, "/alice/example/settings", &[("Cookie", &cookies)]);
+    assert!(updated_settings.contains("A repository with self-service settings."));
+    assert!(updated_settings.contains("carol · reader"));
+    assert!(http_get(http, "/alice/example").contains(
+        "<p class=\"repository-description\">A repository with self-service settings.</p>"
+    ));
     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\">"));
@@ -388,12 +595,15 @@
         )
         .expect("make the repository private");
     assert!(http_get(http, "/alice/example").starts_with("HTTP/1.1 404"));
+    let public_profile_with_private_repository = http_get(http, "/alice");
+    assert!(public_profile_with_private_repository.starts_with("HTTP/1.1 200"));
+    assert!(!public_profile_with_private_repository.contains("href=\"/alice/example\""));
     let private_summary =
         http_get_with_headers(http, "/alice/example", &[("Cookie", &private_cookies)]);
     assert!(private_summary.starts_with("HTTP/1.1 200"));
     let private_feed = http_get_with_headers(
         http,
-        "/alice/example/atom.xml",
+        "/alice/example/rss.xml",
         &[("Cookie", &private_cookies)],
     );
     assert!(private_feed.starts_with("HTTP/1.1 200"));
@@ -733,7 +943,6 @@
     for route in [
         "/alice/private",
         "/alice/private/refs",
-        "/alice/private/atom.xml",
         "/alice/private/rss.xml",
         "/alice/private/search?q=serve&ref=HEAD",
         "/alice/private/commit/main",
@@ -841,6 +1050,12 @@
     assert!(git_push(&member_key, &created_clone, &["feature"]).success());
     let base = git_revision(&created_clone, "main");
     let head = git_revision(&created_clone, "feature");
+    command(&created_clone, ["switch", "-q", "main"]);
+    command(&created_clone, ["switch", "-q", "-c", "feature-two"]);
+    fs::write(created_clone.join("feature-two.txt"), b"second feature\n")
+        .expect("write the second pull-request feature");
+    git_commit(&created_clone, "create second feature");
+    assert!(git_push(&member_key, &created_clone, &["feature-two"]).success());
     let pull_request_database = rusqlite::Connection::open(instance.path().join("tit.sqlite3"))
         .expect("open the pull-request command database");
     let repository_id: String = pull_request_database
@@ -886,6 +1101,15 @@
             rusqlite::params![base, head],
         )
         .expect("create a pull-request revision fixture");
+    pull_request_database
+        .execute(
+            "INSERT INTO repository_counter (repository_id, next_pull_request_number)
+             VALUES (?1, 2)
+             ON CONFLICT (repository_id)
+             DO UPDATE SET next_pull_request_number = 2",
+            [&repository_id],
+        )
+        .expect("advance the pull-request number fixture");
     drop(pull_request_database);
 
     let human_checkout = ssh_exec(ssh, &member_key, &["pr", "checkout", "bob/example", "1"]);
@@ -913,6 +1137,60 @@
         machine_checkout["commands"]["fetch"],
         "git fetch origin refs/pull/1/head:refs/heads/pr-1"
     );
+    let listed_pull_requests = ssh_exec(
+        ssh,
+        &member_key,
+        &[
+            "pr",
+            "list",
+            "bob/example",
+            "--state",
+            "open",
+            "--output",
+            "json",
+        ],
+    );
+    assert!(listed_pull_requests.status.success());
+    let listed_pull_requests: serde_json::Value =
+        serde_json::from_slice(&listed_pull_requests.stdout).expect("parse pull-request list");
+    assert_eq!(listed_pull_requests["version"], 1);
+    assert_eq!(listed_pull_requests["pull_requests"][0]["number"], 1);
+    let closed_pull_request = ssh_exec(ssh, &member_key, &["pr", "close", "bob/example", "1"]);
+    assert!(closed_pull_request.status.success());
+    assert_eq!(
+        String::from_utf8(closed_pull_request.stdout).expect("read pull-request close output"),
+        "Closed pull request bob/example#1.\n"
+    );
+    assert!(
+        ssh_exec(ssh, &member_key, &["pr", "reopen", "bob/example", "1"])
+            .status
+            .success()
+    );
+    let created_pull_request = ssh_exec_with_input(
+        ssh,
+        &member_key,
+        &[
+            "pr",
+            "create",
+            "bob/example",
+            "refs/heads/main",
+            "refs/heads/feature-two",
+            "--output",
+            "json",
+        ],
+        b"Second feature\nOpened through SSH.\n",
+    );
+    assert!(
+        created_pull_request.status.success(),
+        "pr create failed: stdout={} stderr={}",
+        String::from_utf8_lossy(&created_pull_request.stdout),
+        String::from_utf8_lossy(&created_pull_request.stderr)
+    );
+    let created_pull_request: serde_json::Value =
+        serde_json::from_slice(&created_pull_request.stdout)
+            .expect("parse pull-request create output");
+    assert_eq!(created_pull_request["version"], 1);
+    assert_eq!(created_pull_request["pull_request"]["number"], 2);
     let checkout_clone = instance.path().join("pull-request-checkout");
     assert!(ssh_clone_repository_succeeds(
         ssh,
@@ -976,6 +1254,33 @@
     assert_eq!(machine_issues["repository"]["name"], "example");
     assert_eq!(machine_issues["issues"][0]["number"], 1);
     assert_eq!(machine_issues["issues"][0]["title"], "First issue");
+    let issue_comment = ssh_exec_with_input(
+        ssh,
+        &member_key,
+        &["issue", "comment", "bob/example", "1", "--output", "json"],
+        b"A bounded SSH comment.",
+    );
+    assert!(issue_comment.status.success());
+    let issue_comment: serde_json::Value =
+        serde_json::from_slice(&issue_comment.stdout).expect("parse issue comment");
+    assert_eq!(issue_comment["operation"], "commented");
+    assert!(issue_comment["comment_id"].is_string());
+    assert!(
+        ssh_exec(ssh, &member_key, &["issue", "close", "bob/example", "1"])
+            .status
+            .success()
+    );
+    let reopened_issue = ssh_exec(
+        ssh,
+        &member_key,
+        &["issue", "reopen", "bob/example", "1", "--output", "json"],
+    );
+    assert!(reopened_issue.status.success());
+    assert_eq!(
+        serde_json::from_slice::<serde_json::Value>(&reopened_issue.stdout)
+            .expect("parse issue reopen")["operation"],
+        "reopened"
+    );
 
     let access_database = rusqlite::Connection::open(instance.path().join("tit.sqlite3"))
         .expect("open the issue access database");
@@ -1015,6 +1320,17 @@
         )
         .expect("give the administrator reader access");
     drop(access_database);
+    let denied_pull_request = ssh_exec(
+        ssh,
+        &private_key,
+        &["pr", "close", "bob/example", "1", "--output", "json"],
+    );
+    assert!(!denied_pull_request.status.success());
+    assert_eq!(
+        serde_json::from_slice::<serde_json::Value>(&denied_pull_request.stdout)
+            .expect("parse denied pull-request mutation")["error"]["code"],
+        "permission-denied"
+    );
     let reader_create = ssh_exec_with_input(
         ssh,
         &private_key,
@@ -1334,14 +1650,14 @@
     git_commit(&writer_clone, "second writer update");
     assert!(git_push(&writer_key, &writer_clone, &["topic"]).success());
     assert!(git_push(&writer_key, &writer_clone, &["--delete", "topic"]).success());
-    assert!(!git_push(&writer_key, &writer_clone, &["HEAD:main"]).success());
+    assert!(!git_push(&writer_key, &writer_clone, &["HEAD:trunk"]).success());
     assert!(!git_push(&writer_key, &writer_clone, &["HEAD:refs/notes/test"]).success());
 
     let owner_clone = instance.path().join("owner-private");
     fs::write(owner_clone.join("owner.txt"), b"owner update\n").expect("write an owner change");
     git_commit(&owner_clone, "owner update");
-    assert!(git_push(&owner_key, &owner_clone, &["main"]).success());
-    assert!(!git_push(&owner_key, &owner_clone, &["--delete", "main"]).success());
+    assert!(git_push(&owner_key, &owner_clone, &["trunk"]).success());
+    assert!(!git_push(&owner_key, &owner_clone, &["--delete", "trunk"]).success());
     command(&owner_clone, ["switch", "-q", "-c", "force-test"]);
     fs::write(owner_clone.join("force.txt"), b"first history\n").expect("write a branch change");
     git_commit(&owner_clone, "first branch history");
@@ -1452,6 +1768,10 @@
         .output()
         .expect("commit source content");
     assert!(output.status.success(), "Git commit failed");
+    command(&worktree, ["branch", "trunk"]);
+    command(&worktree, ["switch", "-q", "trunk"]);
+    fs::write(worktree.join("TRUNK.md"), b"trunk default\n").expect("write trunk content");
+    git_commit(&worktree, "trunk head");
     let bare = parent.join("source.git");
     command(
         parent,
@@ -1846,6 +2166,21 @@
         )
         .expect("create an SSH key fixture");
     private_key
+}
+
+fn ssh_fingerprint(private_key: &Path) -> String {
+    let output = Command::new("ssh-keygen")
+        .args(["-E", "sha256", "-lf"])
+        .arg(private_key.with_extension("pub"))
+        .output()
+        .expect("read an SSH key fingerprint");
+    assert!(output.status.success());
+    String::from_utf8(output.stdout)
+        .expect("read a UTF-8 SSH key fingerprint")
+        .split_whitespace()
+        .nth(1)
+        .expect("read the SSH key fingerprint")
+        .to_owned()
 }
 
 fn git_commit(worktree: &Path, message: &str) {

tests/snapshots/web/bad-request.html

Mode 100644100644; object 02f796682ead99703e680414

@@ -22,12 +22,16 @@
   </header>
   <main id="main">
 
+  <div class="two-column">
+  <section>
 
   <h1>Recently updated public repositories</h1>
 
 
   <p>No repositories are available.</p>
 
+  </section>
+  <section>
   <h2>Open a repository</h2>
   <p>Enter an owner and a repository to open its public page.</p>
 
@@ -44,6 +48,8 @@
     </div>
     <button type="submit">Open repository</button>
   </form>
+  </section>
+  </div>
 
   </main>
   <footer>

tests/snapshots/web/home.html

Mode 100644100644; object cb42f4347711856a3d36f189

@@ -22,12 +22,16 @@
   </header>
   <main id="main">
 
+  <div class="two-column">
+  <section>
 
   <h1>Recently updated public repositories</h1>
 
 
   <p>No repositories are available.</p>
 
+  </section>
+  <section>
   <h2>Open a repository</h2>
   <p>Enter an owner and a repository to open its public page.</p>
 
@@ -42,6 +46,8 @@
     </div>
     <button type="submit">Open repository</button>
   </form>
+  </section>
+  </div>
 
   </main>
   <footer>

tests/sqlite.rs

Mode 100644100644; object 647152561fd223b75b03203d

@@ -11,8 +11,8 @@
 
 use rusqlite::{Connection, ErrorCode, TransactionBehavior, params};
 use store::{
-    GitOperationIntent, InitialAdministrator, IssueChange, NewAuditEvent, NewIssue, NewRepository,
-    NewRepositoryReference, RepositoryOrigin, Store, StoreError, WatchPreferences,
+    AuditContext, GitOperationIntent, InitialAdministrator, IssueChange, NewAuditEvent, NewIssue,
+    NewRepository, NewRepositoryReference, RepositoryOrigin, Store, StoreError,
 };
 use tempfile::TempDir;
 
@@ -235,7 +235,7 @@
     let directory = TempDir::new().expect("create a temporary directory");
     let store = Store::open(&database(&directory, "store.sqlite")).expect("open the store");
 
-    assert_eq!(store.schema_version().expect("read the schema version"), 18);
+    assert_eq!(store.schema_version().expect("read the schema version"), 23);
     assert_eq!(
         store
             .connection()
@@ -421,6 +421,7 @@
         owner: "alice",
         slug: "project",
         object_format: "sha256",
+        default_branch: "refs/heads/main",
         created_at: 20,
         origin: RepositoryOrigin::Imported,
         initial_references: &initial_references,
@@ -533,6 +534,7 @@
         owner: "alice",
         slug: "event-failure",
         object_format: "sha1",
+        default_branch: "refs/heads/main",
         created_at: 20,
         origin: RepositoryOrigin::Created,
         initial_references: &[],
@@ -682,6 +684,59 @@
         store.audit_events(1).expect("read the newest audit event")[0].outcome,
         "failure"
     );
+    store
+        .connection()
+        .execute(
+            "INSERT INTO account
+             (id, username, is_administrator, state, created_at)
+             VALUES (2, 'bob', 0, 'active', 20)",
+            [],
+        )
+        .expect("create a collaborator");
+    store
+        .update_repository_settings(
+            "alice",
+            "project",
+            "alice",
+            "A small public repository.",
+            "private",
+            23,
+            "settings",
+        )
+        .expect("update repository settings");
+    store
+        .update_repository_collaborator(
+            "alice",
+            "project",
+            "alice",
+            "bob",
+            Some("reader"),
+            24,
+            "collaborator",
+        )
+        .expect("add a repository collaborator");
+    let settings = store
+        .repository_settings("alice", "project", "alice")
+        .expect("read repository settings");
+    assert_eq!(settings.description, "A small public repository.");
+    assert_eq!(settings.repository.visibility, "private");
+    assert_eq!(settings.collaborators.len(), 1);
+    assert_eq!(settings.collaborators[0].username, "bob");
+    assert!(matches!(
+        store.repository_settings("alice", "project", "bob"),
+        Err(StoreError::PullRequestDenied)
+    ));
+    store
+        .update_repository_settings(
+            "alice",
+            "project",
+            "alice",
+            "A small public repository.",
+            "public",
+            25,
+            "settings-public",
+        )
+        .expect("restore public visibility");
 
     assert!(matches!(
         store.create_repository(&repository),
@@ -698,9 +753,10 @@
     ));
     let missing_owner = NewRepository {
         id: "1234567890abcdef1234567890abcdef",
-        owner: "bob",
+        owner: "charlie",
         slug: "project",
         object_format: "sha1",
+        default_branch: "refs/heads/main",
         created_at: 20,
         origin: RepositoryOrigin::Created,
         initial_references: &[],
@@ -709,29 +765,33 @@
     };
     assert!(matches!(
         store.create_repository(&missing_owner),
-        Err(StoreError::AccountNotFound(owner)) if owner == "bob"
+        Err(StoreError::AccountNotFound(owner)) if owner == "charlie"
     ));
     let (_, unchanged_events) = store
         .public_repository_events("alice", "project", None, 10)
         .expect("read events after rejected repository mutations");
     assert_eq!(unchanged_events.len(), 5);
 
-    store
-        .rename_repository(
+    assert!(matches!(
+        store.rename_repository_for_owner(
             "alice",
             "project",
-            "renamed",
+            "reader-rename",
+            "bob",
             25,
-            "admin-cli",
-            "test-rename",
-        )
+            "test-reader-rename",
+        ),
+        Err(StoreError::PullRequestDenied)
+    ));
+    store
+        .rename_repository_for_owner("alice", "project", "renamed", "alice", 25, "test-rename")
         .expect("rename a repository");
     assert!(matches!(
         store.repository("alice", "project"),
         Err(StoreError::RepositoryNotFound(_, _))
     ));
     store
-        .archive_repository("alice", "renamed", 30, "admin-cli", "test-archive")
+        .archive_repository_for_actor("alice", "renamed", "alice", 30, "test-archive")
         .expect("archive a repository");
     let archived = store
         .repository("alice", "renamed")
@@ -753,6 +813,27 @@
         ),
         Err(StoreError::RepositoryArchived(_, _))
     ));
+    assert!(matches!(
+        store.unarchive_repository_for_owner(
+            "alice",
+            "renamed",
+            "bob",
+            33,
+            "test-reader-unarchive",
+        ),
+        Err(StoreError::PullRequestDenied)
+    ));
+    store
+        .unarchive_repository_for_owner("alice", "renamed", "alice", 34, "test-unarchive")
+        .expect("unarchive a repository");
+    let unarchived = Store::open(&database(&directory, "store.sqlite"))
+        .expect("reopen the unarchived repository store")
+        .repository_settings("alice", "renamed", "alice")
+        .expect("read the unarchived repository after restart");
+    assert_eq!(unarchived.repository.state, "active");
+    assert_eq!(unarchived.repository.archived_at, None);
+    assert_eq!(unarchived.repository.visibility, "public");
+    assert_eq!(unarchived.collaborators.len(), 1);
 }
 
 #[test]
@@ -785,6 +866,7 @@
             owner: "alice",
             slug: "project",
             object_format: "sha1",
+            default_branch: "refs/heads/main",
             created_at: 2,
             origin: RepositoryOrigin::Created,
             initial_references: &[],
@@ -876,40 +958,23 @@
         )
         .expect("comment as a reader");
     assert_eq!(comment_id.len(), 32);
-    assert!(matches!(
-        store.set_issue_label(&change("bob", 10), "bug", true),
-        Err(StoreError::IssueDenied)
-    ));
     store
-        .set_issue_label(&change("maintainer", 11), "bug", true)
-        .expect("label as a maintainer");
-    store
-        .set_issue_assignee(&change("maintainer", 12), "bob", true)
-        .expect("assign a repository reader");
-    assert!(matches!(
-        store.set_issue_assignee(&change("maintainer", 13), "stranger", true),
-        Err(StoreError::IssueAssigneeNotFound(username)) if username == "stranger"
-    ));
-    store
-        .set_issue_state("alice", "project", 1, "bob", "closed", 14)
+        .set_issue_state("alice", "project", 1, "bob", "closed", 10)
         .expect("close an authored issue");
     store
-        .set_issue_state("alice", "project", 1, "carol", "open", 15)
+        .set_issue_state("alice", "project", 1, "carol", "open", 11)
         .expect("reopen an issue as a writer");
 
     let detail = store
-        .issue_detail("alice", "project", 1, Some("maintainer"))
+        .issue_detail("alice", "project", 1, Some("maintainer"), 1, 1, 50)
         .expect("read the issue timeline");
     assert_eq!(detail.repository.slug, "project");
     assert_eq!(detail.issue.title, "Writer edit");
     assert_eq!(detail.issue.body, source);
     assert_eq!(detail.issue.state, "open");
     assert_eq!(detail.comments.len(), 1);
-    assert_eq!(detail.labels, ["bug"]);
-    assert_eq!(detail.assignees, ["bob"]);
     assert!(detail.can_comment);
     assert!(detail.can_edit);
-    assert!(detail.can_maintain);
     assert_eq!(
         detail
             .timeline
@@ -921,8 +986,6 @@
             "issue-edited",
             "issue-edited",
             "issue-commented",
-            "issue-labeled",
-            "issue-assigned",
             "issue-closed",
             "issue-reopened",
         ]
@@ -933,6 +996,18 @@
             .windows(2)
             .all(|events| events[0].sequence < events[1].sequence)
     );
+    let first_activity_page = store
+        .issue_detail("alice", "project", 1, Some("maintainer"), 1, 1, 1)
+        .expect("read the first bounded activity page");
+    assert_eq!(first_activity_page.comments.len(), 1);
+    assert!(!first_activity_page.comments_has_next);
+    assert_eq!(first_activity_page.timeline.len(), 1);
+    assert!(first_activity_page.timeline_has_next);
+    let second_timeline_page = store
+        .issue_detail("alice", "project", 1, Some("maintainer"), 1, 2, 1)
+        .expect("read the second timeline page");
+    assert_eq!(second_timeline_page.timeline_page, 2);
+    assert_eq!(second_timeline_page.timeline.len(), 1);
     for event in &detail.timeline {
         let payload: serde_json::Value =
             serde_json::from_str(&event.payload).expect("parse an issue event payload");
@@ -948,32 +1023,20 @@
             row.get(0)
         })
         .expect("count events before watch changes");
-    let everything = WatchPreferences {
-        pushes: true,
-        issues: true,
-        pull_requests: true,
-    };
     let watch = store
-        .set_watch("alice", "project", "bob", everything, 16)
+        .set_watch("alice", "project", "bob", true, 16)
         .expect("watch all repository activity")
         .expect("read the created watch");
     assert_eq!(watch.id.len(), 32);
-    assert!(watch.pushes && watch.issues && watch.pull_requests);
     assert_eq!(watch.created_at, 16);
     assert_eq!(watch.updated_at, 16);
-    let granular = WatchPreferences {
-        pushes: false,
-        issues: true,
-        pull_requests: false,
-    };
     let updated = store
-        .set_watch("alice", "project", "bob", granular, 17)
-        .expect("set granular watch preferences")
+        .set_watch("alice", "project", "bob", true, 17)
+        .expect("keep watching")
         .expect("read the updated watch");
     assert_eq!(updated.id, watch.id);
     assert_eq!(updated.created_at, 16);
     assert_eq!(updated.updated_at, 17);
-    assert!(!updated.pushes && updated.issues && !updated.pull_requests);
     let (watched_repository, stored_watch) = store
         .watch("alice", "project", Some("bob"))
         .expect("read watch preferences");
@@ -984,7 +1047,7 @@
         Err(StoreError::WatchDenied)
     ));
     assert!(matches!(
-        store.set_watch("alice", "project", "suspended", everything, 18),
+        store.set_watch("alice", "project", "suspended", true, 18),
         Err(StoreError::WatchDenied)
     ));
     assert_eq!(
@@ -997,17 +1060,7 @@
     );
     assert_eq!(
         store
-            .set_watch(
-                "alice",
-                "project",
-                "bob",
-                WatchPreferences {
-                    pushes: false,
-                    issues: false,
-                    pull_requests: false,
-                },
-                19,
-            )
+            .set_watch("alice", "project", "bob", false, 19)
             .expect("stop watching the repository"),
         None
     );
@@ -1018,33 +1071,9 @@
             .1,
         None
     );
-    assert!(
-        store
-            .connection()
-            .execute(
-                "INSERT INTO watch
-             (id, repository_id, account_id, pushes, issues, pull_requests,
-              created_at, updated_at)
-             VALUES ('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
-                     '00112233445566778899aabbccddeeff', 2, 0, 0, 0, 20, 20)",
-                [],
-            )
-            .is_err()
-    );
-
     store
-        .set_watch(
-            "alice",
-            "project",
-            "bob",
-            WatchPreferences {
-                pushes: false,
-                issues: true,
-                pull_requests: false,
-            },
-            20,
-        )
-        .expect("restore the issue watch");
+        .set_watch("alice", "project", "bob", true, 20)
+        .expect("restore the watch");
     store
         .comment_issue(
             "alice",
@@ -1055,53 +1084,85 @@
             21,
         )
         .expect("create a mention event");
-    let repository_token = store
-        .create_feed_token(
-            "bob",
-            "repository",
-            Some(("alice", "project")),
-            &[1; 32],
-            22,
-        )
-        .expect("create a private repository feed token");
-    let watched_token = store
-        .create_feed_token("bob", "watched", None, &[2; 32], 23)
-        .expect("create a watched feed token");
-    let assignment_token = store
-        .create_feed_token("bob", "assignments", None, &[3; 32], 24)
-        .expect("create an assignment feed token");
-    let mention_token = store
-        .create_feed_token("bob", "mentions", None, &[4; 32], 25)
-        .expect("create a mention feed token");
-    assert!(matches!(
-        store.create_feed_token(
-            "stranger",
-            "repository",
-            Some(("alice", "project")),
-            &[5; 32],
-            26,
-        ),
-        Err(StoreError::FeedTokenDenied)
-    ));
-    assert!(matches!(
-        store.create_feed_token("bob", "repository", None, &[5; 32], 26),
-        Err(StoreError::InvalidFeedScope)
-    ));
-    let private_page = store
-        .token_feed_events(&[1; 32], 100)
-        .expect("read a private repository feed");
-    assert_eq!(private_page.scope, "repository");
-    assert_eq!(private_page.username, "bob");
-    assert_eq!(
-        private_page.target.as_deref(),
-        Some("00112233445566778899aabbccddeeff")
-    );
-    assert!(
-        private_page
+    let first_web_activity = store
+        .watched_activity_page("bob", None, 2)
+        .expect("read the first watched activity page");
+    assert_eq!(first_web_activity.events.len(), 2);
+    let before = first_web_activity
+        .next_before
+        .as_ref()
+        .expect("read the stable activity cursor");
+    store
+        .comment_issue("alice", "project", 1, "carol", "Newer activity.", 22)
+        .expect("create activity after the first page");
+    let second_web_activity = store
+        .watched_activity_page("bob", Some(before), 2)
+        .expect("read the second watched activity page");
+    assert!(second_web_activity.events.iter().all(|second| {
+        first_web_activity
             .events
             .iter()
-            .all(|event| event.repository.slug == "project")
+            .all(|first| first.event.event_id != second.event.event_id)
+    }));
+    store
+        .connection()
+        .execute_batch(
+            "UPDATE repository SET visibility = 'private' WHERE slug = 'project';
+             DELETE FROM repository_collaborator
+             WHERE repository_id = (SELECT id FROM repository WHERE slug = 'project')
+               AND account_id = (SELECT id FROM account WHERE username = 'bob');",
+        )
+        .expect("remove watched private repository access");
+    assert!(
+        store
+            .watched_activity_page("bob", None, 20)
+            .expect("apply activity access immediately")
+            .events
+            .is_empty()
     );
+    store
+        .set_repository_collaborator(
+            "alice",
+            "project",
+            "bob",
+            "maintainer",
+            &AuditContext {
+                actor: "alice",
+                correlation_id: "restore-activity-access",
+                created_at: 22,
+            },
+        )
+        .expect("restore watched repository access");
+    store
+        .connection()
+        .execute(
+            "UPDATE repository
+             SET state = 'archived', archived_at = 22 WHERE slug = 'project'",
+            [],
+        )
+        .expect("archive the watched repository");
+    assert!(
+        store
+            .watched_activity_page("bob", None, 20)
+            .expect("apply archived state immediately")
+            .events
+            .is_empty()
+    );
+    store
+        .connection()
+        .execute(
+            "UPDATE repository
+             SET state = 'active', archived_at = NULL WHERE slug = 'project'",
+            [],
+        )
+        .expect("restore the watched repository");
+    let watched_token = store
+        .create_feed_token("bob", &[2; 32], 23)
+        .expect("create a watched feed token");
+    assert!(matches!(
+        store.create_feed_token("bob", &[3; 32], 24),
+        Err(StoreError::FeedTokenLimit)
+    ));
     let watched_page = store
         .token_feed_events(&[2; 32], 100)
         .expect("read watched issue activity");
@@ -1109,20 +1170,10 @@
         watched_page
             .events
             .iter()
-            .all(|event| event.event.kind.starts_with("issue-"))
+            .any(|event| event.event.kind == "issue-commented")
     );
     assert!(!watched_page.events.is_empty());
-    let assignment_page = store
-        .token_feed_events(&[3; 32], 100)
-        .expect("read assignment activity");
-    assert_eq!(assignment_page.events.len(), 1);
-    assert_eq!(assignment_page.events[0].event.kind, "issue-assigned");
-    let mention_page = store
-        .token_feed_events(&[4; 32], 100)
-        .expect("read mention activity");
-    assert_eq!(mention_page.events.len(), 1);
-    assert_eq!(mention_page.events[0].event.kind, "issue-commented");
-    assert_eq!(store.feed_tokens("bob").expect("list feed tokens").len(), 4);
+    assert_eq!(store.feed_tokens("bob").expect("list feed tokens").len(), 1);
     store
         .connection()
         .execute_batch(
@@ -1134,27 +1185,27 @@
         )
         .expect("inject a feed token failure");
     assert!(matches!(
-        store.rotate_feed_token("bob", &repository_token.id, &[7; 32], 27),
+        store.rotate_feed_token("bob", &watched_token.id, &[7; 32], 27),
         Err(StoreError::Sqlite(_))
     ));
     store
-        .token_feed_events(&[1; 32], 100)
+        .token_feed_events(&[2; 32], 100)
         .expect("retain the old token after a failed rotation");
     store
         .connection()
         .execute_batch("DROP TRIGGER reject_feed_token;")
         .expect("remove the feed token failure");
     let rotated = store
-        .rotate_feed_token("bob", &repository_token.id, &[6; 32], 28)
-        .expect("rotate a repository feed token");
-    assert_eq!(rotated.scope, repository_token.scope);
+        .rotate_feed_token("bob", &watched_token.id, &[6; 32], 28)
+        .expect("rotate the watched feed token");
+    assert_eq!(rotated.scope, watched_token.scope);
     assert!(matches!(
-        store.token_feed_events(&[1; 32], 100),
+        store.token_feed_events(&[2; 32], 100),
         Err(StoreError::FeedTokenNotFound)
     ));
     store
         .revoke_feed_token("bob", &rotated.id, 29)
-        .expect("revoke a repository feed token");
+        .expect("revoke the watched feed token");
     assert!(matches!(
         store.token_feed_events(&[6; 32], 100),
         Err(StoreError::FeedTokenNotFound)
@@ -1168,11 +1219,9 @@
             .all(|event| event.kind.starts_with("issue-"))
     );
     assert_eq!(watched_token.scope, "watched");
-    assert_eq!(assignment_token.scope, "assignments");
-    assert_eq!(mention_token.scope, "mentions");
     let mut metadata = Vec::new();
     let metadata_truncated = store
-        .visit_metadata_search_candidates(Some("bob"), 100, 1024, |candidate| {
+        .visit_metadata_search_candidates(Some("bob"), 100, |candidate| {
             metadata.push(candidate);
             true
         })
@@ -1186,14 +1235,9 @@
             && candidate.title == b"alice/project"
             && candidate.body.is_empty()
     }));
-    assert!(
-        metadata
-            .iter()
-            .any(|candidate| candidate.issue_number == Some(1))
-    );
     let mut anonymous_metadata = Vec::new();
     let anonymous_truncated = store
-        .visit_metadata_search_candidates(None, 100, 1024, |candidate| {
+        .visit_metadata_search_candidates(None, 100, |candidate| {
             anonymous_metadata.push(candidate);
             true
         })
@@ -1228,30 +1272,10 @@
         comments_before
     );
     store
-        .connection()
-        .execute(
-            "DELETE FROM repository_collaborator
-             WHERE repository_id = '00112233445566778899aabbccddeeff'
-               AND account_id = 2",
-            [],
-        )
-        .expect("remove the feed account repository role");
-    for hash in [[2; 32], [3; 32], [4; 32]] {
-        assert!(
-            store
-                .token_feed_events(&hash, 100)
-                .expect("read a feed after role removal")
-                .events
-                .is_empty()
-        );
-    }
-    for value in 10_u8..39 {
-        store
-            .create_feed_token("bob", "mentions", None, &[value; 32], 30 + i64::from(value))
-            .expect("fill the active feed token limit");
-    }
+        .create_feed_token("bob", &[10; 32], 30)
+        .expect("create a replacement watched feed token");
     assert!(matches!(
-        store.create_feed_token("bob", "mentions", None, &[40; 32], 70),
+        store.create_feed_token("bob", &[11; 32], 31),
         Err(StoreError::FeedTokenLimit)
     ));
 }
@@ -1607,7 +1631,7 @@
         create_fixture(&path, fixture);
 
         let store = Store::open(&path).expect("migrate the fixture");
-        assert_eq!(store.schema_version().expect("read the schema version"), 18);
+        assert_eq!(store.schema_version().expect("read the schema version"), 23);
         store.integrity_check().expect("check migrated integrity");
         let state: String = store
             .connection()
@@ -1632,6 +1656,60 @@
         );
         backup.integrity_check().expect("check backup integrity");
     }
+}
+
+#[test]
+fn migration_uses_an_existing_repository_symbolic_head() {
+    let directory = TempDir::new().expect("create a migration directory");
+    let path = database(&directory, "tit.sqlite3");
+    let mut store = Store::open(&path).expect("create the current database");
+    store
+        .connection()
+        .execute(
+            "INSERT INTO account
+             (id, username, is_administrator, state, created_at)
+             VALUES (1, 'alice', 1, 'active', 1)",
+            [],
+        )
+        .expect("create the migration account");
+    store
+        .create_repository(&NewRepository {
+            id: "00112233445566778899aabbccddeeff",
+            owner: "alice",
+            slug: "project",
+            object_format: "sha1",
+            default_branch: "refs/heads/main",
+            created_at: 2,
+            origin: RepositoryOrigin::Created,
+            initial_references: &[],
+            actor: "alice",
+            correlation_id: "migration-default",
+        })
+        .expect("create the migration repository");
+    store
+        .connection()
+        .execute("DROP TABLE repository_default_branch", [])
+        .expect("remove the new table from the historical fixture");
+    store
+        .connection()
+        .pragma_update(None, "user_version", 22)
+        .expect("set the historical schema version");
+    drop(store);
+    let repository = directory
+        .path()
+        .join("repositories")
+        .join("00112233445566778899aabbccddeeff.git");
+    fs::create_dir_all(&repository).expect("create the historical repository directory");
+    fs::write(repository.join("HEAD"), "ref: refs/heads/trunk\n")
+        .expect("write the historical symbolic HEAD");
+
+    let migrated = Store::open(&path).expect("migrate the historical database");
+    assert_eq!(
+        migrated
+            .repository_default_branch("alice", "project")
+            .expect("read the migrated default branch"),
+        "refs/heads/trunk"
+    );
 }
 
 #[test]
@@ -1680,7 +1758,7 @@
 
 #[test]
 fn recovers_complete_schema_versions_after_a_process_kill_during_migration() {
-    for (mode, expected_version) in [("migration-uncommitted", 1), ("migration-committed", 18)] {
+    for (mode, expected_version) in [("migration-uncommitted", 1), ("migration-committed", 23)] {
         let directory = TempDir::new().expect("create a temporary directory");
         let path = database(&directory, "fixture.sqlite");
         create_fixture(&path, V1_FIXTURE);
@@ -1749,4 +1827,93 @@
             .expect("count parents"),
         1
     );
+}
+
+#[test]
+fn maintenance_prunes_only_terminal_records_before_the_cutoff() {
+    let directory = TempDir::new().expect("create a maintenance fixture directory");
+    let mut store =
+        Store::open(&database(&directory, "maintenance.sqlite")).expect("open the store");
+    store
+        .create_initial_administrator(&InitialAdministrator {
+            username: "alice",
+            canonical_key: "ssh-ed25519 AAAAmaintenance",
+            fingerprint: "SHA256:maintenance",
+            recovery_hash: &[7; 32],
+            created_at: 1,
+        })
+        .expect("create the account");
+    store
+        .create_repository(&NewRepository {
+            id: "00112233445566778899aabbccddeeff",
+            owner: "alice",
+            slug: "project",
+            object_format: "sha1",
+            default_branch: "refs/heads/main",
+            created_at: 1,
+            origin: RepositoryOrigin::Created,
+            initial_references: &[],
+            actor: "alice",
+            correlation_id: "maintenance-repository",
+        })
+        .expect("create the repository");
+    store
+        .connection()
+        .execute_batch(
+            "DELETE FROM audit_event;
+             INSERT INTO signup_invitation
+                 (code_hash, created_at, expires_at, consumed_at)
+             VALUES
+                 (randomblob(32), 1, 2, NULL),
+                 (randomblob(32), 99, 100, NULL);
+             INSERT INTO login_nonce
+                 (nonce_hash, csrf_hash, account_id, ssh_public_key_id,
+                  created_at, expires_at, consumed_at)
+             VALUES
+                 (randomblob(32), randomblob(32), 1, 1, 1, 2, NULL),
+                 (randomblob(32), randomblob(32), 1, 1, 99, 100, NULL);
+             INSERT INTO ssh_login_approval
+                 (secret_hash, csrf_hash, account_id, ssh_public_key_id,
+                  created_at, expires_at, approved_at, consumed_at)
+             VALUES
+                 (randomblob(32), randomblob(32), NULL, NULL, 1, 2, NULL, NULL),
+                 (randomblob(32), randomblob(32), NULL, NULL, 99, 100, NULL, NULL);
+             INSERT INTO web_session
+                 (session_hash, csrf_hash, account_id, created_at, expires_at, ended_at)
+             VALUES
+                 (randomblob(32), randomblob(32), 1, 1, 2, NULL),
+                 (randomblob(32), randomblob(32), 1, 99, 200, NULL);
+             INSERT INTO feed_token
+                 (id, token_hash, account_id, scope, repository_id, created_at, revoked_at)
+             VALUES
+                 ('11111111111111111111111111111111', randomblob(32), 1,
+                  'repository', '00112233445566778899aabbccddeeff', 1, 2),
+                 ('22222222222222222222222222222222', randomblob(32), 1,
+                  'repository', '00112233445566778899aabbccddeeff', 99, 100);
+             INSERT INTO audit_event
+                 (action, actor, target, outcome, correlation_id, created_at)
+             VALUES
+                 ('old', 'test', 'old', 'success', 'old', 1),
+                 ('current', 'test', 'current', 'success', 'current', 100);",
+        )
+        .expect("create maintenance records");
+
+    let result = store.maintain(100).expect("run maintenance");
+    assert_eq!(result.deleted, 6);
+    for table in [
+        "signup_invitation",
+        "login_nonce",
+        "ssh_login_approval",
+        "web_session",
+        "feed_token",
+        "audit_event",
+    ] {
+        let count: i64 = store
+            .connection()
+            .query_row(&format!("SELECT count(*) FROM {table}"), [], |row| {
+                row.get(0)
+            })
+            .expect("count retained maintenance records");
+        assert_eq!(count, 1, "unexpected retained count for {table}");
+    }
 }

tests/sqlite_workload.rs

Mode 100644100644; object ba3f4bd41d7fde4ed06f5847

@@ -21,7 +21,7 @@
 const MAX_P99_QUERY_TIME: Duration = Duration::from_millis(250);
 
 #[test]
-#[ignore = "run through scripts/check-m1a"]
+#[ignore = "run this workload explicitly"]
 fn measures_the_m1a_workload() {
     let directory = TempDir::new().expect("create a temporary directory");
     let database_path = directory.path().join("workload.sqlite");

tests/ssh.rs

Mode 100644100644; object 97bb578af9a9552bf89103f4

@@ -25,6 +25,9 @@
 )]
 #[path = "../src/policy.rs"]
 mod policy;
+#[allow(dead_code, reason = "the SSH identity test does not use pull requests")]
+#[path = "../src/pull_request.rs"]
+mod pull_request;
 #[path = "../src/rate_limit.rs"]
 mod rate_limit;
 #[allow(
@@ -102,10 +105,17 @@
     );
     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("login ONE-TIME-SECRET"));
+    assert!(help_text.contains("auth ONE-TIME-SECRET"));
     assert!(help_text.contains("repo create NAME"));
     assert!(!help_text.contains("object-format"));
     assert!(help_text.contains("issue list OWNER/REPOSITORY"));
+    assert!(help_text.contains("issue comment OWNER/REPOSITORY NUMBER"));
+    assert!(help_text.contains("issue close OWNER/REPOSITORY NUMBER"));
+    assert!(help_text.contains("issue reopen OWNER/REPOSITORY NUMBER"));
+    assert!(help_text.contains("pr list OWNER/REPOSITORY"));
+    assert!(help_text.contains("pr create OWNER/REPOSITORY BASE HEAD"));
+    assert!(help_text.contains("pr close OWNER/REPOSITORY NUMBER"));
+    assert!(help_text.contains("pr reopen OWNER/REPOSITORY NUMBER"));
     assert!(help_text.contains("pr checkout OWNER/REPOSITORY NUMBER"));
 
     let invalid = ssh(&server, &private_key, "alice", &["not-a-command"]);

tests/web_session.rs

Mode 100644100644; object 093f4529d16517eec5451fa6

@@ -31,7 +31,7 @@
 use tempfile::TempDir;
 use url::Url;
 
-use account::AccountService;
+use account::{AccountError, AccountKeyRequest, AccountService};
 use auth::SshPublicKey;
 use session::{SessionError, WebLoginService};
 use store::{InitialAdministrator, Store, StoreError};
@@ -291,6 +291,207 @@
         login.approve(&expired.secret, "alice", parsed.fingerprint()),
         Err(SessionError::Store(StoreError::InvalidLoginApproval))
     ));
+}
+
+#[test]
+fn account_key_changes_require_durable_one_time_ssh_approval() {
+    let directory = TempDir::new().expect("create a Web session directory");
+    let database = directory.path().join("tit.sqlite3");
+    let first_private_key = directory.path().join("first-identity");
+    let second_private_key = directory.path().join("second-identity");
+    create_ssh_key_fixture(&first_private_key);
+    create_ssh_key_fixture(&second_private_key);
+    let first_public_key = fs::read_to_string(first_private_key.with_extension("pub"))
+        .expect("read the first SSH public key");
+    let second_public_key = fs::read_to_string(second_private_key.with_extension("pub"))
+        .expect("read the second SSH public key");
+    let first = SshPublicKey::parse(&first_public_key).expect("parse the first SSH public key");
+    let second = SshPublicKey::parse(&second_public_key).expect("parse the second SSH public key");
+    Store::open(&database)
+        .expect("create the database")
+        .create_initial_administrator(&InitialAdministrator {
+            username: "alice",
+            canonical_key: first.canonical(),
+            fingerprint: first.fingerprint(),
+            recovery_hash: &[3; 32],
+            created_at: now(),
+        })
+        .expect("create the account");
+    let origin = Url::parse("https://tit.example/").expect("parse the origin");
+    let login = WebLoginService::new(database.clone(), &origin).expect("create the login service");
+    let browser = login
+        .issue_approval()
+        .expect("issue a browser login approval");
+    login
+        .approve(&browser.secret, "alice", first.fingerprint())
+        .expect("approve the browser login");
+    let session = login
+        .complete_approval(&browser.secret, &browser.login_csrf, "browser-login")
+        .expect("create the browser session");
+
+    let add = login
+        .issue_account_approval("alice", &session.csrf)
+        .expect("issue an account-key approval");
+    let restarted =
+        WebLoginService::new(database.clone(), &origin).expect("restart the login service");
+    restarted
+        .approve(&add.secret, "alice", first.fingerprint())
+        .expect("approve the account-key change after restart");
+    let accounts = AccountService::new(database.clone());
+    let add_request = AccountKeyRequest {
+        username: "alice",
+        session: &session.token,
+        csrf: &session.csrf,
+        secret: &add.secret,
+        correlation_id: "key-add",
+    };
+    accounts
+        .complete_key_add(&add_request, "laptop", &second_public_key)
+        .expect("add the second key");
+    assert!(matches!(
+        accounts.complete_key_add(&add_request, "replay", &second_public_key),
+        Err(AccountError::Store(StoreError::InvalidLoginApproval))
+    ));
+    let restarted_accounts = AccountService::new(database.clone());
+    let keys = restarted_accounts
+        .keys("alice")
+        .expect("read keys after restart");
+    assert_eq!(
+        keys.iter().filter(|key| key.revoked_at.is_none()).count(),
+        2
+    );
+    assert!(keys.iter().any(|key| {
+        key.label == "laptop" && key.fingerprint == second.fingerprint() && key.revoked_at.is_none()
+    }));
+
+    let revoke = restarted
+        .issue_account_approval("alice", &session.csrf)
+        .expect("issue a revoke approval");
+    restarted
+        .approve(&revoke.secret, "alice", first.fingerprint())
+        .expect("approve the revoke");
+    let revoke_request = AccountKeyRequest {
+        username: "alice",
+        session: &session.token,
+        csrf: &session.csrf,
+        secret: &revoke.secret,
+        correlation_id: "key-revoke",
+    };
+    restarted_accounts
+        .complete_key_revoke(&revoke_request, second.fingerprint())
+        .expect("revoke the second key");
+    assert_eq!(
+        AccountService::new(database.clone())
+            .keys("alice")
+            .expect("read durable revoked key")
+            .iter()
+            .filter(|key| key.revoked_at.is_none())
+            .count(),
+        1
+    );
+
+    let final_revoke = restarted
+        .issue_account_approval("alice", &session.csrf)
+        .expect("issue the final revoke approval");
+    restarted
+        .approve(&final_revoke.secret, "alice", first.fingerprint())
+        .expect("approve the final revoke");
+    let final_request = AccountKeyRequest {
+        username: "alice",
+        session: &session.token,
+        csrf: &session.csrf,
+        secret: &final_revoke.secret,
+        correlation_id: "final-key",
+    };
+    assert!(matches!(
+        restarted_accounts.complete_key_revoke(&final_request, first.fingerprint()),
+        Err(AccountError::Store(StoreError::LastKey))
+    ));
+
+    let third_private_key = directory.path().join("third-identity");
+    create_ssh_key_fixture(&third_private_key);
+    let third_public_key = fs::read_to_string(third_private_key.with_extension("pub"))
+        .expect("read the third SSH public key");
+    let third = SshPublicKey::parse(&third_public_key).expect("parse the third SSH public key");
+    let add_third = restarted
+        .issue_account_approval("alice", &session.csrf)
+        .expect("issue the third-key approval");
+    restarted
+        .approve(&add_third.secret, "alice", first.fingerprint())
+        .expect("approve the third-key change");
+    restarted_accounts
+        .complete_key_add(
+            &AccountKeyRequest {
+                username: "alice",
+                session: &session.token,
+                csrf: &session.csrf,
+                secret: &add_third.secret,
+                correlation_id: "third-key",
+            },
+            "workstation",
+            &third_public_key,
+        )
+        .expect("add the third key");
+    let third_login = restarted
+        .issue_approval()
+        .expect("issue a login for the third key");
+    restarted
+        .approve(&third_login.secret, "alice", third.fingerprint())
+        .expect("approve login with the third key");
+    let third_session = restarted
+        .complete_approval(&third_login.secret, &third_login.login_csrf, "third-login")
+        .expect("create a session with the third key");
+    let revoke_current = restarted
+        .issue_account_approval("alice", &third_session.csrf)
+        .expect("issue current-key revoke approval");
+    restarted
+        .approve(&revoke_current.secret, "alice", third.fingerprint())
+        .expect("approve current-key revoke");
+    restarted_accounts
+        .complete_key_revoke(
+            &AccountKeyRequest {
+                username: "alice",
+                session: &third_session.token,
+                csrf: &third_session.csrf,
+                secret: &revoke_current.secret,
+                correlation_id: "revoke-current",
+            },
+            third.fingerprint(),
+        )
+        .expect("revoke the current session key");
+    assert!(matches!(
+        restarted.authenticate(&third_session.token, Some(&third_session.csrf)),
+        Err(SessionError::Store(StoreError::InvalidSession))
+    ));
+    assert!(matches!(
+        restarted.authenticate(&session.token, Some(&session.csrf)),
+        Err(SessionError::Store(StoreError::InvalidSession))
+    ));
+
+    let audits = Store::open(&database)
+        .expect("open the database")
+        .audit_events(20)
+        .expect("read key audit events");
+    assert!(
+        audits
+            .iter()
+            .any(|event| { event.action == "key.add" && event.outcome == "success" })
+    );
+    assert!(
+        audits
+            .iter()
+            .any(|event| { event.action == "key.add" && event.outcome == "failure" })
+    );
+    assert!(
+        audits
+            .iter()
+            .any(|event| { event.action == "key.revoke" && event.outcome == "success" })
+    );
+    assert!(
+        audits
+            .iter()
+            .any(|event| { event.action == "key.revoke" && event.outcome == "failure" })
+    );
 }
 
 fn sign(directory: &Path, private_key: &Path, challenge: &str) -> String {

tests/web_shell.rs

Mode 100644100644; object 2c31df67d2421f4f6a7836ff

@@ -107,6 +107,8 @@
     assert_eq!(css.header("content-type"), "text/css; charset=utf-8");
     assert_eq!(css.header("cache-control"), "no-cache");
     assert_eq!(css.body, include_str!("../assets/style.css"));
+    assert!(css.body.contains("@media (max-width: 44rem)"));
+    assert!(css.body.contains(".two-column"));
     assert_security_policy(&css);
 
     let css_head = request(server.address(), "HEAD", "/assets/style.css", &[]);
@@ -250,8 +252,21 @@
 async fn enforces_request_and_login_attempt_limits() {
     let server = start().await;
 
-    for _ in 0..10 {
-        assert_eq!(request(server.address(), "POST", "/login", &[]).status, 400);
+    for attempt in 0..10 {
+        let forwarded = format!("for=192.0.2.{attempt}");
+        assert_eq!(
+            request(
+                server.address(),
+                "POST",
+                "/login",
+                &[
+                    ("Forwarded", &forwarded),
+                    ("X-Forwarded-For", "198.51.100.1")
+                ]
+            )
+            .status,
+            400
+        );
     }
     let limited = request(server.address(), "POST", "/login", &[]);
     assert_eq!(limited.status, 429);
@@ -261,6 +276,20 @@
     assert_eq!(oversized.status, 413);
 
     server.shutdown().await.expect("stop the Web server");
+}
+
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn rate_limits_signup_and_recovery() {
+    for path in ["/signup", "/recover"] {
+        let server = start().await;
+        for _ in 0..10 {
+            assert_eq!(request(server.address(), "POST", path, &[]).status, 400);
+        }
+        let limited = request(server.address(), "POST", path, &[]);
+        assert_eq!(limited.status, 429);
+        assert_eq!(limited.body, "Account attempt limit exceeded.\n");
+        server.shutdown().await.expect("stop the Web server");
+    }
 }
 
 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]