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

One forge connects to another as an origin: bring in a friend's app, and stay connected #8908

Merged opened this in answer to

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

Merged

torque/forge:torque/8900-one-forge-connects-to-another-as-an-orig → main 5 commits fork

  • Merged. torque/8900-one-forge-connects-to-another-as-an-orig was merged into main as 23dacf5.

Review

Sign in to review this change.
28 files changed +814 −30
docs/features/forge-origin.md added +9
@@ -0,0 +1,9 @@
1 # A forge is an origin (#8900, first piece)
2
3 Connect another forge at `/settings/origins` the way you connect Forgejo: type its address, paste a token it gave you (Settings, Tokens there; `ro` is enough to bring code across). `discover` asks `GET /api/v1/version`; a forge answers `{"version", "software":"forge"}`, and is stored as an origin with `software = forge` (kind `gitea`, the dialect it speaks).
4
5 - **Picker:** `GET /api/v1/user/repos` lists the token's account's repositories, private ones included, in the Gitea shape. Only that route speaks the Gitea shape; a repository's own JSON is unchanged.
6 - **Credential:** the token travels as `Authorization: token …` for the API (accepted as a Bearer token) and as Basic through `GIT_CONFIG_*` for git — never argv, never config.
7 - **Mirror:** brings the code and its history. The origin's card and `GET /api/v1/user/origins` (`carries`) say what did not come: issues, pulls, comments and labels (not yet), how the app runs (not yet), and its data and secrets (never by default).
8
9 Not in this piece, in order: sign in through OAuth, migrate an app whole with its run and build, follow upstream between forges with a pull back, and the one-page paste-a-link path. Test: `test/forge-origin.test.ts` runs two forges against each other.
src/actions/origin_words.ts +14
@@ -16,3 +16,17 @@ export const disconnectedWords = (revokedUpstream: boolean): string =>
1616 /** What the confirm page tells you before a disconnect. */
1717 export const DISCONNECT_DETAIL =
1818 "The stored credential is destroyed here immediately, and the repositories mirroring it stop syncing — they keep their git objects, issues and comments, and go on being readable as ordinary local repositories. The grant is revoked upstream only where the forge allows it (GitHub, and only when this forge has a client secret configured); everywhere else revoke the token on the origin yourself. The message after this says which happened.";
19
20 /** Why a connect did not happen, in one sentence, on the page and in the terminal. */
21 export function connectWords(error: string): string {
22 const words: Record<string, string> = {
23 unknown_host: "That does not look like a forge this can talk to.",
24 private_host: "That address is inside a network, not on the internet.",
25 unreachable: "That instance could not be reached.",
26 bad_token: "That token was refused by the origin.",
27 already_connected: "That account is already connected.",
28 rate_limited: "Too many connect attempts. Wait a few minutes.",
29 unauthorized: "Sign in first.",
30 };
31 return words[error] ?? "That connection could not be made.";
32 }
src/actions/origins.ts +8
@@ -181,6 +181,7 @@ export async function connectOrigin(
181181 kind: found.value.kind,
182182 apiUrl: found.value.apiUrl,
183183 webUrl: found.value.webUrl,
184 software: softwareOf(found.value.software),
184185 token: input.token.trim(),
185186 }),
186187 ).whoami();
@@ -196,6 +197,7 @@ export async function connectOrigin(
196197 login: identity.value.login,
197198 token: input.token.trim(),
198199 authKind: "pat",
200 software: softwareOf(found.value.software),
199201 });
200202 if (!stored.ok) return stored;
201203 return ok({
@@ -204,6 +206,9 @@ export async function connectOrigin(
204206 });
205207 }
206208
209 /** What `discover` named the software, as the column that keeps it: another forge or nothing. */
210 const softwareOf = (named: string): string => (named.startsWith("forge ") ? "forge" : "");
211
207212 export type ReplaceTokenError =
208213 | "unauthorized"
209214 | "not_found"
@@ -238,6 +243,7 @@ export async function replaceOriginToken(
238243 kind: origin.kind,
239244 apiUrl: origin.apiUrl,
240245 webUrl: origin.webUrl,
246 software: origin.software,
241247 token: secret,
242248 }),
243249 ).whoami();
@@ -287,6 +293,7 @@ function storeOrigin(
287293 authKind: "pat" | "oauth";
288294 refreshToken?: string;
289295 expiresAt?: string | null;
296 software?: string;
290297 },
291298 ): Result<Origin, "already_connected"> {
292299 // Connecting an account whose credential died is re-authorizing it: the
@@ -330,6 +337,7 @@ function storeOrigin(
330337 ? {}
331338 : { refreshToken: input.refreshToken }),
332339 ...(input.expiresAt === undefined ? {} : { expiresAt: input.expiresAt }),
340 ...(input.software === undefined ? {} : { software: input.software }),
333341 });
334342 if (!created.ok) return created;
335343 // The payload names the host and the login and nothing else: an audit
src/contract/openapi/routes/getApiV1UserRepos.ts added +9
@@ -0,0 +1,9 @@
1 import type { Route } from "../shared.ts";
2
3 export const route: Route = {
4 method: "get",
5 path: "/api/v1/user/repos",
6 summary: "The caller's own repositories, private ones included, as a bare array in the Gitea shape (page, limit; Link rel=next): what a forge connecting to this one as an origin lists",
7 auth: true,
8 refusals: { unauthorized: "no valid credentials were sent" },
9 };
src/contract/openapi/routes/getApiV1Version.ts added +9
@@ -0,0 +1,9 @@
1 import type { Route } from "../shared.ts";
2
3 export const route: Route = {
4 method: "get",
5 path: "/api/v1/version",
6 summary: "The API generation this forge answers, with software \"forge\": how another forge recognises it as an origin",
7 auth: false,
8 refusals: {},
9 };
src/contract/openapi/routes/index.ts +2
@@ -177,9 +177,11 @@ export { route as getApiV1UserOriginsIdMirrorJobStream } from "./getApiV1UserOri
177177 export { route as getApiV1UserOriginsIdRepos } from "./getApiV1UserOriginsIdRepos.ts";
178178 export { route as getApiV1UserOriginsOauthId } from "./getApiV1UserOriginsOauthId.ts";
179179 export { route as getApiV1UserPins } from "./getApiV1UserPins.ts";
180 export { route as getApiV1UserRepos } from "./getApiV1UserRepos.ts";
180181 export { route as getApiV1UsersUsername } from "./getApiV1UsersUsername.ts";
181182 export { route as getApiV1UsersUsernameRepos } from "./getApiV1UsersUsernameRepos.ts";
182183 export { route as getApiV1UsersUsernameSignins } from "./getApiV1UsersUsernameSignins.ts";
184 export { route as getApiV1Version } from "./getApiV1Version.ts";
183185 export { route as getContractOpenapiJson } from "./getContractOpenapiJson.ts";
184186 export { route as getMetrics } from "./getMetrics.ts";
185187 export { route as patchApiV1AdminSettings } from "./patchApiV1AdminSettings.ts";
src/core/migration-pins/20261103140000-origin-software.pin added +1
@@ -0,0 +1,1 @@
1 560aece073658a58b8da8fd06de2e3972ed19f08b0b001407770bd4da7edd509
src/core/migrations/20261103140000-origin-software.ts added +7
@@ -0,0 +1,7 @@
1 // The software an origin runs, when it is more than its kind says (#8900):
2 // `forge` for another forge, which speaks the Gitea dialect and is stored as
3 // a Gitea origin, so the one thing that tells them apart lives here. Empty for
4 // every origin that already exists. Additive.
5 export default `
6 ALTER TABLE origin ADD COLUMN software TEXT NOT NULL DEFAULT '';
7 `;
src/core/origins.ts +7 −2
@@ -35,6 +35,7 @@ interface OriginRow {
3535 refresh_token_enc: string;
3636 expires_at: string | null;
3737 needs_reauth: number;
38 software: string;
3839 }
3940
4041 function toOrigin(row: OriginRow): Origin {
@@ -51,6 +52,7 @@ function toOrigin(row: OriginRow): Origin {
5152 authKind: row.auth_kind,
5253 expiresAt: row.expires_at,
5354 needsReauth: row.needs_reauth === 1,
55 software: row.software,
5456 };
5557 }
5658
@@ -68,6 +70,8 @@ export interface CreateOriginInput {
6870 refreshToken?: string;
6971 /** OAuth only: when the access token stops working. */
7072 expiresAt?: string | null;
73 /** `forge` for another forge; see `Origin.software`. */
74 software?: string;
7175 }
7276
7377 export type CreateOriginError = "already_connected";
@@ -93,8 +97,8 @@ export function createOrigin(
9397 const originId = id();
9498 db.query(
9599 `INSERT INTO origin (id, user_id, kind, api_url, web_url, login, token_enc,
96 auth_kind, refresh_token_enc, expires_at)
97 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)`,
100 auth_kind, refresh_token_enc, expires_at, software)
101 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)`,
98102 ).run(
99103 originId,
100104 input.userId,
@@ -108,6 +112,7 @@ export function createOrigin(
108112 ? ""
109113 : seal(dataDir, input.refreshToken, originId),
110114 input.expiresAt ?? null,
115 input.software ?? "",
111116 );
112117 return ok(getOriginById(db, originId)!);
113118 }
src/http/api.ts +6
@@ -334,6 +334,8 @@ import { tokenRoutes } from "./api/tokens.ts";
334334 import { accountAdmin, adminRoutes } from "./api/admin.ts";
335335 import { flagsRoutes } from "./api/flags.ts";
336336 import { issueRoutes, labelsHandler } from "./api/issues.ts";
337 import { FORGE_NOT_CARRIED } from "../origins/words.ts";
338 import { forgeOriginRoutes } from "./api/forge_origin.ts";
337339 import { userRoutes } from "./api/user.ts";
338340 import { appRoutes } from "./api/app.ts";
339341 import { appNoteRoutes } from "./api/app_notes.ts";
@@ -539,6 +541,9 @@ export function originJson(origin: Origin) {
539541 lastError: origin.lastError,
540542 needsReauth: origin.needsReauth,
541543 expiresAt: origin.expiresAt,
544 software: origin.software,
545 // What a forge origin does not bring, said where a client reads the origin.
546 carries: origin.software === "forge" ? FORGE_NOT_CARRIED : null,
542547 };
543548 }
544549
@@ -1157,6 +1162,7 @@ export function apiRoutes(router: Router<Handler>): void {
11571162 tokenRoutes(router);
11581163
11591164 userRoutes(router);
1165 forgeOriginRoutes(router);
11601166 adminRoutes(router);
11611167 flagsRoutes(router);
11621168
src/http/api/forge_origin.ts added +93
@@ -0,0 +1,93 @@
1 /**
2 * What another forge needs to connect to this one as an origin
3 * (`docs/features/forge-origin.md`). Origins speak the Gitea dialect, so a
4 * forge answers the few calls connecting needs in that dialect rather than
5 * growing a fourth adapter: `GET /api/v1/version` (how `discover` recognises
6 * it), and the Gitea shape of a repository, listed at `GET /api/v1/user/repos`
7 * and nowhere else.
8 *
9 * Nothing here is new authority. The list is the caller's own repositories
10 * through the same visibility rule as every other read, and a private one is
11 * only ever seen by the token that may see it.
12 */
13
14 import { listReposByOwner, type Repo } from "../../core/repos.ts";
15 import type { Ctx, Handler } from "../context.ts";
16 import { json, unauthorized } from "../respond.ts";
17 import type { Router } from "../router.ts";
18 import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts";
19
20 /** The Gitea API generation this answers: the calls above, and no more. */
21 export const FORGE_API_VERSION = "1.0.0";
22
23 const PAGE_MAX = 100;
24 const OWNED_MAX = 1000;
25
26 /** A repository in the shape a Gitea-speaking client reads. */
27 export function giteaShape(ctx: Ctx, repo: Repo) {
28 const full = `${repo.ownerName}/${repo.name}`;
29 const web = `${ctx.url.origin}/${full}`;
30 const mine = ctx.auth.user?.id === repo.ownerId;
31 return {
32 id: repo.id,
33 full_name: full,
34 name: repo.name,
35 owner: { login: repo.ownerName },
36 description: repo.description,
37 private: repo.visibility === "private",
38 fork: repo.forkParentId !== null,
39 default_branch: repo.defaultBranch,
40 clone_url: `${web}.git`,
41 html_url: web,
42 permissions: { push: mine, admin: mine },
43 };
44 }
45
46 /** A positive integer from the query, or the fallback. */
47 function whole(raw: string | null, fallback: number, max: number): number {
48 const n = Number(raw);
49 return Number.isInteger(n) && n > 0 ? Math.min(n, max) : fallback;
50 }
51
52 export function forgeOriginRoutes(router: Router<Handler>): void {
53 router.add("GET", "/api/v1/version", (ctx) => {
54 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
55 return timePhase(ctx.request, "render", () => json({ version: FORGE_API_VERSION, software: "forge" }));
56 });
57
58 // The signed-in account's own repositories, `page` and `limit` as Gitea
59 // pages them. Owned repositories are few; they are read whole (bounded) and
60 // sliced, which keeps the page numbers honest.
61 router.add("GET", "/api/v1/user/repos", (ctx) => {
62 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
63 const me = ctx.auth.user;
64 if (!me) return unauthorized();
65 const limit = whole(ctx.url.searchParams.get("limit"), 50, PAGE_MAX);
66 const page = whole(ctx.url.searchParams.get("page"), 1, OWNED_MAX);
67 const owned: Repo[] = [];
68 let cursor: string | undefined;
69 timePhase(ctx.request, "repos", () => {
70 while (owned.length < OWNED_MAX) {
71 const batch = listReposByOwner(ctx.app.db, me.username, me, {
72 limit: PAGE_MAX,
73 ...(cursor === undefined ? {} : { cursor }),
74 });
75 owned.push(...batch);
76 if (batch.length < PAGE_MAX) break;
77 cursor = batch[batch.length - 1]!.id;
78 }
79 });
80 const start = (page - 1) * limit;
81 const rows = owned.slice(start, start + limit).map((r) => giteaShape(ctx, r));
82 const headers: Record<string, string> = {
83 "x-total-count": String(owned.length),
84 };
85 if (start + limit < owned.length) {
86 const next = new URL(ctx.url);
87 next.searchParams.set("page", String(page + 1));
88 next.searchParams.set("limit", String(limit));
89 headers.link = `<${next.toString()}>; rel="next"`;
90 }
91 return timePhase(ctx.request, "render", () => json(rows, 200, headers));
92 });
93 }
src/http/server.ts +11 −4
@@ -5,6 +5,7 @@
55 * verdict. Auth is resolved once per request and handed to handlers as data.
66 */
77
8 import { isTokenScheme, tokenSecret } from "./token_header.ts";
89 import { followUpstreamCheck, followUpstreamUpdate } from "../web/pages/upstream-follow.ts";
910 import { secureFor } from "./cookie_secure.ts";
1011 import { refusedByDatabase, shownRefusal } from "./refusal.ts";
@@ -541,9 +542,12 @@ async function resolveAuth(app: App, request: Request): Promise<Auth> {
541542 const header = request.headers.get("authorization");
542543 if (!header) return NO_AUTH;
543544
544 // Bearer is always a token.
545 if (header.startsWith("Bearer ")) {
546 const verified = verifyToken(app.db, header.slice(7));
545 // Bearer is always a token; so is `token <secret>`, the scheme a Gitea
546 // client sends — another forge connecting to this one as an origin.
547 if (isTokenScheme(header)) {
548 const secret = tokenSecret(header);
549 if (secret === null) return NO_AUTH;
550 const verified = verifyToken(app.db, secret);
547551 if (verified.ok) {
548552 return {
549553 user: verified.value.user,
@@ -1555,7 +1559,10 @@ function tokenCredential(request: Request, auth: Auth): string | undefined {
15551559 if (auth.via !== "token") return undefined;
15561560 const header = request.headers.get("authorization");
15571561 if (header === null) return undefined;
1558 if (header.startsWith("Bearer ")) return sha256(header.slice(7));
1562 if (isTokenScheme(header)) {
1563 const secret = tokenSecret(header);
1564 return secret === null ? undefined : sha256(secret);
1565 }
15591566 if (header.startsWith("Basic ")) {
15601567 const decoded = Buffer.from(header.slice(6), "base64").toString();
15611568 const colon = decoded.indexOf(":");
src/http/token_header.ts added +23
@@ -0,0 +1,23 @@
1 /**
2 * The secret in an `Authorization` header that names a token: `Bearer <secret>`
3 * and `token <secret>`, the scheme a Gitea client sends. Nothing else is a
4 * token header, and a header with the scheme but no secret is not one either,
5 * so a malformed header never reaches `verifyToken` with a guessed slice.
6 */
7
8 const SCHEMES = ["Bearer ", "token "] as const;
9
10 /** The secret, or `null` when the header is not a token header or holds none. */
11 export function tokenSecret(header: string): string | null {
12 for (const scheme of SCHEMES) {
13 if (!header.startsWith(scheme)) continue;
14 const secret = header.slice(scheme.length).trim();
15 return secret === "" ? null : secret;
16 }
17 return null;
18 }
19
20 /** Whether the header names a token scheme at all, empty secret or not. */
21 export function isTokenScheme(header: string): boolean {
22 return SCHEMES.some((scheme) => header.startsWith(scheme));
23 }
src/origins/credential.ts +1
@@ -58,6 +58,7 @@ export function credentialFor(
5858 webUrl: origin.webUrl,
5959 login: origin.login,
6060 authKind: origin.authKind,
61 software: origin.software,
6162 token: () => resolveToken(app, origin.id),
6263 });
6364 }
src/origins/discover.ts +4 −1
@@ -230,13 +230,16 @@ async function viaVersionEndpoint(
230230 if (!doc.ok) return undefined;
231231 const version = asString(field(doc.value, "version"));
232232 if (version === "") return undefined;
233 // Another forge of this kind says so, and speaks the Gitea dialect: it is
234 // stored as a Gitea origin and shown as a forge (`docs/features/forge-origin.md`).
235 const forge = asString(field(doc.value, "software")) === "forge";
233236 const kind: OriginKind = version.includes("+gitea-") ? "forgejo" : "gitea";
234237 return {
235238 kind,
236239 apiUrl: `${web}/api/v1`,
237240 webUrl: web,
238241 via: "version-endpoint",
239 software: version,
242 software: forge ? `forge ${version}` : version,
240243 };
241244 }
242245
src/origins/providers/forge.ts added +95
@@ -0,0 +1,95 @@
1 /**
2 * Another forge as an origin (`docs/features/forge-origin.md`).
3 *
4 * A forge answers the Gitea dialect only for the one list connecting needs,
5 * `GET /user/repos`; who you are and one repository are read in its own
6 * shape, so the rest of its API grows no Gitea fields. Git comes over HTTPS
7 * as ever. What a forge does not yet answer to an origin is its issues,
8 * pulls, comments and labels: those reads say "none" rather than failing, so
9 * the mirror brings the code across and settles, and the origin's `carries`
10 * note says what did not come.
11 */
12
13 import type {
14 Page,
15 ProviderResult,
16 RemoteIdentity,
17 RemoteLabel,
18 RemoteMilestone,
19 RemoteIssue,
20 RemoteRepo,
21 } from "../types.ts";
22 import { ForgejoProvider, nextPage, toRepo } from "./forgejo.ts";
23 import { NO_ACCOUNT } from "../words.ts";
24 import { asArray, asString, dig, fail, field, seg, splitFullName, succeed } from "./http.ts";
25
26 const NONE = { ok: true, value: [] } as const;
27
28 export class ForgeProvider extends ForgejoProvider {
29 /** The account is `username` on a forge, not Gitea's `login`. */
30 override async whoami(): Promise<ProviderResult<RemoteIdentity>> {
31 const result = await this.api.call({ path: "/user" });
32 if (!result.ok) return result;
33 const login = asString(field(result.value.data, "username"));
34 if (login === "") return fail("invalid_response", NO_ACCOUNT);
35 return succeed({
36 login,
37 displayName: asString(field(result.value.data, "displayName")) || login,
38 htmlUrl: `${this.webUrl}/${login}`,
39 });
40 }
41
42 /** The caller's own repositories, in the shape `/user/repos` answers. */
43 override async listRepos(cursor?: string): Promise<ProviderResult<Page<RemoteRepo>>> {
44 const page = Math.max(1, Number(cursor) || 1);
45 const limit = await this.pageSize();
46 const result = await this.api.call({ path: "/user/repos", query: { limit, page } });
47 if (!result.ok) return result;
48 const rows = asArray(result.value.data);
49 return succeed({
50 items: rows.map(toRepo),
51 next: nextPage(result.value.headers, rows.length, limit, page),
52 });
53 }
54
55 /** One repository, from a forge's own JSON (`ownerName`, `visibility`). */
56 override async getRepo(fullName: string): Promise<ProviderResult<RemoteRepo>> {
57 const named = splitFullName(fullName);
58 if (!named) return fail("not_found", `not a repository name: ${fullName}`);
59 const result = await this.api.call({
60 path: `/repos/${seg(named.owner)}/${seg(named.name)}`,
61 });
62 if (!result.ok) return result;
63 const raw = result.value.data;
64 const owner = asString(field(raw, "ownerName")) || named.owner;
65 const name = asString(field(raw, "name")) || named.name;
66 const web = `${this.webUrl}/${owner}/${name}`;
67 return succeed(
68 toRepo({
69 id: field(raw, "id"),
70 full_name: `${owner}/${name}`,
71 name,
72 owner: { login: owner },
73 description: field(raw, "description"),
74 private: asString(field(raw, "visibility")) !== "public",
75 fork: dig(raw, "forkParentId") !== null && dig(raw, "forkParentId") !== undefined,
76 default_branch: field(raw, "defaultBranch"),
77 clone_url: `${web}.git`,
78 html_url: web,
79 }),
80 );
81 }
82
83 override listLabels(): Promise<ProviderResult<readonly RemoteLabel[]>> {
84 return Promise.resolve(NONE);
85 }
86
87 /** Implements the optional `Provider.listMilestones`; Forgejo has none to override. */
88 listMilestones(): Promise<ProviderResult<readonly RemoteMilestone[]>> {
89 return Promise.resolve(NONE);
90 }
91
92 override listIssues(): Promise<ProviderResult<Page<RemoteIssue>>> {
93 return Promise.resolve({ ok: true, value: { items: [], next: null } });
94 }
95 }
src/origins/providers/forgejo.ts +4 −4
@@ -92,7 +92,7 @@ export class ForgejoProvider implements Provider {
9292 readonly kind: OriginKind;
9393 readonly apiUrl: string;
9494 readonly webUrl: string;
95 private readonly api: ApiClient;
95 protected readonly api: ApiClient;
9696 /** The instance's `max_response_items`, learned once. */
9797 private limit: number | undefined;
9898 /** Our own numeric id, needed for the repo search that sees org repos. */
@@ -116,7 +116,7 @@ export class ForgejoProvider implements Provider {
116116 * an error here, it is a silent truncation — the one pagination bug that
117117 * looks like a correct empty result.
118118 */
119 private async pageSize(): Promise<number> {
119 protected async pageSize(): Promise<number> {
120120 if (this.limit !== undefined) return this.limit;
121121 const result = await this.api.call({ path: "/settings/api" });
122122 const reported = result.ok
@@ -767,7 +767,7 @@ const pageOf = (cursor: string | undefined): number => {
767767 * deliberately conservative, because a page shorter than `limit` here means
768768 * "the server clamped you", not "the list ended".
769769 */
770 function nextPage(
770 export function nextPage(
771771 headers: Headers,
772772 received: number,
773773 limit: number,
@@ -780,7 +780,7 @@ function nextPage(
780780
781781 // ---- mapping ----
782782
783 function toRepo(raw: unknown): RemoteRepo {
783 export function toRepo(raw: unknown): RemoteRepo {
784784 const fullName = asString(field(raw, "full_name"));
785785 const named = splitFullName(fullName);
786786 return {
src/origins/providers/index.ts
Binary file not shown.
src/origins/types.ts +7
@@ -42,6 +42,11 @@ export interface Origin {
4242 * is how one expired token becomes a permanent error loop.
4343 */
4444 readonly needsReauth: boolean;
45 /**
46 * What the origin runs when its kind does not say: `forge` for another
47 * forge (stored as a Gitea origin, which is the dialect it speaks), else "".
48 */
49 readonly software: string;
4550 }
4651
4752 /** A repository as the origin describes it — the import picker's rows. */
@@ -463,6 +468,8 @@ export interface ProviderCredential {
463468 * Bearer for both. @default "pat"
464469 */
465470 readonly authKind?: AuthKind;
471 /** `forge` when the origin is another forge; see `Origin.software`. */
472 readonly software?: string;
466473 /**
467474 * The bearer secret for one call. A failure here is `unauthorized` — the
468475 * credential is gone or unrefreshable, and the origin needs reconnecting.
src/origins/words.ts +7
@@ -18,3 +18,10 @@ export const PATH_IS_FILE = "that path is a file, not a directory";
1818
1919 /** A file was asked of a path that is a directory. */
2020 export const PATH_IS_DIRECTORY = "that path is a directory, not a file";
21
22 /**
23 * What comes across from another forge, and what does not — said on the page
24 * and in the API, so a mirror of a friend's app never reads as the whole of it.
25 */
26 export const FORGE_NOT_CARRIED =
27 "Code comes across, and the repository's history. Not yet: issues, pulls, comments, labels, and how the app runs. Never by default: its data and its secrets.";
src/tui/screens/connections.ts +43 −6
@@ -4,16 +4,17 @@
44 * it after a confirm — through `actions/origins.ts`, in `origin_words.ts`'s words.
55 */
66
7 import { disconnectOrigin, listUserOrigins, syncOriginNow } from "../../actions/origins.ts";
8 import { DISCONNECT_DETAIL, NO_SUCH_ORIGIN, disconnectedWords, syncedWords } from "../../actions/origin_words.ts";
7 import { connectOrigin, disconnectOrigin, listUserOrigins, syncOriginNow } from "../../actions/origins.ts";
8 import { DISCONNECT_DETAIL, NO_SUCH_ORIGIN, connectWords, disconnectedWords, syncedWords } from "../../actions/origin_words.ts";
99 import { mirrorsByOrigin } from "../../core/origins.ts";
10 import { FORGE_NOT_CARRIED } from "../../origins/words.ts";
1011 import type { Ctx, Screen } from "../app/app.ts";
1112 import { binding } from "../app/keys.ts";
1213 import { style } from "../term/style.ts";
1314 import { truncate } from "../term/width.ts";
1415 import type { Session } from "../session.ts";
1516 import { fill, len, split } from "../ui/layout.ts";
16 import { ListState, confirmScreen, drawList } from "./common.ts";
17 import { ListState, confirmScreen, drawList, later, promptScreen } from "./common.ts";
1718 import type { PaletteEntry } from "./entries.ts";
1819
1920 const host = (webUrl: string): string => URL.parse(webUrl)?.host ?? webUrl;
@@ -26,12 +27,18 @@ export function connectionsScreen(f: Session, ctx: Ctx): Screen {
2627
2728 const screen: Screen = {
2829 title: "Connected origins",
29 keys: [binding(["j", "k"], "move cursor"), binding(["s"], "sync now"), binding(["d"], "disconnect"), binding(["esc"], "back")],
30 keys: [binding(["j", "k"], "move cursor"), binding(["c"], "connect"), binding(["s"], "sync now"), binding(["d"], "disconnect"), binding(["esc"], "back")],
3031 render(area, s) {
3132 const c = ctx.theme.c;
3233 const [head, list] = split(area, "v", [len(1), fill(1)]);
3334 const all = origins();
34 const text = all.length === 0 ? "No origin connected. Connect one in Settings › Origins." : "The hosts your mirrors come from.";
35 const picked = all[state.index];
36 const text =
37 picked?.software === "forge"
38 ? `Another forge. ${FORGE_NOT_CARRIED}`
39 : all.length === 0
40 ? "No origin connected. Press c to connect one."
41 : "The hosts your mirrors come from.";
3542 s.text(head!.x, head!.y, truncate(text, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w);
3643 const n = counts();
3744 drawList(
@@ -44,7 +51,7 @@ export function connectionsScreen(f: Session, ctx: Ctx): Screen {
4451 return {
4552 title: `${host(o.webUrl)} · ${o.login}`,
4653 ...(o.lastError !== "" ? { tone: "danger" as const } : {}),
47 secondary: `${k} repositor${k === 1 ? "y" : "ies"} · ${day(o.lastSyncAt)}`,
54 secondary: `${o.software === "forge" ? "another forge · " : ""}${k} repositor${k === 1 ? "y" : "ies"} · ${day(o.lastSyncAt)}`,
4855 meta: o.lastError !== "" ? o.lastError : o.authKind === "oauth" ? "OAuth" : "token",
4956 };
5057 }),
@@ -53,6 +60,36 @@ export function connectionsScreen(f: Session, ctx: Ctx): Screen {
5360 key(e) {
5461 if (state.key(e, origins().length)) return true;
5562 if (e.ctrl || e.alt || f.user === undefined) return false;
63 if (e.key === "c") {
64 const user = f.user;
65 ctx.push(
66 promptScreen(ctx, screen, {
67 title: "Connect an origin",
68 label: "Host or address (a forge, Forgejo, Gitea, GitHub)",
69 placeholder: "forge.example.com",
70 onSubmit: (hostText) => {
71 if (hostText === "") return "Type the address of the forge.";
72 return later(ctx, () =>
73 promptScreen(ctx, screen, {
74 title: "Connect an origin",
75 label: `Access token for ${hostText}`,
76 hint: "Made in that forge's settings; kept encrypted here.",
77 mask: true,
78 onSubmit: (token) => {
79 if (token.trim() === "") return "Paste the token.";
80 ctx.toast(`Connecting ${hostText}…`);
81 return connectOrigin(f.app, user, { host: hostText, token }).then((r) => {
82 ctx.toast(r.ok ? `Connected ${r.value.login} at ${host(r.value.webUrl)}.` : connectWords(r.error), r.ok ? "success" : "danger");
83 ctx.invalidate();
84 });
85 },
86 }),
87 );
88 },
89 }),
90 );
91 return true;
92 }
5693 const o = origins()[state.index];
5794 if (o === undefined) return false;
5895 const user = f.user;
src/web/pages/origins.ts +7 −12
@@ -17,6 +17,7 @@
1717 * where it was — that page and its poll live in `origin-flow.ts`.
1818 */
1919
20 import { FORGE_NOT_CARRIED } from "../../origins/words.ts";
2021 import {
2122 connectOrigin,
2223 deleteOriginApp,
@@ -31,6 +32,7 @@ import {
3132 } from "../../actions/origins.ts";
3233 import {
3334 NO_SUCH_ORIGIN,
35 connectWords,
3436 disconnectedWords,
3537 syncedWords,
3638 } from "../../actions/origin_words.ts";
@@ -351,6 +353,10 @@ function originCard(origin: Origin, mirrors: number): Html {
351353 </div>`,
352354 )}
353355 ${when(origin.needsReauth, () => reauthNote(origin, mirrors))}
356 ${when(
357 origin.software === "forge",
358 () => html`<p class="small mt">Another forge. ${FORGE_NOT_CARRIED}</p>`,
359 )}
354360 <div class="bar mt">
355361 <form method="post" action="${base}/sync">
356362 ${btn("Sync now", { size: "sm", icon: "sync" })}
@@ -915,18 +921,7 @@ export async function originConnectToken(ctx: Ctx): Promise<Response> {
915921 });
916922 }
917923
918 function connectMessage(error: string): string {
919 const words: Record<string, string> = {
920 unknown_host: "That does not look like a forge this can talk to.",
921 private_host: "That address is inside a network, not on the internet.",
922 unreachable: "That instance could not be reached.",
923 bad_token: "That token was refused by the origin.",
924 already_connected: "That account is already connected.",
925 rate_limited: "Too many connect attempts. Wait a few minutes.",
926 unauthorized: "Sign in first.",
927 };
928 return words[error] ?? "That connection could not be made.";
929 }
924 const connectMessage = connectWords;
930925
931926 // POST /settings/origins/oauth
932927 /**
test/api-list-shape.test.ts +2
@@ -20,6 +20,8 @@ const BARE: Record<string, string> = {
2020 "/api/v1/repos/:owner/:name/commits": "#5083",
2121 "/api/v1/user/origins": "#4841",
2222 "/api/v1/user/origin-apps": "#4841",
23 // The Gitea dialect a connecting forge reads is a bare array by definition (#8900).
24 "/api/v1/user/repos": "#8900",
2325 };
2426
2527 const FILL: Record<string, string> = {
test/forge-origin-edges.test.ts added +143
@@ -0,0 +1,143 @@
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 { createToken } from "../src/core/tokens.ts";
7 import { createUser, getUserByName } from "../src/core/users.ts";
8 import { isTokenScheme, tokenSecret } from "../src/http/token_header.ts";
9 import { serve, type Servers } from "../src/http/server.ts";
10 import { quietLogs } from "./helpers/globals.ts";
11
12 /**
13 * The edges of what another forge reads from this one (#8900): the `token`
14 * scheme, `GET /api/v1/user/repos` paged, capped and private to its owner.
15 */
16
17 quietLogs();
18
19 test("a token header holds a secret, or it is not a token header", () => {
20 expect(tokenSecret("Bearer abc")).toBe("abc");
21 expect(tokenSecret("token abc")).toBe("abc");
22 expect(tokenSecret("token ")).toBeNull();
23 expect(tokenSecret("Bearer ")).toBeNull();
24 expect(tokenSecret("token")).toBeNull();
25 expect(tokenSecret("tokenabc")).toBeNull();
26 expect(tokenSecret("Basic abc")).toBeNull();
27 expect(isTokenScheme("token")).toBe(false);
28 expect(isTokenScheme("token ")).toBe(true);
29 });
30
31 describe("the repositories a forge lists to another", () => {
32 let dir = "";
33 let app: App;
34 let servers: Servers;
35 let aliceToken = "";
36 let bobToken = "";
37
38 const get = (path: string, authorization?: string) =>
39 fetch(`${servers.url}${path}`, authorization === undefined ? {} : { headers: { authorization } });
40
41 beforeAll(async () => {
42 dir = await mkdtemp(join(tmpdir(), "forge-origin-edges-"));
43 // A tiny limit for this listener, so the token scheme's bucket can be seen.
44 process.env["FORGE_RATE_LIMIT"] = "30";
45 app = createApp({ dataDir: dir, port: 0 });
46 servers = await serve(app);
47 delete process.env["FORGE_RATE_LIMIT"];
48 for (const name of ["alice", "bob"]) {
49 const made = await createUser(app.db, {
50 origin: "seed",
51 trusted: true,
52 username: name,
53 email: `${name}@forge.local`,
54 password: "a-good-passphrase",
55 });
56 if (!made.ok) throw new Error(made.error);
57 }
58 const alice = getUserByName(app.db, "alice")!;
59 aliceToken = createToken(app.db, alice.id, "t", "ro").token;
60 bobToken = createToken(app.db, getUserByName(app.db, "bob")!.id, "t", "ro").token;
61 const insert = app.db.query(
62 `INSERT INTO repo (id, owner_id, name, visibility, description, default_branch) VALUES (?1, ?2, ?3, ?4, '', 'main')`,
63 );
64 app.db.transaction(() => {
65 for (let i = 1; i <= 1005; i++)
66 insert.run(`x${String(i).padStart(6, "0")}`, alice.id, `r${i}`, i === 500 ? "private" : "public");
67 })();
68 });
69
70 afterAll(async () => {
71 servers.stop();
72 app.close();
73 await rm(dir, { recursive: true, force: true, maxRetries: 5 });
74 });
75
76 test("a malformed token header is no one, and never a wrong slice", async () => {
77 for (const header of ["token", "token ", "Bearer ", "tokenxyz", `tok ${aliceToken}`])
78 expect((await get("/api/v1/user/repos", header)).status).toBe(401);
79 expect((await get("/api/v1/user/repos", `token ${aliceToken}`)).status).toBe(200);
80 });
81
82 test("the token scheme and Bearer are one bucket under the rate limit", async () => {
83 // Bob's own token: 30 a minute, shared by both spellings of the header.
84 let refused = 0;
85 for (let i = 0; i < 40; i++) {
86 const r = await get("/api/v1/version", i % 2 === 0 ? `token ${bobToken}` : `Bearer ${bobToken}`);
87 if (r.status === 429) refused++;
88 }
89 const after = await get("/api/v1/user/repos", `token ${bobToken}`);
90 expect(refused).toBeGreaterThan(0);
91 expect(after.status).toBe(429);
92 expect((await get("/api/v1/user/repos", `Bearer ${bobToken}`)).status).toBe(429);
93 });
94
95 test("pages follow page and limit, with the total and the next link", async () => {
96 const first = await get("/api/v1/user/repos?limit=10", `token ${aliceToken}`);
97 expect(first.status).toBe(200);
98 const rows = (await first.json()) as { full_name: string }[];
99 expect(rows.length).toBe(10);
100 expect(first.headers.get("x-total-count")).toBe("1000");
101 const link = first.headers.get("link") ?? "";
102 expect(link).toContain("page=2");
103 expect(link).toContain('rel="next"');
104
105 const second = (await (await get("/api/v1/user/repos?limit=10&page=2", `token ${aliceToken}`)).json()) as {
106 full_name: string;
107 }[];
108 expect(second.length).toBe(10);
109 expect(second.map((r) => r.full_name).filter((n) => rows.some((r) => r.full_name === n))).toEqual([]);
110
111 const last = await get("/api/v1/user/repos?limit=100&page=10", `token ${aliceToken}`);
112 expect(((await last.json()) as unknown[]).length).toBe(100);
113 expect(last.headers.get("link")).toBeNull();
114 const beyond = await get("/api/v1/user/repos?limit=100&page=11", `token ${aliceToken}`);
115 expect(await beyond.json()).toEqual([]);
116 });
117
118 test("a limit over 100 is 100, and nonsense is the default", async () => {
119 expect(((await (await get("/api/v1/user/repos?limit=5000", `token ${aliceToken}`)).json()) as unknown[]).length).toBe(100);
120 expect(((await (await get("/api/v1/user/repos?limit=-3&page=abc", `token ${aliceToken}`)).json()) as unknown[]).length).toBe(50);
121 });
122
123 test("1005 owned repositories are read as 1000, and say so", async () => {
124 const r = await get("/api/v1/user/repos?limit=1", `token ${aliceToken}`);
125 expect(r.headers.get("x-total-count")).toBe("1000");
126 });
127
128 test("the private repository is in its owner's list only; a guest has no list", async () => {
129 const names = async (auth: string) => {
130 const all: string[] = [];
131 for (let page = 1; page <= 10; page++) {
132 const rows = (await (await get(`/api/v1/user/repos?limit=100&page=${page}`, auth)).json()) as { full_name: string }[];
133 all.push(...rows.map((r) => r.full_name));
134 }
135 return all;
136 };
137 expect(await names(`token ${aliceToken}`)).toContain("alice/r500");
138 const asBob = await get("/api/v1/user/repos?limit=100", `Basic ${Buffer.from("bob:a-good-passphrase").toString("base64")}`);
139 expect(asBob.status).toBe(200);
140 expect(await asBob.json()).toEqual([]);
141 expect((await get("/api/v1/user/repos")).status).toBe(401);
142 });
143 });
test/forge-origin.test.ts added +166
@@ -0,0 +1,166 @@
1 import { afterAll, beforeAll, describe, expect, setDefaultTimeout, 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 { createToken } from "../src/core/tokens.ts";
7 import { getUserByName } from "../src/core/users.ts";
8 import { discover } from "../src/origins/discover.ts";
9 import { resolveOid } from "../src/git/merge.ts";
10 import { repoDir } from "../src/git/repos.ts";
11 import { serve, type Servers } from "../src/http/server.ts";
12 import { syncsSettled } from "../src/origins/sync.ts";
13 import { mirrorAndWait } from "./fake-origin.ts";
14 import { seedAdmin } from "./helpers/admin.ts";
15 import { proveAll } from "./helpers/verified.ts";
16
17 /**
18 * One forge connects to another as an origin (#8900): two real forges, one
19 * friend's with a private and a public repository, and a pasted token that
20 * lets this one see both and bring the private one across.
21 */
22
23 setDefaultTimeout(60_000);
24
25 const ALICE = "alice-login-secret";
26 const BOB = "bob-login-secret";
27 const basic = (user: string, secret: string): string =>
28 `Basic ${Buffer.from(`${user}:${secret}`).toString("base64")}`;
29
30 describe("a forge is an origin", () => {
31 let dirs: string[] = [];
32 let mine: App;
33 let friend: App;
34 let mineServers: Servers;
35 let friendServers: Servers;
36 let friendToken = "";
37 let originId = "";
38
39 const mineApi = (path: string, init: RequestInit = {}) => {
40 const headers = new Headers(init.headers);
41 headers.set("authorization", basic("alice", ALICE));
42 if (init.body !== undefined) headers.set("content-type", "application/json");
43 return fetch(`${mineServers.url}${path}`, { ...init, headers });
44 };
45
46 async function makeRepo(visibility: "public" | "private", name: string): Promise<void> {
47 const headers = { "content-type": "application/json", authorization: basic("bob", BOB) };
48 const made = await fetch(`${friendServers.url}/api/v1/repos`, {
49 method: "POST",
50 headers,
51 body: JSON.stringify({ name, visibility }),
52 });
53 expect(made.status).toBe(201);
54 const wrote = await fetch(`${friendServers.url}/api/v1/repos/bob/${name}/contents`, {
55 method: "POST",
56 headers,
57 body: JSON.stringify({
58 branch: "main",
59 message: "first",
60 files: [{ path: "README.md", content: `${name}\n` }],
61 }),
62 });
63 expect(wrote.status).toBeLessThan(300);
64 }
65
66 beforeAll(async () => {
67 const make = async () => {
68 const dir = await mkdtemp(join(tmpdir(), "forge-forge-origin-"));
69 dirs.push(dir);
70 return dir;
71 };
72 friend = createApp({ dataDir: await make(), port: 0 });
73 friendServers = await serve(friend);
74 await seedAdmin(friend, { username: "bob", email: "[email protected]", password: BOB });
75 proveAll(friend);
76 friendToken = createToken(friend.db, getUserByName(friend.db, "bob")!.id, "alice", "ro").token;
77 await makeRepo("private", "secret-app");
78 await makeRepo("public", "open-app");
79
80 mine = createApp({ dataDir: await make(), port: 0 });
81 mineServers = await serve(mine);
82 await seedAdmin(mine, { username: "alice", email: "[email protected]", password: ALICE });
83 proveAll(mine);
84 }, 120_000);
85
86 afterAll(async () => {
87 await syncsSettled();
88 mineServers.stop();
89 friendServers.stop();
90 mine.close();
91 friend.close();
92 for (const dir of dirs) await rm(dir, { recursive: true, force: true, maxRetries: 5 });
93 }, 120_000);
94
95 test("a forge answers its version, and discover recognises it", async () => {
96 const version = await fetch(`${friendServers.url}/api/v1/version`);
97 expect(version.status).toBe(200);
98 expect(await version.json()).toMatchObject({ software: "forge" });
99 const found = await discover(friendServers.url, { FORGE_DEV: "1" });
100 expect(found.ok).toBe(true);
101 if (found.ok) {
102 expect(found.value.via).toBe("version-endpoint");
103 expect(found.value.software).toStartWith("forge ");
104 expect(found.value.apiUrl).toBe(`${friendServers.url}/api/v1`);
105 }
106 });
107
108 test("the token scheme a Gitea client sends is a token here", async () => {
109 const as = (authorization: string) =>
110 fetch(`${friendServers.url}/api/v1/user`, { headers: { authorization } });
111 const ok = await as(`token ${friendToken}`);
112 expect(ok.status).toBe(200);
113 expect(await ok.json()).toMatchObject({ username: "bob" });
114 expect((await as("token forge_nope")).status).toBe(401);
115 });
116
117 test("pasting a token connects, and the picker lists the private repository", async () => {
118 const connected = await mineApi("/api/v1/user/origins", {
119 method: "POST",
120 body: JSON.stringify({ host: friendServers.url, token: friendToken }),
121 });
122 expect(connected.status).toBe(201);
123 const origin = (await connected.json()) as { id: string; login: string; token?: string };
124 expect(origin.login).toBe("bob");
125 expect(JSON.stringify(origin)).not.toContain(friendToken);
126 originId = origin.id;
127
128 const listed = await mineApi(`/api/v1/user/origins/${originId}/repos`);
129 expect(listed.status).toBe(200);
130 const text = await listed.text();
131 expect(text).toContain("bob/secret-app");
132 expect(text).toContain("bob/open-app");
133 expect(text).not.toContain(friendToken);
134 });
135
136 test("a private repository comes across with the credential", async () => {
137 const job = await mirrorAndWait(
138 await mineApi(`/api/v1/user/origins/${originId}/mirror`, {
139 method: "POST",
140 body: JSON.stringify({ fullName: "bob/secret-app" }),
141 }),
142 );
143 expect(job?.stage).toBe("done");
144 const theirs = await resolveOid(repoDir(friend.reposRoot, "bob", "secret-app"), "refs/heads/main");
145 const ours = await resolveOid(repoDir(mine.reposRoot, "alice", "secret-app"), "refs/heads/main");
146 expect(theirs).toBeDefined();
147 expect(ours).toBe(theirs);
148 });
149
150 test("the origin says what did not come", async () => {
151 const listed = await mineApi("/api/v1/user/origins");
152 const rows = (await listed.json()) as { id: string; software: string; carries: string | null }[];
153 const row = rows.find((o) => o.id === originId)!;
154 expect(row.software).toBe("forge");
155 expect(row.carries).toContain("data and its secrets");
156 const page = await (await mineApi("/settings/origins")).text();
157 expect(page.replace(/\s+/g, " ")).toContain("Another forge.");
158 });
159
160 test("without a token the private repository stays private", async () => {
161 const anon = await fetch(`${friendServers.url}/bob/secret-app.git/info/refs?service=git-upload-pack`);
162 expect(anon.status).not.toBe(200);
163 const listed = await fetch(`${friendServers.url}/api/v1/user/repos`);
164 expect(listed.status).toBe(401);
165 });
166 });
test/origin-words.test.ts +1 −1
@@ -15,7 +15,7 @@ const PROVIDERS = [
1515 const sentences = Object.entries(words) as [string, string][];
1616
1717 test("each sentence is non-empty", () => {
18 expect(sentences.length).toBe(5);
18 expect(sentences.length).toBe(6);
1919 for (const [, s] of sentences) expect(s.trim().length).toBeGreaterThan(0);
2020 });
2121
test/tui-origins-forge.test.ts added +134
@@ -0,0 +1,134 @@
1 /** Another forge as an origin, in the terminal: connected by `c`, shown with what it does not carry. */
2
3 import { afterAll, beforeAll, expect, test } from "bun:test";
4 import { mkdtemp, rm } from "node:fs/promises";
5 import { tmpdir } from "node:os";
6 import { join } from "node:path";
7 import { createApp, type App as Forge } from "../src/app.ts";
8 import { createToken } from "../src/core/tokens.ts";
9 import { listOrigins } from "../src/core/origins.ts";
10 import { createUser, getUserByName } from "../src/core/users.ts";
11 import { serve, type Servers } from "../src/http/server.ts";
12 import { createForgeApp } from "../src/tui/forge.ts";
13 import type { TuiIdentity } from "../src/tui/session.ts";
14 import { headlessTerminal } from "../src/tui/term/terminal.ts";
15 import { quietLogs } from "./helpers/globals.ts";
16 import { settleApp } from "./helpers/tui.ts";
17
18 quietLogs();
19
20 let dirs: string[] = [];
21 let mine: Forge;
22 let friend: Forge;
23 let friendServers: Servers;
24 let friendToken = "";
25
26 async function seed(app: Forge, name: string): Promise<void> {
27 const made = await createUser(app.db, {
28 origin: "seed",
29 trusted: true,
30 username: name,
31 email: `${name}@forge.local`,
32 password: "a-good-passphrase",
33 });
34 if (!made.ok) throw new Error(made.error);
35 }
36
37 beforeAll(async () => {
38 const make = async () => {
39 const dir = await mkdtemp(join(tmpdir(), "forge-tui-forge-origin-"));
40 dirs.push(dir);
41 return dir;
42 };
43 friend = createApp({ dataDir: await make(), port: 0 });
44 friendServers = await serve(friend);
45 await seed(friend, "bob");
46 friendToken = createToken(friend.db, getUserByName(friend.db, "bob")!.id, "alice", "ro").token;
47 mine = createApp({ dataDir: await make(), port: 0 });
48 await seed(mine, "alice");
49 });
50
51 afterAll(async () => {
52 friendServers.stop();
53 mine.close();
54 friend.close();
55 for (const dir of dirs) await rm(dir, { recursive: true, force: true, maxRetries: 5 });
56 });
57
58 test("c connects another forge by address and token, and the list says what did not come", async () => {
59 const terminal = headlessTerminal(140, 32);
60 const identity = { sshUsername: "t", remoteAddress: "127.0.0.1", user: getUserByName(mine.db, "alice")! } as TuiIdentity;
61 const { app } = createForgeApp(mine, identity, { terminal, exitHooks: false });
62 app.start();
63 const key = async (k: string) => {
64 app.dispatch({ type: "key", key: k, ctrl: k === "k", alt: false, shift: false });
65 await settleApp(app);
66 };
67 const type = async (t: string) => {
68 for (const ch of t) app.dispatch({ type: "key", key: ch, ctrl: false, alt: false, shift: false });
69 await settleApp(app);
70 };
71 try {
72 await key("k");
73 await type("Connected origins");
74 await key("enter");
75 expect(app.screen.toText()).toContain("Press c to connect one.");
76
77 await key("c");
78 await type(friendServers.url);
79 await key("enter");
80 expect(app.screen.toText()).toContain("Access token for");
81 await type(friendToken);
82 expect(app.screen.toText()).not.toContain(friendToken);
83 await key("enter");
84 for (let i = 0; i < 50 && listOrigins(mine.db, getUserByName(mine.db, "alice")!.id).length === 0; i++) await Bun.sleep(100);
85 await settleApp(app);
86
87 const [origin] = listOrigins(mine.db, getUserByName(mine.db, "alice")!.id);
88 expect(origin?.software).toBe("forge");
89 expect(origin?.login).toBe("bob");
90 const text = app.screen.toText().replace(/\s+/g, " ");
91 expect(text).toContain("another forge");
92 expect(text).toContain("Another forge. Code comes across");
93 expect(text).not.toContain(friendToken);
94 } finally {
95 app.stop();
96 }
97 });
98
99 test("a token the forge refuses connects nothing and says so", async () => {
100 await seed(mine, "carol");
101 const carol = getUserByName(mine.db, "carol")!;
102 const terminal = headlessTerminal(140, 32);
103 const { app } = createForgeApp(mine, { sshUsername: "t", remoteAddress: "127.0.0.1", user: carol } as TuiIdentity, {
104 terminal,
105 exitHooks: false,
106 });
107 app.start();
108 const press = async (k: string, ctrl = false) => {
109 app.dispatch({ type: "key", key: k, ctrl, alt: false, shift: false });
110 await settleApp(app);
111 };
112 const type = async (t: string) => {
113 for (const ch of t) app.dispatch({ type: "key", key: ch, ctrl: false, alt: false, shift: false });
114 await settleApp(app);
115 };
116 try {
117 await press("k", true);
118 await type("Connected origins");
119 await press("enter");
120 await press("c");
121 await type(friendServers.url);
122 await press("enter");
123 await type("forge_not_a_token");
124 await press("enter");
125 for (let i = 0; i < 30 && !app.screen.toText().includes("refused"); i++) {
126 await Bun.sleep(100);
127 await settleApp(app);
128 }
129 expect(app.screen.toText()).toContain("That token was refused by the origin.");
130 expect(listOrigins(mine.db, carol.id)).toHaveLength(0);
131 } finally {
132 app.stop();
133 }
134 });
test/tui-waves.test.ts +1
@@ -54,6 +54,7 @@ const remoteOf = (src: RepoSource): RepoSource => ({
5454 authKind: "pat",
5555 expiresAt: null,
5656 needsReauth: false,
57 software: "",
5758 },
5859 fullName: "someone/else",
5960 htmlUrl: "https://github.test/someone/else",

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs