Public: anyone can read this; the crew can change it.
Land tdobbertin-espo's 6 pulls from the Forgejo mirror onto trunk #8979
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/8938-land-tdobbertin-espo-s-6-pulls-from-the → main 11 commits fork 2 threads · 2 unresolved
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`). | ||
| 13 | 13 | Connecting origins |
| 14 | 14 | adds `FORGE_URL` (the canonical public base URL, needed for OAuth callbacks), |
| 15 | 15 | `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 | |
| 17 | 18 | platform edge that signs people in for every app it serves, `FORGE_EDGE_TOKEN` |
| 18 | 19 | is the edge's secret: a request carrying it and `X-Plat-User` is that person, |
| 19 | 20 | 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. | ||
| 70 | 70 | |
| 71 | 71 | ## Connecting and mirroring, as a person does it |
| 72 | 72 | |
| 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. | |
| 74 | 74 | |
| 75 | 75 | A pull from an outsider's fork on the origin is someone else's code, so its |
| 76 | 76 | 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 | ||
| 198 | 198 | also offers OSC 52 copy, which reaches the user's own terminal rather than the |
| 199 | 199 | server's clipboard. |
| 200 | 200 | |
| 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 | ||
| 201 | 210 | Discovery: `github.com` is known by name. Otherwise `GET /api/v1/version` — |
| 202 | 211 | NodeInfo is the one thing here resembling a standard and no live Forgejo |
| 203 | 212 | 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, | ||
| 211 | 220 | dialling the address it judged), refuses non-http schemes, follows no redirect |
| 212 | 221 | 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 |
| 213 | 222 | 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. | |
| 215 | 228 | |
| 216 | 229 | **Refresh.** A Forgejo OAuth access token lives about an hour, so it is |
| 217 | 230 | 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 | ||
| 403 | 416 | the fork's, so long as nothing was committed on it directly (`docs/APPS.md`, |
| 404 | 417 | "Forks follow their parents"). |
| 405 | 418 | |
| 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. | |
| 413 | 451 | |
| 414 | 452 | **Review states.** A review is a _verdict_ on a pull at one commit — |
| 415 | 453 | `approved`, `changes_requested` or `commented` — stored in `review` beside |
| @@ -446,6 +484,99 @@ local client (`src/cli/`) reads them over the API, clones under | ||
| 446 | 484 | `~/src/<host>/<owner>/<repo>`, keeps them fetched, and reports |
| 447 | 485 | ahead/behind/dirty. The server never knows local state. |
| 448 | 486 | |
| 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 | ||
| 449 | 580 | ## When a token expires |
| 450 | 581 | |
| 451 | 582 | 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`. | ||
| 885 | 1016 | it wants a spawn path of its own, and "fetching git objects" was worth more |
| 886 | 1017 | than that today. |
| 887 | 1018 | - **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. | |
| 892 | 1019 | - **Contributing needs a workbench.** `contribute` mirrors your fork (and, on |
| 893 | 1020 | the push-rights path, the upstream) if it is not mirrored already, so the |
| 894 | 1021 | 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`. | ||
| 896 | 1023 | review through the origin sends the state and the body; anchored comments |
| 897 | 1024 | are still posted one at a time. Both forges accept comments inside the |
| 898 | 1025 | 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. | |
| 899 | 1029 | - **Key rotation is not built.** The sealed blob carries a `v1` version tag |
| 900 | 1030 | so it can be, but losing `secret.key` today means reconnecting the origins. |
| 901 | 1031 | - **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 [](../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( | ||
| 545 | 545 | return { red: stillRed, besideOthers: cleared, line }; |
| 546 | 546 | } |
| 547 | 547 | |
| 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?):$/; | |
| 549 | 552 | const FAILED = /^\(fail\) /; |
| 550 | 553 | |
| 551 | 554 | /** |
| 552 | 555 | * 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. | |
| 556 | 560 | */ |
| 557 | 561 | export function failingFiles(output: string): string[] { |
| 558 | 562 | const found: string[] = []; |
src/actions/branches.ts +5 −1
| @@ -30,7 +30,8 @@ import { | ||
| 30 | 30 | import { resolveOid } from "../git/merge.ts"; |
| 31 | 31 | import { repoDir } from "../git/repos.ts"; |
| 32 | 32 | 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"; | |
| 34 | 35 | import { redact, secretsIn } from "../origins/credentials.ts"; |
| 35 | 36 | import { pushToOrigin, type PushUpdate } from "../origins/git-mirror.ts"; |
| 36 | 37 | import type { WriteThroughError } from "../origins/write-through.ts"; |
| @@ -179,6 +180,9 @@ export async function originPush( | ||
| 179 | 180 | if (!link) return ok(undefined); |
| 180 | 181 | if (link.origin.userId !== actor.id) return err("origin_owner_only"); |
| 181 | 182 | 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"); | |
| 182 | 186 | |
| 183 | 187 | const provider = providerForOrigin(app, link.origin); |
| 184 | 188 | if (!provider.ok) return err("origin_unreachable"); |
src/actions/contribute.ts +19 −124
| @@ -35,7 +35,6 @@ | ||
| 35 | 35 | import { requireVerified } from "./verify.ts"; |
| 36 | 36 | import type { App } from "../app.ts"; |
| 37 | 37 | import { |
| 38 | findOriginFork, | |
| 39 | 38 | getOriginFor, |
| 40 | 39 | getOriginRepo, |
| 41 | 40 | mirrorOf, |
| @@ -50,43 +49,32 @@ import { isSafeRef } from "../git/guard.ts"; | ||
| 50 | 49 | import { repoDir } from "../git/repos.ts"; |
| 51 | 50 | import type { CommitResult } from "../git/write.ts"; |
| 52 | 51 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 53 | import { providerForOrigin } from "../origins/credential.ts"; | |
| 52 | import { providerForOrigin, writesPaused } from "../origins/credential.ts"; | |
| 54 | 53 | import { fetchRefFrom } from "../origins/git-mirror.ts"; |
| 55 | 54 | import { importIssue } from "../origins/import.ts"; |
| 56 | 55 | import { settled, type MirrorJob } from "../origins/jobs.ts"; |
| 57 | 56 | import { mirrorRepo, startMirror } from "../origins/mirror.ts"; |
| 57 | import { ensureFork, forkDeadline } from "../origins/fork-route.ts"; | |
| 58 | ||
| 59 | import { denied } from "../origins/operator.ts"; | |
| 58 | 60 | import type { Provider, RemoteRepo } from "../origins/types.ts"; |
| 59 | 61 | import type { WriteThroughError } from "../origins/write-through.ts"; |
| 60 | 62 | import { createBranch } from "./branches.ts"; |
| 61 | 63 | import { editFiles, type EditFile, type EditFilesError } from "./contents.ts"; |
| 62 | 64 | import { requireUser } from "./access.ts"; |
| 63 | 65 | |
| 66 | export { forkDeadline }; | |
| 67 | ||
| 64 | 68 | // ---- forking at the origin ---- |
| 65 | 69 | |
| 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 | ||
| 84 | 70 | export type ForkOriginError = |
| 85 | 71 | | "unauthorized" |
| 86 | 72 | | "unverified" |
| 87 | 73 | | "not_found" |
| 88 | 74 | | "origin_unreachable" |
| 89 | 75 | | "origin_refused" |
| 76 | /** The connection is not verified against the operator's rules yet. */ | |
| 77 | | "origin_paused" | |
| 90 | 78 | /** The origin took the request and the fork never appeared in time. */ |
| 91 | 79 | | "fork_timeout"; |
| 92 | 80 | |
| @@ -135,6 +123,7 @@ export async function forkOrigin( | ||
| 135 | 123 | // Somebody else's connected account does not exist for you (invariant 9). |
| 136 | 124 | const origin = getOriginFor(app.db, user.value.id, originId); |
| 137 | 125 | if (!origin) return err("not_found"); |
| 126 | if (writesPaused(app, origin.id) !== undefined) return err("origin_paused"); | |
| 138 | 127 | const provider = providerForOrigin(app, origin); |
| 139 | 128 | if (!provider.ok) return err("origin_unreachable"); |
| 140 | 129 | |
| @@ -144,62 +133,15 @@ export async function forkOrigin( | ||
| 144 | 133 | upstream.error.error === "not_found" ? "not_found" : "origin_unreachable", |
| 145 | 134 | ); |
| 146 | 135 | |
| 147 | const found = await existingFork( | |
| 136 | const forked = await ensureFork( | |
| 148 | 137 | app, |
| 149 | 138 | origin, |
| 150 | 139 | provider.value, |
| 151 | 140 | upstream.value.fullName, |
| 141 | options.waitMs ?? forkDeadline.ms, | |
| 152 | 142 | ); |
| 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; | |
| 203 | 145 | |
| 204 | 146 | app.bus.append({ |
| 205 | 147 | kind: "repo.forked", |
| @@ -251,58 +193,6 @@ export async function forkOrigin( | ||
| 251 | 193 | return ok({ remote, mirror, job, existed }); |
| 252 | 194 | } |
| 253 | 195 | |
| 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 | ||
| 306 | 196 | /** The local mirror of a remote repo at this origin, or undefined. */ |
| 307 | 197 | function localMirror( |
| 308 | 198 | app: App, |
| @@ -381,6 +271,7 @@ export async function contribute( | ||
| 381 | 271 | if (!proven.ok) return proven; |
| 382 | 272 | const origin = getOriginFor(app.db, user.value.id, input.originId); |
| 383 | 273 | if (!origin) return err("not_found"); |
| 274 | if (writesPaused(app, origin.id) !== undefined) return err("origin_paused"); | |
| 384 | 275 | const branch = input.branch.trim(); |
| 385 | 276 | if (!isSafeRef(branch) || branch.startsWith("refs/")) |
| 386 | 277 | return err("invalid_branch"); |
| @@ -404,7 +295,11 @@ export async function contribute( | ||
| 404 | 295 | if (!listed.value.some((b) => b.name === base)) return err("unknown_base"); |
| 405 | 296 | } |
| 406 | 297 | |
| 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 | |
| 408 | 303 | ? await contributeDirect(app, user.value, origin, upstream.value, { |
| 409 | 304 | ...input, |
| 410 | 305 | branch, |
src/actions/origin_words.ts +9
| @@ -30,3 +30,12 @@ export function connectWords(error: string): string { | ||
| 30 | 30 | }; |
| 31 | 31 | return words[error] ?? "That connection could not be made."; |
| 32 | 32 | } |
| 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 { | ||
| 28 | 28 | findOrigin, |
| 29 | 29 | getOriginById, |
| 30 | 30 | getOriginFor, |
| 31 | hostOf, | |
| 31 | 32 | siteOf, |
| 32 | 33 | linkRepoToOrigin, |
| 33 | 34 | listDetachedMirrors, |
| @@ -55,7 +56,13 @@ import type { User } from "../core/users.ts"; | ||
| 55 | 56 | import { repoDir } from "../git/repos.ts"; |
| 56 | 57 | import { bustOrigin } from "../view/cache.ts"; |
| 57 | 58 | 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"; | |
| 59 | 66 | import { safeRemoteUrl } from "../origins/credentials.ts"; |
| 60 | 67 | import { configureMirror, mirrorRemoteUrl } from "../origins/git-mirror.ts"; |
| 61 | 68 | import { getJob, type MirrorJob } from "../origins/jobs.ts"; |
| @@ -85,8 +92,10 @@ import { | ||
| 85 | 92 | type AppRow, |
| 86 | 93 | type FlowRow, |
| 87 | 94 | } from "../origins/oauth.ts"; |
| 95 | import { checkCredential, type GuardRefusal } from "../origins/operator.ts"; | |
| 88 | 96 | import { forgetProvider, providerFor } from "../origins/providers/index.ts"; |
| 89 | 97 | import { forgetTokenReach } from "../origins/token-reach.ts"; |
| 98 | import { scopeWords } from "../origins/providers/http.ts"; | |
| 90 | 99 | import { |
| 91 | 100 | requestSyncNow, |
| 92 | 101 | syncOrigin, |
| @@ -96,6 +105,7 @@ import { open } from "../kernel/secret.ts"; | ||
| 96 | 105 | import { |
| 97 | 106 | staticCredential, |
| 98 | 107 | type OriginKind, |
| 108 | type RemoteRepo, | |
| 99 | 109 | } from "../origins/types.ts"; |
| 100 | 110 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 101 | 111 | import { |
| @@ -158,13 +168,16 @@ export interface ConnectInput { | ||
| 158 | 168 | * Connect with a pasted token — the fallback path, and the only one that |
| 159 | 169 | * works with no OAuth application registered anywhere. The token is |
| 160 | 170 | * 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. | |
| 162 | 175 | */ |
| 163 | 176 | export async function connectOrigin( |
| 164 | 177 | app: App, |
| 165 | 178 | actor: User | undefined, |
| 166 | 179 | input: ConnectInput, |
| 167 | ): Promise<Result<Connected, ConnectError>> { | |
| 180 | ): Promise<Result<Connected, ConnectError | GuardRefusal | ScopeRefusal>> { | |
| 168 | 181 | const user = requireUser(actor); |
| 169 | 182 | if (!user.ok) return user; |
| 170 | 183 | if (tooManyAttempts(app, user.value.id)) return err("rate_limited"); |
| @@ -177,7 +190,7 @@ export async function connectOrigin( | ||
| 177 | 190 | ); |
| 178 | 191 | if (!found.ok) return err(found.error); |
| 179 | 192 | |
| 180 | const identity = await providerFor( | |
| 193 | const provider = providerFor( | |
| 181 | 194 | staticCredential({ |
| 182 | 195 | kind: found.value.kind, |
| 183 | 196 | apiUrl: found.value.apiUrl, |
| @@ -185,11 +198,24 @@ export async function connectOrigin( | ||
| 185 | 198 | software: softwareOf(found.value.software), |
| 186 | 199 | token: input.token.trim(), |
| 187 | 200 | }), |
| 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 | }); | |
| 190 | 213 | return err( |
| 191 | 214 | identity.error.error === "unreachable" ? "unreachable" : "bad_token", |
| 192 | 215 | ); |
| 216 | } | |
| 217 | const guard = await checkCredential(provider, identity.value); | |
| 218 | if (!guard.ok) return err(guard.error); | |
| 193 | 219 | |
| 194 | 220 | const stored = storeOrigin(app, user.value, { |
| 195 | 221 | kind: found.value.kind, |
| @@ -201,9 +227,15 @@ export async function connectOrigin( | ||
| 201 | 227 | software: softwareOf(found.value.software), |
| 202 | 228 | }); |
| 203 | 229 | if (!stored.ok) return stored; |
| 230 | if (guard.value.checked) noteGuardPassed(app, stored.value.id); | |
| 204 | 231 | return ok({ |
| 205 | 232 | ...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 | ), | |
| 207 | 239 | }); |
| 208 | 240 | } |
| 209 | 241 | |
| @@ -216,7 +248,8 @@ export type ReplaceTokenError = | ||
| 216 | 248 | | "rate_limited" |
| 217 | 249 | | "bad_token" |
| 218 | 250 | | "unreachable" |
| 219 | | "other_account"; | |
| 251 | | "other_account" | |
| 252 | | GuardRefusal["error"]; | |
| 220 | 253 | |
| 221 | 254 | /** |
| 222 | 255 | * Put a new pasted token on an origin that is already connected — one that |
| @@ -239,7 +272,7 @@ export async function replaceOriginToken( | ||
| 239 | 272 | const secret = token.trim(); |
| 240 | 273 | if (secret === "") return err("bad_token"); |
| 241 | 274 | |
| 242 | const identity = await providerFor( | |
| 275 | const provider = providerFor( | |
| 243 | 276 | staticCredential({ |
| 244 | 277 | kind: origin.kind, |
| 245 | 278 | apiUrl: origin.apiUrl, |
| @@ -247,13 +280,18 @@ export async function replaceOriginToken( | ||
| 247 | 280 | software: origin.software, |
| 248 | 281 | token: secret, |
| 249 | 282 | }), |
| 250 | ).whoami(); | |
| 283 | ); | |
| 284 | const identity = await provider.whoami(); | |
| 251 | 285 | if (!identity.ok) |
| 252 | 286 | return err( |
| 253 | 287 | identity.error.error === "unreachable" ? "unreachable" : "bad_token", |
| 254 | 288 | ); |
| 255 | 289 | if (identity.value.login.toLowerCase() !== origin.login.toLowerCase()) |
| 256 | 290 | 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); | |
| 257 | 295 | |
| 258 | 296 | renewOrigin(app.db, app.config.dataDir, origin.id, { |
| 259 | 297 | token: secret, |
| @@ -261,6 +299,7 @@ export async function replaceOriginToken( | ||
| 261 | 299 | }); |
| 262 | 300 | forgetProvider({ dataDir: app.config.dataDir, originId: origin.id }); |
| 263 | 301 | forgetTokenReach(app.config.dataDir, origin.id); |
| 302 | if (guard.value.checked) noteGuardPassed(app, origin.id); | |
| 264 | 303 | app.bus.append({ |
| 265 | 304 | kind: "origin.connected", |
| 266 | 305 | actorId: user.value.id, |
| @@ -277,6 +316,27 @@ export async function replaceOriginToken( | ||
| 277 | 316 | return ok(renewed); |
| 278 | 317 | } |
| 279 | 318 | |
| 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 | ||
| 280 | 340 | /** A connected origin, and the frozen mirrors connecting it brought back. */ |
| 281 | 341 | export type Connected = Origin & { |
| 282 | 342 | readonly reattached: readonly Reattachment[]; |
| @@ -406,6 +466,34 @@ export function listUserOrigins(app: App, actor: User): Origin[] { | ||
| 406 | 466 | return listOrigins(app.db, actor.id); |
| 407 | 467 | } |
| 408 | 468 | |
| 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 | ||
| 409 | 497 | // ---- OAuth ---- |
| 410 | 498 | |
| 411 | 499 | export type OAuthStartError = |
| @@ -726,7 +814,7 @@ async function completeConnect( | ||
| 726 | 814 | expiresAt: string | null; |
| 727 | 815 | }, |
| 728 | 816 | ): Promise<{ status: "done" | "failed"; detail: string; originId?: string }> { |
| 729 | const identity = await providerFor( | |
| 817 | const provider = providerFor( | |
| 730 | 818 | staticCredential({ |
| 731 | 819 | kind: registration.kind, |
| 732 | 820 | apiUrl: registration.apiUrl, |
| @@ -735,9 +823,31 @@ async function completeConnect( | ||
| 735 | 823 | software: registration.software ?? "", |
| 736 | 824 | token: tokens.accessToken, |
| 737 | 825 | }), |
| 738 | ).whoami(); | |
| 826 | ); | |
| 827 | const identity = await provider.whoami(); | |
| 739 | 828 | 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 | } | |
| 741 | 851 | |
| 742 | 852 | const stored = storeOrigin(app, user, { |
| 743 | 853 | kind: registration.kind, |
| @@ -752,7 +862,8 @@ async function completeConnect( | ||
| 752 | 862 | }); |
| 753 | 863 | if (!stored.ok) |
| 754 | 864 | 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); | |
| 756 | 867 | return { status: "done", detail: "", originId: stored.value.id }; |
| 757 | 868 | } |
| 758 | 869 | |
| @@ -991,7 +1102,12 @@ async function afterConnect( | ||
| 991 | 1102 | app: App, |
| 992 | 1103 | user: User, |
| 993 | 1104 | origin: Origin, |
| 1105 | listed: readonly RemoteRepo[] | null = null, | |
| 994 | 1106 | ): 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); | |
| 995 | 1111 | const out: Reattachment[] = []; |
| 996 | 1112 | for (const mirror of listDetachedMirrors(app.db, { |
| 997 | 1113 | site: siteOf(origin.webUrl), |
src/actions/pulls.ts +76 −13
| @@ -81,9 +81,34 @@ import { | ||
| 81 | 81 | } from "./access.ts"; |
| 82 | 82 | import { canWrite } from "../core/repos.ts"; |
| 83 | 83 | import { refreshPullHead as refreshHead } from "../core/following.ts"; |
| 84 | import { getOriginRepo } from "../core/origins.ts"; | |
| 84 | 85 | import { watchPullsSoon } from "../origins/hook.ts"; |
| 85 | import { carryBranchThrough } from "../origins/push-through.ts"; | |
| 86 | 86 | 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 | } | |
| 87 | 112 | |
| 88 | 113 | export interface OpenPullInput { |
| 89 | 114 | title: string; |
| @@ -135,10 +160,13 @@ export async function openPull( | ||
| 135 | 160 | if (title === "") return err("empty_title"); |
| 136 | 161 | // A native fork of this mirror proposing upstream: its branch is |
| 137 | 162 | // 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>`. | |
| 140 | 167 | const head = input.headRepo; |
| 141 | 168 | let carriedAs = input.headBranch; |
| 169 | let pullHead = input.headBranch; | |
| 142 | 170 | if (head !== undefined && head.id !== baseRepo.id) { |
| 143 | 171 | if ( |
| 144 | 172 | !canRead(app.db, head, user.value) || |
| @@ -152,15 +180,50 @@ export async function openPull( | ||
| 152 | 180 | // name. |
| 153 | 181 | if (input.headBranch === input.baseBranch) |
| 154 | 182 | 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 | } | |
| 164 | 227 | } |
| 165 | 228 | const created = await through.value.provider.createPull( |
| 166 | 229 | through.value.fullName, |
| @@ -168,7 +231,7 @@ export async function openPull( | ||
| 168 | 231 | title, |
| 169 | 232 | body: input.body ?? "", |
| 170 | 233 | base: input.baseBranch, |
| 171 | head: carriedAs, | |
| 234 | head: pullHead, | |
| 172 | 235 | }, |
| 173 | 236 | ); |
| 174 | 237 | if (!created.ok) return err(refusal(created.error.error)); |
src/core/origins.ts +5 −1
| @@ -219,10 +219,14 @@ export function storeRefreshed( | ||
| 219 | 219 | originId: string, |
| 220 | 220 | next: { token: string; refresh?: string; expiresAt?: string | null }, |
| 221 | 221 | ): 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. | |
| 222 | 225 | db.query( |
| 223 | 226 | `UPDATE origin SET token_enc = ?1, |
| 224 | 227 | 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 | |
| 226 | 230 | WHERE id = ?4`, |
| 227 | 231 | ).run( |
| 228 | 232 | seal(dataDir, next.token, originId), |
src/http/api.ts +12
| @@ -644,6 +644,18 @@ export function originAware(error: string, status = 422): Response { | ||
| 644 | 644 | 409, |
| 645 | 645 | "the origin refused this change; nothing was changed here either", |
| 646 | 646 | ); |
| 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 | ); | |
| 647 | 659 | return respondAccess(error, status); |
| 648 | 660 | } |
| 649 | 661 |
src/http/api/user.ts +9 −5
| @@ -8,7 +8,7 @@ | ||
| 8 | 8 | import { contribute, forkOrigin } from "../../actions/contribute.ts"; |
| 9 | 9 | import { CAPPED_WORDS } from "../../actions/mail.ts"; |
| 10 | 10 | 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"; | |
| 12 | 12 | import { addSshKey, changePassword, deleteSshKey, updateProfile } from "../../actions/users.ts"; |
| 13 | 13 | import { VERIFY_WORDS } from "../../actions/verify_words.ts"; |
| 14 | 14 | import { resendVerification } from "../../actions/verify.ts"; |
| @@ -260,12 +260,16 @@ export function userRoutes(router: Router<Handler>): void { | ||
| 260 | 260 | : {}), |
| 261 | 261 | token: str(input.token), |
| 262 | 262 | }); |
| 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); | |
| 264 | 267 | 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), | |
| 268 | 271 | ); |
| 272 | } | |
| 269 | 273 | // The frozen mirrors connecting this account brought back, and the ones |
| 270 | 274 | // it could not, each with its reason. |
| 271 | 275 | return json( |
src/http/settings_routes.ts +4 −1
| @@ -102,6 +102,7 @@ import { | ||
| 102 | 102 | originAppCreate, |
| 103 | 103 | originAppDelete, |
| 104 | 104 | originConnectToken, |
| 105 | originCheckSubmit, | |
| 105 | 106 | originDisconnect, |
| 106 | 107 | originOAuthStart, |
| 107 | 108 | originSyncSubmit, |
| @@ -240,7 +241,7 @@ export function settingsRoutes(router: Router<Handler>, knownPatterns: () => rea | ||
| 240 | 241 | ? "error=state" |
| 241 | 242 | : done.value.status === "done" |
| 242 | 243 | ? "connected=1" |
| 243 | : `error=${encodeURIComponent(done.value.detail.slice(0, 120))}`; | |
| 244 | : `error=${encodeURIComponent(done.value.detail.slice(0, 300))}`; | |
| 244 | 245 | return new Response(null, { |
| 245 | 246 | status: 303, |
| 246 | 247 | headers: { location: `/settings/origins?${query}` }, |
| @@ -263,6 +264,8 @@ export function settingsRoutes(router: Router<Handler>, knownPatterns: () => rea | ||
| 263 | 264 | // shape for the same reason `flows` and `apps` are. |
| 264 | 265 | router.add("GET", "/settings/origins/:id/mirror/:job", mirrorJobPage); |
| 265 | 266 | 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); | |
| 266 | 269 | router.add("POST", "/settings/origins/:id/delete", originDisconnect); |
| 267 | 270 | router.add("POST", "/settings/origins/:id/token", originReplaceToken); |
| 268 | 271 | router.add("GET", "/settings/keys", keysPage); |
src/main.ts +8 −1
| @@ -37,6 +37,7 @@ import { drainStreams } from "./http/sse.ts"; | ||
| 37 | 37 | import { serve } from "./http/server.ts"; |
| 38 | 38 | import { checkDue } from "./actions/upstream_follow.ts"; |
| 39 | 39 | import { runSync } from "./origins/sync.ts"; |
| 40 | import { runGuardSweep } from "./origins/credential.ts"; | |
| 40 | 41 | import { startForgeSsh } from "./tui/ssh.ts"; |
| 41 | 42 | import { AppsRuntime } from "./apps/runtime.ts"; |
| 42 | 43 | import { listApps } from "./core/apps.ts"; |
| @@ -572,8 +573,14 @@ function positive(value: string | undefined, fallback: number): number { | ||
| 572 | 573 | // pushed to: an origin is due every ten minutes by default, and the tick only |
| 573 | 574 | // asks who is due — so this costs one indexed read a minute when nothing is |
| 574 | 575 | // 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); | |
| 576 | 582 | runSync(app); // one pass on boot: a forge that was down has fetching to do |
| 583 | void runGuardSweep(app); | |
| 577 | 584 | |
| 578 | 585 | // Which green pulls no longer merge as they stand, so the asks can leave |
| 579 | 586 | // them out (`crew/stale_asks.ts`): git answers, off the request path, and an |
src/origins/credential.ts +203 −2
| @@ -17,9 +17,14 @@ | ||
| 17 | 17 | |
| 18 | 18 | import type { App } from "../app.ts"; |
| 19 | 19 | import { |
| 20 | cacheOriginRepos, | |
| 21 | clearCursor, | |
| 22 | getCursor, | |
| 20 | 23 | getOriginById, |
| 24 | listAllOrigins, | |
| 21 | 25 | markNeedsReauth, |
| 22 | 26 | sealedSecrets, |
| 27 | setCursor, | |
| 23 | 28 | storeRefreshed, |
| 24 | 29 | type Origin, |
| 25 | 30 | } from "../core/origins.ts"; |
| @@ -27,8 +32,14 @@ import { createLog } from "../kernel/log.ts"; | ||
| 27 | 32 | import { open } from "../kernel/secret.ts"; |
| 28 | 33 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 29 | 34 | 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"; | |
| 32 | 43 | |
| 33 | 44 | const log = createLog("origins"); |
| 34 | 45 | |
| @@ -60,6 +71,12 @@ export function credentialFor( | ||
| 60 | 71 | authKind: origin.authKind, |
| 61 | 72 | software: origin.software, |
| 62 | 73 | 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 | }, | |
| 63 | 80 | }); |
| 64 | 81 | } |
| 65 | 82 | |
| @@ -189,6 +206,190 @@ async function refreshOnce(app: App, originId: string): Promise<void> { | ||
| 189 | 206 | actorId: origin.userId, |
| 190 | 207 | payload: { originId, host: origin.webUrl }, |
| 191 | 208 | }); |
| 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)); | |
| 192 | 393 | } |
| 193 | 394 | |
| 194 | 395 | 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> { | ||
| 54 | 54 | ); |
| 55 | 55 | } |
| 56 | 56 | |
| 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 | ||
| 57 | 76 | /** A development instance (`FORGE_DEV=1`, also set for the test suite). */ |
| 58 | 77 | function isDev(env: Record<string, string | undefined>): boolean { |
| 59 | 78 | return env.FORGE_DEV === "1"; |
| @@ -147,11 +166,7 @@ export function allowedOrigin( | ||
| 147 | 166 | if (url.hostname === "") return undefined; |
| 148 | 167 | if (url.protocol !== "https:" && url.protocol !== "http:") return undefined; |
| 149 | 168 | |
| 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; | |
| 155 | 170 | if (isDev(env) && isLoopback(url.hostname)) return url.origin; |
| 156 | 171 | if (url.protocol !== "https:") return undefined; |
| 157 | 172 | if (isPrivateLiteral(url.hostname)) return undefined; |
| @@ -294,12 +309,9 @@ function exempt( | ||
| 294 | 309 | href: string, |
| 295 | 310 | env: Record<string, string | undefined>, |
| 296 | 311 | ): boolean { |
| 297 | const url = new URL(href); | |
| 298 | const allowed = allowedHosts(env); | |
| 299 | 312 | 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)) | |
| 303 | 315 | ); |
| 304 | 316 | } |
| 305 | 317 |
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"; | ||
| 25 | 25 | import { ZERO_OID } from "../git/hooks.ts"; |
| 26 | 26 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 27 | 27 | import { trustedHosts } from "./trusted-hosts.ts"; |
| 28 | import { operatorAllowed } from "./discover.ts"; | |
| 28 | 29 | import { vetHost, type OutboundOptions } from "../kernel/outbound.ts"; |
| 29 | 30 | import { pinFor, remoteGitEnv } from "../git/remote-env.ts"; |
| 30 | 31 | import { redact, safeRemoteUrl, secretsIn } from "./credentials.ts"; |
| @@ -66,7 +67,13 @@ export async function vettedEnv( | ||
| 66 | 67 | protocols?: string, |
| 67 | 68 | ): Promise<Result<Record<string, string>, GitMirrorError>> { |
| 68 | 69 | 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 }); | |
| 70 | 77 | if (!vetted.ok) |
| 71 | 78 | return err({ |
| 72 | 79 | message: vetted.error === "refused" ? MIRROR_REFUSED : MIRROR_UNRESOLVED, |
src/origins/oauth.ts +21
| @@ -527,6 +527,27 @@ export function targetForShortCode( | ||
| 527 | 527 | return opened.ok ? opened.value : undefined; |
| 528 | 528 | } |
| 529 | 529 | |
| 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 | ||
| 530 | 551 | export function getFlow(db: Database, flowId: string): FlowRow | undefined { |
| 531 | 552 | const row = db |
| 532 | 553 | .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 { | ||
| 41 | 41 | login, |
| 42 | 42 | displayName: asString(field(result.value.data, "displayName")) || login, |
| 43 | 43 | 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, | |
| 44 | 46 | }); |
| 45 | 47 | } |
| 46 | 48 | |
| @@ -81,6 +83,10 @@ export class ForgeProvider extends ForgejoProvider { | ||
| 81 | 83 | default_branch: field(raw, "defaultBranch"), |
| 82 | 84 | clone_url: `${web}.git`, |
| 83 | 85 | 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 }, | |
| 84 | 90 | }), |
| 85 | 91 | ); |
| 86 | 92 | } |
src/origins/providers/forgejo.ts +89 −5
| @@ -43,6 +43,7 @@ import type { | ||
| 43 | 43 | RemoteReview, |
| 44 | 44 | RemoteReviewState, |
| 45 | 45 | RemoteTreeEntry, |
| 46 | RepoWalk, | |
| 46 | 47 | } from "../types.ts"; |
| 47 | 48 | import { |
| 48 | 49 | NO_ACCOUNT, |
| @@ -141,6 +142,9 @@ export class ForgejoProvider implements Provider { | ||
| 141 | 142 | htmlUrl: |
| 142 | 143 | asString(field(result.value.data, "html_url")) || |
| 143 | 144 | `${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")), | |
| 144 | 148 | }); |
| 145 | 149 | } |
| 146 | 150 | |
| @@ -184,6 +188,60 @@ export class ForgejoProvider implements Provider { | ||
| 184 | 188 | }); |
| 185 | 189 | } |
| 186 | 190 | |
| 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 | ||
| 187 | 245 | async getRepo(fullName: string): Promise<ProviderResult<RemoteRepo>> { |
| 188 | 246 | const named = splitFullName(fullName); |
| 189 | 247 | if (!named) return fail("not_found", `not a repository name: ${fullName}`); |
| @@ -719,9 +777,10 @@ export class ForgejoProvider implements Provider { | ||
| 719 | 777 | ): Promise<ProviderResult<RemoteIssue>> { |
| 720 | 778 | const named = splitFullName(fullName); |
| 721 | 779 | 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`). | |
| 725 | 784 | const result = await this.api.call({ |
| 726 | 785 | path: `/repos/${seg(named.owner)}/${seg(named.name)}/pulls`, |
| 727 | 786 | method: "POST", |
| @@ -736,13 +795,38 @@ export class ForgejoProvider implements Provider { | ||
| 736 | 795 | return succeed(toPull(result.value.data)); |
| 737 | 796 | } |
| 738 | 797 | |
| 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>> { | |
| 740 | 822 | const named = splitFullName(fullName); |
| 741 | 823 | if (!named) return fail("not_found", `not a repository name: ${fullName}`); |
| 742 | 824 | const result = await this.api.call({ |
| 743 | 825 | path: `/repos/${seg(named.owner)}/${seg(named.name)}/forks`, |
| 744 | 826 | 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 }, | |
| 746 | 830 | }); |
| 747 | 831 | if (!result.ok) return result; |
| 748 | 832 | return succeed(toRepo(result.value.data)); |
src/origins/providers/github.ts +65 −2
| @@ -31,6 +31,7 @@ import type { | ||
| 31 | 31 | RemoteReviewState, |
| 32 | 32 | RemoteTreeEntry, |
| 33 | 33 | TokenReach, |
| 34 | RepoWalk, | |
| 34 | 35 | } from "../types.ts"; |
| 35 | 36 | import { |
| 36 | 37 | NO_ACCOUNT, |
| @@ -107,6 +108,7 @@ export class GitHubProvider implements Provider { | ||
| 107 | 108 | htmlUrl: |
| 108 | 109 | asString(field(result.value.data, "html_url")) || |
| 109 | 110 | `${this.webUrl}/${login}`, |
| 111 | siteAdmin: asBool(field(result.value.data, "site_admin")), | |
| 110 | 112 | }); |
| 111 | 113 | } |
| 112 | 114 | |
| @@ -150,6 +152,42 @@ export class GitHubProvider implements Provider { | ||
| 150 | 152 | }); |
| 151 | 153 | } |
| 152 | 154 | |
| 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 | ||
| 153 | 191 | async getRepo(fullName: string): Promise<ProviderResult<RemoteRepo>> { |
| 154 | 192 | const named = splitFullName(fullName); |
| 155 | 193 | if (!named) return fail("not_found", `not a repository name: ${fullName}`); |
| @@ -692,13 +730,38 @@ export class GitHubProvider implements Provider { | ||
| 692 | 730 | return succeed(toPull(result.value.data)); |
| 693 | 731 | } |
| 694 | 732 | |
| 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>> { | |
| 696 | 757 | const named = splitFullName(fullName); |
| 697 | 758 | if (!named) return fail("not_found", `not a repository name: ${fullName}`); |
| 698 | 759 | const result = await this.api.call({ |
| 699 | 760 | path: `/repos/${seg(named.owner)}/${seg(named.name)}/forks`, |
| 700 | 761 | 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 }, | |
| 702 | 765 | }); |
| 703 | 766 | if (!result.ok) return result; |
| 704 | 767 | return succeed(toRepo(result.value.data)); |
src/origins/providers/http.ts +71 −2
| @@ -36,6 +36,47 @@ export const fail = ( | ||
| 36 | 36 | error: { error, message, ...(retryAt === undefined ? {} : { retryAt }) }, |
| 37 | 37 | }); |
| 38 | 38 | |
| 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 | ||
| 39 | 80 | export const succeed = <T>(value: T): ProviderResult<T> => ({ |
| 40 | 81 | ok: true, |
| 41 | 82 | value, |
| @@ -175,6 +216,14 @@ export class ApiClient { | ||
| 175 | 216 | const method = request.method ?? "GET"; |
| 176 | 217 | const maxBytes = options.maxBytes ?? this.maxBytes; |
| 177 | 218 | |
| 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 | ||
| 178 | 227 | // Resolved per call: an OAuth token may have been refreshed since the |
| 179 | 228 | // last one, and a token that cannot be resolved must not become a |
| 180 | 229 | // credential-less request that reads as "not found". |
| @@ -252,8 +301,28 @@ export class ApiClient { | ||
| 252 | 301 | |
| 253 | 302 | if (response.status === 401) |
| 254 | 303 | 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 | } | |
| 257 | 326 | if (response.status === 404) |
| 258 | 327 | return fail("not_found", "the origin has no such thing"); |
| 259 | 328 | 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"; | ||
| 37 | 37 | import { fetchInto, resolveOid, writeRef } from "../git/merge.ts"; |
| 38 | 38 | import { repoDir } from "../git/repos.ts"; |
| 39 | 39 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 40 | import { providerForOrigin } from "./credential.ts"; | |
| 40 | import { providerForOrigin, writesPaused } from "./credential.ts"; | |
| 41 | 41 | import { redact, secretsIn } from "./credentials.ts"; |
| 42 | 42 | import { pushToOrigin, snapshotRefs } from "./git-mirror.ts"; |
| 43 | import { denied } from "./operator.ts"; | |
| 43 | 44 | |
| 44 | 45 | /** A push-through must not outlive a pusher's patience. */ |
| 45 | 46 | const TIMEOUT_MS = 60_000; |
| @@ -47,6 +48,8 @@ const TIMEOUT_MS = 60_000; | ||
| 47 | 48 | export interface PushThroughRefusal { |
| 48 | 49 | /** Shown to the pusher verbatim; already scrubbed of credentials. */ |
| 49 | 50 | readonly message: string; |
| 51 | /** The origin itself refused the push (no rights there), not this forge. */ | |
| 52 | readonly byOrigin?: boolean; | |
| 50 | 53 | } |
| 51 | 54 | |
| 52 | 55 | /** |
| @@ -129,6 +132,10 @@ export async function carryBranchThrough( | ||
| 129 | 132 | |
| 130 | 133 | const ZERO_OID_STRING = "0".repeat(40); |
| 131 | 134 | |
| 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 | ||
| 132 | 139 | /** The origin's refusal, in its words, with the credential kept out of them. */ |
| 133 | 140 | function refusedBy( |
| 134 | 141 | link: NonNullable<ReturnType<typeof originForRepo>>, |
| @@ -145,6 +152,7 @@ function refusedBy( | ||
| 145 | 152 | `${link.fullName} on ${link.origin.webUrl} refused this push: ${message}`, |
| 146 | 153 | ...secretsIn(authEnv), |
| 147 | 154 | ), |
| 155 | byOrigin: true, | |
| 148 | 156 | }; |
| 149 | 157 | } |
| 150 | 158 | |
| @@ -191,6 +199,14 @@ async function wayThrough( | ||
| 191 | 199 | message: "this mirror has no upstream clone URL; re-mirror it", |
| 192 | 200 | }); |
| 193 | 201 | |
| 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 | ||
| 194 | 210 | const provider = providerForOrigin(app, link.origin); |
| 195 | 211 | if (!provider.ok) |
| 196 | 212 | return err({ |
src/origins/token-reach.ts +6
| @@ -64,6 +64,12 @@ export function replaceTokenMessage(error: string): string { | ||
| 64 | 64 | unreachable: "That instance could not be reached.", |
| 65 | 65 | rate_limited: "Too many connect attempts. Wait a few minutes.", |
| 66 | 66 | 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.", | |
| 67 | 73 | }; |
| 68 | 74 | return words[error] ?? "That token could not be put on this origin."; |
| 69 | 75 | } |
src/origins/types.ts +55 −1
| @@ -241,6 +241,13 @@ export interface RemoteIdentity { | ||
| 241 | 241 | readonly login: string; |
| 242 | 242 | readonly displayName: string; |
| 243 | 243 | 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; | |
| 244 | 251 | } |
| 245 | 252 | |
| 246 | 253 | export type ProviderError = |
| @@ -256,6 +263,26 @@ export interface ProviderFailure { | ||
| 256 | 263 | readonly message: string; |
| 257 | 264 | /** For `rate_limited`: when to try again. */ |
| 258 | 265 | 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"; | |
| 259 | 286 | } |
| 260 | 287 | |
| 261 | 288 | /** A page of a list, with the cursor for the next page (null at the end). */ |
| @@ -296,6 +323,18 @@ export interface Provider { | ||
| 296 | 323 | */ |
| 297 | 324 | tokenReach?(): Promise<ProviderResult<TokenReach>>; |
| 298 | 325 | 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>>; | |
| 299 | 338 | getRepo(fullName: string): Promise<ProviderResult<RemoteRepo>>; |
| 300 | 339 | |
| 301 | 340 | // ---- reads for browsing a repository we have not mirrored ---- |
| @@ -436,12 +475,21 @@ export interface Provider { | ||
| 436 | 475 | body: string; |
| 437 | 476 | }, |
| 438 | 477 | ): 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[]>>; | |
| 439 | 483 | /** |
| 440 | 484 | * Fork a repo into the credential's account. GitHub creates the fork |
| 441 | 485 | * *asynchronously* and answers 202 with a repo that does not exist yet, so |
| 442 | 486 | * a caller must poll `getRepo` until it does (`actions/contribute.ts`). |
| 443 | 487 | */ |
| 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>>; | |
| 445 | 493 | /** |
| 446 | 494 | * Archive a repo (`PATCH /repos/{o}/{r}` with `archived: true`): read-only |
| 447 | 495 | * on the origin from then on. Needs admin there; without it the answer is |
| @@ -499,6 +547,12 @@ export interface ProviderCredential { | ||
| 499 | 547 | * credential is gone or unrefreshable, and the origin needs reconnecting. |
| 500 | 548 | */ |
| 501 | 549 | 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>; | |
| 502 | 556 | } |
| 503 | 557 | |
| 504 | 558 | /** 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"; | ||
| 15 | 15 | import type { Repo } from "../core/repos.ts"; |
| 16 | 16 | import type { User } from "../core/users.ts"; |
| 17 | 17 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 18 | import { providerForOrigin } from "./credential.ts"; | |
| 18 | import { providerForOrigin, writesPaused } from "./credential.ts"; | |
| 19 | 19 | import type { Provider } from "./types.ts"; |
| 20 | 20 | |
| 21 | 21 | export type WriteThroughError = |
| 22 | 22 | | "origin_owner_only" |
| 23 | 23 | | "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"; | |
| 25 | 29 | |
| 26 | 30 | export interface WriteThrough { |
| 27 | 31 | readonly provider: Provider; |
| @@ -49,6 +53,8 @@ export function writeThroughFor( | ||
| 49 | 53 | const link = originForRepo(app.db, repo.id); |
| 50 | 54 | if (!link) return ok(undefined); |
| 51 | 55 | if (link.origin.userId !== actor.id) return err("origin_owner_only"); |
| 56 | if (writesPaused(app, link.origin.id) !== undefined) | |
| 57 | return err("origin_paused"); | |
| 52 | 58 | const provider = providerForOrigin(app, link.origin); |
| 53 | 59 | if (!provider.ok) return err("origin_unreachable"); |
| 54 | 60 | return ok({ |
src/tui/screens/common.ts +2
| @@ -982,6 +982,8 @@ const MESSAGES: Record<string, string> = { | ||
| 982 | 982 | mirrored: "this repo mirrors an origin — do it there", |
| 983 | 983 | origin_owner_only: "only whoever connected this origin can write through it", |
| 984 | 984 | 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", | |
| 985 | 987 | origin_unreachable: "the origin could not be reached; nothing changed", |
| 986 | 988 | already_connected: ORIGIN_ALREADY_CONNECTED, |
| 987 | 989 | already_mirrored: "that repository is already mirrored here", |
src/tui/screens/connections.ts +22 −7
| @@ -4,9 +4,10 @@ | ||
| 4 | 4 | * it after a confirm — through `actions/origins.ts`, in `origin_words.ts`'s words. |
| 5 | 5 | */ |
| 6 | 6 | |
| 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"; | |
| 9 | 9 | import { mirrorsByOrigin } from "../../core/origins.ts"; |
| 10 | import { writesPaused } from "../../origins/credential.ts"; | |
| 10 | 11 | import { FORGE_NOT_CARRIED, FORGE_SIGN_IN_ON_PAGE } from "../../origins/words.ts"; |
| 11 | 12 | import type { Ctx, Screen } from "../app/app.ts"; |
| 12 | 13 | import { binding } from "../app/keys.ts"; |
| @@ -27,16 +28,19 @@ export function connectionsScreen(f: Session, ctx: Ctx): Screen { | ||
| 27 | 28 | |
| 28 | 29 | const screen: Screen = { |
| 29 | 30 | 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")], | |
| 31 | 32 | render(area, s) { |
| 32 | 33 | const c = ctx.theme.c; |
| 33 | 34 | const [head, list] = split(area, "v", [len(1), fill(1)]); |
| 34 | 35 | const all = origins(); |
| 35 | 36 | const picked = all[state.index]; |
| 37 | const pausedNote = picked !== undefined && writesPaused(f.app, picked.id) !== undefined; | |
| 36 | 38 | const text = |
| 37 | 39 | picked?.software === "forge" |
| 38 | 40 | ? `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 | |
| 40 | 44 | ? `No origin connected. Press c to connect one. ${FORGE_SIGN_IN_ON_PAGE}` |
| 41 | 45 | : `The hosts your mirrors come from. ${FORGE_SIGN_IN_ON_PAGE}`; |
| 42 | 46 | 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 { | ||
| 50 | 54 | const k = n.get(o.id)?.count ?? 0; |
| 51 | 55 | return { |
| 52 | 56 | 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 } : {}), | |
| 54 | 58 | 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", | |
| 56 | 60 | }; |
| 57 | 61 | }), |
| 58 | 62 | ); |
| @@ -79,7 +83,7 @@ export function connectionsScreen(f: Session, ctx: Ctx): Screen { | ||
| 79 | 83 | if (token.trim() === "") return "Paste the token."; |
| 80 | 84 | ctx.toast(`Connecting ${hostText}…`); |
| 81 | 85 | 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"); | |
| 83 | 87 | ctx.invalidate(); |
| 84 | 88 | }); |
| 85 | 89 | }, |
| @@ -105,6 +109,17 @@ export function connectionsScreen(f: Session, ctx: Ctx): Screen { | ||
| 105 | 109 | .catch(() => ctx.toast("The sync failed.", "danger")); |
| 106 | 110 | return true; |
| 107 | 111 | } |
| 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 | } | |
| 108 | 123 | if (e.key === "d") { |
| 109 | 124 | ctx.push( |
| 110 | 125 | confirmScreen(ctx, screen, { |
src/web/pages/branches.ts +4
| @@ -349,6 +349,10 @@ export const CREATE_ERRORS: Record<string, string> = { | ||
| 349 | 349 | "The origin could not be reached, so nothing was created.", |
| 350 | 350 | origin_refused: |
| 351 | 351 | "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.", | |
| 352 | 356 | forbidden: "You need write access to create a branch.", |
| 353 | 357 | unauthorized: "You need write access to create a branch.", |
| 354 | 358 | 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 { | ||
| 950 | 950 | return "The origin could not be reached. Nothing was created."; |
| 951 | 951 | if (error === "origin_refused") |
| 952 | 952 | 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."; | |
| 953 | 955 | if (error === "mirror_failed") |
| 954 | 956 | return "The fork was made, but a local copy of it could not be."; |
| 955 | 957 | if (error === "unauthorized") return "Sign in first."; |
src/web/pages/edit.ts +4
| @@ -126,6 +126,10 @@ export function editErrorText(error: unknown): string { | ||
| 126 | 126 | return "The origin could not be reached, so nothing was written here either."; |
| 127 | 127 | if (error === "origin_refused") |
| 128 | 128 | 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."; | |
| 129 | 133 | return "That edit could not be saved."; |
| 130 | 134 | } |
| 131 | 135 | 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"; | ||
| 43 | 43 | import { convergeWords, followModeOf, takeOrTell, takeWords } from "../../actions/follow.ts"; |
| 44 | 44 | import { FOLLOWS_NOTHING, proposeRefusalWords } from "../../actions/follow_words.ts"; |
| 45 | 45 | import { openProposal, proposeUpstream } from "../../actions/upstream.ts"; |
| 46 | import { proposesViaFork } from "../../origins/fork-route.ts"; | |
| 46 | 47 | import { field, readForm } from "../form.ts"; |
| 47 | 48 | import { html, when, type Html } from "../html.ts"; |
| 48 | 49 | import { flashOrJson } from "../layout.ts"; |
| @@ -284,11 +285,18 @@ export function followingCard( | ||
| 284 | 285 | proposal === undefined |
| 285 | 286 | ? html`<form method="post" action="${base}/following"> |
| 286 | 287 | <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 | })} | |
| 292 | 300 | </form>` |
| 293 | 301 | : btn("See the upstream proposal", { |
| 294 | 302 | size: "sm", |
src/web/pages/origin-flow.ts +69 −18
| @@ -9,7 +9,7 @@ | ||
| 9 | 9 | */ |
| 10 | 10 | |
| 11 | 11 | import { pollOAuthConnect } from "../../actions/origins.ts"; |
| 12 | import { getFlowFor } from "../../origins/oauth.ts"; | |
| 12 | import { flowExpired, flowTarget, getFlowFor } from "../../origins/oauth.ts"; | |
| 13 | 13 | import type { Ctx } from "../../http/context.ts"; |
| 14 | 14 | import { forgeBaseUrl } from "../../http/api.ts"; |
| 15 | 15 | import { qrEncode, qrSvg } from "../../design/qr.ts"; |
| @@ -49,14 +49,15 @@ export function originFlowPage(ctx: Ctx): Response { | ||
| 49 | 49 | return redirect(`${then ?? "/settings/origins"}?connected=1`); |
| 50 | 50 | if (flow.status === "failed") |
| 51 | 51 | return redirect( |
| 52 | `/settings/origins?error=${encodeURIComponent(flow.detail.slice(0, 120))}`, | |
| 52 | `/settings/origins?error=${encodeURIComponent(flow.detail.slice(0, 300))}`, | |
| 53 | 53 | ); |
| 54 | 54 | |
| 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 | ||
| 55 | 59 | 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; | |
| 60 | 61 | const base = forgeBaseUrl(ctx.url, ctx.app.config.host); |
| 61 | 62 | const shortLink = |
| 62 | 63 | base === "" || flow.shortCode === "" ? "" : `${base}/go/${flow.shortCode}`; |
| @@ -211,24 +212,74 @@ function qrScale(modules: number): number { | ||
| 211 | 212 | } |
| 212 | 213 | |
| 213 | 214 | /** |
| 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. | |
| 222 | 222 | */ |
| 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); | |
| 226 | 233 | if (!url || !instance) return undefined; |
| 227 | 234 | 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; | |
| 228 | 239 | if (url.searchParams.get("code_challenge") === null) return undefined; |
| 229 | 240 | return url.toString(); |
| 230 | 241 | } |
| 231 | 242 | |
| 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 | ||
| 232 | 283 | /** |
| 233 | 284 | * POST /settings/origins/flows/:id/poll — ask the origin once, on the user's |
| 234 | 285 | * 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> { | ||
| 265 | 316 | return redirect(`${then ?? "/settings/origins"}?connected=1`); |
| 266 | 317 | if (status === "failed") |
| 267 | 318 | return redirect( |
| 268 | `/settings/origins?error=${encodeURIComponent(detail.slice(0, 120))}`, | |
| 319 | `/settings/origins?error=${encodeURIComponent(detail.slice(0, 300))}`, | |
| 269 | 320 | ); |
| 270 | 321 | return redirect( |
| 271 | 322 | `/settings/origins/flows/${ctx.params.id ?? ""}${then === undefined ? "" : `?${thenQuery(then)}`}`, |
src/web/pages/origins.ts +97 −10
| @@ -19,7 +19,9 @@ | ||
| 19 | 19 | |
| 20 | 20 | import { FORGE_NOT_CARRIED } from "../../origins/words.ts"; |
| 21 | 21 | import { |
| 22 | checkOriginAgain, | |
| 22 | 23 | connectOrigin, |
| 24 | connectRefusal, | |
| 23 | 25 | deleteOriginApp, |
| 24 | 26 | disconnectOrigin, |
| 25 | 27 | listOriginApps, |
| @@ -35,6 +37,8 @@ import { | ||
| 35 | 37 | connectWords, |
| 36 | 38 | disconnectedWords, |
| 37 | 39 | syncedWords, |
| 40 | CHECK_TOO_SOON, | |
| 41 | CHECK_WORDS, | |
| 38 | 42 | } from "../../actions/origin_words.ts"; |
| 39 | 43 | import { |
| 40 | 44 | editableHosts, |
| @@ -46,6 +50,7 @@ import { | ||
| 46 | 50 | } from "../../origins/trusted-hosts.ts"; |
| 47 | 51 | import { reattachMessage } from "./frozen.ts"; |
| 48 | 52 | import { replaceTokenMessage } from "../../origins/token-reach.ts"; |
| 53 | import { writesPaused } from "../../origins/credential.ts"; | |
| 49 | 54 | import { |
| 50 | 55 | listDetachedMirrors, |
| 51 | 56 | mirrorsByOrigin, |
| @@ -328,7 +333,25 @@ function reauthNote(origin: Origin, mirrors: number): Html { | ||
| 328 | 333 | </p>`; |
| 329 | 334 | } |
| 330 | 335 | |
| 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 { | |
| 332 | 355 | const base = `/settings/origins/${origin.id}`; |
| 333 | 356 | return card( |
| 334 | 357 | html`<ul class="list setting-list"> |
| @@ -364,6 +387,7 @@ function originCard(origin: Origin, mirrors: number): Html { | ||
| 364 | 387 | origin.software === "forge", |
| 365 | 388 | () => html`<p class="small mt">Another forge. ${FORGE_NOT_CARRIED}</p>`, |
| 366 | 389 | )} |
| 390 | ${when(paused, () => pausedNote(origin))} | |
| 367 | 391 | <div class="bar mt"> |
| 368 | 392 | <form method="post" action="${base}/sync"> |
| 369 | 393 | ${btn("Sync now", { size: "sm", icon: "sync" })} |
| @@ -640,8 +664,9 @@ function tokenSection(values: ConnectValues): Html { | ||
| 640 | 664 | required: true, |
| 641 | 665 | mono: true, |
| 642 | 666 | autocomplete: "off", |
| 643 | hint: "Needs repository and issue access. It is never displayed after this.", | |
| 667 | hint: "It is never displayed after this.", | |
| 644 | 668 | })} |
| 669 | ${tokenNeeds(values.tokenHost)} | |
| 645 | 670 | <div class="form-actions">${btn("Connect", { variant: "primary" })}</div> |
| 646 | 671 | </form>`; |
| 647 | 672 | } |
| @@ -681,6 +706,41 @@ function trustedHostsCard(text: string, error?: string): Html { | ||
| 681 | 706 | ); |
| 682 | 707 | } |
| 683 | 708 | |
| 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 | ||
| 684 | 744 | // ---- the page ---- |
| 685 | 745 | |
| 686 | 746 | function originsView( |
| @@ -715,7 +775,13 @@ function originsView( | ||
| 715 | 775 | note: "Connect GitHub, a Forgejo instance or a Gitea instance and its repositories become mirrorable here.", |
| 716 | 776 | }) |
| 717 | 777 | : 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 | )} | |
| 719 | 785 | </div>`} |
| 720 | 786 | ${frozenCard(frozen)} |
| 721 | 787 | ${mayEditTrustedHosts(user) |
| @@ -871,7 +937,7 @@ export function originsPage(ctx: Ctx): Response { | ||
| 871 | 937 | <div class="toast-body"> |
| 872 | 938 | ${error === "state" |
| 873 | 939 | ? "That authorization could not be matched to a connect you started here. Start again." |
| 874 | : error.slice(0, 200)} | |
| 940 | : error.slice(0, 300)} | |
| 875 | 941 | </div> |
| 876 | 942 | </div>` |
| 877 | 943 | : null; |
| @@ -901,7 +967,8 @@ export async function originConnectToken(ctx: Ctx): Promise<Response> { | ||
| 901 | 967 | // secret is a smaller cost than a secret sitting in a value attribute. |
| 902 | 968 | const values = blankValues(); |
| 903 | 969 | values.tokenHost = host; |
| 904 | values.tokenError = connectMessage(connected.error); | |
| 970 | const refused = connectRefusal(connected.error); | |
| 971 | values.tokenError = refused.message ?? connectMessage(refused.code); | |
| 905 | 972 | return shell(ctx, originsView(ctx, values), 422); |
| 906 | 973 | } |
| 907 | 974 | const { reattached } = connected.value; |
| @@ -979,11 +1046,8 @@ export async function originOAuthStart(ctx: Ctx): Promise<Response> { | ||
| 979 | 1046 | : connectMessage(started.error.error); |
| 980 | 1047 | return shell(ctx, originsView(ctx, values), 422); |
| 981 | 1048 | } |
| 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"; | |
| 987 | 1051 | return redirect(`/settings/origins/flows/${started.value.flowId}${qs}`); |
| 988 | 1052 | } |
| 989 | 1053 | |
| @@ -1072,6 +1136,29 @@ export function notFoundNote(): Html { | ||
| 1072 | 1136 | } |
| 1073 | 1137 | // ---- one origin ---- |
| 1074 | 1138 | |
| 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 | ||
| 1075 | 1162 | // POST /settings/origins/:id/sync |
| 1076 | 1163 | export async function originSyncSubmit(ctx: Ctx): Promise<Response> { |
| 1077 | 1164 | const denied = accountOnly(ctx); |
src/web/pages/thread.ts +4
| @@ -92,6 +92,10 @@ export function originError(error: string): string | undefined { | ||
| 92 | 92 | origin_owner_only: |
| 93 | 93 | "Only the person who connected this origin can write through it. Your comment would otherwise appear upstream under their name.", |
| 94 | 94 | 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.", | |
| 95 | 99 | origin_unreachable: "The origin could not be reached. Nothing was changed.", |
| 96 | 100 | }; |
| 97 | 101 | return words[error]; |
src/web/scope.ts +5 −1
| @@ -32,6 +32,7 @@ import { | ||
| 32 | 32 | type Origin, |
| 33 | 33 | } from "../core/origins.ts"; |
| 34 | 34 | import { canWrite, countsForRepos, type Repo } from "../core/repos.ts"; |
| 35 | import { denied } from "../origins/operator.ts"; | |
| 35 | 36 | import type { Ctx } from "../http/context.ts"; |
| 36 | 37 | import type { |
| 37 | 38 | RepoFacts, |
| @@ -193,7 +194,10 @@ function mirrorFacts( | ||
| 193 | 194 | const link = originForRepo(ctx.app.db, repo.id); |
| 194 | 195 | if (link === undefined) return { upstream, propose: undefined }; |
| 195 | 196 | 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 }; | |
| 197 | 201 | return { |
| 198 | 202 | upstream, |
| 199 | 203 | propose: { |
test/apps-feedback-mirror.test.ts +1 −1
| @@ -50,7 +50,7 @@ describe("the note door on a mirrored app", () => { | ||
| 50 | 50 | github = serveFakeForge(newFakeState("github", "alice", secret)); |
| 51 | 51 | forgetProviders(dataDir); |
| 52 | 52 | 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)); | |
| 54 | 54 | const created = await newApp(app, alice, { name: "mirrored", description: "Words", visibility: "public" }); |
| 55 | 55 | if (!created.ok) throw new Error(String(created.error)); |
| 56 | 56 | const repo = getRepo(app.db, "alice", "mirrored")!; |
test/fake-origin.ts +68 −10
| @@ -222,6 +222,19 @@ export interface FakeState { | ||
| 222 | 222 | * unset, the fake is a fine-grained token, which names none. |
| 223 | 223 | */ |
| 224 | 224 | 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>; | |
| 225 | 238 | } |
| 226 | 239 | |
| 227 | 240 | export interface FakeForge { |
| @@ -396,6 +409,14 @@ function route( | ||
| 396 | 409 | if (rest === "/settings/api") |
| 397 | 410 | return jsonResponse({ max_response_items: state.maxResponseItems }); |
| 398 | 411 | |
| 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 | ); | |
| 399 | 420 | if (rest === "/user") |
| 400 | 421 | return jsonResponse( |
| 401 | 422 | { |
| @@ -404,23 +425,42 @@ function route( | ||
| 404 | 425 | full_name: state.login, |
| 405 | 426 | name: state.login, |
| 406 | 427 | html_url: `${url.origin}/${state.login}`, |
| 428 | ...(state.kind === "github" | |
| 429 | ? { site_admin: state.siteAdmin === true } | |
| 430 | : { is_admin: state.siteAdmin === true }), | |
| 407 | 431 | }, |
| 408 | 432 | 200, |
| 409 | 433 | state.scopes === undefined ? {} : { "x-oauth-scopes": state.scopes }, |
| 410 | 434 | ); |
| 411 | 435 | |
| 412 | 436 | 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); | |
| 413 | 442 | 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) | |
| 421 | 459 | .map((r) => repoJson(state, r)); |
| 422 | 460 | 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 | }); | |
| 424 | 464 | } |
| 425 | 465 | |
| 426 | 466 | // Cross-repo issue search. Both forges hang it off a path that *looks* |
| @@ -461,15 +501,30 @@ function route( | ||
| 461 | 501 | if (parse(bodyText).archived === true) repo.archived = true; |
| 462 | 502 | return jsonResponse(repoJson(state, repo)); |
| 463 | 503 | } |
| 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 | } | |
| 465 | 509 | |
| 466 | 510 | // ---- 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 | } | |
| 467 | 517 | if (tail === "/forks" && method === "POST") { |
| 468 | 518 | if (state.refuseFork !== "") |
| 469 | 519 | 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]!; | |
| 471 | 523 | const forkName = `${state.login}/${name}`; |
| 472 | 524 | 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); | |
| 473 | 528 | if (already === undefined) { |
| 474 | 529 | state.repos.push({ |
| 475 | 530 | id: state.nextId++, |
| @@ -1169,6 +1224,8 @@ export interface FakeOAuth { | ||
| 1169 | 1224 | expiresIn: number; |
| 1170 | 1225 | /** Hand back a new refresh token on every refresh, as rotation does. */ |
| 1171 | 1226 | rotate: boolean; |
| 1227 | /** Called while a refresh is being answered: something racing it. */ | |
| 1228 | onRefresh?: () => void; | |
| 1172 | 1229 | /** Answer the next refresh with this OAuth error code. */ |
| 1173 | 1230 | refreshError: string; |
| 1174 | 1231 | /** Every token-endpoint request body, parsed. */ |
| @@ -1247,6 +1304,7 @@ export function oauthRoute( | ||
| 1247 | 1304 | }); |
| 1248 | 1305 | if (form.refresh_token !== oauth.refreshToken) |
| 1249 | 1306 | return jsonResponse({ error: "unauthorized_client" }); |
| 1307 | oauth.onRefresh?.(); | |
| 1250 | 1308 | oauth.accessToken = `gho_refreshed${oauth.tokenRequests.length}00000000000000000`; |
| 1251 | 1309 | if (oauth.rotate) |
| 1252 | 1310 | oauth.refreshToken = `ghr_rotated${oauth.tokenRequests.length}0000000000000000000`; |
test/helpers/chrome.ts +134
| @@ -46,11 +46,13 @@ class Cdp { | ||
| 46 | 46 | private nextId = 1; |
| 47 | 47 | private readonly pending = new Map<number, (msg: CdpMessage) => void>(); |
| 48 | 48 | private readonly waiters = new Map<string, () => void>(); |
| 49 | private readonly listeners = new Map<string, ((params: any) => void)[]>(); | |
| 49 | 50 | |
| 50 | 51 | constructor(private readonly ws: WebSocket) { |
| 51 | 52 | ws.addEventListener("message", (event) => { |
| 52 | 53 | const msg = JSON.parse(event.data as string) as CdpMessage & { |
| 53 | 54 | method?: string; |
| 55 | params?: unknown; | |
| 54 | 56 | }; |
| 55 | 57 | if (msg.id !== undefined) { |
| 56 | 58 | this.pending.get(msg.id)?.(msg); |
| @@ -58,10 +60,17 @@ class Cdp { | ||
| 58 | 60 | } else if (msg.method !== undefined) { |
| 59 | 61 | this.waiters.get(msg.method)?.(); |
| 60 | 62 | this.waiters.delete(msg.method); |
| 63 | for (const listen of this.listeners.get(msg.method) ?? []) | |
| 64 | listen(msg.params); | |
| 61 | 65 | } |
| 62 | 66 | }); |
| 63 | 67 | } |
| 64 | 68 | |
| 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 | ||
| 65 | 74 | send(method: string, params: object = {}): Promise<any> { |
| 66 | 75 | const id = this.nextId++; |
| 67 | 76 | return within(5_000, method, new Promise((resolve, reject) => { |
| @@ -316,3 +325,128 @@ export function chromiumRuns(binary: string): boolean { | ||
| 316 | 325 | rmSync(profile, { recursive: true, force: true }); |
| 317 | 326 | } |
| 318 | 327 | } |
| 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", () => { | ||
| 116 | 116 | kind: "github", |
| 117 | 117 | token: secret, |
| 118 | 118 | }); |
| 119 | if (!connected.ok) throw new Error(connected.error); | |
| 119 | if (!connected.ok) throw new Error(JSON.stringify(connected.error)); | |
| 120 | 120 | originId = connected.value.id; |
| 121 | 121 | stopMirror = startMirrorOut(app, async (a, repo) => { |
| 122 | 122 | try { |
test/migrate-token-reach.test.ts +3 −3
| @@ -13,7 +13,7 @@ import { mkdtemp, rm } from "node:fs/promises"; | ||
| 13 | 13 | import { tmpdir } from "node:os"; |
| 14 | 14 | import { join } from "node:path"; |
| 15 | 15 | 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"; | |
| 17 | 17 | import { listOrigins } from "../src/core/origins.ts"; |
| 18 | 18 | import { createSession } from "../src/core/sessions.ts"; |
| 19 | 19 | 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 | ||
| 122 | 122 | kind: "github", |
| 123 | 123 | token: "public-only-token", |
| 124 | 124 | }); |
| 125 | if (!connected.ok) throw new Error(connected.error); | |
| 125 | if (!connected.ok) throw new Error(connectRefusal(connected.error).code); | |
| 126 | 126 | const originId = connected.value.id; |
| 127 | 127 | |
| 128 | 128 | const page = await get("/migrate"); |
| @@ -293,7 +293,7 @@ test("the terminal's migrate screen says public only, names the new-token page, | ||
| 293 | 293 | kind: "github", |
| 294 | 294 | token: "carol-public", |
| 295 | 295 | }); |
| 296 | if (!connected.ok) throw new Error(connected.error); | |
| 296 | if (!connected.ok) throw new Error(connectRefusal(connected.error).code); | |
| 297 | 297 | tui.start(); |
| 298 | 298 | await settleApp(tui); |
| 299 | 299 | terminal.feed("M"); |
test/migrate.test.ts +3 −3
| @@ -744,7 +744,7 @@ describe("migrate", () => { | ||
| 744 | 744 | kind: "github", |
| 745 | 745 | token: upstreamSecret, |
| 746 | 746 | }); |
| 747 | if (!connected.ok) throw new Error(connected.error); | |
| 747 | if (!connected.ok) throw new Error(JSON.stringify(connected.error)); | |
| 748 | 748 | const agent = await createAgent(app, alice, { |
| 749 | 749 | username: "mover", |
| 750 | 750 | model: "", |
| @@ -893,7 +893,7 @@ describe("migrate", () => { | ||
| 893 | 893 | kind: "github", |
| 894 | 894 | token: upstreamSecret, |
| 895 | 895 | }); |
| 896 | if (!connected.ok) throw new Error(connected.error); | |
| 896 | if (!connected.ok) throw new Error(JSON.stringify(connected.error)); | |
| 897 | 897 | const job = await migrate(alice, { |
| 898 | 898 | source: { originId: connected.value.id, fullName: "octo/forked-pulls" }, |
| 899 | 899 | }); |
| @@ -1004,7 +1004,7 @@ describe("migrate", () => { | ||
| 1004 | 1004 | try { |
| 1005 | 1005 | forgetProviders(dataDir); |
| 1006 | 1006 | 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)); | |
| 1008 | 1008 | const value = `${connected.value.id}/octo/arm`; |
| 1009 | 1009 | |
| 1010 | 1010 | // 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("&", "&"); | |
| 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&resource=all&scope=read:user&scope=write:repository&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", () => { | ||
| 686 | 686 | expect(failingFiles(report)).toEqual(["test/b.test.ts", "test/c.test.ts"]); |
| 687 | 687 | }); |
| 688 | 688 | |
| 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 | ||
| 689 | 711 | test("a report that names no failing test names no file", () => { |
| 690 | 712 | expect(failingFiles("test/a.test.ts:\nerror: cannot load\n")).toEqual([]); |
| 691 | 713 | expect(failingFiles("(fail) before any header")).toEqual([]); |
test/tui-migrate.test.ts +2 −2
| @@ -130,7 +130,7 @@ beforeAll(async () => { | ||
| 130 | 130 | fake = serveFakeForge(state); |
| 131 | 131 | forgetProviders(dir); |
| 132 | 132 | 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)); | |
| 134 | 134 | |
| 135 | 135 | const agent = await createAgent(app, alice, { username: "porter", model: "", brief: "Moves things in." }); |
| 136 | 136 | if (!agent.ok) throw new Error(String(agent.error)); |
| @@ -289,7 +289,7 @@ describe("migrate from the terminal", () => { | ||
| 289 | 289 | try { |
| 290 | 290 | for (const [host, token] of [[fake.url, secretToken], [down.url, "tok-ghost"]] as const) { |
| 291 | 291 | 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)); | |
| 293 | 293 | } |
| 294 | 294 | down.state.rateLimitFor = 1000; |
| 295 | 295 | 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 | }); |
new above
offline
Re-export sits between imports; move it below them.