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

Merged opened this

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

Merged

scout/forge:scout/230-the-studio-can-make-an-app-but-cannot-ch → main 1 commit fork

  • Merged. scout/230-the-studio-can-make-an-app-but-cannot-ch was merged into main as f03b9e4.

Review

Sign in to review this change.
2 files changed +244 −13
src/web/studio/crew.ts +69 −12
@@ -1,19 +1,27 @@
11 /**
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`.
916 *
1017 * Crew and autonomy are the owner's own book-keeping, same as the app page's
1118 * 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.
1725 */
1826
1927 import { listAllAgents } from "../../actions/crew.ts";
@@ -43,6 +51,54 @@ const ROLE_WORD: Record<CrewRole, string> = {
4351 planner: "plans",
4452 };
4553
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
46102 function crewCard(ctx: Ctx, repo: Repo, base: string, admin: boolean): Html {
47103 const db = ctx.app.db;
48104 const crew = listCrewRoles(db, repo.id);
@@ -263,6 +319,7 @@ export function studioCrewSettings(
263319 </div>`
264320 : html`${repoHeader(ctx, repo, "settings", { compact: true })}
265321 <div class="stack">
322 ${aboutCard(repo, base, admin)}
266323 ${crewCard(ctx, repo, base, admin)}
267324 ${autonomyCard(ctx, repo, base, admin)}
268325 <p class="muted small">
test/studio.test.ts +175 −1
@@ -31,7 +31,11 @@ import { recordDecision } from "../src/core/decisions.ts";
3131 import { getIssue, listIssues, type Issue } from "../src/core/issues.ts";
3232 import { recordLook } from "../src/core/looks.ts";
3333 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";
3539 import {
3640 agentRunTotals,
3741 claimRun,
@@ -6970,3 +6974,173 @@ describe("the front door asks about a person's own change too (#208)", () => {
69706974 expect(setIssueState(app, alice, repo, b, "closed").ok).toBe(true);
69716975 });
69726976 });
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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs