Public: anyone can read this; the crew can change it.
The studio can make an app but cannot change what it says it is #249
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/230-the-studio-can-make-an-app-but-cannot-ch → main 1 commit fork
2 files
changed +244
−13
src/web/studio/crew.ts +69 −12
| @@ -1,19 +1,27 @@ | ||
| 1 | 1 | /** |
| 2 | * The studio's own settings: who is on the crew, and how far it may go on | |
| 3 | * its own — the two decisions that belong to the studio's story, told from | |
| 4 | * one page reached from its head. Everything here posts to the very same | |
| 5 | * routes dev's Crew and Autonomy pages already use (`crewAdd`, `crewRemove`, | |
| 6 | * `crewRoleUpdate`, `autonomySettingsUpdate`) — same same-origin check, same | |
| 7 | * authorization, same errors. This module only draws the page; it never | |
| 8 | * decides anything a mutation already decides. | |
| 2 | * The studio's own settings: what the app says it is, who is on the crew, | |
| 3 | * and how far it may go on its own — the decisions that belong to the | |
| 4 | * studio's story, told from one page reached from its head. Everything here | |
| 5 | * posts to the very same routes dev's General, Crew and Autonomy pages | |
| 6 | * already use (`settingsUpdate`, `crewAdd`, `crewRemove`, `crewRoleUpdate`, | |
| 7 | * `autonomySettingsUpdate`) — same same-origin check, same authorization, | |
| 8 | * same errors. This module only draws the page; it never decides anything a | |
| 9 | * mutation already decides. | |
| 10 | * | |
| 11 | * The description and visibility form posts the same fields dev's General | |
| 12 | * page always sends (`protectDefault`/`requireReview`/`requireChecks`) as | |
| 13 | * hidden inputs carrying the repo's current values, so a save from here | |
| 14 | * cannot silently turn off branch protection it never showed — the same | |
| 15 | * trick `autonomyCard` already plays with `direction`. | |
| 9 | 16 | * |
| 10 | 17 | * Crew and autonomy are the owner's own book-keeping, same as the app page's |
| 11 | 18 | * rail (`canSeeOwnerFacts`): a viewer who cannot write here is told that, |
| 12 | * not shown the roster or the day's spend. The forms that change either are | |
| 13 | * drawn only for who the actions themselves would let post — an admin — | |
| 14 | * so nothing here offers a control that would just come back refused. | |
| 15 | * Everything else about the repository — visibility, branches, labels, | |
| 16 | * webhooks, deletion — stays dev's, one link away. | |
| 19 | * not shown the roster or the day's spend. The forms that change any of | |
| 20 | * this are drawn only for who the actions themselves would let post — an | |
| 21 | * admin — so nothing here offers a control that would just come back | |
| 22 | * refused. Everything else about the repository — branches, labels, | |
| 23 | * webhooks, the app's own environment, deletion or disabling — stays | |
| 24 | * dev's, one link away. | |
| 17 | 25 | */ |
| 18 | 26 | |
| 19 | 27 | import { listAllAgents } from "../../actions/crew.ts"; |
| @@ -43,6 +51,54 @@ const ROLE_WORD: Record<CrewRole, string> = { | ||
| 43 | 51 | planner: "plans", |
| 44 | 52 | }; |
| 45 | 53 | |
| 54 | /** | |
| 55 | * The sentence and the ceiling the New app page asked for, editable again: | |
| 56 | * posts straight to `settingsUpdate` (`POST /:owner/:name/settings`, the | |
| 57 | * same one dev's General page uses), with hidden inputs carrying the branch | |
| 58 | * protection fields that form always sends so this save can't reset them. | |
| 59 | */ | |
| 60 | function aboutCard(repo: Repo, base: string, admin: boolean): Html | null { | |
| 61 | if (!admin) return null; | |
| 62 | return card( | |
| 63 | html`<form method="post" action="${base}/settings"> | |
| 64 | ${field({ | |
| 65 | label: "Description", | |
| 66 | name: "description", | |
| 67 | value: repo.description, | |
| 68 | hint: "One line, shown wherever this app is listed.", | |
| 69 | })} | |
| 70 | ${selectField({ | |
| 71 | label: "Visibility", | |
| 72 | name: "visibility", | |
| 73 | hint: "A private app and one that does not exist look identical to anyone else.", | |
| 74 | options: [ | |
| 75 | { | |
| 76 | value: "private", | |
| 77 | label: "Private", | |
| 78 | selected: repo.visibility === "private", | |
| 79 | }, | |
| 80 | { | |
| 81 | value: "public", | |
| 82 | label: "Public", | |
| 83 | selected: repo.visibility === "public", | |
| 84 | }, | |
| 85 | ], | |
| 86 | })} | |
| 87 | ${repo.protectDefault | |
| 88 | ? html`<input type="hidden" name="protect" value="on" />` | |
| 89 | : null} | |
| 90 | ${repo.requireReview | |
| 91 | ? html`<input type="hidden" name="requireReview" value="on" />` | |
| 92 | : null} | |
| 93 | ${repo.requireChecks | |
| 94 | ? html`<input type="hidden" name="requireChecks" value="on" />` | |
| 95 | : null} | |
| 96 | <div class="form-actions">${btn("Save", { variant: "primary" })}</div> | |
| 97 | </form>`, | |
| 98 | { head: html`${icon("pencil")}<h2>About</h2>` }, | |
| 99 | ); | |
| 100 | } | |
| 101 | ||
| 46 | 102 | function crewCard(ctx: Ctx, repo: Repo, base: string, admin: boolean): Html { |
| 47 | 103 | const db = ctx.app.db; |
| 48 | 104 | const crew = listCrewRoles(db, repo.id); |
| @@ -263,6 +319,7 @@ export function studioCrewSettings( | ||
| 263 | 319 | </div>` |
| 264 | 320 | : html`${repoHeader(ctx, repo, "settings", { compact: true })} |
| 265 | 321 | <div class="stack"> |
| 322 | ${aboutCard(repo, base, admin)} | |
| 266 | 323 | ${crewCard(ctx, repo, base, admin)} |
| 267 | 324 | ${autonomyCard(ctx, repo, base, admin)} |
| 268 | 325 | <p class="muted small"> |
test/studio.test.ts +175 −1
| @@ -31,7 +31,11 @@ import { recordDecision } from "../src/core/decisions.ts"; | ||
| 31 | 31 | import { getIssue, listIssues, type Issue } from "../src/core/issues.ts"; |
| 32 | 32 | import { recordLook } from "../src/core/looks.ts"; |
| 33 | 33 | import { getPull, mergedPullCount, type Pull } from "../src/core/pulls.ts"; |
| 34 | import { setDirectionIssue, type Repo } from "../src/core/repos.ts"; | |
| 34 | import { | |
| 35 | getRepoById, | |
| 36 | setDirectionIssue, | |
| 37 | type Repo, | |
| 38 | } from "../src/core/repos.ts"; | |
| 35 | 39 | import { |
| 36 | 40 | agentRunTotals, |
| 37 | 41 | claimRun, |
| @@ -6970,3 +6974,173 @@ describe("the front door asks about a person's own change too (#208)", () => { | ||
| 6970 | 6974 | expect(setIssueState(app, alice, repo, b, "closed").ok).toBe(true); |
| 6971 | 6975 | }); |
| 6972 | 6976 | }); |
| 6977 | ||
| 6978 | /** | |
| 6979 | * Issue #230: the studio's settings page can change the two facts it | |
| 6980 | * prints most about an app — description and visibility — by posting to | |
| 6981 | * the very same `/:owner/:name/settings` route dev's General page uses. | |
| 6982 | */ | |
| 6983 | describe("the studio can change what an app says it is (#230)", () => { | |
| 6984 | let dataDir: string; | |
| 6985 | let app: App; | |
| 6986 | let servers: Servers; | |
| 6987 | let base: string; | |
| 6988 | let repo: Repo; | |
| 6989 | let ownerCookie: string; | |
| 6990 | let readerCookie: string; | |
| 6991 | ||
| 6992 | const signIn = async (username: string): Promise<string> => | |
| 6993 | ( | |
| 6994 | ( | |
| 6995 | await fetch(`${base}/signin`, { | |
| 6996 | method: "POST", | |
| 6997 | body: new URLSearchParams({ | |
| 6998 | username, | |
| 6999 | password: "a-good-passphrase", | |
| 7000 | }), | |
| 7001 | redirect: "manual", | |
| 7002 | }) | |
| 7003 | ).headers.get("set-cookie") ?? "" | |
| 7004 | ).split(";")[0]!; | |
| 7005 | ||
| 7006 | const get = (path: string, cookie?: string) => | |
| 7007 | fetch(`${base}${path}`, { | |
| 7008 | headers: { | |
| 7009 | cookie: | |
| 7010 | cookie === undefined | |
| 7011 | ? "forge_mode=studio" | |
| 7012 | : `${cookie}; forge_mode=studio`, | |
| 7013 | }, | |
| 7014 | redirect: "manual", | |
| 7015 | }); | |
| 7016 | ||
| 7017 | const post = (path: string, body: Record<string, string>, cookie: string) => | |
| 7018 | fetch(`${base}${path}`, { | |
| 7019 | method: "POST", | |
| 7020 | headers: { | |
| 7021 | cookie: `${cookie}; forge_mode=studio`, | |
| 7022 | origin: base, | |
| 7023 | }, | |
| 7024 | body: new URLSearchParams(body), | |
| 7025 | redirect: "manual", | |
| 7026 | }); | |
| 7027 | ||
| 7028 | beforeAll(async () => { | |
| 7029 | dataDir = await mkdtemp(join(tmpdir(), "forge-studio-about-")); | |
| 7030 | app = createApp({ dataDir, port: 0 }); | |
| 7031 | servers = await serve(app); | |
| 7032 | base = servers.url; | |
| 7033 | const alice = await createUser(app.db, { | |
| 7034 | username: "alice", | |
| 7035 | email: "[email protected]", | |
| 7036 | password: "a-good-passphrase", | |
| 7037 | adminIfFirst: true, | |
| 7038 | }); | |
| 7039 | if (!alice.ok) throw new Error(alice.error); | |
| 7040 | const carol = await createUser(app.db, { | |
| 7041 | username: "carol", | |
| 7042 | email: "[email protected]", | |
| 7043 | password: "a-good-passphrase", | |
| 7044 | }); | |
| 7045 | if (!carol.ok) throw new Error(carol.error); | |
| 7046 | ownerCookie = await signIn("alice"); | |
| 7047 | readerCookie = await signIn("carol"); | |
| 7048 | const made = await createRepo(app, alice.value, { | |
| 7049 | name: "lamp", | |
| 7050 | visibility: "public", | |
| 7051 | description: "lights the hallway", | |
| 7052 | }); | |
| 7053 | if (!made.ok) throw new Error(String(made.error)); | |
| 7054 | repo = made.value; | |
| 7055 | const seeded = await commitToBranch(app, alice.value, repo, { | |
| 7056 | message: "Start", | |
| 7057 | files: [{ path: "README.md", content: "lamp\n" }], | |
| 7058 | }); | |
| 7059 | if (!seeded.ok) throw new Error(JSON.stringify(seeded.error)); | |
| 7060 | // Branch protection is on and unexposed by the studio's form — proof | |
| 7061 | // that saving from here does not silently turn it off. | |
| 7062 | const protectedOn = await updateRepoSettings(app, alice.value, repo, { | |
| 7063 | protectDefault: true, | |
| 7064 | requireReview: true, | |
| 7065 | requireChecks: true, | |
| 7066 | }); | |
| 7067 | if (!protectedOn.ok) throw new Error(String(protectedOn.error)); | |
| 7068 | repo = protectedOn.value; | |
| 7069 | const enabled = await enableApp(app, alice.value, repo, { slug: "lamp" }); | |
| 7070 | if (!enabled.ok) throw new Error(String(enabled.error)); | |
| 7071 | }); | |
| 7072 | ||
| 7073 | afterAll(async () => { | |
| 7074 | servers.stop(); | |
| 7075 | app.close(); | |
| 7076 | await rm(dataDir, { recursive: true, force: true }); | |
| 7077 | }); | |
| 7078 | ||
| 7079 | test("a writer changes the description and visibility from the studio, and the app's page and the shelf row show it", async () => { | |
| 7080 | // The rendered form carries the branch-protection fields it never shows | |
| 7081 | // as hidden inputs, so a real submit of it — exactly what's posted here | |
| 7082 | // — cannot silently turn them off. | |
| 7083 | const before = await ( | |
| 7084 | await get("/alice/lamp/settings", ownerCookie) | |
| 7085 | ).text(); | |
| 7086 | expect(before).toContain('<input type="hidden" name="protect" value="on"'); | |
| 7087 | expect(before).toContain( | |
| 7088 | '<input type="hidden" name="requireReview" value="on"', | |
| 7089 | ); | |
| 7090 | expect(before).toContain( | |
| 7091 | '<input type="hidden" name="requireChecks" value="on"', | |
| 7092 | ); | |
| 7093 | ||
| 7094 | const saved = await post( | |
| 7095 | "/alice/lamp/settings", | |
| 7096 | { | |
| 7097 | description: "a lamp that stays lit all night", | |
| 7098 | visibility: "private", | |
| 7099 | protect: "on", | |
| 7100 | requireReview: "on", | |
| 7101 | requireChecks: "on", | |
| 7102 | }, | |
| 7103 | ownerCookie, | |
| 7104 | ); | |
| 7105 | expect(saved.status).toBe(303); | |
| 7106 | expect(saved.headers.get("location")).toBe("/alice/lamp/settings"); | |
| 7107 | ||
| 7108 | const ownPage = await ( | |
| 7109 | await get("/alice/lamp?mode=dev", ownerCookie) | |
| 7110 | ).text(); | |
| 7111 | expect(ownPage).toContain("a lamp that stays lit all night"); | |
| 7112 | expect(ownPage).toContain("Private"); | |
| 7113 | ||
| 7114 | const appHead = await (await get("/alice/lamp/app", ownerCookie)).text(); | |
| 7115 | expect(appHead).toContain("Private"); | |
| 7116 | ||
| 7117 | const shelf = await (await get("/", ownerCookie)).text(); | |
| 7118 | expect(shelf).toContain("a lamp that stays lit all night"); | |
| 7119 | ||
| 7120 | const list = await (await get("/apps", ownerCookie)).text(); | |
| 7121 | expect(list).toContain("a lamp that stays lit all night"); | |
| 7122 | ||
| 7123 | // Branch protection, never shown on this form, is exactly as it was. | |
| 7124 | const dbRepo = getRepoById(app.db, repo.id); | |
| 7125 | expect(dbRepo?.protectDefault).toBe(true); | |
| 7126 | expect(dbRepo?.requireReview).toBe(true); | |
| 7127 | expect(dbRepo?.requireChecks).toBe(true); | |
| 7128 | }); | |
| 7129 | ||
| 7130 | test("a reader gets neither control: no form on the settings page, and the post is refused", async () => { | |
| 7131 | const settings = await ( | |
| 7132 | await get("/alice/lamp/settings", readerCookie) | |
| 7133 | ).text(); | |
| 7134 | expect(settings).not.toContain('action="/alice/lamp/settings"'); | |
| 7135 | expect(settings).not.toContain("<textarea"); | |
| 7136 | ||
| 7137 | const refused = await post( | |
| 7138 | "/alice/lamp/settings", | |
| 7139 | { description: "carol was here", visibility: "public" }, | |
| 7140 | readerCookie, | |
| 7141 | ); | |
| 7142 | expect(refused.status).toBe(404); | |
| 7143 | const dbRepo = getRepoById(app.db, repo.id); | |
| 7144 | expect(dbRepo?.description).not.toBe("carol was here"); | |
| 7145 | }); | |
| 7146 | }); |
new above
offline