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

Land tdobbertin-espo's 6 pulls from the Forgejo mirror onto trunk #8979

Merged opened this in answer to

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

Merged

mason/forge:mason/8938-land-tdobbertin-espo-s-6-pulls-from-the → main 11 commits fork 2 threads · 2 unresolved

  • Merged. mason/8938-land-tdobbertin-espo-s-6-pulls-from-the was merged into main as d8a7ab0.

Review

Sign in to review this change.
53 files changed +3882 −265
docs/OPERATING.md +2 −1
@@ -13,7 +13,8 @@ routers learn it is going, so a roll refuses nobody (`docs/ops/ROLL.md`).
1313 Connecting origins
1414 adds `FORGE_URL` (the canonical public base URL, needed for OAuth callbacks),
1515 `FORGE_GITHUB_CLIENT_ID` / `FORGE_GITHUB_CLIENT_SECRET`, and
16 `FORGE_ORIGIN_ALLOW` (hosts an operator vouches for on a LAN). Behind a
16 `FORGE_ORIGIN_ALLOW` (hosts an operator vouches for on a LAN, for discovery
17 and for that origin's git). Behind a
1718 platform edge that signs people in for every app it serves, `FORGE_EDGE_TOKEN`
1819 is the edge's secret: a request carrying it and `X-Plat-User` is that person,
1920 signed in by the platform if an account of that name exists. An unknown login
docs/ORIGINS.md +143 −13
@@ -70,7 +70,7 @@ them is correct.
7070
7171 ## Connecting and mirroring, as a person does it
7272
73 Outbound git refuses a host that resolves to a private address. A site admin can vouch for exact host names (the owner's own Forgejo, reached by its in-cluster address): `FORGE_OUTBOUND_TRUSTED_HOSTS=git.example.test` (comma or space separated), plus the same list editable by an admin on **Settings → Origins**. A trusted host may resolve privately for origin sync, mirror, migrate and write-through; trust is by name, never by address range, and webhooks never read it. The origin row says "trusted internal host". In the terminal, the palette's **Trusted hosts** reads and saves the same list (admins only, same audit). An entry that is not a bare host name (an address, a wildcard, a URL) is refused with its name, on the page and in the terminal, never silently dropped.
73 Outbound git refuses a host that resolves to a private address. A site admin can vouch for exact host names (the owner's own Forgejo, reached by its in-cluster address): `FORGE_OUTBOUND_TRUSTED_HOSTS=git.example.test` (comma or space separated), plus the same list editable by an admin on **Settings → Origins**. A trusted host may resolve privately for origin sync, mirror, migrate and write-through; trust is by name, never by address range, and webhooks never read it. A host name in `FORGE_ORIGIN_ALLOW` is trusted the same way for its origin's mirror, sync and fetch, so an origin that connects can be mirrored; an address named there is not (trust is by name). The origin row says "trusted internal host". In the terminal, the palette's **Trusted hosts** reads and saves the same list (admins only, same audit). An entry that is not a bare host name (an address, a wildcard, a URL) is refused with its name, on the page and in the terminal, never silently dropped.
7474
7575 A pull from an outsider's fork on the origin is someone else's code, so its
7676 check and preview wait. A person with write access lets that exact head run:
@@ -198,6 +198,15 @@ and it is never written to a log. Both surfaces render the link as a QR code
198198 also offers OSC 52 copy, which reaches the user's own terminal rather than the
199199 server's clipboard.
200200
201 The flow's own page (`/settings/origins/flows/:id`) links to that sealed
202 URL and to nothing else — never to one from its query string — after
203 checking it again: the instance's `/login/oauth/authorize`, the flow's
204 client id and callback, a PKCE challenge. A `to` an older link still
205 carries must equal it exactly. A flow past its ten minutes says "This
206 connect link expired", with a fresh **Connect**.
207 (`test/origin-flow-page.test.ts`; `test/oauth-connect-browser.test.ts`
208 clicks Authorize and Continue in headless Chromium.)
209
201210 Discovery: `github.com` is known by name. Otherwise `GET /api/v1/version` —
202211 NodeInfo is the one thing here resembling a standard and no live Forgejo
203212 serves it, so it is only an opportunistic first probe. The version string is
@@ -211,7 +220,11 @@ resolves the name and refuses any private answer (`vetHost`, at every hop,
211220 dialling the address it judged), refuses non-http schemes, follows no redirect
212221 off-host, reaches loopback only in dev (`FORGE_DEV=1`), caps the body at 64 KB and times out in 5 s. An operator can vouch for a LAN
213222 or plaintext host with `FORGE_ORIGIN_ALLOW=host[,host…]`; there is no
214 per-user toggle, because the risk it accepts belongs to the server.
223 per-user toggle, because the risk it accepts belongs to the server. A host
224 name vouched for there is trusted for that origin's git as well (mirror,
225 sync, fetch, push), as `FORGE_OUTBOUND_TRUSTED_HOSTS` is: by name, pinned to
226 the answer that passed. So an origin that connects can be mirrored. An
227 address literal named there reaches discovery only: trust is by name.
215228
216229 **Refresh.** A Forgejo OAuth access token lives about an hour, so it is
217230 refreshed a minute before it expires rather than after a 401. Refreshes are
@@ -403,13 +416,38 @@ the mirror: each sync that moves the mirror's default branch fast-forwards
403416 the fork's, so long as nothing was committed on it directly (`docs/APPS.md`,
404417 "Forks follow their parents").
405418
406 The `owner:branch` form is verified against the fakes in
407 `test/fork-flow.test.ts` (both dialects, with real git behind them) and it
408 matches both forges' documented shape. **It has not yet been run against a
409 live Forgejo instance**, and that is the one thing worth doing before
410 leaning on it: Forgejo's `head` is an undocumented plain string, and a real
411 instance is the only thing that can settle whether it resolves the owner the
412 way GitHub does.
419 **Reading is enough to propose.** Carrying the branch into the origin
420 repository needs push rights there. An account that can only read it — and
421 anyone at all, for a repository the operator protects
422 (`FORGE_ORIGIN_DENY_PUSH`) — proposes through its own fork at the origin
423 instead (`src/origins/fork-route.ts`): the fork it has is found (the
424 picker's cache, then its two possible names, then the upstream's own list
425 of forks for one the account owns under any name, `GET
426 /repos/{o}/{r}/forks`, bounded) or made (`POST
427 /repos/{o}/{r}/forks`, under `<owner>-<name>` when the account already uses
428 the name for something else); the branch is pushed into that fork, leased on
429 where it stands there; and the pull opens on the origin repository with the
430 head `<account>:<branch>`. Which way is asked of the origin when the pull
431 is opened (`permissions.push`), and a direct push the origin refuses falls
432 back to the fork. A protected repository is never pushed to from here at
433 all — push-through, a carried branch and a branch made on its mirror are
434 refused (`origin_protected`) — because a branch pushed into a repository
435 runs its workflows with its Actions secrets, and a pull from a fork runs
436 them without (Forgejo `services/actions/secret.go:43`). The same fork is
437 what `contribute` uses on its fork path (`ensureFork`), and a fork of a
438 mirror whose origin it will reach through a fork says so on its button:
439 **Propose via your fork**. `test/propose-via-fork.test.ts` runs it against
440 a real forge serving git behind the fake API.
441
442 The `owner:branch` head form is Forgejo's (v15, `routers/api/v1/repo/
443 pull.go`): `parseCompareInfo` splits the head on its colon (:1092), looks
444 the owner up (:1098) and takes that owner's fork of the base as the head
445 repository (`GetForkedRepo`, :1127), whatever the fork is named; the base
446 needs read access to code and pulls (:1162), the head read access to code
447 (:1175-1181). Verified live on Forgejo 15.0.9 on 2026-10-16: a read-only
448 account opened two pulls on a private scratch repository through its fork,
449 one from a branch and one from the fork's default branch (`<account>/main`),
450 and the scratch repository never gained a branch.
413451
414452 **Review states.** A review is a _verdict_ on a pull at one commit —
415453 `approved`, `changes_requested` or `commented` — stored in `review` beside
@@ -446,6 +484,99 @@ local client (`src/cli/`) reads them over the API, clones under
446484 `~/src/<host>/<owner>/<repo>`, keeps them fetched, and reports
447485 ahead/behind/dirty. The server never knows local state.
448486
487 ## A token that lacks a scope
488
489 Forgejo answers a token without a scope with a 403 that names it (`token
490 does not have at least one of required scope(s): [read:user]`). The API
491 client reads that body (4 KB at most) and keeps only scope-shaped words, so
492 connecting says "This token is missing User: Read." (`missing_scopes`, 422)
493 and a sync error says "the token is missing Issue: Read" — not "the token
494 was refused", which a person cannot tell from a wrong or expired one. The
495 token form lists what a Forgejo token needs and, once the instance is
496 known, links to
497 `<host>/user/settings/applications/tokens/new?name=forge&resource=all&scope=read:user&scope=write:repository&scope=write:issue`,
498 which Forgejo 15 opens with those already chosen; on Gitea or an older
499 Forgejo the list says what to pick. `test/origin-token-scopes.test.ts`.
500
501 ## The operator guard
502
503 A forge keeps an origin's credential on a machine where agents run, so the
504 risk is the account a person authorized — never the owner of this forge. On
505 a *guarded* host — one the operator registered an application for
506 (`FORGE_ORIGIN_CLIENT_ID`), or one a push rule covers
507 (`FORGE_ORIGIN_DENY_PUSH`) — every credential is checked after `whoami` and
508 before anything is stored, on all three ways in (a pasted token, the code
509 flow, the device flow). Both variables are read per call.
510
511 - An account that **administers the instance** (Forgejo's `is_admin`,
512 GitHub's `site_admin`) is refused: "`<login>` is an administrator of
513 `<host>`. Sign in to Forgejo with your everyday non-admin account and try
514 again." (`admin_account`)
515 - An account that **can push to a protected repository** is refused, with
516 the repositories named (`can_push`). The check asks the origin for
517 `permissions.push` — the account's real rights, whatever scopes its token
518 claims. Every pattern with no `*` is asked about by name (`GET
519 /repos/{o}/{r}`, four at a time, however many there are). A wildcard
520 walks the repositories the account can see (`walkRepos`): on Forgejo
521 `/repos/search?uid=` sorted by id — every repository it owns,
522 collaborates on or reaches through a team, every way to push — so a
523 repository pushed or made during the walk cannot move into a page already
524 read; with no fallback to another listing; and complete only when the
525 distinct repositories seen equal `X-Total-Count`, which must not change
526 between pages. On GitHub, `/user/repos` oldest first. The walk is bounded
527 (20 pages); an account with more, a page that fails, or a count that does
528 not add up is refused (`guard_unchecked`) — the guard fails closed. A
529 confirmed hit always wins: an account seen to push to a protected
530 repository is refused for that (`can_push`), even when another lookup
531 failed or the walk did not finish.
532 - A refusal stores nothing. On the code flow the grant is revoked where the
533 provider allows it (GitHub with a client secret); Forgejo has no
534 revocation endpoint, so the message says the forge did not keep the token
535 and where to remove the grant.
536
537 `FORGE_ORIGIN_DENY_PUSH` patterns: `owner/name`, `*` for any run of
538 characters inside one part, a bare `owner` meaning `owner/*`, case-
539 insensitive. A bare pattern covers every origin (so it also turns the
540 administrator check on for every origin); `host:pattern` covers one host.
541 Unguarded hosts behave exactly as before, so a person who runs their own
542 Forgejo can still connect it as its administrator.
543
544 The walk is also the person's list of repositories: on a guarded host it is
545 written to the picker's cache at connect, for free.
546
547 **Checked again, after it is stored** (`src/origins/credential.ts`). A
548 grant outlives the rights it was given with, so the guard runs again on
549 what is already stored:
550
551 - **On every OAuth refresh**, on the new token, before it is used: the
552 request that needed the refresh fails if the account is refused.
553 - **By a sweep** on the sync tick: an origin on a guarded host that was never
554 checked (connected before the guard, or before the operator named its
555 host) at once — so on boot — and every other once a day. One origin per
556 tick, half a second between its requests, so the sweep adds a trickle to
557 the origin it protects. A connect that passed the guard counts as a check.
558 - A refusal marks the origin `needs_reauth` with the guard's sentence as
559 its reason. From that moment every use of the credential fails — sync,
560 write-through, push-through (no git credential is handed out), the crew's
561 pulls through it — and Settings → Origins says to re-authorize, with the
562 reason. A credential the origin no longer accepts (a grant revoked there)
563 is refused at once. A refresh never clears `needs_reauth` — one racing a
564 refusal cannot undo it; only connecting again, through the guard, does.
565 - **Writes wait on a check.** On a guarded host an origin whose account has
566 not passed the guard — never checked yet, or a check that cannot run (the
567 origin down, a count that moved) — reads and syncs but writes nothing:
568 write-through (`origin_paused`), push-through, branches carried up, forks
569 and pulls made there, and, in the adapter itself, any request that is not
570 a read (`ProviderCredential.writable`). Settings → Origins says
571 "Couldn't verify this connection; writes paused" with **Check again**
572 (`POST /settings/origins/:id/check`, once a minute per origin). A failing
573 check is tried again in 15 minutes; one that has not run for a day refuses
574 the origin.
575 - When each origin last passed lives in its cursor store (`guard_checked_at`
576 and friends): a cache — lose it, and the origin is checked once more.
577
578 `test/origin-guard.test.ts`.
579
449580 ## When a token expires
450581
451582 A credential dies: a token is revoked, a refresh token is spent, an OAuth
@@ -885,10 +1016,6 @@ carries token material, a `state`, or a `code`.
8851016 it wants a spawn path of its own, and "fetching git objects" was worth more
8861017 than that today.
8871018 - **No LFS.** A mirror of an LFS repo has the pointers, not the objects.
888 - **The `owner:branch` head form is unverified against a live Forgejo.** It
889 is exercised against the fake in both dialects and matches the documented
890 shape; Forgejo's own `head` is an undocumented plain string. Try it against
891 a real instance before relying on it in anger.
8921019 - **Contributing needs a workbench.** `contribute` mirrors your fork (and, on
8931020 the push-rights path, the upstream) if it is not mirrored already, so the
8941021 first contribution to a large repository pays for a clone and an import.
@@ -896,6 +1023,9 @@ carries token material, a `state`, or a `code`.
8961023 review through the origin sends the state and the body; anchored comments
8971024 are still posted one at a time. Both forges accept comments inside the
8981025 review payload, which is the obvious next step.
1026 - **The guard's window is a day.** An account that gains push rights to a
1027 protected repository keeps its credential until the next refresh or the
1028 next daily sweep, whichever is first.
8991029 - **Key rotation is not built.** The sealed blob carries a `v1` version tag
9001030 so it can be, but losing `secret.key` today means reconnecting the origins.
9011031 - **DNS rebinding is not closed.** The SSRF policy checks address _literals_;
docs/features/origin-guard.md added +8
@@ -0,0 +1,8 @@
1 # The operator guard and proposing through your fork (#8938)
2
3 The operator may name hosts to guard (`FORGE_ORIGIN_CLIENT_ID`, `FORGE_ORIGIN_DENY_PUSH`; see "The operator guard" in [`docs/ORIGINS.md`](../ORIGINS.md)). On such a host an origin is refused when its account administers the instance or can push to a protected repository.
4
5 - **Paused:** when the guard cannot verify an account (the host is down, say), the origin still reads and syncs but writes nothing. `/settings/origins` shows "Couldn't verify this connection; writes paused" with a **Check again** button (`POST /settings/origins/:id/check`, once a minute per origin). A refused origin shows **Needs re-auth**.
6 - **In the terminal:** Connected origins marks a paused origin `writes paused`, says why on the top line, and `v` checks it again, saying what it found in the page's words (`CHECK_WORDS`, `src/actions/origin_words.ts`). Test: `test/tui-origins-guard.test.ts`.
7 - **Try it:** set `FORGE_ORIGIN_DENY_PUSH=system/*` and connect a token that can push there on Settings → Origins; it is refused with the repository named.
8 - **Propose via your fork:** an account that can only read a repository, or any repository the operator protects, proposes through the account's own fork there (`src/origins/fork-route.ts`, `contribute` in `src/actions/contribute.ts`). Test: `test/propose-via-fork.test.ts`; the guard: `test/origin-guard.test.ts`.
scripts/shards.ts +8 −4
@@ -545,14 +545,18 @@ export function besideOthers(
545545 return { red: stillRed, besideOthers: cleared, line };
546546 }
547547
548 const HEADER = /^(\S+\.test\.[cm]?[jt]sx?):$/;
548 // Under GitHub-style Actions (GITHUB_ACTIONS=true, which Forgejo's runner
549 // sets too) bun opens each file's tests with `::group::path:` instead of
550 // `path:`; the group marker is not part of the path.
551 const HEADER = /^(?:::group::)?(\S+\.test\.[cm]?[jt]sx?):$/;
549552 const FAILED = /^\(fail\) /;
550553
551554 /**
552555 * The files whose tests failed in one `bun test` report, in the order the
553 * report reached them: bun prints `path:` before a file's tests and
554 * `(fail) name` for each one that failed. A report that names no failing
555 * test (a crash, a file that would not load) yields none.
556 * report reached them: bun prints `path:` (in an Actions job,
557 * `::group::path:`) before a file's tests and `(fail) name` for each one
558 * that failed. A report that names no failing test (a crash, a file that
559 * would not load) yields none.
556560 */
557561 export function failingFiles(output: string): string[] {
558562 const found: string[] = [];
src/actions/branches.ts +5 −1
@@ -30,7 +30,8 @@ import {
3030 import { resolveOid } from "../git/merge.ts";
3131 import { repoDir } from "../git/repos.ts";
3232 import { err, ok, type Result } from "../kernel/result.ts";
33 import { providerForOrigin } from "../origins/credential.ts";
33 import { providerForOrigin, writesPaused } from "../origins/credential.ts";
34 import { denied } from "../origins/operator.ts";
3435 import { redact, secretsIn } from "../origins/credentials.ts";
3536 import { pushToOrigin, type PushUpdate } from "../origins/git-mirror.ts";
3637 import type { WriteThroughError } from "../origins/write-through.ts";
@@ -179,6 +180,9 @@ export async function originPush(
179180 if (!link) return ok(undefined);
180181 if (link.origin.userId !== actor.id) return err("origin_owner_only");
181182 if (link.cloneUrl === "") return err("origin_unreachable");
183 if (writesPaused(app, link.origin.id) !== undefined)
184 return err("origin_paused");
185 if (denied(link.origin, link.fullName)) return err("origin_protected");
182186
183187 const provider = providerForOrigin(app, link.origin);
184188 if (!provider.ok) return err("origin_unreachable");
src/actions/contribute.ts +19 −124
@@ -35,7 +35,6 @@
3535 import { requireVerified } from "./verify.ts";
3636 import type { App } from "../app.ts";
3737 import {
38 findOriginFork,
3938 getOriginFor,
4039 getOriginRepo,
4140 mirrorOf,
@@ -50,43 +49,32 @@ import { isSafeRef } from "../git/guard.ts";
5049 import { repoDir } from "../git/repos.ts";
5150 import type { CommitResult } from "../git/write.ts";
5251 import { err, ok, type Result } from "../kernel/result.ts";
53 import { providerForOrigin } from "../origins/credential.ts";
52 import { providerForOrigin, writesPaused } from "../origins/credential.ts";
5453 import { fetchRefFrom } from "../origins/git-mirror.ts";
5554 import { importIssue } from "../origins/import.ts";
5655 import { settled, type MirrorJob } from "../origins/jobs.ts";
5756 import { mirrorRepo, startMirror } from "../origins/mirror.ts";
57 import { ensureFork, forkDeadline } from "../origins/fork-route.ts";

Re-export sits between imports; move it below them.

58
59 import { denied } from "../origins/operator.ts";
5860 import type { Provider, RemoteRepo } from "../origins/types.ts";
5961 import type { WriteThroughError } from "../origins/write-through.ts";
6062 import { createBranch } from "./branches.ts";
6163 import { editFiles, type EditFile, type EditFilesError } from "./contents.ts";
6264 import { requireUser } from "./access.ts";
6365
66 export { forkDeadline };
67
6468 // ---- forking at the origin ----
6569
66 /**
67 * How long to wait for a fork to exist. GitHub's docs say most forks are
68 * ready "within five minutes" and are in practice ready in one or two
69 * seconds; thirty is long enough for the common case and short enough that a
70 * request does not hang on the pathological one.
71 */
72 const FORK_DEADLINE_MS = 30_000;
73
74 /**
75 * The deadline `forkOrigin` uses when its caller names none. Mutable for the
76 * same reason `firstFetchRetry` is: a test that wants the timeout shortens it
77 * and puts back what it found.
78 */
79 export const forkDeadline: { ms: number } = { ms: FORK_DEADLINE_MS };
80
81 /** Backoff between polls, in ms; the last value repeats until the deadline. */
82 const FORK_BACKOFF = [250, 500, 1_000, 2_000, 3_000] as const;
83
8470 export type ForkOriginError =
8571 | "unauthorized"
8672 | "unverified"
8773 | "not_found"
8874 | "origin_unreachable"
8975 | "origin_refused"
76 /** The connection is not verified against the operator's rules yet. */
77 | "origin_paused"
9078 /** The origin took the request and the fork never appeared in time. */
9179 | "fork_timeout";
9280
@@ -135,6 +123,7 @@ export async function forkOrigin(
135123 // Somebody else's connected account does not exist for you (invariant 9).
136124 const origin = getOriginFor(app.db, user.value.id, originId);
137125 if (!origin) return err("not_found");
126 if (writesPaused(app, origin.id) !== undefined) return err("origin_paused");
138127 const provider = providerForOrigin(app, origin);
139128 if (!provider.ok) return err("origin_unreachable");
140129
@@ -144,62 +133,15 @@ export async function forkOrigin(
144133 upstream.error.error === "not_found" ? "not_found" : "origin_unreachable",
145134 );
146135
147 const found = await existingFork(
136 const forked = await ensureFork(
148137 app,
149138 origin,
150139 provider.value,
151140 upstream.value.fullName,
141 options.waitMs ?? forkDeadline.ms,
152142 );
153 let existed = found !== undefined;
154 let remote = found;
155
156 if (remote === undefined) {
157 const asked = await provider.value.forkRepo(upstream.value.fullName);
158 if (!asked.ok) {
159 // An origin that forks in the request itself can take longer than we
160 // wait for an answer, and still have done it. Before calling that a
161 // failure, look for the fork under its conventional name.
162 const name = upstream.value.fullName.split("/")[1] ?? "";
163 const late =
164 asked.error.error === "unreachable" &&
165 name !== "" &&
166 origin.login !== ""
167 ? await awaitFork(
168 provider.value,
169 `${origin.login}/${name}`,
170 options.waitMs ?? forkDeadline.ms,
171 )
172 : undefined;
173 if (
174 late === undefined ||
175 !late.ok ||
176 !sameRepo(late.value.parent, upstream.value.fullName)
177 )
178 return err(
179 asked.error.error === "unreachable" ||
180 asked.error.error === "rate_limited"
181 ? "origin_unreachable"
182 : "origin_refused",
183 );
184 remote = late.value;
185 } else {
186 const ready = await awaitFork(
187 provider.value,
188 asked.value.fullName,
189 options.waitMs ?? forkDeadline.ms,
190 );
191 if (!ready.ok) return ready;
192 remote = ready.value;
193 }
194 existed = false;
195 }
196
197 rememberOriginRepo(app.db, origin.id, {
198 ...remote,
199 // Both forges report the parent on `getRepo`; if this one did not, we
200 // know it anyway, because we are the reason the fork exists.
201 parent: remote.parent ?? upstream.value.fullName,
202 });
143 if (!forked.ok) return forked;
144 const { remote, existed } = forked.value;
203145
204146 app.bus.append({
205147 kind: "repo.forked",
@@ -251,58 +193,6 @@ export async function forkOrigin(
251193 return ok({ remote, mirror, job, existed });
252194 }
253195
254 /**
255 * The fork of `upstream` this account already has, if any. The cache is
256 * asked first (it is the picker's own answer and costs nothing); failing
257 * that, the conventional name is asked for directly, because a fork made
258 * outside this forge is not in the cache and forking again would be wrong.
259 */
260 async function existingFork(
261 app: App,
262 origin: Origin,
263 provider: Provider,
264 upstream: string,
265 ): Promise<RemoteRepo | undefined> {
266 const cached = findOriginFork(app.db, origin.id, upstream);
267 if (cached !== undefined) {
268 const fresh = await provider.getRepo(cached.fullName);
269 if (fresh.ok && sameRepo(fresh.value.parent, upstream)) return fresh.value;
270 }
271 const name = upstream.split("/")[1] ?? "";
272 if (name === "" || origin.login === "") return undefined;
273 const guess = await provider.getRepo(`${origin.login}/${name}`);
274 if (guess.ok && sameRepo(guess.value.parent, upstream)) return guess.value;
275 return undefined;
276 }
277
278 const sameRepo = (a: string | null, b: string): boolean =>
279 a !== null && a.toLowerCase() === b.toLowerCase();
280
281 /** Poll until the origin admits the fork exists, or the deadline passes. */
282 async function awaitFork(
283 provider: Provider,
284 fullName: string,
285 waitMs: number,
286 ): Promise<Result<RemoteRepo, ForkOriginError>> {
287 const deadline = Date.now() + waitMs;
288 for (let attempt = 0; ; attempt++) {
289 const found = await provider.getRepo(fullName);
290 // An empty fork is still a fork; what we are waiting for is the row.
291 if (found.ok) return ok(found.value);
292 if (found.error.error !== "not_found")
293 return err(
294 found.error.error === "unreachable" ||
295 found.error.error === "rate_limited"
296 ? "origin_unreachable"
297 : "origin_refused",
298 );
299 const wait =
300 FORK_BACKOFF[Math.min(attempt, FORK_BACKOFF.length - 1)] ?? 3_000;
301 if (Date.now() + wait > deadline) return err("fork_timeout");
302 await new Promise((resolve) => setTimeout(resolve, wait));
303 }
304 }
305
306196 /** The local mirror of a remote repo at this origin, or undefined. */
307197 function localMirror(
308198 app: App,
@@ -381,6 +271,7 @@ export async function contribute(
381271 if (!proven.ok) return proven;
382272 const origin = getOriginFor(app.db, user.value.id, input.originId);
383273 if (!origin) return err("not_found");
274 if (writesPaused(app, origin.id) !== undefined) return err("origin_paused");
384275 const branch = input.branch.trim();
385276 if (!isSafeRef(branch) || branch.startsWith("refs/"))
386277 return err("invalid_branch");
@@ -404,7 +295,11 @@ export async function contribute(
404295 if (!listed.value.some((b) => b.name === base)) return err("unknown_base");
405296 }
406297
407 return upstream.value.permissions.push
298 // Push rights decide the shape — except on a repository the operator
299 // protects, which is never pushed to from here: always the fork.
300 const direct =
301 upstream.value.permissions.push && !denied(origin, upstream.value.fullName);
302 return direct
408303 ? await contributeDirect(app, user.value, origin, upstream.value, {
409304 ...input,
410305 branch,
src/actions/origin_words.ts +9
@@ -30,3 +30,12 @@ export function connectWords(error: string): string {
3030 };
3131 return words[error] ?? "That connection could not be made.";
3232 }
33
34 /** What "Check again" found, in one sentence, on the page and in the terminal. */
35 export const CHECK_TOO_SOON = "It was checked a moment ago. Try again in a minute.";
36 export const CHECK_WORDS = {
37 passed: "Verified. Writes through this connection are on again.",
38 unguarded: "This connection has nothing to verify.",
39 unchecked: "It still could not be verified, so writes stay paused. Try again in a few minutes.",
40 refused: "This connection was refused. Reconnect it with an account the operator allows.",
41 } as const;
src/actions/origins.ts +130 −14
@@ -28,6 +28,7 @@ import {
2828 findOrigin,
2929 getOriginById,
3030 getOriginFor,
31 hostOf,
3132 siteOf,
3233 linkRepoToOrigin,
3334 listDetachedMirrors,
@@ -55,7 +56,13 @@ import type { User } from "../core/users.ts";
5556 import { repoDir } from "../git/repos.ts";
5657 import { bustOrigin } from "../view/cache.ts";
5758 import { discover } from "../origins/discover.ts";
58 import { providerForOrigin } from "../origins/credential.ts";
59 import {
60 lastGuardTry,
61 noteGuardPassed,
62 providerForOrigin,
63 recheckOrigin,
64 type Recheck,
65 } from "../origins/credential.ts";
5966 import { safeRemoteUrl } from "../origins/credentials.ts";
6067 import { configureMirror, mirrorRemoteUrl } from "../origins/git-mirror.ts";
6168 import { getJob, type MirrorJob } from "../origins/jobs.ts";
@@ -85,8 +92,10 @@ import {
8592 type AppRow,
8693 type FlowRow,
8794 } from "../origins/oauth.ts";
95 import { checkCredential, type GuardRefusal } from "../origins/operator.ts";
8896 import { forgetProvider, providerFor } from "../origins/providers/index.ts";
8997 import { forgetTokenReach } from "../origins/token-reach.ts";
98 import { scopeWords } from "../origins/providers/http.ts";
9099 import {
91100 requestSyncNow,
92101 syncOrigin,
@@ -96,6 +105,7 @@ import { open } from "../kernel/secret.ts";
96105 import {
97106 staticCredential,
98107 type OriginKind,
108 type RemoteRepo,
99109 } from "../origins/types.ts";
100110 import { err, ok, type Result } from "../kernel/result.ts";
101111 import {
@@ -158,13 +168,16 @@ export interface ConnectInput {
158168 * Connect with a pasted token — the fallback path, and the only one that
159169 * works with no OAuth application registered anywhere. The token is
160170 * validated against the origin before it is stored, and `login` comes from
161 * the origin's answer rather than anything the user typed.
171 * the origin's answer rather than anything the user typed. On a host the
172 * operator guards, the account behind it is checked too (`checkCredential`)
173 * and a refusal stores nothing: it comes back as a `GuardRefusal`, with the
174 * sentence to show.
162175 */
163176 export async function connectOrigin(
164177 app: App,
165178 actor: User | undefined,
166179 input: ConnectInput,
167 ): Promise<Result<Connected, ConnectError>> {
180 ): Promise<Result<Connected, ConnectError | GuardRefusal | ScopeRefusal>> {
168181 const user = requireUser(actor);
169182 if (!user.ok) return user;
170183 if (tooManyAttempts(app, user.value.id)) return err("rate_limited");
@@ -177,7 +190,7 @@ export async function connectOrigin(
177190 );
178191 if (!found.ok) return err(found.error);
179192
180 const identity = await providerFor(
193 const provider = providerFor(
181194 staticCredential({
182195 kind: found.value.kind,
183196 apiUrl: found.value.apiUrl,
@@ -185,11 +198,24 @@ export async function connectOrigin(
185198 software: softwareOf(found.value.software),
186199 token: input.token.trim(),
187200 }),
188 ).whoami();
189 if (!identity.ok)
201 );
202 const identity = await provider.whoami();
203 if (!identity.ok) {
204 // A token the origin knows but finds too narrow is not a bad token: say
205 // which scope it lacks, in the words of the page that makes tokens.
206 const missing = identity.error.missingScopes ?? [];
207 if (missing.length > 0)
208 return err({
209 error: "missing_scopes",
210 message: `This token is missing ${scopeWords(missing)}. ${TOKEN_NEEDS}`,
211 scopes: missing,
212 });
190213 return err(
191214 identity.error.error === "unreachable" ? "unreachable" : "bad_token",
192215 );
216 }
217 const guard = await checkCredential(provider, identity.value);
218 if (!guard.ok) return err(guard.error);
193219
194220 const stored = storeOrigin(app, user.value, {
195221 kind: found.value.kind,
@@ -201,9 +227,15 @@ export async function connectOrigin(
201227 software: softwareOf(found.value.software),
202228 });
203229 if (!stored.ok) return stored;
230 if (guard.value.checked) noteGuardPassed(app, stored.value.id);
204231 return ok({
205232 ...stored.value,
206 reattached: await afterConnect(app, user.value, stored.value),
233 reattached: await afterConnect(
234 app,
235 user.value,
236 stored.value,
237 guard.value.listed,
238 ),
207239 });
208240 }
209241
@@ -216,7 +248,8 @@ export type ReplaceTokenError =
216248 | "rate_limited"
217249 | "bad_token"
218250 | "unreachable"
219 | "other_account";
251 | "other_account"
252 | GuardRefusal["error"];
220253
221254 /**
222255 * Put a new pasted token on an origin that is already connected — one that
@@ -239,7 +272,7 @@ export async function replaceOriginToken(
239272 const secret = token.trim();
240273 if (secret === "") return err("bad_token");
241274
242 const identity = await providerFor(
275 const provider = providerFor(
243276 staticCredential({
244277 kind: origin.kind,
245278 apiUrl: origin.apiUrl,
@@ -247,13 +280,18 @@ export async function replaceOriginToken(
247280 software: origin.software,
248281 token: secret,
249282 }),
250 ).whoami();
283 );
284 const identity = await provider.whoami();
251285 if (!identity.ok)
252286 return err(
253287 identity.error.error === "unreachable" ? "unreachable" : "bad_token",
254288 );
255289 if (identity.value.login.toLowerCase() !== origin.login.toLowerCase())
256290 return err("other_account");
291 // A new token is a new credential: on a guarded host it passes the same
292 // check a connect does, and a refusal leaves the old token as it was.
293 const guard = await checkCredential(provider, identity.value);
294 if (!guard.ok) return err(guard.error.error);
257295
258296 renewOrigin(app.db, app.config.dataDir, origin.id, {
259297 token: secret,
@@ -261,6 +299,7 @@ export async function replaceOriginToken(
261299 });
262300 forgetProvider({ dataDir: app.config.dataDir, originId: origin.id });
263301 forgetTokenReach(app.config.dataDir, origin.id);
302 if (guard.value.checked) noteGuardPassed(app, origin.id);
264303 app.bus.append({
265304 kind: "origin.connected",
266305 actorId: user.value.id,
@@ -277,6 +316,27 @@ export async function replaceOriginToken(
277316 return ok(renewed);
278317 }
279318
319 /** What a token for a Forgejo origin needs, in the token page's words. */
320 export const TOKEN_NEEDS =
321 "A token for this forge needs User: Read, Repository: Read and write, and Issue: Read and write.";
322
323 /** A token the origin refused for a scope it lacks: which, by name. */
324 export interface ScopeRefusal {
325 readonly error: "missing_scopes";
326 readonly message: string;
327 /** In the origin's words: `read:user`. */
328 readonly scopes: readonly string[];
329 }
330
331 /** A refused connect as a stable code and the words to show for it. */
332 export function connectRefusal(
333 error: ConnectError | GuardRefusal | ScopeRefusal,
334 ): { code: string; message: string | undefined } {
335 return typeof error === "string"
336 ? { code: error, message: undefined }
337 : { code: error.error, message: error.message };
338 }
339
280340 /** A connected origin, and the frozen mirrors connecting it brought back. */
281341 export type Connected = Origin & {
282342 readonly reattached: readonly Reattachment[];
@@ -406,6 +466,34 @@ export function listUserOrigins(app: App, actor: User): Origin[] {
406466 return listOrigins(app.db, actor.id);
407467 }
408468
469 // ---- checking again ----
470
471 /** How soon a person may ask for another check of the same origin. */
472 const CHECK_AGAIN_MS = 60_000;
473
474 /**
475 * "Check again": the operator guard on one of the actor's origins, now
476 * (`recheckOrigin`). The way out of paused writes without waiting for the
477 * sweep — and, like it, refused outright when the account fails the guard.
478 * Once a minute per origin at most: it is a walk of the origin's API.
479 */
480 export async function checkOriginAgain(
481 app: App,
482 actor: User | undefined,
483 originId: string,
484 ): Promise<Result<Recheck, "unauthorized" | "not_found" | "too_soon">> {
485 const user = requireUser(actor);
486 if (!user.ok) return user;
487 const origin = getOriginFor(app.db, user.value.id, originId);
488 if (!origin) return err("not_found");
489 const last = lastGuardTry(app, origin.id);
490 if (last !== undefined && Date.now() - Date.parse(last) < CHECK_AGAIN_MS)
491 return err("too_soon");
492 const provider = providerForOrigin(app, origin);
493 if (!provider.ok) return ok("refused");
494 return ok(await recheckOrigin(app, origin, provider.value, { pauseMs: 250 }));
495 }
496
409497 // ---- OAuth ----
410498
411499 export type OAuthStartError =
@@ -726,7 +814,7 @@ async function completeConnect(
726814 expiresAt: string | null;
727815 },
728816 ): Promise<{ status: "done" | "failed"; detail: string; originId?: string }> {
729 const identity = await providerFor(
817 const provider = providerFor(
730818 staticCredential({
731819 kind: registration.kind,
732820 apiUrl: registration.apiUrl,
@@ -735,9 +823,31 @@ async function completeConnect(
735823 software: registration.software ?? "",
736824 token: tokens.accessToken,
737825 }),
738 ).whoami();
826 );
827 const identity = await provider.whoami();
739828 if (!identity.ok)
740 return { status: "failed", detail: "the new token did not work" };
829 return {
830 status: "failed",
831 detail:
832 (identity.error.missingScopes ?? []).length > 0
833 ? `The grant is missing ${scopeWords(identity.error.missingScopes!)}. The application asks for it; remove the grant on the instance and connect again.`
834 : "the new token did not work",
835 };
836 // The account that consented is the one checked: an administrator's grant
837 // or a protected repository's pusher is refused here, before anything is
838 // stored, and the grant is revoked where the provider lets us.
839 const guard = await checkCredential(provider, identity.value);
840 if (!guard.ok) {
841 const revoked = await revokeGrant(registration, tokens.accessToken);
842 return {
843 status: "failed",
844 detail: `${guard.error.message} ${
845 revoked.ok
846 ? "The grant was revoked."
847 : `This forge did not keep the token. You can remove the grant at ${hostOf(registration.webUrl)} under Settings → Applications.`
848 }`,
849 };
850 }
741851
742852 const stored = storeOrigin(app, user, {
743853 kind: registration.kind,
@@ -752,7 +862,8 @@ async function completeConnect(
752862 });
753863 if (!stored.ok)
754864 return { status: "failed", detail: ORIGIN_ALREADY_CONNECTED };
755 await afterConnect(app, user, stored.value);
865 if (guard.value.checked) noteGuardPassed(app, stored.value.id);
866 await afterConnect(app, user, stored.value, guard.value.listed);
756867 return { status: "done", detail: "", originId: stored.value.id };
757868 }
758869
@@ -991,7 +1102,12 @@ async function afterConnect(
9911102 app: App,
9921103 user: User,
9931104 origin: Origin,
1105 listed: readonly RemoteRepo[] | null = null,
9941106 ): Promise<Reattachment[]> {
1107 // The guard listed what the account can see: that is the picker's list,
1108 // at once — no second walk of the same pages. Anywhere the guard did not
1109 // walk, the picker fetches it when it is opened, as before.
1110 if (listed !== null) cacheOriginRepos(app.db, origin.id, listed);
9951111 const out: Reattachment[] = [];
9961112 for (const mirror of listDetachedMirrors(app.db, {
9971113 site: siteOf(origin.webUrl),
src/actions/pulls.ts +76 −13
@@ -81,9 +81,34 @@ import {
8181 } from "./access.ts";
8282 import { canWrite } from "../core/repos.ts";
8383 import { refreshPullHead as refreshHead } from "../core/following.ts";
84 import { getOriginRepo } from "../core/origins.ts";
8485 import { watchPullsSoon } from "../origins/hook.ts";
85 import { carryBranchThrough } from "../origins/push-through.ts";
8686 import { ownHost, pullSourceOf, sourceTrailers } from "../core/trusted_sources.ts";
87 import { carryBranchThrough } from "../origins/push-through.ts";
88 import { ensureFork, forkHead, pushToFork } from "../origins/fork-route.ts";
89 import { denied } from "../origins/operator.ts";
90 import type { WriteThrough } from "../origins/write-through.ts";
91
92 /**
93 * How a fork of a mirror proposes to the mirror's origin: `direct`, its
94 * branch pushed into the origin repository, when the account may push
95 * there; `fork`, through the account's own fork at the origin, when it may
96 * only read — and always for a repository the operator protects
97 * (`FORGE_ORIGIN_DENY_PUSH`), which this forge never pushes to. The rights
98 * are the origin's own answer, asked now; when it cannot be asked, the
99 * cached one, and failing that the direct way, whose refusal falls back to
100 * the fork.
101 */
102 async function proposalRoute(
103 app: App,
104 through: WriteThrough,
105 ): Promise<"direct" | "fork"> {
106 if (denied(through.origin, through.fullName)) return "fork";
107 const live = await through.provider.getRepo(through.fullName);
108 if (live.ok) return live.value.permissions.push ? "direct" : "fork";
109 const cached = getOriginRepo(app.db, through.origin.id, through.fullName);
110 return cached?.canPush === false ? "fork" : "direct";
111 }
87112
88113 export interface OpenPullInput {
89114 title: string;
@@ -135,10 +160,13 @@ export async function openPull(
135160 if (title === "") return err("empty_title");
136161 // A native fork of this mirror proposing upstream: its branch is
137162 // carried to the origin first, under the connecting account's
138 // credential like any push through, and the origin opens the pull
139 // from the branch it now holds.
163 // credential, and the origin opens the pull from it. Directly into the
164 // origin repository when the account can push there; otherwise — and
165 // always for a repository the operator protects — into the account's
166 // own fork at the origin, the pull's head then `<owner>:<branch>`.
140167 const head = input.headRepo;
141168 let carriedAs = input.headBranch;
169 let pullHead = input.headBranch;
142170 if (head !== undefined && head.id !== baseRepo.id) {
143171 if (
144172 !canRead(app.db, head, user.value) ||
@@ -152,15 +180,50 @@ export async function openPull(
152180 // name.
153181 if (input.headBranch === input.baseBranch)
154182 carriedAs = `${head.ownerName}/${input.headBranch}`;
155 const carried = await carryBranchThrough(
156 app,
157 baseRepo,
158 head,
159 input.headBranch,
160 user.value.id,
161 carriedAs,
162 );
163 if (!carried.ok) return err(refusal(carried.error.message));
183 pullHead = carriedAs;
184 const route = await proposalRoute(app, through.value);
185 let viaFork = route === "fork";
186 if (route === "direct") {
187 const carried = await carryBranchThrough(
188 app,
189 baseRepo,
190 head,
191 input.headBranch,
192 user.value.id,
193 carriedAs,
194 );
195 // The origin said no to the push itself: the account cannot write
196 // there after all. Reading is enough to propose, from a fork.
197 if (!carried.ok && carried.error.byOrigin !== true)
198 return err(refusal(carried.error.message));
199 viaFork = !carried.ok;
200 }
201 if (viaFork) {
202 const forked = await ensureFork(
203 app,
204 through.value.origin,
205 through.value.provider,
206 through.value.fullName,
207 );
208 if (!forked.ok)
209 return err(
210 forked.error === "origin_unreachable" ||
211 forked.error === "fork_timeout"
212 ? "origin_unreachable"
213 : "origin_refused",
214 );
215 const pushed = await pushToFork(
216 app,
217 through.value.origin,
218 through.value.provider,
219 forked.value.remote,
220 repoDir(app.reposRoot, head.ownerName, head.name),
221 input.headBranch,
222 carriedAs,
223 );
224 if (!pushed.ok) return err("origin_refused");
225 pullHead = forkHead(forked.value.remote, carriedAs);
226 }
164227 }
165228 const created = await through.value.provider.createPull(
166229 through.value.fullName,
@@ -168,7 +231,7 @@ export async function openPull(
168231 title,
169232 body: input.body ?? "",
170233 base: input.baseBranch,
171 head: carriedAs,
234 head: pullHead,
172235 },
173236 );
174237 if (!created.ok) return err(refusal(created.error.error));
src/core/origins.ts +5 −1
@@ -219,10 +219,14 @@ export function storeRefreshed(
219219 originId: string,
220220 next: { token: string; refresh?: string; expiresAt?: string | null },
221221 ): void {
222 // A refresh never clears `needs_reauth`: one that races a refusal (the
223 // operator guard's sweep, a dead grant) must not undo it. Only connecting
224 // again — `renewOrigin`, after the guard passed — brings an origin back.
222225 db.query(
223226 `UPDATE origin SET token_enc = ?1,
224227 refresh_token_enc = CASE WHEN ?2 = '' THEN refresh_token_enc ELSE ?2 END,
225 expires_at = ?3, needs_reauth = 0, last_error = ''
228 expires_at = ?3,
229 last_error = CASE WHEN needs_reauth = 1 THEN last_error ELSE '' END
226230 WHERE id = ?4`,
227231 ).run(
228232 seal(dataDir, next.token, originId),
src/http/api.ts +12
@@ -644,6 +644,18 @@ export function originAware(error: string, status = 422): Response {
644644 409,
645645 "the origin refused this change; nothing was changed here either",
646646 );
647 if (error === "origin_protected")
648 return apiError(
649 "origin_protected",
650 409,
651 "the operator protects this repository at its origin: nothing is pushed to it from here; propose the change from your fork",
652 );
653 if (error === "origin_paused")
654 return apiError(
655 "origin_paused",
656 409,
657 "this connection is not verified against the operator's rules yet, so writes through it are paused; nothing was changed",
658 );
647659 return respondAccess(error, status);
648660 }
649661
src/http/api/user.ts +9 −5
@@ -8,7 +8,7 @@
88 import { contribute, forkOrigin } from "../../actions/contribute.ts";
99 import { CAPPED_WORDS } from "../../actions/mail.ts";
1010 import { ORIGIN_UNREACHABLE } from "../../actions/origin_app_words.ts";
11 import { connectOrigin, deleteOriginApp, disconnectOrigin, listOriginApps, listPinned, listRemoteRepos, listUserOrigins, mirrorJobFor, pinRepo, pollOAuthConnect, registerOriginApp, startMirrorJob, startOAuthConnect, syncOriginNow, unpinRepo } from "../../actions/origins.ts";
11 import { connectOrigin, connectRefusal, deleteOriginApp, disconnectOrigin, listOriginApps, listPinned, listRemoteRepos, listUserOrigins, mirrorJobFor, pinRepo, pollOAuthConnect, registerOriginApp, startMirrorJob, startOAuthConnect, syncOriginNow, unpinRepo } from "../../actions/origins.ts";
1212 import { addSshKey, changePassword, deleteSshKey, updateProfile } from "../../actions/users.ts";
1313 import { VERIFY_WORDS } from "../../actions/verify_words.ts";
1414 import { resendVerification } from "../../actions/verify.ts";
@@ -260,12 +260,16 @@ export function userRoutes(router: Router<Handler>): void {
260260 : {}),
261261 token: str(input.token),
262262 });
263 if (!connected.ok)
263 if (!connected.ok) {
264 // A refusal that names its own reason (a missing scope) keeps it;
265 // every other code has its sentence here.
266 const refused = connectRefusal(connected.error);
264267 return apiError(
265 connected.error,
266 connected.error === "rate_limited" ? 429 : 422,
267 connectMessage(connected.error),
268 refused.code,
269 refused.code === "rate_limited" ? 429 : 422,
270 refused.message ?? connectMessage(refused.code),
268271 );
272 }
269273 // The frozen mirrors connecting this account brought back, and the ones
270274 // it could not, each with its reason.
271275 return json(
src/http/settings_routes.ts +4 −1
@@ -102,6 +102,7 @@ import {
102102 originAppCreate,
103103 originAppDelete,
104104 originConnectToken,
105 originCheckSubmit,
105106 originDisconnect,
106107 originOAuthStart,
107108 originSyncSubmit,
@@ -240,7 +241,7 @@ export function settingsRoutes(router: Router<Handler>, knownPatterns: () => rea
240241 ? "error=state"
241242 : done.value.status === "done"
242243 ? "connected=1"
243 : `error=${encodeURIComponent(done.value.detail.slice(0, 120))}`;
244 : `error=${encodeURIComponent(done.value.detail.slice(0, 300))}`;
244245 return new Response(null, {
245246 status: 303,
246247 headers: { location: `/settings/origins?${query}` },
@@ -263,6 +264,8 @@ export function settingsRoutes(router: Router<Handler>, knownPatterns: () => rea
263264 // shape for the same reason `flows` and `apps` are.
264265 router.add("GET", "/settings/origins/:id/mirror/:job", mirrorJobPage);
265266 router.add("POST", "/settings/origins/:id/sync", originSyncSubmit);
267 // "Check again": the operator guard on a connection whose writes are paused.
268 router.add("POST", "/settings/origins/:id/check", originCheckSubmit);
266269 router.add("POST", "/settings/origins/:id/delete", originDisconnect);
267270 router.add("POST", "/settings/origins/:id/token", originReplaceToken);
268271 router.add("GET", "/settings/keys", keysPage);
src/main.ts +8 −1
@@ -37,6 +37,7 @@ import { drainStreams } from "./http/sse.ts";
3737 import { serve } from "./http/server.ts";
3838 import { checkDue } from "./actions/upstream_follow.ts";
3939 import { runSync } from "./origins/sync.ts";
40 import { runGuardSweep } from "./origins/credential.ts";
4041 import { startForgeSsh } from "./tui/ssh.ts";
4142 import { AppsRuntime } from "./apps/runtime.ts";
4243 import { listApps } from "./core/apps.ts";
@@ -572,8 +573,14 @@ function positive(value: string | undefined, fallback: number): number {
572573 // pushed to: an origin is due every ten minutes by default, and the tick only
573574 // asks who is due — so this costs one indexed read a minute when nothing is
574575 // connected. Like runDelivery, runSync never throws into the timer.
575 const syncTimer = setInterval(() => runSync(app), 60_000);
576 // The operator guard's sweep of stored origins rides the same tick: one
577 // origin a tick, the never-checked first (so on boot), every other daily.
578 const syncTimer = setInterval(() => {
579 runSync(app);
580 void runGuardSweep(app);
581 }, 60_000);
576582 runSync(app); // one pass on boot: a forge that was down has fetching to do
583 void runGuardSweep(app);
577584
578585 // Which green pulls no longer merge as they stand, so the asks can leave
579586 // them out (`crew/stale_asks.ts`): git answers, off the request path, and an
src/origins/credential.ts +203 −2
@@ -17,9 +17,14 @@
1717
1818 import type { App } from "../app.ts";
1919 import {
20 cacheOriginRepos,
21 clearCursor,
22 getCursor,
2023 getOriginById,
24 listAllOrigins,
2125 markNeedsReauth,
2226 sealedSecrets,
27 setCursor,
2328 storeRefreshed,
2429 type Origin,
2530 } from "../core/origins.ts";
@@ -27,8 +32,14 @@ import { createLog } from "../kernel/log.ts";
2732 import { open } from "../kernel/secret.ts";
2833 import { err, ok, type Result } from "../kernel/result.ts";
2934 import { appFor, refresh, type AppRegistration } from "./oauth.ts";
30 import { providerFor } from "./providers/index.ts";
31 import type { Provider, ProviderCredential, ProviderResult } from "./types.ts";
35 import { checkCredential, guarded } from "./operator.ts";
36 import { forgetProvider, providerFor } from "./providers/index.ts";
37 import {
38 staticCredential,
39 type Provider,
40 type ProviderCredential,
41 type ProviderResult,
42 } from "./types.ts";
3243
3344 const log = createLog("origins");
3445
@@ -60,6 +71,12 @@ export function credentialFor(
6071 authKind: origin.authKind,
6172 software: origin.software,
6273 token: () => resolveToken(app, origin.id),
74 writable: () => {
75 const paused = writesPaused(app, origin.id);
76 return paused === undefined
77 ? { ok: true, value: true }
78 : { ok: false, error: { error: "forbidden", message: paused } };
79 },
6380 });
6481 }
6582
@@ -189,6 +206,190 @@ async function refreshOnce(app: App, originId: string): Promise<void> {
189206 actorId: origin.userId,
190207 payload: { originId, host: origin.webUrl },
191208 });
209
210 // A grant outlives the rights it was given with. The account behind the
211 // new token is checked again before the token is used for anything: one
212 // that has since become an administrator, or can now push to a protected
213 // repository, is cut off here, and `resolveToken` sees it at once.
214 const fresh = getOriginById(app.db, originId);
215 if (fresh !== undefined && guarded(fresh))
216 await recheckOrigin(
217 app,
218 fresh,
219 providerFor(
220 staticCredential({
221 kind: fresh.kind,
222 apiUrl: fresh.apiUrl,
223 webUrl: fresh.webUrl,
224 login: fresh.login,
225 authKind: "oauth",
226 token: next.value.accessToken,
227 }),
228 ),
229 );
230 }
231
232 // ---- the operator guard, again, on what is already stored ----
233
234 /** When the account last passed the guard. A cache: lose it, check again. */
235 const CHECKED = "guard_checked_at";
236 /** When the guard first could not run, while it still cannot. */
237 const UNCHECKED_SINCE = "guard_unchecked_since";
238 /** When the sweep last tried this origin, passed or not. */
239 const TRIED = "guard_tried_at";
240
241 /** A stored origin is checked again this often. */
242 export const GUARD_EVERY_MS = 24 * 60 * 60_000;
243 /** A check that could not run is tried again after this long. */
244 const GUARD_RETRY_MS = 15 * 60_000;
245 /** A check that cannot run for this long refuses the origin: fail closed. */
246 const GUARD_GIVE_UP_MS = 24 * 60 * 60_000;
247 /** The sweep's pause between requests: one origin at a time, gently. */
248 const SWEEP_PAUSE_MS = 500;
249
250 /** What a write through a paused origin is told, everywhere. */
251 export const WRITES_PAUSED =
252 "This forge could not verify this connection against the operator's rules, so writes through it are paused. Reads and sync go on. Check it again under Settings → Origins.";
253
254 /**
255 * Why writes through this origin are paused, or undefined when they are
256 * not. On a guarded host an origin writes only once its account has passed
257 * the guard, and not while a check is failing: until then it may read and
258 * sync, and nothing it does reaches the origin — no write-through, no push,
259 * no fork or pull made there. (A refused origin is `needs_reauth`, and does
260 * nothing at all.) Fails closed: an origin never checked is paused too,
261 * until the sweep or a person's "Check again" checks it.
262 */
263 export function writesPaused(app: App, originId: string): string | undefined {
264 const origin = getOriginById(app.db, originId);
265 if (origin === undefined || origin.needsReauth || !guarded(origin))
266 return undefined;
267 const checked = getCursor(app.db, originId, CHECKED);
268 const failing = getCursor(app.db, originId, UNCHECKED_SINCE);
269 return checked === undefined || failing !== undefined
270 ? WRITES_PAUSED
271 : undefined;
272 }
273
274 /** When the guard last tried this origin, if it ever did. */
275 export function lastGuardTry(app: App, originId: string): string | undefined {
276 return getCursor(app.db, originId, TRIED);
277 }
278
279 /** Record that this origin's account passed the guard now (a connect did). */
280 export function noteGuardPassed(app: App, originId: string): void {
281 setCursor(app.db, originId, CHECKED, new Date().toISOString());
282 clearCursor(app.db, originId, UNCHECKED_SINCE);
283 }
284
285 export type Recheck = "passed" | "refused" | "unchecked" | "unguarded";
286
287 /**
288 * The guard (`origins/operator.ts`) on an origin already stored: after an
289 * OAuth refresh, and on the sweep. A refusal marks it `needs_reauth` with
290 * the guard's sentence as the reason, so nothing — sync, write-through,
291 * push-through, the crew's pulls — uses the credential again until a person
292 * reconnects with an account the operator allows. A check that cannot run
293 * is retried; one that has not run for a day refuses the origin as well.
294 */
295 export async function recheckOrigin(
296 app: App,
297 origin: Origin,
298 provider: Provider,
299 options: { pauseMs?: number } = {},
300 ): Promise<Recheck> {
301 if (!guarded(origin)) return "unguarded";
302 if (getOriginById(app.db, origin.id)?.needsReauth !== false) return "refused";
303 setCursor(app.db, origin.id, TRIED, new Date().toISOString());
304 const identity = await provider.whoami();
305 const verdict = identity.ok
306 ? await checkCredential(provider, identity.value, process.env, options)
307 : undefined;
308 if (verdict?.ok === true) {
309 noteGuardPassed(app, origin.id);
310 if (verdict.value.listed !== null)
311 cacheOriginRepos(app.db, origin.id, verdict.value.listed);
312 return "passed";
313 }
314 if (verdict !== undefined && verdict.error.error !== "guard_unchecked") {
315 refuse(app, origin, verdict.error.message);
316 return "refused";
317 }
318 // A credential the origin no longer takes (a grant revoked there) is a
319 // reconnect, said now, not a day of quiet failures.
320 if (!identity.ok && identity.error.error === "unauthorized") {
321 refuse(app, origin, "The origin no longer accepts this credential. Reconnect it.");
322 return "refused";
323 }
324 const since = getCursor(app.db, origin.id, UNCHECKED_SINCE);
325 if (since === undefined) {
326 setCursor(app.db, origin.id, UNCHECKED_SINCE, new Date().toISOString());
327 } else if (Date.now() - Date.parse(since) >= GUARD_GIVE_UP_MS) {
328 refuse(
329 app,
330 origin,
331 "This forge could not check this account against the operator's rules for a day. Reconnect it.",
332 );
333 return "refused";
334 }
335 return "unchecked";
336 }
337
338 function refuse(app: App, origin: Origin, reason: string): void {
339 // Already refused (a refresh's check inside this one): its reason stands.
340 if (getOriginById(app.db, origin.id)?.needsReauth !== false) return;
341 markNeedsReauth(app.db, origin.id, reason);
342 forgetProvider({ dataDir: app.config.dataDir, originId: origin.id });
343 announceReauth(app, origin, reason);
344 log.warn("origin refused by the operator guard", {
345 origin: origin.id,
346 host: origin.webUrl,
347 });
348 }
349
350 /** Whether the sweep owes this origin a check now. */
351 function due(app: App, origin: Origin, now: number): boolean {
352 if (origin.needsReauth || !guarded(origin)) return false;
353 const tried = getCursor(app.db, origin.id, TRIED);
354 if (tried !== undefined && now - Date.parse(tried) < GUARD_RETRY_MS)
355 return false;
356 const checked = getCursor(app.db, origin.id, CHECKED);
357 return checked === undefined || now - Date.parse(checked) >= GUARD_EVERY_MS;
358 }
359
360 const sweeping = new WeakSet<App>();
361
362 /**
363 * The guard over every stored origin on a guarded host: one never checked
364 * (connected before the guard existed, or before the operator named its
365 * host) at once, every other once a day. One origin per call — the sync
366 * tick calls it each minute — with its requests spaced out, so the sweep
367 * never adds more than a trickle to the origin it protects. Single-flight
368 * per forge; never throws.
369 */
370 export function runGuardSweep(app: App): Promise<void> {
371 if (sweeping.has(app)) return Promise.resolve();
372 const now = Date.now();
373 // Paused origins first: their writes wait on this check.
374 const owed = listAllOrigins(app.db).filter((o) => due(app, o, now));
375 const origin =
376 owed.find((o) => writesPaused(app, o.id) !== undefined) ?? owed[0];
377 if (origin === undefined) return Promise.resolve();
378 sweeping.add(app);
379 return (async () => {
380 const provider = providerForOrigin(app, origin);
381 if (!provider.ok) return;
382 await recheckOrigin(app, origin, provider.value, {
383 pauseMs: SWEEP_PAUSE_MS,
384 });
385 })()
386 .catch((error: unknown) => {
387 log.warn("origin guard sweep failed", {
388 origin: origin.id,
389 error: String(error),
390 });
391 })
392 .finally(() => sweeping.delete(app));
192393 }
193394
194395 function announceReauth(app: App, origin: Origin, reason: string): void {
src/origins/discover.ts +22 −10
@@ -54,6 +54,25 @@ function allowedHosts(env: Record<string, string | undefined>): Set<string> {
5454 );
5555 }
5656
57 /**
58 * Whether the operator named this URL's host in `FORGE_ORIGIN_ALLOW`: its
59 * `host:port` for that port, or its bare hostname for every port. Discovery
60 * reads it, and so does the mirror's git (`origins/git-mirror.ts`), so a
61 * host an origin may be connected from is a host it may be mirrored from.
62 */
63 export function operatorAllowed(
64 href: string,
65 env: Record<string, string | undefined> = process.env,
66 ): boolean {
67 const url = URL.parse(href);
68 if (url === null) return false;
69 const allowed = allowedHosts(env);
70 return (
71 allowed.has(url.host.toLowerCase()) ||
72 allowed.has(url.hostname.toLowerCase())
73 );
74 }
75
5776 /** A development instance (`FORGE_DEV=1`, also set for the test suite). */
5877 function isDev(env: Record<string, string | undefined>): boolean {
5978 return env.FORGE_DEV === "1";
@@ -147,11 +166,7 @@ export function allowedOrigin(
147166 if (url.hostname === "") return undefined;
148167 if (url.protocol !== "https:" && url.protocol !== "http:") return undefined;
149168
150 const allowed = allowedHosts(env);
151 const named =
152 allowed.has(url.host.toLowerCase()) ||
153 allowed.has(url.hostname.toLowerCase());
154 if (named) return url.origin;
169 if (operatorAllowed(url.href, env)) return url.origin;
155170 if (isDev(env) && isLoopback(url.hostname)) return url.origin;
156171 if (url.protocol !== "https:") return undefined;
157172 if (isPrivateLiteral(url.hostname)) return undefined;
@@ -294,12 +309,9 @@ function exempt(
294309 href: string,
295310 env: Record<string, string | undefined>,
296311 ): boolean {
297 const url = new URL(href);
298 const allowed = allowedHosts(env);
299312 return (
300 allowed.has(url.host.toLowerCase()) ||
301 allowed.has(url.hostname.toLowerCase()) ||
302 (isDev(env) && isLoopback(url.hostname))
313 operatorAllowed(href, env) ||
314 (isDev(env) && isLoopback(new URL(href).hostname))
303315 );
304316 }
305317
src/origins/fork-route.ts added +268
@@ -0,0 +1,268 @@
1 /**
2 * Proposing through the account's own fork at the origin — the way to
3 * contribute to a repository the account can only read, and the only way
4 * this forge ever proposes to a repository the operator protects.
5 *
6 * Reading is enough to contribute: anyone who can read a repository can fork
7 * it and open a pull from the fork. On Forgejo 15 the pull's head is
8 * `<owner>:<branch>`: `parseCompareInfo` (routers/api/v1/repo/pull.go:1092)
9 * splits it on the colon, looks the owner up (:1098) and takes the head
10 * repository to be that owner's fork of the base (`GetForkedRepo`, :1127),
11 * whatever the fork is named; the base needs only read access for code and
12 * pulls (:1162), the head read access for code (:1175-1181). GitHub reads
13 * the same form. And a pull from a fork runs the base's workflows without
14 * its Actions secrets (services/actions/secret.go:43), which is why a
15 * protected repository is always proposed to from a fork, never from a
16 * branch pushed into it.
17 *
18 * This file holds the pieces both callers share: finding or making the fork
19 * (`contribute`'s fork step, and `openPull` on a mirror), and carrying a
20 * branch into it. Pushing into the account's own fork is never a push to the
21 * repository being proposed to.
22 */
23
24 import type { App } from "../app.ts";
25 import {
26 findOriginFork,
27 getOriginRepo,
28 originForRepo,
29 rememberOriginRepo,
30 type Origin,
31 } from "../core/origins.ts";
32 import type { Repo } from "../core/repos.ts";
33 import { denied } from "./operator.ts";
34 import { resolveOid } from "../git/merge.ts";
35 import { err, ok, type Result } from "../kernel/result.ts";
36 import { writesPaused } from "./credential.ts";
37 import { redact, secretsIn } from "./credentials.ts";
38 import { pushToOrigin } from "./git-mirror.ts";
39 import type { Provider, RemoteRepo } from "./types.ts";
40
41 /**
42 * How long to wait for a fork to exist. GitHub's docs say most forks are
43 * ready "within five minutes" and are in practice ready in one or two
44 * seconds; thirty is long enough for the common case and short enough that a
45 * request does not hang on the pathological one.
46 */
47 const FORK_DEADLINE_MS = 30_000;
48
49 /**
50 * The deadline a fork waits for when its caller names none. Mutable for the
51 * same reason `firstFetchRetry` is: a test that wants the timeout shortens it
52 * and puts back what it found.
53 */
54 export const forkDeadline: { ms: number } = { ms: FORK_DEADLINE_MS };
55
56 /** Backoff between polls, in ms; the last value repeats until the deadline. */
57 const FORK_BACKOFF = [250, 500, 1_000, 2_000, 3_000] as const;
58
59 /** Pushing a branch into the fork: the same cap as any push through. */
60 const PUSH_TIMEOUT_MS = 120_000;
61
62 export type EnsureForkError =
63 | "origin_unreachable"
64 | "origin_refused"
65 /** The origin took the request and the fork never appeared in time. */
66 | "fork_timeout";
67
68 export const sameRepo = (a: string | null, b: string): boolean =>
69 a !== null && a.toLowerCase() === b.toLowerCase();
70
71 /**
72 * The name a fork takes when the account already has an unrelated
73 * repository by the upstream's name: `<upstream owner>-<name>`.
74 */
75 const secondName = (upstream: string): string => upstream.replace("/", "-");
76
77 /**
78 * The fork of `upstream` this account already has, if any. The cache is
79 * asked first (it is the picker's own answer and costs nothing); failing
80 * that, the two names a fork made here can have are asked for directly — a
81 * fork made outside this forge is not in the cache, and forking again would
82 * be refused by the origin (one fork per account).
83 */
84 export async function existingFork(
85 app: App,
86 origin: Origin,
87 provider: Provider,
88 upstream: string,
89 ): Promise<RemoteRepo | undefined> {
90 const cached = findOriginFork(app.db, origin.id, upstream);
91 if (cached !== undefined) {
92 const fresh = await provider.getRepo(cached.fullName);
93 if (fresh.ok && sameRepo(fresh.value.parent, upstream)) return fresh.value;
94 }
95 const name = upstream.split("/")[1] ?? "";
96 if (name === "" || origin.login === "") return undefined;
97 for (const guess of [name, secondName(upstream)]) {
98 const found = await provider.getRepo(`${origin.login}/${guess}`);
99 if (found.ok && sameRepo(found.value.parent, upstream)) return found.value;
100 }
101 // Renamed, or made under a name of its own: the upstream's own list of
102 // forks names it by owner (bounded; one account has one fork of a repo).
103 const forks = await provider.listForks(upstream);
104 if (!forks.ok) return undefined;
105 const mine = forks.value.find(
106 (f) => f.owner.toLowerCase() === origin.login.toLowerCase(),
107 );
108 if (mine === undefined) return undefined;
109 const fresh = await provider.getRepo(mine.fullName);
110 return fresh.ok ? fresh.value : undefined;
111 }
112
113 /** Poll until the origin admits the fork exists, or the deadline passes. */
114 export async function awaitFork(
115 provider: Provider,
116 fullName: string,
117 waitMs: number,
118 ): Promise<Result<RemoteRepo, EnsureForkError>> {
119 const deadline = Date.now() + waitMs;
120 for (let attempt = 0; ; attempt++) {
121 const found = await provider.getRepo(fullName);
122 // An empty fork is still a fork; what we are waiting for is the row.
123 if (found.ok) return ok(found.value);
124 if (found.error.error !== "not_found")
125 return err(
126 found.error.error === "unreachable" ||
127 found.error.error === "rate_limited"
128 ? "origin_unreachable"
129 : "origin_refused",
130 );
131 const wait =
132 FORK_BACKOFF[Math.min(attempt, FORK_BACKOFF.length - 1)] ?? 3_000;
133 if (Date.now() + wait > deadline) return err("fork_timeout");
134 await new Promise((resolve) => setTimeout(resolve, wait));
135 }
136 }
137
138 /**
139 * The account's fork of `upstream` at the origin: the one it has, or a new
140 * one. Idempotent — GitHub answers a repeat `POST /forks` with the existing
141 * fork and Forgejo answers 409, so the check is ours. When the account
142 * already owns an unrelated repository by the upstream's name, the fork is
143 * made under a second name (`<owner>-<name>`); the pull's head form names
144 * the owner, not the repository, so the name does not matter to it.
145 */
146 export async function ensureFork(
147 app: App,
148 origin: Origin,
149 provider: Provider,
150 upstream: string,
151 waitMs: number = forkDeadline.ms,
152 ): Promise<Result<{ remote: RemoteRepo; existed: boolean }, EnsureForkError>> {
153 const found = await existingFork(app, origin, provider, upstream);
154 if (found !== undefined) {
155 remember(app, origin, found, upstream);
156 return ok({ remote: found, existed: true });
157 }
158
159 const name = upstream.split("/")[1] ?? "";
160 const failed = (error: string): EnsureForkError =>
161 error === "unreachable" || error === "rate_limited"
162 ? "origin_unreachable"
163 : "origin_refused";
164 let asked = await provider.forkRepo(upstream);
165 if (!asked.ok && asked.error.error !== "unreachable" && name !== "") {
166 // Refused: the usual reason is a repository of the account's own by
167 // the same name. Then the fork takes the second name.
168 const taken = await provider.getRepo(`${origin.login}/${name}`);
169 if (taken.ok && !sameRepo(taken.value.parent, upstream))
170 asked = await provider.forkRepo(upstream, secondName(upstream));
171 }
172 let remote: RemoteRepo;
173 if (asked.ok) {
174 const ready = await awaitFork(provider, asked.value.fullName, waitMs);
175 if (!ready.ok) return ready;
176 remote = ready.value;
177 } else {
178 // An origin that forks in the request itself can take longer than we
179 // wait for an answer, and still have done it. Before calling that a
180 // failure, look for the fork under its conventional name.
181 if (asked.error.error !== "unreachable" || name === "" || origin.login === "")
182 return err(failed(asked.error.error));
183 const late = await awaitFork(provider, `${origin.login}/${name}`, waitMs);
184 if (!late.ok || !sameRepo(late.value.parent, upstream))
185 return err(failed(asked.error.error));
186 remote = late.value;
187 }
188 remember(app, origin, remote, upstream);
189 return ok({ remote, existed: false });
190 }
191
192 /** The picker row for the fork, with the parent we know it has. */
193 function remember(
194 app: App,
195 origin: Origin,
196 remote: RemoteRepo,
197 upstream: string,
198 ): void {
199 // Both forges report the parent on `getRepo`; if this one did not, we
200 // know it anyway, because we are the reason the fork exists.
201 rememberOriginRepo(app.db, origin.id, {
202 ...remote,
203 parent: remote.parent ?? upstream,
204 });
205 }
206
207 /**
208 * Whether a proposal to this mirror's origin goes through the account's
209 * fork, as far as this forge knows without asking: the operator protects
210 * the repository, or the picker's cache says the account cannot push there.
211 * What a surface says on its button; `openPull` asks the origin itself.
212 */
213 export function proposesViaFork(app: App, mirror: Repo): boolean {
214 const link = originForRepo(app.db, mirror.id);
215 if (link === undefined) return false;
216 if (denied(link.origin, link.fullName)) return true;
217 return getOriginRepo(app.db, link.origin.id, link.fullName)?.canPush === false;
218 }
219
220 /** The pull's head for a branch of the account's fork: `<owner>:<branch>`. */
221 export const forkHead = (fork: RemoteRepo, branch: string): string =>
222 `${fork.owner}:${branch}`;
223
224 /**
225 * Carry a branch of a local repository into the account's fork at the
226 * origin, as `as`, leased on where that branch stands in the fork now (a
227 * new branch must not exist yet). Answers the tip it carried.
228 */
229 export async function pushToFork(
230 app: App,
231 origin: Origin,
232 provider: Provider,
233 fork: RemoteRepo,
234 dir: string,
235 branch: string,
236 as: string,
237 ): Promise<Result<string, string>> {
238 // Its callers ask too; this is the push itself asking, so no path that
239 // forgets cannot write through an origin whose account is not verified.
240 const paused = writesPaused(app, origin.id);
241 if (paused !== undefined) return err(paused);
242 const tip = await resolveOid(dir, `refs/heads/${branch}`);
243 if (tip === undefined) return err(`there is no branch ${branch} to propose`);
244 if (fork.cloneUrl === "") return err(`${fork.fullName} has no clone URL`);
245 const authEnv = await provider.gitAuthEnv(fork.cloneUrl);
246 if (Object.keys(authEnv).length === 0)
247 return err("this origin needs to be reconnected");
248 const branches = await provider.listBranches(fork.fullName);
249 if (!branches.ok) return err(`${fork.fullName} could not be read`);
250 const held =
251 branches.value.find((b) => b.name === as)?.oid ?? "0".repeat(40);
252 if (held === tip) return ok(tip);
253 const pushed = await pushToOrigin(
254 dir,
255 fork.cloneUrl,
256 [{ ref: `refs/heads/${as}`, oldOid: held, newOid: tip }],
257 authEnv,
258 { timeoutMs: PUSH_TIMEOUT_MS },
259 );
260 if (!pushed.ok)
261 return err(
262 redact(
263 `${fork.fullName} refused the branch: ${pushed.error.message}`,
264 ...secretsIn(authEnv),
265 ),
266 );
267 return ok(tip);
268 }
src/origins/git-mirror.ts +8 −1
@@ -25,6 +25,7 @@ import { listRefs } from "../git/read.ts";
2525 import { ZERO_OID } from "../git/hooks.ts";
2626 import { err, ok, type Result } from "../kernel/result.ts";
2727 import { trustedHosts } from "./trusted-hosts.ts";
28 import { operatorAllowed } from "./discover.ts";
2829 import { vetHost, type OutboundOptions } from "../kernel/outbound.ts";
2930 import { pinFor, remoteGitEnv } from "../git/remote-env.ts";
3031 import { redact, safeRemoteUrl, secretsIn } from "./credentials.ts";
@@ -66,7 +67,13 @@ export async function vettedEnv(
6667 protocols?: string,
6768 ): Promise<Result<Record<string, string>, GitMirrorError>> {
6869 const host = new URL(url).hostname;
69 const vetted = await vetHost(host, { ...outbound, trusted: trustedHosts() });
70 // A host the operator vouched for as an origin (`FORGE_ORIGIN_ALLOW`) is
71 // trusted for that origin's git as an admin-trusted host is: by name, and
72 // pinned to the answer that passed. Otherwise it connects and can never
73 // be mirrored. An address literal named there is not trusted: trust is by
74 // name, never by address (`origins/trusted-hosts.ts`).
75 const trusted = operatorAllowed(url) ? [...trustedHosts(), host] : trustedHosts();
76 const vetted = await vetHost(host, { ...outbound, trusted });
7077 if (!vetted.ok)
7178 return err({
7279 message: vetted.error === "refused" ? MIRROR_REFUSED : MIRROR_UNRESOLVED,
src/origins/oauth.ts +21
@@ -527,6 +527,27 @@ export function targetForShortCode(
527527 return opened.ok ? opened.value : undefined;
528528 }
529529
530 /**
531 * Where a pending flow sends the person — the authorize URL a code flow was
532 * started with, or a device flow's verification page — as it was sealed on
533 * the row when the flow began. Expiry is the caller's to judge (a page says
534 * "expired"; a short link just stops answering).
535 */
536 export function flowTarget(
537 db: Database,
538 dataDir: string,
539 flowId: string,
540 ): string | undefined {
541 const row = db
542 .query<RawFlow, [string]>(
543 "SELECT * FROM oauth_flow WHERE id = ?1 AND status = 'pending'",
544 )
545 .get(flowId);
546 if (!row || row.target_enc === "") return undefined;
547 const opened = open(dataDir, row.target_enc, row.id);
548 return opened.ok ? opened.value : undefined;
549 }
550
530551 export function getFlow(db: Database, flowId: string): FlowRow | undefined {
531552 const row = db
532553 .query<RawFlow, [string]>("SELECT * FROM oauth_flow WHERE id = ?1")
src/origins/operator.ts added +305
@@ -0,0 +1,305 @@
1 /**
2 * What the operator decides about the credentials people connect, read from
3 * the environment on each call: the guard.
4 *
5 * On a guarded host — one the operator registered an OAuth application for
6 * (`FORGE_ORIGIN_CLIENT_ID`), or one a push rule covers
7 * (`FORGE_ORIGIN_DENY_PUSH`) — a credential is checked before it is stored,
8 * after each OAuth refresh, and daily (`origins/credential.ts`). An account
9 * that administers the instance is refused, and so is an account that can
10 * push to a repository `FORGE_ORIGIN_DENY_PUSH` protects. The risk is the
11 * account the person authorized, not who owns this forge: a forge holds its
12 * credential where agents run.
13 *
14 * A leaf module: parsing and one check over a `Provider`, no database.
15 */
16
17 import { err, ok, type Result } from "../kernel/result.ts";
18 import type { Provider, RemoteIdentity, RemoteRepo } from "./types.ts";
19
20 type Env = Record<string, string | undefined>;
21
22 /** `git.example` from `https://git.example:8443/api/v1`: lower-case, no port. */
23 export function hostnameOf(url: string): string {
24 // A bare `host:port` parses as a URL whose scheme is the host.
25 const parsed = URL.parse(url.includes("://") ? url : `https://${url}`);
26 return parsed === null ? "" : parsed.hostname.toLowerCase();
27 }
28
29 /**
30 * The hostnames the operator registered an OAuth application for, in the
31 * order it named them — the same entries `operatorClientId` reads.
32 */
33 export function operatorHosts(env: Env = process.env): string[] {
34 const out: string[] = [];
35 for (const entry of (env.FORGE_ORIGIN_CLIENT_ID ?? "").split(",")) {
36 const at = entry.indexOf("=");
37 if (at === -1) continue;
38 const host = entry.slice(0, at).trim().toLowerCase();
39 if (host !== "" && entry.slice(at + 1).trim() !== "" && !out.includes(host))
40 out.push(host);
41 }
42 return out;
43 }
44
45 /**
46 * One entry of a list keyed by host: `host:owner/name` or `owner/name`.
47 * The host is everything before the last `:` ahead of the first `/`, so a
48 * port (`git.example:8443:owner/name`) survives; it is matched by hostname.
49 */
50 function splitHost(entry: string): { host: string | null; rest: string } {
51 const slash = entry.indexOf("/");
52 const head = slash === -1 ? entry : entry.slice(0, slash);
53 const colon = head.lastIndexOf(":");
54 if (colon === -1) return { host: null, rest: entry };
55 return {
56 host: hostnameOf(entry.slice(0, colon)) || null,
57 rest: entry.slice(colon + 1),
58 };
59 }
60
61 // ---- the push guard ----
62
63 export interface DenyRule {
64 /** The hostname it covers, or null for every origin. */
65 readonly host: string | null;
66 /** `owner/name`, `*` for any run of characters within one part. */
67 readonly pattern: string;
68 /** No wildcard: one repository, asked about by name. */
69 readonly exact: boolean;
70 readonly matches: (fullName: string) => boolean;
71 }
72
73 const PART = /^[A-Za-z0-9._*-]+$/;
74
75 /**
76 * `FORGE_ORIGIN_DENY_PUSH`: comma-separated `owner/name` patterns, `*` for
77 * any run of characters inside one part (`system/*`, `acme/app-*`). A bare
78 * `owner` means `owner/*`. A `host:` in front limits an entry to that host;
79 * without one it covers every origin. Case does not matter, as on Forgejo.
80 */
81 export function denyPushRules(env: Env = process.env): DenyRule[] {
82 const out: DenyRule[] = [];
83 for (const raw of (env.FORGE_ORIGIN_DENY_PUSH ?? "").split(",")) {
84 const entry = raw.trim();
85 if (entry === "") continue;
86 const { host, rest } = splitHost(entry);
87 const parts = rest.split("/");
88 const [owner, name = "*"] = parts;
89 if (parts.length > 2 || owner === undefined) continue;
90 if (!PART.test(owner) || !PART.test(name)) continue;
91 const pattern = `${owner}/${name}`.toLowerCase();
92 const re = new RegExp(
93 `^${pattern
94 .split("*")
95 .map((s) => s.replace(/[.+?^${}()|[\]\\]/g, "\\$&"))
96 .join("[^/]*")}$`,
97 );
98 out.push({
99 host,
100 pattern,
101 exact: !pattern.includes("*"),
102 matches: (fullName) => re.test(fullName.toLowerCase()),
103 });
104 }
105 return out;
106 }
107
108 /** The rules that cover one host. */
109 export function rulesForHost(
110 rules: readonly DenyRule[],
111 host: string,
112 ): DenyRule[] {
113 return rules.filter((r) => r.host === null || r.host === host);
114 }
115
116 /**
117 * Whether the operator protects this repository on this origin
118 * (`FORGE_ORIGIN_DENY_PUSH`). Such a repository is never pushed to from
119 * this forge, whatever the credential could do: a change to it is proposed
120 * from the account's own fork (`origins/fork-route.ts`), whose pulls run the
121 * base's workflows without its secrets.
122 */
123 export function denied(
124 origin: { apiUrl: string; webUrl: string },
125 fullName: string,
126 env: Env = process.env,
127 ): boolean {
128 const rules = denyPushRules(env);
129 return hostsOf(origin).some((h) =>
130 rulesForHost(rules, h).some((r) => r.matches(fullName)),
131 );
132 }
133
134 /** The hostnames an origin answers to: its web host and its API host. */
135 const hostsOf = (origin: { apiUrl: string; webUrl: string }): string[] =>
136 [...new Set([hostnameOf(origin.webUrl), hostnameOf(origin.apiUrl)])].filter(
137 (h) => h !== "",
138 );
139
140 /**
141 * Whether the guard covers this origin: the operator registered an
142 * application for its host, or a push rule covers it. Anywhere else a
143 * person connects what they like, as before.
144 */
145 export function guarded(
146 origin: { apiUrl: string; webUrl: string },
147 env: Env = process.env,
148 ): boolean {
149 const hosts = hostsOf(origin);
150 const operator = operatorHosts(env);
151 if (hosts.some((h) => operator.includes(h))) return true;
152 const rules = denyPushRules(env);
153 return hosts.some((h) => rulesForHost(rules, h).length > 0);
154 }
155
156 export type GuardError = "admin_account" | "can_push" | "guard_unchecked";
157
158 /** Why a credential was refused, in one sentence a person can act on. */
159 export interface GuardRefusal {
160 readonly error: GuardError;
161 readonly message: string;
162 /** For `can_push`: the protected repositories it can push to. */
163 readonly repos: readonly string[];
164 }
165
166 export interface GuardPass {
167 /**
168 * The repositories the account can see, when the walk ran (every guarded
169 * host): the picker's cache, filled for free.
170 */
171 readonly listed: readonly RemoteRepo[] | null;
172 /** The guard covered this host and the account passed it. */
173 readonly checked: boolean;
174 }
175
176 /** Pages of the account's repositories the check walks: 20 × the page size. */
177 export const GUARD_MAX_PAGES = 20;
178 /**
179 * Repositories named outright are asked about this many at a time. Every
180 * one is asked about: a rule past some count is never skipped.
181 */
182 const EXACT_AT_ONCE = 4;
183
184 const forgeName = (kind: Provider["kind"]): string =>
185 kind === "github" ? "GitHub" : kind === "gitea" ? "Gitea" : "Forgejo";
186
187 function named(repos: readonly string[]): string {
188 const shown = repos.slice(0, 3).join(", ");
189 return repos.length > 3 ? `${shown} and ${repos.length - 3} more` : shown;
190 }
191
192 const unchecked = (): GuardRefusal => ({
193 error: "guard_unchecked",
194 message:
195 "This forge could not check which repositories this account can push to. Try again in a minute.",
196 repos: [],
197 });
198
199 const pause = (ms: number): Promise<void> =>
200 ms > 0 ? new Promise((resolve) => setTimeout(resolve, ms)) : Promise.resolve();
201
202 /**
203 * Check a credential: before it is stored, after each OAuth refresh, and on
204 * the daily sweep of stored origins (`origins/credential.ts`). `identity` is
205 * what `whoami` just said about it; `provider` is built on the same
206 * credential. `pauseMs` spaces the requests out — the sweep's, so a forge
207 * re-checking its origins never leans on the origin it checks.
208 *
209 * The push check asks the origin for `permissions.push` — the account's
210 * real rights, whatever scopes the token claims. A repository named outright
211 * is asked about by name (`GET /repos/{o}/{r}`), every one of them; a
212 * wildcard walks every repository the account can see (`walkRepos`), which
213 * on Forgejo is every repository it owns, collaborates on or reaches through
214 * a team — every way to push. The walk is ordered so nothing moves under it,
215 * bounded, and counted; one that fails or does not finish refuses the
216 * account rather than waving it through: the guard fails closed.
217 */
218 export async function checkCredential(
219 provider: Provider,
220 identity: RemoteIdentity,
221 env: Env = process.env,
222 options: { pauseMs?: number } = {},
223 ): Promise<Result<GuardPass, GuardRefusal>> {
224 if (!guarded(provider, env)) return ok({ listed: null, checked: false });
225 const host = hostnameOf(provider.webUrl);
226 if (identity.siteAdmin)
227 return err({
228 error: "admin_account",
229 message: `${identity.login} is an administrator of ${host}. Sign in to ${forgeName(provider.kind)} with your everyday non-admin account and try again.`,
230 repos: [],
231 });
232
233 const rules = hostsOf(provider).flatMap((h) =>
234 rulesForHost(denyPushRules(env), h),
235 );
236 const pushable = new Set<string>();
237 const pauseMs = options.pauseMs ?? 0;
238 // A confirmed hit is the answer, whatever else could not be read: an
239 // account that can push to one protected repository is refused for that,
240 // never let off as "could not check" because another lookup failed.
241 const hit = (): Result<GuardPass, GuardRefusal> => {
242 const repos = [...pushable].sort();
243 return err({
244 error: "can_push",
245 message: `${identity.login} can push to ${named(repos)}. This forge does not accept an account that can push there. Connect an account without that access.`,
246 repos,
247 });
248 };
249
250 const exact = [...new Set(rules.filter((r) => r.exact).map((r) => r.pattern))];
251 let failed = false;
252 for (let at = 0; at < exact.length; at += EXACT_AT_ONCE) {
253 if (at > 0) await pause(pauseMs);
254 const answers = await Promise.all(
255 exact.slice(at, at + EXACT_AT_ONCE).map((name) => provider.getRepo(name)),
256 );
257 for (const found of answers) {
258 if (found.ok) {
259 if (found.value.permissions.push) pushable.add(found.value.fullName);
260 continue;
261 }
262 // A repository the account cannot see is one it cannot push to.
263 if (found.error.error === "not_found" || found.error.error === "forbidden")
264 continue;
265 failed = true;
266 }
267 if (pushable.size > 0) return hit();
268 }
269
270 // The walk runs on every guarded host: it is the person's list of
271 // repositories too, which the picker shows the moment they are back.
272 // Only a wildcard rule needs it whole, so only then does a short or failed
273 // walk refuse the account — after any hit in what it did read.
274 const needed = rules.some((r) => !r.exact);
275 if (needed) await pause(exact.length > 0 ? pauseMs : 0);
276 const walked = await provider.walkRepos({
277 maxPages: GUARD_MAX_PAGES,
278 pauseMs,
279 });
280 if (needed && walked.ok)
281 for (const repo of walked.value.repos)
282 if (repo.permissions.push && rules.some((r) => r.matches(repo.fullName)))
283 pushable.add(repo.fullName);
284 if (pushable.size > 0) return hit();
285 if (failed) return err(unchecked());
286 if (needed) {
287 if (!walked.ok) return err(unchecked());
288 if (!walked.value.complete)
289 return err(
290 walked.value.why === "too_many"
291 ? {
292 error: "guard_unchecked",
293 message:
294 "This account can see more repositories than this forge can check. Connect an account with access to fewer repositories.",
295 repos: [],
296 }
297 : unchecked(),
298 );
299 }
300
301 return ok({
302 listed: walked.ok && walked.value.complete ? [...walked.value.repos] : null,
303 checked: true,
304 });
305 }
src/origins/providers/forge.ts +6
@@ -41,6 +41,8 @@ export class ForgeProvider extends ForgejoProvider {
4141 login,
4242 displayName: asString(field(result.value.data, "displayName")) || login,
4343 htmlUrl: `${this.webUrl}/${login}`,
44 // `isAdmin` on a forge's own `/user`: the guard refuses such an account.
45 siteAdmin: field(result.value.data, "isAdmin") === true,
4446 });
4547 }
4648
@@ -81,6 +83,10 @@ export class ForgeProvider extends ForgejoProvider {
8183 default_branch: field(raw, "defaultBranch"),
8284 clone_url: `${web}.git`,
8385 html_url: web,
86 // A forge's repository JSON carries no rights. Unknown is not
87 // "cannot push": the direct way is tried, and its refusal falls
88 // back to the account's own fork.
89 permissions: { push: true, admin: false },
8490 }),
8591 );
8692 }
src/origins/providers/forgejo.ts +89 −5
@@ -43,6 +43,7 @@ import type {
4343 RemoteReview,
4444 RemoteReviewState,
4545 RemoteTreeEntry,
46 RepoWalk,
4647 } from "../types.ts";
4748 import {
4849 NO_ACCOUNT,
@@ -141,6 +142,9 @@ export class ForgejoProvider implements Provider {
141142 htmlUrl:
142143 asString(field(result.value.data, "html_url")) ||
143144 `${this.webUrl}/${login}`,
145 // Forgejo states it about the account asking (`/user` is always that
146 // account), and only then: it is no secret from the account itself.
147 siteAdmin: asBool(field(result.value.data, "is_admin")),
144148 });
145149 }
146150
@@ -184,6 +188,60 @@ export class ForgejoProvider implements Provider {
184188 });
185189 }
186190
191 /**
192 * `listRepos` is for a person looking; this is for a check that must see
193 * everything. It sorts by id, so a repository pushed or made while the walk
194 * runs cannot move into a page already read; it never falls back to
195 * `/user/repos`, whose order differs; and it is complete only when the
196 * distinct repositories seen equal `X-Total-Count`, which must say the
197 * same on every page. Anything else fails, and the guard refuses.
198 */
199 async walkRepos(options: {
200 maxPages: number;
201 pauseMs?: number;
202 }): Promise<ProviderResult<RepoWalk>> {
203 if (this.uid === undefined) {
204 const me = await this.whoami();
205 if (!me.ok) return me;
206 }
207 if (this.uid === undefined)
208 return fail("invalid_response", "the origin gave no id for the account");
209 const limit = await this.pageSize();
210 const seen = new Map<string, RemoteRepo>();
211 const read = (
212 complete: boolean,
213 why?: RepoWalk["why"],
214 ): ProviderResult<RepoWalk> =>
215 succeed({
216 repos: [...seen.values()],
217 complete,
218 ...(why === undefined ? {} : { why }),
219 });
220 let total: number | undefined;
221 for (let page = 1; page <= options.maxPages; page++) {
222 if (page > 1 && (options.pauseMs ?? 0) > 0)
223 await new Promise((resolve) => setTimeout(resolve, options.pauseMs));
224 const result = await this.api.call({
225 path: "/repos/search",
226 query: { uid: this.uid, sort: "id", order: "asc", limit, page },
227 });
228 if (!result.ok) return read(false, "failed");
229 const counted = Number(result.value.headers.get("x-total-count") ?? "");
230 if (!Number.isInteger(counted) || counted < 0) return read(false, "moved");
231 if (total !== undefined && counted !== total) return read(false, "moved");
232 total = counted;
233 const rows = asArray(field(result.value.data, "data"));
234 for (const raw of rows) {
235 const repo = toRepo(raw);
236 seen.set(repo.remoteId || repo.fullName.toLowerCase(), repo);
237 }
238 if (seen.size === total) return read(true);
239 // More than counted, or a page that ends the list short of the count.
240 if (seen.size > total || rows.length < limit) return read(false, "moved");
241 }
242 return read(false, "too_many");
243 }
244
187245 async getRepo(fullName: string): Promise<ProviderResult<RemoteRepo>> {
188246 const named = splitFullName(fullName);
189247 if (!named) return fail("not_found", `not a repository name: ${fullName}`);
@@ -719,9 +777,10 @@ export class ForgejoProvider implements Provider {
719777 ): Promise<ProviderResult<RemoteIssue>> {
720778 const named = splitFullName(fullName);
721779 if (!named) return fail("not_found", `not a repository name: ${fullName}`);
722 // `head` is an undocumented plain string here. `branch` is certain;
723 // whether `owner:branch` works for a cross-repo pull is unverified, which
724 // is why the fork-and-contribute flow is not offered yet (docs/ORIGINS.md).
780 // `head` is a plain string here: a branch of this repository, or
781 // `owner:branch` for a branch of a fork (Forgejo's parseCompareInfo,
782 // checked on Forgejo 15.0.9). A reader proposes with the second form
783 // (`origins/fork-route.ts`).
725784 const result = await this.api.call({
726785 path: `/repos/${seg(named.owner)}/${seg(named.name)}/pulls`,
727786 method: "POST",
@@ -736,13 +795,38 @@ export class ForgejoProvider implements Provider {
736795 return succeed(toPull(result.value.data));
737796 }
738797
739 async forkRepo(fullName: string): Promise<ProviderResult<RemoteRepo>> {
798 async listForks(
799 fullName: string,
800 ): Promise<ProviderResult<readonly RemoteRepo[]>> {
801 const named = splitFullName(fullName);
802 if (!named) return fail("not_found", `not a repository name: ${fullName}`);
803 const limit = await this.pageSize();
804 const forks: RemoteRepo[] = [];
805 for (let page = 1; page <= MAX_INTERNAL_PAGES; page++) {
806 const result = await this.api.call({
807 path: `/repos/${seg(named.owner)}/${seg(named.name)}/forks`,
808 query: { limit, page },
809 });
810 if (!result.ok) return result;
811 const rows = asArray(result.value.data);
812 forks.push(...rows.map((r) => toRepo(r)));
813 if (nextPage(result.value.headers, rows.length, limit, page) === null) break;
814 }
815 return succeed(forks);
816 }
817
818 async forkRepo(
819 fullName: string,
820 name?: string,
821 ): Promise<ProviderResult<RemoteRepo>> {
740822 const named = splitFullName(fullName);
741823 if (!named) return fail("not_found", `not a repository name: ${fullName}`);
742824 const result = await this.api.call({
743825 path: `/repos/${seg(named.owner)}/${seg(named.name)}/forks`,
744826 method: "POST",
745 body: {},
827 // Both forges take `name` (Forgejo's CreateForkOption, GitHub's
828 // create-fork body); without it the fork takes the upstream's name.
829 body: name === undefined ? {} : { name },
746830 });
747831 if (!result.ok) return result;
748832 return succeed(toRepo(result.value.data));
src/origins/providers/github.ts +65 −2
@@ -31,6 +31,7 @@ import type {
3131 RemoteReviewState,
3232 RemoteTreeEntry,
3333 TokenReach,
34 RepoWalk,
3435 } from "../types.ts";
3536 import {
3637 NO_ACCOUNT,
@@ -107,6 +108,7 @@ export class GitHubProvider implements Provider {
107108 htmlUrl:
108109 asString(field(result.value.data, "html_url")) ||
109110 `${this.webUrl}/${login}`,
111 siteAdmin: asBool(field(result.value.data, "site_admin")),
110112 });
111113 }
112114
@@ -150,6 +152,42 @@ export class GitHubProvider implements Provider {
150152 });
151153 }
152154
155 /**
156 * The guard's walk on GitHub: `/user/repos` oldest first, so a repository
157 * made while the walk runs lands on a later page rather than shifting an
158 * earlier one, and `Link` decides the end. GitHub counts nothing here,
159 * so there is no total to check against; a failed page fails the walk.
160 */
161 async walkRepos(options: {
162 maxPages: number;
163 pauseMs?: number;
164 }): Promise<ProviderResult<RepoWalk>> {
165 const repos = new Map<string, RemoteRepo>();
166 for (let page = 1; page <= options.maxPages; page++) {
167 if (page > 1 && (options.pauseMs ?? 0) > 0)
168 await new Promise((resolve) => setTimeout(resolve, options.pauseMs));
169 const result = await this.api.call({
170 path: "/user/repos",
171 query: {
172 per_page: PER_PAGE,
173 page,
174 sort: "created",
175 direction: "asc",
176 affiliation: "owner,collaborator,organization_member",
177 },
178 });
179 if (!result.ok)
180 return succeed({ repos: [...repos.values()], complete: false, why: "failed" });
181 for (const raw of asArray(result.value.data)) {
182 const repo = toRepo(raw);
183 repos.set(repo.remoteId || repo.fullName.toLowerCase(), repo);
184 }
185 if (!linkNext(result.value.headers))
186 return succeed({ repos: [...repos.values()], complete: true });
187 }
188 return succeed({ repos: [...repos.values()], complete: false, why: "too_many" });
189 }
190
153191 async getRepo(fullName: string): Promise<ProviderResult<RemoteRepo>> {
154192 const named = splitFullName(fullName);
155193 if (!named) return fail("not_found", `not a repository name: ${fullName}`);
@@ -692,13 +730,38 @@ export class GitHubProvider implements Provider {
692730 return succeed(toPull(result.value.data));
693731 }
694732
695 async forkRepo(fullName: string): Promise<ProviderResult<RemoteRepo>> {
733 async listForks(
734 fullName: string,
735 ): Promise<ProviderResult<readonly RemoteRepo[]>> {
736 const named = splitFullName(fullName);
737 if (!named) return fail("not_found", `not a repository name: ${fullName}`);
738
739 const forks: RemoteRepo[] = [];
740 for (let page = 1; page <= MAX_INTERNAL_PAGES; page++) {
741 const result = await this.api.call({
742 path: `/repos/${seg(named.owner)}/${seg(named.name)}/forks`,
743 query: { per_page: PER_PAGE, page },
744 });
745 if (!result.ok) return result;
746 const rows = asArray(result.value.data);
747 forks.push(...rows.map((r) => toRepo(r)));
748 if (!linkNext(result.value.headers)) break;
749 }
750 return succeed(forks);
751 }
752
753 async forkRepo(
754 fullName: string,
755 name?: string,
756 ): Promise<ProviderResult<RemoteRepo>> {
696757 const named = splitFullName(fullName);
697758 if (!named) return fail("not_found", `not a repository name: ${fullName}`);
698759 const result = await this.api.call({
699760 path: `/repos/${seg(named.owner)}/${seg(named.name)}/forks`,
700761 method: "POST",
701 body: {},
762 // Both forges take `name` (Forgejo's CreateForkOption, GitHub's
763 // create-fork body); without it the fork takes the upstream's name.
764 body: name === undefined ? {} : { name },
702765 });
703766 if (!result.ok) return result;
704767 return succeed(toRepo(result.value.data));
src/origins/providers/http.ts +71 −2
@@ -36,6 +36,47 @@ export const fail = (
3636 error: { error, message, ...(retryAt === undefined ? {} : { retryAt }) },
3737 });
3838
39 /**
40 * The scopes in a Forgejo or Gitea refusal — `token does not have at least
41 * one of required scope(s): [read:user]` — or none. Only scope-shaped words
42 * are kept, so nothing else the body says can ride along into a message.
43 */
44 export function missingScopes(body: string): string[] {
45 const listed = /required scope\(s\): \[([^\]]{0,200})\]/.exec(body);
46 if (!listed) return [];
47 return (listed[1] ?? "")
48 .split(/[\s,]+/)
49 .filter((s) => /^(read|write):[a-z]{1,20}$/.test(s));
50 }
51
52 const CATEGORY: Record<string, string> = {
53 activitypub: "ActivityPub",
54 admin: "Admin",
55 issue: "Issue",
56 misc: "Misc",
57 notification: "Notification",
58 organization: "Organization",
59 package: "Package",
60 repository: "Repository",
61 user: "User",
62 };
63
64 /**
65 * Scopes as the token page names them: `read:user` is "User: Read" and
66 * `write:issue` "Issue: Read and write" (write includes read there).
67 */
68 export function scopeWords(scopes: readonly string[]): string {
69 const words = scopes.map((scope) => {
70 const [level, category = ""] = scope.split(":");
71 const name =
72 CATEGORY[category] ?? category.charAt(0).toUpperCase() + category.slice(1);
73 return `${name}: ${level === "write" ? "Read and write" : "Read"}`;
74 });
75 return words.length <= 1
76 ? (words[0] ?? "")
77 : `${words.slice(0, -1).join(", ")} or ${words.at(-1)}`;
78 }
79
3980 export const succeed = <T>(value: T): ProviderResult<T> => ({
4081 ok: true,
4182 value,
@@ -175,6 +216,14 @@ export class ApiClient {
175216 const method = request.method ?? "GET";
176217 const maxBytes = options.maxBytes ?? this.maxBytes;
177218
219 // Anything that is not a read asks the credential first: an origin whose
220 // account could not be verified (the operator guard) reads but does not
221 // write, and this is the one door every write through an adapter takes.
222 if (method !== "GET" && method !== "HEAD" && this.credential.writable) {
223 const may = this.credential.writable();
224 if (!may.ok) return may;
225 }
226
178227 // Resolved per call: an OAuth token may have been refreshed since the
179228 // last one, and a token that cannot be resolved must not become a
180229 // credential-less request that reads as "not found".
@@ -252,8 +301,28 @@ export class ApiClient {
252301
253302 if (response.status === 401)
254303 return fail("unauthorized", "the origin rejected this token");
255 if (response.status === 403)
256 return fail("forbidden", "the token may not do that on the origin");
304 if (response.status === 403) {
305 // Forgejo and Gitea say which scope a token lacks; that sentence is
306 // worth one small read, because "may not" alone sends a person to
307 // guess between a wrong token, an expired one and a narrow one.
308 let said = "";
309 try {
310 said = (await readCapped(response, 4096, signal)) ?? "";
311 } catch {
312 said = "";
313 }
314 const missing = missingScopes(said);
315 return missing.length === 0
316 ? fail("forbidden", "the token may not do that on the origin")
317 : {
318 ok: false,
319 error: {
320 error: "forbidden",
321 message: `the token is missing ${scopeWords(missing)}`,
322 missingScopes: missing,
323 },
324 };
325 }
257326 if (response.status === 404)
258327 return fail("not_found", "the origin has no such thing");
259328 if (response.status >= 500)
src/origins/push-through.ts +17 −1
@@ -37,9 +37,10 @@ import { FORWARDED_GIT_ENV, type RefUpdate } from "../git/hook-protocol.ts";
3737 import { fetchInto, resolveOid, writeRef } from "../git/merge.ts";
3838 import { repoDir } from "../git/repos.ts";
3939 import { err, ok, type Result } from "../kernel/result.ts";
40 import { providerForOrigin } from "./credential.ts";
40 import { providerForOrigin, writesPaused } from "./credential.ts";
4141 import { redact, secretsIn } from "./credentials.ts";
4242 import { pushToOrigin, snapshotRefs } from "./git-mirror.ts";
43 import { denied } from "./operator.ts";
4344
4445 /** A push-through must not outlive a pusher's patience. */
4546 const TIMEOUT_MS = 60_000;
@@ -47,6 +48,8 @@ const TIMEOUT_MS = 60_000;
4748 export interface PushThroughRefusal {
4849 /** Shown to the pusher verbatim; already scrubbed of credentials. */
4950 readonly message: string;
51 /** The origin itself refused the push (no rights there), not this forge. */
52 readonly byOrigin?: boolean;
5053 }
5154
5255 /**
@@ -129,6 +132,10 @@ export async function carryBranchThrough(
129132
130133 const ZERO_OID_STRING = "0".repeat(40);
131134
135 /** What a push to a protected repository is told, everywhere. */
136 export const protectedMessage = (webUrl: string, fullName: string): string =>
137 `${fullName} on ${webUrl} is protected by this forge's operator: nothing is pushed to it from here. Propose the change from your fork instead.`;
138
132139 /** The origin's refusal, in its words, with the credential kept out of them. */
133140 function refusedBy(
134141 link: NonNullable<ReturnType<typeof originForRepo>>,
@@ -145,6 +152,7 @@ function refusedBy(
145152 `${link.fullName} on ${link.origin.webUrl} refused this push: ${message}`,
146153 ...secretsIn(authEnv),
147154 ),
155 byOrigin: true,
148156 };
149157 }
150158
@@ -191,6 +199,14 @@ async function wayThrough(
191199 message: "this mirror has no upstream clone URL; re-mirror it",
192200 });
193201
202 const paused = writesPaused(app, link.origin.id);
203 if (paused !== undefined)
204 return err({ message: `${link.origin.webUrl}: ${paused}` });
205 // A repository the operator protects is never pushed to from here, even
206 // by an account that could: changes to it go up as pulls from a fork.
207 if (denied(link.origin, link.fullName))
208 return err({ message: protectedMessage(link.origin.webUrl, link.fullName) });
209
194210 const provider = providerForOrigin(app, link.origin);
195211 if (!provider.ok)
196212 return err({
src/origins/token-reach.ts +6
@@ -64,6 +64,12 @@ export function replaceTokenMessage(error: string): string {
6464 unreachable: "That instance could not be reached.",
6565 rate_limited: "Too many connect attempts. Wait a few minutes.",
6666 unauthorized: "Sign in first.",
67 admin_account:
68 "That token is an administrator's. This forge does not accept one; use an everyday account's.",
69 can_push:
70 "That token can push to a protected repository. This forge does not accept one that can.",
71 guard_unchecked:
72 "This forge could not check what that token can do. Try again in a minute.",
6773 };
6874 return words[error] ?? "That token could not be put on this origin.";
6975 }
src/origins/types.ts +55 −1
@@ -241,6 +241,13 @@ export interface RemoteIdentity {
241241 readonly login: string;
242242 readonly displayName: string;
243243 readonly htmlUrl: string;
244 /**
245 * The account administers the whole instance: Forgejo's and Gitea's
246 * `is_admin`, GitHub's `site_admin`. A credential of such an account can
247 * write every repository there is, which is what the operator guard
248 * (`origins/operator.ts`) refuses on the hosts it covers.
249 */
250 readonly siteAdmin: boolean;
244251 }
245252
246253 export type ProviderError =
@@ -256,6 +263,26 @@ export interface ProviderFailure {
256263 readonly message: string;
257264 /** For `rate_limited`: when to try again. */
258265 readonly retryAt?: string;
266 /**
267 * For `forbidden`: the token scopes the origin said it lacks, in the
268 * origin's own words (`read:user`), when it said (Forgejo, Gitea).
269 */
270 readonly missingScopes?: readonly string[];
271 }
272
273 /**
274 * Every repository a credential can see, read for a check that must not miss
275 * one. `complete` is false when the pages ran out before the list did.
276 */
277 export interface RepoWalk {
278 /** What was read, complete or not: a protected repository among them counts. */
279 readonly repos: readonly RemoteRepo[];
280 readonly complete: boolean;
281 /**
282 * Why it is not complete: the pages ran out (`too_many`), the list moved
283 * or its count did not add up (`moved`), or a page failed (`failed`).
284 */
285 readonly why?: "too_many" | "moved" | "failed";
259286 }
260287
261288 /** A page of a list, with the cursor for the next page (null at the end). */
@@ -296,6 +323,18 @@ export interface Provider {
296323 */
297324 tokenReach?(): Promise<ProviderResult<TokenReach>>;
298325 listRepos(cursor?: string): Promise<ProviderResult<Page<RemoteRepo>>>;
326 /**
327 * Every repository the credential can see, for the operator guard
328 * (`origins/operator.ts`): in an order a repository cannot move in while
329 * the walk runs (by id, or by creation), with no fallback to another
330 * endpoint, at most `maxPages` pages and `pauseMs` between them. A page
331 * that fails, or a count that does not add up, ends it incomplete, with
332 * what was read so far; only a walk that cannot start fails.
333 */
334 walkRepos(options: {
335 maxPages: number;
336 pauseMs?: number;
337 }): Promise<ProviderResult<RepoWalk>>;
299338 getRepo(fullName: string): Promise<ProviderResult<RemoteRepo>>;
300339
301340 // ---- reads for browsing a repository we have not mirrored ----
@@ -436,12 +475,21 @@ export interface Provider {
436475 body: string;
437476 },
438477 ): Promise<ProviderResult<RemoteReviewState>>;
478 /**
479 * The forks of a repository, as many as fit a bounded walk: what finds an
480 * account's fork when it goes by a name this forge did not give it.
481 */
482 listForks(fullName: string): Promise<ProviderResult<readonly RemoteRepo[]>>;
439483 /**
440484 * Fork a repo into the credential's account. GitHub creates the fork
441485 * *asynchronously* and answers 202 with a repo that does not exist yet, so
442486 * a caller must poll `getRepo` until it does (`actions/contribute.ts`).
443487 */
444 forkRepo(fullName: string): Promise<ProviderResult<RemoteRepo>>;
488 forkRepo(
489 fullName: string,
490 /** The fork's name, when not the upstream's own (a name already taken). */
491 name?: string,
492 ): Promise<ProviderResult<RemoteRepo>>;
445493 /**
446494 * Archive a repo (`PATCH /repos/{o}/{r}` with `archived: true`): read-only
447495 * on the origin from then on. Needs admin there; without it the answer is
@@ -499,6 +547,12 @@ export interface ProviderCredential {
499547 * credential is gone or unrefreshable, and the origin needs reconnecting.
500548 */
501549 token(): Promise<ProviderResult<string>>;
550 /**
551 * Whether the credential may change anything on the origin now: asked
552 * before every request that is not a read. A refusal is `forbidden`, with
553 * the reason. Absent, it always may (a credential checked by nobody).
554 */
555 writable?(): ProviderResult<true>;
502556 }
503557
504558 /** A credential that is just a stored secret — the PAT path, and tests. */
src/origins/write-through.ts +8 −2
@@ -15,13 +15,17 @@ import { originForRepo, type Origin } from "../core/origins.ts";
1515 import type { Repo } from "../core/repos.ts";
1616 import type { User } from "../core/users.ts";
1717 import { err, ok, type Result } from "../kernel/result.ts";
18 import { providerForOrigin } from "./credential.ts";
18 import { providerForOrigin, writesPaused } from "./credential.ts";
1919 import type { Provider } from "./types.ts";
2020
2121 export type WriteThroughError =
2222 | "origin_owner_only"
2323 | "origin_unreachable"
24 | "origin_refused";
24 | "origin_refused"
25 /** The account behind the origin is not verified against the operator's rules yet. */
26 | "origin_paused"
27 /** The operator protects the origin repository: propose from a fork. */
28 | "origin_protected";
2529
2630 export interface WriteThrough {
2731 readonly provider: Provider;
@@ -49,6 +53,8 @@ export function writeThroughFor(
4953 const link = originForRepo(app.db, repo.id);
5054 if (!link) return ok(undefined);
5155 if (link.origin.userId !== actor.id) return err("origin_owner_only");
56 if (writesPaused(app, link.origin.id) !== undefined)
57 return err("origin_paused");
5258 const provider = providerForOrigin(app, link.origin);
5359 if (!provider.ok) return err("origin_unreachable");
5460 return ok({
src/tui/screens/common.ts +2
@@ -982,6 +982,8 @@ const MESSAGES: Record<string, string> = {
982982 mirrored: "this repo mirrors an origin — do it there",
983983 origin_owner_only: "only whoever connected this origin can write through it",
984984 origin_refused: "the origin refused that; nothing changed here",
985 origin_paused: "this connection is not verified yet; writes are paused",
986 origin_protected: "the operator protects this repository at its origin: nothing is pushed to it from here; propose the change from your fork",
985987 origin_unreachable: "the origin could not be reached; nothing changed",
986988 already_connected: ORIGIN_ALREADY_CONNECTED,
987989 already_mirrored: "that repository is already mirrored here",
src/tui/screens/connections.ts +22 −7
@@ -4,9 +4,10 @@
44 * it after a confirm — through `actions/origins.ts`, in `origin_words.ts`'s words.
55 */
66
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";
7 import { connectOrigin, checkOriginAgain, connectRefusal, disconnectOrigin, listUserOrigins, syncOriginNow } from "../../actions/origins.ts";
8 import { CHECK_TOO_SOON, CHECK_WORDS, DISCONNECT_DETAIL, NO_SUCH_ORIGIN, connectWords, disconnectedWords, syncedWords } from "../../actions/origin_words.ts";
99 import { mirrorsByOrigin } from "../../core/origins.ts";
10 import { writesPaused } from "../../origins/credential.ts";
1011 import { FORGE_NOT_CARRIED, FORGE_SIGN_IN_ON_PAGE } from "../../origins/words.ts";
1112 import type { Ctx, Screen } from "../app/app.ts";
1213 import { binding } from "../app/keys.ts";
@@ -27,16 +28,19 @@ export function connectionsScreen(f: Session, ctx: Ctx): Screen {
2728
2829 const screen: Screen = {
2930 title: "Connected origins",
30 keys: [binding(["j", "k"], "move cursor"), binding(["c"], "connect"), binding(["s"], "sync now"), binding(["d"], "disconnect"), binding(["esc"], "back")],
31 keys: [binding(["j", "k"], "move cursor"), binding(["c"], "connect"), binding(["s"], "sync now"), binding(["v"], "verify again"), binding(["d"], "disconnect"), binding(["esc"], "back")],
3132 render(area, s) {
3233 const c = ctx.theme.c;
3334 const [head, list] = split(area, "v", [len(1), fill(1)]);
3435 const all = origins();
3536 const picked = all[state.index];
37 const pausedNote = picked !== undefined && writesPaused(f.app, picked.id) !== undefined;
3638 const text =
3739 picked?.software === "forge"
3840 ? `Another forge. ${FORGE_NOT_CARRIED}`
39 : all.length === 0
41 : pausedNote
42 ? "Couldn't verify this connection; writes paused. Reading and syncing go on. Press v to check again."
43 : all.length === 0
4044 ? `No origin connected. Press c to connect one. ${FORGE_SIGN_IN_ON_PAGE}`
4145 : `The hosts your mirrors come from. ${FORGE_SIGN_IN_ON_PAGE}`;
4246 s.text(head!.x, head!.y, truncate(text, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w);
@@ -50,9 +54,9 @@ export function connectionsScreen(f: Session, ctx: Ctx): Screen {
5054 const k = n.get(o.id)?.count ?? 0;
5155 return {
5256 title: `${host(o.webUrl)} · ${o.login}`,
53 ...(o.lastError !== "" ? { tone: "danger" as const } : {}),
57 ...(o.lastError !== "" || writesPaused(f.app, o.id) !== undefined ? { tone: "danger" as const } : {}),
5458 secondary: `${o.software === "forge" ? "another forge · " : ""}${k} repositor${k === 1 ? "y" : "ies"} · ${day(o.lastSyncAt)}`,
55 meta: o.lastError !== "" ? o.lastError : o.authKind === "oauth" ? "OAuth" : "token",
59 meta: o.lastError !== "" ? o.lastError : writesPaused(f.app, o.id) !== undefined ? "writes paused" : o.authKind === "oauth" ? "OAuth" : "token",
5660 };
5761 }),
5862 );
@@ -79,7 +83,7 @@ export function connectionsScreen(f: Session, ctx: Ctx): Screen {
7983 if (token.trim() === "") return "Paste the token.";
8084 ctx.toast(`Connecting ${hostText}…`);
8185 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");
86 ctx.toast(r.ok ? `Connected ${r.value.login} at ${host(r.value.webUrl)}.` : connectRefusal(r.error).message ?? connectWords(connectRefusal(r.error).code), r.ok ? "success" : "danger");
8387 ctx.invalidate();
8488 });
8589 },
@@ -105,6 +109,17 @@ export function connectionsScreen(f: Session, ctx: Ctx): Screen {
105109 .catch(() => ctx.toast("The sync failed.", "danger"));
106110 return true;
107111 }
112 if (e.key === "v") {
113 ctx.toast(`Checking ${host(o.webUrl)}…`);
114 void checkOriginAgain(f.app, user, o.id)
115 .then((r) => {
116 if (!r.ok) ctx.toast(r.error === "too_soon" ? CHECK_TOO_SOON : NO_SUCH_ORIGIN, "danger");
117 else ctx.toast(CHECK_WORDS[r.value], r.value === "passed" || r.value === "unguarded" ? "success" : "danger");
118 ctx.invalidate();
119 })
120 .catch(() => ctx.toast("The check failed.", "danger"));
121 return true;
122 }
108123 if (e.key === "d") {
109124 ctx.push(
110125 confirmScreen(ctx, screen, {
src/web/pages/branches.ts +4
@@ -349,6 +349,10 @@ export const CREATE_ERRORS: Record<string, string> = {
349349 "The origin could not be reached, so nothing was created.",
350350 origin_refused:
351351 "The origin refused that branch, so nothing was created here.",
352 origin_protected:
353 "The operator protects this repository at its origin, so no branch is pushed to it from here. Propose the change from your fork.",
354 origin_paused:
355 "This connection is not verified yet, so writes through it are paused. Nothing was created. Check it again under Settings → Origins.",
352356 forbidden: "You need write access to create a branch.",
353357 unauthorized: "You need write access to create a branch.",
354358 not_found: "You need write access to create a branch.",
src/web/pages/browse.ts +2
@@ -950,6 +950,8 @@ export function forkFailure(error: string): string {
950950 return "The origin could not be reached. Nothing was created.";
951951 if (error === "origin_refused")
952952 return "The origin refused to fork that repository.";
953 if (error === "origin_paused")
954 return "This connection is not verified yet, so writes through it are paused. Check it again under Settings → Origins.";
953955 if (error === "mirror_failed")
954956 return "The fork was made, but a local copy of it could not be.";
955957 if (error === "unauthorized") return "Sign in first.";
src/web/pages/edit.ts +4
@@ -126,6 +126,10 @@ export function editErrorText(error: unknown): string {
126126 return "The origin could not be reached, so nothing was written here either.";
127127 if (error === "origin_refused")
128128 return "The origin refused this change, so nothing landed here either.";
129 if (error === "origin_protected")
130 return "The operator protects this repository at its origin, so nothing is pushed to it from here. Propose the change from your fork.";
131 if (error === "origin_paused")
132 return "This connection is not verified yet, so writes through it are paused. Check it again under Settings → Origins.";
129133 return "That edit could not be saved.";
130134 }
131135 const e = error as { kind?: string; branch?: string; message?: string };
src/web/pages/following.ts +13 −5
@@ -43,6 +43,7 @@ import { loadRepo, notFoundPage, repoBase, signInFirst } from "../chrome.ts";
4343 import { convergeWords, followModeOf, takeOrTell, takeWords } from "../../actions/follow.ts";
4444 import { FOLLOWS_NOTHING, proposeRefusalWords } from "../../actions/follow_words.ts";
4545 import { openProposal, proposeUpstream } from "../../actions/upstream.ts";
46 import { proposesViaFork } from "../../origins/fork-route.ts";
4647 import { field, readForm } from "../form.ts";
4748 import { html, when, type Html } from "../html.ts";
4849 import { flashOrJson } from "../layout.ts";
@@ -284,11 +285,18 @@ export function followingCard(
284285 proposal === undefined
285286 ? html`<form method="post" action="${base}/following">
286287 <input type="hidden" name="do" value="propose" />
287 ${btn("Propose upstream", {
288 size: "sm",
289 type: "submit",
290 icon: "merge",
291 })}
288 ${proposesViaFork(ctx.app, parent)
289 ? btn("Propose via your fork", {
290 size: "sm",
291 type: "submit",
292 icon: "fork",
293 title: `Your account can only read ${parent.ownerName}/${parent.name}'s origin, or its operator protects it: the change goes to your own fork there, and the pull opens from it`,
294 })
295 : btn("Propose upstream", {
296 size: "sm",
297 type: "submit",
298 icon: "merge",
299 })}
292300 </form>`
293301 : btn("See the upstream proposal", {
294302 size: "sm",
src/web/pages/origin-flow.ts +69 −18
@@ -9,7 +9,7 @@
99 */
1010
1111 import { pollOAuthConnect } from "../../actions/origins.ts";
12 import { getFlowFor } from "../../origins/oauth.ts";
12 import { flowExpired, flowTarget, getFlowFor } from "../../origins/oauth.ts";
1313 import type { Ctx } from "../../http/context.ts";
1414 import { forgeBaseUrl } from "../../http/api.ts";
1515 import { qrEncode, qrSvg } from "../../design/qr.ts";
@@ -49,14 +49,15 @@ export function originFlowPage(ctx: Ctx): Response {
4949 return redirect(`${then ?? "/settings/origins"}?connected=1`);
5050 if (flow.status === "failed")
5151 return redirect(
52 `/settings/origins?error=${encodeURIComponent(flow.detail.slice(0, 120))}`,
52 `/settings/origins?error=${encodeURIComponent(flow.detail.slice(0, 300))}`,
5353 );
5454
55 // A pending flow past its time leads nowhere: not to a consent screen
56 // for a state that will be refused, nor to a code that no longer works.
57 if (flowExpired(flow)) return expiredFlow(ctx, flow, then);
58
5559 const pollAction = `/settings/origins/flows/${flow.id}/poll${then === undefined ? "" : `?${thenQuery(then)}`}`;
56 const authorize =
57 flow.flow === "code"
58 ? authorizeLink(ctx.url.searchParams.get("to") ?? "", flow.webUrl)
59 : undefined;
60 const authorize = flow.flow === "code" ? authorizeFor(ctx, flow) : undefined;
6061 const base = forgeBaseUrl(ctx.url, ctx.app.config.host);
6162 const shortLink =
6263 base === "" || flow.shortCode === "" ? "" : `${base}/go/${flow.shortCode}`;
@@ -211,24 +212,74 @@ function qrScale(modules: number): number {
211212 }
212213
213214 /**
214 * The authorize URL for a code flow, checked before it becomes a link.
215 *
216 * It arrives on the query string because the flow row stores a *device*
217 * flow's verification URI and nothing for a code flow. A crafted link could
218 * therefore reach this page, so the URL is accepted only when it points at
219 * the same instance the flow already names and carries a PKCE challenge —
220 * the worst a forged one can do is send you to another page on the forge you
221 * asked to connect.
215 * The authorize URL a code flow goes on to: the one sealed on the flow when
216 * it began (`flowTarget`), never one from the query string — a link a
217 * stranger crafted would otherwise choose the client and the callback. It is
218 * checked again before it becomes a link: the instance's own
219 * `/login/oauth/authorize`, with the flow's client id and callback and a
220 * PKCE challenge. An older link that still carries `to` continues only when
221 * `to` is exactly that URL.
222222 */
223 function authorizeLink(raw: string, webUrl: string): string | undefined {
224 const url = URL.parse(raw);
225 const instance = URL.parse(webUrl);
223 function authorizeFor(
224 ctx: Ctx,
225 flow: { id: string; webUrl: string; clientId: string; redirectUri: string },
226 ): string | undefined {
227 const stored = flowTarget(ctx.app.db, ctx.app.config.dataDir, flow.id);
228 if (stored === undefined) return undefined;
229 const asked = ctx.url.searchParams.get("to");
230 if (asked !== null && asked !== stored) return undefined;
231 const url = URL.parse(stored);
232 const instance = URL.parse(flow.webUrl);
226233 if (!url || !instance) return undefined;
227234 if (url.origin !== instance.origin) return undefined;
235 const at = `${instance.pathname.replace(/\/+$/, "")}/login/oauth/authorize`;
236 if (url.pathname !== at) return undefined;
237 if (url.searchParams.get("client_id") !== flow.clientId) return undefined;
238 if (url.searchParams.get("redirect_uri") !== flow.redirectUri) return undefined;
228239 if (url.searchParams.get("code_challenge") === null) return undefined;
229240 return url.toString();
230241 }
231242
243 /**
244 * A connect that ran out of time before it was finished: said so, with the
245 * way to start again.
246 */
247 function expiredFlow(
248 ctx: Ctx,
249 flow: { kind: string; webUrl: string },
250 then: Then,
251 ): Response {
252 return page({
253 ctx,
254 title: "Connecting an origin",
255 body: html`<div class="page-head"><h1>Connecting an origin</h1></div>
256 <div class="narrow-wide">
257 ${card(
258 html`<p class="mb">
259 This connect link expired. Nothing was connected. Start again:
260 </p>
261 <form method="post" action="/settings/origins/oauth">
262 <input type="hidden" name="host" value="${flow.webUrl}" />
263 ${flow.kind === "github"
264 ? html`<input type="hidden" name="kind" value="github" />`
265 : null}
266 ${then === undefined
267 ? null
268 : html`<input type="hidden" name="then" value="migrate" />`}
269 ${btn(`Connect ${hostLabel(flow.webUrl)}`, {
270 variant: "primary",
271 icon: "external",
272 })}
273 </form>`,
274 {
275 head: html`${icon("alert")}
276 <h2>This connect link expired</h2>`,
277 },
278 )}
279 </div>`,
280 });
281 }
282
232283 /**
233284 * POST /settings/origins/flows/:id/poll — ask the origin once, on the user's
234285 * behalf. The forge does the polling, so a browser (or a phone) only ever
@@ -265,7 +316,7 @@ export async function originFlowPoll(ctx: Ctx): Promise<Response> {
265316 return redirect(`${then ?? "/settings/origins"}?connected=1`);
266317 if (status === "failed")
267318 return redirect(
268 `/settings/origins?error=${encodeURIComponent(detail.slice(0, 120))}`,
319 `/settings/origins?error=${encodeURIComponent(detail.slice(0, 300))}`,
269320 );
270321 return redirect(
271322 `/settings/origins/flows/${ctx.params.id ?? ""}${then === undefined ? "" : `?${thenQuery(then)}`}`,
src/web/pages/origins.ts +97 −10
@@ -19,7 +19,9 @@
1919
2020 import { FORGE_NOT_CARRIED } from "../../origins/words.ts";
2121 import {
22 checkOriginAgain,
2223 connectOrigin,
24 connectRefusal,
2325 deleteOriginApp,
2426 disconnectOrigin,
2527 listOriginApps,
@@ -35,6 +37,8 @@ import {
3537 connectWords,
3638 disconnectedWords,
3739 syncedWords,
40 CHECK_TOO_SOON,
41 CHECK_WORDS,
3842 } from "../../actions/origin_words.ts";
3943 import {
4044 editableHosts,
@@ -46,6 +50,7 @@ import {
4650 } from "../../origins/trusted-hosts.ts";
4751 import { reattachMessage } from "./frozen.ts";
4852 import { replaceTokenMessage } from "../../origins/token-reach.ts";
53 import { writesPaused } from "../../origins/credential.ts";
4954 import {
5055 listDetachedMirrors,
5156 mirrorsByOrigin,
@@ -328,7 +333,25 @@ function reauthNote(origin: Origin, mirrors: number): Html {
328333 </p>`;
329334 }
330335
331 function originCard(origin: Origin, mirrors: number): Html {
336 /**
337 * An origin whose account the operator guard could not verify reads and
338 * syncs but writes nothing (`writesPaused`): said where the origin is, with
339 * the way out, a check now.
340 */
341 function pausedNote(origin: Origin): Html {
342 return html`<div class="toast mt" data-tone="attn" role="status">
343 ${icon("alert")}
344 <div class="toast-body">
345 Couldn't verify this connection; writes paused. Reading and syncing go
346 on.
347 </div>
348 <form method="post" action="/settings/origins/${origin.id}/check">
349 ${btn("Check again", { size: "sm", variant: "outline", icon: "sync" })}
350 </form>
351 </div>`;
352 }
353
354 function originCard(origin: Origin, mirrors: number, paused = false): Html {
332355 const base = `/settings/origins/${origin.id}`;
333356 return card(
334357 html`<ul class="list setting-list">
@@ -364,6 +387,7 @@ function originCard(origin: Origin, mirrors: number): Html {
364387 origin.software === "forge",
365388 () => html`<p class="small mt">Another forge. ${FORGE_NOT_CARRIED}</p>`,
366389 )}
390 ${when(paused, () => pausedNote(origin))}
367391 <div class="bar mt">
368392 <form method="post" action="${base}/sync">
369393 ${btn("Sync now", { size: "sm", icon: "sync" })}
@@ -640,8 +664,9 @@ function tokenSection(values: ConnectValues): Html {
640664 required: true,
641665 mono: true,
642666 autocomplete: "off",
643 hint: "Needs repository and issue access. It is never displayed after this.",
667 hint: "It is never displayed after this.",
644668 })}
669 ${tokenNeeds(values.tokenHost)}
645670 <div class="form-actions">${btn("Connect", { variant: "primary" })}</div>
646671 </form>`;
647672 }
@@ -681,6 +706,41 @@ function trustedHostsCard(text: string, error?: string): Html {
681706 );
682707 }
683708
709 /**
710 * What a Forgejo or Gitea token needs, in the words of the page that makes
711 * one, and — once the instance is known — a link to that page with the
712 * name and the scopes already chosen. Forgejo 15 fills its form from the
713 * query (`/user/settings/applications/tokens/new`); an older one or Gitea
714 * opens the plain form, and the list says what to pick.
715 */
716 function tokenNeeds(host: string): Html {
717 const site =
718 host === ""
719 ? null
720 : URL.parse(host.includes("://") ? host : `https://${host}`);
721 const link =
722 site === null || site.hostname === "github.com" || site.hostname === "github"
723 ? undefined
724 : `${site.origin}/user/settings/applications/tokens/new?name=forge&resource=all&scope=read:user&scope=write:repository&scope=write:issue`;
725 return html`<div class="small muted">
726 <p>For Forgejo or Gitea, the token needs:</p>
727 <ul>
728 <li>User: Read</li>
729 <li>Repository: Read and write</li>
730 <li>Issue: Read and write</li>
731 <li>Everything else: No access</li>
732 </ul>
733 ${link === undefined
734 ? null
735 : html`<p>
736 <a href="${link}" target="_blank" rel="noopener noreferrer"
737 >Make this token at ${site!.host}</a
738 >, with these already chosen. On Gitea or an older Forgejo, open
739 Settings → Applications there instead.
740 </p>`}
741 </div>`;
742 }
743
684744 // ---- the page ----
685745
686746 function originsView(
@@ -715,7 +775,13 @@ function originsView(
715775 note: "Connect GitHub, a Forgejo instance or a Gitea instance and its repositories become mirrorable here.",
716776 })
717777 : html`<div class="stack">
718 ${origins.map((o) => originCard(o, mirrors.get(o.id)?.count ?? 0))}
778 ${origins.map((o) =>
779 originCard(
780 o,
781 mirrors.get(o.id)?.count ?? 0,
782 writesPaused(ctx.app, o.id) !== undefined,
783 ),
784 )}
719785 </div>`}
720786 ${frozenCard(frozen)}
721787 ${mayEditTrustedHosts(user)
@@ -871,7 +937,7 @@ export function originsPage(ctx: Ctx): Response {
871937 <div class="toast-body">
872938 ${error === "state"
873939 ? "That authorization could not be matched to a connect you started here. Start again."
874 : error.slice(0, 200)}
940 : error.slice(0, 300)}
875941 </div>
876942 </div>`
877943 : null;
@@ -901,7 +967,8 @@ export async function originConnectToken(ctx: Ctx): Promise<Response> {
901967 // secret is a smaller cost than a secret sitting in a value attribute.
902968 const values = blankValues();
903969 values.tokenHost = host;
904 values.tokenError = connectMessage(connected.error);
970 const refused = connectRefusal(connected.error);
971 values.tokenError = refused.message ?? connectMessage(refused.code);
905972 return shell(ctx, originsView(ctx, values), 422);
906973 }
907974 const { reattached } = connected.value;
@@ -979,11 +1046,8 @@ export async function originOAuthStart(ctx: Ctx): Promise<Response> {
9791046 : connectMessage(started.error.error);
9801047 return shell(ctx, originsView(ctx, values), 422);
9811048 }
982 const query = new URLSearchParams(
983 started.value.flow === "code" ? { to: started.value.url } : {},
984 );
985 if (then !== undefined) query.set("then", "migrate");
986 const qs = query.size === 0 ? "" : `?${query}`;
1049 // The flow's page knows where to go from the flow itself, not the link.
1050 const qs = then === undefined ? "" : "?then=migrate";
9871051 return redirect(`/settings/origins/flows/${started.value.flowId}${qs}`);
9881052 }
9891053
@@ -1072,6 +1136,29 @@ export function notFoundNote(): Html {
10721136 }
10731137 // ---- one origin ----
10741138
1139 // POST /settings/origins/:id/check — "Check again": the operator guard, now.
1140 export async function originCheckSubmit(ctx: Ctx): Promise<Response> {
1141 const denied = accountOnly(ctx);
1142 if (denied) return denied;
1143 const form = await readForm(ctx, "admin");
1144 if (form instanceof Response) return form;
1145 const to = "/settings/origins";
1146 const checked = await checkOriginAgain(ctx.app, ctx.auth.user, ctx.params.id ?? "");
1147 if (!checked.ok)
1148 return flashTo(ctx, to, {
1149 tone: checked.error === "too_soon" ? "attn" : "danger",
1150 message:
1151 checked.error === "too_soon" ? CHECK_TOO_SOON : "No such connection.",
1152 });
1153 return flashTo(ctx, to, {
1154 tone:
1155 checked.value === "passed" || checked.value === "unguarded"
1156 ? "success"
1157 : "danger",
1158 message: CHECK_WORDS[checked.value],
1159 });
1160 }
1161
10751162 // POST /settings/origins/:id/sync
10761163 export async function originSyncSubmit(ctx: Ctx): Promise<Response> {
10771164 const denied = accountOnly(ctx);
src/web/pages/thread.ts +4
@@ -92,6 +92,10 @@ export function originError(error: string): string | undefined {
9292 origin_owner_only:
9393 "Only the person who connected this origin can write through it. Your comment would otherwise appear upstream under their name.",
9494 origin_refused: "The origin refused that. Nothing was changed here either.",
95 origin_protected:
96 "The operator protects this repository at its origin, so nothing is pushed to it from here. Propose the change from your fork.",
97 origin_paused:
98 "This connection is not verified yet, so writes through it are paused. Nothing was changed. Check it again under Settings → Origins.",
9599 origin_unreachable: "The origin could not be reached. Nothing was changed.",
96100 };
97101 return words[error];
src/web/scope.ts +5 −1
@@ -32,6 +32,7 @@ import {
3232 type Origin,
3333 } from "../core/origins.ts";
3434 import { canWrite, countsForRepos, type Repo } from "../core/repos.ts";
35 import { denied } from "../origins/operator.ts";
3536 import type { Ctx } from "../http/context.ts";
3637 import type {
3738 RepoFacts,
@@ -193,7 +194,10 @@ function mirrorFacts(
193194 const link = originForRepo(ctx.app.db, repo.id);
194195 if (link === undefined) return { upstream, propose: undefined };
195196 const cached = getOriginRepo(ctx.app.db, link.origin.id, link.fullName);
196 if (cached?.canPush !== false) return { upstream, propose: undefined };
197 // A repository the operator protects takes no pushes from here, whatever
198 // the account could do: it, too, is proposed to through a fork.
199 if (cached?.canPush !== false && !denied(link.origin, link.fullName))
200 return { upstream, propose: undefined };
197201 return {
198202 upstream,
199203 propose: {
test/apps-feedback-mirror.test.ts +1 −1
@@ -50,7 +50,7 @@ describe("the note door on a mirrored app", () => {
5050 github = serveFakeForge(newFakeState("github", "alice", secret));
5151 forgetProviders(dataDir);
5252 const connected = await connectOrigin(app, alice, { host: github.url, kind: "github", token: secret });
53 if (!connected.ok) throw new Error(connected.error);
53 if (!connected.ok) throw new Error(JSON.stringify(connected.error));
5454 const created = await newApp(app, alice, { name: "mirrored", description: "Words", visibility: "public" });
5555 if (!created.ok) throw new Error(String(created.error));
5656 const repo = getRepo(app.db, "alice", "mirrored")!;
test/fake-origin.ts +68 −10
@@ -222,6 +222,19 @@ export interface FakeState {
222222 * unset, the fake is a fine-grained token, which names none.
223223 */
224224 scopes?: string;
225 /**
226 * A scope the token lacks for `/user` (`read:user`): answered as Forgejo
227 * answers it, a 403 that names the scope.
228 */
229 userScopeMissing?: string;
230 /** The account administers the instance: `is_admin` / `site_admin` on `/user`. */
231 siteAdmin?: boolean;
232 /** Called before each page of a repository listing is answered: the world moving mid-walk. */
233 onRepoPage?: (page: number) => void;
234 /** What `X-Total-Count` says on a repository listing, when it should lie. */
235 repoTotalOverride?: number;
236 /** Repositories (`owner/name`) whose `GET /repos/{o}/{r}` answers 500. */
237 failRepos?: Set<string>;
225238 }
226239
227240 export interface FakeForge {
@@ -396,6 +409,14 @@ function route(
396409 if (rest === "/settings/api")
397410 return jsonResponse({ max_response_items: state.maxResponseItems });
398411
412 if (rest === "/user" && state.userScopeMissing !== undefined)
413 return jsonResponse(
414 {
415 message: `token does not have at least one of required scope(s): [${state.userScopeMissing}]`,
416 url: `${url.origin}/api/swagger`,
417 },
418 403,
419 );
399420 if (rest === "/user")
400421 return jsonResponse(
401422 {
@@ -404,23 +425,42 @@ function route(
404425 full_name: state.login,
405426 name: state.login,
406427 html_url: `${url.origin}/${state.login}`,
428 ...(state.kind === "github"
429 ? { site_admin: state.siteAdmin === true }
430 : { is_admin: state.siteAdmin === true }),
407431 },
408432 200,
409433 state.scopes === undefined ? {} : { "x-oauth-scopes": state.scopes },
410434 );
411435
412436 if (rest === "/user/repos" || rest === "/repos/search") {
437 // Paged as Forgejo pages: `limit` clamped to the instance's ceiling,
438 // `page` from 1, and `sort` (id, or updated: most recent first) when
439 // asked; with no `limit`, everything at once, as before.
440 const page = Math.max(1, Number(q.get("page") ?? "1") || 1);
441 state.onRepoPage?.(page);
413442 const visibility = q.get("visibility");
414 const rows = state.repos
415 .filter(
416 (r) =>
417 visibility === null ||
418 visibility === "all" ||
419 (visibility === "private") === (r.private ?? false),
420 )
443 const ordered = state.repos.filter(
444 (r) =>
445 visibility === null ||
446 visibility === "all" ||
447 (visibility === "private") === (r.private ?? false),
448 );
449 if (q.get("sort") === "id") ordered.sort((a, b) => a.id - b.id);
450 if (q.get("sort") === "updated")
451 ordered.sort((a, b) => (b.pushedAt ?? "").localeCompare(a.pushedAt ?? ""));
452 const asked = Number(q.get("limit") ?? "");
453 const limit =
454 Number.isFinite(asked) && asked > 0
455 ? Math.min(asked, state.maxResponseItems)
456 : ordered.length;
457 const rows = ordered
458 .slice((page - 1) * limit, page * limit)
421459 .map((r) => repoJson(state, r));
422460 const payload = rest === "/repos/search" ? { ok: true, data: rows } : rows;
423 return jsonResponse(payload, 200, { "x-total-count": String(rows.length) });
461 return jsonResponse(payload, 200, {
462 "x-total-count": String(state.repoTotalOverride ?? state.repos.length),
463 });
424464 }
425465
426466 // Cross-repo issue search. Both forges hang it off a path that *looks*
@@ -461,15 +501,30 @@ function route(
461501 if (parse(bodyText).archived === true) repo.archived = true;
462502 return jsonResponse(repoJson(state, repo));
463503 }
464 if (tail === "") return jsonResponse(repoJson(state, repo));
504 if (tail === "") {
505 if (state.failRepos?.has(fullName))
506 return jsonResponse({ message: "internal error" }, 500);
507 return jsonResponse(repoJson(state, repo));
508 }
465509
466510 // ---- forking ----
511 if (tail === "/forks" && method === "GET") {
512 const forks = state.repos.filter((r) => r.parent === fullName);
513 return jsonResponse(forks.map((r) => repoJson(state, r)), 200, {
514 "x-total-count": String(forks.length),
515 });
516 }
467517 if (tail === "/forks" && method === "POST") {
468518 if (state.refuseFork !== "")
469519 return jsonResponse({ message: state.refuseFork }, 409);
470 const name = fullName.split("/")[1]!;
520 // `name` is the fork's own name when given (both forges take it).
521 const asked = parse(bodyText).name;
522 const name = typeof asked === "string" && asked !== "" ? asked : fullName.split("/")[1]!;
471523 const forkName = `${state.login}/${name}`;
472524 const already = state.repos.find((r) => r.fullName === forkName);
525 // A name the account already uses for something else: Forgejo's 409.
526 if (already !== undefined && already.parent !== fullName)
527 return jsonResponse({ message: "repository is already exist" }, 409);
473528 if (already === undefined) {
474529 state.repos.push({
475530 id: state.nextId++,
@@ -1169,6 +1224,8 @@ export interface FakeOAuth {
11691224 expiresIn: number;
11701225 /** Hand back a new refresh token on every refresh, as rotation does. */
11711226 rotate: boolean;
1227 /** Called while a refresh is being answered: something racing it. */
1228 onRefresh?: () => void;
11721229 /** Answer the next refresh with this OAuth error code. */
11731230 refreshError: string;
11741231 /** Every token-endpoint request body, parsed. */
@@ -1247,6 +1304,7 @@ export function oauthRoute(
12471304 });
12481305 if (form.refresh_token !== oauth.refreshToken)
12491306 return jsonResponse({ error: "unauthorized_client" });
1307 oauth.onRefresh?.();
12501308 oauth.accessToken = `gho_refreshed${oauth.tokenRequests.length}00000000000000000`;
12511309 if (oauth.rotate)
12521310 oauth.refreshToken = `ghr_rotated${oauth.tokenRequests.length}0000000000000000000`;
test/helpers/chrome.ts +134
@@ -46,11 +46,13 @@ class Cdp {
4646 private nextId = 1;
4747 private readonly pending = new Map<number, (msg: CdpMessage) => void>();
4848 private readonly waiters = new Map<string, () => void>();
49 private readonly listeners = new Map<string, ((params: any) => void)[]>();
4950
5051 constructor(private readonly ws: WebSocket) {
5152 ws.addEventListener("message", (event) => {
5253 const msg = JSON.parse(event.data as string) as CdpMessage & {
5354 method?: string;
55 params?: unknown;
5456 };
5557 if (msg.id !== undefined) {
5658 this.pending.get(msg.id)?.(msg);
@@ -58,10 +60,17 @@ class Cdp {
5860 } else if (msg.method !== undefined) {
5961 this.waiters.get(msg.method)?.();
6062 this.waiters.delete(msg.method);
63 for (const listen of this.listeners.get(msg.method) ?? [])
64 listen(msg.params);
6165 }
6266 });
6367 }
6468
69 /** Every time `method` fires as an event, with its params. */
70 on(method: string, listen: (params: any) => void): void {
71 this.listeners.set(method, [...(this.listeners.get(method) ?? []), listen]);
72 }
73
6574 send(method: string, params: object = {}): Promise<any> {
6675 const id = this.nextId++;
6776 return within(5_000, method, new Promise((resolve, reject) => {
@@ -316,3 +325,128 @@ export function chromiumRuns(binary: string): boolean {
316325 rmSync(profile, { recursive: true, force: true });
317326 }
318327 }
328
329 /** What a browser did after a click: where it went, and what it refused. */
330 export interface Followed {
331 /** Every URL the page asked for, in order (navigations and redirects). */
332 readonly requested: string[];
333 /** The page's URL when following stopped. */
334 readonly finalUrl: string;
335 /**
336 * Console errors, log entries and `securitypolicyviolation` events: a
337 * Content-Security-Policy refusal lands here, however the browser says it.
338 */
339 readonly errors: string[];
340 }
341
342 /**
343 * Load `url` with a session cookie, then run each of `steps` (an
344 * expression evaluated in the page — a click) after the page before it has
345 * loaded, and follow the browser until a requested URL satisfies `until` or
346 * `timeoutMs` passes. What a real browser enforces and an HTTP client does
347 * not — a page's CSP on the redirect that follows its form — is exactly
348 * what this sees.
349 */
350 export async function clickAndFollow(
351 binary: string,
352 input: {
353 url: string;
354 cookie: { name: string; value: string };
355 steps: readonly string[];
356 until: (url: string) => boolean;
357 /** Done for another reason the page cannot see (a server got the request). */
358 done?: () => boolean;
359 timeoutMs?: number;
360 },
361 ): Promise<Followed> {
362 const profile = mkdtempSync(join(tmpdir(), "forge-click-"));
363 const proc = Bun.spawn(
364 [
365 binary,
366 "--headless=new",
367 "--no-sandbox",
368 "--disable-gpu",
369 "--disable-dev-shm-usage",
370 "--disable-background-networking",
371 "--disable-crash-reporter",
372 "--no-first-run",
373 `--user-data-dir=${profile}`,
374 "--remote-debugging-port=0",
375 ],
376 { env: childEnv({}), stdin: "ignore", stdout: "ignore", stderr: "pipe" },
377 );
378 let ws: WebSocket | undefined;
379 const requested: string[] = [];
380 const errors: string[] = [];
381 try {
382 const browserWsUrl = await Promise.race([
383 readDebuggerUrl(proc.stderr),
384 Bun.sleep(10_000).then((): string => {
385 throw new Error("chromium never printed its devtools address");
386 }),
387 ]);
388 const target = await findPageTarget(browserWsUrl);
389 ws = new WebSocket(target.webSocketDebuggerUrl);
390 await new Promise<void>((resolve, reject) => {
391 ws!.addEventListener("open", () => resolve());
392 ws!.addEventListener("error", () =>
393 reject(new Error("chromium's devtools socket refused the connection")),
394 );
395 });
396 const cdp = new Cdp(ws);
397 cdp.on("Network.requestWillBeSent", (p) => requested.push(String(p.request?.url ?? "")));
398 cdp.on("Log.entryAdded", (p) => {
399 if (p.entry?.level === "error" || p.entry?.source === "security")
400 errors.push(String(p.entry?.text ?? ""));
401 });
402 cdp.on("Runtime.consoleAPICalled", (p) => {
403 if (p.type === "error")
404 errors.push((p.args ?? []).map((a: any) => String(a.value ?? a.description ?? "")).join(" "));
405 });
406 cdp.on("Audits.issueAdded", (p) => {
407 const csp = p.issue?.details?.contentSecurityPolicyIssueDetails;
408 if (csp !== undefined)
409 errors.push(`CSP issue: ${csp.violatedDirective} ${csp.blockedURL ?? ""}`);
410 });
411 await cdp.send("Page.enable");
412 await cdp.send("Network.enable");
413 await cdp.send("Log.enable");
414 await cdp.send("Runtime.enable");
415 await cdp.send("Audits.enable");
416 // Every document reports its own CSP refusals, before anything else runs.
417 await cdp.send("Page.addScriptToEvaluateOnNewDocument", {
418 source: `document.addEventListener("securitypolicyviolation", (e) =>
419 console.error("CSP violation: " + e.violatedDirective + " " + e.blockedURI));`,
420 });
421 await cdp.send("Network.setCookie", {
422 name: input.cookie.name,
423 value: input.cookie.value,
424 url: input.url,
425 });
426 const deadline = Date.now() + (input.timeoutMs ?? 15_000);
427 const reached = () =>
428 requested.some(input.until) || (input.done?.() ?? false);
429 let loaded = cdp.once("Page.loadEventFired");
430 await cdp.send("Page.navigate", { url: input.url });
431 await Promise.race([loaded, Bun.sleep(Math.max(0, deadline - Date.now()))]);
432 for (const step of input.steps) {
433 if (reached()) break;
434 loaded = cdp.once("Page.loadEventFired");
435 // As a person's click: a user gesture, so a link that opens a tab does.
436 await cdp.send("Runtime.evaluate", { expression: step, userGesture: true });
437 await Promise.race([loaded, Bun.sleep(Math.max(0, deadline - Date.now()))]);
438 }
439 while (!reached() && Date.now() < deadline) await Bun.sleep(100);
440 // Late reports of what the last navigation refused.
441 await Bun.sleep(300);
442 const where = await cdp
443 .send("Runtime.evaluate", { expression: "location.href", returnByValue: true })
444 .catch(() => ({ result: { value: "" } }));
445 return { requested, finalUrl: String(where.result?.value ?? ""), errors };
446 } finally {
447 ws?.close();
448 proc.kill("SIGKILL");
449 await proc.exited.catch(() => {});
450 rmSync(profile, { recursive: true, force: true });
451 }
452 }
test/migrate-after.test.ts +1 −1
@@ -116,7 +116,7 @@ describe("migrate: after", () => {
116116 kind: "github",
117117 token: secret,
118118 });
119 if (!connected.ok) throw new Error(connected.error);
119 if (!connected.ok) throw new Error(JSON.stringify(connected.error));
120120 originId = connected.value.id;
121121 stopMirror = startMirrorOut(app, async (a, repo) => {
122122 try {
test/migrate-token-reach.test.ts +3 −3
@@ -13,7 +13,7 @@ import { mkdtemp, rm } from "node:fs/promises";
1313 import { tmpdir } from "node:os";
1414 import { join } from "node:path";
1515 import { createApp, type App } from "../src/app.ts";
16 import { connectOrigin, listRemoteRepos } from "../src/actions/origins.ts";
16 import { connectOrigin, connectRefusal, listRemoteRepos } from "../src/actions/origins.ts";
1717 import { listOrigins } from "../src/core/origins.ts";
1818 import { createSession } from "../src/core/sessions.ts";
1919 import { createUser, type User } from "../src/core/users.ts";
@@ -122,7 +122,7 @@ test("a classic token without `repo` says public only and links the new-token pa
122122 kind: "github",
123123 token: "public-only-token",
124124 });
125 if (!connected.ok) throw new Error(connected.error);
125 if (!connected.ok) throw new Error(connectRefusal(connected.error).code);
126126 const originId = connected.value.id;
127127
128128 const page = await get("/migrate");
@@ -293,7 +293,7 @@ test("the terminal's migrate screen says public only, names the new-token page,
293293 kind: "github",
294294 token: "carol-public",
295295 });
296 if (!connected.ok) throw new Error(connected.error);
296 if (!connected.ok) throw new Error(connectRefusal(connected.error).code);
297297 tui.start();
298298 await settleApp(tui);
299299 terminal.feed("M");
test/migrate.test.ts +3 −3
@@ -744,7 +744,7 @@ describe("migrate", () => {
744744 kind: "github",
745745 token: upstreamSecret,
746746 });
747 if (!connected.ok) throw new Error(connected.error);
747 if (!connected.ok) throw new Error(JSON.stringify(connected.error));
748748 const agent = await createAgent(app, alice, {
749749 username: "mover",
750750 model: "",
@@ -893,7 +893,7 @@ describe("migrate", () => {
893893 kind: "github",
894894 token: upstreamSecret,
895895 });
896 if (!connected.ok) throw new Error(connected.error);
896 if (!connected.ok) throw new Error(JSON.stringify(connected.error));
897897 const job = await migrate(alice, {
898898 source: { originId: connected.value.id, fullName: "octo/forked-pulls" },
899899 });
@@ -1004,7 +1004,7 @@ describe("migrate", () => {
10041004 try {
10051005 forgetProviders(dataDir);
10061006 const connected = await connectOrigin(app, alice, { host: github.url, kind: "github", token: upstreamSecret });
1007 if (!connected.ok) throw new Error(connected.error);
1007 if (!connected.ok) throw new Error(JSON.stringify(connected.error));
10081008 const value = `${connected.value.id}/octo/arm`;
10091009
10101010 // The row links to its branches; the list shows each last commit,
test/oauth-connect-browser.test.ts added +97
@@ -0,0 +1,97 @@
1 /**
2 * Connecting an origin, clicked in a real browser. An HTTP client follows a
3 * form's redirect wherever it points; a browser first checks the posting
4 * page's `form-action` policy against it, and refuses a redirect off this
5 * forge without a word on the page. So this drives headless Chromium
6 * (`test/helpers/chrome.ts`): click Authorize on Settings → Origins, then
7 * Continue on the flow's page, and see the browser actually ask the origin
8 * for its consent screen — the URL stored on the flow — with no
9 * Content-Security-Policy refusal on the way.
10 */
11
12 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
13 import { mkdtemp, rm } from "node:fs/promises";
14 import { tmpdir } from "node:os";
15 import { join } from "node:path";
16 import { createApp, type App } from "../src/app.ts";
17 import { registerOriginApp } from "../src/actions/origins.ts";
18 import { createSession } from "../src/core/sessions.ts";
19 import { createUser } from "../src/core/users.ts";
20 import { serve, type Servers } from "../src/http/server.ts";
21 import { findChromium } from "../src/looks/capture.ts";
22 import { clickAndFollow } from "./helpers/chrome.ts";
23 import { quietLogs, withoutEnv } from "./helpers/globals.ts";
24 import {
25 newFakeOAuth,
26 newFakeState,
27 serveFakeForge,
28 type FakeForge,
29 } from "./fake-origin.ts";
30
31 quietLogs();
32 withoutEnv("FORGE_URL");
33
34 const CLIENT_ID = "forge-browser-client";
35 const chromium = findChromium();
36 if (chromium === undefined)
37 console.log(
38 "SKIPPING: no chromium on this machine (FORGE_CHROMIUM), so the connect buttons cannot be clicked",
39 );
40
41 describe.skipIf(chromium === undefined)("connecting an origin in a real browser", () => {
42 let dir: string;
43 let app: App;
44 let servers: Servers;
45 let fake: FakeForge;
46 const consentAsked = (since: number) =>
47 fake.state.requests
48 .slice(since)
49 .some((r) => r.method === "GET" && r.path === "/login/oauth/authorize");
50 const refusals = (errors: readonly string[]) =>
51 errors.filter((e) => /content security policy|csp/i.test(e));
52
53 beforeAll(async () => {
54 const state = newFakeState("forgejo", "octo", "octo-browser-token");
55 state.oauth = newFakeOAuth(CLIENT_ID);
56 fake = serveFakeForge(state);
57 dir = await mkdtemp(join(tmpdir(), "forge-connect-browser-"));
58 app = createApp({ dataDir: dir, port: 0 });
59 servers = await serve(app);
60 }, 20_000);
61
62 afterAll(async () => {
63 fake.stop();
64 servers.stop();
65 app.close();
66 await rm(dir, { recursive: true, force: true });
67 });
68
69 test("Settings → Origins: Authorize, then Continue, reaches the consent screen", async () => {
70 const made = await createUser(app.db, {
71 username: "bea",
72 email: "[email protected]",
73 password: "a-long-browser-passphrase",
74 origin: "seed",
75 trusted: true,
76 });
77 if (!made.ok) throw new Error(made.error);
78 const registered = await registerOriginApp(app, made.value, {
79 host: fake.url,
80 clientId: CLIENT_ID,
81 });
82 expect(registered.ok).toBe(true);
83 const before = fake.state.requests.length;
84 const followed = await clickAndFollow(chromium!, {
85 url: `${servers.url}/settings/origins?host=${encodeURIComponent(fake.url)}`,
86 cookie: { name: "forge_session", value: createSession(app.db, made.value.id).token },
87 steps: [
88 `[...document.querySelectorAll('button')].find((b) => b.textContent.trim() === 'Authorize').click()`,
89 `[...document.querySelectorAll('a')].find((a) => a.textContent.includes('Continue to the instance')).click()`,
90 ],
91 until: (url) => url.startsWith(`${fake.url}/login/oauth/authorize`),
92 done: () => consentAsked(before),
93 });
94 expect(refusals(followed.errors)).toEqual([]);
95 expect(consentAsked(before)).toBe(true);
96 }, 30_000);
97 });
test/origin-allow-mirror.test.ts added +166
@@ -0,0 +1,166 @@
1 /**
2 * A host the operator names in `FORGE_ORIGIN_ALLOW` is one an origin may be
3 * connected from, so it is one that origin may be mirrored from too. Before,
4 * discovery let such a host through and the mirror's git refused it as a
5 * private address: the origin connected, and every mirror, sync and fetch
6 * from it then failed. The mirror now trusts that name the way it trusts
7 * `FORGE_OUTBOUND_TRUSTED_HOSTS`: by name, pinned to the answer that passed,
8 * never by address.
9 */
10
11 import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test";
12 import { mkdtemp, rm } from "node:fs/promises";
13 import { networkInterfaces, tmpdir } from "node:os";
14 import { join, normalize } from "node:path";
15 import {
16 fetchMirror,
17 MIRROR_REFUSED,
18 provideMirrorOutboundForTest,
19 vettedEnv,
20 } from "../src/origins/git-mirror.ts";
21 import { loadTrustedHosts } from "../src/origins/trusted-hosts.ts";
22 import { gitClientEnv } from "./helpers/git-client.ts";
23
24 const inCluster = async () => ["10.43.191.15"];
25
26 /** Run `fn` with `FORGE_ORIGIN_ALLOW` set (or unset), and put it back. */
27 async function withAllow<T>(value: string | undefined, fn: () => Promise<T>): Promise<T> {
28 const before = process.env.FORGE_ORIGIN_ALLOW;
29 if (value === undefined) delete process.env.FORGE_ORIGIN_ALLOW;
30 else process.env.FORGE_ORIGIN_ALLOW = value;
31 try {
32 return await fn();
33 } finally {
34 if (before === undefined) delete process.env.FORGE_ORIGIN_ALLOW;
35 else process.env.FORGE_ORIGIN_ALLOW = before;
36 }
37 }
38
39 /** The address git is pinned to, from the environment the mirror hands it. */
40 function pinOf(env: Record<string, string>): string | undefined {
41 for (let i = 0; i < Number(env["GIT_CONFIG_COUNT"] ?? "0"); i++)
42 if (env[`GIT_CONFIG_KEY_${i}`] === "http.curloptResolve")
43 return env[`GIT_CONFIG_VALUE_${i}`];
44 return undefined;
45 }
46
47 afterEach(() => {
48 loadTrustedHosts(undefined, {});
49 provideMirrorOutboundForTest({ allow: ["127.0.0.1", "::1"] });
50 });
51
52 test("a name the operator allows as an origin passes the mirror's vet, pinned to its answer", async () => {
53 provideMirrorOutboundForTest({ resolve: inCluster });
54 const url = "https://git.lan/acme/app.git";
55 const refused = await withAllow(undefined, () => vettedEnv(url, {}));
56 expect(!refused.ok && refused.error.message).toBe(MIRROR_REFUSED);
57
58 const passed = await withAllow("git.lan", () => vettedEnv(url, {}));
59 if (!passed.ok) throw new Error(passed.error.message);
60 expect(pinOf(passed.value)).toContain("10.43.191.15");
61 });
62
63 test("the allowance is the name's: another name at the same address, or another port, is refused", async () => {
64 provideMirrorOutboundForTest({ resolve: inCluster });
65 await withAllow("git.lan:8443", async () => {
66 const port = await vettedEnv("https://git.lan:8443/acme/app.git", {});
67 expect(port.ok).toBe(true);
68 for (const url of [
69 "https://git.lan/acme/app.git",
70 "https://git.lan:9443/acme/app.git",
71 "https://other.lan:8443/acme/app.git",
72 ]) {
73 const refused = await vettedEnv(url, {});
74 expect(!refused.ok && refused.error.message).toBe(MIRROR_REFUSED);
75 }
76 });
77 });
78
79 test("an address the operator names is not trusted for git: trust is by name", async () => {
80 await withAllow("10.43.191.15", async () => {
81 const refused = await vettedEnv("https://10.43.191.15/acme/app.git", {});
82 expect(!refused.ok && refused.error.message).toBe(MIRROR_REFUSED);
83 });
84 });
85
86 /** This machine's first private, non-loopback IPv4 address, if it has one. */
87 function privateAddress(): string | undefined {
88 for (const list of Object.values(networkInterfaces()))
89 for (const a of list ?? [])
90 if (
91 a.family === "IPv4" &&
92 !a.internal &&
93 /^(10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.)/.test(a.address)
94 )
95 return a.address;
96 return undefined;
97 }
98
99 const lan = privateAddress();
100 if (lan === undefined)
101 console.log("SKIPPING the end-to-end half: this machine has no private, non-loopback address");
102
103 describe.skipIf(lan === undefined)("an allowed origin inside the network, end to end", () => {
104 let dir: string;
105 let git: ReturnType<typeof Bun.serve>;
106 let served = 0;
107
108 const run = (args: string[], cwd: string): string => {
109 const done = Bun.spawnSync(["git", ...args], { cwd, env: gitClientEnv() });
110 if (done.exitCode !== 0) throw new Error(`git ${args.join(" ")}: ${done.stderr.toString()}`);
111 return done.stdout.toString().trim();
112 };
113
114 beforeAll(async () => {
115 dir = await mkdtemp(join(tmpdir(), "forge-allow-mirror-"));
116 // A repository served as any static web server serves one (git's "dumb"
117 // HTTP), on this machine's private address only.
118 run(["init", "-q", "-b", "main", "work"], dir);
119 await Bun.write(join(dir, "work", "README.md"), "# app\n");
120 run(["add", "README.md"], join(dir, "work"));
121 run(["commit", "-q", "-m", "first"], join(dir, "work"));
122 run(["clone", "-q", "--bare", "work", "app.git"], dir);
123 run(["update-server-info"], join(dir, "app.git"));
124 run(["init", "-q", "--bare", "mirror.git"], dir);
125 const root = join(dir, "app.git");
126 git = Bun.serve({
127 hostname: lan!,
128 port: 0,
129 fetch(request) {
130 served++;
131 const path = decodeURIComponent(new URL(request.url).pathname);
132 if (!path.startsWith("/acme/app.git/")) return new Response("", { status: 404 });
133 const file = normalize(join(root, path.slice("/acme/app.git/".length)));
134 if (!file.startsWith(root)) return new Response("", { status: 404 });
135 return new Response(Bun.file(file));
136 },
137 error: () => new Response("", { status: 404 }),
138 });
139 });
140
141 afterAll(async () => {
142 git.stop(true);
143 await rm(dir, { recursive: true, force: true });
144 });
145
146 test("not allowed, the name that resolves to it is refused before anything is fetched", async () => {
147 provideMirrorOutboundForTest({ resolve: async () => [lan!] });
148 const before = served;
149 const refused = await withAllow(undefined, () =>
150 fetchMirror(join(dir, "mirror.git"), `http://git.lan:${git.port}/acme/app.git`, {}),
151 );
152 expect(!refused.ok && refused.error.message).toBe(MIRROR_REFUSED);
153 expect(served).toBe(before);
154 });
155
156 test("allowed, the same name is mirrored over git", async () => {
157 provideMirrorOutboundForTest({ resolve: async () => [lan!] });
158 const fetched = await withAllow(`git.lan:${git.port}`, () =>
159 fetchMirror(join(dir, "mirror.git"), `http://git.lan:${git.port}/acme/app.git`, {}),
160 );
161 if (!fetched.ok) throw new Error(fetched.error.message);
162 expect(run(["rev-parse", "refs/heads/main"], join(dir, "mirror.git"))).toBe(
163 run(["rev-parse", "HEAD"], join(dir, "work")),
164 );
165 });
166 });
test/origin-flow-page.test.ts added +165
@@ -0,0 +1,165 @@
1 /**
2 * A connect in flight goes on only to the authorize URL sealed on its flow.
3 *
4 * The flow page used to take that URL from its own query string (`?to=`),
5 * checked only for the instance's origin and a PKCE challenge: a crafted
6 * link could pick another application's client id or another callback on
7 * the same instance. Now the page reads the URL the flow was started with,
8 * checks it again (path, client id, callback, challenge), and refuses a `to`
9 * that is not exactly that URL. A flow past its time says so, with a fresh
10 * Connect, instead of sending anyone to a consent screen for a dead state.
11 */
12
13 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
14 import { mkdtemp, rm } from "node:fs/promises";
15 import { tmpdir } from "node:os";
16 import { join } from "node:path";
17 import { createApp, type App } from "../src/app.ts";
18 import { registerOriginApp } from "../src/actions/origins.ts";
19 import { createSession } from "../src/core/sessions.ts";
20 import { createUser, type User } from "../src/core/users.ts";
21 import { serve, type Servers } from "../src/http/server.ts";
22 import { flowTarget } from "../src/origins/oauth.ts";
23 import { quietLogs, withoutEnv } from "./helpers/globals.ts";
24 import {
25 newFakeOAuth,
26 newFakeState,
27 serveFakeForge,
28 type FakeForge,
29 } from "./fake-origin.ts";
30
31 quietLogs();
32 withoutEnv("FORGE_URL");
33
34 const CLIENT_ID = "forge-flow-page-client";
35
36 describe("the connect flow page", () => {
37 let dir: string;
38 let app: App;
39 let servers: Servers;
40 let fake: FakeForge;
41 let user: User;
42 let cookie = "";
43
44 const browse = (path: string, form?: Record<string, string>) =>
45 fetch(`${servers.url}${path}`, {
46 method: form === undefined ? "GET" : "POST",
47 headers: {
48 cookie,
49 ...(form === undefined ? {} : { origin: new URL(servers.url).origin }),
50 },
51 redirect: "manual",
52 ...(form === undefined ? {} : { body: new URLSearchParams(form) }),
53 });
54
55 /** Start a code flow from Settings → Origins: its page, and its id. */
56 const start = async (): Promise<{ page: string; id: string }> => {
57 const started = await browse("/settings/origins/oauth", { host: fake.url });
58 expect(started.status).toBe(303);
59 const page = started.headers.get("location")!;
60 expect(page).toStartWith("/settings/origins/flows/");
61 return { page, id: page.split("/").at(-1)!.split("?")[0]! };
62 };
63
64 const continueTo = (html: string): string | undefined =>
65 /href="([^"]*\/login\/oauth\/authorize[^"]*)"/.exec(html)?.[1]?.replaceAll("&amp;", "&");
66
67 beforeAll(async () => {
68 const state = newFakeState("forgejo", "octo", "octo-flow-page-token");
69 state.oauth = newFakeOAuth(CLIENT_ID);
70 fake = serveFakeForge(state);
71 dir = await mkdtemp(join(tmpdir(), "forge-flow-page-"));
72 app = createApp({ dataDir: dir, port: 0 });
73 servers = await serve(app);
74 const made = await createUser(app.db, {
75 username: "ada",
76 email: "[email protected]",
77 password: "a-long-flow-page-passphrase",
78 origin: "seed",
79 trusted: true,
80 });
81 if (!made.ok) throw new Error(made.error);
82 user = made.value;
83 cookie = `forge_session=${createSession(app.db, user.id).token}`;
84 const registered = await registerOriginApp(app, user, {
85 host: fake.url,
86 clientId: CLIENT_ID,
87 });
88 if (!registered.ok) throw new Error(registered.error);
89 });
90
91 afterAll(async () => {
92 fake.stop();
93 servers.stop();
94 app.close();
95 await rm(dir, { recursive: true, force: true });
96 });
97
98 test("the redirect carries no URL; the page links to the one stored on the flow", async () => {
99 const { page, id } = await start();
100 expect(page).not.toContain("to=");
101 const stored = flowTarget(app.db, dir, id)!;
102 const url = new URL(stored);
103 expect(url.pathname).toBe("/login/oauth/authorize");
104 expect(url.searchParams.get("client_id")).toBe(CLIENT_ID);
105 expect(continueTo(await (await browse(page)).text())).toBe(stored);
106 });
107
108 test("a crafted `to` for another application on the same instance is refused", async () => {
109 const { page, id } = await start();
110 const crafted = new URL(flowTarget(app.db, dir, id)!);
111 crafted.searchParams.set("client_id", "someone-elses-client");
112 const html = await (
113 await browse(`${page}?to=${encodeURIComponent(crafted.toString())}`)
114 ).text();
115 expect(continueTo(html)).toBeUndefined();
116 expect(html).not.toContain("someone-elses-client");
117 expect(html).toContain("cannot be continued from here");
118 });
119
120 test("a crafted `to` with another callback is refused too; the exact stored URL still goes on", async () => {
121 const { page, id } = await start();
122 const stored = flowTarget(app.db, dir, id)!;
123 const elsewhere = new URL(stored);
124 elsewhere.searchParams.set("redirect_uri", "https://evil.example/callback");
125 const refused = await (
126 await browse(`${page}?to=${encodeURIComponent(elsewhere.toString())}`)
127 ).text();
128 expect(continueTo(refused)).toBeUndefined();
129 const exact = await (await browse(`${page}?to=${encodeURIComponent(stored)}`)).text();
130 expect(continueTo(exact)).toBe(stored);
131 });
132
133 test("an expired flow is refused, with a fresh Connect in its place", async () => {
134 const { page, id } = await start();
135 app.db
136 .query("UPDATE oauth_flow SET expires_at = ?1 WHERE id = ?2")
137 .run(new Date(Date.now() - 60_000).toISOString(), id);
138 const html = await (await browse(page)).text();
139 expect(html).toContain("This connect link expired");
140 expect(continueTo(html)).toBeUndefined();
141 expect(html).toContain('action="/settings/origins/oauth"');
142 expect(html).toContain(`value="${fake.url}"`);
143 });
144
145 test("a connect started from migrate carries only `then`, and its expired page starts again from there", async () => {
146 const started = await browse("/settings/origins/oauth", {
147 host: fake.url,
148 then: "migrate",
149 });
150 expect(started.status).toBe(303);
151 const page = started.headers.get("location")!;
152 expect(page).toEndWith("?then=migrate");
153 expect(page).not.toContain("to=");
154 const id = page.split("/").at(-1)!.split("?")[0]!;
155 expect(continueTo(await (await browse(page)).text())).toBe(
156 flowTarget(app.db, dir, id)!,
157 );
158 app.db
159 .query("UPDATE oauth_flow SET expires_at = ?1 WHERE id = ?2")
160 .run(new Date(Date.now() - 60_000).toISOString(), id);
161 const html = await (await browse(page)).text();
162 expect(html).toContain("This connect link expired");
163 expect(html).toContain('name="then" value="migrate"');
164 });
165 });
test/origin-guard.test.ts added +796
@@ -0,0 +1,796 @@
1 /**
2 * The operator guard on the account behind an origin (`origins/operator.ts`,
3 * `origins/credential.ts`).
4 *
5 * A forge keeps an origin's credential where its agents run, so the risk is
6 * the account a person authorized. On a host the operator guards, an
7 * account that administers the instance is refused, and so is one that can
8 * push to a repository `FORGE_ORIGIN_DENY_PUSH` protects: on a pasted token,
9 * on an OAuth consent, after each refresh, and on the daily sweep of what is
10 * already stored. A check that cannot finish pauses writes (reads go on)
11 * until it can, and refuses the origin after a day.
12 *
13 * The adversarial cases carry the weight: a walk that moves, a count that
14 * lies, a lookup that fails beside a hit, a refresh that races a refusal.
15 */
16
17 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
18 import { mkdtemp, rm } from "node:fs/promises";
19 import { tmpdir } from "node:os";
20 import { join } from "node:path";
21 import { createApp, type App } from "../src/app.ts";
22 import { serve, type Servers } from "../src/http/server.ts";
23 import {
24 checkOriginAgain,
25 connectOrigin,
26 replaceOriginToken,
27 startMirrorJob,
28 startOAuthConnect,
29 syncOriginNow,
30 } from "../src/actions/origins.ts";
31 import { contribute, forkOrigin } from "../src/actions/contribute.ts";
32 import { openIssueThrough } from "../src/actions/issues.ts";
33 import { originPush } from "../src/actions/branches.ts";
34 import { carryBranchThrough, pushThrough } from "../src/origins/push-through.ts";
35 import {
36 getCursor,
37 listOriginRepos,
38 listOrigins,
39 markNeedsReauth,
40 setCursor,
41 storeRefreshed,
42 } from "../src/core/origins.ts";
43 import { createUser, getUserByName, type User } from "../src/core/users.ts";
44 import { createSession } from "../src/core/sessions.ts";
45 import { settled } from "../src/origins/jobs.ts";
46 import {
47 denyPushRules,
48 guarded,
49 GUARD_MAX_PAGES,
50 operatorHosts,
51 } from "../src/origins/operator.ts";
52 import {
53 providerForOrigin,
54 recheckOrigin,
55 runGuardSweep,
56 writesPaused,
57 } from "../src/origins/credential.ts";
58 import { forgetProviders } from "../src/origins/providers/index.ts";
59 import { promote } from "./helpers/members.ts";
60 import { seedAdmin } from "./helpers/admin.ts";
61 import { fastFirstFetch, quietLogs, withoutEnv } from "./helpers/globals.ts";
62 import {
63 newFakeOAuth,
64 newFakeState,
65 serveFakeForge,
66 type FakeForge,
67 type FakeState,
68 } from "./fake-origin.ts";
69
70 quietLogs();
71 fastFirstFetch();
72
73 const ENV = ["FORGE_ORIGIN_CLIENT_ID", "FORGE_ORIGIN_DENY_PUSH", "FORGE_URL"] as const;
74 withoutEnv(...ENV);
75
76 const LOGIN = "octo";
77 const TOKEN = "octo-guard-secret-7c1e";
78 const PASSWORD = "a-long-guard-passphrase";
79 const CLIENT_ID = "forge-guard-client";
80
81 const basic = (user: string, secret: string): string =>
82 `Basic ${Buffer.from(`${user}:${secret}`).toString("base64")}`;
83
84 /** Run `fn` with these variables set, and put back what was there. */
85 async function withEnv<T>(
86 vars: Partial<Record<(typeof ENV)[number], string>>,
87 fn: () => Promise<T>,
88 ): Promise<T> {
89 const before = new Map<string, string | undefined>();
90 for (const [name, value] of Object.entries(vars)) {
91 before.set(name, process.env[name]);
92 process.env[name] = value;
93 }
94 try {
95 return await fn();
96 } finally {
97 for (const [name, value] of before) {
98 if (value === undefined) delete process.env[name];
99 else process.env[name] = value;
100 }
101 }
102 }
103
104 // ---- the parsing, with no forge at all ----
105
106 describe("the operator's variables", () => {
107 test("operator hosts are FORGE_ORIGIN_CLIENT_ID's, by hostname", () => {
108 expect(
109 operatorHosts({
110 FORGE_ORIGIN_CLIENT_ID: " Git.Example=abc , other.example=d,broken,=x,empty=",
111 }),
112 ).toEqual(["git.example", "other.example"]);
113 expect(operatorHosts({})).toEqual([]);
114 });
115
116 test("deny-push rules: bare for every host, host: for one, owner alone is owner/*", () => {
117 const rules = denyPushRules({
118 FORGE_ORIGIN_DENY_PUSH:
119 "system/*, git.example:platform/*, Infra, a/b/c, bad name/x, acme/app-*,git.example:8443:ops/deploy",
120 });
121 expect(rules.map((r) => [r.host, r.pattern, r.exact])).toEqual([
122 [null, "system/*", false],
123 ["git.example", "platform/*", false],
124 [null, "infra/*", false],
125 [null, "acme/app-*", false],
126 ["git.example", "ops/deploy", true],
127 ]);
128 const [system, , infra, apps] = rules;
129 expect(system!.matches("System/Open-Platform")).toBe(true);
130 expect(system!.matches("systems/x")).toBe(false);
131 expect(infra!.matches("infra/anything")).toBe(true);
132 expect(apps!.matches("acme/app-jobs")).toBe(true);
133 expect(apps!.matches("acme/jobs")).toBe(false);
134 });
135
136 test("a host is guarded when the operator registered it or a rule covers it", () => {
137 const at = { apiUrl: "https://git.example/api/v1", webUrl: "https://git.example" };
138 expect(guarded(at, {})).toBe(false);
139 expect(guarded(at, { FORGE_ORIGIN_CLIENT_ID: "git.example=id" })).toBe(true);
140 expect(guarded(at, { FORGE_ORIGIN_DENY_PUSH: "other.example:system/*" })).toBe(false);
141 expect(guarded(at, { FORGE_ORIGIN_DENY_PUSH: "system/*" })).toBe(true);
142 });
143 });
144
145 // ---- against a fake Forgejo and a real forge serving its git ----
146
147 describe("the guard against an operator's Forgejo", () => {
148 let upstreamDir: string;
149 let upstreamApp: App;
150 let upstream: Servers;
151 let fake: FakeForge;
152 let state: FakeState;
153
154 /** A forge of our own, with one member, and its session cookie. */
155 interface Local {
156 dir: string;
157 app: App;
158 servers: Servers;
159 cookie: string;
160 alice: User;
161 }
162 const locals: Local[] = [];
163
164 /** A member of `app`, made directly (sign-ups are limited per address). */
165 const member = async (app: App, username: string): Promise<User> => {
166 const made = await createUser(app.db, {
167 username,
168 email: `${username}@forge.test`,
169 password: PASSWORD,
170 origin: "seed",
171 trusted: true,
172 });
173 if (!made.ok) throw new Error(`no account ${username}: ${made.error}`);
174 // A member, as an owner would make it: a guest owns no repository.
175 promote(app.db, made.value);
176 return getUserByName(app.db, username)!;
177 };
178 const boot = async (): Promise<Local> => {
179 const dir = await mkdtemp(join(tmpdir(), "forge-guard-"));
180 const app = createApp({ dataDir: dir, port: 0 });
181 const servers = await serve(app);
182 const alice = await member(app, "alice");
183 const local = {
184 dir,
185 app,
186 servers,
187 cookie: `forge_session=${createSession(app.db, alice.id).token}`,
188 alice,
189 };
190 locals.push(local);
191 return local;
192 };
193
194 /** A browser request: session cookie, manual redirects, a real Origin. */
195 const browse = (
196 local: Local,
197 path: string,
198 form?: Record<string, string>,
199 cookie = local.cookie,
200 ): Promise<Response> => {
201 const headers = new Headers({ cookie });
202 if (form !== undefined) headers.set("origin", new URL(local.servers.url).origin);
203 return fetch(`${local.servers.url}${path}`, {
204 method: form === undefined ? "GET" : "POST",
205 headers,
206 redirect: "manual",
207 ...(form === undefined ? {} : { body: new URLSearchParams(form) }),
208 });
209 };
210
211 const host = (): string => new URL(fake.url).hostname;
212
213 beforeAll(async () => {
214 upstreamDir = await mkdtemp(join(tmpdir(), "forge-guard-up-"));
215 upstreamApp = createApp({ dataDir: upstreamDir, port: 0 });
216 upstream = await serve(upstreamApp);
217 // The git host's own account: seeded, so it may make repositories.
218 const seeded = await seedAdmin(upstreamApp, {
219 username: LOGIN,
220 email: "[email protected]",
221 password: TOKEN,
222 });
223 if (!seeded.ok) throw new Error(`no git-host account: ${seeded.error}`);
224 await fetch(`${upstream.url}/api/v1/repos`, {
225 method: "POST",
226 headers: { "content-type": "application/json", authorization: basic(LOGIN, TOKEN) },
227 body: JSON.stringify({ name: "widgets", visibility: "public" }),
228 });
229 await fetch(`${upstream.url}/api/v1/repos/octo/widgets/contents`, {
230 method: "POST",
231 headers: { "content-type": "application/json", authorization: basic(LOGIN, TOKEN) },
232 body: JSON.stringify({
233 branch: "main",
234 message: "first",
235 files: [{ path: "README.md", content: "# widgets\n" }],
236 }),
237 });
238
239 state = newFakeState("forgejo", LOGIN, TOKEN);
240 state.repos.push(
241 {
242 id: 41,
243 fullName: "octo/widgets",
244 cloneUrl: `${upstream.url}/octo/widgets.git`,
245 htmlUrl: `${upstream.url}/octo/widgets`,
246 pushedAt: "2026-10-01T00:00:00Z",
247 },
248 {
249 id: 42,
250 fullName: "acme-corp/jobs",
251 cloneUrl: `${upstream.url}/octo/widgets.git`,
252 pushedAt: "2026-10-02T00:00:00Z",
253 },
254 {
255 id: 43,
256 fullName: "system/deploy",
257 cloneUrl: `${upstream.url}/octo/widgets.git`,
258 push: false,
259 admin: false,
260 },
261 );
262 baseline = state.repos.map((r) => ({ ...r }));
263 state.oauth = newFakeOAuth(CLIENT_ID);
264 fake = serveFakeForge(state);
265 });
266
267 afterAll(async () => {
268 fake.stop();
269 upstream.stop();
270 upstreamApp.close();
271 for (const local of locals) {
272 local.servers.stop();
273 local.app.close();
274 forgetProviders(local.dir);
275 await rm(local.dir, { recursive: true, force: true });
276 }
277 await rm(upstreamDir, { recursive: true, force: true });
278 });
279
280 /** The fake as it starts every test: an ordinary account, nothing protected. */
281 let baseline: FakeState["repos"] = [];
282 const reset = (): void => {
283 state.siteAdmin = false;
284 delete state.onRepoPage;
285 delete state.repoTotalOverride;
286 delete state.failRepos;
287 delete state.oauth!.onRefresh;
288 state.maxResponseItems = 50;
289 state.oauth!.expiresIn = 3600;
290 state.repos = baseline.map((r) => ({ ...r }));
291 };
292 const deploy = () => state.repos.find((r) => r.fullName === "system/deploy")!;
293 const operator = () => ({ FORGE_ORIGIN_CLIENT_ID: `${host()}=${CLIENT_ID}` });
294
295 /**
296 * Authorize on the instance with the operator's application, and come back
297 * to the forge's callback. Run inside `withEnv(operator())`.
298 */
299 const consent = async (local: Local): Promise<Response> => {
300 const started = await startOAuthConnect(local.app, local.alice, {
301 host: fake.url,
302 redirectUri: `${local.servers.url}/settings/origins/callback`,
303 });
304 if (!started.ok) throw new Error(`no flow: ${started.error.message}`);
305 const at = new URL(started.value.url);
306 return browse(
307 local,
308 `/settings/origins/callback?state=${encodeURIComponent(at.searchParams.get("state")!)}&code=${state.oauth!.authCode}`,
309 );
310 };
311
312 /** A refusal from the guard, or a thrown test. */
313 const guardError = (result: Awaited<ReturnType<typeof connectOrigin>>) => {
314 if (result.ok || typeof result.error === "string" || !("repos" in result.error))
315 throw new Error("not a guard refusal");
316 return result.error;
317 };
318
319 // ---- a pasted token ----
320
321 describe("on a pasted token", () => {
322 let local: Local;
323 let n = 0;
324 /** A fresh account per attempt: connecting is rate-limited per person. */
325 const person = (): Promise<User> => member(local.app, `p${++n}`);
326 const paste = (who: User) =>
327 connectOrigin(local.app, who, { host: fake.url, token: TOKEN });
328
329 beforeAll(async () => {
330 local = await boot();
331 });
332
333 test("an administrator's token is refused on the operator's host, and nothing is stored", async () => {
334 reset();
335 state.siteAdmin = true;
336 const who = await person();
337 const refused = await withEnv(operator(), () => paste(who));
338 reset();
339 const error = guardError(refused);
340 expect(error.error).toBe("admin_account");
341 expect(error.message).toContain("everyday non-admin account");
342 expect(listOrigins(local.app.db, who.id)).toHaveLength(0);
343 });
344
345 test("the same token connects where the operator guards nothing, as before", async () => {
346 reset();
347 state.siteAdmin = true;
348 const connected = await paste(await person());
349 reset();
350 expect(connected.ok).toBe(true);
351 });
352
353 test("replacing a stored token meets the same guard, and a refusal keeps the old token", async () => {
354 reset();
355 const who = await person();
356 const connected = await paste(who);
357 if (!connected.ok) throw new Error("no origin to replace the token of");
358 const swap = (token: string) =>
359 replaceOriginToken(local.app, who, connected.value.id, token);
360
361 state.siteAdmin = true;
362 const refused = await withEnv(operator(), () => swap(TOKEN));
363 expect(refused.ok).toBe(false);
364 if (!refused.ok) expect(refused.error).toBe("admin_account");
365
366 // Where the operator guards nothing, the same token is replaced as before.
367 expect((await swap(TOKEN)).ok).toBe(true);
368
369 state.siteAdmin = false;
370 const passed = await withEnv(operator(), () => swap(TOKEN));
371 reset();
372 expect(passed.ok).toBe(true);
373 });
374
375 test("a token that can push to a protected repository is refused, naming it", async () => {
376 reset();
377 deploy().push = true;
378 const who = await person();
379 const refused = await withEnv({ FORGE_ORIGIN_DENY_PUSH: "system/*" }, () => paste(who));
380 reset();
381 const error = guardError(refused);
382 expect(error.error).toBe("can_push");
383 expect(error.repos).toEqual(["system/deploy"]);
384 expect(error.message).toContain("system/deploy");
385 expect(listOrigins(local.app.db, who.id)).toHaveLength(0);
386 });
387
388 test("a repository named outright is asked about by name", async () => {
389 reset();
390 deploy().push = true;
391 const who = await person();
392 const before = state.requests.length;
393 const refused = await withEnv({ FORGE_ORIGIN_DENY_PUSH: "system/deploy" }, () =>
394 paste(who),
395 );
396 const asked = state.requests.slice(before).map((r) => r.path);
397 reset();
398 expect(guardError(refused).repos).toEqual(["system/deploy"]);
399 expect(asked).toContain("/api/v1/repos/system/deploy");
400 });
401
402 test("an account that cannot push there connects, and its repositories are cached at once", async () => {
403 reset();
404 const connected = await withEnv({ FORGE_ORIGIN_DENY_PUSH: "system/*" }, async () =>
405 paste(await person()),
406 );
407 if (!connected.ok) throw new Error("connect failed");
408 const cached = listOriginRepos(local.app.db, connected.value.id).map((r) => r.fullName);
409 expect(cached.sort()).toEqual(["acme-corp/jobs", "octo/widgets", "system/deploy"]);
410 // It passed, so its writes are not paused.
411 await withEnv({ FORGE_ORIGIN_DENY_PUSH: "system/*" }, async () => {
412 expect(writesPaused(local.app, connected.value.id)).toBeUndefined();
413 });
414 });
415
416 test("an account with more repositories than the guard reads is refused, not waved through", async () => {
417 reset();
418 // A page of one, and more repositories than the walk has pages.
419 state.maxResponseItems = 1;
420 for (let i = 0; i < GUARD_MAX_PAGES; i++)
421 state.repos.push({ id: 500 + i, fullName: `octo/extra-${i}`, cloneUrl: "", push: false });
422 const who = await person();
423 const refused = await withEnv({ FORGE_ORIGIN_DENY_PUSH: "system/*" }, () => paste(who));
424 reset();
425 expect(guardError(refused).error).toBe("guard_unchecked");
426 expect(listOrigins(local.app.db, who.id)).toHaveLength(0);
427 });
428
429 test("a protected repository pushed mid-walk cannot slip past: the walk is by id, with no fallback", async () => {
430 reset();
431 state.maxResponseItems = 1;
432 deploy().push = true;
433 deploy().pushedAt = "2020-01-01T00:00:00Z";
434 // Between pages, the protected repository becomes the most recently
435 // updated: in a newest-first listing it would jump to the page already read.
436 state.onRepoPage = (page) => {
437 if (page === 2) deploy().pushedAt = new Date().toISOString();
438 };
439 const before = state.requests.length;
440 const refused = await withEnv({ FORGE_ORIGIN_DENY_PUSH: "system/*" }, async () =>
441 paste(await person()),
442 );
443 const requests = state.requests.slice(before);
444 const walked = requests.filter((r) => r.path.endsWith("/repos/search"));
445 reset();
446 expect(guardError(refused).repos).toEqual(["system/deploy"]);
447 expect(walked.length).toBeGreaterThanOrEqual(3);
448 for (const r of walked) expect(r.query).toContain("sort=id");
449 expect(requests.some((r) => r.path.endsWith("/user/repos"))).toBe(false);
450 });
451
452 test("a count that does not add up refuses: one the origin overstates, and one that changes mid-walk", async () => {
453 reset();
454 state.repoTotalOverride = 4;
455 const overstated = await withEnv({ FORGE_ORIGIN_DENY_PUSH: "system/*" }, async () =>
456 paste(await person()),
457 );
458 reset();
459 expect(guardError(overstated).error).toBe("guard_unchecked");
460
461 state.maxResponseItems = 1;
462 state.onRepoPage = (page) => {
463 if (page === 2) state.repos = state.repos.filter((r) => r.fullName !== "octo/widgets");
464 };
465 const shrunk = await withEnv({ FORGE_ORIGIN_DENY_PUSH: "system/*" }, async () =>
466 paste(await person()),
467 );
468 reset();
469 expect(guardError(shrunk).error).toBe("guard_unchecked");
470 });
471
472 test("a confirmed hit is refused as one, even when another lookup fails", async () => {
473 reset();
474 deploy().push = true;
475 state.repos.push({ id: 44, fullName: "ops/broken", cloneUrl: "", push: false });
476 state.failRepos = new Set(["ops/broken"]);
477 const refused = await withEnv(
478 { FORGE_ORIGIN_DENY_PUSH: "ops/broken,system/deploy" },
479 async () => paste(await person()),
480 );
481 reset();
482 const error = guardError(refused);
483 expect(error.error).toBe("can_push");
484 expect(error.repos).toEqual(["system/deploy"]);
485 });
486
487 test("a hit in a walk that did not finish is refused as one, not as unchecked", async () => {
488 reset();
489 deploy().push = true;
490 state.maxResponseItems = 1;
491 // The count says one more than there is: the walk ends incomplete, after
492 // it has read the protected repository.
493 state.repoTotalOverride = 4;
494 const refused = await withEnv({ FORGE_ORIGIN_DENY_PUSH: "system/*" }, async () =>
495 paste(await person()),
496 );
497 reset();
498 const error = guardError(refused);
499 expect(error.error).toBe("can_push");
500 expect(error.repos).toEqual(["system/deploy"]);
501 });
502
503 test("every repository named outright is asked about, the sixtieth too", async () => {
504 reset();
505 deploy().push = true;
506 const names = [...Array.from({ length: 59 }, (_, i) => `ops/repo-${i}`), "system/deploy"];
507 const before = state.requests.length;
508 const refused = await withEnv({ FORGE_ORIGIN_DENY_PUSH: names.join(",") }, async () =>
509 paste(await person()),
510 );
511 const asked = state.requests
512 .slice(before)
513 .filter((r) => /^\/api\/v1\/repos\/(ops|system)\//.test(r.path));
514 reset();
515 expect(guardError(refused).repos).toEqual(["system/deploy"]);
516 expect(asked).toHaveLength(60);
517 });
518
519 test("the web form says the guard's sentence and keeps no token", async () => {
520 reset();
521 state.siteAdmin = true;
522 const who = await person();
523 const cookie = `forge_session=${createSession(local.app.db, who.id).token}`;
524 const response = await withEnv(operator(), () =>
525 browse(local, "/settings/origins/token", { host: fake.url, token: TOKEN }, cookie),
526 );
527 reset();
528 expect(response.status).toBe(422);
529 const page = await response.text();
530 expect(page).toContain("is an administrator of localhost");
531 expect(page).not.toContain(TOKEN);
532 expect(listOrigins(local.app.db, who.id)).toHaveLength(0);
533 });
534 });
535
536 // ---- an OAuth consent ----
537
538 describe("on an OAuth consent", () => {
539 test("an administrator who consents is refused with the sentence, and nothing is kept", async () => {
540 reset();
541 state.siteAdmin = true;
542 const local = await boot();
543 await withEnv(operator(), async () => {
544 const back = await consent(local);
545 reset();
546 expect(back.status).toBe(303);
547 const to = back.headers.get("location")!;
548 expect(to).toStartWith("/settings/origins?error=");
549 const page = await (await browse(local, to)).text();
550 expect(page).toContain("everyday non-admin account");
551 expect(page).toContain("did not keep the token");
552 // The whole sentence, with where to remove the grant: not cut short.
553 expect(page).toContain("under Settings → Applications.");
554 expect(listOrigins(local.app.db, local.alice.id)).toHaveLength(0);
555 });
556 });
557
558 test("an everyday account consents, connects, and writes at once", async () => {
559 reset();
560 const local = await boot();
561 await withEnv(operator(), async () => {
562 const back = await consent(local);
563 expect(back.headers.get("location")).toBe("/settings/origins?connected=1");
564 const origins = listOrigins(local.app.db, local.alice.id);
565 expect(origins).toHaveLength(1);
566 expect(origins[0]!.authKind).toBe("oauth");
567 expect(writesPaused(local.app, origins[0]!.id)).toBeUndefined();
568 });
569 });
570 });
571
572 // ---- what is already stored ----
573
574 describe("stored origins, checked again", () => {
575 const deny = () => ({ ...operator(), FORGE_ORIGIN_DENY_PUSH: "system/*" });
576
577 test("a refresh checks the account again and cuts it off before the new token is used", async () => {
578 reset();
579 // A token that expires inside the refresh margin: the next use refreshes it.
580 state.oauth!.expiresIn = 30;
581 const local = await boot();
582 await withEnv(deny(), async () => {
583 await consent(local);
584 const origin = listOrigins(local.app.db, local.alice.id)[0]!;
585 expect(origin.needsReauth).toBe(false);
586
587 // Since it connected, the account was given push on a protected repository.
588 deploy().push = true;
589 const provider = providerForOrigin(local.app, origin);
590 if (!provider.ok) throw new Error("no provider");
591 const used = await provider.value.whoami();
592 expect(used.ok).toBe(false);
593 expect(state.oauth!.tokenRequests.at(-1)?.grant_type).toBe("refresh_token");
594
595 const after = listOrigins(local.app.db, local.alice.id)[0]!;
596 expect(after.needsReauth).toBe(true);
597 expect(after.lastError).toContain("system/deploy");
598 // Nothing travels on it any more: no git credential either.
599 expect(await provider.value.gitAuthEnv(`${upstream.url}/octo/widgets.git`)).toEqual({});
600 const page = await (await browse(local, "/settings/origins")).text();
601 expect(page).toContain("can push to system/deploy");
602 expect(page).toContain(`Re-authorize ${LOGIN} at`);
603 });
604 reset();
605 });
606
607 test("the sweep refuses an origin connected before the guard, and leaves it refused", async () => {
608 reset();
609 deploy().push = true;
610 const local = await boot();
611 // Connected with nothing guarded: the forge as it was before the guard.
612 const connected = await connectOrigin(local.app, local.alice, { host: fake.url, token: TOKEN });
613 if (!connected.ok) throw new Error("connect failed");
614 await withEnv(deny(), async () => {
615 await runGuardSweep(local.app);
616 const after = listOrigins(local.app.db, local.alice.id)[0]!;
617 expect(after.needsReauth).toBe(true);
618 expect(after.lastError).toContain("system/deploy");
619 // A refused origin is not checked again until someone reconnects it.
620 const before = state.requests.length;
621 await runGuardSweep(local.app);
622 expect(state.requests.length).toBe(before);
623 });
624 reset();
625 });
626
627 test("while a connection cannot be verified, every write is paused and reads go on", async () => {
628 reset();
629 const local = await boot();
630 const connected = await withEnv(deny(), () =>
631 connectOrigin(local.app, local.alice, { host: fake.url, token: TOKEN }),
632 );
633 if (!connected.ok) throw new Error("connect failed");
634 const id = connected.value.id;
635 const job = startMirrorJob(local.app, local.alice, id, "octo/widgets", "widgets");
636 if (!job.ok) throw new Error("mirror refused");
637 const mirror = (await settled(job.value.id))!.repo!;
638
639 await withEnv(deny(), async () => {
640 // The check runs, and cannot finish: a count that does not add up.
641 state.repoTotalOverride = 99;
642 const checked = await checkOriginAgain(local.app, local.alice, id);
643 delete state.repoTotalOverride;
644 expect(checked.ok && checked.value).toBe("unchecked");
645 expect(writesPaused(local.app, id)).toBeDefined();
646 expect(listOrigins(local.app.db, local.alice.id)[0]!.needsReauth).toBe(false);
647
648 const writesBefore = state.requests.filter((r) => r.method !== "GET").length;
649 // Write-through, a push, a branch carried up, a fork, a contribution:
650 // every one refused here, before the origin is asked.
651 const issue = await openIssueThrough(local.app, local.alice, mirror, { title: "x" });
652 expect(!issue.ok && issue.error).toBe("origin_paused");
653 const push = await originPush(local.app, local.alice, mirror);
654 expect(!push.ok && push.error).toBe("origin_paused");
655 const carried = await carryBranchThrough(local.app, mirror, mirror, "topic", local.alice.id);
656 expect(!carried.ok && carried.error.message).toContain("writes through it are paused");
657 const pushed = await pushThrough(local.app, mirror, [], {}, local.alice.id);
658 expect(pushed.ok).toBe(false);
659 const forked = await forkOrigin(local.app, local.alice, id, "acme-corp/jobs");
660 expect(!forked.ok && forked.error).toBe("origin_paused");
661 const proposed = await contribute(local.app, local.alice, {
662 originId: id,
663 fullName: "octo/widgets",
664 branch: "topic",
665 files: [{ path: "a.txt", content: "a" }],
666 });
667 expect(!proposed.ok && proposed.error).toBe("origin_paused");
668 // And the adapter itself refuses any request that is not a read.
669 const provider = providerForOrigin(local.app, listOrigins(local.app.db, local.alice.id)[0]!);
670 if (!provider.ok) throw new Error("no provider");
671 const direct = await provider.value.createIssue("octo/widgets", { title: "x", body: "" });
672 expect(!direct.ok && direct.error.error).toBe("forbidden");
673 expect(state.requests.filter((r) => r.method !== "GET").length).toBe(writesBefore);
674
675 // Reads go on.
676 expect((await provider.value.whoami()).ok).toBe(true);
677 const synced = await syncOriginNow(local.app, local.alice, id);
678 expect(synced.ok && synced.value.error).toBe("");
679
680 // Settings → Origins says so, with the way out.
681 const page = await (await browse(local, "/settings/origins")).text();
682 expect(page).toContain("Couldn't verify this connection; writes paused");
683 expect(page).toContain(`action="/settings/origins/${id}/check"`);
684 // Asked again at once: too soon, said on the page it was asked from.
685 const again = await browse(local, `/settings/origins/${id}/check`, {});
686 expect(again.status).toBe(303);
687 expect(again.headers.get("location")).toBe("/settings/origins");
688 expect(writesPaused(local.app, id)).toBeDefined();
689
690 // A check that passes turns writes back on.
691 const passed = await recheckOrigin(
692 local.app,
693 listOrigins(local.app.db, local.alice.id)[0]!,
694 provider.value,
695 );
696 expect(passed).toBe("passed");
697 expect(writesPaused(local.app, id)).toBeUndefined();
698 const opened = await openIssueThrough(local.app, local.alice, mirror, { title: "now" });
699 expect(opened.ok).toBe(true);
700 const after = await (await browse(local, "/settings/origins")).text();
701 expect(after).not.toContain("writes paused");
702
703 // One that has not run for a day refuses the origin.
704 state.repoTotalOverride = 99;
705 setCursor(
706 local.app.db,
707 id,
708 "guard_unchecked_since",
709 new Date(Date.now() - 25 * 60 * 60_000).toISOString(),
710 );
711 expect(
712 await recheckOrigin(local.app, listOrigins(local.app.db, local.alice.id)[0]!, provider.value),
713 ).toBe("refused");
714 delete state.repoTotalOverride;
715 expect(listOrigins(local.app.db, local.alice.id)[0]!.needsReauth).toBe(true);
716 });
717 reset();
718 });
719
720 test("Check again on Settings → Origins runs the guard and says what it found", async () => {
721 reset();
722 const local = await boot();
723 // Connected before the guard: never checked, so paused once guarded.
724 const connected = await connectOrigin(local.app, local.alice, { host: fake.url, token: TOKEN });
725 if (!connected.ok) throw new Error("connect failed");
726 const id = connected.value.id;
727 await withEnv(deny(), async () => {
728 expect(writesPaused(local.app, id)).toBeDefined();
729 const checked = await browse(local, `/settings/origins/${id}/check`, {});
730 expect(checked.status).toBe(303);
731 expect(checked.headers.get("location")).toBe("/settings/origins");
732 const flash = (checked.headers.get("set-cookie") ?? "").split(";")[0]!;
733 const page = await (
734 await browse(local, "/settings/origins", undefined, `${local.cookie}; ${flash}`)
735 ).text();
736 expect(page).toContain("Verified. Writes through this connection are on again.");
737 expect(writesPaused(local.app, id)).toBeUndefined();
738 // Another person's origin is not theirs to check.
739 const bob = await member(local.app, "bob");
740 const theirs = await checkOriginAgain(local.app, bob, id);
741 expect(!theirs.ok && theirs.error).toBe("not_found");
742 });
743 reset();
744 });
745
746 test("a refresh that races a refusal never undoes it", async () => {
747 reset();
748 state.oauth!.expiresIn = 30;
749 const local = await boot();
750 await withEnv(deny(), async () => {
751 await consent(local);
752 const origin = listOrigins(local.app.db, local.alice.id)[0]!;
753 // The sweep refuses the origin while its refresh is on the wire.
754 state.oauth!.onRefresh = () =>
755 markNeedsReauth(local.app.db, origin.id, "refused by the sweep");
756 const provider = providerForOrigin(local.app, origin);
757 if (!provider.ok) throw new Error("no provider");
758 const used = await provider.value.whoami();
759 expect(state.oauth!.tokenRequests.at(-1)?.grant_type).toBe("refresh_token");
760 expect(used.ok).toBe(false);
761 const after = listOrigins(local.app.db, local.alice.id)[0]!;
762 expect(after.needsReauth).toBe(true);
763 expect(after.lastError).toBe("refused by the sweep");
764 });
765 // And the store itself: a refresh written after a refusal keeps it.
766 const origin = listOrigins(local.app.db, local.alice.id)[0]!;
767 storeRefreshed(local.app.db, local.dir, origin.id, { token: "t", expiresAt: null });
768 expect(listOrigins(local.app.db, local.alice.id)[0]!.needsReauth).toBe(true);
769 reset();
770 });
771
772 test("the sweep checks one origin at a time, stamps the ones that pass, and then rests", async () => {
773 reset();
774 const local = await boot();
775 const bob = await member(local.app, "bob");
776 for (const who of [local.alice, bob]) {
777 const made = await connectOrigin(local.app, who, { host: fake.url, token: TOKEN });
778 if (!made.ok) throw new Error("connect failed");
779 }
780 const ids = [local.alice, bob].map((u) => listOrigins(local.app.db, u.id)[0]!.id);
781 const stamped = () =>
782 ids.filter((id) => getCursor(local.app.db, id, "guard_checked_at") !== undefined).length;
783 await withEnv(deny(), async () => {
784 expect(stamped()).toBe(0);
785 await runGuardSweep(local.app);
786 expect(stamped()).toBe(1);
787 await runGuardSweep(local.app);
788 expect(stamped()).toBe(2);
789 const before = state.requests.length;
790 await runGuardSweep(local.app);
791 expect(state.requests.length).toBe(before);
792 });
793 expect(listOrigins(local.app.db, bob.id)[0]!.needsReauth).toBe(false);
794 });
795 });
796 });
test/origin-token-scopes.test.ts added +128
@@ -0,0 +1,128 @@
1 /**
2 * A token that lacks a scope is told which one, in the words of the page
3 * that makes tokens; the token form says what a token needs, and links to
4 * that page with the scopes already chosen.
5 *
6 * Forgejo answers a missing scope with a 403 whose body names it
7 * (`token does not have at least one of required scope(s): [read:user]`).
8 * Read as a bare "forbidden" that became "That token was refused by the
9 * origin", and a person could not tell a wrong token from a narrow one.
10 */
11
12 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
13 import { mkdtemp, rm } from "node:fs/promises";
14 import { tmpdir } from "node:os";
15 import { join } from "node:path";
16 import { createApp, type App } from "../src/app.ts";
17 import { connectOrigin } from "../src/actions/origins.ts";
18 import { listOrigins } from "../src/core/origins.ts";
19 import { createSession } from "../src/core/sessions.ts";
20 import { createUser, type User } from "../src/core/users.ts";
21 import { serve, type Servers } from "../src/http/server.ts";
22 import { missingScopes, scopeWords } from "../src/origins/providers/http.ts";
23 import { forgetProviders } from "../src/origins/providers/index.ts";
24 import { quietLogs } from "./helpers/globals.ts";
25 import { newFakeState, serveFakeForge, type FakeForge } from "./fake-origin.ts";
26
27 quietLogs();
28
29 const TOKEN = "octo-scope-words-token";
30
31 describe("a scope refusal, read", () => {
32 test("Forgejo's 403 names the scopes; anything else names none", () => {
33 expect(
34 missingScopes(
35 '{"message":"token does not have at least one of required scope(s): [read:user]"}',
36 ),
37 ).toEqual(["read:user"]);
38 expect(
39 missingScopes("required scope(s): [read:issue write:issue, <script>]"),
40 ).toEqual(["read:issue", "write:issue"]);
41 expect(missingScopes('{"message":"forbidden"}')).toEqual([]);
42 expect(missingScopes("")).toEqual([]);
43 });
44
45 test("scopes in the token page's words", () => {
46 expect(scopeWords(["read:user"])).toBe("User: Read");
47 expect(scopeWords(["write:issue"])).toBe("Issue: Read and write");
48 expect(scopeWords(["read:repository", "read:organization"])).toBe(
49 "Repository: Read or Organization: Read",
50 );
51 });
52 });
53
54 describe("connecting with a token that lacks a scope", () => {
55 let dir: string;
56 let app: App;
57 let servers: Servers;
58 let fake: FakeForge;
59 let user: User;
60 let cookie = "";
61
62 beforeAll(async () => {
63 fake = serveFakeForge(newFakeState("forgejo", "octo", TOKEN));
64 dir = await mkdtemp(join(tmpdir(), "forge-token-scopes-"));
65 app = createApp({ dataDir: dir, port: 0 });
66 servers = await serve(app);
67 const made = await createUser(app.db, {
68 username: "ada",
69 email: "[email protected]",
70 password: "a-long-token-passphrase",
71 origin: "seed",
72 trusted: true,
73 });
74 if (!made.ok) throw new Error(made.error);
75 user = made.value;
76 cookie = `forge_session=${createSession(app.db, user.id).token}`;
77 });
78
79 afterAll(async () => {
80 fake.stop();
81 servers.stop();
82 app.close();
83 forgetProviders(dir);
84 await rm(dir, { recursive: true, force: true });
85 });
86
87 test("is told which scope, and nothing is stored", async () => {
88 fake.state.userScopeMissing = "read:user";
89 const refused = await connectOrigin(app, user, { host: fake.url, token: TOKEN });
90 delete fake.state.userScopeMissing;
91 if (refused.ok || typeof refused.error === "string")
92 throw new Error("not a scope refusal");
93 expect(refused.error.error).toBe("missing_scopes");
94 expect(refused.error.message).toContain("This token is missing User: Read.");
95 expect(listOrigins(app.db, user.id)).toHaveLength(0);
96 });
97
98 test("the API says the same, as a code a client can branch on", async () => {
99 fake.state.userScopeMissing = "read:user";
100 const response = await fetch(`${servers.url}/api/v1/user/origins`, {
101 method: "POST",
102 headers: {
103 "content-type": "application/json",
104 cookie,
105 origin: new URL(servers.url).origin,
106 },
107 body: JSON.stringify({ host: fake.url, token: TOKEN }),
108 });
109 delete fake.state.userScopeMissing;
110 expect(response.status).toBe(422);
111 const body = (await response.json()) as { error: string; message: string };
112 expect(body.error).toBe("missing_scopes");
113 expect(body.message).toContain("User: Read");
114 expect(JSON.stringify(body)).not.toContain(TOKEN);
115 });
116
117 test("the token form says what a token needs, and links to a prefilled one", async () => {
118 const page = await (
119 await fetch(`${servers.url}/settings/origins?host=${encodeURIComponent(fake.url)}`, {
120 headers: { cookie },
121 })
122 ).text();
123 expect(page).toContain("Repository: Read and write");
124 expect(page).toContain(
125 `${fake.url}/user/settings/applications/tokens/new?name=forge&amp;resource=all&amp;scope=read:user&amp;scope=write:repository&amp;scope=write:issue`,
126 );
127 });
128 });
test/propose-via-fork.test.ts added +408
@@ -0,0 +1,408 @@
1 /**
2 * Contributing with read access (owner, 2026-10-16): "If you have code read
3 * you can clone or fork; with pull requests you can open a PR." A fork of a
4 * mirror proposes upstream through the account's own fork at the origin
5 * when it cannot push to the origin repository — and always for a
6 * repository the operator protects, which this forge never pushes to.
7 *
8 * Two real forges and a fake API in front of one of them, as in
9 * `fork-flow.test.ts`: the upstream forge is the git host the fake's clone
10 * URLs point at, so a push that "landed" can be read back from its repos.
11 */
12
13 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
14 import { mkdtemp, rm } from "node:fs/promises";
15 import { tmpdir } from "node:os";
16 import { join } from "node:path";
17 import { createApp, type App } from "../src/app.ts";
18 import { serve, type Servers } from "../src/http/server.ts";
19 import { originPush } from "../src/actions/branches.ts";
20 import { editFiles } from "../src/actions/contents.ts";
21 import { contribute } from "../src/actions/contribute.ts";
22 import { connectOrigin, startMirrorJob } from "../src/actions/origins.ts";
23 import { openPull } from "../src/actions/pulls.ts";
24 import { forkRepo } from "../src/actions/repos.ts";
25 import { proposeUpstream } from "../src/actions/upstream.ts";
26 import {
27 clearCursor,
28 getOriginFor,
29 mirrorOf,
30 type Origin,
31 } from "../src/core/origins.ts";
32 import { getForkOf, getRepoById, type Repo } from "../src/core/repos.ts";
33 import { createUser, getUserByName, type User } from "../src/core/users.ts";
34 import { promote } from "./helpers/members.ts";
35 import { tokenFor } from "./helpers/tokens.ts";
36 import { resolveOid } from "../src/git/merge.ts";
37 import { repoDir } from "../src/git/repos.ts";
38 import { noteGuardPassed, providerForOrigin } from "../src/origins/credential.ts";
39 import { ensureFork, pushToFork } from "../src/origins/fork-route.ts";
40 import { settled } from "../src/origins/jobs.ts";
41 import { carryBranchThrough } from "../src/origins/push-through.ts";
42 import { fastFirstFetch, quietLogs, withoutEnv } from "./helpers/globals.ts";
43 import { proveAll } from "./helpers/verified.ts";
44 import {
45 newFakeState,
46 serveFakeForge,
47 type FakeForge,
48 type FakeState,
49 } from "./fake-origin.ts";
50
51 quietLogs();
52 fastFirstFetch();
53 withoutEnv("FORGE_ORIGIN_CLIENT_ID", "FORGE_ORIGIN_DENY_PUSH", "FORGE_URL");
54
55 const ME = "me";
56 const UP = "octo";
57 const PASSWORD = "propose-via-fork-pass";
58
59 const basic = (user: string, secret: string): string =>
60 `Basic ${Buffer.from(`${user}:${secret}`).toString("base64")}`;
61
62 describe("proposing through the account's own fork", () => {
63 let upDir: string;
64 let upApp: App;
65 let up: Servers;
66 let dir: string;
67 let app: App;
68 let servers: Servers;
69 let fake: FakeForge;
70 let state: FakeState;
71 let token = "";
72 let alice: User;
73 let origin: Origin;
74
75 const upstream = (path: string, as: string, body?: unknown): Promise<Response> =>
76 fetch(`${up.url}${path}`, {
77 method: body === undefined ? "GET" : "POST",
78 headers: { "content-type": "application/json", authorization: basic(as, PASSWORD) },
79 ...(body === undefined ? {} : { body: JSON.stringify(body) }),
80 });
81
82 /**
83 * An ordinary, proven account on a forge, and a full token for it. Made
84 * directly: sign-ups make no admin and are limited per address, and the
85 * API mints `admin` tokens for admins only (`test/helpers/tokens.ts`).
86 * Not an admin on purpose: the git host must refuse `me` a push to a
87 * repository `me` only reads.
88 */
89 async function account(app: App, username: string): Promise<string> {
90 const made = await createUser(app.db, {
91 username,
92 email: `${username}@p.dev`,
93 password: PASSWORD,
94 origin: "seed",
95 trusted: true,
96 });
97 if (!made.ok) throw new Error(`no account ${username}: ${made.error}`);
98 // A member, as an owner would make it: a guest owns no repository.
99 promote(app.db, made.value);
100 return tokenFor(app, username, "admin");
101 }
102
103 async function seed(name: string, owner = UP): Promise<void> {
104 await upstream("/api/v1/repos", owner, { name, visibility: "public" });
105 await upstream(`/api/v1/repos/${owner}/${name}/contents`, owner, {
106 branch: "main",
107 message: "first",
108 files: [{ path: "a.txt", content: "one\n" }],
109 });
110 }
111
112 const git = (owner: string, name: string) => `${up.url}/${owner}/${name}.git`;
113 /** Where a branch stands on the upstream forge's own copy of a repository. */
114 const tipUp = (owner: string, name: string, branch: string) =>
115 resolveOid(repoDir(upApp.reposRoot, owner, name), `refs/heads/${branch}`);
116 const asked = (method: string, suffix: string) =>
117 state.requests.filter((r) => r.method === method && r.path.endsWith(suffix));
118 const lastPullHead = (fullName: string): string =>
119 String(
120 (JSON.parse(asked("POST", `/repos/${fullName}/pulls`).at(-1)?.body ?? "{}") as {
121 head?: string;
122 }).head,
123 );
124
125 /**
126 * The workbench: the origin repo mirrored, forked here, one branch
127 * committed. The mirror and the fork are made once and found after, so
128 * each test stands alone in any order.
129 */
130 async function workbench(
131 fullName: string,
132 branch: string,
133 ): Promise<{ mirror: Repo; fork: Repo }> {
134 const name = fullName.split("/")[1]!;
135 let mirrorId = mirrorOf(app.db, origin.id, fullName);
136 if (mirrorId === undefined) {
137 const job = startMirrorJob(app, alice, origin.id, fullName, `${name}-mirror`);
138 if (!job.ok) throw new Error("mirror refused");
139 mirrorId = (await settled(job.value.id))!.repo!.id;
140 }
141 const mirror = getRepoById(app.db, mirrorId)!;
142 let fork = getForkOf(app.db, alice.id, mirror.id);
143 if (fork === undefined) {
144 const made = await forkRepo(app, alice, mirror, `${name}-work`);
145 if (!made.ok) throw new Error(`fork: ${made.error}`);
146 fork = made.value;
147 }
148 const edited = await editFiles(app, alice, fork, {
149 newBranch: branch,
150 files: [{ path: `${branch}.txt`, content: `${branch}\n` }],
151 });
152 if (!edited.ok) throw new Error("edit failed");
153 return { mirror, fork };
154 }
155
156 async function withEnv<T>(name: string, value: string, fn: () => Promise<T>): Promise<T> {
157 const before = process.env[name];
158 process.env[name] = value;
159 try {
160 return await fn();
161 } finally {
162 if (before === undefined) delete process.env[name];
163 else process.env[name] = before;
164 }
165 }
166
167 beforeAll(async () => {
168 upDir = await mkdtemp(join(tmpdir(), "forge-pvf-up-"));
169 upApp = createApp({ dataDir: upDir, port: 0 });
170 up = await serve(upApp);
171 await account(upApp, UP);
172 token = await account(upApp, ME);
173 proveAll(upApp);
174 for (const name of ["gears", "widgets", "pushy"]) await seed(name);
175 await seed("own", ME);
176 // `me` really forks the upstreams it will propose to, so the fork's clone
177 // URL is a repository that exists and that `me` may push to.
178 for (const name of ["gears", "widgets", "pushy"]) {
179 const forked = await upstream(`/api/v1/repos/${UP}/${name}/forks`, ME, {});
180 expect(forked.status).toBe(201);
181 }
182
183 state = newFakeState("forgejo", ME, token);
184 state.repos.push(
185 // Read only: the premise.
186 { id: 1, fullName: `${UP}/gears`, cloneUrl: git(UP, "gears"), push: false, admin: false },
187 { id: 2, fullName: `${UP}/widgets`, cloneUrl: git(UP, "widgets"), push: false, admin: false },
188 // The API says yes, git says no: a stale or wrong answer.
189 { id: 3, fullName: `${UP}/pushy`, cloneUrl: git(UP, "pushy"), push: true, admin: false },
190 { id: 4, fullName: `${ME}/own`, cloneUrl: git(ME, "own"), push: true, admin: true },
191 // A name `me` already uses for something that is not a fork.
192 { id: 5, fullName: `${UP}/thing`, cloneUrl: git(UP, "gears"), push: false },
193 { id: 6, fullName: `${ME}/thing`, cloneUrl: git(ME, "own"), push: true },
194 // A fork the account renamed: neither name this forge would guess.
195 { id: 7, fullName: `${UP}/renamed`, cloneUrl: git(UP, "gears"), push: false },
196 {
197 id: 8,
198 fullName: `${ME}/a-name-of-its-own`,
199 cloneUrl: git(ME, "own"),
200 fork: true,
201 parent: `${UP}/renamed`,
202 push: true,
203 },
204 );
205 fake = serveFakeForge(state);
206
207 dir = await mkdtemp(join(tmpdir(), "forge-pvf-"));
208 app = createApp({ dataDir: dir, port: 0 });
209 servers = await serve(app);
210 await account(app, "alice");
211 proveAll(app);
212 alice = getUserByName(app.db, "alice")!;
213 const connected = await connectOrigin(app, alice, { host: fake.url, token });
214 if (!connected.ok) throw new Error("connect failed");
215 origin = getOriginFor(app.db, alice.id, connected.value.id)!;
216 });
217
218 afterAll(async () => {
219 fake.stop();
220 servers.stop();
221 up.stop();
222 app.close();
223 upApp.close();
224 await rm(dir, { recursive: true, force: true });
225 await rm(upDir, { recursive: true, force: true });
226 });
227
228 test("a read-only account proposes through its own fork, and the pull's head is <owner>:<branch>", async () => {
229 const { mirror, fork } = await workbench(`${UP}/gears`, "topic");
230 const pull = await openPull(app, alice, mirror, {
231 title: "A change from a reader",
232 baseBranch: "main",
233 headBranch: "topic",
234 headRepo: fork,
235 });
236 expect(pull.ok).toBe(true);
237 expect(asked("POST", `/repos/${UP}/gears/forks`).length).toBeLessThanOrEqual(1);
238 expect(lastPullHead(`${UP}/gears`)).toBe(`${ME}:topic`);
239 // The branch is in the account's fork, and nowhere near the upstream.
240 const tip = await resolveOid(repoDir(app.reposRoot, fork.ownerName, fork.name), "refs/heads/topic");
241 expect(await tipUp(ME, "gears", "topic")).toBe(tip!);
242 expect(await tipUp(UP, "gears", "topic")).toBeUndefined();
243 });
244
245 test("the fork is reused, not made twice", async () => {
246 for (const branch of ["second", "third"]) {
247 const { mirror, fork } = await workbench(`${UP}/gears`, branch);
248 const pull = await openPull(app, alice, mirror, {
249 title: `Proposal ${branch}`,
250 baseBranch: "main",
251 headBranch: branch,
252 headRepo: fork,
253 });
254 expect(pull.ok).toBe(true);
255 expect(lastPullHead(`${UP}/gears`)).toBe(`${ME}:${branch}`);
256 // However many proposals, one fork was ever asked for.
257 expect(asked("POST", `/repos/${UP}/gears/forks`)).toHaveLength(1);
258 }
259 });
260
261 test("a fork's own default branch goes up under its owner's name, and the page says how", async () => {
262 // gears: read-only in every test, so the picker's cache never says
263 // otherwise (the protected-repository test lends widgets push rights).
264 const { fork } = await workbench(`${UP}/gears`, "unused");
265 const landed = await editFiles(app, alice, fork, {
266 files: [{ path: "main.txt", content: "on main\n" }],
267 });
268 expect(landed.ok).toBe(true);
269 const page = await (
270 await fetch(`${servers.url}/${fork.ownerName}/${fork.name}`, {
271 headers: { authorization: basic("alice", PASSWORD) },
272 })
273 ).text();
274 expect(page).toContain("Propose via your fork");
275 const proposed = await proposeUpstream(app, alice, fork);
276 expect(proposed.ok).toBe(true);
277 expect(lastPullHead(`${UP}/gears`)).toBe(`${ME}:alice/main`);
278 expect(await tipUp(ME, "gears", "alice/main")).toBeDefined();
279 expect(await tipUp(UP, "gears", "alice/main")).toBeUndefined();
280 });
281
282 test("an account that can push still pushes directly: no fork", async () => {
283 const { mirror, fork } = await workbench(`${ME}/own`, "direct");
284 const pull = await openPull(app, alice, mirror, {
285 title: "Straight in",
286 baseBranch: "main",
287 headBranch: "direct",
288 headRepo: fork,
289 });
290 expect(pull.ok).toBe(true);
291 expect(asked("POST", `/repos/${ME}/own/forks`)).toHaveLength(0);
292 expect(lastPullHead(`${ME}/own`)).toBe("direct");
293 expect(await tipUp(ME, "own", "direct")).toBeDefined();
294 });
295
296 test("a push the origin refuses falls back to the fork", async () => {
297 const { mirror, fork } = await workbench(`${UP}/pushy`, "fallback");
298 const pull = await openPull(app, alice, mirror, {
299 title: "Said yes, meant no",
300 baseBranch: "main",
301 headBranch: "fallback",
302 headRepo: fork,
303 });
304 expect(pull.ok).toBe(true);
305 expect(await tipUp(UP, "pushy", "fallback")).toBeUndefined();
306 expect(asked("POST", `/repos/${UP}/pushy/forks`)).toHaveLength(1);
307 expect(lastPullHead(`${UP}/pushy`)).toBe(`${ME}:fallback`);
308 });
309
310 test("a protected repository is never pushed to, even by an account that could: always the fork", async () => {
311 await withEnv("FORGE_ORIGIN_DENY_PUSH", `${UP}/widgets`, async () => {
312 // Checked when it connected; the account gained push rights since.
313 noteGuardPassed(app, origin.id);
314 state.repos.find((r) => r.fullName === `${UP}/widgets`)!.push = true;
315 const { mirror, fork } = await workbench(`${UP}/widgets`, "guarded");
316 const forksBefore = asked("POST", `/repos/${UP}/widgets/forks`).length;
317
318 // The direct ways in are refused before anything moves.
319 const carried = await carryBranchThrough(app, mirror, fork, "guarded", alice.id);
320 expect(!carried.ok && carried.error.message).toContain("protected by this forge's operator");
321 const pushed = await originPush(app, alice, mirror);
322 expect(!pushed.ok && pushed.error).toBe("origin_protected");
323
324 const pull = await openPull(app, alice, mirror, {
325 title: "To a protected repository",
326 baseBranch: "main",
327 headBranch: "guarded",
328 headRepo: fork,
329 });
330 expect(pull.ok).toBe(true);
331 expect(lastPullHead(`${UP}/widgets`)).toBe(`${ME}:guarded`);
332 expect(await tipUp(UP, "widgets", "guarded")).toBeUndefined();
333 expect(asked("POST", `/repos/${UP}/widgets/forks`).length).toBeLessThanOrEqual(forksBefore + 1);
334
335 // contribute takes its fork path there too.
336 const made = await contribute(app, alice, {
337 originId: origin.id,
338 fullName: `${UP}/widgets`,
339 branch: "contributed",
340 files: [{ path: "c.txt", content: "c\n" }],
341 openPull: { title: "Contributed" },
342 });
343 expect(made.ok && made.value.via).toBe("fork");
344 expect(lastPullHead(`${UP}/widgets`)).toBe(`${ME}:contributed`);
345 expect(await tipUp(UP, "widgets", "contributed")).toBeUndefined();
346 state.repos.find((r) => r.fullName === `${UP}/widgets`)!.push = false;
347 });
348 });
349
350 test("a name the account already uses: the fork takes a second name, then is found again", async () => {
351 const provider = providerForOrigin(app, origin);
352 if (!provider.ok) throw new Error("no provider");
353 const first = await ensureFork(app, origin, provider.value, `${UP}/thing`);
354 expect(first.ok && first.value.remote.fullName).toBe(`${ME}/${UP}-thing`);
355 expect(first.ok && first.value.existed).toBe(false);
356 const posts = asked("POST", `/repos/${UP}/thing/forks`).length;
357 const again = await ensureFork(app, origin, provider.value, `${UP}/thing`);
358 expect(again.ok && again.value.existed).toBe(true);
359 expect(asked("POST", `/repos/${UP}/thing/forks`)).toHaveLength(posts);
360 });
361
362 test("a fork under a name of its own is found through the upstream's forks, not made again", async () => {
363 const provider = providerForOrigin(app, origin);
364 if (!provider.ok) throw new Error("no provider");
365 const found = await ensureFork(app, origin, provider.value, `${UP}/renamed`);
366 expect(found.ok && found.value.existed).toBe(true);
367 expect(found.ok && found.value.remote.fullName).toBe(`${ME}/a-name-of-its-own`);
368 expect(asked("GET", `/repos/${UP}/renamed/forks`).length).toBeGreaterThan(0);
369 expect(asked("POST", `/repos/${UP}/renamed/forks`)).toHaveLength(0);
370 });
371
372 test("pushing into the fork asks for itself whether writes are paused", async () => {
373 const provider = providerForOrigin(app, origin);
374 if (!provider.ok) throw new Error("no provider");
375 const fork = await ensureFork(app, origin, provider.value, `${UP}/gears`);
376 if (!fork.ok) throw new Error("no fork");
377 const { fork: local } = await workbench(`${UP}/gears`, "paused");
378 await withEnv("FORGE_ORIGIN_DENY_PUSH", "nobody/nothing", async () => {
379 // Guarded now, and never checked: writes wait for a check.
380 clearCursor(app.db, origin.id, "guard_checked_at");
381 const pushed = await pushToFork(
382 app,
383 getOriginFor(app.db, alice.id, origin.id)!,
384 provider.value,
385 fork.value.remote,
386 repoDir(app.reposRoot, local.ownerName, local.name),
387 "paused",
388 "paused",
389 );
390 expect(!pushed.ok && pushed.error).toContain("writes through it are paused");
391 });
392 expect(await tipUp(ME, "gears", "paused")).toBeUndefined();
393 });
394
395 test("the guard is unchanged: an account that can push to a protected repository is refused", async () => {
396 await account(app, "bob");
397 proveAll(app);
398 const bob = getUserByName(app.db, "bob")!;
399 state.repos.find((r) => r.fullName === `${UP}/widgets`)!.push = true;
400 const refused = await withEnv("FORGE_ORIGIN_DENY_PUSH", `${UP}/widgets`, () =>
401 connectOrigin(app, bob, { host: fake.url, token }),
402 );
403 state.repos.find((r) => r.fullName === `${UP}/widgets`)!.push = false;
404 expect(refused.ok).toBe(false);
405 if (!refused.ok && typeof refused.error !== "string")
406 expect(refused.error.error).toBe("can_push");
407 });
408 });
test/shards.test.ts +22
@@ -686,6 +686,28 @@ describe("failingFiles", () => {
686686 expect(failingFiles(report)).toEqual(["test/b.test.ts", "test/c.test.ts"]);
687687 });
688688
689 test("an Actions job's report: the group marker is not part of the path", () => {
690 // bun under GITHUB_ACTIONS=true (Forgejo's runner sets it too), as a
691 // runner logged it: read as a path, `::group::test/b.test.ts` was run
692 // again alone, could not be found, and nothing was ever cleared.
693 const report = [
694 "bun test v1.3.14 (0d9b296a)",
695 "",
696 "::group::test/a.test.ts:",
697 "(pass) fine",
698 "",
699 "::endgroup::",
700 "",
701 "::group::test/b.test.ts:",
702 "error: expect(received).toBeUndefined()",
703 "::error file=test/b.test.ts,line=6,col=68,title=error: expect(received).toBeUndefined()::Received: true",
704 "(fail) victim [0.09ms]",
705 "",
706 "::endgroup::",
707 ].join("\n");
708 expect(failingFiles(report)).toEqual(["test/b.test.ts"]);
709 });
710
689711 test("a report that names no failing test names no file", () => {
690712 expect(failingFiles("test/a.test.ts:\nerror: cannot load\n")).toEqual([]);
691713 expect(failingFiles("(fail) before any header")).toEqual([]);
test/tui-migrate.test.ts +2 −2
@@ -130,7 +130,7 @@ beforeAll(async () => {
130130 fake = serveFakeForge(state);
131131 forgetProviders(dir);
132132 const connected = await connectOrigin(app, alice, { host: fake.url, token: secret });
133 if (!connected.ok) throw new Error(connected.error);
133 if (!connected.ok) throw new Error(JSON.stringify(connected.error));
134134
135135 const agent = await createAgent(app, alice, { username: "porter", model: "", brief: "Moves things in." });
136136 if (!agent.ok) throw new Error(String(agent.error));
@@ -289,7 +289,7 @@ describe("migrate from the terminal", () => {
289289 try {
290290 for (const [host, token] of [[fake.url, secretToken], [down.url, "tok-ghost"]] as const) {
291291 const connected = await connectOrigin(app, erin.value, { host, token });
292 if (!connected.ok) throw new Error(connected.error);
292 if (!connected.ok) throw new Error(JSON.stringify(connected.error));
293293 }
294294 down.state.rateLimitFor = 1000;
295295 await withTui(erin.value, async ({ tui, terminal }) => {
test/tui-origins-guard.test.ts added +84
@@ -0,0 +1,84 @@
1 /** The operator guard in the terminal: a paused origin says so, with its reason, and `v` checks it again. */
2
3 import { afterAll, beforeAll, describe, 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 { CHECK_WORDS } from "../src/actions/origin_words.ts";
9 import { createOrigin, getOriginFor } from "../src/core/origins.ts";
10 import { lastGuardTry } from "../src/origins/credential.ts";
11 import { createUser, getUserByName } from "../src/core/users.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 describe("a paused origin in the terminal", () => {
21 let dataDir: string;
22 let forge: Forge;
23 let before: string | undefined;
24 let userId = "";
25 let originId = "";
26
27 beforeAll(async () => {
28 before = process.env.FORGE_ORIGIN_DENY_PUSH;
29 process.env.FORGE_ORIGIN_DENY_PUSH = "system/*";
30 dataDir = await mkdtemp(join(tmpdir(), "forge-tui-guard-"));
31 forge = createApp({ dataDir, port: 0 });
32 const made = await createUser(forge.db, {
33 origin: "seed",
34 trusted: true,
35 username: "dana",
36 email: "[email protected]",
37 password: "a-good-passphrase",
38 adminIfFirst: true,
39 });
40 if (!made.ok) throw new Error(made.error);
41 userId = made.value.id;
42 const o = createOrigin(forge.db, dataDir, {
43 userId: made.value.id,
44 kind: "gitea",
45 apiUrl: "http://127.0.0.1:9/api/v1",
46 webUrl: "http://127.0.0.1:9",
47 login: "dana",
48 token: "t0ken-not-shown",
49 });
50 if (!o.ok) throw new Error(o.error);
51 originId = o.value.id;
52 });
53 afterAll(async () => {
54 if (before === undefined) delete process.env.FORGE_ORIGIN_DENY_PUSH;
55 else process.env.FORGE_ORIGIN_DENY_PUSH = before;
56 forge.close();
57 await rm(dataDir, { recursive: true, force: true });
58 });
59
60 test("the list shows writes paused and its reason; v checks again and says what it found", async () => {
61 const terminal = headlessTerminal(160, 32);
62 const identity = { sshUsername: "t", remoteAddress: "127.0.0.1", user: getUserByName(forge.db, "dana")! } as TuiIdentity;
63 const { app } = createForgeApp(forge, identity, { terminal, exitHooks: false });
64 app.start();
65 const key = async (k: string, ctrl = false) => {
66 app.dispatch({ type: "key", key: k, ctrl, alt: false, shift: false });
67 await settleApp(app);
68 };
69 try {
70 await key("k", true);
71 for (const ch of "Connected origins") await key(ch);
72 await key("enter");
73 expect(app.screen.toText()).toContain("writes paused");
74 expect(app.screen.toText()).toContain("Press v to check again");
75 await key("v");
76 for (let i = 0; i < 40 && getOriginFor(forge.db, userId, originId) !== undefined && lastGuardTry(forge, originId) === undefined; i++)
77 await Bun.sleep(100);
78 await key("k");
79 expect(app.screen.toText()).toContain(CHECK_WORDS.unchecked.slice(0, 30));
80 } finally {
81 app.stop();
82 }
83 }, 20_000);
84 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs