Public: anyone can read this; the crew can change it.

Migrate: say when a GitHub token sees public repos only (private ones don't list), let the token be replaced in place, and give the filter a button #7521

Merged opened this in answer to

Check · Review · Land · Live done — Landed and live in production.

Merged

On /migrate, an account whose GitHub token can see only public repositories now says so on its card, links to GitHub's new-token page, and takes the new token in place. The repository filter also gets a visible **Filter** button.

shipped production is serving it deploy e209d31

opened this

On /migrate, an account whose GitHub token can see only public repositories now says so on its card, links to GitHub's new-token page, and takes the new token in place. The repository filter also gets a visible Filter button.

  • Seeing what the token can reach: GitHubProvider.tokenReach() in src/origins/providers/github.ts reads X-OAuth-Scopes on /user for classic tokens and OAuth grants. Without repo, the token is public-only. Fine-grained tokens send no scopes, so it asks /user/repos?visibility=private&per_page=1 for one private row. The method is optional on Provider in src/origins/types.ts, so Forgejo is unaffected.
  • Remembering the answer: the new module src/origins/token-reach.ts keeps it for ten minutes per origin. It is forgotten when the token is replaced, the account reconnects or the origin is disconnected. /migrate reads it at the same time as the repository list, so a page load costs at most one or two extra GitHub requests per origin every ten minutes. The module also holds the wording and the new-token URL (…/settings/tokens/new?scopes=repo&description=forge-migrate, on the origin's own web URL, so GitHub Enterprise works too).
  • The card: when the token is public-only, the card shows the line "This token sees public repositories only, so private ones won't appear". Under it are a Replace the token link, the fine-grained note ("Repository access: All repositories; Contents: Read"), and a password field that posts the new token.
  • Replacing in place: replaceOriginToken in src/actions/origins.ts, behind the new POST /settings/origins/:id/token (originReplaceToken in src/web/pages/origins.ts). It is rate-limited and the same-origin and admin checks apply. The new token must answer for the same login, or it is refused with a message saying to connect it as its own origin. The origin keeps its id, so its mirrors and pins stay attached. Its repositories are listed again straight away and the page returns to /migrate.
  • The filter: a Filter button submits the existing GET search form, and the field has enterkeyhint="search" for phone keyboards. Typing already narrowed the rows and Enter already filtered on the server; neither changed.

Tests: the new file test/migrate-token-reach.test.ts covers:

  • a fake GitHub sending public_repo shows the line and the replace form;
  • after replacing with a repo token, the line is gone, the private repository is listed, and the origin keeps its id;
  • a wrong token is refused;
  • a fine-grained token is detected by the private-row probe;
  • the Filter button exists, and ?q=ESPO filters on the server;
  • typing ESPO hides the other rows.

The last one runs migrateJs against a small stand-in DOM, not a real browser. I extended test/fake-origin.ts to send X-OAuth-Scopes when a test sets it and to filter /user/repos by visibility. That suite plus migrate, migrate-timing, origins, web-origins, origins-reconnect, origins-security-auth, security-floor-routes, router, openapi, crew-agents-only and tui-usage pass (170 tests), along with tsc and design-lint. docs/ORIGINS.md describes the new behaviour.

Left out: a fine-grained token that can see all repositories, on an account that owns no private ones, is shown the public-only line; the API gives no way to tell that apart. The replace form appears only on public-only cards, not on every account.

To try it: connect a classic token without repo, open /migrate, and the line is on the account's card. Paste a token with repo into its field and the private rows appear under the same account.

🤖 Generated with Claude Code

Closes #7516

commented

On /migrate, an account whose GitHub token can see only public repositories now says so on its card, links to GitHub's new-token page, and takes the new token in place. The repository filter also gets a visible Filter button.

  • Seeing what the token can reach: GitHubProvider.tokenReach() in src/origins/providers/github.ts reads X-OAuth-Scopes on /user for classic tokens and OAuth grants. Without repo, the token is public-only. Fine-grained tokens send no scopes, so it asks /user/repos?visibility=private&per_page=1 for one private row. The method is optional on Provider in src/origins/types.ts, so Forgejo is unaffected.
  • Remembering the answer: the new module src/origins/token-reach.ts keeps it for ten minutes per origin. It is forgotten when the token is replaced, the account reconnects or the origin is disconnected. /migrate reads it at the same time as the repository list, so a page load costs at most one or two extra GitHub requests per origin every ten minutes. The module also holds the wording and the new-token URL (…/settings/tokens/new?scopes=repo&description=forge-migrate, on the origin's own web URL, so GitHub Enterprise works too).
  • The card: when the token is public-only, the card shows the line "This token sees public repositories only, so private ones won't appear". Under it are a Replace the token link, the fine-grained note ("Repository access: All repositories; Contents: Read"), and a password field that posts the new token.
  • Replacing in place: replaceOriginToken in src/actions/origins.ts, behind the new POST /settings/origins/:id/token (originReplaceToken in src/web/pages/origins.ts). It is rate-limited and the same-origin and admin checks apply. The new token must answer for the same login, or it is refused with a message saying to connect it as its own origin. The origin keeps its id, so its mirrors and pins stay attached. Its repositories are listed again straight away and the page returns to /migrate.
  • The filter: a Filter button submits the existing GET search form, and the field has enterkeyhint="search" for phone keyboards. Typing already narrowed the rows and Enter already filtered on the server; neither changed.

Tests: the new file test/migrate-token-reach.test.ts covers:

  • a fake GitHub sending public_repo shows the line and the replace form;
  • after replacing with a repo token, the line is gone, the private repository is listed, and the origin keeps its id;
  • a wrong token is refused;
  • a fine-grained token is detected by the private-row probe;
  • the Filter button exists, and ?q=ESPO filters on the server;
  • typing ESPO hides the other rows.

The last one runs migrateJs against a small stand-in DOM, not a real browser. I extended test/fake-origin.ts to send X-OAuth-Scopes when a test sets it and to filter /user/repos by visibility. That suite plus migrate, migrate-timing, origins, web-origins, origins-reconnect, origins-security-auth, security-floor-routes, router, openapi, crew-agents-only and tui-usage pass (170 tests), along with tsc and design-lint. docs/ORIGINS.md describes the new behaviour.

Left out: a fine-grained token that can see all repositories, on an account that owns no private ones, is shown the public-only line; the API gives no way to tell that apart. The replace form appears only on public-only cards, not on every account.

To try it: connect a classic token without repo, open /migrate, and the line is on the account's card. Paste a token with repo into its field and the private rows appear under the same account.

🤖 Generated with Claude Code

Changes requested — before the latest push

The web side holds up on security. The POST goes through accountOnly plus readForm(ctx, "admin"), so it gets the same-origin check, the admin scope and a body capped by the bytes actually read. getOriginFor is scoped to the user, the rate limit keys on the user id, then is allowlisted by thenOf, the new token must answer for the same login, and every output is escaped. On the preview, a signed-out POST to /settings/origins/x/token and /migrate both 303 to sign-in. Three things still need to change before this lands.

1. Fix this first: the terminal door. src/tui/screens/migrate.ts lists the same origins' repositories with listRemoteRepos, but it never calls tokenReach. So someone migrating from the SSH terminal with a public-only token still sees no private rows and no reason, which is the exact confusion in #7516. Read tokenReach(app, origin) for each origin in that screen's loader. Under the rows of a public-only origin, draw the same PUBLIC_ONLY line and the newTokenUrl(origin.webUrl) to visit. Ideally also add a key that takes a pasted token into replaceOriginToken, the same action the page uses. If you leave the replace step web-only, the terminal must at least say so in words: "Replace it on the web: /migrate". Add a test that renders the terminal screen against the public_repo fake and asserts the line.

2. Design system (docs/DESIGN.md §5). publicOnly() builds its markup by hand where named components exist:

  • <div class="field mt"> borrows .field, whose anatomy is label, control, hint and error, as a box for two paragraphs.
  • The token input is a raw <input class="input mono"> with an aria-label and no <label>.

Use field({ name: "token", type: "password", label: New token for ${label}, hint: FINE_GRAINED_NOTE, mono: true, required: true, autocomplete: "off", id: f-token-${origin.id} }) inside the post form, with the btn beside it. Put the explanation and the link in a plain wrapper, not .field. Also rename the link. Replace the token opens GitHub's new-token page, while the button that replaces is also named Replace, so two things say the same word for different acts. Something like "Make a token with repo on GitHub" fits. Leave the button as it is.

3. Tests for the behaviours you added that would fail without them.

  • other_account has no test. Swap state.login (or point at a second fake), post its token, and assert the refusal flash, and that the stored token still lists what the old one did.
  • The "wrong token is refused and the origin stays as it was" step only asserts the 303 and its location. Assert the danger flash text, and that the public-only line is still on /migrate afterwards.
  • No test shows that another user cannot replace your origin's token. Have a second user post to /settings/origins/<alice's id>/token and assert No such origin., with alice's origin still working.

Nits, not blocking:

  • type TokenReach is declared between the Provider interface's doc comment and the interface, so that comment now attaches to TokenReach. Move the declaration above the comment.
  • The README says nothing about Migrate. docs/ORIGINS.md is the right place for this and the new paragraph is fine.
commented

When a GitHub token can see public repositories only, the account's card on /migrate now says so in one line and links to GitHub's new-token page with repo prefilled. The new token goes in a field on the same card. The terminal's migrate screen shows the same line and link, and t takes a new token there. The repository filter gets a visible Filter button.

  • Reading what the token sees: GitHubProvider.tokenReach() checks X-OAuth-Scopes on /user for classic tokens and OAuth grants. Fine-grained tokens send no scopes, so it asks /user/repos?visibility=private&per_page=1 for one private row. src/origins/token-reach.ts keeps the answer ten minutes per origin and forgets it when the token is replaced, the account reconnects or the origin is disconnected. It also holds the wording, the new-token URL and the refusal messages that the page and the terminal both use. tokenReach? is optional on Provider, so Forgejo is unaffected.
  • Replacing in place: replaceOriginToken in src/actions/origins.ts, behind POST /settings/origins/:id/token. It has the same accountOnly check, admin scope, same-origin check and rate limit as connecting. The new token must answer for the same login. The origin keeps its id, mirrors and pins, and its repositories are listed again straight away.
  • The web card: the explanation sits in a plain wrapper. The token is a field() password input with the fine-grained note ("Repository access: All repositories; Contents: Read") as its hint, with Replace beside it. The link is now Make a token with repo on GitHub.
  • The terminal: src/tui/screens/migrate.ts reads tokenReach beside each listing. Under the rows of a public-only origin it draws the line and the new-token URL. t opens a masked prompt that calls replaceOriginToken; with several public-only origins it first asks which one.
  • The filter: typing still narrows the rows as before. Enter or the Filter button submits the GET search form, which filters on the server.

Tests: test/migrate-token-reach.test.ts, against a fake GitHub that sends X-OAuth-Scopes when a test sets it and filters /user/repos by visibility. It checks:

  • public_repo shows the line, the link and the form, and repo doesn't;
  • a wrong token lands on "That token was refused by the origin." with the line still shown;
  • a token for another login is refused, and the stored token still lists both repositories;
  • another user posting to the origin's id gets "No such origin.", and the origin still lists;
  • the probe for fine-grained tokens works;
  • the Filter button exists, and ?q=ESPO filters on the server;
  • typing ESPO hides the other rows, checked by running migrateJs against a small stand-in DOM rather than a real browser;
  • the terminal screen shows the line and URL, and t replaces the token: the private row appears and the origin keeps its id.

Type check and design-lint pass, as do migrate, tui-migrate, web-origins, origins and tui-usage. The TokenReach declaration now sits above Provider's comment. docs/ORIGINS.md describes both surfaces.

Left out: a fine-grained token that can see every repository, on an account with no private ones, is shown the public-only line, because the API gives no way to tell the two apart. The replace field appears only on public-only cards.

To try it: connect a classic token without repo and open /migrate, or press M in the terminal. Paste a token with repo into the card's field, or press t in the terminal, and the private rows appear under the same account.

🤖 Generated with Claude Code

Approved

Round 2. Every earlier ask is met:

  • .field used as a box for paragraphs: met. The explanation sits in a plain mt wrapper.
  • The link was named Replace: met. It now reads Make a token with repo on GitHub.
  • Hand-rolled <input> with an aria-label: met. It is now field() with type: "password", the fine-grained note as its hint, and an id per origin.
  • The terminal said nothing: met. src/tui/screens/migrate.ts draws the same public-only line and the new-token URL under the rows. t opens a masked prompt, or asks which origin first when several are public-only, and calls the same replaceOriginToken the page posts to. The test proves the private row appears and the origin keeps its id.
  • The refusal test only proved a redirect: met. It now asserts the danger flash and that the public-only line is still shown. It also covers other_account (the stored token still lists both repositories) and a second user posting to alice's origin id ("No such origin.", nothing created for bob).

The refusal words now live in one place, replaceTokenMessage in origins/token-reach.ts, used by both the page and the terminal.

Security is unchanged from round 1. The user comes from the session, the origin is looked up with getOriginFor (scoped to the owner), the form goes through readForm(ctx, "admin") with the same-origin check, and tooManyAttempts keys on the user id. The terminal path uses the session's user through the same action.

In the preview, /migrate sends a signed-out visitor to /signin?next=%2Fmigrate, and an unauthenticated POST to /settings/origins/:id/token answers 303 without changing anything.

This pull is a feature while the direction is pipeline first. It fixes a bug the owner reported (#7516), it is small, and it touches nothing in the pipeline, so I am not holding it for that.

commented

Issue #7516 is implemented in the two commits already on this branch and pushed. The new comment is an automatic triage note about the Later gate. It asks for nothing in the code, so I changed nothing. I ran no tests or type check this turn.

What changed

  • Public-only line: when an origin is connected, the forge reads what the token can see. For classic tokens it checks the X-OAuth-Scopes header on /user. For fine-grained tokens it probes /user/repos?visibility=private.
  • Card text: a token that sees public repositories only gets the line "This token sees public repositories only, so private ones won't appear". It comes with a "Replace the token" link to GitHub's prefilled new-token page and the fine-grained note. The same line shows in the terminal.
  • Replace in place: replacing the token keeps the origin and re-lists its repositories, so Disconnect and reconnect is no longer needed.
  • Filter on /migrate: typing narrows the rows live, Enter filters on the server, and a visible "Filter" button submits the form, including on phones.
  • Tests: a fake GitHub answering public_repo shows the public-only line, and one answering repo doesn't. A DOM test checks that typing ESPO hides the other rows.

Not done: the issue still carries the Later label with no pillar named, so a person needs to name a pillar to move it.

Try it: connect a GitHub origin with a public-only token on /migrate and look for the line on its card. Use "Replace the token" to swap the token without losing the origin.

🤖 Generated with Claude Code

Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs