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

The terminal lists an app's variables, sets one and unsets one, never showing a value, as MCP `app_env` does #7277

Merged opened this

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

Merged

mason/forge:mason/7232-the-terminal-lists-an-app-s-variables-se → main 2 commits fork

  • Merged. mason/7232-the-terminal-lists-an-app-s-variables-se was merged into main as b9b21cf.

Review

Sign in to review this change.
6 files changed +375 −22
docs/APPS.md +6
@@ -711,6 +711,12 @@ again — the page, the API and the event outbox carry the name and when it
711711 was set, nothing more. At rest each value is sealed with the data
712712 directory's key (`kernel/secret.ts`), bound to the app and the name.
713713
714 The same list is in the terminal: an admin's repository screen has
715 *Variables* in its `:` palette, which lists names and scopes, sets one with
716 `a` (a name, a value typed masked, a scope) and unsets one with `d` after a
717 confirm, refused in the page's words (`actions/app_env_words.ts`). MCP
718 `app_env` does the same for an agent.
719
714720 A variable has a **scope**: everywhere (`all`, the default), `production`
715721 only, or `preview` only — the select beside the value, or `"scope"` in the
716722 `PUT` body and `?scope=` on the `DELETE`. The same name may be set once in
src/actions/app_env_words.ts added +29
@@ -0,0 +1,29 @@
1 /**
2 * The words a person reads about an app's variables: where a scope sends
3 * one, and why `setAppEnvVar` or `deleteAppEnvVar` refused. The settings
4 * page and the terminal both say them, so they are said from here.
5 */
6
7 import { NAME_MAX, type AppEnvScope } from "../core/app_env.ts";
8
9 /** Where a variable in a scope goes. */
10 export const SCOPE_WORDS: Record<AppEnvScope, string> = {
11 all: "everywhere",
12 production: "production",
13 preview: "previews",
14 };
15
16 /** `setAppEnvVar`'s refusals, by code. */
17 export const APP_ENV_ERRORS: Record<string, string> = {
18 invalid_name: `A name is capital letters, digits and underscores, starting with a letter — ADMIN_PASSWORD, STRIPE_SECRET_KEY — and at most ${NAME_MAX} characters.`,
19 reserved_name:
20 "That name is the forge's own (PORT, HOST, PATH, FORGE_…); the app is already told it.",
21 invalid_value: "A value is one line or many, never empty and under 64 KiB.",
22 invalid_scope: "A variable goes everywhere, to production, or to previews.",
23 };
24
25 /** `setAppEnvVar` refused with a code the table does not name. */
26 export const APP_ENV_NOT_SET = "That variable could not be set.";
27
28 /** `deleteAppEnvVar` refused as `no_such_var`. */
29 export const APP_ENV_NO_SUCH = "No variable by that name.";
src/tui/screens/appenv.ts added +169
@@ -0,0 +1,169 @@
1 /**
2 * An app's variables in the terminal, an admin's (#7232): the names and
3 * scopes the settings page lists, from a repository's `:` palette. `a` adds
4 * or replaces one — a name, a value typed masked, a scope — and `d` unsets
5 * the one under the cursor after a confirm, through the same actions as the
6 * page and MCP `app_env`, refused in the same words. A value is write-only:
7 * it is held in a closure from the masked field to the action and is never
8 * drawn, toasted or logged.
9 */
10
11 import { deleteAppEnvVar, listAppEnv, setAppEnvVar } from "../../actions/apps.ts";
12 import {
13 APP_ENV_ERRORS,
14 APP_ENV_NO_SUCH,
15 APP_ENV_NOT_SET,
16 SCOPE_WORDS,
17 } from "../../actions/app_env_words.ts";
18 import { APP_ENV_SCOPES, type AppEnvName } from "../../core/app_env.ts";
19 import { getAppByRepo } from "../../core/apps.ts";
20 import { canAdmin, type Repo } from "../../core/repos.ts";
21 import type { Ctx, Screen } from "../app/app.ts";
22 import { binding } from "../app/keys.ts";
23 import type { Command } from "../app/palette.ts";
24 import { style } from "../term/style.ts";
25 import { wrap } from "../term/width.ts";
26 import { fill, len, split } from "../ui/layout.ts";
27 import type { Session } from "../session.ts";
28 import {
29 ListState,
30 confirmScreen,
31 drawList,
32 later,
33 pickerScreen,
34 promptScreen,
35 runAsync,
36 type ListRow,
37 } from "./common.ts";
38
39 /** The palette's "Variables", for an admin of a repository that is an app. */
40 export function appEnvCommands(f: Session, ctx: Ctx, repo: Repo | undefined): Command[] {
41 if (repo === undefined || f.user === undefined) return [];
42 if (getAppByRepo(f.app.db, repo.id) === undefined || !canAdmin(f.app.db, repo, f.user)) return [];
43 return [
44 {
45 id: "app-env",
46 title: `${repo.ownerName}/${repo.name} — Variables`,
47 hint: "the app's environment: names and scopes, never a value",
48 run: () => ctx.push(appEnvScreen(f, ctx, repo)),
49 },
50 ];
51 }
52
53 export function appEnvScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
54 const state = new ListState();
55 let rows: AppEnvName[] = [];
56 // Whether the action lists for this viewer; it decides, not the screen.
57 let canChange = false;
58 let note = "";
59 const reload = (): void => {
60 const listed = listAppEnv(f.app, f.user, repo);
61 canChange = listed.ok;
62 rows = listed.ok ? listed.value : [];
63 state.clamp(rows.length);
64 ctx.invalidate();
65 };
66 reload();
67 const say = (text: string): void => {
68 note = text;
69 reload();
70 };
71 const where = (v: { scope: AppEnvName["scope"] }): string =>
72 v.scope === "all" ? "everywhere" : `for ${SCOPE_WORDS[v.scope]}`;
73
74 const add = (): Screen =>
75 promptScreen(ctx, screen, {
76 title: "Set a variable",
77 label: "name, like STRIPE_SECRET_KEY",
78 placeholder: "ADMIN_PASSWORD",
79 onSubmit: (name) => {
80 if (name === "") return "A variable needs a name.";
81 return later(ctx, () =>
82 promptScreen(ctx, screen, {
83 title: name,
84 label: "the value (sealed, never shown again)",
85 mask: true,
86 onSubmit: (value) =>
87 later(ctx, () =>
88 pickerScreen(ctx, screen, {
89 title: `Where ${name} goes`,
90 items: APP_ENV_SCOPES.map((s) => ({ id: s, label: SCOPE_WORDS[s] })),
91 onPick: ([scope]) =>
92 runAsync(ctx, async () => {
93 const set = await setAppEnvVar(f.app, f.user, repo, name, value, scope);
94 say(
95 set.ok
96 ? `${set.value.name} is set ${where(set.value)}.`
97 : (APP_ENV_ERRORS[set.error] ?? APP_ENV_NOT_SET),
98 );
99 }),
100 }),
101 ),
102 }),
103 );
104 },
105 });
106
107 const unset = (v: AppEnvName): Screen =>
108 confirmScreen(ctx, screen, {
109 title: "Unset this variable",
110 body: [`${v.name}, ${SCOPE_WORDS[v.scope]}`, "The app restarts without it."],
111 actions: [{ label: "Keep", variant: "ghost" }, { label: "Unset", variant: "danger" }],
112 onChoose: (i) => {
113 if (i !== 1) return;
114 runAsync(ctx, async () => {
115 const removed = await deleteAppEnvVar(f.app, f.user, repo, v.name, v.scope);
116 say(removed.ok ? `${v.name} is unset ${where(v)}.` : APP_ENV_NO_SUCH);
117 });
118 },
119 });
120
121 const lines = (): ListRow[] =>
122 rows.map((v) => ({
123 title: v.name,
124 meta: `${SCOPE_WORDS[v.scope]} · set ${v.updatedAt.slice(0, 16).replace("T", " ")} UTC`,
125 }));
126
127 const screen: Screen = {
128 title: "Variables",
129 get keys() {
130 return [
131 ...(canChange ? [binding(["a"], "set one"), binding(["d"], "unset")] : []),
132 binding(["j", "k"], "move cursor"),
133 ];
134 },
135 render(area, s) {
136 const c = ctx.theme.c;
137 const text =
138 note !== ""
139 ? note
140 : !canChange
141 ? "An app's variables are its admin's."
142 : rows.length === 0
143 ? `${repo.ownerName}/${repo.name} sets no variable. Press a to set one.`
144 : `${rows.length} set for ${repo.ownerName}/${repo.name}; a value is never shown.`;
145 // A refusal is the page's sentence, whole: it wraps rather than cuts.
146 const said = wrap(text, Math.max(1, area.w)).slice(0, 3);
147 const [head, list] = split(area, "v", [len(said.length), fill(1)]);
148 said.forEach((line, i) =>
149 s.text(head!.x, head!.y + i, line, style(c.mutedFg, c.bg), head!.x + head!.w),
150 );
151 drawList(s, list!, c, state, lines());
152 },
153 key(e) {
154 if (state.key(e, rows.length)) return true;
155 if (e.ctrl || e.alt || !canChange) return false;
156 if (e.key === "a") {
157 ctx.push(add());
158 return true;
159 }
160 const v = rows[state.index];
161 if (e.key === "d" && v !== undefined) {
162 ctx.push(unset(v));
163 return true;
164 }
165 return false;
166 },
167 };
168 return screen;
169 }
src/tui/screens/workspace.ts +2
@@ -121,6 +121,7 @@ import { reportCommands } from "./report.ts";
121121 import { tellCommands } from "./tell.ts";
122122 import { workingCommands } from "./working.ts";
123123 import { yoursCommands } from "./yours.ts";
124 import { appEnvCommands } from "./appenv.ts";
124125 import { externalEditor, editorHint, hasExternalEditor } from "./external.ts";
125126 import { finderScreen } from "./finder.ts";
126127 import { issuesScreen } from "./issues.ts";
@@ -1283,6 +1284,7 @@ export function workspaceScreen(
12831284 ...reportCommands(f, ctx, () => screen, repo),
12841285 ...noteCommands(f, ctx, () => screen, repo),
12851286 ...yoursCommands(f, ctx, repo),
1287 ...appEnvCommands(f, ctx, repo),
12861288 ],
12871289 get title() {
12881290 return sourceName(src);
src/web/pages/settings.ts +13 −22
@@ -36,8 +36,13 @@ import {
3636 listAppEnvNames,
3737 NAME_MAX,
3838 type AppEnvName,
39 type AppEnvScope,
4039 } from "../../core/app_env.ts";
40 import {
41 APP_ENV_ERRORS,
42 APP_ENV_NO_SUCH,
43 APP_ENV_NOT_SET,
44 SCOPE_WORDS,
45 } from "../../actions/app_env_words.ts";
4146 import { isAutonomy, isDecisionsBy } from "../../core/repos.ts";
4247 import { updateRepoSettings } from "../../actions/repos.ts";
4348 import { appsRuntime } from "../../apps/runtime.ts";
@@ -734,17 +739,6 @@ function appSettingsView(
734739 </div>`;
735740 }
736741
737 /**
738 * The app's environment: the names that are set, and a form for one more.
739 * Values are write-only — the page shows a name and when it was set, and
740 * the value input forgets what was typed the moment the form is sent.
741 */
742 const SCOPE_WORDS: Record<AppEnvScope, string> = {
743 all: "everywhere",
744 production: "production",
745 preview: "previews",
746 };
747
748742 /** Production's visitors and the speed they saw, from the app's own beacon. */
749743 export function appVisitorsView(v: AppVitals): Html {
750744 const known = v.loadDays.filter((n): n is number => n !== null);
@@ -775,6 +769,11 @@ export function appVisitorsView(v: AppVitals): Html {
775769 );
776770 }
777771
772 /**
773 * The app's environment: the names that are set, and a form for one more.
774 * Values are write-only — the page shows a name and when it was set, and
775 * the value input forgets what was typed the moment the form is sent.
776 */
778777 function appEnvView(base: string, names: AppEnvName[]): Html {
779778 const rows = names.map((v) =>
780779 row({
@@ -1160,14 +1159,6 @@ export async function appDisable(ctx: Ctx): Promise<Response> {
11601159 );
11611160 }
11621161
1163 const APP_ENV_ERRORS: Record<string, string> = {
1164 invalid_name: `A name is capital letters, digits and underscores, starting with a letter — ADMIN_PASSWORD, STRIPE_SECRET_KEY — and at most ${NAME_MAX} characters.`,
1165 reserved_name:
1166 "That name is the forge's own (PORT, HOST, PATH, FORGE_…); the app is already told it.",
1167 invalid_value: "A value is one line or many, never empty and under 64 KiB.",
1168 invalid_scope: "A variable goes everywhere, to production, or to previews.",
1169 };
1170
11711162 // POST /:owner/:name/settings/app/env — set one variable.
11721163 export async function appEnvSet(ctx: Ctx): Promise<Response> {
11731164 const gate = await adminForm(ctx);
@@ -1193,7 +1184,7 @@ export async function appEnvSet(ctx: Ctx): Promise<Response> {
11931184 : {
11941185 tone: "danger",
11951186 message:
1196 APP_ENV_ERRORS[set.error] ?? "That variable could not be set.",
1187 APP_ENV_ERRORS[set.error] ?? APP_ENV_NOT_SET,
11971188 },
11981189 );
11991190 }
@@ -1218,7 +1209,7 @@ export async function appEnvDelete(ctx: Ctx): Promise<Response> {
12181209 `${repoBase(repo)}/settings/app`,
12191210 removed.ok
12201211 ? { message: `${name} is removed.${redeploying(ctx, "without it")}` }
1221 : { tone: "danger", message: "No variable by that name." },
1212 : { tone: "danger", message: APP_ENV_NO_SUCH },
12221213 );
12231214 }
12241215
test/tui-app-env.test.ts added +156
@@ -0,0 +1,156 @@
1 /**
2 * An app's variables in the terminal (#7232): an admin opens "Variables"
3 * from the repository's `:` palette, sets one through a masked field and a
4 * scope, sees its name and scope listed and never its value, and unsets it
5 * after a confirm. A refusal reads in the settings page's words. A writer
6 * who is not an admin is offered no entry. Every frame drawn on the way is
7 * kept, and none of them holds the value.
8 */
9 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
10 import { mkdtemp, rm } from "node:fs/promises";
11 import { tmpdir } from "node:os";
12 import { join } from "node:path";
13 import { newApp } from "../src/actions/apps.ts";
14 import { APP_ENV_ERRORS } from "../src/actions/app_env_words.ts";
15 import { createApp, type App } from "../src/app.ts";
16 import { listAppEnvNames } from "../src/core/app_env.ts";
17 import { getAppByRepo } from "../src/core/apps.ts";
18 import { listEvents } from "../src/core/events.ts";
19 import { addTeamMember, createOrg, createTeam } from "../src/core/orgs.ts";
20 import { getRepo, type Repo } from "../src/core/repos.ts";
21 import { createUser, type User } from "../src/core/users.ts";
22 import { createForgeApp } from "../src/tui/forge.ts";
23 import { repoScreen } from "../src/tui/screens/repo.ts";
24 import { local } from "../src/tui/screens/where.ts";
25 import { headlessTerminal } from "../src/tui/term/terminal.ts";
26 import { quietLogs } from "./helpers/globals.ts";
27 import { settleApp } from "./helpers/tui.ts";
28 import { proveAll } from "./helpers/verified.ts";
29
30 quietLogs();
31
32 const SECRET = "sk_live_b4n4n4s";
33 const RIGHT = "\x1b[C";
34 const DOWN = "\x1b[B";
35
36 let dir: string;
37 let app: App;
38 let alice: User; // admin of acme, and so of the app
39 let bob: User; // writes to it through a team, administers nothing
40 let shop: Repo;
41 /** Every frame any session in this file drew, after every key. */
42 const frames: string[] = [];
43
44 const vars = () => listAppEnvNames(app.db, getAppByRepo(app.db, shop.id)!.id);
45
46 /** Open the repository as `user`, feed `keys` one chunk at a time, keep each frame. */
47 async function drive(user: User, keys: string[]): Promise<string> {
48 const terminal = headlessTerminal(120, 40);
49 const { app: tui, session } = createForgeApp(
50 app,
51 { sshUsername: user.username, remoteAddress: "127.0.0.1", fingerprint: "SHA256:test", user },
52 { terminal, exitHooks: false },
53 );
54 tui.start();
55 tui.ctx.push(repoScreen(session, tui.ctx, local(session, shop)));
56 await settleApp(tui);
57 frames.push(tui.screen.toText());
58 for (const k of keys) {
59 terminal.feed(k);
60 await settleApp(tui);
61 frames.push(tui.screen.toText());
62 }
63 const text = tui.screen.toText();
64 tui.stop?.();
65 return text;
66 }
67
68 const open = [":", "Variables", "\r"];
69
70 beforeAll(async () => {
71 dir = await mkdtemp(join(tmpdir(), "forge-tui-app-env-"));
72 app = createApp({ dataDir: dir, port: 0 });
73 const mk = async (username: string, first = false) => {
74 const r = await createUser(app.db, {
75 origin: "seed", trusted: true, username, email: `${username}@forge.local`,
76 password: "a-good-passphrase", adminIfFirst: first,
77 });
78 if (!r.ok) throw new Error(r.error);
79 return r.value;
80 };
81 alice = await mk("alice", true);
82 bob = await mk("bob");
83 proveAll(app);
84 const org = createOrg(app.db, "acme", alice.id);
85 if (!org.ok) throw new Error("org");
86 const team = createTeam(app.db, org.value.id, "writers", "write");
87 if (!team.ok) throw new Error("team");
88 addTeamMember(app.db, team.value, bob.id);
89 const made = await newApp(app, alice, { name: "shop", owner: "acme", visibility: "public" });
90 if (!made.ok) throw new Error(String(made.error));
91 shop = getRepo(app.db, "acme", "shop")!;
92 });
93 afterAll(async () => {
94 app.db.close();
95 await rm(dir, { recursive: true, force: true });
96 });
97
98 describe("an app's variables, in the terminal", () => {
99 test("an admin sets one: masked as it is typed, then listed by name and scope", async () => {
100 const text = await drive(alice, [...open, "a", "STRIPE_KEY", "\r", SECRET, "\r", DOWN, "\r"]);
101 expect(vars().map((v) => [v.name, v.scope])).toEqual([["STRIPE_KEY", "production"]]);
102 expect(text).toContain("STRIPE_KEY is set for production.");
103 expect(text).toContain("STRIPE_KEY");
104 // The masked field drew one dot per character the moment it held the value.
105 expect(frames.some((f) => f.includes("•".repeat(SECRET.length)))).toBe(true);
106 });
107
108 test("the list shows the name and scope, never the value", async () => {
109 const text = await drive(alice, open);
110 expect(text).toContain("Variables");
111 expect(text).toContain("STRIPE_KEY");
112 expect(text).toContain("production · set");
113 expect(text).not.toContain(SECRET);
114 });
115
116 test("a bad name is refused in the page's words, and nothing is set", async () => {
117 const text = await drive(alice, [...open, "a", "lower", "\r", SECRET, "\r", "\r"]);
118 // The page's sentence wraps across the head; its start is on the screen.
119 expect(text).toContain("A name is capital letters, digits and underscores");
120 expect(APP_ENV_ERRORS["invalid_name"]).toStartWith("A name is capital letters, digits and underscores");
121 expect(vars().map((v) => v.name)).toEqual(["STRIPE_KEY"]);
122 });
123
124 test("a value too long is refused in the page's words", async () => {
125 const long = `\x1b[200~${"x".repeat(64 * 1024 + 1)}\x1b[201~`;
126 const text = await drive(alice, [...open, "a", "TOO_LONG", "\r", long, "\r", "\r"]);
127 expect(text).toContain(APP_ENV_ERRORS["invalid_value"]!);
128 expect(vars().map((v) => v.name)).toEqual(["STRIPE_KEY"]);
129 });
130
131 test("unset asks first: keeping it changes nothing, confirming removes it", async () => {
132 const kept = await drive(alice, [...open, "d", "\r"]);
133 expect(kept).toContain("STRIPE_KEY");
134 expect(vars().length).toBe(1);
135 const asked = await drive(alice, [...open, "d"]);
136 expect(asked).toContain("Unset this variable");
137 const text = await drive(alice, [...open, "d", RIGHT, "\r"]);
138 expect(vars()).toEqual([]);
139 expect(text).toContain("STRIPE_KEY is unset for production.");
140 });
141
142 test("a writer who is not an admin has no entry, and can set nothing", async () => {
143 expect(await drive(bob, [":", "Variables"])).not.toContain("acme/shop — Variables");
144 expect(await drive(alice, [":", "Variables"])).toContain("acme/shop — Variables");
145 await drive(bob, [...open, "a", "BOB_KEY", "\r", SECRET, "\r", "\r"]);
146 expect(vars()).toEqual([]);
147 });
148
149 test("no frame drawn, and no event written, ever held the value", () => {
150 expect(frames.length).toBeGreaterThan(20);
151 for (const f of frames) expect(f).not.toContain(SECRET);
152 const events = listEvents(app.db, { repoId: shop.id }).filter((e) => e.kind.startsWith("app.env_"));
153 expect(events.map((e) => e.kind).sort()).toEqual(["app.env_deleted", "app.env_set"]);
154 expect(JSON.stringify(events)).not.toContain(SECRET);
155 });
156 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs