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

Connecting a friend's forge is obvious and works with forges on an older release #9390

Merged opened this in answer to

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

Merged

torque/forge:torque/9388-connecting-a-friend-s-forge-is-obvious-a → main 3 commits fork 1 thread · 1 unresolved

  • Merged. torque/9388-connecting-a-friend-s-forge-is-obvious-a was merged into main as e8399e0.

Review

Sign in to review this change.
16 files changed +699 −96
docs/features/forge-origin-older.md added +25
@@ -0,0 +1,25 @@
1 # Connecting a friend's forge, whatever release it runs (#9388)
2
3 `/settings/origins` leads with **Another forge**: its address and a token made on it (`<their forge>/settings/tokens`, read is enough; private repositories come with it), or **Or sign in there** for one click.
4
5 `/bring` with a link to a forge you have not connected shows **Connect that forge first** on the same page: paste a token (or sign in there), and the app starts coming across at once. The link is kept through the sign-in (`then=bring&link=…`, the link checked as a short http(s) URL, the return always a path on this forge).
6
7 ## Older forges
8
9 A forge from before `/api/v1/version` (0.5.x) is recognised by two answers no other software gives together: `/api/v1/health` (`ok`, a dotted `version`, a `commit`) and `/api/v1/here` (`here`, `actions`). It is stored like any forge (`software = forge`). Without `/api/v1/user/repos`, its repositories are listed from `GET /api/v1/repos` with the token, keeping the account's own, private ones included (`src/origins/providers/forge.ts`, at most 10 pages per call). Test: `test/forge-origin-older.test.ts` runs a fixture of such a forge's answers.
10
11 Not checked: the live forge-apps.benjivers.com — the build sandbox reaches only this forge, so the old-forge path is proven against the fixture, not against that host.
12
13 The fixture is written from this repository's own routes, not captured from a real 0.5.x forge, so what a 0.5.x forge actually answers is unverified. There is no "needs a newer release" error either: a forge that matches neither fingerprint is refused as unrecognised.
14
15 ## Tokens and plain http
16
17 A token is sent to the forge named by the link, so `http:` links are refused for it (page and terminal) unless `FORGE_DEV=1`; the one-click sign-in still works. `connectSite` in `src/origins/bring.ts` decides.
18
19 ## In the terminal
20
21 Bring an app (palette) with a link to an unconnected forge asks for its token, masked, connects, and carries on with the same link (`src/tui/screens/bring.ts`). Connected origins (`c`) takes an address and a token as before. Both are for a person's own account, never an agent's.
22
23 ## One accent
24
25 Another forge's Connect is the only primary button on the origins page; the other paths' buttons are plain. On `/bring`, the link form is plain while the connect card is showing, and external links use the shared button component.
src/http/server.ts +2 −1
@@ -423,7 +423,7 @@ import {
423423 browseRaw,
424424 browseTree,
425425 } from "../web/pages/browse.ts";
426 import { bringEnvSubmit, bringJobPage, bringPage, bringRun, bringSubmit } from "../web/pages/bring.ts";
426 import { bringConnect, bringEnvSubmit, bringJobPage, bringPage, bringRun, bringSubmit } from "../web/pages/bring.ts";
427427 import { pushThrough } from "../origins/push-through.ts";
428428 import { CAME_FROM_ELSEWHERE, forbiddenPage, notFoundPage, SENT_FROM_ELSEWHERE } from "../web/chrome.ts";
429429 import { targetForShortCode } from "../origins/oauth.ts";
@@ -784,6 +784,7 @@ export function buildRouter(): Router<Handler> {
784784 router.add("GET", "/new", newRepoPage);
785785 router.add("GET", "/bring", bringPage);
786786 router.add("POST", "/bring", bringSubmit);
787 router.add("POST", "/bring/connect", bringConnect);
787788 router.add("GET", "/bring/:id/:job", bringJobPage);
788789 router.add("POST", "/bring/:id/:job/run", bringRun);
789790 router.add("POST", "/bring/:id/:job/env", bringEnvSubmit);
src/origins/bring.ts +21
@@ -69,3 +69,24 @@ export function needsOf(example: string): string[] {
6969 }
7070 return names;
7171 }
72
73 /** Words for a token that would travel in clear. */
74 export const PLAIN_HTTP_WORDS =
75 "That link is plain http, and a token sent there travels in clear. Use the https link.";
76
77 /**
78 * The address to connect for a link: `https:` unless the link itself says
79 * `http:`, and then only on a development instance (`FORGE_DEV=1`), because a
80 * token is sent to it.
81 */
82 export function connectSite(
83 link: string,
84 host: string,
85 env: Record<string, string | undefined> = process.env,
86 ): { ok: true; value: string } | { ok: false; error: "plain_http" } {
87 if (/^http:\/\//i.test(link.trim())) {
88 if (env.FORGE_DEV !== "1") return { ok: false, error: "plain_http" };
89 return { ok: true, value: `http://${host}` };
90 }
91 return { ok: true, value: `https://${host}` };
92 }
src/origins/discover.ts +41
@@ -227,6 +227,10 @@ export async function discover(
227227 const version = await viaVersionEndpoint(web, probe);
228228 if (version) return ok(version);
229229
230 // A forge from before `/api/v1/version` still answers health and here.
231 const older = await viaOlderForge(web, probe);
232 if (older) return ok(older);
233
230234 return err(probe.refused ? "private_host" : "unreachable");
231235 }
232236
@@ -258,6 +262,43 @@ async function viaVersionEndpoint(
258262 };
259263 }
260264
265 /**
266 * A forge too old to answer `/api/v1/version` (0.5.x). It is known by two
267 * answers no other software gives together: `/api/v1/health` with a boolean
268 * `ok`, a dotted `version` and a `commit`; and `/api/v1/here` with the
269 * manifest's `here` and `actions`. Its repositories are then listed through
270 * `GET /api/v1/repos` (`ForgeProvider.listRepos`).
271 */
272 async function viaOlderForge(
273 web: string,
274 probe: Probe,
275 ): Promise<Discovered | undefined> {
276 const health = await getJson(`${web}/api/v1/health`, web, probe);
277 if (!health.ok) return undefined;
278 const version = asString(field(health.value, "version"));
279 if (
280 typeof field(health.value, "ok") !== "boolean" ||
281 !/^\d+\.\d+\.\d+[0-9A-Za-z.+-]{0,40}$/.test(version) ||
282 asString(field(health.value, "commit")) === ""
283 )
284 return undefined;
285 const here = await getJson(`${web}/api/v1/here`, web, probe);
286 if (!here.ok) return undefined;
287 if (
288 typeof field(here.value, "here") !== "object" ||
289 field(here.value, "here") === null ||
290 !Array.isArray(field(here.value, "actions"))
291 )
292 return undefined;
293 return {
294 kind: "gitea",
295 apiUrl: `${web}/api/v1`,
296 webUrl: web,
297 via: "health",
298 software: `forge ${version}`,
299 };
300 }
301
261302 async function viaNodeInfo(
262303 web: string,
263304 probe: Probe,
src/origins/providers/forge.ts +51 −13
@@ -28,6 +28,7 @@ import { forgeCursor, toForgeComment, toForgeWork } from "./forge-work.ts";
2828 import { asArray, asId, asString, asTime, dig, fail, field, seg, splitFullName, succeed } from "./http.ts";
2929
3030 const MAX_COMMENT_PAGES = 1000;
31 const OLDER_PAGES = 10;
3132 const NONE = { ok: true, value: [] } as const;
3233
3334 export class ForgeProvider extends ForgejoProvider {
@@ -51,6 +52,8 @@ export class ForgeProvider extends ForgejoProvider {
5152 const page = Math.max(1, Number(cursor) || 1);
5253 const limit = await this.pageSize();
5354 const result = await this.api.call({ path: "/user/repos", query: { limit, page } });
55 // A forge from before `/user/repos` (0.5.x) has no such route.
56 if (!result.ok && result.error.error === "not_found") return this.listReposOlder(cursor, limit);
5457 if (!result.ok) return result;
5558 const rows = asArray(result.value.data);
5659 return succeed({
@@ -59,19 +62,43 @@ export class ForgeProvider extends ForgejoProvider {
5962 });
6063 }
6164
62 /** One repository, from a forge's own JSON (`ownerName`, `visibility`). */
63 override async getRepo(fullName: string): Promise<ProviderResult<RemoteRepo>> {
64 const named = splitFullName(fullName);
65 if (!named) return fail("not_found", `not a repository name: ${fullName}`);
66 const result = await this.api.call({
67 path: `/repos/${seg(named.owner)}/${seg(named.name)}`,
68 });
69 if (!result.ok) return result;
70 const raw = result.value.data;
71 const owner = asString(field(raw, "ownerName")) || named.owner;
72 const name = asString(field(raw, "name")) || named.name;
65 /**
66 * An old forge's list: `GET /repos` pages by cursor and shows everything the
67 * token may see, so the account's own repositories are picked out of it. A
68 * cursor here is the forge's `next_cursor`, not a page number. At most
69 * `OLDER_PAGES` pages are read for one call, so a huge forge costs a bounded
70 * walk; the rest is one "next" away.
71 */
72 private async listReposOlder(
73 cursor: string | undefined,
74 limit: number,
75 ): Promise<ProviderResult<Page<RemoteRepo>>> {
76 const me = await this.whoami();
77 if (!me.ok) return me;
78 const own: RemoteRepo[] = [];
79 let after = cursor !== undefined && /^[A-Za-z0-9_-]{1,64}$/.test(cursor) ? cursor : undefined;
80 for (let walked = 0; walked < OLDER_PAGES; walked++) {
81 const result = await this.api.call({
82 path: "/repos",
83 query: { limit: 100, ...(after === undefined ? {} : { cursor: after }) },
84 });
85 if (!result.ok) return result;
86 for (const raw of asArray(field(result.value.data, "items")))
87 if (asString(field(raw, "ownerName")).toLowerCase() === me.value.login.toLowerCase())
88 own.push(this.repoFrom(raw, undefined));
89 const next = field(result.value.data, "next_cursor");
90 after = typeof next === "string" || typeof next === "number" ? String(next) : undefined;
91 if (after === undefined || own.length >= limit) break;
92 }
93 return succeed({ items: own, next: after ?? null });
94 }
95
96 /** A repository from a forge's own JSON (`ownerName`, `visibility`). */
97 private repoFrom(raw: unknown, named: { owner: string; name: string } | undefined): RemoteRepo {
98 const owner = asString(field(raw, "ownerName")) || named?.owner || "";
99 const name = asString(field(raw, "name")) || named?.name || "";
73100 const web = `${this.webUrl}/${owner}/${name}`;
74 return succeed(
101 return (
75102 toRepo({
76103 id: field(raw, "id"),
77104 full_name: `${owner}/${name}`,
@@ -87,10 +114,21 @@ export class ForgeProvider extends ForgejoProvider {
87114 // "cannot push": the direct way is tried, and its refusal falls
88115 // back to the account's own fork.
89116 permissions: { push: true, admin: false },
90 }),
117 })
91118 );
92119 }
93120
121 /** One repository, from a forge's own JSON. */
122 override async getRepo(fullName: string): Promise<ProviderResult<RemoteRepo>> {
123 const named = splitFullName(fullName);
124 if (!named) return fail("not_found", `not a repository name: ${fullName}`);
125 const result = await this.api.call({
126 path: `/repos/${seg(named.owner)}/${seg(named.name)}`,
127 });
128 if (!result.ok) return result;
129 return succeed(this.repoFrom(result.value.data, named));
130 }
131
94132 override listLabels(): Promise<ProviderResult<readonly RemoteLabel[]>> {
95133 return Promise.resolve(NONE);
96134 }
src/origins/types.ts +1 −1
@@ -575,6 +575,6 @@ export interface Discovered {
575575 readonly apiUrl: string;
576576 readonly webUrl: string;
577577 /** How we knew: the evidence to show the user. */
578 readonly via: "known" | "nodeinfo" | "version-endpoint";
578 readonly via: "known" | "nodeinfo" | "version-endpoint" | "health";
579579 readonly software: string;
580580 }
src/tui/screens/bring.ts +53 −15
@@ -6,10 +6,13 @@
66 */
77
88 import { bringLanded, setBroughtEnv, startBring, type BringError, type Brought } from "../../actions/bring.ts";
9 import { connectOrigin, connectRefusal } from "../../actions/origins.ts";
10 import { connectWords } from "../../actions/origin_words.ts";
11 import { connectSite, PLAIN_HTTP_WORDS } from "../../origins/bring.ts";
912 import type { MirrorJob } from "../../origins/jobs.ts";
1013 import type { Ctx, Screen } from "../app/app.ts";
1114 import type { Session } from "../session.ts";
12 import { promptScreen, runAsync } from "./common.ts";
15 import { later, promptScreen, runAsync } from "./common.ts";
1316 import { showMirrorJob } from "./mirror.ts";
1417
1518 /** The words for a refusal, as the page says them. */
@@ -60,24 +63,59 @@ function land(f: Session, ctx: Ctx, parent: Screen, job: MirrorJob): void {
6063 });
6164 }
6265
66 /** Start the bring for a link and show its job; the answer in words when it cannot start. */
67 function begin(f: Session, ctx: Ctx, parent: Screen, link: string): string | Promise<void> | undefined {
68 const started = startBring(f.app, f.user, link);
69 if (!started.ok) return started.error.kind === "connect" ? connectFirst(f, ctx, parent, link, started.error.host) : bringWords(started.error);
70 const { job, originId } = started.value;
71 return Promise.resolve().then(() =>
72 showMirrorJob(f, ctx, job, {
73 parent,
74 originId,
75 fullName: job.fullName,
76 onLanded: () => land(f, ctx, parent, job),
77 }),
78 );
79 }
80
81 /**
82 * The link names a forge no account here is connected to: ask for a token made
83 * on it (masked), connect, and carry on with the same link. Plain http is
84 * refused, as the page does, unless this is a development forge.
85 */
86 function connectFirst(f: Session, ctx: Ctx, parent: Screen, link: string, host: string): string | Promise<void> {
87 const site = connectSite(link, host);
88 if (!site.ok) return PLAIN_HTTP_WORDS;
89 return later(ctx, () =>
90 promptScreen(ctx, parent, {
91 title: `Connect ${host} first`,
92 label: `Token for ${host}`,
93 hint: `Made at ${site.value}/settings/tokens; read is enough. Kept encrypted here.`,
94 mask: true,
95 onSubmit: (token) => {
96 if (token.trim() === "") return "Paste the token.";
97 ctx.toast(`Connecting ${host}…`);
98 return connectOrigin(f.app, f.user!, { host: site.value, token }).then((r) => {
99 if (!r.ok) {
100 const refused = connectRefusal(r.error);
101 ctx.toast(refused.message ?? connectWords(refused.code), "danger");
102 return;
103 }
104 ctx.toast(`Connected ${r.value.login} at ${host}.`, "success");
105 const next = begin(f, ctx, parent, link);
106 if (typeof next === "string") ctx.toast(next, "danger");
107 });
108 },
109 }),
110 );
111 }
112
63113 export function bringScreen(f: Session, ctx: Ctx, parent: Screen): Screen {
64114 return promptScreen(ctx, parent, {
65115 title: "Bring an app",
66116 label: "Link to the app",
67117 placeholder: "https://codeberg.org/ana/notes",
68 hint: "On a forge you have connected. Its data and secrets stay behind.",
69 onSubmit: (link) => {
70 const started = startBring(f.app, f.user, link);
71 if (!started.ok) return bringWords(started.error);
72 const { job, originId } = started.value;
73 return Promise.resolve().then(() =>
74 showMirrorJob(f, ctx, job, {
75 parent,
76 originId,
77 fullName: job.fullName,
78 onLanded: () => land(f, ctx, parent, job),
79 }),
80 );
81 },
118 hint: "On another forge. If you have not connected it, you are asked for a token. Its data and secrets stay behind.",
119 onSubmit: (link) => begin(f, ctx, parent, link),
82120 });
83121 }
src/web/pages/bring.ts +81 −14
@@ -9,21 +9,68 @@
99
1010 import { bringLanded, bringLook, setBroughtEnv, startBring, type BringError } from "../../actions/bring.ts";
1111 import { notePhase, takeSessionTime } from "../../core/route_timing.ts";
12 import { mirrorJobFor } from "../../actions/origins.ts";
12 import { connectOrigin, connectRefusal, mirrorJobFor } from "../../actions/origins.ts";
13 import { connectWords } from "../../actions/origin_words.ts";
14 import { connectSite, parseBringLink, PLAIN_HTTP_WORDS } from "../../origins/bring.ts";
1315 import { notFoundPage } from "../chrome.ts";
1416 import { readForm } from "../form.ts";
1517 import type { Flash } from "../flash.ts";
16 import { html } from "../html.ts";
18 import { html, type Html } from "../html.ts";
1719 import { page, redirect } from "../layout.ts";
1820 import { mirrorJs } from "../client.ts";
1921 import { btn, card, field, icon } from "../ui.ts";
2022 import type { Ctx } from "../../http/context.ts";
21 import { accountOnly } from "./origins.ts";
23 import { accountOnly, hostLabel } from "./origins.ts";
2224 import { stageList } from "./browse.ts";
2325
2426 const NOT_COPIED = ["data", "variables", "secrets"];
2527
26 function form(ctx: Ctx, value: string, problem?: Flash, status = 200): Response {
28 /** The card's address: `http:` only when the link says so (the card then refuses a token unless this is a development forge). */
29 const cardSite = (host: string, link: string): string => `${/^http:\/\//i.test(link.trim()) ? "http:" : "https:"}//${host}`;
30
31 /**
32 * The link names a forge no account here is connected to: connect it in
33 * place. The link is kept in the form, the token is asked for where the
34 * friend's forge makes one, and the one click works too when it can.
35 */
36 function connectCard(host: string, link: string, error?: string): Html {
37 const site = cardSite(host, link);
38 return card(
39 html`<p>
40 This app is on <strong>${hostLabel(site)}</strong>, and you have not
41 connected your account there yet. Do it here: your link is kept.
42 </p>
43 <form method="post" action="/bring/connect" class="stack" id="bring-connect">
44 <input type="hidden" name="link" value="${link}" />
45 <p class="small">
46 Make a token on their forge:
47 ${btn(`${hostLabel(site)}/settings/tokens`, { href: `${site}/settings/tokens`, external: true, variant: "link" })}
48 </p>
49 ${field({
50 label: `Your token on ${hostLabel(site)}`,
51 name: "token",
52 type: "password",
53 required: true,
54 mono: true,
55 autocomplete: "off",
56 spellcheck: false,
57 hint: "Read is enough to bring an app; private repositories come with it. It is never shown again.",
58 ...(error === undefined ? {} : { error }),
59 })}
60 <div class="form-actions">${btn("Connect and bring it", { variant: "primary", icon: "download" })}</div>
61 </form>
62 <form method="post" action="/settings/origins/oauth" class="form-actions">
63 <input type="hidden" name="host" value="${site}" />
64 <input type="hidden" name="then" value="bring" />
65 <input type="hidden" name="link" value="${link}" />
66 ${btn("Sign in there instead", { icon: "external" })}
67 <span class="muted small">One click, no token to copy.</span>
68 </form>`,
69 { id: "bring-connect-card", head: html`${icon("globe")}<h2>Connect ${hostLabel(site)} first</h2>` },
70 );
71 }
72
73 function form(ctx: Ctx, value: string, problem?: Flash, status = 200, connect?: Html): Response {
2774 return page(
2875 {
2976 ctx,
@@ -50,10 +97,11 @@ function form(ctx: Ctx, value: string, problem?: Flash, status = 200): Response
5097 placeholder: "https://codeberg.org/ana/notes",
5198 spellcheck: false,
5299 })}
53 <div class="bar">${btn("Bring it here", { variant: "primary", icon: "download" })}</div>
100 <div class="bar">${btn("Bring it here", { ...(connect === undefined ? { variant: "primary" as const } : {}), icon: "download" })}</div>
54101 </form>`,
55102 { head: html`${icon("download")}<h2>Bring it here</h2>` },
56103 )}
104 ${connect ?? null}
57105 </div>`,
58106 },
59107 status,
@@ -69,15 +117,7 @@ const danger = (message: string, link?: Flash["link"]): Flash => ({
69117 function refusal(ctx: Ctx, link: string, e: BringError): Response {
70118 switch (e.kind) {
71119 case "connect":
72 return form(
73 ctx,
74 link,
75 danger(`Sign in to ${e.host} first, then paste the link again.`, {
76 href: "/settings/origins",
77 label: "connect your account there",
78 }),
79 400,
80 );
120 return form(ctx, link, undefined, 200, connectCard(e.host, link));
81121 case "invalid":
82122 return form(ctx, link, danger(e.message), 400);
83123 case "unverified":
@@ -111,6 +151,33 @@ export async function bringSubmit(ctx: Ctx): Promise<Response> {
111151 return redirect(`/bring/${started.value.originId}/${started.value.job.id}`);
112152 }
113153
154 /** POST /bring/connect: a token for the forge the link names, then straight on with the bring. */
155 export async function bringConnect(ctx: Ctx): Promise<Response> {
156 const denied = accountOnly(ctx);
157 if (denied) return denied;
158 const data = await readForm(ctx, "admin");
159 if (data instanceof Response) return data;
160 const raw = data.get("link");
161 const link = (typeof raw === "string" ? raw : "").slice(0, 300);
162 const token = data.get("token");
163 const parsed = parseBringLink(link);
164 if (!parsed.ok) return form(ctx, link, danger("That is not a link to a repository (https://host/owner/name)."), 400);
165 const site = connectSite(link, parsed.value.host);
166 if (!site.ok)
167 return form(ctx, link, undefined, 400, connectCard(parsed.value.host, link, PLAIN_HTTP_WORDS));
168 const connected = await connectOrigin(ctx.app, ctx.auth.user, {
169 host: site.value,
170 token: typeof token === "string" ? token : "",
171 });
172 if (!connected.ok) {
173 const refused = connectRefusal(connected.error);
outdated

Token goes to an http:// host if the link says so. Refuse it, or require the dev flag.

174 return form(ctx, link, undefined, 422, connectCard(parsed.value.host, link, refused.message ?? connectWords(refused.code)));
175 }
176 const started = startBring(ctx.app, ctx.auth.user, link);
177 if (!started.ok) return refusal(ctx, link, started.error);
178 return redirect(`/bring/${started.value.originId}/${started.value.job.id}`);
179 }
180
114181 /** GET /bring/:id/:job */
115182 export async function bringJobPage(ctx: Ctx): Promise<Response> {
116183 const denied = accountOnly(ctx);
src/web/pages/origin-flow.ts +15 −11
@@ -23,13 +23,13 @@ import {
2323 hostLabel,
2424 notFoundNote,
2525 shell,
26 linkOf,
2627 thenOf,
28 thenQuery,
29 thenTarget,
2730 type Then,
2831 } from "./origins.ts";
2932
30 const thenQuery = (then: Then): string =>
31 then === undefined ? "" : "then=migrate";
32
3333 /**
3434 * GET /settings/origins/flows/:id — a connect in progress.
3535 *
@@ -44,9 +44,9 @@ export function originFlowPage(ctx: Ctx): Response {
4444 const flow = getFlowFor(ctx.app.db, ctx.auth.user!.id, ctx.params.id ?? "");
4545 if (!flow) return shell(ctx, notFoundNote(), 404);
4646 const then = thenOf(ctx.url.searchParams.get("then"));
47 const link = linkOf(ctx.url.searchParams.get("link"));
4748
48 if (flow.status === "done")
49 return redirect(`${then ?? "/settings/origins"}?connected=1`);
49 if (flow.status === "done") return redirect(thenTarget(then, link));
5050 if (flow.status === "failed")
5151 return redirect(
5252 `/settings/origins?error=${encodeURIComponent(flow.detail.slice(0, 300))}`,
@@ -54,9 +54,9 @@ export function originFlowPage(ctx: Ctx): Response {
5454
5555 // A pending flow past its time leads nowhere: not to a consent screen
5656 // for a state that will be refused, nor to a code that no longer works.
57 if (flowExpired(flow)) return expiredFlow(ctx, flow, then);
57 if (flowExpired(flow)) return expiredFlow(ctx, flow, then, link);
5858
59 const pollAction = `/settings/origins/flows/${flow.id}/poll${then === undefined ? "" : `?${thenQuery(then)}`}`;
59 const pollAction = `/settings/origins/flows/${flow.id}/poll${then === undefined ? "" : `?${thenQuery(then, link)}`}`;
6060 const authorize = flow.flow === "code" ? authorizeFor(ctx, flow) : undefined;
6161 const base = forgeBaseUrl(ctx.url, ctx.app.config.host);
6262 const shortLink =
@@ -248,6 +248,7 @@ function expiredFlow(
248248 ctx: Ctx,
249249 flow: { kind: string; webUrl: string },
250250 then: Then,
251 link: string | undefined,
251252 ): Response {
252253 return page({
253254 ctx,
@@ -265,7 +266,10 @@ function expiredFlow(
265266 : null}
266267 ${then === undefined
267268 ? null
268 : html`<input type="hidden" name="then" value="migrate" />`}
269 : html`<input type="hidden" name="then" value="${then === "/bring" ? "bring" : "migrate"}" />`}
270 ${link === undefined
271 ? null
272 : html`<input type="hidden" name="link" value="${link}" />`}
269273 ${btn(`Connect ${hostLabel(flow.webUrl)}`, {
270274 variant: "primary",
271275 icon: "external",
@@ -311,14 +315,14 @@ export async function originFlowPoll(ctx: Ctx): Promise<Response> {
311315 }
312316 const { status, detail } = polled.value;
313317 const then = thenOf(ctx.url.searchParams.get("then"));
318 const link = linkOf(ctx.url.searchParams.get("link"));
314319 if (wantsJson) return Response.json({ status, detail });
315 if (status === "done")
316 return redirect(`${then ?? "/settings/origins"}?connected=1`);
320 if (status === "done") return redirect(thenTarget(then, link));
317321 if (status === "failed")
318322 return redirect(
319323 `/settings/origins?error=${encodeURIComponent(detail.slice(0, 300))}`,
320324 );
321325 return redirect(
322 `/settings/origins/flows/${ctx.params.id ?? ""}${then === undefined ? "" : `?${thenQuery(then)}`}`,
326 `/settings/origins/flows/${ctx.params.id ?? ""}${then === undefined ? "" : `?${thenQuery(then, link)}`}`,
323327 );
324328 }
src/web/pages/origins.ts +94 −16
@@ -485,7 +485,7 @@ function githubSection(configured: boolean, callback: string): Html {
485485 </p>
486486 <form method="post" action="/settings/origins/oauth">
487487 <input type="hidden" name="kind" value="github" />
488 ${btn("Connect GitHub", { variant: "primary", icon: "github" })}
488 ${btn("Connect GitHub", { icon: "github" })}
489489 </form>`;
490490 }
491491
@@ -522,7 +522,7 @@ function forgejoSection(
522522 id <span class="mono">${registered.clientId}</span>).
523523 </p>
524524 <div class="bar">
525 ${btn("Authorize", { variant: "primary", icon: "external" })}
525 ${btn("Authorize", { icon: "external" })}
526526 ${btn("Forget this app", {
527527 size: "sm",
528528 variant: "ghost",
@@ -590,7 +590,7 @@ function forgejoSection(
590590 hint: "Optional. A public client is fine; a secret only buys revoke-on-disconnect.",
591591 })}
592592 <div class="form-actions">
593 ${btn("Register application", { variant: "primary" })}
593 ${btn("Register application")}
594594 </div>
595595 </form>
596596 </div>`
@@ -667,10 +667,62 @@ function tokenSection(values: ConnectValues): Html {
667667 hint: "It is never displayed after this.",
668668 })}
669669 ${tokenNeeds(values.tokenHost)}
670 <div class="form-actions">${btn("Connect", { variant: "primary" })}</div>
670 <div class="form-actions">${btn("Connect")}</div>
671671 </form>`;
672672 }
673673
674 /**
675 * The path a friend's forge needs, first on the page: its address and a token
676 * made on it, or the one click. The line says where that token is made.
677 */
678 function forgeSection(ctx: Ctx, values: ConnectValues): Html {
679 const site =
680 values.tokenHost === ""
681 ? null
682 : URL.parse(values.tokenHost.includes("://") ? values.tokenHost : `https://${values.tokenHost}`);
683 const tokens =
684 site === null || (site.protocol !== "https:" && site.protocol !== "http:")
685 ? html`<span class="mono">/settings/tokens</span> on their forge`
686 : btn(`${site.host}/settings/tokens`, { href: `${site.origin}/settings/tokens`, external: true, variant: "link" });
687 return html`<p class="muted small">
688 You have an account on a friend's forge and want their apps here. Type
689 their address and a token from them; the token is checked before it is
690 stored, encrypted at rest, and never shown again. Their forge may run an
691 older release than this one: that is fine.
692 </p>
693 <form method="post" action="/settings/origins/token">
694 ${uiField({
695 label: "Their forge's address",
696 name: "host",
697 id: "f-forge-host",
698 value: values.tokenHost,
699 required: true,
700 placeholder: "forge.example.com",
701 autocomplete: "off",
702 ...(values.tokenError ? { error: values.tokenError } : {}),
703 })}
704 ${uiField({
705 label: "Token",
706 name: "token",
707 id: "f-forge-token",
708 type: "password",
709 required: true,
710 mono: true,
711 autocomplete: "off",
712 hint: "Read is enough to bring an app; private repositories come with it.",
713 })}
714 <p class="muted small">Make the token there: ${tokens} (Settings → Access tokens).</p>
715 <div class="form-actions">${btn("Connect that forge", { variant: "primary", icon: "globe" })}</div>
716 </form>
717 ${callbackUri(ctx) === ""
718 ? null
719 : html`<form method="post" action="/settings/origins/oauth" class="bar mt">
720 <input type="hidden" name="host" value="${values.tokenHost}" />
721 ${btn("Or sign in there", { variant: "outline", icon: "external" })}
722 <span class="muted small">One click with the address above; no token to copy.</span>
723 </form>`}`;
724 }
725
674726 /** The admin's list of hosts an origin may reach inside the network. */
675727 function trustedHostsCard(text: string, error?: string): Html {
676728 const fixed = envHosts();
@@ -733,9 +785,7 @@ function tokenNeeds(host: string): Html {
733785 ${link === undefined
734786 ? null
735787 : html`<p>
736 <a href="${link}" target="_blank" rel="noopener noreferrer"
737 >Make this token at ${site!.host}</a
738 >, with these already chosen. On Gitea or an older Forgejo, open
788 ${btn(`Make this token at ${site!.host}`, { href: link, external: true, variant: "link" })}, with these already chosen. On Gitea or an older Forgejo, open
739789 Settings → Applications there instead.
740790 </p>`}
741791 </div>`;
@@ -772,7 +822,7 @@ function originsView(
772822 ? empty({
773823 icon: "globe",
774824 title: "No origins connected",
775 note: "Connect GitHub, a Forgejo instance or a Gitea instance and its repositories become mirrorable here.",
825 note: "Connect another forge, GitHub, a Forgejo instance or a Gitea instance and its repositories become mirrorable here.",
776826 })
777827 : html`<div class="stack">
778828 ${origins.map((o) =>
@@ -792,12 +842,18 @@ function originsView(
792842 : null}
793843 ${card(
794844 html`<ul class="list">
845 ${connectPath(
846 "connect-forge",
847 "globe",
848 html`Another forge ${badge("a friend's", { tone: "success" })}`,
849 true,
850 forgeSection(ctx, values),
851 )}
795852 ${connectPath(
796853 "connect-token",
797854 "key",
798 html`Personal access token
799 ${badge("recommended", { tone: "success" })}`,
800 true,
855 "A token for GitHub, Forgejo or Gitea",
856 false,
801857 tokenSection(values),
802858 )}
803859 ${connectPath(
@@ -999,12 +1055,33 @@ const connectMessage = connectWords;
9991055
10001056 // POST /settings/origins/oauth
10011057 /**
1002 * Where a connect or a disconnect started, when it was not here: only
1003 * `/migrate`, named by a fixed word — never a path a form can choose.
1058 * Where a connect or a disconnect started, when it was not here: `/migrate`
1059 * or `/bring`, named by a fixed word — never a path a form can choose.
10041060 */
1005 export type Then = "/migrate" | undefined;
1061 export type Then = "/migrate" | "/bring" | undefined;
10061062 export const thenOf = (value: string | null): Then =>
1007 value === "migrate" ? "/migrate" : undefined;
1063 value === "migrate" ? "/migrate" : value === "bring" ? "/bring" : undefined;
1064
1065 /** The link a `/bring` started with, kept across a connect: a short http(s) URL or nothing. */
1066 export function linkOf(value: string | null): string | undefined {
1067 if (value === null || value.length > 300 || /[\u0000-\u001f\u007f]/.test(value)) return undefined;
1068 const url = URL.parse(value);
1069 return url !== null && (url.protocol === "https:" || url.protocol === "http:") ? value : undefined;
1070 }
1071
1072 /** The query that carries `then` (and the link, for `/bring`) through a connect flow. */
1073 export function thenQuery(then: Then, link?: string): string {
1074 if (then === undefined) return "";
1075 if (then === "/migrate") return "then=migrate";
1076 return `then=bring${link === undefined ? "" : `&link=${encodeURIComponent(link)}`}`;
1077 }
1078
1079 /** Where a finished connect returns: the page it began on, with the link kept. */
1080 export function thenTarget(then: Then, link?: string): string {
1081 if (then === "/bring")
1082 return `/bring?${link === undefined ? "" : `link=${encodeURIComponent(link)}&`}connected=1`;
1083 return `${then ?? "/settings/origins"}?connected=1`;
1084 }
10081085
10091086 export async function originOAuthStart(ctx: Ctx): Promise<Response> {
10101087 const denied = accountOnly(ctx);
@@ -1014,6 +1091,7 @@ export async function originOAuthStart(ctx: Ctx): Promise<Response> {
10141091 const kind = field(form, "kind");
10151092 const host = field(form, "host").trim();
10161093 const then = thenOf(field(form, "then"));
1094 const link = linkOf(field(form, "link"));
10171095
10181096 const back = callbackUri(ctx);
10191097 if (back === "" && kind !== "github") {
@@ -1047,7 +1125,7 @@ export async function originOAuthStart(ctx: Ctx): Promise<Response> {
10471125 return shell(ctx, originsView(ctx, values), 422);
10481126 }
10491127 // The flow's page knows where to go from the flow itself, not the link.
1050 const qs = then === undefined ? "" : "?then=migrate";
1128 const qs = then === undefined ? "" : `?${thenQuery(then, link)}`;
10511129 return redirect(`/settings/origins/flows/${started.value.flowId}${qs}`);
10521130 }
10531131
test/bring-connect-return.test.ts added +23
@@ -0,0 +1,23 @@
1 import { describe, expect, test } from "bun:test";
2 import { linkOf, thenOf, thenQuery, thenTarget } from "../src/web/pages/origins.ts";
3
4 describe("a connect begun on /bring comes back to it with the link kept (#9388)", () => {
5 const link = "https://forge-apps.benjivers.com/ben/notes";
6 test("the query carries the link and the target restores it", () => {
7 expect(thenOf("bring")).toBe("/bring");
8 expect(thenQuery("/bring", link)).toBe(`then=bring&link=${encodeURIComponent(link)}`);
9 expect(thenTarget("/bring", link)).toBe(`/bring?link=${encodeURIComponent(link)}&connected=1`);
10 expect(thenTarget("/migrate")).toBe("/migrate?connected=1");
11 expect(thenTarget(undefined)).toBe("/settings/origins?connected=1");
12 });
13 test("only an http(s) link of reasonable size comes back; nothing else is followed", () => {
14 expect(linkOf(link)).toBe(link);
15 expect(linkOf("javascript:alert(1)")).toBeUndefined();
16 expect(linkOf("//evil.example/x")).toBeUndefined();
17 expect(linkOf(`${link}\n`)).toBeUndefined();
18 expect(linkOf(link.padEnd(400, "a"))).toBeUndefined();
19 expect(linkOf(null)).toBeUndefined();
20 // The return is always a path on this forge, whatever the link says.
21 expect(thenTarget("/bring", "https://evil.example/")).toStartWith("/bring?");
22 });
23 });
test/bring-oauth-return.test.ts added +89
@@ -0,0 +1,89 @@
1 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2 import { mkdtemp, rm } from "node:fs/promises";
3 import { tmpdir } from "node:os";
4 import { join } from "node:path";
5 import { createApp, type App } from "../src/app.ts";
6 import { serve, type Servers } from "../src/http/server.ts";
7 import { forgetProviders } from "../src/origins/providers/index.ts";
8 import { newFakeOAuth, newFakeState, serveFakeForge, type FakeForge, type FakeOAuth } from "./fake-origin.ts";
9 import { withoutEnv } from "./helpers/globals.ts";
10 import { proveAll } from "./helpers/verified.ts";
11
12 /** A sign-in begun from /bring comes back to /bring with the link kept, through the real flow routes (#9388). */
13
14 const PASSWORD = "alice-login-secret";
15 const CLIENT_ID = "Iv1.forge-test-client";
16 const auth = `Basic ${Buffer.from(`alice:${PASSWORD}`).toString("base64")}`;
17 withoutEnv("FORGE_URL");
18
19 let dir: string;
20 let app: App;
21 let servers: Servers;
22 let fake: FakeForge;
23 let oauth: FakeOAuth;
24
25 beforeAll(async () => {
26 dir = await mkdtemp(join(tmpdir(), "forge-bring-oauth-"));
27 oauth = newFakeOAuth(CLIENT_ID);
28 const state = newFakeState("github", "octo", "unused-pat");
29 state.oauth = oauth;
30 fake = serveFakeForge(state);
31 process.env.FORGE_GITHUB_CLIENT_ID = CLIENT_ID;
32 app = createApp({ dataDir: dir, port: 0 });
33 servers = await serve(app);
34 await fetch(`${servers.url}/api/v1/users`, {
35 method: "POST",
36 headers: { "content-type": "application/json" },
37 body: JSON.stringify({ username: "alice", email: "[email protected]", password: PASSWORD }),
38 });
39 proveAll(app);
40 forgetProviders(dir);
41 });
42 afterAll(async () => {
43 delete process.env.FORGE_GITHUB_CLIENT_ID;
44 fake.stop();
45 servers.stop();
46 app.close();
47 forgetProviders(dir);
48 await rm(dir, { recursive: true, force: true });
49 });
50
51 const form = (path: string, body: Record<string, string>) =>
52 fetch(`${servers.url}${path}`, {
53 method: "POST",
54 redirect: "manual",
55 headers: { authorization: auth, "content-type": "application/x-www-form-urlencoded" },
56 body: new URLSearchParams(body),
57 });
58
59 describe("sign in from /bring and come back to it", () => {
60 test("start, poll while pending, poll when approved: each step keeps the link", async () => {
61 const link = `${fake.url}/octo/widgets`;
62 const query = `then=bring&link=${encodeURIComponent(link)}`;
63 const started = await form("/settings/origins/oauth", { host: fake.url, kind: "github", then: "bring", link });
64 expect(started.status).toBe(303);
65 const flowPage = started.headers.get("location")!;
66 expect(flowPage).toContain("/settings/origins/flows/");
67 expect(flowPage).toContain(query);
68 const flowId = flowPage.split("/flows/")[1]!.split("?")[0]!;
69
70 // The page names where the poll goes, with the return in its query.
71 const shown = await fetch(`${servers.url}${flowPage}`, { headers: { authorization: auth } });
72 expect(await shown.text()).toContain(`/settings/origins/flows/${flowId}/poll?${query.replaceAll("&", "&amp;")}`);
73
74 // Not approved yet: back to the flow page, still carrying the return.
75 const pending = await form(`/settings/origins/flows/${flowId}/poll?${query}`, {});
76 expect(pending.status).toBe(303);
77 expect(pending.headers.get("location")).toBe(`/settings/origins/flows/${flowId}?${query}`);
78
79 // Approved: the poll lands on /bring with the link and a connected flag.
80 oauth.approved = true;
81 const done = await form(`/settings/origins/flows/${flowId}/poll?${query}`, {});
82 expect(done.status).toBe(303);
83 expect(done.headers.get("location")).toBe(`/bring?link=${encodeURIComponent(link)}&connected=1`);
84
85 const back = await fetch(`${servers.url}${done.headers.get("location")!}`, { headers: { authorization: auth } });
86 expect(back.status).toBe(200);
87 expect(await back.text()).toContain(`value="${link}"`);
88 });
89 });
test/bring.test.ts +43 −16
@@ -5,12 +5,12 @@ import { join } from "node:path";
55 import { createApp, type App } from "../src/app.ts";
66 import { getAppByRepo } from "../src/core/apps.ts";
77 import { listAppEnvNames } from "../src/core/app_env.ts";
8 import { createOrigin } from "../src/core/origins.ts";
8 import { listOrigins } from "../src/core/origins.ts";
99 import { getRepo } from "../src/core/repos.ts";
1010 import { getUserByName } from "../src/core/users.ts";
1111 import { serve, type Servers } from "../src/http/server.ts";
1212 import { settled } from "../src/origins/jobs.ts";
13 import { needsOf, originOnHost, parseBringLink } from "../src/origins/bring.ts";
13 import { connectSite, needsOf, originOnHost, parseBringLink } from "../src/origins/bring.ts";
1414 import { syncsSettled } from "../src/origins/sync.ts";
1515 import { SYSTEM_NAMES } from "../src/core/reserved.ts";
1616 import { newFakeState, serveFakeForge, type FakeForge } from "./fake-origin.ts";
@@ -50,6 +50,13 @@ describe("the link and the needs", () => {
5050 expect(originOnHost([], "b.org")).toBeUndefined();
5151 });
5252
53 test("the address to connect: https, or http only on a development forge", () => {
54 expect(connectSite("https://x.org/a/b", "x.org", {})).toEqual({ ok: true, value: "https://x.org" });
55 expect(connectSite("x.org/a/b", "x.org", {})).toEqual({ ok: true, value: "https://x.org" });
56 expect(connectSite("http://x.org/a/b", "x.org", {})).toEqual({ ok: false, error: "plain_http" });
57 expect(connectSite("http://x.org/a/b", "x.org", { FORGE_DEV: "1" })).toEqual({ ok: true, value: "http://x.org" });
58 });
59
5360 test("needs are names only, from NAME= lines", () => {
5461 expect(
5562 needsOf("# keys\nAPI_KEY=abc123\nexport DB_URL = x\nlower=1\nAPI_KEY=again\n\nPORT=3000\n"),
@@ -149,27 +156,47 @@ describe("bringing an app whole", () => {
149156 const bad = await post("/bring", { link: "nonsense" });
150157 expect(bad.status).toBe(400);
151158 const none = await post("/bring", { link: `${fake.url}/octo/widgets` });
152 expect(none.status).toBe(400);
153 expect(await none.text()).toContain("/settings/origins");
159 // Not a dead end (#9388): the page offers to connect in place and keeps the link.
160 expect(none.status).toBe(200);
161 const card = await none.text();
162 expect(card).toContain('action="/bring/connect"');
163 expect(card).toContain(`value="${fake.url}/octo/widgets"`);
164 expect(card).toContain(`${fake.url}/settings/tokens`);
165 expect(card).toContain("Sign in there instead");
166 // The sign-in carries the link through and back.
167 expect(card).toContain('name="then" value="bring"');
168 // A token the forge refuses keeps the card and the link, and echoes nothing.
169 const wrong = await post("/bring/connect", { link: `${fake.url}/octo/widgets`, token: "not-the-token-1" });
170 expect(wrong.status).toBe(422);
171 const wrongText = await wrong.text();
172 expect(wrongText).toContain('action="/bring/connect"');
173 expect(wrongText).not.toContain("not-the-token-1");
154174 expect(getRepo(app.db, "alice", "widgets")).toBeUndefined();
155175 });
156176
157 test("a link comes across, private, linked back, asking only by name", async () => {
158 const made = createOrigin(app.db, app.config.dataDir, {
159 userId: alice().id,
160 kind: "forgejo",
161 apiUrl: fake.apiUrl,
162 webUrl: fake.url,
163 login: LOGIN,
164 token: TOKEN,
165 });
166 if (!made.ok) throw new Error(made.error);
177 test("a token is never sent over plain http unless this is a development forge", async () => {
178 const before = process.env.FORGE_DEV;
179 process.env.FORGE_DEV = "0";
180 try {
181 const refused = await post("/bring/connect", { link: `${fake.url}/octo/widgets`, token: TOKEN });
182 expect(refused.status).toBe(400);
183 expect(await refused.text()).toContain("travels in clear");
184 expect(listOrigins(app.db, alice().id)).toHaveLength(0);
185 } finally {
186 if (before === undefined) delete process.env.FORGE_DEV;
187 else process.env.FORGE_DEV = before;
188 }
189 });
167190
168 const started = await post("/bring", { link: `${fake.url}/octo/widgets.git` });
191 test("a link comes across, private, linked back, asking only by name", async () => {
192 // Connecting in place with the token goes straight on to bringing the link.
193 const started = await post("/bring/connect", { link: `${fake.url}/octo/widgets.git`, token: TOKEN });
169194 expect(started.status).toBe(303);
170195 const where = started.headers.get("location")!;
171196 const jobId = where.split("/").pop()!;
172 expect(where).toBe(`/bring/${made.value.id}/${jobId}`);
197 const connected = listOrigins(app.db, alice().id);
198 expect(connected).toHaveLength(1);
199 expect(where).toBe(`/bring/${connected[0]!.id}/${jobId}`);
173200 expect((await settled(jobId))?.stage).toBe("done");
174201
175202 // A GET only looks: the copy is here, nothing is registered or running.
test/forge-origin-older.test.ts added +108
@@ -0,0 +1,108 @@
1 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2 import { discover } from "../src/origins/discover.ts";
3 import { providerFor } from "../src/origins/providers/index.ts";
4 import { staticCredential } from "../src/origins/types.ts";
5
6 /**
7 * A forge on 0.5.x, as one really answers (#9388): no `/api/v1/version`, no
8 * NodeInfo, no `/api/v1/user/repos`; but `/api/v1/health`, `/api/v1/here`,
9 * `/api/v1/user` and the cursor-paged `/api/v1/repos` in a forge's own JSON.
10 */
11
12 const TOKEN = "friend-token";
13 const repo = (id: number, owner: string, name: string, visibility: string) => ({
14 id: String(id),
15 ownerName: owner,
16 name,
17 description: `${name} app`,
18 visibility,
19 forkParentId: null,
20 defaultBranch: "main",
21 });
22 const ALL = [
23 repo(1, "zed", "other", "public"),
24 repo(2, "ben", "notes", "public"),
25 repo(3, "ben", "secret", "private"),
26 repo(4, "zed", "more", "public"),
27 repo(5, "ben", "clock", "public"),
28 ];
29
30 let server: ReturnType<typeof Bun.serve>;
31 let base = "";
32 const seen: string[] = [];
33
34 beforeAll(() => {
35 process.env.FORGE_DEV = "1";
36 server = Bun.serve({
37 port: 0,
38 hostname: "127.0.0.1",
39 fetch(req) {
40 const url = new URL(req.url);
41 seen.push(url.pathname + url.search);
42 const json = (v: unknown) => Response.json(v);
43 if (url.pathname === "/api/v1/health")
44 return json({ ok: true, version: "0.5.29", commit: "abc1234", draining: false });
45 if (url.pathname === "/api/v1/here")
46 return json({ here: { kind: "forge", name: "/" }, you: null, actions: [], mcp: {}, docs: [] });
47 const authed = req.headers.get("authorization")?.includes(TOKEN) === true;
48 if (url.pathname === "/api/v1/user")
49 return authed ? json({ username: "ben", displayName: "Ben" }) : new Response("{}", { status: 401 });
50 if (url.pathname === "/api/v1/repos") {
51 if (!authed) return new Response("{}", { status: 401 });
52 const after = Number(url.searchParams.get("cursor") ?? 0);
53 const limit = Number(url.searchParams.get("limit") ?? 30);
54 const rows = ALL.filter((r) => Number(r.id) > after).slice(0, limit);
55 const last = rows[rows.length - 1];
56 return json({ items: rows, next_cursor: rows.length >= limit && last ? last.id : null });
57 }
58 return new Response('{"error":"no_route"}', { status: 404 });
59 },
60 });
61 base = `http://127.0.0.1:${server.port}`;
62 });
63 afterAll(() => server.stop(true));
64
65 describe("a forge on an older release", () => {
66 test("discover recognises it through health and here", async () => {
67 const found = await discover(base);
68 expect(found.ok).toBe(true);
69 if (!found.ok) return;
70 expect(found.value.via).toBe("health");
71 expect(found.value.software).toBe("forge 0.5.29");
72 expect(seen).toContain("/api/v1/version");
73 });
74
75 test("something that answers health but is no forge is not one", async () => {
76 const other = Bun.serve({
77 port: 0,
78 hostname: "127.0.0.1",
79 fetch: (req) =>
80 new URL(req.url).pathname === "/api/v1/health" ? Response.json({ ok: true }) : new Response("no", { status: 404 }),
81 });
82 const found = await discover(`http://127.0.0.1:${other.port}`);
83 other.stop(true);
84 expect(found.ok).toBe(false);
85 });
86
87 test("provider forge lists the account's own repositories, private ones too", async () => {
88 const provider = providerFor(
89 staticCredential({
90 kind: "gitea",
91 software: "forge",
92 apiUrl: `${base}/api/v1`,
93 webUrl: base,
94 login: "ben",
95 token: TOKEN,
96 }),
97 );
98 const page = await provider.listRepos();
99 expect(page.ok).toBe(true);
100 if (!page.ok) return;
101 expect(page.value.items.map((r) => [r.fullName, r.private])).toEqual([
102 ["ben/notes", false],
103 ["ben/secret", true],
104 ["ben/clock", false],
105 ]);
106 expect(page.value.next).toBeNull();
107 });
108 });
test/tui-bring.test.ts +41
@@ -68,4 +68,45 @@ describe("the bring screen", () => {
6868 }
6969 expect(bringWords({ kind: "connect", host: "codeberg.org" })).toContain("codeberg.org");
7070 });
71
72 test("a link to a forge not yet connected asks for its token, masked, instead of a dead end", async () => {
73 const terminal = headlessTerminal(120, 30);
74 const { app: tui, session } = createForgeApp(app, identity(alice), { terminal, exitHooks: false });
75 tui.start();
76 tui.ctx.push(bringScreen(session, tui.ctx, blank("Bring an app")));
77 try {
78 await settleApp(tui);
79 terminal.feed("https://friend.example/ana/notes\r");
80 await settleApp(tui);
81 const text = tui.screen.toText();
82 expect(text).toContain("Token for friend.example");
83 expect(text).toContain("https://friend.example/settings/tokens");
84 terminal.feed("s3cret-token\r");
85 await settleApp(tui);
86 expect(tui.screen.toText()).not.toContain("s3cret-token");
87 } finally {
88 tui.stop();
89 }
90 });
91
92 test("plain http is refused in words, not asked a token for, off a development forge", async () => {
93 const before = process.env.FORGE_DEV;
94 process.env.FORGE_DEV = "0";
95 const terminal = headlessTerminal(120, 30);
96 const { app: tui, session } = createForgeApp(app, identity(alice), { terminal, exitHooks: false });
97 tui.start();
98 tui.ctx.push(bringScreen(session, tui.ctx, blank("Bring an app")));
99 try {
100 await settleApp(tui);
101 terminal.feed("http://friend.example/ana/notes\r");
102 await settleApp(tui);
103 const text = tui.screen.toText();
104 expect(text).toContain("plain http");
105 expect(text).not.toContain("Token for friend.example");
106 } finally {
107 tui.stop();
108 if (before === undefined) delete process.env.FORGE_DEV;
109 else process.env.FORGE_DEV = before;
110 }
111 });
71112 });
test/web-origins.test.ts +11 −9
@@ -205,12 +205,11 @@ describe("origins on the web", () => {
205205 const html = await (await asUser(alice, "/settings/origins")).text();
206206 expect(html).toContain("No origins connected");
207207 expect(html).toContain("Forgejo / Gitea, one click");
208 expect(html).toContain("Personal access token");
209 // The token path comes first and is open; the OAuth pair is folded away.
210 expect(html.indexOf("Personal access token")).toBeLessThan(
211 html.indexOf("One-click sign-in"),
212 );
213 expect(html).toContain("recommended");
208 // Another forge leads (#9388), with where its token is made; the rest are folded.
209 expect(html).toContain("Another forge");
210 expect(html).toContain("/settings/tokens");
211 expect(html.indexOf("Another forge")).toBeLessThan(html.indexOf("A token for GitHub, Forgejo or Gitea"));
212 expect(html.indexOf("Another forge")).toBeLessThan(html.indexOf("One-click sign-in"));
214213 // Drawn with §5's rows and buttons, not the page's own classes.
215214 for (const gone of ["handoff", "connect-more", "connect-path"]) {
216215 expect(html).not.toContain(gone);
@@ -240,6 +239,7 @@ describe("origins on the web", () => {
240239 const closed = await (await asUser(alice, "/settings/origins")).text();
241240 const used = await (await asUser(alice, "/settings/origins?host=codeberg.org")).text();
242241 for (const [id, brand] of [
242 ["connect-forge", "globe"],
243243 ["connect-token", "key"],
244244 ["connect-github", "github"],
245245 ["connect-forgejo", "forgejo"],
@@ -254,10 +254,12 @@ describe("origins on the web", () => {
254254 expect(/<span\s+class="row-title"[^>]*>([\s\S]*?)<\/span\s*>/.exec(summary)![1]).toContain(pointAtSprite(icon(brand).text, spriteAsset().path));
255255 }
256256 }
257 expect(fold(closed, "connect-token").open).toBe(true);
257 expect(fold(closed, "connect-forge").open).toBe(true);
258 expect(fold(closed, "connect-token").open).toBe(false);
258259 expect(fold(closed, "connect-github").open).toBe(false);
259260 expect(fold(closed, "connect-forgejo").open).toBe(false);
260 expect(fold(used, "connect-token").open).toBe(true);
261 expect(fold(used, "connect-forge").open).toBe(true);
262 expect(fold(used, "connect-token").open).toBe(false);
261263 expect(fold(used, "connect-github").open).toBe(true);
262264 expect(fold(used, "connect-forgejo").open).toBe(true);
263265 });
@@ -869,7 +871,7 @@ describe("origins on the web", () => {
869871 expect(html).not.toContain("error-note");
870872 expect(html).not.toContain("origin-callback-err");
871873 // The token path is unaffected, and still offered.
872 expect(html).toContain("Personal access token");
874 expect(html).toContain("Another forge");
873875
874876 const started = await fetch(`${base}/settings/origins/oauth`, {
875877 method: "POST",

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs