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
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/8900-one-forge-connects-to-another-as-an-orig → main 5 commits fork
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 => | ||
| 16 | 16 | /** What the confirm page tells you before a disconnect. */ |
| 17 | 17 | export const DISCONNECT_DETAIL = |
| 18 | 18 | "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( | ||
| 181 | 181 | kind: found.value.kind, |
| 182 | 182 | apiUrl: found.value.apiUrl, |
| 183 | 183 | webUrl: found.value.webUrl, |
| 184 | software: softwareOf(found.value.software), | |
| 184 | 185 | token: input.token.trim(), |
| 185 | 186 | }), |
| 186 | 187 | ).whoami(); |
| @@ -196,6 +197,7 @@ export async function connectOrigin( | ||
| 196 | 197 | login: identity.value.login, |
| 197 | 198 | token: input.token.trim(), |
| 198 | 199 | authKind: "pat", |
| 200 | software: softwareOf(found.value.software), | |
| 199 | 201 | }); |
| 200 | 202 | if (!stored.ok) return stored; |
| 201 | 203 | return ok({ |
| @@ -204,6 +206,9 @@ export async function connectOrigin( | ||
| 204 | 206 | }); |
| 205 | 207 | } |
| 206 | 208 | |
| 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 | ||
| 207 | 212 | export type ReplaceTokenError = |
| 208 | 213 | | "unauthorized" |
| 209 | 214 | | "not_found" |
| @@ -238,6 +243,7 @@ export async function replaceOriginToken( | ||
| 238 | 243 | kind: origin.kind, |
| 239 | 244 | apiUrl: origin.apiUrl, |
| 240 | 245 | webUrl: origin.webUrl, |
| 246 | software: origin.software, | |
| 241 | 247 | token: secret, |
| 242 | 248 | }), |
| 243 | 249 | ).whoami(); |
| @@ -287,6 +293,7 @@ function storeOrigin( | ||
| 287 | 293 | authKind: "pat" | "oauth"; |
| 288 | 294 | refreshToken?: string; |
| 289 | 295 | expiresAt?: string | null; |
| 296 | software?: string; | |
| 290 | 297 | }, |
| 291 | 298 | ): Result<Origin, "already_connected"> { |
| 292 | 299 | // Connecting an account whose credential died is re-authorizing it: the |
| @@ -330,6 +337,7 @@ function storeOrigin( | ||
| 330 | 337 | ? {} |
| 331 | 338 | : { refreshToken: input.refreshToken }), |
| 332 | 339 | ...(input.expiresAt === undefined ? {} : { expiresAt: input.expiresAt }), |
| 340 | ...(input.software === undefined ? {} : { software: input.software }), | |
| 333 | 341 | }); |
| 334 | 342 | if (!created.ok) return created; |
| 335 | 343 | // 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 | ||
| 177 | 177 | export { route as getApiV1UserOriginsIdRepos } from "./getApiV1UserOriginsIdRepos.ts"; |
| 178 | 178 | export { route as getApiV1UserOriginsOauthId } from "./getApiV1UserOriginsOauthId.ts"; |
| 179 | 179 | export { route as getApiV1UserPins } from "./getApiV1UserPins.ts"; |
| 180 | export { route as getApiV1UserRepos } from "./getApiV1UserRepos.ts"; | |
| 180 | 181 | export { route as getApiV1UsersUsername } from "./getApiV1UsersUsername.ts"; |
| 181 | 182 | export { route as getApiV1UsersUsernameRepos } from "./getApiV1UsersUsernameRepos.ts"; |
| 182 | 183 | export { route as getApiV1UsersUsernameSignins } from "./getApiV1UsersUsernameSignins.ts"; |
| 184 | export { route as getApiV1Version } from "./getApiV1Version.ts"; | |
| 183 | 185 | export { route as getContractOpenapiJson } from "./getContractOpenapiJson.ts"; |
| 184 | 186 | export { route as getMetrics } from "./getMetrics.ts"; |
| 185 | 187 | 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 { | ||
| 35 | 35 | refresh_token_enc: string; |
| 36 | 36 | expires_at: string | null; |
| 37 | 37 | needs_reauth: number; |
| 38 | software: string; | |
| 38 | 39 | } |
| 39 | 40 | |
| 40 | 41 | function toOrigin(row: OriginRow): Origin { |
| @@ -51,6 +52,7 @@ function toOrigin(row: OriginRow): Origin { | ||
| 51 | 52 | authKind: row.auth_kind, |
| 52 | 53 | expiresAt: row.expires_at, |
| 53 | 54 | needsReauth: row.needs_reauth === 1, |
| 55 | software: row.software, | |
| 54 | 56 | }; |
| 55 | 57 | } |
| 56 | 58 | |
| @@ -68,6 +70,8 @@ export interface CreateOriginInput { | ||
| 68 | 70 | refreshToken?: string; |
| 69 | 71 | /** OAuth only: when the access token stops working. */ |
| 70 | 72 | expiresAt?: string | null; |
| 73 | /** `forge` for another forge; see `Origin.software`. */ | |
| 74 | software?: string; | |
| 71 | 75 | } |
| 72 | 76 | |
| 73 | 77 | export type CreateOriginError = "already_connected"; |
| @@ -93,8 +97,8 @@ export function createOrigin( | ||
| 93 | 97 | const originId = id(); |
| 94 | 98 | db.query( |
| 95 | 99 | `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)`, | |
| 98 | 102 | ).run( |
| 99 | 103 | originId, |
| 100 | 104 | input.userId, |
| @@ -108,6 +112,7 @@ export function createOrigin( | ||
| 108 | 112 | ? "" |
| 109 | 113 | : seal(dataDir, input.refreshToken, originId), |
| 110 | 114 | input.expiresAt ?? null, |
| 115 | input.software ?? "", | |
| 111 | 116 | ); |
| 112 | 117 | return ok(getOriginById(db, originId)!); |
| 113 | 118 | } |
src/http/api.ts +6
| @@ -334,6 +334,8 @@ import { tokenRoutes } from "./api/tokens.ts"; | ||
| 334 | 334 | import { accountAdmin, adminRoutes } from "./api/admin.ts"; |
| 335 | 335 | import { flagsRoutes } from "./api/flags.ts"; |
| 336 | 336 | import { issueRoutes, labelsHandler } from "./api/issues.ts"; |
| 337 | import { FORGE_NOT_CARRIED } from "../origins/words.ts"; | |
| 338 | import { forgeOriginRoutes } from "./api/forge_origin.ts"; | |
| 337 | 339 | import { userRoutes } from "./api/user.ts"; |
| 338 | 340 | import { appRoutes } from "./api/app.ts"; |
| 339 | 341 | import { appNoteRoutes } from "./api/app_notes.ts"; |
| @@ -539,6 +541,9 @@ export function originJson(origin: Origin) { | ||
| 539 | 541 | lastError: origin.lastError, |
| 540 | 542 | needsReauth: origin.needsReauth, |
| 541 | 543 | 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, | |
| 542 | 547 | }; |
| 543 | 548 | } |
| 544 | 549 | |
| @@ -1157,6 +1162,7 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 1157 | 1162 | tokenRoutes(router); |
| 1158 | 1163 | |
| 1159 | 1164 | userRoutes(router); |
| 1165 | forgeOriginRoutes(router); | |
| 1160 | 1166 | adminRoutes(router); |
| 1161 | 1167 | flagsRoutes(router); |
| 1162 | 1168 |
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 @@ | ||
| 5 | 5 | * verdict. Auth is resolved once per request and handed to handlers as data. |
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | import { isTokenScheme, tokenSecret } from "./token_header.ts"; | |
| 8 | 9 | import { followUpstreamCheck, followUpstreamUpdate } from "../web/pages/upstream-follow.ts"; |
| 9 | 10 | import { secureFor } from "./cookie_secure.ts"; |
| 10 | 11 | import { refusedByDatabase, shownRefusal } from "./refusal.ts"; |
| @@ -541,9 +542,12 @@ async function resolveAuth(app: App, request: Request): Promise<Auth> { | ||
| 541 | 542 | const header = request.headers.get("authorization"); |
| 542 | 543 | if (!header) return NO_AUTH; |
| 543 | 544 | |
| 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); | |
| 547 | 551 | if (verified.ok) { |
| 548 | 552 | return { |
| 549 | 553 | user: verified.value.user, |
| @@ -1555,7 +1559,10 @@ function tokenCredential(request: Request, auth: Auth): string | undefined { | ||
| 1555 | 1559 | if (auth.via !== "token") return undefined; |
| 1556 | 1560 | const header = request.headers.get("authorization"); |
| 1557 | 1561 | 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 | } | |
| 1559 | 1566 | if (header.startsWith("Basic ")) { |
| 1560 | 1567 | const decoded = Buffer.from(header.slice(6), "base64").toString(); |
| 1561 | 1568 | 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( | ||
| 58 | 58 | webUrl: origin.webUrl, |
| 59 | 59 | login: origin.login, |
| 60 | 60 | authKind: origin.authKind, |
| 61 | software: origin.software, | |
| 61 | 62 | token: () => resolveToken(app, origin.id), |
| 62 | 63 | }); |
| 63 | 64 | } |
src/origins/discover.ts +4 −1
| @@ -230,13 +230,16 @@ async function viaVersionEndpoint( | ||
| 230 | 230 | if (!doc.ok) return undefined; |
| 231 | 231 | const version = asString(field(doc.value, "version")); |
| 232 | 232 | 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"; | |
| 233 | 236 | const kind: OriginKind = version.includes("+gitea-") ? "forgejo" : "gitea"; |
| 234 | 237 | return { |
| 235 | 238 | kind, |
| 236 | 239 | apiUrl: `${web}/api/v1`, |
| 237 | 240 | webUrl: web, |
| 238 | 241 | via: "version-endpoint", |
| 239 | software: version, | |
| 242 | software: forge ? `forge ${version}` : version, | |
| 240 | 243 | }; |
| 241 | 244 | } |
| 242 | 245 |
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 { | ||
| 92 | 92 | readonly kind: OriginKind; |
| 93 | 93 | readonly apiUrl: string; |
| 94 | 94 | readonly webUrl: string; |
| 95 | private readonly api: ApiClient; | |
| 95 | protected readonly api: ApiClient; | |
| 96 | 96 | /** The instance's `max_response_items`, learned once. */ |
| 97 | 97 | private limit: number | undefined; |
| 98 | 98 | /** Our own numeric id, needed for the repo search that sees org repos. */ |
| @@ -116,7 +116,7 @@ export class ForgejoProvider implements Provider { | ||
| 116 | 116 | * an error here, it is a silent truncation — the one pagination bug that |
| 117 | 117 | * looks like a correct empty result. |
| 118 | 118 | */ |
| 119 | private async pageSize(): Promise<number> { | |
| 119 | protected async pageSize(): Promise<number> { | |
| 120 | 120 | if (this.limit !== undefined) return this.limit; |
| 121 | 121 | const result = await this.api.call({ path: "/settings/api" }); |
| 122 | 122 | const reported = result.ok |
| @@ -767,7 +767,7 @@ const pageOf = (cursor: string | undefined): number => { | ||
| 767 | 767 | * deliberately conservative, because a page shorter than `limit` here means |
| 768 | 768 | * "the server clamped you", not "the list ended". |
| 769 | 769 | */ |
| 770 | function nextPage( | |
| 770 | export function nextPage( | |
| 771 | 771 | headers: Headers, |
| 772 | 772 | received: number, |
| 773 | 773 | limit: number, |
| @@ -780,7 +780,7 @@ function nextPage( | ||
| 780 | 780 | |
| 781 | 781 | // ---- mapping ---- |
| 782 | 782 | |
| 783 | function toRepo(raw: unknown): RemoteRepo { | |
| 783 | export function toRepo(raw: unknown): RemoteRepo { | |
| 784 | 784 | const fullName = asString(field(raw, "full_name")); |
| 785 | 785 | const named = splitFullName(fullName); |
| 786 | 786 | return { |
src/origins/providers/index.ts
Binary file not shown.
src/origins/types.ts +7
| @@ -42,6 +42,11 @@ export interface Origin { | ||
| 42 | 42 | * is how one expired token becomes a permanent error loop. |
| 43 | 43 | */ |
| 44 | 44 | 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; | |
| 45 | 50 | } |
| 46 | 51 | |
| 47 | 52 | /** A repository as the origin describes it — the import picker's rows. */ |
| @@ -463,6 +468,8 @@ export interface ProviderCredential { | ||
| 463 | 468 | * Bearer for both. @default "pat" |
| 464 | 469 | */ |
| 465 | 470 | readonly authKind?: AuthKind; |
| 471 | /** `forge` when the origin is another forge; see `Origin.software`. */ | |
| 472 | readonly software?: string; | |
| 466 | 473 | /** |
| 467 | 474 | * The bearer secret for one call. A failure here is `unauthorized` — the |
| 468 | 475 | * 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"; | ||
| 18 | 18 | |
| 19 | 19 | /** A file was asked of a path that is a directory. */ |
| 20 | 20 | 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 @@ | ||
| 4 | 4 | * it after a confirm — through `actions/origins.ts`, in `origin_words.ts`'s words. |
| 5 | 5 | */ |
| 6 | 6 | |
| 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"; | |
| 9 | 9 | import { mirrorsByOrigin } from "../../core/origins.ts"; |
| 10 | import { FORGE_NOT_CARRIED } from "../../origins/words.ts"; | |
| 10 | 11 | import type { Ctx, Screen } from "../app/app.ts"; |
| 11 | 12 | import { binding } from "../app/keys.ts"; |
| 12 | 13 | import { style } from "../term/style.ts"; |
| 13 | 14 | import { truncate } from "../term/width.ts"; |
| 14 | 15 | import type { Session } from "../session.ts"; |
| 15 | 16 | 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"; | |
| 17 | 18 | import type { PaletteEntry } from "./entries.ts"; |
| 18 | 19 | |
| 19 | 20 | const host = (webUrl: string): string => URL.parse(webUrl)?.host ?? webUrl; |
| @@ -26,12 +27,18 @@ export function connectionsScreen(f: Session, ctx: Ctx): Screen { | ||
| 26 | 27 | |
| 27 | 28 | const screen: Screen = { |
| 28 | 29 | 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")], | |
| 30 | 31 | render(area, s) { |
| 31 | 32 | const c = ctx.theme.c; |
| 32 | 33 | const [head, list] = split(area, "v", [len(1), fill(1)]); |
| 33 | 34 | 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."; | |
| 35 | 42 | s.text(head!.x, head!.y, truncate(text, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w); |
| 36 | 43 | const n = counts(); |
| 37 | 44 | drawList( |
| @@ -44,7 +51,7 @@ export function connectionsScreen(f: Session, ctx: Ctx): Screen { | ||
| 44 | 51 | return { |
| 45 | 52 | title: `${host(o.webUrl)} · ${o.login}`, |
| 46 | 53 | ...(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)}`, | |
| 48 | 55 | meta: o.lastError !== "" ? o.lastError : o.authKind === "oauth" ? "OAuth" : "token", |
| 49 | 56 | }; |
| 50 | 57 | }), |
| @@ -53,6 +60,36 @@ export function connectionsScreen(f: Session, ctx: Ctx): Screen { | ||
| 53 | 60 | key(e) { |
| 54 | 61 | if (state.key(e, origins().length)) return true; |
| 55 | 62 | 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 | } | |
| 56 | 93 | const o = origins()[state.index]; |
| 57 | 94 | if (o === undefined) return false; |
| 58 | 95 | const user = f.user; |
src/web/pages/origins.ts +7 −12
| @@ -17,6 +17,7 @@ | ||
| 17 | 17 | * where it was — that page and its poll live in `origin-flow.ts`. |
| 18 | 18 | */ |
| 19 | 19 | |
| 20 | import { FORGE_NOT_CARRIED } from "../../origins/words.ts"; | |
| 20 | 21 | import { |
| 21 | 22 | connectOrigin, |
| 22 | 23 | deleteOriginApp, |
| @@ -31,6 +32,7 @@ import { | ||
| 31 | 32 | } from "../../actions/origins.ts"; |
| 32 | 33 | import { |
| 33 | 34 | NO_SUCH_ORIGIN, |
| 35 | connectWords, | |
| 34 | 36 | disconnectedWords, |
| 35 | 37 | syncedWords, |
| 36 | 38 | } from "../../actions/origin_words.ts"; |
| @@ -351,6 +353,10 @@ function originCard(origin: Origin, mirrors: number): Html { | ||
| 351 | 353 | </div>`, |
| 352 | 354 | )} |
| 353 | 355 | ${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 | )} | |
| 354 | 360 | <div class="bar mt"> |
| 355 | 361 | <form method="post" action="${base}/sync"> |
| 356 | 362 | ${btn("Sync now", { size: "sm", icon: "sync" })} |
| @@ -915,18 +921,7 @@ export async function originConnectToken(ctx: Ctx): Promise<Response> { | ||
| 915 | 921 | }); |
| 916 | 922 | } |
| 917 | 923 | |
| 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; | |
| 930 | 925 | |
| 931 | 926 | // POST /settings/origins/oauth |
| 932 | 927 | /** |
test/api-list-shape.test.ts +2
| @@ -20,6 +20,8 @@ const BARE: Record<string, string> = { | ||
| 20 | 20 | "/api/v1/repos/:owner/:name/commits": "#5083", |
| 21 | 21 | "/api/v1/user/origins": "#4841", |
| 22 | 22 | "/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", | |
| 23 | 25 | }; |
| 24 | 26 | |
| 25 | 27 | 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 = [ | ||
| 15 | 15 | const sentences = Object.entries(words) as [string, string][]; |
| 16 | 16 | |
| 17 | 17 | test("each sentence is non-empty", () => { |
| 18 | expect(sentences.length).toBe(5); | |
| 18 | expect(sentences.length).toBe(6); | |
| 19 | 19 | for (const [, s] of sentences) expect(s.trim().length).toBeGreaterThan(0); |
| 20 | 20 | }); |
| 21 | 21 |
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 => ({ | ||
| 54 | 54 | authKind: "pat", |
| 55 | 55 | expiresAt: null, |
| 56 | 56 | needsReauth: false, |
| 57 | software: "", | |
| 57 | 58 | }, |
| 58 | 59 | fullName: "someone/else", |
| 59 | 60 | htmlUrl: "https://github.test/someone/else", |
new above
offline