feat(engine): a forge-neutral failure type and listers for Gitea servers #203

Merged
james.maes merged 2 commits from feat/202-gitea-listers into main 2026-11-11 22:21:51 +00:00
Owner

What

Step 1 of #201 (Gitea organizations and accounts beside GitHub): the engine can list the organizations and repositories of a Gitea server. Library only. Accounts, the command line and the desktop app do not use it yet, so nothing a user sees changes.

  • ForgeKind; ForgeAccessException / ForgeAccessKind as the failure type of every forge. GitHubAccessException derives from it and keeps its own Kind.
  • GiteaServerUrl: what may be the address of a server (HTTPS, no credentials in it, nothing after the path).
  • GiteaRepositoryLister, GiteaOrganizationLister behind the existing IRepositoryLister / IOrganizationLister, on HttpClient and source-generated JSON. No new package.
  • A contract test suite (ListerContractTests) that the listers of both forges pass.
  • ADR 0010 and threat model T16.

Why

Closes #202. The address of a Gitea server is typed by a user, and it decides where the account's token is sent. The rules, each pinned by a test:

  • the token goes to the configured address, in a header, and to no other host: redirects are not followed, the address of a next page is never taken from an answer, and clone addresses are built from the configured address (the server's clone_url is not read);
  • HTTPS only, certificates validated by the platform, and no setting that changes either;
  • answers bounded in size, lists in pages, requests in time; a server's own words reach a message only as the reason of a refusal.

How it was tested

  • GiteaListerTests (a fake server behind HttpClient): paging by what the server says, the account fallback, the owner filter, what is and is not taken from answers, every failure kind, cancellation, timeouts, answers and lists without end.
  • GiteaServerUrlTests, ForgeAccessExceptionTests, and the contract suite run for GitHub and Gitea.
  • Gates, started from PowerShell: numbers in the comment below.

Not merged until

1.1.1 is tagged (#200): main is the release candidate. This stays open, and the next steps of #201 are stacked on it.

To confirm against a live server before step 2: Gitea accepts the token as the git password with a fixed user name (the form the git client uses for GitHub).

Checklist

  • Commits are signed and signed off (git commit -S -s)
  • Zero build warnings; format and lint are clean
  • Tests added or updated; the 100% coverage gate still passes
  • Docs updated for user-visible changes; threat model updated if the attack surface changed (ADR 0010, T16; no user-visible change yet)
  • No secrets, credentials, or personal data
  • New dependencies are justified below, or none were added (none)
  • Substantial AI assistance, if any, is noted here: written with Claude Code.

🤖 Generated with Claude Code

## What Step 1 of #201 (Gitea organizations and accounts beside GitHub): the engine can list the organizations and repositories of a Gitea server. Library only. Accounts, the command line and the desktop app do not use it yet, so nothing a user sees changes. - `ForgeKind`; `ForgeAccessException` / `ForgeAccessKind` as the failure type of every forge. `GitHubAccessException` derives from it and keeps its own `Kind`. - `GiteaServerUrl`: what may be the address of a server (HTTPS, no credentials in it, nothing after the path). - `GiteaRepositoryLister`, `GiteaOrganizationLister` behind the existing `IRepositoryLister` / `IOrganizationLister`, on `HttpClient` and source-generated JSON. No new package. - A contract test suite (`ListerContractTests`) that the listers of both forges pass. - ADR 0010 and threat model T16. ## Why Closes #202. The address of a Gitea server is typed by a user, and it decides where the account's token is sent. The rules, each pinned by a test: - the token goes to the configured address, in a header, and to no other host: redirects are not followed, the address of a next page is never taken from an answer, and clone addresses are built from the configured address (the server's `clone_url` is not read); - HTTPS only, certificates validated by the platform, and no setting that changes either; - answers bounded in size, lists in pages, requests in time; a server's own words reach a message only as the reason of a refusal. ## How it was tested - `GiteaListerTests` (a fake server behind `HttpClient`): paging by what the server says, the account fallback, the owner filter, what is and is not taken from answers, every failure kind, cancellation, timeouts, answers and lists without end. - `GiteaServerUrlTests`, `ForgeAccessExceptionTests`, and the contract suite run for GitHub and Gitea. - Gates, started from PowerShell: numbers in the comment below. ## Not merged until 1.1.1 is tagged (#200): `main` is the release candidate. This stays open, and the next steps of #201 are stacked on it. To confirm against a live server before step 2: Gitea accepts the token as the git password with a fixed user name (the form the git client uses for GitHub). ## Checklist - [x] Commits are signed and signed off (`git commit -S -s`) - [x] Zero build warnings; format and lint are clean - [x] Tests added or updated; the 100% coverage gate still passes - [x] Docs updated for user-visible changes; threat model updated if the attack surface changed (ADR 0010, T16; no user-visible change yet) - [x] No secrets, credentials, or personal data - [x] New dependencies are justified below, or none were added (none) - [x] Substantial AI assistance, if any, is noted here: written with Claude Code. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
james.maes added 1 commit 2026-11-11 06:49:26 +00:00
The engine can list the organizations and repositories of a Gitea server (or
one that speaks its API) beside GitHub. Library only: accounts, the CLI and
the app do not use it yet (#201).

- ForgeKind, and ForgeAccessException with ForgeAccessKind: what a failed
  request to any forge means to a caller. GitHubAccessException derives from
  it and keeps its own Kind, so code written against it still compiles and
  still catches.
- GiteaServerUrl: the address of a server is HTTPS, without credentials, and
  without anything after its path. There is no setting for http.
- GiteaRepositoryLister and GiteaOrganizationLister, on HttpClient and
  source-generated JSON, with no new package. The token goes to the server
  the lister was made for and nowhere else: redirects are not followed, the
  address of a next page is never taken from an answer, and clone addresses
  are built from the configured address instead of being read from the
  server. Certificates are validated by the platform; nothing switches that
  off. Answers, lists and requests are bounded.
- Paging follows what the server says about a next page: a Gitea server caps
  the page size and leaves out what the token may not see, so a short or
  empty page does not end a list.
- A lister contract test suite that the listers of both forges pass.
- ADR 0010; threat model T16.

Closes #202

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
Author
Owner

Gates on 0d76ea6, started from PowerShell: build with warnings as errors 0, engine 1,276, CLI 212, line coverage 100% on the three gated assemblies (the Gitea client included, nothing excluded), format clean, UI suite 13 of 13.

🤖 Generated with Claude Code

Gates on 0d76ea6, started from PowerShell: build with warnings as errors 0, engine 1,276, CLI 212, line coverage 100% on the three gated assemblies (the Gitea client included, nothing excluded), format clean, UI suite 13 of 13. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
james.maes added 1 commit 2026-11-11 07:08:21 +00:00
An independent review found no way to send a token to another host on an
address that was typed right, and these gaps.

- An address that is refused is no longer repeated in the message: one that
  was refused for the password or the token in it put that into the text.
- 'https:/host' with one slash was taken for a host named 'https'. Text
  without a scheme is a host with at most a port; anything else is refused.
  A host with characters that have no place in a name is refused instead of
  throwing.
- The client all accounts share keeps no cookies: what one account's server
  set travelled with another account's token.
- A Link header that is not about paging (one a proxy added) ended a list
  after its first page. Only paging relations say that there is no next page.
- A connection lost while an answer is read is 'unavailable', not an
  unexpected error. A page with more items than were asked for is refused, so
  a list has an upper bound. A token with a space or a line break in it is
  refused before anything is sent, and is not repeated.
- A token that may not read organizations is refused at an organization's
  list whatever the name is: the account path is tried then too, so it still
  lists its own account.
- A redirect says that the organization or account may have been renamed;
  408 is 'retry later'; other statuses no longer claim there is no API.
- Tests for each, for the escaping of names in requests and clone addresses,
  for the time limit covering the reading of an answer, and that the client
  of production is the one built on the handler whose settings are asserted.

Refs #202

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
Author
Owner

Independent review: merge after fixes. It found no way to send a token to another host on an address that was typed right. What it found, all fixed in bce029d:

Finding Fix
The message for a refused address repeated the address, and with it a password or token that was in it The address is not repeated
https:/host (one slash) was taken for a host named https Text without a scheme is a host with at most a port; a host with characters that have no place in a name is refused instead of throwing
The client all accounts share kept cookies: what one account's server set travelled with another account's token No cookies, asserted on the production handler
A Link header that is not about paging ended a list after its first page Only paging relations say there is no next page
A connection lost while an answer is read surfaced as an unexpected error It is "unavailable"
A page could hold any number of items A page with more than was asked for is refused: a list has an upper bound
A token without the scope to read organizations could not list its own account (from the reviewer's reading of Gitea; to confirm on a live server) The account path is tried after a refusal of the organization's list too
A redirect for a renamed organization read as "check the server address" The message says to use the new name
Test gaps: escaping of names, the time limit over reading an answer, that production uses the asserted handler Tests added

Left as it is, and written into ADR 0010: reflection that looks Kind up by name on GitHubAccessException now finds two properties.

Gates on bce029d, started from PowerShell: build with warnings as errors 0, engine 1,313, CLI 212, line coverage 100% on the three gated assemblies, format clean, UI suite 13 of 13.

🤖 Generated with Claude Code

Independent review: *merge after fixes*. It found no way to send a token to another host on an address that was typed right. What it found, all fixed in bce029d: | Finding | Fix | |---|---| | The message for a refused address repeated the address, and with it a password or token that was in it | The address is not repeated | | `https:/host` (one slash) was taken for a host named `https` | Text without a scheme is a host with at most a port; a host with characters that have no place in a name is refused instead of throwing | | The client all accounts share kept cookies: what one account's server set travelled with another account's token | No cookies, asserted on the production handler | | A `Link` header that is not about paging ended a list after its first page | Only paging relations say there is no next page | | A connection lost while an answer is read surfaced as an unexpected error | It is "unavailable" | | A page could hold any number of items | A page with more than was asked for is refused: a list has an upper bound | | A token without the scope to read organizations could not list its own account (from the reviewer's reading of Gitea; to confirm on a live server) | The account path is tried after a refusal of the organization's list too | | A redirect for a renamed organization read as "check the server address" | The message says to use the new name | | Test gaps: escaping of names, the time limit over reading an answer, that production uses the asserted handler | Tests added | Left as it is, and written into ADR 0010: reflection that looks `Kind` up by name on `GitHubAccessException` now finds two properties. Gates on bce029d, started from PowerShell: build with warnings as errors 0, engine 1,313, CLI 212, line coverage 100% on the three gated assemblies, format clean, UI suite 13 of 13. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
james.maes merged commit cecaeb96cd into main 2026-11-11 22:21:51 +00:00
Sign in to join this conversation.