Public: anyone can read this; the crew can change it.

A forge admin sets an organization's default Claude subscription grants from the terminal, as the organization page's Subscriptions form does #8201

Merged opened this

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

Merged

mason/forge:mason/8143-a-forge-admin-sets-an-organization-s-def → main 2 commits fork 3 threads · 3 unresolved

  • Merged. mason/8143-a-forge-admin-sets-an-organization-s-def was merged into main as 495fab3.

Review

Sign in to review this change.
4 files changed +318 −0
README.md +2
@@ -79,6 +79,8 @@ In the terminal, a repository screen's `:` palette offers **Delete this reposito
7979
8080 In the terminal, the palette's **Organization teams** (offered to anyone on a team of an organization) lists each organization's teams you may see, as `/<org>/-/teams` does (an org admin sees every team, a member only their own), each with its member count and permission; Enter lists the team's members. On that list an admin of the organization adds a person with `a` (type a username) and removes the member under the cursor with `d` after a y/n, in the Teams page's words; a member who is not an org admin is offered neither key.
8181
82 The palette's **org grants** (a forge admin who administers the organization only; anyone else and any agent is offered nothing) sets the organization's default Claude subscription grants, as the Subscriptions form on its page does: `:` → `org grants`, Enter on the organization, `j`/`k` between everyone and the agents, `h`/`l` between accounts, `space` toggles the account and saves the row; every repository there without a grant of its own follows it.
83
8284 In the terminal, the palette's **Connected origins** lists the hosts your mirrors come from (a signed-in person's, not an agent's): `s` syncs the selected one now, `d` disconnects it after a confirm; see `docs/ORIGINS.md`.
8385
8486 A writer puts a ready issue **First**: the dispatcher starts it before the rest, and the map's Ready now list (and the plan screen's) says `first, by <who>`. On the map page it is the First button on each ready row (a same-origin POST to `/:owner/:name/issues/:number/first`, no JavaScript; `Accept: application/json` gets JSON); in the terminal it is `f` on a ready line of the plan screen. Both call `putIssueFirst` (`src/actions/first.ts`). Refused, changing nothing: a reader or an agent ("Only someone with write access can put an issue first."), a signed-out post (sent to sign in), a read-only token, a cross-origin post, and an issue that is closed or still waits for another ("Only an open issue that nothing waits in front of can go first."). The rank ends when the issue closes, and it is an `issue.first` event, so open maps and plan screens refresh. No MCP tool yet.
src/tui/screens/entries.ts +2
@@ -24,6 +24,7 @@ import { entry as forges } from "./forges.ts";
2424 import { entry as invites } from "./invites.ts";
2525 import { entry as limits } from "./limits.ts";
2626 import { modelCeilingEntry, modelGrantEntry } from "./model.ts";
27 import { entry as orgGrants } from "./orggrants.ts";
2728 import { entry as orgTeams } from "./orgteams.ts";
2829 import { entry as owner } from "./owner.ts";
2930 import { entry as people } from "./people.ts";
@@ -73,6 +74,7 @@ export const ENTRIES: readonly PaletteEntry[] = [
7374 limits,
7475 teams,
7576 orgTeams,
77 orgGrants,

New terminal door, but README.md does not mention org grants; add how to use it there.

7678 agents,
7779 archived,
7880 invites,
src/tui/screens/orggrants.ts added +141
@@ -0,0 +1,141 @@
1 /**
2 * An organization's default Claude subscription grants in the terminal, as the
3 * Subscriptions form on the organization page has them: one decision for every
4 * repository of the organization that has no grant of its own. First the
5 * viewer's organizations they administer; then one row for everyone and one
6 * per agent on the forge, each with the accounts granted at the organization.
7 * `h`/`l` move between accounts, `space` toggles the one under the cursor and
8 * the row is saved whole through `setGrants`, the page's write. The gate is
9 * the page's `POST /:owner/-/subscriptions`: an admin of the organization who
10 * is also a forge admin; an agent's session is offered nothing. Names and
11 * kinds only, never a token. The row write is `saveGrants`' per-row
12 * `setGrants` call, made directly because `saveGrants` reads a request form.
13 */
14
15 import { listAgents } from "../../core/agents.ts";
16 import { accessAllows, listOrgsForUser, orgAccess } from "../../core/orgs.ts";
17 import { EVERYONE, listGrants, listSubscriptions, setGrants, type Subscription } from "../../core/subscriptions.ts";
18 import { getUserById, type User } from "../../core/users.ts";
19 import type { Ctx, Screen } from "../app/app.ts";
20 import { binding } from "../app/keys.ts";
21 import { style } from "../term/style.ts";
22 import { truncate } from "../term/width.ts";
23 import type { Session } from "../session.ts";
24 import { fill, len, split } from "../ui/layout.ts";
25 import { ListState, drawList, type ListRow } from "./common.ts";
26 import type { PaletteEntry } from "./entries.ts";
27
28 /** The page's sentence after a save. */
29 export const SAVED = "Saved. Every repository here without a grant of its own follows it.";
30
31 /** Read afresh, so an admin demoted while a screen is open is refused at the next toggle. */
32 const isForgeAdmin = (user: User | undefined): boolean => user?.isAdmin === true && user.kind === "user";
33
34 /** The organizations this session may set grants for: none unless a forge admin who is not an agent. */
35 export function grantableOrgs(f: Session): User[] {
36 const user = f.user === undefined ? undefined : getUserById(f.app.db, f.user.id);
37 if (user === undefined || !isForgeAdmin(user)) return [];
38 return listOrgsForUser(f.app.db, user.id)
39 .filter((org) => accessAllows(orgAccess(f.app.db, org.id, user), "admin"))
40 .sort((a, b) => a.username.localeCompare(b.username));
41 }
42
43 function grantsScreen(f: Session, ctx: Ctx, org: User): Screen {
44 const state = new ListState();
45 let note = "";
46 let col = 0;
outdated

The re-check on every toggle is the security-relevant branch and is untested; add a test where the viewer loses admin after the screen opens and space writes nothing.

47 const db = f.app.db;
48 const admitted = () => grantableOrgs(f).some((o) => o.id === org.id);
49
50 let subs: Subscription[] = [];
51 let who: { id: string; name: string }[] = [];
52 let granted = new Map<string, Set<string>>();
53 const load = (): void => {
54 subs = listSubscriptions(db);
55 who = [{ id: EVERYONE, name: "everyone" }, ...listAgents(db).map((a) => ({ id: a.user.id, name: `@${a.user.username}` }))];
56 granted = new Map();
57 for (const g of listGrants(db, org.id)) {
58 const set = granted.get(g.agentId) ?? new Set<string>();
59 set.add(g.subscriptionId);
60 granted.set(g.agentId, set);
61 }
62 state.clamp(who.length);
63 col = Math.max(0, Math.min(col, subs.length - 1));
64 };
65 load();
66
67 const rows = (): ListRow[] =>
68 who.map((w, i) => {
69 const has = granted.get(w.id) ?? new Set<string>();
70 const chips = subs.map((s, j) => `${i === state.index && j === col ? ">" : " "}[${has.has(s.id) ? "x" : " "}] ${s.name}`);
71 return { title: w.name, below: [subs.length === 0 ? "No subscription yet." : chips.join(" ")] };
72 });
73
74 const screen: Screen = {
75 title: `Grants of ${org.username}`,
76 keys: [binding(["j", "k"], "move row"), binding(["h", "l"], "move account"), binding(["space"], "toggle"), binding(["esc"], "back")],
77 render(area, s) {
78 const c = ctx.theme.c;
79 const [head, body] = split(area, "v", [len(1), fill(1)]);
80 const text = note !== "" ? note : "One decision for every repository here without a grant of its own. space toggles.";
81 s.text(head!.x, head!.y, truncate(text, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w);
82 drawList(s, body!, c, state, rows());
83 },
84 key(e) {
85 note = "";
86 if (state.key(e, who.length)) return true;
87 if (e.ctrl || e.alt) return false;
outdated

With subs.length === 0 this yields col = -1. Use Math.max(0, Math.min(subs.length - 1, col + 1)) and test the empty case.

88 if (e.key === "h" || e.key === "left") col = Math.max(0, col - 1);
89 else if (e.key === "l" || e.key === "right") col = Math.max(0, Math.min(subs.length - 1, col + 1));
90 else if (e.key === " " || e.key === "space") {
91 const w = who[state.index];
92 const sub = subs[col];
93 if (w === undefined || sub === undefined) return true;
94 if (!admitted()) {
95 note = "Only an admin of this organization who is a forge admin sets its grants.";
96 } else {
97 const has = new Set(granted.get(w.id) ?? []);
98 if (has.has(sub.id)) has.delete(sub.id);
99 else has.add(sub.id);
100 setGrants(db, org.id, w.id, [...has]);
101 load();
102 note = SAVED;
103 }
104 } else return false;
105 ctx.invalidate();
106 return true;
107 },
108 };
109 return screen;
110 }
111
112 export function orgGrantsScreen(f: Session, ctx: Ctx): Screen {
113 const state = new ListState();
114 const orgs = grantableOrgs(f);
115 const rows: ListRow[] = orgs.map((o) => ({ title: o.username, secondary: "organization" }));
116 return {
117 title: "Org grants",
118 keys: [binding(["j", "k"], "move cursor"), binding(["enter"], "grants"), binding(["esc"], "back")],
119 render(area, s) {
120 drawList(s, area, ctx.theme.c, state, rows);
121 },
122 key(e) {
123 if (state.key(e, orgs.length)) return true;
124 if (e.ctrl || e.alt || e.key !== "enter") return false;
125 const org = orgs[state.index];
126 if (org === undefined) return false;
127 ctx.push(grantsScreen(f, ctx, org));
128 return true;
129 },
130 };
131 }
132
133 /** An organization's default grants, as its page's Subscriptions form has them: a forge admin who administers one. */
134 export const entry: PaletteEntry = {
135 id: "org-grants",
136 title: "org grants",
137 hint: "which Claude accounts your organization's repositories without a grant of their own use",
138 after: ["org-teams", "subscriptions", "teams"],
139 offered: (session) => grantableOrgs(session).length > 0,
140 run: (session, ctx) => ctx.push(orgGrantsScreen(session, ctx)),
141 };
test/tui-org-grants.test.ts added +173
@@ -0,0 +1,173 @@
1 /**
2 * An organization's default subscription grants in the terminal (#8143, pull #8201): the
3 * `org grants` screen saves a toggle at the organization's scope, a repository
4 * without its own grant then reads it (through the usage read), and anyone but
5 * an admin of the organization who is a forge admin, or any agent's session,
6 * is offered nothing.
7 */
8 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
9 import { mkdtemp, rm } from "node:fs/promises";
10 import { tmpdir } from "node:os";
11 import { join } from "node:path";
12 import { createRepo } from "../src/actions/repos.ts";
13 import { readUsage } from "../src/actions/usage.ts";
14 import { createApp, type App as Forge } from "../src/app.ts";
15 import { createAgentRow } from "../src/core/agents.ts";
16 import { addTeamMember, createOrg, listTeams } from "../src/core/orgs.ts";
17 import { addSubscription, EVERYONE, listGrants } from "../src/core/subscriptions.ts";
18 import { createUser, getUserByName } from "../src/core/users.ts";
19 import { createForgeApp } from "../src/tui/forge.ts";
20 import { headlessTerminal } from "../src/tui/term/terminal.ts";
21 import { quietLogs } from "./helpers/globals.ts";
22 import { settleApp } from "./helpers/tui.ts";
23
24 quietLogs();
25
26 describe("organization grants in the terminal", () => {
27 let dataDir: string;
28 let forge: Forge;
29 let orgId: string;
30 let starterId: string;
31
32 function boot(username: string) {
33 const terminal = headlessTerminal(140, 32);
34 const user = getUserByName(forge.db, username)!;
35 const { app } = createForgeApp(
36 forge,
37 { sshUsername: "t", remoteAddress: "127.0.0.1", user },
38 { terminal, exitHooks: false },
39 );
40 app.start();
41 const key = (k: string, ctrl = false) => app.dispatch({ type: "key", key: k, ctrl, alt: false, shift: false });
42 return {
43 text: () => app.screen.toText(),
44 press: async (...keys: string[]) => {
45 keys.forEach((k) => key(k));
46 await settleApp(app);
47 },
48 ctrl: async (k: string) => {
49 key(k, true);
50 await settleApp(app);
51 },
52 type: async (t: string) => {
53 for (const ch of t) key(ch);
54 await settleApp(app);
55 },
56 stop: () => app.stop(),
57 };
58 }
59
60 const palette = async (h: ReturnType<typeof boot>) => {
61 await h.ctrl("k");
62 await h.type("org grants");
63 };
64
65 beforeAll(async () => {
66 dataDir = await mkdtemp(join(tmpdir(), "forge-tui-org-grants-"));
67 forge = createApp({ dataDir, port: 0 });
68 for (const username of ["rooty", "olive", "carol", "bot1"]) {
69 const made = await createUser(forge.db, {
70 origin: "seed",
71 trusted: true,
72 username,
73 email: `${username}@forge.local`,
74 password: "a-good-passphrase",
75 adminIfFirst: username === "rooty",
76 });
77 if (!made.ok) throw new Error(made.error);
78 if (username === "bot1") createAgentRow(forge.db, { userId: made.value.id, model: "opus" });
79 }
80 forge.db.query("UPDATE user SET is_admin = 1, is_agent = 1 WHERE username = 'carol'").run();
81 const id = (n: string) => getUserByName(forge.db, n)!.id;
82 const org = createOrg(forge.db, "acme", id("rooty"));
83 if (!org.ok) throw new Error(org.error);
84 orgId = org.value.id;
85 const owners = listTeams(forge.db, orgId).find((t) => t.name === "Owners")!;
86 addTeamMember(forge.db, owners, id("olive"));
87 addTeamMember(forge.db, owners, id("carol"));
88 const sub = addSubscription(forge.db, dataDir, { name: "starter", token: "sk-ant-oat01-secretstarter" });
89 if (!sub.ok) throw new Error(sub.error);
90 starterId = sub.value.id;
91 });
92 afterAll(async () => {
93 forge.close();
94 await rm(dataDir, { recursive: true, force: true });
95 });
96
97 test("a toggle is saved at the organization's scope and a repository without its own grant reads it", async () => {
98 const rooty = getUserByName(forge.db, "rooty")!;
99 const repo = await createRepo(forge, rooty, { name: "wares", owner: "acme" });
100 if (!repo.ok) throw new Error(String(repo.error));
101 const standing = () => {
102 const usage = readUsage(forge, rooty, repo.value, 7);
103 if (!usage.ok) throw new Error("usage");
104 return usage.value.standing.map((s) => s.subscription);
105 };
106 expect(standing()).not.toContain("starter");
107
108 const h = boot("rooty");
109 await palette(h);
110 await h.press("enter");
111 expect(h.text()).toContain("acme");
112 await h.press("enter");
113 const screen = h.text();
114 expect(screen).toContain("everyone");
115 expect(screen).toContain("@bot1");
116 expect(screen).toContain("[ ] starter");
117 expect(screen).not.toContain("secretstarter");
118 await h.press(" ");
119 expect(h.text()).toContain("Saved. Every repository here without a grant of its own follows it.");
120 expect(h.text()).toContain("[x] starter");
121 expect(listGrants(forge.db, orgId)).toEqual([{ scopeId: orgId, agentId: EVERYONE, subscriptionId: starterId }]);
122 expect(standing()).toContain("starter");
123
124 await h.press(" ");
125 expect(listGrants(forge.db, orgId)).toEqual([]);
126 h.stop();
127 });
128
129 test("a row other than everyone is saved on its own", async () => {
130 const h = boot("rooty");
131 await palette(h);
132 await h.press("enter", "enter", "j", " ");
133 const bot = getUserByName(forge.db, "bot1")!;
134 expect(listGrants(forge.db, orgId)).toEqual([{ scopeId: orgId, agentId: bot.id, subscriptionId: starterId }]);
135 await h.press(" ");
136 h.stop();
137 });
138
139 test("an org admin who is not a forge admin, and an agent's session, are offered nothing", async () => {
140 for (const name of ["olive", "carol", "bot1"]) {
141 const h = boot(name);
142 await palette(h);
143 await h.press("enter");
144 expect(h.text()).not.toContain("Org grants");
145 h.stop();
146 }
147 expect(listGrants(forge.db, orgId)).toEqual([]);
148 });
149
150 test("an admin who loses admin after the screen opens writes nothing", async () => {
151 const h = boot("rooty");
152 await palette(h);
153 await h.press("enter", "enter");
154 forge.db.query("UPDATE user SET is_admin = 0 WHERE username = 'rooty'").run();
155 await h.press(" ");
156 expect(h.text()).toContain("Only an admin of this organization");
157 expect(listGrants(forge.db, orgId)).toEqual([]);
158 forge.db.query("UPDATE user SET is_admin = 1 WHERE username = 'rooty'").run();
159 h.stop();
160 });
161
162 test("with no subscription the screen says so and h, l and space do nothing", async () => {
163 forge.db.query("DELETE FROM subscription").run();
164 const h = boot("rooty");
165 await palette(h);
166 await h.press("enter", "enter");
167 expect(h.text()).toContain("No subscription yet.");
168 await h.press("l", "l", "h", " ");
169 expect(h.text()).toContain("No subscription yet.");
170 expect(listGrants(forge.db, orgId)).toEqual([]);
171 h.stop();
172 });
173 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs