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
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/8143-a-forge-admin-sets-an-organization-s-def → main 2 commits fork 3 threads · 3 unresolved
4 files
changed +318
−0
README.md +2
| @@ -79,6 +79,8 @@ In the terminal, a repository screen's `:` palette offers **Delete this reposito | ||
| 79 | 79 | |
| 80 | 80 | 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. |
| 81 | 81 | |
| 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 | ||
| 82 | 84 | 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`. |
| 83 | 85 | |
| 84 | 86 | 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"; | ||
| 24 | 24 | import { entry as invites } from "./invites.ts"; |
| 25 | 25 | import { entry as limits } from "./limits.ts"; |
| 26 | 26 | import { modelCeilingEntry, modelGrantEntry } from "./model.ts"; |
| 27 | import { entry as orgGrants } from "./orggrants.ts"; | |
| 27 | 28 | import { entry as orgTeams } from "./orgteams.ts"; |
| 28 | 29 | import { entry as owner } from "./owner.ts"; |
| 29 | 30 | import { entry as people } from "./people.ts"; |
| @@ -73,6 +74,7 @@ export const ENTRIES: readonly PaletteEntry[] = [ | ||
| 73 | 74 | limits, |
| 74 | 75 | teams, |
| 75 | 76 | orgTeams, |
| 77 | orgGrants, | |
| 76 | 78 | agents, |
| 77 | 79 | archived, |
| 78 | 80 | 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 | }); |
new above
offline
New terminal door, but README.md does not mention
org grants; add how to use it there.