Public: anyone can read this; the crew can change it.
A repository's Settings page moves it to another owner, as the API and terminal already do #10118
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/10099-a-repository-s-settings-page-moves-it-to → main 3 commits fork 1 thread · 1 unresolved
5 files
changed +210
−4
docs/features/repo-transfer.md +5 −1
| @@ -1,6 +1,10 @@ | ||
| 1 | 1 | # Moving a repository to another owner |
| 2 | 2 | |
| 3 | From the terminal: on a repository's screen, `:` then "Move this repository | |
| 3 | From the web: a repository's Settings page has a "Move to another owner" block | |
| 4 | (an admin's, beside Archive): type the new owner, confirm, and you land on | |
| 5 | Settings at the new path. A refusal comes back beside the field as its | |
| 6 | sentence, the field kept; anyone who does not administer the repository gets | |
| 7 | `404`. From the terminal: on a repository's screen, `:` then "Move this repository | |
| 4 | 8 | to another owner", and type the new owner. From the API: |
| 5 | 9 | `POST /api/v1/repos/:owner/:name/transfer` with `{"newOwner": "<account or org>"}` |
| 6 | 10 | moves a repository (an `rw` token; the caller administers the repository and |
src/http/server.ts +2
| @@ -379,6 +379,7 @@ import { | ||
| 379 | 379 | settingsUpdate, |
| 380 | 380 | waveCut, |
| 381 | 381 | archiveSubmit, |
| 382 | transferSubmit, | |
| 382 | 383 | provenanceSubmit, |
| 383 | 384 | mirrorStop, |
| 384 | 385 | } from "../web/pages/settings.ts"; |
| @@ -1415,6 +1416,7 @@ export function buildRouter(): Router<Handler> { | ||
| 1415 | 1416 | router.add("POST", "/:owner/:name/settings", settingsUpdate); |
| 1416 | 1417 | router.add("POST", "/:owner/:name/settings/wave", waveCut); |
| 1417 | 1418 | router.add("POST", "/:owner/:name/settings/archive", archiveSubmit); |
| 1419 | router.add("POST", "/:owner/:name/settings/transfer", transferSubmit); | |
| 1418 | 1420 | router.add("POST", "/:owner/:name/settings/provenance", provenanceSubmit); |
| 1419 | 1421 | router.add("POST", "/:owner/:name/settings/mirror/stop", mirrorStop); |
| 1420 | 1422 | router.add("POST", "/:owner/:name/settings/proposal-tokens", proposalTokenMint); |
src/web/pages/search.ts +16 −1
| @@ -550,6 +550,8 @@ interface Destructive { | ||
| 550 | 550 | detail: string; |
| 551 | 551 | /** A phrase the visitor must type back before the button will work. */ |
| 552 | 552 | phrase: string; |
| 553 | /** A form value the settings page typed that the confirmed POST needs back. */ | |
| 554 | carries?: string; | |
| 553 | 555 | } |
| 554 | 556 | |
| 555 | 557 | /** |
| @@ -571,6 +573,17 @@ const DESTRUCTIVE: { | ||
| 571 | 573 | phrase: `${m[1]}/${m[2]}`, |
| 572 | 574 | }), |
| 573 | 575 | }, |
| 576 | { | |
| 577 | pattern: /^\/([^/]+)\/([^/]+)\/settings\/transfer$/, | |
| 578 | describe: (m) => ({ | |
| 579 | verb: "Move", | |
| 580 | title: `Move ${m[1]}/${m[2]} to another owner?`, | |
| 581 | detail: | |
| 582 | "The repository, its issues and pulls move to the new owner and its address changes; the old address redirects. You must be able to create repositories under the new owner.", | |
| 583 | phrase: "", | |
| 584 | carries: "owner", | |
| 585 | }), | |
| 586 | }, | |
| 574 | 587 | { |
| 575 | 588 | pattern: /^\/([^/]+)\/([^/]+)\/settings\/detach$/, |
| 576 | 589 | describe: (m) => ({ |
| @@ -912,7 +925,8 @@ function confirmBody(ctx: Ctx): Response { | ||
| 912 | 925 | // link therefore cannot invent reassuring words for a dangerous button, and |
| 913 | 926 | // cannot aim the form at an endpoint that was never meant for it. |
| 914 | 927 | if (!known) return notFoundPage(ctx); |
| 915 | const { verb, title, detail, phrase } = known; | |
| 928 | const { verb, title, detail, phrase, carries } = known; | |
| 929 | const carried = carries === undefined ? "" : (params.get(carries) ?? "").slice(0, 200); | |
| 916 | 930 | |
| 917 | 931 | const referer = ctx.request.headers.get("referer"); |
| 918 | 932 | let back = "/"; |
| @@ -926,6 +940,7 @@ function confirmBody(ctx: Ctx): Response { | ||
| 926 | 940 | ${pageHead(title)} |
| 927 | 941 | <p>${detail}</p> |
| 928 | 942 | <form method="post" action="${to}" class="mt"> |
| 943 | ${carries === undefined ? null : html`<input type="hidden" name="${carries}" value="${carried}" />`} | |
| 929 | 944 | ${when(phrase !== "", () => |
| 930 | 945 | formField({ |
| 931 | 946 | label: `Type ${phrase} to continue`, |
src/web/pages/settings.ts +55 −2
| @@ -51,6 +51,7 @@ import { | ||
| 51 | 51 | SCOPE_WORDS, |
| 52 | 52 | } from "../../actions/app_env_words.ts"; |
| 53 | 53 | import { AUTONOMY_SAID, checkAutonomy, isDecisionsBy } from "../../core/repos.ts"; |
| 54 | import { TRANSFER_SAID, TRANSFER_STATUS, transferRepository } from "../../actions/transfer.ts"; | |
| 54 | 55 | import { updateRepoSettings } from "../../actions/repos.ts"; |
| 55 | 56 | import { appsRuntime } from "../../apps/runtime.ts"; |
| 56 | 57 | import { nameFrame, notePhase, takeSessionTime, timePhase, timePhaseAsync } from "../../core/route_timing.ts"; |
| @@ -227,7 +228,11 @@ export async function adminForm( | ||
| 227 | 228 | * gates and the secrets, with the rest — the app, prompts, labels, |
| 228 | 229 | * webhooks, the danger zone — one quiet section over in the same frame. |
| 229 | 230 | */ |
| 230 | export async function settingsPage(ctx: Ctx): Promise<Response> { | |
| 231 | export async function settingsPage( | |
| 232 | ctx: Ctx, | |
| 233 | /** A refused move: the owner as typed and the sentence to show beside it. */ | |
| 234 | refused?: { owner: string; said: string; status: number }, | |
| 235 | ): Promise<Response> { | |
| 231 | 236 | nameFrame(ctx.request); |
| 232 | 237 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); |
| 233 | 238 | const [readable, repo] = timePhase(ctx.request, "repo", () => { |
| @@ -411,6 +416,28 @@ export async function settingsPage(ctx: Ctx): Promise<Response> { | ||
| 411 | 416 | </div> |
| 412 | 417 | </form> |
| 413 | 418 | </section>`; |
| 419 | const move = html`<section class="settings-block"> | |
| 420 | <h2>Move to another owner</h2> | |
| 421 | <form method="get" action="/-/confirm"> | |
| 422 | <input type="hidden" name="to" value="${base}/settings/transfer" /> | |
| 423 | <p class="muted"> | |
| 424 | Give the repository to an account or organisation you can create | |
| 425 | repositories under. | |
| 426 | </p> | |
| 427 | ${formField({ | |
| 428 | label: "New owner", | |
| 429 | name: "owner", | |
| 430 | value: refused?.owner ?? "", | |
| 431 | required: true, | |
| 432 | maxlength: 64, | |
| 433 | autocomplete: "off", | |
| 434 | spellcheck: false, | |
| 435 | mono: true, | |
| 436 | ...(refused ? { error: refused.said } : {}), | |
| 437 | })} | |
| 438 | <div class="form-actions">${btn("Move", { variant: "danger" })}</div> | |
| 439 | </form> | |
| 440 | </section>`; | |
| 414 | 441 | renderMs += performance.now() - since; |
| 415 | 442 | const held = timePhase(ctx.request, "mirror", () => getMirrorTarget(ctx.app.db, repo.id)); |
| 416 | 443 | since = performance.now(); |
| @@ -437,11 +464,13 @@ export async function settingsPage(ctx: Ctx): Promise<Response> { | ||
| 437 | 464 | "Settings", |
| 438 | 465 | html`${lead}${mirror ? html`<div class="mt">${mirror}</div>` : null}<div class="mt">${body}</div>${trail |
| 439 | 466 | ? html`<div class="mt">${trail}</div>` |
| 440 | : null}${upstream ? html`<div class="mt">${upstream}</div>` : null}${sources ? html`<div class="mt">${sources}</div>` : null}<div class="mt">${wave}</div><div class="mt">${archive}</div>${proposals | |
| 467 | : null}${upstream ? html`<div class="mt">${upstream}</div>` : null}${sources ? html`<div class="mt">${sources}</div>` : null}<div class="mt">${wave}</div><div class="mt">${archive}</div><div class="mt">${move}</div>${proposals | |
| 441 | 468 | ? html`<div class="mt">${proposals}</div>` |
| 442 | 469 | : null}${secrets |
| 443 | 470 | ? html`<div class="mt">${secrets}</div>` |
| 444 | 471 | : null}`, |
| 472 | undefined, | |
| 473 | refused?.status, | |
| 445 | 474 | ); |
| 446 | 475 | notePhase(ctx.request, "render", renderMs + performance.now() - since); |
| 447 | 476 | return response; |
| @@ -545,6 +574,30 @@ export async function archiveSubmit(ctx: Ctx): Promise<Response> { | ||
| 545 | 574 | ); |
| 546 | 575 | } |
| 547 | 576 | |
| 577 | /** | |
| 578 | * POST /:owner/:name/settings/transfer — move the repository to another | |
| 579 | * owner, through the action (never core). A refusal is the settings page | |
| 580 | * again, with the sentence beside the field and the field as typed. | |
| 581 | */ | |
| 582 | export async function transferSubmit(ctx: Ctx): Promise<Response> { | |
| 583 | const gate = await adminForm(ctx); | |
| 584 | if (gate instanceof Response) return gate; | |
| 585 | const { repo, form } = gate; | |
| 586 | const owner = (field(form, "owner") ?? "").trim(); | |
| 587 | const refuse = (said: string, status: number) => | |
| 588 | settingsPage(ctx, { owner: owner.slice(0, 64), said, status }); | |
| 589 | if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(owner)) | |
| 590 | return refuse(TRANSFER_SAID.unknown_owner, TRANSFER_STATUS.unknown_owner); | |
| 591 | const moved = await transferRepository(ctx.app, ctx.auth.user, repo, owner); | |
| 592 | if (!moved.ok) { | |
| 593 | if (moved.error === "not_found") return repoNotFound(ctx); | |
| 594 | return refuse(TRANSFER_SAID[moved.error], TRANSFER_STATUS[moved.error]); | |
| 595 | } | |
| 596 | return flashTo(ctx, `${repoBase(moved.value)}/settings`, { | |
| 597 | message: `Moved to ${moved.value.ownerName}/${moved.value.name}.`, | |
| 598 | }); | |
| 599 | } | |
| 600 | ||
| 548 | 601 | /** |
| 549 | 602 | * "Show where this came from" (#3293), drawn only for a repository that |
| 550 | 603 | * has a trail to show: the line as it reads, and the one switch for it. |
test/repo-transfer-web.test.ts added +132
| @@ -0,0 +1,132 @@ | ||
| 1 | /** | |
| 2 | * The Settings page moves a repository to another owner (#10099): the block is | |
| 3 | * an admin's, the confirm step carries the field, the POST calls the action, | |
| 4 | * and each refusal comes back on the page as its sentence with the field kept. | |
| 5 | */ | |
| 6 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 7 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 8 | import { tmpdir } from "node:os"; | |
| 9 | import { join } from "node:path"; | |
| 10 | import { createApp, type App } from "../src/app.ts"; | |
| 11 | import { createOrg } from "../src/actions/orgs.ts"; | |
| 12 | import { createRepo } from "../src/actions/repos.ts"; | |
| 13 | import { TRANSFER_SAID } from "../src/actions/transfer.ts"; | |
| 14 | import { createRepo as makeRepo } from "../src/core/repos.ts"; | |
| 15 | import { createUser, type User } from "../src/core/users.ts"; | |
| 16 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 17 | import { quietLogs } from "./helpers/globals.ts"; | |
| 18 | ||
| 19 | quietLogs(); | |
| 20 | ||
| 21 | const PASSWORD = "a-long-transfer-web-passphrase"; | |
| 22 | ||
| 23 | describe("moving a repository from Settings", () => { | |
| 24 | let dataDir = ""; | |
| 25 | let app: App; | |
| 26 | let servers: Servers; | |
| 27 | let alice: User; | |
| 28 | let bob: User; | |
| 29 | const cookies: Record<string, string> = {}; | |
| 30 | ||
| 31 | const post = (path: string, who: string, fields: Record<string, string>, origin: string | false = servers.url) => | |
| 32 | fetch(`${servers.url}${path}`, { | |
| 33 | method: "POST", | |
| 34 | redirect: "manual", | |
| 35 | headers: { cookie: cookies[who]!, ...(origin ? { origin } : {}) }, | |
| 36 | body: new URLSearchParams(fields), | |
| 37 | }); | |
| 38 | const get = (path: string, who: string) => | |
| 39 | fetch(`${servers.url}${path}`, { redirect: "manual", headers: { cookie: cookies[who]! } }); | |
| 40 | ||
| 41 | beforeAll(async () => { | |
| 42 | dataDir = await mkdtemp(join(tmpdir(), "forge-transfer-web-")); | |
| 43 | app = createApp({ dataDir, port: 0 }); | |
| 44 | servers = await serve(app); | |
| 45 | for (const username of ["alice", "bob", "carol"]) { | |
| 46 | const u = await createUser(app.db, { | |
| 47 | origin: "seed", | |
| 48 | trusted: true, | |
| 49 | username, | |
| 50 | email: `${username}@forge.local`, | |
| 51 | password: PASSWORD, | |
| 52 | adminIfFirst: username === "alice", | |
| 53 | }); | |
| 54 | if (!u.ok) throw new Error(u.error); | |
| 55 | if (username === "alice") alice = u.value; | |
| 56 | if (username === "bob") bob = u.value; | |
| 57 | const res = await fetch(`${servers.url}/signin`, { | |
| 58 | method: "POST", | |
| 59 | body: new URLSearchParams({ username, password: PASSWORD }), | |
| 60 | redirect: "manual", | |
| 61 | }); | |
| 62 | cookies[username] = (res.headers.get("set-cookie") ?? "").split(";")[0]!; | |
| 63 | } | |
| 64 | const org = createOrg(app, alice, "lab"); | |
| 65 | if (!org.ok) throw new Error(org.error); | |
| 66 | for (const name of ["notes", "taken", "other"]) { | |
| 67 | const r = await createRepo(app, alice, { name, visibility: "public" }); | |
| 68 | if (!r.ok) throw new Error(String(r.error)); | |
| 69 | } | |
| 70 | const mine = await makeRepo(app.db, app.reposRoot, { ownerId: bob.id, name: "mine", visibility: "public" }); | |
| 71 | if (!mine.ok) throw new Error(String(mine.error)); | |
| 72 | const dup = await createRepo(app, alice, { name: "taken", visibility: "public", owner: "lab" }); | |
| 73 | if (!dup.ok) throw new Error(String(dup.error)); | |
| 74 | }); | |
| 75 | ||
| 76 | afterAll(async () => { | |
| 77 | await servers.stop(); | |
| 78 | app.db.close(); | |
| 79 | await rm(dataDir, { recursive: true, force: true }); | |
| 80 | }); | |
| 81 | ||
| 82 | test("the block shows to the admin only, and its confirm step carries the field", async () => { | |
| 83 | const page = await (await get("/alice/notes/settings", "alice")).text(); | |
| 84 | expect(page).toContain("Move to another owner"); | |
| 85 | const reader = await (await get("/alice/notes/settings", "bob")).text(); | |
| 86 | expect(reader).not.toContain("Move to another owner"); | |
| 87 | const confirm = await ( | |
| 88 | await get(`/-/confirm?to=${encodeURIComponent("/alice/notes/settings/transfer")}&owner=lab`, "alice") | |
| 89 | ).text(); | |
| 90 | expect(confirm).toContain('name="owner" value="lab"'); | |
| 91 | const other = await ( | |
| 92 | await get(`/-/confirm?to=${encodeURIComponent("/alice/notes/settings/transfer")}&owner=lab&evil=x&confirm=y`, "alice") | |
| 93 | ).text(); | |
| 94 | expect(other).not.toContain('name="evil"'); | |
| 95 | expect(other).not.toContain('value="x"'); | |
| 96 | expect(other.match(/<input type="hidden"/g)?.length).toBe(1); | |
| 97 | }); | |
| 98 | ||
| 99 | test("a reader gets 404, and a cross-origin post is refused", async () => { | |
| 100 | expect((await post("/alice/notes/settings/transfer", "bob", { owner: "bob" })).status).toBe(404); | |
| 101 | const res = await post("/alice/notes/settings/transfer", "alice", { owner: "lab" }, "https://evil.example"); | |
| 102 | expect(res.status).toBe(403); | |
| 103 | expect((await get("/alice/notes", "alice")).status).toBe(200); | |
| 104 | }); | |
| 105 | ||
| 106 | const refusals: [string, string, string, number][] = [ | |
| 107 | ["name taken", "taken", "name_taken", 409], | |
| 108 | ["same owner", "alice", "same_owner", 409], | |
| 109 | ["unknown owner", "nobody", "unknown_owner", 422], | |
| 110 | ["forbidden owner", "carol", "forbidden_owner", 403], | |
| 111 | ]; | |
| 112 | for (const [label, owner, key, status] of refusals) { | |
| 113 | test(`refusal: ${label}`, async () => { | |
| 114 | const repo = key === "name_taken" ? "taken" : "other"; | |
| 115 | const target = key === "name_taken" ? "lab" : owner; | |
| 116 | const who = key === "forbidden_owner" ? "bob" : "alice"; | |
| 117 | const res = await post(`/${who}/${key === "forbidden_owner" ? "mine" : repo}/settings/transfer`, who, { owner: target }); | |
| 118 | expect(res.status).toBe(status); | |
| 119 | const text = await res.text(); | |
| 120 | expect(text).toContain(TRANSFER_SAID[key as keyof typeof TRANSFER_SAID].replace(/'/g, "'")); | |
| 121 | expect(text).toContain(`value="${target}"`); | |
| 122 | }); | |
| 123 | } | |
| 124 | ||
| 125 | test("a move redirects to Settings at the new path", async () => { | |
| 126 | const res = await post("/alice/notes/settings/transfer", "alice", { owner: "lab" }); | |
| 127 | expect(res.status).toBe(303); | |
| 128 | expect(res.headers.get("location")).toContain("/lab/notes/settings"); | |
| 129 | expect((await get("/lab/notes/settings", "alice")).status).toBe(200); | |
| 130 | expect((await get("/alice/notes", "alice")).status).toBe(308); | |
| 131 | }); | |
| 132 | }); |
Principle 1: this paragraph and the confirm page's detail say the same thing (address changes, old one redirects). Say it once; drop it here or shorten to one sentence.