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

mason/forge:mason/7516-migrate-say-when-a-github-token-sees-pub → main 2 commits fork 5 threads · 5 unresolved

  • Merged. mason/7516-migrate-say-when-a-github-token-sees-pub was merged into main as 44a201a.

Review

Sign in to review this change.
11 files changed +740 −23
docs/ORIGINS.md +15
@@ -577,6 +577,21 @@ chosen gets the same rule in the job, over the clone's own tips
577577 That branch becomes the default branch here, the job says which and why,
578578 and the crew's brief names it: start from it, open the pull against it.
579579
580 **A token that sees public repositories only says so.** A classic GitHub
581 token without `repo` (its `X-OAuth-Scopes` on `/user`), or a fine-grained one
582 whose `/user/repos?visibility=private&per_page=1` answers no row, lists no
583 private repository, and the account's card on /migrate says it in one line:
584 "This token sees public repositories only, so private ones won't appear",
585 with **Make a token with `repo` on GitHub** linking GitHub's new-token page (`scopes=repo`
586 prefilled) and a field for the new one (`POST /settings/origins/:id/token`).
587 The new token must answer for the same login; the origin keeps its id, its
588 mirrors and pins, and its repositories are listed again at once. The answer
589 is remembered ten minutes per origin and forgotten when the token changes
590 (`origins/token-reach.ts`). The terminal's migrate screen draws the same
591 line and the new-token page under its rows, and `t` takes the new token into
592 the same action. The picker's filter narrows the rows as you
593 type, Enter or the **Filter** button asks the server.
594
580595 **The conversations come too** — from a connected origin, not a pasted URL
581596 (`origins/carry.ts`). Labels; every issue, open and closed, with its comments
582597 and labels, under its original number; and the open pull requests as pulls
src/actions/origins.ts +69
@@ -85,6 +85,7 @@ import {
8585 type FlowRow,
8686 } from "../origins/oauth.ts";
8787 import { forgetProvider, providerFor } from "../origins/providers/index.ts";
88 import { forgetTokenReach } from "../origins/token-reach.ts";
8889 import {
8990 requestSyncNow,
9091 syncOrigin,
@@ -203,6 +204,72 @@ export async function connectOrigin(
203204 });
204205 }
205206
207 export type ReplaceTokenError =
208 | "unauthorized"
209 | "not_found"
210 | "rate_limited"
211 | "bad_token"
212 | "unreachable"
213 | "other_account";
214
215 /**
216 * Put a new pasted token on an origin that is already connected — one that
217 * sees more, or one that still works. The row keeps its id, so its mirrors
218 * and pins stay attached; the token must answer for the same account, or
219 * it is a different origin and Connect is the way to add it. The picker's
220 * rows are listed again at once with what the new token sees.
221 */
222 export async function replaceOriginToken(
223 app: App,
224 actor: User | undefined,
225 originId: string,
226 token: string,
227 ): Promise<Result<Origin, ReplaceTokenError>> {
228 const user = requireUser(actor);
229 if (!user.ok) return user;
230 const origin = getOriginFor(app.db, user.value.id, originId);
231 if (!origin) return err("not_found");
232 if (tooManyAttempts(app, user.value.id)) return err("rate_limited");
233 const secret = token.trim();
234 if (secret === "") return err("bad_token");
235
236 const identity = await providerFor(
237 staticCredential({
238 kind: origin.kind,
239 apiUrl: origin.apiUrl,
240 webUrl: origin.webUrl,
241 token: secret,
242 }),
243 ).whoami();
244 if (!identity.ok)
245 return err(
246 identity.error.error === "unreachable" ? "unreachable" : "bad_token",
247 );
248 if (identity.value.login.toLowerCase() !== origin.login.toLowerCase())
249 return err("other_account");
250
251 renewOrigin(app.db, app.config.dataDir, origin.id, {
252 token: secret,
253 authKind: "pat",
254 });
255 forgetProvider({ dataDir: app.config.dataDir, originId: origin.id });
256 forgetTokenReach(app.config.dataDir, origin.id);
257 app.bus.append({
258 kind: "origin.connected",
259 actorId: user.value.id,
260 payload: {
261 originId: origin.id,
262 host: origin.webUrl,
263 login: origin.login,
264 authKind: "pat",
265 renewed: true,
266 },
267 });
268 const renewed = getOriginById(app.db, origin.id)!;
269 await listRemoteRepos(app, user.value, origin.id, true);
270 return ok(renewed);
271 }
272
206273 /** A connected origin, and the frozen mirrors connecting it brought back. */
207274 export type Connected = Origin & {
208275 readonly reattached: readonly Reattachment[];
@@ -237,6 +304,7 @@ function storeOrigin(
237304 ...(input.expiresAt === undefined ? {} : { expiresAt: input.expiresAt }),
238305 });
239306 forgetProvider({ dataDir: app.config.dataDir, originId: existing.id });
307 forgetTokenReach(app.config.dataDir, existing.id);
240308 app.bus.append({
241309 kind: "origin.connected",
242310 actorId: user.id,
@@ -316,6 +384,7 @@ export async function disconnectOrigin(
316384
317385 deleteOrigin(app.db, user.value.id, originId);
318386 forgetProvider({ dataDir: app.config.dataDir, originId });
387 forgetTokenReach(app.config.dataDir, originId);
319388 app.bus.append({
320389 kind: "origin.disconnected",
321390 actorId: user.value.id,
src/http/server.ts +2
@@ -463,6 +463,7 @@ import {
463463 originOAuthStart,
464464 originSyncSubmit,
465465 originsPage,
466 originReplaceToken,
466467 originTrustedHosts,
467468 } from "../web/pages/origins.ts";
468469 import { originFlowPage, originFlowPoll } from "../web/pages/origin-flow.ts";
@@ -1021,6 +1022,7 @@ export function buildRouter(): Router<Handler> {
10211022 router.add("GET", "/settings/origins/:id/mirror/:job", mirrorJobPage);
10221023 router.add("POST", "/settings/origins/:id/sync", originSyncSubmit);
10231024 router.add("POST", "/settings/origins/:id/delete", originDisconnect);
1025 router.add("POST", "/settings/origins/:id/token", originReplaceToken);
10241026
10251027 // Browsing a repository on an origin, live and read-only. Registered here,
10261028 // above every parameterized repo route, so `/origins` can never be captured
src/origins/providers/github.ts +22
@@ -30,6 +30,7 @@ import type {
3030 RemoteMilestone,
3131 RemoteReviewState,
3232 RemoteTreeEntry,
33 TokenReach,
3334 } from "../types.ts";
3435 import {
3536 NO_ACCOUNT,
@@ -109,6 +110,27 @@ export class GitHubProvider implements Provider {
109110 });
110111 }
111112
113 /**
114 * A classic token (and an OAuth grant) names its scopes on every answer
115 * in `X-OAuth-Scopes`: without `repo` it lists public repositories only.
116 * A fine-grained token names none, so one private row is asked for.
117 */
118 async tokenReach(): Promise<ProviderResult<TokenReach>> {
119 const user = await this.api.call({ path: "/user" });
120 if (!user.ok) return user;
121 const scopes = user.value.headers.get("x-oauth-scopes");
122 if (scopes !== null)
123 return succeed(
124 scopes.split(",").some((s) => s.trim() === "repo") ? "private" : "public",
125 );
126 const probe = await this.api.call({
127 path: "/user/repos",
128 query: { visibility: "private", per_page: 1 },
129 });
130 if (!probe.ok) return probe;
131 return succeed(asArray(probe.value.data).length > 0 ? "private" : "public");
132 }
133
112134 async listRepos(cursor?: string): Promise<ProviderResult<Page<RemoteRepo>>> {
113135 const page = pageOf(cursor);
114136 const result = await this.api.call({
src/origins/token-reach.ts added +73
@@ -0,0 +1,73 @@
1 /**
2 * What an origin's token can see, remembered. A GitHub token without the
3 * `repo` scope — or a fine-grained one limited to public repositories —
4 * lists public repositories only, and a picker with no private rows looks
5 * broken unless it says why (#7516).
6 *
7 * The answer costs a request or two at the origin, so it is kept per
8 * origin for a while and forgotten the moment the token is replaced.
9 */
10
11 import type { App } from "../app.ts";
12 import type { Origin } from "../core/origins.ts";
13 import { providerForOrigin } from "./credential.ts";
14 import type { TokenReach } from "./types.ts";
15
16 const KEEP_MS = 10 * 60_000;
17 const MAX_KEPT = 256;
18 const kept = new Map<string, { reach: TokenReach; at: number }>();
19
20 const keyOf = (dataDir: string, originId: string): string =>
21 `${dataDir} ${originId}`;
22
23 /**
24 * The origin's reach, or undefined when its provider names none or the
25 * origin did not answer — the picker then says nothing rather than guess.
26 */
27 export async function tokenReach(
28 app: App,
29 origin: Origin,
30 ): Promise<TokenReach | undefined> {
31 const key = keyOf(app.config.dataDir, origin.id);
32 const hit = kept.get(key);
33 if (hit !== undefined && Date.now() - hit.at < KEEP_MS) return hit.reach;
34 const provider = providerForOrigin(app, origin);
35 if (!provider.ok || provider.value.tokenReach === undefined) return undefined;
36 const read = await provider.value.tokenReach();
37 if (!read.ok) return undefined;
38 if (kept.size >= MAX_KEPT) {
39 const oldest = kept.keys().next();
40 if (!oldest.done) kept.delete(oldest.value);
41 }
42 kept.set(key, { reach: read.value, at: Date.now() });
43 return read.value;
44 }
45
46 /** Drop what was learned about an origin's token: it was replaced or removed. */
47 export function forgetTokenReach(dataDir: string, originId: string): void {
48 kept.delete(keyOf(dataDir, originId));
49 }
50
51 export const PUBLIC_ONLY =
52 "This token sees public repositories only, so private ones won't appear";
53
54 export const FINE_GRAINED_NOTE =
55 "A fine-grained token: Repository access: All repositories; Contents: Read";
56
57 /** A refused replacement, in words — the page and the terminal say the same. */
58 export function replaceTokenMessage(error: string): string {
59 const words: Record<string, string> = {
60 not_found: "No such origin.",
61 other_account:
62 "That token belongs to another account. Connect it as its own origin instead.",
63 bad_token: "That token was refused by the origin.",
64 unreachable: "That instance could not be reached.",
65 rate_limited: "Too many connect attempts. Wait a few minutes.",
66 unauthorized: "Sign in first.",
67 };
68 return words[error] ?? "That token could not be put on this origin.";
69 }
70
71 /** GitHub's new-token page with the scope the picker needs filled in. */
72 export const newTokenUrl = (webUrl: string): string =>
73 `${webUrl.replace(/\/+$/, "")}/settings/tokens/new?scopes=repo&description=forge-migrate`;
src/origins/types.ts +8
@@ -261,6 +261,9 @@ export interface ListIssuesQuery {
261261 readonly cursor?: string;
262262 }
263263
264 /** What a token can list: private repositories too, or public ones only. */
265 export type TokenReach = "private" | "public";
266
264267 /**
265268 * What every provider implements. Results are `Result<T, ProviderFailure>`
266269 * (see kernel/result.ts) — network and remote refusals are expected outcomes.
@@ -276,6 +279,11 @@ export interface Provider {
276279 readonly webUrl: string;
277280
278281 whoami(): Promise<ProviderResult<RemoteIdentity>>;
282 /**
283 * Whether the credential can see private repositories at all. Left out
284 * by an origin whose tokens carry no such limit worth naming.
285 */
286 tokenReach?(): Promise<ProviderResult<TokenReach>>;
279287 listRepos(cursor?: string): Promise<ProviderResult<Page<RemoteRepo>>>;
280288 getRepo(fullName: string): Promise<ProviderResult<RemoteRepo>>;
281289
src/tui/screens/migrate.ts +102 −3
@@ -6,6 +6,10 @@
66 * the job. Nothing here is a second path: the last step is `migrateRepo`
77 * with `branch` and `crew`, and this file only puts its questions and its
88 * answer on a terminal.
9 *
10 * An origin whose token sees public repositories only says so under the
11 * rows, with GitHub's new-token page to visit; `t` takes the new token
12 * into `replaceOriginToken`, the action the web page posts to.
913 */
1014
1115 import { listAllAgents } from "../../actions/crew.ts";
@@ -15,7 +19,11 @@ import {
1519 migrateRepo,
1620 type MigrateSource,
1721 } from "../../actions/migrate.ts";
18 import { listRemoteRepos, listUserOrigins } from "../../actions/origins.ts";
22 import {
23 listRemoteRepos,
24 listUserOrigins,
25 replaceOriginToken,
26 } from "../../actions/origins.ts";
1927 import { repositoryLimitMessage } from "../../actions/repos.ts";
2028 import { accessAllows, listOrgsForUser, orgAccess } from "../../core/orgs.ts";
2129 import type { OriginRepoRow } from "../../core/origins.ts";
@@ -24,6 +32,12 @@ import { subscribe, type MirrorJob, type MirrorStage } from "../../origins/jobs.
2432 import { providerForOrigin } from "../../origins/credential.ts";
2533 import { cachedRemoteTips, pickWorkBranch } from "../../origins/work-branch.ts";
2634 import { getOriginFor } from "../../core/origins.ts";
35 import {
36 PUBLIC_ONLY,
37 newTokenUrl,
38 replaceTokenMessage,
39 tokenReach,
40 } from "../../origins/token-reach.ts";
2741 import type { Ctx, Screen } from "../app/app.ts";
2842 import { binding } from "../app/keys.ts";
2943 import { G } from "../term/screen.ts";
@@ -51,6 +65,13 @@ interface Row {
5165 readonly repo: OriginRepoRow;
5266 }
5367
68 /** An origin whose token lists public repositories only. */
69 interface PublicOnly {
70 readonly originId: string;
71 readonly login: string;
72 readonly webUrl: string;
73 }
74
5475 /** Where the repository comes from, and — for an origin — the branch chosen. */
5576 interface Picked {
5677 readonly source: MigrateSource;
@@ -74,6 +95,7 @@ export function migrateScreen(f: Session, ctx: Ctx): Screen {
7495 let loading = true;
7596 // Origins that would not answer: said aloud, never read as "no repositories".
7697 let unreachable: string[] = [];
98 let publicOnly: PublicOnly[] = [];
7799 let self: Screen;
78100
79101 const shown = (): Row[] =>
@@ -160,6 +182,46 @@ export function migrateScreen(f: Session, ctx: Ctx): Screen {
160182 );
161183 };
162184
185 const replaceToken = (origin: PublicOnly): void => {
186 if (user === undefined) return;
187 ctx.push(
188 promptScreen(ctx, self, {
189 title: `Replace the token for ${origin.login}`,
190 label: "New token (with repo)",
191 hint: newTokenUrl(origin.webUrl),
192 mask: true,
193 onSubmit: (token) => {
194 if (token.trim() === "") return "Paste the new token first.";
195 return replaceOriginToken(f.app, user, origin.originId, token).then(
196 async (replaced) => {
197 if (!replaced.ok) {
198 ctx.toast(replaceTokenMessage(replaced.error), "danger");
199 return;
200 }
201 ctx.toast(`Token replaced for ${origin.login}; listed again.`);
202 await self.load?.(new AbortController().signal);
203 ctx.invalidate();
204 },
205 );
206 },
207 }),
208 );
209 };
210
211 const pickReplace = (): void => {
212 if (publicOnly.length === 1) return replaceToken(publicOnly[0]!);
213 ctx.push(
214 pickerScreen(ctx, self, {
215 title: "Replace which token",
216 items: publicOnly.map((o) => ({ id: o.originId, label: o.login })),
217 onPick: ([id]) => {
218 const origin = publicOnly.find((o) => o.originId === id);
219 if (origin !== undefined) replaceToken(origin);
220 },
221 }),
222 );
223 };
224
163225 self = {
164226 title: "Migrate a repository",
165227 get keys() {
@@ -168,6 +230,7 @@ export function migrateScreen(f: Session, ctx: Ctx): Screen {
168230 binding(["enter"], "pick branch"),
169231 binding(["/"], "filter"),
170232 binding(["u"], "paste a URL"),
233 ...(publicOnly.length > 0 ? [binding(["t"], "replace token")] : []),
171234 binding(["esc"], "back"),
172235 ];
173236 },
@@ -178,8 +241,18 @@ export function migrateScreen(f: Session, ctx: Ctx): Screen {
178241 }
179242 const out: Row[] = [];
180243 const failed: string[] = [];
244 const limited: PublicOnly[] = [];
181245 for (const origin of listUserOrigins(f.app, user)) {
182 const listed = await listRemoteRepos(f.app, user, origin.id);
246 const [listed, reach] = await Promise.all([
247 listRemoteRepos(f.app, user, origin.id),
248 tokenReach(f.app, origin),
249 ]);
250 if (reach === "public")
251 limited.push({
252 originId: origin.id,
253 login: origin.login,
254 webUrl: origin.webUrl,
255 });
183256 if (!listed.ok) {
184257 failed.push(origin.login);
185258 continue;
@@ -189,12 +262,34 @@ export function migrateScreen(f: Session, ctx: Ctx): Screen {
189262 }
190263 rows = out;
191264 unreachable = failed;
265 publicOnly = limited;
192266 loading = false;
193267 ctx.invalidate();
194268 },
195269 render(area, s) {
196270 const c = ctx.theme.c;
197 const [top, list] = split(area, "v", [len(2), fill(1)]);
271 const [top, list, foot] = split(area, "v", [
272 len(2),
273 fill(1),
274 len(publicOnly.length === 0 ? 0 : 2 * publicOnly.length),
275 ]);
276 publicOnly.forEach((o, i) => {
277 const at = foot!.y + 2 * i;
278 s.text(
279 foot!.x,
280 at,
281 truncate(`${PUBLIC_ONLY} — ${o.login}.`, foot!.w),
282 style(c.attn, c.bg),
283 foot!.x + foot!.w,
284 );
285 s.text(
286 foot!.x,
287 at + 1,
288 truncate(`Make one with repo at ${newTokenUrl(o.webUrl)} · t to paste it`, foot!.w),
289 style(c.mutedFg, c.bg),
290 foot!.x + foot!.w,
291 );
292 });
198293 ctx.setCursor(filter.render(s, { ...top!, h: 1 }, c, "filter repositories"));
199294 const items = shown();
200295 const down = `Could not reach ${unreachable.join(", ")}.`;
@@ -261,6 +356,10 @@ export function migrateScreen(f: Session, ctx: Ctx): Screen {
261356 pasteUrl();
262357 return true;
263358 }
359 if (e.key === "t" && publicOnly.length > 0) {
360 pickReplace();
361 return true;
362 }
264363 if (state.key(e, shown().length)) return true;
265364 if (e.key === "enter") {
266365 const row = shown()[state.index];
src/web/pages/migrate.ts +66 −12
@@ -13,6 +13,10 @@
1313 * commits and selects the one the app most likely lives on — the person's
1414 * newest (`origins/work-branch.ts`); the Migrate button carries the choice.
1515 *
16 * An account whose token sees public repositories only says so on its card
17 * (`origins/token-reach.ts`), with GitHub's new-token page one click away
18 * and a field that replaces the token in place.
19 *
1620 * Every mutation is `migrateRepo`; the page only puts its answer into words.
1721 */
1822
@@ -57,6 +61,13 @@ import {
5761 selectField,
5862 field as formField,
5963 } from "../ui.ts";
64 import {
65 FINE_GRAINED_NOTE,
66 PUBLIC_ONLY,
67 newTokenUrl,
68 tokenReach,
69 } from "../../origins/token-reach.ts";
70 import type { TokenReach } from "../../origins/types.ts";
6071 import { STAGE_BADGE_JS, stageRow, stageStateFor } from "../stage-row.ts";
6172 import { prefill, originHost } from "./migrate-prefill.ts";
6273 import { githubConnectable, hostLabel } from "./origins.ts";
@@ -92,7 +103,10 @@ interface OriginChoices {
92103 readonly id: string;
93104 readonly kind: string;
94105 readonly host: string;
106 readonly webUrl: string;
95107 readonly repos: readonly OriginRepoRow[];
108 /** What its token sees; undefined when the origin names no limit. */
109 readonly reach: TokenReach | undefined;
96110 }
97111
98112 /** Every origin you connected; one that cannot list has no rows. */
@@ -100,13 +114,18 @@ async function originChoices(ctx: Ctx): Promise<OriginChoices[]> {
100114 const user = ctx.auth.user!;
101115 const out: OriginChoices[] = [];
102116 for (const origin of listUserOrigins(ctx.app, user)) {
103 const listed = await listRemoteRepos(ctx.app, user, origin.id);
117 const [listed, reach] = await Promise.all([
118 listRemoteRepos(ctx.app, user, origin.id),
119 tokenReach(ctx.app, origin),
120 ]);
104121 out.push({
105122 id: origin.id,
106123 kind: origin.kind,
107124 host: originHost(origin.webUrl),
125 webUrl: origin.webUrl,
108126 label: `${origin.login} @ ${hostLabel(origin.webUrl)}`,
109127 repos: listed.ok ? listed.value : [],
128 reach,
110129 });
111130 }
112131 return out;
@@ -221,6 +240,36 @@ export function matchesFilter(
221240 );
222241 }
223242
243 /**
244 * Under an account whose token lists public repositories only: why the
245 * private ones are missing, where to make a token that sees them, and the
246 * field that puts it on this origin without disconnecting it.
247 */
248 function publicOnly(origin: OriginChoices): Html {
249 return html`<div class="mt" id="migrate-reach-${origin.id}" data-reach="public">
outdated

.field is the form-field component (label, control, hint, error). Here it's a box for paragraphs. Use a plain wrapper for the explanation, and field({...type:"password", hint: FINE_GRAINED_NOTE}) for the input. See the review body.

250 <p class="small">${icon("lock")} ${PUBLIC_ONLY} — ${origin.label}.</p>
251 <p class="small">
252 <a href="${newTokenUrl(origin.webUrl)}" target="_blank" rel="noopener noreferrer"
253 >Make a token with <span class="mono">repo</span> on GitHub</a
outdated

This link opens GitHub's new-token page. It doesn't replace anything, and the button below is also named Replace. Name the link for what it does, e.g. "Make a token with repo on GitHub".

254 >
255 </p>
256 <form method="post" action="/settings/origins/${origin.id}/token">
257 <input type="hidden" name="then" value="migrate" />
258 ${formField({
259 label: `New token for ${origin.label}`,
260 name: "token",
outdated

Hand-rolled <input class="input"> with an aria-label in place of a label. Use field() (imported here as formField), which wires up the label, the hint and aria-describedby.

261 type: "password",
262 hint: `${FINE_GRAINED_NOTE}.`,
263 mono: true,
264 required: true,
265 autocomplete: "off",
266 id: `f-token-${origin.id}`,
267 })}
268 ${btn("Replace", { size: "sm", variant: "outline", type: "submit" })}
269 </form>
270 </div>`;
271 }
272
224273 /**
225274 * The accounts half, outside the migrate form (forms do not nest): each
226275 * connected origin with a Disconnect, and Connect GitHub when there is no
@@ -265,6 +314,7 @@ function accounts(ctx: Ctx, origins: readonly OriginChoices[]): Html {
265314 }),
266315 )}
267316 </ul>`}
317 ${origins.filter((o) => o.reach === "public").map(publicOnly)}

The web card names the limit, but the terminal's migrate screen (src/tui/screens/migrate.ts) lists the same origins and says nothing. The terminal needs the same line and the new-token URL, and a way to replace the token or words saying where to do it, plus a test.

268318 ${connect}`,
269319 {
270320 head: html`${icon("globe")}
@@ -294,16 +344,20 @@ function picker(
294344 </p>`;
295345 return html`<div class="field" id="migrate-picker">
296346 <label for="f-q">Your repositories</label>
297 <input
298 class="input"
299 type="search"
300 id="f-q"
301 name="q"
302 form="migrate-search"
303 value="${values.q}"
304 placeholder="Filter by name or description"
305 autocomplete="off"
306 />
347 <div class="bar">
348 <input
349 class="input"
350 type="search"
351 id="f-q"
352 name="q"
353 form="migrate-search"
354 value="${values.q}"
355 placeholder="Filter by name or description"
356 autocomplete="off"
357 enterkeyhint="search"
358 />
359 ${btn("Filter", { variant: "outline", type: "submit", form: "migrate-search", icon: "search" })}
360 </div>
307361 ${values.source === ""
308362 ? null
309363 : html`<input type="hidden" name="source" value="${values.source}" />`}
@@ -526,7 +580,7 @@ function migrateForm(
526580 * The name field follows the picked repository or the pasted URL until
527581 * somebody types in it — a preview; the server names it the same way.
528582 */
529 const migrateJs = /* js */ `
583 export const migrateJs = /* js */ `
530584 (function () {
531585 var name = document.getElementById('f-name');
532586 var url = document.getElementById('f-url');
src/web/pages/origins.ts +29
@@ -24,6 +24,7 @@ import {
2424 listOriginApps,
2525 listUserOrigins,
2626 registerOriginApp,
27 replaceOriginToken,
2728 startOAuthConnect,
2829 syncOriginNow,
2930 type Reattachment,
@@ -37,6 +38,7 @@ import {
3738 updateTrustedHosts,
3839 } from "../../origins/trusted-hosts.ts";
3940 import { reattachMessage } from "./frozen.ts";
41 import { replaceTokenMessage } from "../../origins/token-reach.ts";
4042 import {
4143 listDetachedMirrors,
4244 mirrorsByOrigin,
@@ -1102,3 +1104,30 @@ export async function originDisconnect(ctx: Ctx): Promise<Response> {
11021104 : "Disconnected. Revoke the token on the origin too if you want it dead there.",
11031105 });
11041106 }
1107
1108 // POST /settings/origins/:id/token
1109 /**
1110 * A new token on an origin already connected: the origin, its mirrors and
1111 * its pins stay; its repositories are listed again with what it sees.
1112 */
1113 export async function originReplaceToken(ctx: Ctx): Promise<Response> {
1114 const denied = accountOnly(ctx);
1115 if (denied) return denied;
1116 const form = await readForm(ctx, "admin");
1117 if (form instanceof Response) return form;
1118 const back = thenOf(field(form, "then")) ?? "/settings/origins";
1119 const replaced = await replaceOriginToken(
1120 ctx.app,
1121 ctx.auth.user,
1122 ctx.params.id ?? "",
1123 field(form, "token"),
1124 );
1125 if (!replaced.ok)
1126 return flashTo(ctx, back, {
1127 tone: "danger",
1128 message: replaceTokenMessage(replaced.error),
1129 });
1130 return flashTo(ctx, back, {
1131 message: `Token replaced for ${replaced.value.login} at ${hostLabel(replaced.value.webUrl)}; its repositories are listed again.`,
1132 });
1133 }
test/fake-origin.ts +25 −8
@@ -217,6 +217,11 @@ export interface FakeState {
217217 nextId: number;
218218 /** The OAuth endpoints, when a test wants the connect flows. */
219219 oauth?: FakeOAuth;
220 /**
221 * GitHub's `X-OAuth-Scopes` on every answer, as a classic token sends it;
222 * unset, the fake is a fine-grained token, which names none.
223 */
224 scopes?: string;
220225 }
221226
222227 export interface FakeForge {
@@ -392,16 +397,28 @@ function route(
392397 return jsonResponse({ max_response_items: state.maxResponseItems });
393398
394399 if (rest === "/user")
395 return jsonResponse({
396 id: 7,
397 login: state.login,
398 full_name: state.login,
399 name: state.login,
400 html_url: `${url.origin}/${state.login}`,
401 });
400 return jsonResponse(
401 {
402 id: 7,
403 login: state.login,
404 full_name: state.login,
405 name: state.login,
406 html_url: `${url.origin}/${state.login}`,
407 },
408 200,
409 state.scopes === undefined ? {} : { "x-oauth-scopes": state.scopes },
410 );
402411
403412 if (rest === "/user/repos" || rest === "/repos/search") {
404 const rows = state.repos.map((r) => repoJson(state, r));
413 const visibility = q.get("visibility");
414 const rows = state.repos
415 .filter(
416 (r) =>
417 visibility === null ||
418 visibility === "all" ||
419 (visibility === "private") === (r.private ?? false),
420 )
421 .map((r) => repoJson(state, r));
405422 const payload = rest === "/repos/search" ? { ok: true, data: rows } : rows;
406423 return jsonResponse(payload, 200, { "x-total-count": String(rows.length) });
407424 }
test/migrate-token-reach.test.ts added +329
@@ -0,0 +1,329 @@
1 /**
2 * Migrate says when a GitHub token sees public repositories only (#7516).
3 * The owner connected a classic token without `repo`, saw 62 public rows
4 * and no private one, and thought the forge was broken. Here a fake GitHub
5 * answers with the scopes a classic token sends (or none, as a fine-grained
6 * one does): the card names the limit, links GitHub's new-token page, and
7 * takes the new token in place — same origin, rows listed again. The
8 * picker's filter narrows as you type, and has a button.
9 */
10
11 import { afterAll, beforeAll, expect, test } from "bun:test";
12 import { mkdtemp, rm } from "node:fs/promises";
13 import { tmpdir } from "node:os";
14 import { join } from "node:path";
15 import { createApp, type App } from "../src/app.ts";
16 import { connectOrigin, listRemoteRepos } from "../src/actions/origins.ts";
17 import { listOrigins } from "../src/core/origins.ts";
18 import { createSession } from "../src/core/sessions.ts";
19 import { createUser, type User } from "../src/core/users.ts";
20 import { serve, type Servers } from "../src/http/server.ts";
21 import { GitHubProvider } from "../src/origins/providers/github.ts";
22 import { forgetProviders } from "../src/origins/providers/index.ts";
23 import { PUBLIC_ONLY, newTokenUrl } from "../src/origins/token-reach.ts";
24 import { html } from "../src/web/html.ts";
25 import { staticCredential } from "../src/origins/types.ts";
26 import { migrateJs } from "../src/web/pages/migrate.ts";
27 import { createForgeApp } from "../src/tui/forge.ts";
28 import { headlessTerminal } from "../src/tui/term/terminal.ts";
29 import { settleApp } from "./helpers/tui.ts";
30 import { newFakeState, serveFakeForge } from "./fake-origin.ts";
31 import { quietLogs } from "./helpers/globals.ts";
32 import { promote } from "./helpers/members.ts";
33
34 quietLogs();
35
36 let dataDir: string;
37 let app: App;
38 let servers: Servers;
39 let base: string;
40 let alice: User;
41 let bob: User;
42 let carol: User;
43 let cookie: string;
44 let bobCookie: string;
45 /** The line as the page draws it: escaped like every other text. */
46 const publicOnly = html`${PUBLIC_ONLY}`.text;
47
48 beforeAll(async () => {
49 dataDir = await mkdtemp(join(tmpdir(), "forge-reach-"));
50 app = createApp({ dataDir, port: 0 });
51 servers = await serve(app);
52 base = `http://127.0.0.1:${servers.port}`;
53 const person = async (username: string, adminIfFirst = false) => {
54 const made = await createUser(app.db, {
55 origin: "seed",
56 trusted: true,
57 username,
58 email: `${username}@forge.test`,
59 adminIfFirst,
60 });
61 if (!made.ok) throw new Error(String(made.error));
62 promote(app.db, made.value);
63 return made.value;
64 };
65 alice = await person("alice", true);
66 bob = await person("bob");
67 carol = await person("carol");
68 cookie = `forge_session=${createSession(app.db, alice.id).token}`;
69 bobCookie = `forge_session=${createSession(app.db, bob.id).token}`;
70 });
71
72 afterAll(async () => {
73 servers.stop();
74 forgetProviders(dataDir);
75 await rm(dataDir, { recursive: true, force: true });
76 });
77
78 const get = async (path: string): Promise<string> =>
79 (await fetch(`${base}${path}`, { headers: { cookie } })).text();
80
81 const post = (
82 path: string,
83 form: Record<string, string>,
84 as = cookie,
85 ): Promise<Response> =>
86 fetch(`${base}${path}`, {
87 method: "POST",
88 headers: { cookie: as, origin: new URL(base).origin },
89 body: new URLSearchParams(form),
90 redirect: "manual",
91 });
92
93 /** The page a form's 303 lands on, with the flash cookie it set. */
94 async function landing(answer: Response, as = cookie): Promise<string> {
95 expect(answer.status).toBe(303);
96 const flash = answer.headers
97 .getSetCookie()
98 .map((c) => c.split(";")[0])
99 .join("; ");
100 const to = answer.headers.get("location")!;
101 return (
102 await fetch(`${base}${to}`, { headers: { cookie: `${as}; ${flash}` } })
103 ).text();
104 }
105
106 const repo = (id: number, fullName: string, isPrivate = false) => ({
107 id,
108 fullName,
109 description: `about ${fullName}`,
110 cloneUrl: `https://example.invalid/${fullName}.git`,
111 private: isPrivate,
112 });
113
114 test("a classic token without `repo` says public only and links the new-token page; one with `repo` does not", async () => {
115 const state = newFakeState("github", "trevato", "public-only-token");
116 state.scopes = "public_repo, read:org";
outdated

This proves a redirect, not a refusal. Assert the danger flash, and that the public-only line is still on /migrate. Also add a case for other_account and one for a second user posting to alice's origin id (not_found).

117 state.repos.push(repo(1, "trevato/ESPO"), repo(2, "trevato/garden"));
118 const github = serveFakeForge(state);
119 try {
120 const connected = await connectOrigin(app, alice, {
121 host: github.url,
122 kind: "github",
123 token: "public-only-token",
124 });
125 if (!connected.ok) throw new Error(connected.error);
126 const originId = connected.value.id;
127
128 const page = await get("/migrate");
129 expect(page).toContain(publicOnly);
130 expect(page).toContain('data-reach="public"');
131 expect(page).toContain(
132 `href="${newTokenUrl(github.url).replaceAll("&", "&amp;")}"`,
133 );
134 expect(page).toContain("/settings/tokens/new?scopes=repo&amp;description=forge-migrate");
135 expect(page).toContain("Repository access: All repositories; Contents: Read");
136 expect(page).toContain(`action="/settings/origins/${originId}/token"`);
137 expect(page).not.toContain("trevato/secret");
138
139 // A wrong token is refused in words, and the origin stays as it was.
140 const refused = await post(`/settings/origins/${originId}/token`, {
141 token: "not-a-token",
142 then: "migrate",
143 });
144 expect(refused.headers.get("location")).toStartWith("/migrate");
145 const refusedPage = await landing(refused);
146 expect(refusedPage).toContain("That token was refused by the origin.");
147 expect(refusedPage).toContain(publicOnly);
148
149 // A token for another account is refused; the stored one still lists.
150 state.login = "someone-else";
151 state.token = "other-account-token";
152 const other = await landing(
153 await post(`/settings/origins/${originId}/token`, {
154 token: "other-account-token",
155 then: "migrate",
156 }),
157 );
158 expect(other).toContain("That token belongs to another account.");
159 state.login = "trevato";
160 state.token = "public-only-token";
161 const still = await listRemoteRepos(app, alice, originId, true);
162 expect(still.ok && still.value.map((r) => r.fullName).sort()).toEqual([
163 "trevato/ESPO",
164 "trevato/garden",
165 ]);
166
167 // Somebody else cannot name alice's origin at all.
168 const stranger = await landing(
169 await post(
170 `/settings/origins/${originId}/token`,
171 { token: "public-only-token", then: "migrate" },
172 bobCookie,
173 ),
174 bobCookie,
175 );
176 expect(stranger).toContain("No such origin.");
177 expect((await listRemoteRepos(app, alice, originId, true)).ok).toBe(true);
178 expect(listOrigins(app.db, bob.id)).toEqual([]);
179
180 // The new token sees private repositories: the same origin, re-listed.
181 state.token = "repo-token";
182 state.scopes = "repo, read:org";
183 state.repos.push(repo(3, "trevato/secret", true));
184 const replaced = await post(`/settings/origins/${originId}/token`, {
185 token: "repo-token",
186 then: "migrate",
187 });
188 expect(replaced.status).toBe(303);
189 expect(replaced.headers.get("location")).toStartWith("/migrate");
190 const origins = listOrigins(app.db, alice.id);
191 expect(origins.map((o) => o.id)).toEqual([originId]);
192
193 const after = await get("/migrate");
194 expect(after).not.toContain(publicOnly);
195 expect(after).not.toContain("data-reach");
196 expect(after).toContain("trevato/secret");
197 expect(after).toContain("3 repositories");
198 } finally {
199 github.stop();
200 }
201 });
202
203 test("a fine-grained token names no scopes: one private row is the answer", async () => {
204 const state = newFakeState("github", "mona", "fine-grained");
205 state.repos.push(repo(1, "mona/open"));
206 const github = serveFakeForge(state);
207 try {
208 const provider = new GitHubProvider(
209 staticCredential({
210 kind: "github",
211 apiUrl: github.url,
212 webUrl: github.url,
213 token: "fine-grained",
214 }),
215 );
216 expect(await provider.tokenReach()).toEqual({ ok: true, value: "public" });
217 state.repos.push(repo(2, "mona/closed", true));
218 expect(await provider.tokenReach()).toEqual({ ok: true, value: "private" });
219 } finally {
220 github.stop();
221 }
222 });
223
224 test("the filter: a visible button submits the search form; Enter's answer is the server's filter", async () => {
225 const page = await get("/migrate");
226 expect(page).toMatch(
227 /<button[^>]*type="submit"[^>]*form="migrate-search"[^>]*>[\s\S]*?Filter\s*<\/button>|<button[^>]*form="migrate-search"[^>]*type="submit"[^>]*>[\s\S]*?Filter\s*<\/button>/,
228 );
229 expect(page).toContain('<form method="get" action="/migrate" id="migrate-search">');
230 const filtered = await get("/migrate?q=ESPO");
231 expect(filtered).toContain("trevato/ESPO");
232 expect(filtered).not.toContain('<span class="mono">trevato/garden</span>');
233 });
234
235 /** Just enough DOM for `migrateJs`: the filter field and the picker's rows. */
236 function fakeDom(names: readonly string[]) {
237 const listeners: Record<string, (() => void)[]> = {};
238 const q = {
239 value: "",
240 addEventListener: (kind: string, fn: () => void) => {
241 (listeners[kind] ??= []).push(fn);
242 },
243 };
244 const rows = names.map((name) => ({
245 name,
246 hidden: false,
247 getAttribute: (attr: string) =>
248 attr === "data-q" ? `${name} about ${name}`.toLowerCase() : null,
249 }));
250 const document = {
251 getElementById: (id: string) => (id === "f-q" ? q : null),
252 querySelectorAll: (selector: string) =>
253 selector === "[data-pick-rows] [data-q]" ? rows : [],
254 };
255 const type = (text: string) => {
256 q.value = text;
257 for (const fn of listeners.input ?? []) fn();
258 };
259 return { document, rows, type };
260 }
261
262 test("typing ESPO hides the other rows, and clearing it shows them again", () => {
263 const dom = fakeDom(["trevato/ESPO", "trevato/garden", "trevato/espo-docs"]);
264 new Function("document", migrateJs)(dom.document);
265 dom.type("ESPO");
266 expect(dom.rows.filter((r) => !r.hidden).map((r) => r.name)).toEqual([
267 "trevato/ESPO",
268 "trevato/espo-docs",
269 ]);
270 dom.type("");
271 expect(dom.rows.every((r) => !r.hidden)).toBe(true);
272 });
273
274 test("the terminal's migrate screen says public only, names the new-token page, and t replaces the token in place", async () => {
275 const state = newFakeState("github", "carol-gh", "carol-public");
276 state.scopes = "public_repo";
277 state.repos.push(repo(1, "carol-gh/open"));
278 const github = serveFakeForge(state);
279 const terminal = headlessTerminal(140, 40);
280 const { app: tui } = createForgeApp(
281 app,
282 {
283 sshUsername: carol.username,
284 remoteAddress: "127.0.0.1",
285 fingerprint: "SHA256:test",
286 user: carol,
287 },
288 { terminal, exitHooks: false },
289 );
290 try {
291 const connected = await connectOrigin(app, carol, {
292 host: github.url,
293 kind: "github",
294 token: "carol-public",
295 });
296 if (!connected.ok) throw new Error(connected.error);
297 tui.start();
298 await settleApp(tui);
299 terminal.feed("M");
300 await settleApp(tui);
301 const screen = tui.screen.toText();
302 expect(screen).toContain("carol-gh/open");
303 expect(screen).toContain(PUBLIC_ONLY);
304 expect(screen).toContain("/settings/tokens/new?scopes=repo&description=forge-migrate");
305
306 state.token = "carol-repo";
307 state.scopes = "repo";
308 state.repos.push(repo(2, "carol-gh/closed", true));
309 terminal.feed("t");
310 await settleApp(tui);
311 expect(tui.screen.toText()).toContain("Replace the token for carol-gh");
312 terminal.feed("carol-repo");
313 terminal.feed("\r");
314 const end = Date.now() + 10_000;
315 while (!tui.screen.toText().includes("carol-gh/closed") && Date.now() < end) {
316 await settleApp(tui);
317 await Bun.sleep(25);
318 }
319 const after = tui.screen.toText();
320 expect(after).toContain("carol-gh/closed");
321 expect(after).not.toContain(PUBLIC_ONLY);
322 expect(listOrigins(app.db, carol.id).map((o) => o.id)).toEqual([
323 connected.value.id,
324 ]);
325 } finally {
326 tui.stop?.();
327 github.stop();
328 }
329 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs