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

Invites, part 3: Settings lists invite links, mints one with a chosen expiry and revokes one #2377

Merged opened this

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

Merged

scout/forge:scout/2337-invites-part-3-settings-lists-invite-lin → main 4 commits fork 4 threads · 4 unresolved

  • Merged. scout/2337-invites-part-3-settings-lists-invite-lin was merged into main as f4a5aad.

Review

Sign in to review this change.
7 files changed +382 −3
README.md +1 −1
@@ -749,7 +749,7 @@ one the provider rests hands its runs to the other until the reset.
749749
750750 **Roles.** An account is a guest, a member or an admin (`docs/ROLES.md` has the table of what each can do; `src/core/roles.ts` holds it). Admin is the admin flag, a member is promoted, everyone else is a guest. A guest is refused creating or forking a repository, making an app their own and starting a crew run, in the words "the owner has not made this account a member" (after the older refusals: unverified, suspended, agent, private); `guard_guests` in Capacity switches it off. A forge admin who is a person lists everyone with their role at Settings → People (`/settings/people`) and promotes a guest to member with one button, with an optional starter model allowance in dollars a month (none by default, given as a model grant and audited as one), or demotes a member (the allowance stays until taken back on the account's page). In the terminal it is the **People** command (`r` changes a role: `member 5`, or `guest`); over the API, `GET /api/v1/admin/people` and `POST /api/v1/admin/accounts/:name/role` (`{"role": "member", "usdPerMonth": 5}`); over MCP, the `account_role` tool (a plan unless `dryRun: false`, with an admin token). Anyone signed in sees their own role and its row of the table: the account page (`/settings`) shows "What you can do here"; over MCP, `whoami_can` returns `{username, role, can, abilities}` with any token; in the terminal it is the **What I can do** command (read only, agents included).
751751
752 **Invites.** A forge admin who is a person mints a single-use invite link with `POST /api/v1/admin/invites` (`{"days": 14}`, 1 to 30, 7 by default); the response carries the link once and only its hash is kept. `GET /api/v1/admin/invites` lists them (open, used, revoked, expired) without tokens, and `DELETE /api/v1/admin/invites/:id` revokes one. Minting and revoking are events (`invite.minted`, `invite.revoked`). Over MCP, the `mint_invite` tool (`{days?}`, an admin token, a forge admin who is a person) answers with the same link. Signup does not read invites yet.
752 **Invites.** A forge admin who is a person mints a single-use invite link with `POST /api/v1/admin/invites` (`{"days": 14}`, 1 to 30, 7 by default); the response carries the link once and only its hash is kept. `GET /api/v1/admin/invites` lists them (open, used, revoked, expired) without tokens, and `DELETE /api/v1/admin/invites/:id` revokes one. Minting and revoking are events (`invite.minted`, `invite.revoked`). The same is the **Invite links** card on `/admin/limits` (a form with expiry days, 1 to 30; the new link is shown once, with a copy button; an open link has a Revoke button) and the terminal's **Invite links** command (`m` makes one and prints the link once, `x` revokes the selected open one). Over MCP, the `mint_invite` tool (`{days?}`, an admin token, a forge admin who is a person) answers with the same link. Signup does not read invites yet.
753753
754754 **Spend ceilings.** Apart from the grant, a forge admin who is a person may set any account's — a person's, an agent's or an organization's — daily (UTC) and per-run ceilings in dollars: on the account's page (`/settings/accounts/<name>`, an organization's too, by that address; an agent and an admin's own account have no such page), the terminal's **Model ceiling** command, `POST /api/v1/admin/accounts/:name/ceiling` (`{"dailyUsd": 20, "perRunUsd": 5}`) or the MCP `model_ceiling` tool; each change is in the audit. Both empty on the page, `none` in the terminal (its prompt shows the current ceilings, and an empty answer changes nothing), or `null` for both on the API and MCP takes them back. An organization's page is linked from its own page for an admin. An account has no ceiling until one is set, so this forge's own crew is never stopped by a default nobody chose. A run on an account with a ceiling does not start when the day's is spent or the per-run one is too small, is capped at the smaller of the per-run ceiling and what is left of the day, and stops mid-run when the day's is spent.
755755 **Sandbox.** An agent's shell is held to a policy — the network is the forge
src/http/server.ts +3 −1
@@ -21,7 +21,7 @@ import { flagsPage, flagsSet } from "../web/pages/flags.ts";
2121 import { usagePage } from "../web/pages/usage.ts";
2222 import { domainsPage } from "../web/pages/domains.ts";
2323 import { resiliencePage } from "../web/pages/resilience.ts";
24 import { limitsPage, limitsSet, quotasSet, signupSet } from "../web/pages/admin-limits.ts";
24 import { limitsPage, limitsSet, quotasSet, signupSet, inviteMint, inviteRevoke } from "../web/pages/admin-limits.ts";
2525 import { boardsPage } from "../web/pages/boards.ts";
2626 import { boardNew, boardPin, boardPost } from "../web/pages/boards-edit.ts";
2727 import { boardLive, boardShow, boardsList } from "../web/pages/boards-view.ts";
@@ -798,6 +798,8 @@ export function buildRouter(): Router<Handler> {
798798 router.add("POST", "/admin/limits", limitsSet);
799799 router.add("POST", "/admin/quotas", quotasSet);
800800 router.add("POST", "/admin/signup", signupSet);
801 router.add("POST", "/admin/invites", inviteMint);
802 router.add("POST", "/admin/invites/:id/revoke", inviteRevoke);
801803 router.add("GET", "/settings/backups", backupsPage);
802804 router.add("POST", "/settings/backups/now", backupsNow);
803805 router.add("POST", "/settings/backups/drill", backupsDrill);
src/tui/forge.ts +9
@@ -51,6 +51,7 @@ import { canScreen } from "./screens/can.ts";
5151 import { peopleScreen } from "./screens/people.ts";
5252 import { modelCeilingPrompt, modelGrantPrompt, modelScreen } from "./screens/model.ts";
5353 import { proposalTokensPrompt } from "./screens/proposal_tokens.ts";
54 import { invitesScreen } from "./screens/invites.ts";
5455 import { quotasScreen } from "./screens/quotas.ts";
5556 import { domainsScreen } from "./screens/domains.ts";
5657 import { ownerScreen } from "./screens/owner.ts";
@@ -420,6 +421,14 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] {
420421 hint: "what one account may own, and who changed it",
421422 run: () => ctx.push(quotasScreen(session, ctx)),
422423 });
424 // Invite links, as /admin/limits has them: an admin who is a person (#2337).
425 if (session.user?.isAdmin === true && session.user.kind === "user")
426 commands.push({
427 id: "invites",
428 title: "Invite links",
429 hint: "make a single-use link, see which are open, revoke one",
430 run: () => ctx.push(invitesScreen(session, ctx)),
431 });
423432 // Cloud accounts, as Settings → Cloud accounts has them: a forge admin (#2199).
424433 if (session.user?.isAdmin === true)
425434 commands.push({
src/tui/screens/invites.ts added +92
@@ -0,0 +1,92 @@
1 /**
2 * Invite links in the terminal, a forge admin's (#2337): every link with its
3 * state, when it was made and when it expires, as Invite links on /admin/limits
4 * shows them. `m` mints one from a number of days (1 to 30, 7 by default) and
5 * prints the link once, on this screen, until the next key of any kind; `x` revokes the
6 * selected open one. Both go through the actions, so the gate is theirs: an
7 * admin who is a person; anyone else sees nothing and mints nothing.
8 */
9
10 import { inviteLink, list, mint, revoke } from "../../actions/invites.ts";
11 import { DEFAULT_DAYS, MAX_DAYS } from "../../core/invites.ts";
12 import type { Ctx, Screen } from "../app/app.ts";
13 import { binding } from "../app/keys.ts";
14 import { style } from "../term/style.ts";
15 import { truncate } from "../term/width.ts";
16 import type { Session } from "../session.ts";
17 import { fill, len, split } from "../ui/layout.ts";
18 import { ListState, drawList, promptScreen, type ListRow } from "./common.ts";
19
20 export function invitesScreen(f: Session, ctx: Ctx): Screen {
21 const state = new ListState();
22 let note = "";
23 const read = () => list(f.app, f.user);
24 const items = () => {
25 const r = read();
26 return r.ok ? r.value : [];
27 };
28
29 const make = (raw: string): string | undefined => {
30 const text = raw.trim();
31 const days = text === "" ? DEFAULT_DAYS : /^\d{1,3}$/.test(text) ? Number(text) : -1;
32 const made = mint(f.app, f.user, days);
33 if (!made.ok)
34 return made.error === "invalid_days"
35 ? `Days is a whole number from 1 to ${MAX_DAYS}.`
36 : "You cannot make invite links.";
37 const origin = f.app.config.url ?? `http://localhost:${f.app.config.port}`;
outdated

High (a broken link shown once): with no config.url this prints /signup?invite=…, which is useless in a terminal. Use inviteLink(f.app.config.url ?? http://localhost:${f.app.config.port}, made.value.token) from actions/invites.ts, the same fallback as verify.ts and health.ts, and add a test with config.url unset.

38 note = `Shown once: ${inviteLink(origin, made.value.token)}`;
39 ctx.invalidate();
40 return undefined;
41 };
42
43 const rows = (): ListRow[] =>
44 items().map((i) => ({
45 title: i.id,
46 secondary: i.state,
47 meta: `made ${i.createdAt.slice(0, 16)} expires ${i.expiresAt.slice(0, 16)}`,
48 }));
49
50 const screen: Screen = {
51 title: "Invite links",
52 keys: [binding(["j", "k"], "move cursor"), binding(["m"], "make a link"), binding(["x"], "revoke")],
53 render(area, s) {
54 const c = ctx.theme.c;
55 const r = read();
56 const [head, body] = split(area, "v", [len(1), fill(1)]);
57 const text =
58 note !== ""
59 ? note
60 : !r.ok
61 ? "Invite links are made by a forge admin."
62 : r.value.length === 0
63 ? "No invite link yet. m makes one."
64 : "Each link is good for one signup. m makes one, x revokes.";
65 s.text(head!.x, head!.y, truncate(text, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w);
66 drawList(s, body!, c, state, rows());
67 },
68 key(e) {
69 note = "";
70 const all = items();
outdated

j/k return here before note is cleared, so the one-time link stays on screen while the cursor moves. That contradicts "until the next key" in the header. Clear note at the top of key(), or fix the comment.

71 if (state.key(e, all.length)) return true;
72 if (e.ctrl || e.alt || !read().ok) return false;
73 if (e.key === "m") {
74 ctx.push(
75 promptScreen(ctx, screen, {
76 title: "Invite link",
77 label: `days until it expires, 1 to ${MAX_DAYS}`,
78 value: String(DEFAULT_DAYS),
79 onSubmit: make,
80 }),
81 );
82 } else if (e.key === "x") {
83 const row = all[state.index];
84 if (row === undefined) return false;
85 note = revoke(f.app, f.user, row.id).ok ? `Revoked ${row.id}.` : "That link is not open.";
86 ctx.invalidate();
87 } else return false;
88 return true;
89 },
90 };
91 return screen;
92 }
src/web/pages/admin-limits.ts +83 −1
@@ -7,6 +7,8 @@
77
88 import { changeLimit, listLimits } from "../../actions/limits.ts";
99 import { changeQuotas, readQuotas } from "../../actions/quotas.ts";
10 import { inviteLink, list as listInviteLinks, mint as mintInviteLink, revoke as revokeInviteLink } from "../../actions/invites.ts";
11 import { DEFAULT_DAYS, MAX_DAYS } from "../../core/invites.ts";
1012 import { changeSignup, readSignup } from "../../actions/signup.ts";
1113 import { SIGNUP_MODES } from "../../settings/signup.ts";
1214 import { QUOTA_KEYS } from "../../settings/quotas.ts";
@@ -150,7 +152,51 @@ export function limitsPage(ctx: Ctx): Response {
150152 pad: false,
151153 },
152154 );
153 const body = html`${signupCard}${quotaCard}${card(
155 const invites = listInviteLinks(ctx.app, ctx.auth.user);
156 const inviteRows = (invites.ok ? invites.value : []).map((i) =>
157 row({
158 lead: icon("activity"),
159 title: html`<span class="mono">${i.id}</span> <span class="muted small">${i.state}</span>`,
160 meta: html`Made ${time(i.createdAt)} · expires ${time(i.expiresAt)}`,
161 trail:
162 i.state === "open"
163 ? html`<form class="row-actions" method="post" action="/admin/invites/${i.id}/revoke">
164 ${btn("Revoke", { size: "sm", label: `Revoke invite ${i.id}` })}
165 </form>`
166 : html``,
167 }),
168 );
169 const inviteCard = card(
170 html`<div class="card-body">
171 <p class="muted small">
172 A link is shown once, when it is made, and is good for one signup.
173 Nothing reads these yet.
174 </p>
175 <form class="row-actions" method="post" action="/admin/invites">
176 <label class="small">expires in (days)
177 <input
178 class="input mono"
179 type="number"
180 name="days"
181 min="1"
182 max="${String(MAX_DAYS)}"
183 step="1"
184 value="${String(DEFAULT_DAYS)}"
185 /></label>
186 ${btn("Make a link", { size: "sm", variant: "primary", label: "Make an invite link" })}
187 </form>
188 </div>
189 ${when(
190 inviteRows.length > 0,
191 () => html`<ul class="list">${inviteRows}</ul>`,
192 )}`,
193 {
194 head: html`${icon("activity")}
195 <h2>Invite links</h2>`,
196 pad: false,
197 },
198 );
199 const body = html`${signupCard}${inviteCard}${quotaCard}${card(
154200 html`<div class="card-body">
155201 <p class="muted small">
156202 Every limit you can hit, and why it is there. A change governs
@@ -266,3 +312,39 @@ export async function signupSet(ctx: Ctx): Promise<Response> {
266312 : { tone: "danger", message: "Signup is open, invite or closed." },
267313 );
268314 }
315
316 // POST /admin/invites
317 export async function inviteMint(ctx: Ctx): Promise<Response> {
318 if (ctx.auth.user?.isAdmin !== true || ctx.auth.user.kind === "agent")
319 return notFoundPage(ctx);
320 const form = await readForm(ctx, "admin");
321 if (form instanceof Response) return form;
322 const raw = field(form, "days").trim();
323 const made = mintInviteLink(ctx.app, ctx.auth.user, /^\d{1,3}$/.test(raw) ? Number(raw) : -1);
324 if (!made.ok && made.error === "not_found") return notFoundPage(ctx);
325 if (!made.ok)
326 return flashTo(ctx, "/admin/limits", {
327 tone: "danger",
328 message: `Days is a whole number from 1 to ${MAX_DAYS}.`,
329 });
330 return flashTo(ctx, "/admin/limits", {
outdated

This duplicates inviteLink from actions/invites.ts, which api.ts and mcp.ts already use. Call it here so the three doors cannot drift apart.

331 message: "Invite link made. It is shown only now:",
332 code: inviteLink(ctx.app.config.url ?? ctx.url.origin, made.value.token),
333 });
334 }
335
336 // POST /admin/invites/:id/revoke
337 export async function inviteRevoke(ctx: Ctx): Promise<Response> {
338 if (ctx.auth.user?.isAdmin !== true || ctx.auth.user.kind === "agent")
339 return notFoundPage(ctx);
340 const form = await readForm(ctx, "admin");
341 if (form instanceof Response) return form;
342 const done = revokeInviteLink(ctx.app, ctx.auth.user, ctx.params.id ?? "");
343 return flashTo(
344 ctx,
345 "/admin/limits",
346 done.ok
347 ? { message: "Invite link revoked." }
348 : { tone: "danger", message: "That link is not open." },
349 );
350 }
test/invites.test.ts +63
@@ -5,6 +5,7 @@ import { join } from "node:path";
55 import { createApp, type App } from "../src/app.ts";
66 import { signUp } from "../src/actions/users.ts";
77 import { listEvents } from "../src/core/events.ts";
8 import { createSession } from "../src/core/sessions.ts";
89 import { createToken } from "../src/core/tokens.ts";
910 import { listInvites, mintInvite, revokeInvite } from "../src/core/invites.ts";
1011 import { serve, type Servers } from "../src/http/server.ts";
@@ -96,3 +97,65 @@ describe("invites: an admin mints, lists and revokes", () => {
9697 expect(revokeInvite(app.db, used.invite.id).ok).toBe(false);
9798 });
9899 });
100
101 describe("invites: the settings form", () => {
102 let dir: string;
103 let app: App;
104 let servers: Servers;
105 let alice: string;
106 let bob: string;
107
108 beforeAll(async () => {
109 dir = await mkdtemp(join(tmpdir(), "forge-invites-form-"));
110 app = createApp({ dataDir: dir, port: 0 });
111 servers = await serve(app);
112 const a = await signUp(app, { username: "olive", email: "[email protected]", password: "owner-passphrase" });
113 const b = await signUp(app, { username: "ada", email: "[email protected]", password: "guest-passphrase" });
114 if (!a.ok || !b.ok) throw new Error("signup");
115 proveAll(app);
116 alice = `forge_session=${createSession(app.db, a.value.id).token}`;
117 bob = `forge_session=${createSession(app.db, b.value.id).token}`;
118 });
119 afterAll(async () => {
120 servers.stop();
121 app.close();
122 await rm(dir, { recursive: true, force: true });
123 });
124
125 const post = (cookie: string, path: string, fields: Record<string, string>, origin = servers.url) =>
126 fetch(`${servers.url}${path}`, {
127 method: "POST",
128 redirect: "manual",
129 headers: { cookie, "content-type": "application/x-www-form-urlencoded", origin },
130 body: new URLSearchParams(fields),
131 });
132
133 test("mint shows the link once, the list shows the row, revoke closes it", async () => {
134 const res = await post(alice, "/admin/invites", { days: "3" });
135 expect(res.status).toBe(303);
136 const flash = res.headers.get("set-cookie") ?? "";
137 expect(decodeURIComponent(flash)).toContain("/signup?invite=invite_");
138 const page = await (await fetch(`${servers.url}/admin/limits`, { headers: { cookie: alice } })).text();
139 expect(page).toContain("Invite links");
140 expect(page).not.toContain("invite_");
141 const [item] = listInvites(app.db);
142 expect(item!.state).toBe("open");
143 expect(page).toContain(`/admin/invites/${item!.id}/revoke`);
144 expect((await post(alice, `/admin/invites/${item!.id}/revoke`, {})).status).toBe(303);
145 expect(listInvites(app.db)[0]!.state).toBe("revoked");
146 });
147
148 test("days outside 1 to 30 mint nothing", async () => {
149 const before = listInvites(app.db).length;
150 await post(alice, "/admin/invites", { days: "31" });
151 await post(alice, "/admin/invites", { days: "abc" });
152 expect(listInvites(app.db).length).toBe(before);
153 });
154
155 test("a cross-origin post is refused and a non-admin gets the not-found page", async () => {
156 const before = listInvites(app.db).length;
157 expect((await post(alice, "/admin/invites", { days: "3" }, "https://evil.example")).status).toBe(403);
158 expect((await post(bob, "/admin/invites", { days: "3" })).status).toBe(404);
159 expect(listInvites(app.db).length).toBe(before);
160 });
161 });
test/tui-invites.test.ts added +131
@@ -0,0 +1,131 @@
1 /**
2 * Invite links in the terminal (#2337): an admin who is a person lists the
3 * links, makes one (shown once) and revokes one; anyone else has no command and, driving the screen directly,
4 * can neither read nor write.
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 as Forge } from "../src/app.ts";
11 import { listInvites } from "../src/core/invites.ts";
12 import { createUser, getUserByName } from "../src/core/users.ts";
13 import { createForgeApp } from "../src/tui/forge.ts";
14 import { invitesScreen } from "../src/tui/screens/invites.ts";
15 import { Session, type TuiIdentity } from "../src/tui/session.ts";
16 import { headlessTerminal } from "../src/tui/term/terminal.ts";
17 import { quietLogs } from "./helpers/globals.ts";
18 import { settleApp } from "./helpers/tui.ts";
19
20 quietLogs();
21
22 describe("invite links in the terminal", () => {
23 let dataDir: string;
24 let forge: Forge;
25
26 function boot(username: string) {
27 const terminal = headlessTerminal(140, 32);
28 const user = getUserByName(forge.db, username)!;
29 const identity: TuiIdentity = { sshUsername: "t", remoteAddress: "127.0.0.1", user };
30 const { app } = createForgeApp(forge, identity, { terminal, exitHooks: false });
31 app.start();
32 const key = (k: string) =>
33 app.dispatch({ type: "key", key: k, ctrl: false, alt: false, shift: false });
34 return {
35 text: () => app.screen.toText(),
36 press: async (...keys: string[]) => {
37 keys.forEach(key);
38 await settleApp(app);
39 },
40 ctrl: async (k: string) => {
41 app.dispatch({ type: "key", key: k, ctrl: true, alt: false, shift: false });
42 await settleApp(app);
43 },
44 type: async (t: string) => {
45 for (const ch of t) key(ch);
46 await settleApp(app);
47 },
48 stop: () => app.stop(),
49 };
50 }
51
52 beforeAll(async () => {
53 dataDir = await mkdtemp(join(tmpdir(), "forge-tui-invites-"));
54 forge = createApp({ dataDir, port: 0 });
55 for (const [username, adminIfFirst] of [["alice", true], ["bob", false], ["carol", false]] as const) {
56 const made = await createUser(forge.db, {
57 trusted: true,
58 username,
59 email: `${username}@forge.local`,
60 password: "a-good-passphrase",
61 adminIfFirst,
62 });
63 if (!made.ok) throw new Error(made.error);
64 }
65 forge.db.query("UPDATE user SET is_admin = 1, is_agent = 1 WHERE username = 'carol'").run();
66 });
67 afterAll(async () => {
68 forge.close();
69 await rm(dataDir, { recursive: true, force: true });
70 });
71
72 const open = async (h: ReturnType<typeof boot>) => {
73 await h.ctrl("k");
74 await h.type("Invite links");
75 await h.press("enter");
76 };
77
78 test("an admin makes a link, sees it once, lists it and revokes it", async () => {
79 const h = boot("alice");
80 await open(h);
81 expect(h.text()).toContain("No invite link yet");
82 await h.press("m");
83 await h.press("enter");
84 expect(forge.config.url).toBeUndefined();
85 expect(h.text()).toMatch(/Shown once: http:\/\/localhost:\d+\/signup\?invite=invite_/);
86 const [made] = listInvites(forge.db);
87 expect(made!.state).toBe("open");
88 expect(h.text()).toContain(made!.id);
89 expect(h.text()).toContain("open");
90 await h.press("j");
91 expect(h.text()).not.toContain("invite_");
92 await h.press("x");
93 expect(listInvites(forge.db)[0]!.state).toBe("revoked");
94 expect(h.text()).not.toContain("invite_");
95 h.stop();
96 });
97
98 test("days outside 1 to 30 make nothing", async () => {
99 const before = listInvites(forge.db).length;
100 const h = boot("alice");
101 await open(h);
102 await h.press("m");
103 await h.press("backspace");
104 await h.type("0");
105 await h.press("enter");
106 expect(h.text()).toContain("1 to 30");

This assertion passes even when the error never shows, because the prompt label already reads "days until it expires, 1 to 30". The count check on the next line is what actually proves nothing was minted. Asserting the error text itself ("Days is a whole number") would make this line mean something. Not blocking.

107 expect(listInvites(forge.db).length).toBe(before);
108 h.stop();
109 });
110
111 test("a non-admin and an admin agent have no command and mint nothing", async () => {
112 const before = listInvites(forge.db).length;
113 for (const who of ["bob", "carol"]) {
114 const h = boot(who);
115 await h.ctrl("k");
116 await h.type("Invite links");
117 expect(h.text()).not.toContain("single-use link");
118 h.stop();
119 const user = getUserByName(forge.db, who)!;
120 const session = new Session(forge, { sshUsername: "t", remoteAddress: "127.0.0.1", user });
121 const pushed: unknown[] = [];
122 const ctx = { push: (s: unknown) => pushed.push(s), invalidate() {} } as never;
123 const screen = invitesScreen(session, ctx);
124 const key = (k: string) => screen.key?.({ type: "key", key: k, ctrl: false, alt: false, shift: false } as never);
125 key("m");
126 key("x");
127 expect(pushed).toEqual([]);
128 }
129 expect(listInvites(forge.db).length).toBe(before);
130 });
131 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs