Public: anyone can read this; the crew can change it.
An admin turns following on in settings and checks now, the repo page says what it follows, and a check runs every 30 minutes #4218
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/3980-an-admin-turns-following-on-in-settings → main 4 commits fork 2 threads · 2 unresolved
11 files
changed +562
−7
docs/ORIGINS.md +30
| @@ -534,6 +534,36 @@ The clone runs as a job in the mirror's registry (`origins/jobs.ts`, kind | ||
| 534 | 534 | app and the crew's first run — and then lands on the repository, which is |
| 535 | 535 | the app's page, with what happened as the flash. |
| 536 | 536 | |
| 537 | ## Follow an upstream | |
| 538 | ||
| 539 | A repository can follow a git URL outside the forge (`repo_upstream`, | |
| 540 | `core/upstream_follow.ts`; the check is `actions/upstream_follow.ts`). An | |
| 541 | admin of the repository does it, on its settings page's **Follow upstream** | |
| 542 | card: the URL, the branch (default `main`), and an on/off box; **Check | |
| 543 | upstream now** appears once it is on. The URL is `https:` or `http:` with no | |
| 544 | password in it — `git:`, `ssh:`, `file:` and the rest are refused at save, | |
| 545 | so the vetted-address pin always holds. A host inside the network is checked | |
| 546 | as the mirror's fetch is: vetted, and the vetted address pinned. | |
| 547 | ||
| 548 | Every 30 minutes (the server's one-minute timer runs `checkDue`) and on | |
| 549 | **Check now**: `ls-remote`, a fetch only when the tip moved into | |
| 550 | `refs/upstream-follow/<branch>` — not a branch — and, when the commits merge | |
| 551 | cleanly, one pull from `upstream/<tip7>`. One check runs per repository at a | |
| 552 | time, the clock's and the admin's sharing one claim; a second is `busy` | |
| 553 | (409 in the API). Never a push to the default branch. | |
| 554 | ||
| 555 | **Credentials.** A public upstream needs none. A private one is fetched with | |
| 556 | the credential of the origin the admin connected (Settings → Origins), sent | |
| 557 | only to the exact https origin it was connected at, as a header in git's | |
| 558 | environment — never in the URL, never in argv. Nothing is stored in the | |
| 559 | repository. A check's failure message is the remote's stderr with secrets | |
| 560 | redacted, shown to admins only. | |
| 561 | ||
| 562 | The About card says "Following <upstream> · last checked HH:MM · N behind", | |
| 563 | to admins only. API, all admin-only (anyone else gets 404): | |
| 564 | `GET|PUT /api/v1/repos/:owner/:name/upstream` (PUT takes | |
| 565 | `{url, branch, enabled}`) and `POST …/upstream/check`. | |
| 566 | ||
| 537 | 567 | ## API |
| 538 | 568 | |
| 539 | 569 |
src/actions/upstream_follow.ts +125 −5
| @@ -21,12 +21,19 @@ import type { App } from "../app.ts"; | ||
| 21 | 21 | import { getIssueById, updateIssueText } from "../core/issues.ts"; |
| 22 | 22 | import { getOriginFor } from "../core/origins.ts"; |
| 23 | 23 | import { pullForBranch, retargetPull } from "../core/pulls.ts"; |
| 24 | import { canWrite, getRepoById, type Repo } from "../core/repos.ts"; | |
| 24 | import { canAdmin, canWrite, getRepoById, type Repo } from "../core/repos.ts"; | |
| 25 | 25 | import { storageStale } from "../core/storage.ts"; |
| 26 | import { getFollow, listFollowing, noteCheck } from "../core/upstream_follow.ts"; | |
| 27 | import { getUserById } from "../core/users.ts"; | |
| 26 | import { | |
| 27 | getFollow, | |
| 28 | listFollowing, | |
| 29 | noteCheck, | |
| 30 | putFollow, | |
| 31 | upstreamUrl, | |
| 32 | type UpstreamFollow, | |
| 33 | } from "../core/upstream_follow.ts"; | |
| 34 | import { getUserById, type User } from "../core/users.ts"; | |
| 28 | 35 | import { runGitCapture } from "../git/env.ts"; |
| 29 | import { isSafeOid } from "../git/guard.ts"; | |
| 36 | import { isSafeOid, isSafeRef } from "../git/guard.ts"; | |
| 30 | 37 | import { aheadCount, mergeable, resolveOid, writeRef } from "../git/merge.ts"; |
| 31 | 38 | import { REMOTE_PROTOCOLS } from "../git/remote-env.ts"; |
| 32 | 39 | import { repoDir } from "../git/repos.ts"; |
| @@ -34,6 +41,7 @@ import { err, ok, type Result } from "../kernel/result.ts"; | ||
| 34 | 41 | import { providerForOrigin } from "../origins/credential.ts"; |
| 35 | 42 | import { redact, secretsIn } from "../origins/credentials.ts"; |
| 36 | 43 | import { vettedEnv } from "../origins/git-mirror.ts"; |
| 44 | import { migrateUrl } from "./migrate.ts"; | |
| 37 | 45 | import { openPull } from "./pulls.ts"; |
| 38 | 46 | |
| 39 | 47 | /** How often a following repository is checked on its own. */ |
| @@ -95,7 +103,11 @@ export async function checkUpstream(app: App, repo: Repo): Promise<Result<Checke | ||
| 95 | 103 | if (checking.has(repo.id)) return err("busy"); |
| 96 | 104 | checking.add(repo.id); |
| 97 | 105 | try { |
| 98 | return await checkOnce(app, repo); | |
| 106 | const result = await checkOnce(app, repo); | |
| 107 | // So an open settings page or About card updates; no address in it. | |
| 108 | if (result.ok || result.error !== "off") | |
| 109 | app.bus.append({ kind: "repo.upstream_checked", repoId: repo.id, payload: {} }); | |
| 110 | return result; | |
| 99 | 111 | } finally { |
| 100 | 112 | checking.delete(repo.id); |
| 101 | 113 | } |
| @@ -232,3 +244,111 @@ async function checkOnce(app: App, repo: Repo): Promise<Result<Checked, CheckErr | ||
| 232 | 244 | noteCheck(app.db, repo.id, { at, tip: staged, behind, error: "" }); |
| 233 | 245 | return ok({ tip: staged, behind, outcome: "proposed", pull: number }); |
| 234 | 246 | } |
| 247 | ||
| 248 | export type FollowError = "invalid_url" | "invalid_branch" | "not_found" | "unauthorized" | "forbidden"; | |
| 249 | ||
| 250 | /** What each refusal of a save says, at every door; a check's are `CHECK_REFUSALS`. */ | |
| 251 | export const FOLLOW_REFUSALS: Record<FollowError | CheckError, string> = { | |
| 252 | ...CHECK_REFUSALS, | |
| 253 | invalid_url: "The upstream is an https or http git address, with no password in it.", | |
| 254 | invalid_branch: "That is not a branch name git accepts.", | |
| 255 | not_found: "No such repository.", | |
| 256 | unauthorized: "Sign in first.", | |
| 257 | forbidden: "Only an admin of this repository follows an upstream.", | |
| 258 | }; | |
| 259 | ||
| 260 | /** The follow row, for an admin of the repository and for nobody else. */ | |
| 261 | export function followShown(app: App, repo: Repo, actor: User | undefined): UpstreamFollow | undefined { | |
| 262 | if (actor === undefined || !canAdmin(app.db, repo, actor)) return undefined; | |
| 263 | return getFollow(app.db, repo.id); | |
| 264 | } | |
| 265 | ||
| 266 | /** Point a repository at an upstream and turn following on or off. Admins only. */ | |
| 267 | export function setFollowUpstream( | |
| 268 | app: App, | |
| 269 | actor: User | undefined, | |
| 270 | repo: Repo, | |
| 271 | input: { readonly url: string; readonly branch: string; readonly enabled: boolean }, | |
| 272 | ): Result<UpstreamFollow, FollowError> { | |
| 273 | if (actor === undefined || !canAdmin(app.db, repo, actor)) return err("not_found"); | |
| 274 | const was = getFollow(app.db, repo.id); | |
| 275 | // Turning it off needs no address: the row keeps the last one. | |
| 276 | const blank = input.url.trim() === ""; | |
| 277 | const raw = blank && was !== undefined ? was.url : input.url; | |
| 278 | const normal = migrateUrl(raw); | |
| 279 | const url = normal === undefined ? undefined : upstreamUrl(normal); | |
| 280 | if (url === undefined) return err("invalid_url"); | |
| 281 | const branch = input.branch.trim() === "" ? (was?.branch ?? "main") : input.branch.trim(); | |
| 282 | if (!isSafeRef(branch) || branch.length > 200) return err("invalid_branch"); | |
| 283 | const saved = putFollow(app.db, { | |
| 284 | repoId: repo.id, | |
| 285 | url, | |
| 286 | branch, | |
| 287 | enabled: input.enabled, | |
| 288 | byId: actor.id, | |
| 289 | originId: was?.url === url ? was.originId : "", | |
| 290 | }); | |
| 291 | app.bus.append({ | |
| 292 | kind: "repo.upstream_follow", | |
| 293 | repoId: repo.id, | |
| 294 | actorId: actor.id, | |
| 295 | payload: { enabled: saved.enabled, branch: saved.branch }, | |
| 296 | }); | |
| 297 | return ok(saved); | |
| 298 | } | |
| 299 | ||
| 300 | /** "Check upstream now": an admin's, through the same claim as the clock. */ | |
| 301 | export async function checkUpstreamNow( | |
| 302 | app: App, | |
| 303 | actor: User | undefined, | |
| 304 | repo: Repo, | |
| 305 | ): Promise<Result<Checked, FollowError | CheckError>> { | |
| 306 | if (actor === undefined || !canAdmin(app.db, repo, actor)) return err("not_found"); | |
| 307 | return checkUpstream(app, repo); | |
| 308 | } | |
| 309 | ||
| 310 | /** The follow row as the API shows it. */ | |
| 311 | export function followJson(f: UpstreamFollow): Record<string, unknown> { | |
| 312 | return { | |
| 313 | url: f.url, | |
| 314 | branch: f.branch, | |
| 315 | enabled: f.enabled, | |
| 316 | checked_at: f.checkedAt, | |
| 317 | tip: f.tip === "" ? null : f.tip, | |
| 318 | behind: f.behind, | |
| 319 | error: f.error === "" ? null : f.error, | |
| 320 | pending_id: f.pendingId, | |
| 321 | opened: f.opened, | |
| 322 | }; | |
| 323 | } | |
| 324 | ||
| 325 | /** `host/path` of an upstream, for a line of prose. */ | |
| 326 | export function upstreamLabel(url: string): string { | |
| 327 | try { | |
| 328 | const u = new URL(url); | |
| 329 | return `${u.host}${u.pathname}`.replace(/\.git$/, "").replace(/\/$/, ""); | |
| 330 | } catch { | |
| 331 | return url; | |
| 332 | } | |
| 333 | } | |
| 334 | ||
| 335 | /** HH:MM, UTC. */ | |
| 336 | export const clockWords = (iso: string): string => { | |
| 337 | const at = new Date(iso); | |
| 338 | return Number.isNaN(at.getTime()) ? "" : at.toISOString().slice(11, 16); | |
| 339 | }; | |
| 340 | ||
| 341 | /** Where a following stands: what the last check read. */ | |
| 342 | export function standing(f: UpstreamFollow): string { | |
| 343 | if (!f.enabled) return "off"; | |
| 344 | if (f.error !== "") return f.error === "conflict" ? `${f.behind} behind, conflicts` : "last check failed"; | |
| 345 | if (f.checkedAt === null) return "not checked yet"; | |
| 346 | return f.behind === 0 ? "up to date" : `${f.behind} behind`; | |
| 347 | } | |
| 348 | ||
| 349 | /** What "Check upstream now" found, in a sentence. */ | |
| 350 | export function checkedWords(c: Checked): string { | |
| 351 | if (c.outcome === "none") return "Up to date: nothing new upstream."; | |
| 352 | if (c.outcome === "conflict") return `${c.behind} new upstream commits conflict with the default branch.`; | |
| 353 | return `${c.behind} new upstream commit${c.behind === 1 ? "" : "s"} proposed${c.pull === undefined ? "" : ` in #${c.pull}`}.`; | |
| 354 | } |
src/core/event_audience.ts +1
| @@ -163,6 +163,7 @@ export function classifiedAudience(kind: string): EventAudience | undefined { | ||
| 163 | 163 | // Where a repository follows is its admins' to read, off or on: the |
| 164 | 164 | // address is often a private repository's. |
| 165 | 165 | kind === "repo.upstream_follow" || |
| 166 | kind === "repo.upstream_checked" || | |
| 166 | 167 | kind === "repo.crew_outsiders" |
| 167 | 168 | ) |
| 168 | 169 | return "admins"; |
src/design/activity.ts +1
| @@ -120,6 +120,7 @@ const WORDS: Record<string, string> = { | ||
| 120 | 120 | "repo.created": "created a repository", |
| 121 | 121 | "repo.forked": "forked a repository", |
| 122 | 122 | "repo.settings_updated": "changed repository settings", |
| 123 | "repo.upstream_follow": "changed which upstream the repository follows", | |
| 123 | 124 | "repo.crew_outsiders": "changed who may trigger the crew", |
| 124 | 125 | "repo.mirrored": "mirrored a repository", |
| 125 | 126 | "repo.imported": "imported issues and pulls", |
src/http/api.ts +51
| @@ -8,6 +8,13 @@ | ||
| 8 | 8 | * back to continue); errors are `{ error, message }`. |
| 9 | 9 | */ |
| 10 | 10 | |
| 11 | import { | |
| 12 | checkUpstreamNow, | |
| 13 | FOLLOW_REFUSALS, | |
| 14 | followJson, | |
| 15 | setFollowUpstream, | |
| 16 | } from "../actions/upstream_follow.ts"; | |
| 17 | import { getFollow } from "../core/upstream_follow.ts"; | |
| 11 | 18 | import { showProvenance } from "../actions/provenance.ts"; |
| 12 | 19 | import { getProvenance, provenanceFacts } from "../core/provenance.ts"; |
| 13 | 20 | import { goldenPath } from "../apps/golden_path.ts"; |
| @@ -2366,6 +2373,50 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 2366 | 2373 | return res; |
| 2367 | 2374 | }); |
| 2368 | 2375 | |
| 2376 | // Following an outside upstream (#3442): an admin reads, sets and checks. | |
| 2377 | router.add("GET", "/api/v1/repos/:owner/:name/upstream", (ctx) => { | |
| 2378 | const denied = requireScope(ctx, "ro"); | |
| 2379 | if (denied) return denied; | |
| 2380 | const repo = loadRepo(ctx); | |
| 2381 | if (!repo) return notFound(); | |
| 2382 | if (!canAdmin(ctx.app.db, repo, ctx.auth.user)) return notFound(); | |
| 2383 | const follow = getFollow(ctx.app.db, repo.id); | |
| 2384 | return json({ following: follow === undefined ? null : followJson(follow) }); | |
| 2385 | }); | |
| 2386 | ||
| 2387 | router.add("PUT", "/api/v1/repos/:owner/:name/upstream", async (ctx) => { | |
| 2388 | const denied = requireScope(ctx, "admin"); | |
| 2389 | if (denied) return denied; | |
| 2390 | const repo = loadRepo(ctx); | |
| 2391 | if (!repo) return notFound(); | |
| 2392 | const input = await body(ctx.request); | |
| 2393 | if (typeof input.enabled !== "boolean") | |
| 2394 | return apiError("bad_enabled", 422, "enabled is true or false"); | |
| 2395 | const saved = setFollowUpstream(ctx.app, ctx.auth.user, repo, { | |
| 2396 | url: str(input.url), | |
| 2397 | branch: str(input.branch), | |
| 2398 | enabled: input.enabled, | |
| 2399 | }); | |
| 2400 | if (!saved.ok) | |
| 2401 | return saved.error === "not_found" || saved.error === "unauthorized" || saved.error === "forbidden" | |
| 2402 | ? respondAccess(saved.error, 422) | |
| 2403 | : apiError(saved.error, 422, FOLLOW_REFUSALS[saved.error]); | |
| 2404 | return json({ following: followJson(saved.value) }); | |
| 2405 | }); | |
| 2406 | ||
| 2407 | router.add("POST", "/api/v1/repos/:owner/:name/upstream/check", async (ctx) => { | |
| 2408 | const denied = requireScope(ctx, "admin"); | |
| 2409 | if (denied) return denied; | |
| 2410 | const repo = loadRepo(ctx); | |
| 2411 | if (!repo) return notFound(); | |
| 2412 | const checked = await checkUpstreamNow(ctx.app, ctx.auth.user, repo); | |
| 2413 | if (!checked.ok) | |
| 2414 | return checked.error === "not_found" || checked.error === "unauthorized" || checked.error === "forbidden" | |
| 2415 | ? respondAccess(checked.error, 422) | |
| 2416 | : apiError(checked.error, checked.error === "busy" ? 409 : 422, FOLLOW_REFUSALS[checked.error]); | |
| 2417 | return json(checked.value); | |
| 2418 | }); | |
| 2419 | ||
| 2369 | 2420 | router.add("PATCH", "/api/v1/repos/:owner/:name", async (ctx) => { |
| 2370 | 2421 | const denied = requireScope(ctx, "admin"); |
| 2371 | 2422 | if (denied) return denied; |
src/http/server.ts +3
| @@ -5,6 +5,7 @@ | ||
| 5 | 5 | * verdict. Auth is resolved once per request and handed to handlers as data. |
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | import { followUpstreamCheck, followUpstreamUpdate } from "../web/pages/upstream-follow.ts"; | |
| 8 | 9 | import { refusedByDatabase, shownRefusal } from "./refusal.ts"; |
| 9 | 10 | import { NOT_A_MEMBER } from "../core/roles.ts"; |
| 10 | 11 | import { signupOptions, signupPasskey } from "../web/pages/signup-passkey.ts"; |
| @@ -1408,6 +1409,8 @@ export function buildRouter(): Router<Handler> { | ||
| 1408 | 1409 | ); |
| 1409 | 1410 | router.add("GET", "/:owner/:name/settings/autonomy", autonomySettingsPage); |
| 1410 | 1411 | router.add("POST", "/:owner/:name/settings/autonomy", autonomySettingsUpdate); |
| 1412 | router.add("POST", "/:owner/:name/settings/upstream", followUpstreamUpdate); | |
| 1413 | router.add("POST", "/:owner/:name/settings/upstream/check", followUpstreamCheck); | |
| 1411 | 1414 | router.add("GET", "/:owner/:name/settings/labels", labelsPage); |
| 1412 | 1415 | router.add("POST", "/:owner/:name/settings/labels", labelCreate); |
| 1413 | 1416 | router.add("POST", "/:owner/:name/settings/labels/:id", labelUpdate); |
src/web/live.ts +1 −1
| @@ -302,7 +302,7 @@ export const liveClientJs = /* js */ ` | ||
| 302 | 302 | 'label.created', 'label.updated', 'label.deleted', |
| 303 | 303 | 'run.queued', 'run.started', 'run.finished', 'app.serving', |
| 304 | 304 | 'app.created', 'app.updated', 'app.deleted', 'app.stopped', |
| 305 | 'limb.moved', 'limb.replan', 'limb.replan_taken_back', 'issue.assigned', 'pull.assigned', 'crew.added', 'crew.removed', | |
| 305 | 'repo.upstream_checked', 'limb.moved', 'limb.replan', 'limb.replan_taken_back', 'issue.assigned', 'pull.assigned', 'crew.added', 'crew.removed', | |
| 306 | 306 | ]; |
| 307 | 307 | var extra = (cfg.getAttribute('data-repos') || '').split(' ').filter(Boolean); |
| 308 | 308 | var watching = [repo].concat(extra).filter(function (r, i, all) { |
src/web/pages/repo.ts +2
| @@ -8,6 +8,7 @@ | ||
| 8 | 8 | * path segment and can never smuggle a path of its own. |
| 9 | 9 | */ |
| 10 | 10 | |
| 11 | import { followingLine } from "./upstream-follow.ts"; | |
| 11 | 12 | import { NOT_A_MEMBER } from "../../core/roles.ts"; |
| 12 | 13 | import { storageLimitMessage } from "../../core/storage.ts"; |
| 13 | 14 | import { canRead, listForks, type Repo } from "../../core/repos.ts"; |
| @@ -652,6 +653,7 @@ function aboutCard( | ||
| 652 | 653 | }), |
| 653 | 654 | )} |
| 654 | 655 | </ul> |
| 656 | ${followingLine(ctx, repo)} | |
| 655 | 657 | ${whereItLives} |
| 656 | 658 | ${when( |
| 657 | 659 | repo.visibility === "public" && repo.origin === null, |
src/web/pages/settings.ts +3 −1
| @@ -119,6 +119,7 @@ import { cutWaveFor } from "../../actions/cut_wave.ts"; | ||
| 119 | 119 | import { proposalTokensSection } from "./proposal-tokens.ts"; |
| 120 | 120 | import { secretsSection } from "./secrets.ts"; |
| 121 | 121 | import { checkMemoryAt } from "../../apps/check-memory.ts"; |
| 122 | import { followUpstreamCard } from "./upstream-follow.ts"; | |
| 122 | 123 | import { saveGrants } from "./subscriptions.ts"; |
| 123 | 124 | |
| 124 | 125 | type Section = |
| @@ -340,6 +341,7 @@ export async function settingsPage(ctx: Ctx): Promise<Response> { | ||
| 340 | 341 | { head: html`${icon("branch")}<h2>Branch and gates</h2>` }, |
| 341 | 342 | ); |
| 342 | 343 | const secrets = secretsSection(ctx, "repo", `${repo.ownerName}/${repo.name}`); |
| 344 | const upstream = followUpstreamCard(ctx, repo); | |
| 343 | 345 | const wave = card( |
| 344 | 346 | html`<form method="post" action="${base}/settings/wave"> |
| 345 | 347 | <p class="muted"> |
| @@ -393,7 +395,7 @@ export async function settingsPage(ctx: Ctx): Promise<Response> { | ||
| 393 | 395 | "Settings", |
| 394 | 396 | html`${lead}${mirror ? html`<div class="mt">${mirror}</div>` : null}<div class="mt">${body}</div>${trail |
| 395 | 397 | ? html`<div class="mt">${trail}</div>` |
| 396 | : null}<div class="mt">${wave}</div><div class="mt">${archive}</div>${proposals | |
| 398 | : null}${upstream ? html`<div class="mt">${upstream}</div>` : null}<div class="mt">${wave}</div><div class="mt">${archive}</div>${proposals | |
| 397 | 399 | ? html`<div class="mt">${proposals}</div>` |
| 398 | 400 | : null}${secrets |
| 399 | 401 | ? html`<div class="mt">${secrets}</div>` |
src/web/pages/upstream-follow.ts added +138
| @@ -0,0 +1,138 @@ | ||
| 1 | /** | |
| 2 | * Following an upstream (#3442), on the page: the settings card that turns | |
| 3 | * it on, points it and checks now; and the one line the repository's About | |
| 4 | * card shows admins. Every mutation is a same-origin POST through | |
| 5 | * `readForm`; the doors only word what `actions/upstream_follow.ts` answers. | |
| 6 | */ | |
| 7 | ||
| 8 | import { | |
| 9 | checkedWords, | |
| 10 | checkUpstreamNow, | |
| 11 | clockWords, | |
| 12 | FOLLOW_REFUSALS, | |
| 13 | followShown, | |
| 14 | setFollowUpstream, | |
| 15 | standing, | |
| 16 | upstreamLabel, | |
| 17 | } from "../../actions/upstream_follow.ts"; | |
| 18 | import { canAdmin, type Repo } from "../../core/repos.ts"; | |
| 19 | import { getFollow } from "../../core/upstream_follow.ts"; | |
| 20 | import type { Ctx } from "../../http/context.ts"; | |
| 21 | import { loadRepo, repoBase, repoNotFound } from "../chrome.ts"; | |
| 22 | import { field, readForm } from "../form.ts"; | |
| 23 | import { html, when, type Html } from "../html.ts"; | |
| 24 | import { icon } from "../icons.ts"; | |
| 25 | import { flashTo } from "../layout.ts"; | |
| 26 | import { btn, card, field as formField } from "../ui.ts"; | |
| 27 | ||
| 28 | /** HH:MM in UTC, with the instant for machines and the title for people. */ | |
| 29 | function clock(iso: string): Html { | |
| 30 | const at = new Date(iso); | |
| 31 | if (Number.isNaN(at.getTime())) return html``; | |
| 32 | const exact = `${at.toISOString().slice(0, 16).replace("T", " ")} UTC`; | |
| 33 | return html`<time datetime="${at.toISOString()}" title="${exact}">${clockWords(iso)}</time>`; | |
| 34 | } | |
| 35 | ||
| 36 | /** The About card's line, for admins; null for everyone else. */ | |
| 37 | export function followingLine(ctx: Ctx, repo: Repo): Html | null { | |
| 38 | const follow = followShown(ctx.app, repo, ctx.auth.user); | |
| 39 | if (follow === undefined || !follow.enabled) return null; | |
| 40 | return html`<p class="mt small muted" data-following-upstream> | |
| 41 | ${icon("sync")} Following | |
| 42 | <a href="${follow.url}" rel="noreferrer noopener">${upstreamLabel(follow.url)}</a> | |
| 43 | <span class="mono">${follow.branch}</span> | |
| 44 | ${follow.checkedAt === null ? null : html`· last checked ${clock(follow.checkedAt)}`} | |
| 45 | · ${standing(follow)} | |
| 46 | </p>`; | |
| 47 | } | |
| 48 | ||
| 49 | /** The settings card: on or off, the URL and branch, and "Check upstream now". */ | |
| 50 | export function followUpstreamCard(ctx: Ctx, repo: Repo): Html | null { | |
| 51 | if (!canAdmin(ctx.app.db, repo, ctx.auth.user)) return null; | |
| 52 | const follow = getFollow(ctx.app.db, repo.id); | |
| 53 | const base = repoBase(repo); | |
| 54 | return card( | |
| 55 | html`<form method="post" action="${base}/settings/upstream"> | |
| 56 | <p class="muted"> | |
| 57 | New upstream commits arrive as a pull; never a push to | |
| 58 | <span class="mono">${repo.defaultBranch}</span>. A check runs every 30 minutes. | |
| 59 | </p> | |
| 60 | ${formField({ | |
| 61 | label: "Upstream git URL", | |
| 62 | name: "url", | |
| 63 | value: follow?.url ?? "", | |
| 64 | placeholder: "https://github.com/owner/name.git", | |
| 65 | mono: true, | |
| 66 | spellcheck: false, | |
| 67 | hint: "A public upstream needs no credential. https or http only.", | |
| 68 | })} | |
| 69 | ${formField({ | |
| 70 | label: "Branch", | |
| 71 | name: "branch", | |
| 72 | value: follow?.branch ?? "main", | |
| 73 | mono: true, | |
| 74 | spellcheck: false, | |
| 75 | })} | |
| 76 | <div class="field"> | |
| 77 | <label class="check"> | |
| 78 | <input type="checkbox" name="enabled" ${follow?.enabled === true ? html`checked` : null} /> | |
| 79 | <span>Follow upstream</span> | |
| 80 | </label> | |
| 81 | </div> | |
| 82 | ${when( | |
| 83 | follow, | |
| 84 | (f) => html`<p class="hint" data-following-standing>${standing(f)}${ | |
| 85 | f.checkedAt === null ? "" : html` · last checked ${clock(f.checkedAt)}` | |
| 86 | }${f.error === "" ? "" : html` · ${f.error}`} · ${f.opened} proposed so far</p>`, | |
| 87 | )} | |
| 88 | <div class="form-actions">${btn("Save")}</div> | |
| 89 | </form> | |
| 90 | ${when( | |
| 91 | follow?.enabled === true, | |
| 92 | () => html`<form method="post" action="${base}/settings/upstream/check" class="mt"> | |
| 93 | ${btn("Check upstream now", { size: "sm", icon: "sync" })} | |
| 94 | </form>`, | |
| 95 | )}`, | |
| 96 | { head: html`${icon("sync")}<h2 id="upstream">Follow upstream</h2>` }, | |
| 97 | ); | |
| 98 | } | |
| 99 | ||
| 100 | // POST /:owner/:name/settings/upstream | |
| 101 | export async function followUpstreamUpdate(ctx: Ctx): Promise<Response> { | |
| 102 | const repo = loadRepo(ctx); | |
| 103 | if (!repo || !canAdmin(ctx.app.db, repo, ctx.auth.user)) return repoNotFound(ctx); | |
| 104 | const form = await readForm(ctx, "admin"); | |
| 105 | if (form instanceof Response) return form; | |
| 106 | const saved = setFollowUpstream(ctx.app, ctx.auth.user, repo, { | |
| 107 | url: field(form, "url"), | |
| 108 | branch: field(form, "branch"), | |
| 109 | enabled: form.get("enabled") !== null, | |
| 110 | }); | |
| 111 | return flashTo( | |
| 112 | ctx, | |
| 113 | `${repoBase(repo)}/settings#upstream`, | |
| 114 | saved.ok | |
| 115 | ? { | |
| 116 | message: saved.value.enabled | |
| 117 | ? `Following ${upstreamLabel(saved.value.url)} ${saved.value.branch}. The next check runs within a minute.` | |
| 118 | : "Following the upstream is off. Nothing is checked or proposed.", | |
| 119 | } | |
| 120 | : { tone: "danger", message: FOLLOW_REFUSALS[saved.error] }, | |
| 121 | ); | |
| 122 | } | |
| 123 | ||
| 124 | // POST /:owner/:name/settings/upstream/check | |
| 125 | export async function followUpstreamCheck(ctx: Ctx): Promise<Response> { | |
No test reaches this handler. Add three cases: a guest gets 404, an evil origin gets 403, and an admin gets 303 with the check's flash. |
||
| 126 | const repo = loadRepo(ctx); | |
| 127 | if (!repo || !canAdmin(ctx.app.db, repo, ctx.auth.user)) return repoNotFound(ctx); | |
| 128 | const form = await readForm(ctx, "admin"); | |
| 129 | if (form instanceof Response) return form; | |
| 130 | const checked = await checkUpstreamNow(ctx.app, ctx.auth.user, repo); | |
| 131 | return flashTo( | |
| 132 | ctx, | |
| 133 | `${repoBase(repo)}/settings#upstream`, | |
| 134 | checked.ok | |
| 135 | ? { message: checkedWords(checked.value) } | |
| 136 | : { tone: "danger", message: FOLLOW_REFUSALS[checked.error] }, | |
| 137 | ); | |
| 138 | } |
test/upstream-follow-web.test.ts added +207
| @@ -0,0 +1,207 @@ | ||
| 1 | /** | |
| 2 | * Following an upstream, at the doors (#3980): the API and the settings | |
| 3 | * card are an admin's; everyone else gets 404 and sees nothing. | |
| 4 | */ | |
| 5 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 6 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 7 | import { tmpdir } from "node:os"; | |
| 8 | import { join } from "node:path"; | |
| 9 | import { createApp, type App } from "../src/app.ts"; | |
| 10 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 11 | import { checkUpstream } from "../src/actions/upstream_follow.ts"; | |
| 12 | import { listEvents } from "../src/core/events.ts"; | |
| 13 | import { getRepo } from "../src/core/repos.ts"; | |
| 14 | import { createUser } from "../src/core/users.ts"; | |
| 15 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 16 | ||
| 17 | let dataDir = ""; | |
| 18 | let app: App; | |
| 19 | let servers: Servers; | |
| 20 | let base = ""; | |
| 21 | let alice = ""; | |
| 22 | let guest = ""; | |
| 23 | const URL_ = "https://example.com/up/stream.git"; | |
| 24 | ||
| 25 | type Body = { following: { url: string; branch: string; enabled: boolean } | null }; | |
| 26 | ||
| 27 | const signIn = async (username: string): Promise<string> => | |
| 28 | ( | |
| 29 | ( | |
| 30 | await fetch(`${base}/signin`, { | |
| 31 | method: "POST", | |
| 32 | body: new URLSearchParams({ username, password: "a-good-passphrase" }), | |
| 33 | redirect: "manual", | |
| 34 | }) | |
| 35 | ).headers.get("set-cookie") ?? "" | |
| 36 | ).split(";")[0]!; | |
| 37 | ||
| 38 | const api = (cookie: string, method: string, path: string, json?: object, origin = base) => | |
| 39 | fetch(`${base}/api/v1/repos/alice/square/upstream${path}`, { | |
| 40 | method, | |
| 41 | headers: { | |
| 42 | ...(cookie ? { cookie } : {}), | |
| 43 | origin, | |
| 44 | "content-type": "application/json", | |
| 45 | }, | |
| 46 | ...(json ? { body: JSON.stringify(json) } : {}), | |
| 47 | }); | |
| 48 | ||
| 49 | const read = async (cookie: string): Promise<Body> => (await api(cookie, "GET", "")).json() as Promise<Body>; | |
| 50 | ||
| 51 | beforeAll(async () => { | |
| 52 | dataDir = await mkdtemp(join(tmpdir(), "forge-follow-web-")); | |
| 53 | app = createApp({ dataDir, port: 0 }); | |
| 54 | const make = async (username: string, extra: object = {}) => { | |
| 55 | const made = await createUser(app.db, { | |
| 56 | origin: "seed", | |
| 57 | trusted: true, | |
| 58 | username, | |
| 59 | email: `${username}@forge.local`, | |
| 60 | password: "a-good-passphrase", | |
| 61 | ...extra, | |
| 62 | }); | |
| 63 | if (!made.ok) throw new Error(JSON.stringify(made.error)); | |
| 64 | return made.value; | |
| 65 | }; | |
| 66 | const owner = await make("alice", { adminIfFirst: true }); | |
| 67 | await make("guest"); | |
| 68 | const made = await createRepo(app, owner, { name: "square", visibility: "public" }); | |
| 69 | if (!made.ok) throw new Error(JSON.stringify(made.error)); | |
| 70 | const seeded = await commitToBranch(app, owner, made.value, { | |
| 71 | message: "Start", | |
| 72 | files: [{ path: "README.md", content: "# square\n" }], | |
| 73 | }); | |
| 74 | if (!seeded.ok) throw new Error(JSON.stringify(seeded.error)); | |
| 75 | servers = await serve(app); | |
| 76 | base = servers.url; | |
| 77 | alice = await signIn("alice"); | |
| 78 | guest = await signIn("guest"); | |
| 79 | }); | |
| 80 | ||
| 81 | afterAll(async () => { | |
| 82 | servers?.stop(); | |
| 83 | app?.close(); | |
| 84 | await rm(dataDir, { recursive: true, force: true }); | |
| 85 | }); | |
| 86 | ||
| 87 | describe("the API", () => { | |
| 88 | test("anonymous is 401, a stranger is 404, the admin reads null", async () => { | |
| 89 | expect((await api("", "GET", "")).status).toBe(401); | |
| 90 | expect((await api(guest, "GET", "")).status).toBe(404); | |
| 91 | const read = await api(alice, "GET", ""); | |
| 92 | expect(read.status).toBe(200); | |
| 93 | expect(await read.json()).toEqual({ following: null }); | |
| 94 | }); | |
| 95 | ||
| 96 | test("a stranger cannot set or check; a cross-origin set is refused", async () => { | |
| 97 | const put = { url: URL_, branch: "main", enabled: true }; | |
| 98 | expect((await api(guest, "PUT", "", put)).status).toBe(404); | |
| 99 | expect((await api(guest, "POST", "/check")).status).toBe(404); | |
| 100 | expect((await api(alice, "PUT", "", put, "https://evil.example")).status).toBe(403); | |
| 101 | expect((await read(alice))).toEqual({ following: null }); | |
| 102 | }); | |
| 103 | ||
| 104 | test("input is validated by upstreamUrl and the branch is checked", async () => { | |
| 105 | for (const url of ["git://example.com/a.git", "ssh://[email protected]/a", "https://u:[email protected]/a", "file:///etc"]) { | |
| 106 | const res = await api(alice, "PUT", "", { url, branch: "main", enabled: true }); | |
| 107 | expect(res.status).toBe(422); | |
| 108 | expect(((await res.json()) as { error: string }).error).toBe("invalid_url"); | |
| 109 | } | |
| 110 | const bad = await api(alice, "PUT", "", { url: URL_, branch: "-x", enabled: true }); | |
| 111 | expect(((await bad.json()) as { error: string }).error).toBe("invalid_branch"); | |
| 112 | expect((await api(alice, "PUT", "", { url: URL_ })).status).toBe(422); | |
| 113 | }); | |
| 114 | ||
| 115 | test("an admin sets, reads, checks and turns it off", async () => { | |
| 116 | const off = await api(alice, "POST", "/check"); | |
| 117 | expect([off.status, ((await off.json()) as { error: string }).error]).toEqual([422, "off"]); | |
| 118 | const set = await api(alice, "PUT", "", { url: URL_, branch: "main", enabled: true }); | |
| 119 | expect(set.status).toBe(200); | |
| 120 | expect(((await set.json()) as Body).following).toMatchObject({ url: URL_, branch: "main", enabled: true }); | |
| 121 | expect((await read(alice)).following?.enabled).toBe(true); | |
| 122 | // Unreachable from here: the check fails and says why, it does not throw. | |
| 123 | const checked = await api(alice, "POST", "/check"); | |
| 124 | expect([200, 422]).toContain(checked.status); | |
| 125 | const cleared = await api(alice, "PUT", "", { url: "", branch: "", enabled: false }); | |
| 126 | expect(((await cleared.json()) as Body).following).toMatchObject({ url: URL_, enabled: false }); | |
| 127 | }); | |
| 128 | }); | |
| 129 | ||
| 130 | describe("the pages", () => { | |
| 131 | const settings = async (cookie: string) => | |
| 132 | (await fetch(`${base}/alice/square/settings`, { headers: cookie ? { cookie } : {} })).text(); | |
| 133 | const about = async (cookie: string) => | |
| 134 | (await fetch(`${base}/alice/square`, { headers: cookie ? { cookie } : {} })).text(); | |
| 135 | ||
| 136 | test("the card renders for an admin and the About line shows only when on", async () => { | |
| 137 | expect(await settings(alice)).toContain("Follow upstream"); | |
| 138 | expect(await about(alice)).not.toContain("data-following-upstream"); | |
| 139 | await api(alice, "PUT", "", { url: URL_, branch: "main", enabled: true }); | |
| 140 | expect(await settings(alice)).toContain("Check upstream now"); | |
| 141 | expect(await about(alice)).toContain("data-following-upstream"); | |
| 142 | expect(await about(alice)).toContain("example.com/up/stream"); | |
| 143 | }); | |
| 144 | ||
| 145 | test("a guest and a stranger see none of it", async () => { | |
| 146 | expect(await about(guest)).not.toContain("data-following-upstream"); | |
| 147 | expect(await about("")).not.toContain("example.com/up/stream"); | |
| 148 | expect(await settings(guest)).not.toContain("Check upstream now"); | |
| 149 | }); | |
| 150 | ||
| 151 | test("the form posts are an admin's and same-origin", async () => { | |
| 152 | const post = (cookie: string, origin: string) => | |
| 153 | fetch(`${base}/alice/square/settings/upstream`, { | |
| 154 | method: "POST", | |
| 155 | headers: { cookie, origin }, | |
| 156 | body: new URLSearchParams({ url: "https://example.com/other.git", branch: "dev", enabled: "on" }), | |
| 157 | redirect: "manual", | |
| 158 | }); | |
| 159 | expect((await post(guest, base)).status).toBe(404); | |
| 160 | expect((await post(alice, "https://evil.example")).status).toBe(403); | |
| 161 | expect((await post(alice, base)).status).toBe(303); | |
| 162 | expect((await read(alice)).following?.branch).toBe("dev"); | |
| 163 | }); | |
| 164 | ||
| 165 | test("the check post is an admin's and same-origin, and says what it found", async () => { | |
| 166 | const check = (cookie: string, origin: string) => | |
| 167 | fetch(`${base}/alice/square/settings/upstream/check`, { | |
| 168 | method: "POST", | |
| 169 | headers: { cookie, origin }, | |
| 170 | redirect: "manual", | |
| 171 | }); | |
| 172 | const flashOf = (res: Response) => decodeURIComponent(res.headers.get("set-cookie") ?? "") + (res.headers.get("location") ?? ""); | |
| 173 | expect((await check(guest, base)).status).toBe(404); | |
| 174 | expect((await check(alice, "https://evil.example")).status).toBe(403); | |
| 175 | // On, but nothing reachable from here: the admin is told, not thrown at. | |
| 176 | await api(alice, "PUT", "", { url: URL_, branch: "main", enabled: true }); | |
| 177 | const done = await check(alice, base); | |
| 178 | expect(done.status).toBe(303); | |
| 179 | expect(done.headers.get("location")).toContain("/alice/square/settings#upstream"); | |
| 180 | // Off: the same post answers that the check is off. | |
| 181 | await api(alice, "PUT", "", { url: "", branch: "", enabled: false }); | |
| 182 | const off = await check(alice, base); | |
| 183 | expect(off.status).toBe(303); | |
| 184 | expect(flashOf(off)).toContain("Following an upstream is off"); | |
| 185 | }); | |
| 186 | }); | |
| 187 | ||
| 188 | describe("the check's event", () => { | |
| 189 | const checked = () => | |
| 190 | listEvents(app.db, {}).filter((e) => e.kind === "repo.upstream_checked"); | |
| 191 | ||
| 192 | test("one event with an empty payload per check, and none when it is off", async () => { | |
| 193 | const repo = getRepo(app.db, "alice", "square")!; | |
| 194 | const put = (enabled: boolean) => | |
| 195 | api(alice, "PUT", "", { url: URL_, branch: "main", enabled }); | |
| 196 | await put(false); | |
| 197 | const before = checked().length; | |
| 198 | expect((await checkUpstream(app, repo)).ok).toBe(false); | |
| 199 | expect(checked().length).toBe(before); | |
| 200 | await put(true); | |
| 201 | await checkUpstream(app, repo); | |
| 202 | const after = checked(); | |
| 203 | expect(after.length).toBe(before + 1); | |
| 204 | expect(after.at(-1)?.payload).toEqual({}); | |
| 205 | expect(after.at(-1)?.repoId).toBe(repo.id); | |
| 206 | }); | |
| 207 | }); |
new above
offline
New behaviour with no test. Assert that one
repo.upstream_checkedevent is appended withpayload: {}after a check, and that none is appended when following is off.