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

Settings → Flags lets an admin see every declared flag and set its value per forge, user, role or percentage #2134

Merged opened this

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

Merged

scout/forge:scout/1970-settings-flags-lets-an-admin-see-every-d → main 5 commits fork 2 threads · 2 unresolved

  • Merged. scout/1970-settings-flags-lets-an-admin-see-every-d was merged into main as b3af545.

Review

Sign in to review this change.
10 files changed +648 −0
README.md +4
@@ -1104,6 +1104,10 @@ In the forge itself: a pull's check runs `fast` when the manifest has one, in a
11041104
11051105 While the default branch's own last check is red, the forge holds its pulls' checks queued rather than fail each one on the same test (`src/checks/red_main.ts`): the vitals carry one card naming the failing test and how many checks wait, and main going green releases them. A check a person asks for again runs at once, and no check is held more than 20 minutes, so the pull that fixes main can always show green.
11061106
1107 ## Flags
1108
1109 A forge admin sees every declared flag at `/settings/flags` (Settings → Flags): why it exists, its default, the owner issue and its live value. Set it for the whole forge, for listed user ids or roles, or for a percentage of people; Clear returns it to the default. A change is validated, kept in the change record and live on the next request, with no restart. In the terminal the same admin opens the command palette (`ctrl+k`), picks Flags, and presses enter to set a flag from a line such as `forge=on users=u1,u2 roles=admin percent=30` or `x` to clear it; an agent is never an admin there either.
1110
11071111 ## Limits
11081112
11091113 Every limit the owner can hit is declared in `src/settings/limits.ts` with its default, bounds and reason, and changed live by a forge admin at `/admin/limits`, `PUT /api/v1/admin/limits/:key` or the `limits` MCP tool (never by an agent). Each change is kept with who made it.
src/core/flags.ts +105
@@ -108,6 +108,51 @@ export function evaluate(
108108 return value.forge ?? def;
109109 }
110110
111 export type FlagInput = {
112 /** "" leaves `forge` unset; "on"/"off" set it. */
113 forge: string;
114 users: string;
115 roles: string;
116 percent: string;
117 };
118
119 const ID = /^[A-Za-z0-9_-]{1,64}$/;
120 const ROLE = /^[a-z][a-z0-9_-]{0,31}$/;
121 const MAX_LIST = 200;
122
123 function list(text: string, shape: RegExp, what: string) {
124 const items = [...new Set(text.split(/[\s,]+/).filter((x) => x !== ""))];
125 if (items.length > MAX_LIST) return `Too many ${what}.`;
126 const bad = items.find((x) => !shape.test(x));
127 return bad === undefined ? items : `"${bad.slice(0, 40)}" is not a valid ${what.replace(/s$/, "")}.`;
128 }
129
130 /**
131 * Check form text against the declared type of a flag value (see `FlagValue`)
132 * and build it. `undefined` means nothing is set: clear the row.
133 */
134 export function checkInput(
135 input: FlagInput,
136 ): { ok: true; value: FlagValue | undefined } | { ok: false; message: string } {
137 const out: FlagValue = {};
138 if (input.forge === "on") out.forge = true;
139 else if (input.forge === "off") out.forge = false;
140 else if (input.forge !== "") return { ok: false, message: "forge must be on, off or unset." };
141 const users = list(input.users, ID, "user ids");
142 if (typeof users === "string") return { ok: false, message: users };
143 if (users.length > 0) out.users = users;
144 const roles = list(input.roles, ROLE, "roles");
145 if (typeof roles === "string") return { ok: false, message: roles };
146 if (roles.length > 0) out.roles = roles;
147 const pct = input.percent.trim();
148 if (pct !== "") {
149 if (!/^\d{1,3}$/.test(pct) || Number(pct) > 100)
150 return { ok: false, message: "percent is a whole number from 0 to 100." };
151 out.percent = Number(pct);
152 }
153 return { ok: true, value: Object.keys(out).length === 0 ? undefined : out };
154 }
155
111156 /** Declared names that no source reads as `flag("name"` (or `'name'`). */
112157 export function unreadFlags(
113158 names: readonly string[],
@@ -145,6 +190,45 @@ export function makeFlags<const D extends Record<string, FlagDecl>>(decls: D) {
145190 values = new Map();
146191 }
147192 },
193 decls,
194 /** The live value of `name`, if one is set. */
195 value(name: keyof D & string): FlagValue | undefined {
196 return values.get(name);
197 },
198 /**
199 * Store `value` for a declared flag (`undefined` clears it), keep the
200 * change in `forge_setting_change`, and reload so it is live at once.
201 */
202 set(
203 db: Database,
204 name: string,
205 value: FlagValue | undefined,
206 actorId: string,
207 ): boolean {
208 if (!Object.hasOwn(decls, name)) return false;
209 const key = KEY_PREFIX + name;
210 const at = new Date().toISOString();
211 const show = (v: string | null | undefined) => v ?? "default";
212 db.transaction(() => {
213 const from = db
214 .query<{ value: string }, [string]>("SELECT value FROM forge_setting WHERE key = ?1")
215 .get(key)?.value;
216 const to = value === undefined ? undefined : JSON.stringify(value);
217 if (to === undefined) db.query("DELETE FROM forge_setting WHERE key = ?1").run(key);
218 else
219 db.query(
220 `INSERT INTO forge_setting (key, value, updated_by, updated_at) VALUES (?1, ?2, ?3, ?4)
221 ON CONFLICT (key) DO UPDATE SET value = excluded.value,
222 updated_by = excluded.updated_by, updated_at = excluded.updated_at`,
223 ).run(key, to, actorId, at);
224 db.query(
225 `INSERT INTO forge_setting_change (key, from_value, to_value, actor_id, at)
226 VALUES (?1, ?2, ?3, ?4, ?5)`,
227 ).run(key, show(from), show(to), actorId, at);
228 })();
229 this.load(db);
230 return true;
231 },
148232 flag(name: keyof D & string, viewer?: Viewer): boolean {
149233 return evaluate(name, decls[name]!.default, values.get(name), viewer);
150234 },
@@ -158,3 +242,24 @@ export const loadFlags = forgeFlags.load;
158242
159243 /** Whether `name` is on for `viewer` (none: signed out). */
160244 export const flag = forgeFlags.flag;
245
246 /** The forge's registry, for the settings page. */
247 export const forgeRegistry = forgeFlags;

Nit: forgeRegistry is only read in this file (as the default for active). Use forgeFlags directly and drop the export, so the module exposes flagRegistry() as its only way in.

248
249 type Registry = typeof forgeRegistry;
250 let active: Registry = forgeRegistry;
251
252 /** The registry the settings page and the terminal screen read and set. */
253 export function flagRegistry(): Registry {
254 return active;
255 }
256
257 /** Tests swap the registry for one with declared flags; the forge never does. */
258 export function useFlagRegistry(next: Registry | undefined): void {
259 active = next ?? forgeRegistry;
260 }
261
262 /** Whether `user` may see and set flags: an admin who is not an agent. */
263 export function mayManageFlags(user: { isAdmin: boolean; kind: string } | undefined): boolean {
264 return user !== undefined && user.isAdmin && user.kind !== "agent";
265 }
src/http/server.ts +3
@@ -11,6 +11,7 @@ import { unsealReleased } from "../actions/sealed.ts";
1111 import { accessPage } from "../web/pages/access.ts";
1212 import { capacityPage, capacitySet } from "../web/pages/capacity.ts";
1313 import { flowPage } from "../web/pages/flow.ts";
14 import { flagsPage, flagsSet } from "../web/pages/flags.ts";
1415 import { domainsPage } from "../web/pages/domains.ts";
1516 import { resiliencePage } from "../web/pages/resilience.ts";
1617 import { limitsPage, limitsSet, quotasSet } from "../web/pages/admin-limits.ts";
@@ -749,6 +750,8 @@ export function buildRouter(): Router<Handler> {
749750 router.add("GET", "/boards/:slug", boardShow);
750751 router.add("GET", "/boards/:slug/live", boardLive);
751752 router.add("POST", "/boards/:slug/:op", boardPost);
753 router.add("GET", "/settings/flags", flagsPage);
754 router.add("POST", "/settings/flags", flagsSet);
752755 router.add("GET", "/admin/limits", limitsPage);
753756 router.add("POST", "/admin/limits", limitsSet);
754757 router.add("POST", "/admin/quotas", quotasSet);
src/main.ts +2
@@ -11,6 +11,7 @@ import { parentUrlProblem } from "./core/parent_releases.ts";
1111 import { releaseEnv } from "./release/env.ts";
1212 import { createApp } from "./app.ts";
1313 import { backfillCarried } from "./core/carried.ts";
14 import { loadFlags } from "./core/flags.ts";
1415 import { reconcile } from "./core/reconcile.ts";
1516 import { syncAllPillars } from "./actions/pillars.ts";
1617 import { bootHostedChildren } from "./spawn/host.ts";
@@ -233,6 +234,7 @@ const app = createApp({
233234 }),
234235 });
235236
237 loadFlags(app.db);
236238 await reconcile(app);
237239 // Issues linked under a pillar's umbrella before pillars had labels.
238240 const labelled = syncAllPillars(app);
src/tui/forge.ts +10
@@ -37,7 +37,9 @@ import { issueScreen } from "./screens/issue.ts";
3737 import { pullScreen } from "./screens/pull.ts";
3838 import { blobScreen } from "./screens/repo.ts";
3939 import { dashboardScreen } from "./screens/dashboard.ts";
40 import { mayManageFlags } from "../core/flags.ts";
4041 import { accessScreen } from "./screens/access.ts";
42 import { flagsScreen } from "./screens/flags.ts";
4143 import { domainsScreen } from "./screens/domains.ts";
4244 import { resilienceLines, resilienceScreen } from "./screens/resilience.ts";
4345 import { flowLines, flowScreen } from "./screens/flow.ts";
@@ -268,6 +270,14 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] {
268270 hint: "what outside agents tried",
269271 run: () => ctx.push(accessScreen(session, ctx)),
270272 });
273 // Settings → Flags, as the page has it: an admin who is not an agent (#1970).
274 if (mayManageFlags(session.user))
275 commands.push({
276 id: "flags",
277 title: "Flags",
278 hint: "declared flags and their live values",
279 run: () => ctx.push(flagsScreen(session, ctx)),
280 });
271281 // Each failure domain and its last drill, as Settings → Failure domains says it (#1945).
272282 if (session.user?.isAdmin === true && session.user.kind !== "agent")
273283 commands.push({
src/tui/screens/flags.ts added +127
@@ -0,0 +1,127 @@
1 /**
2 * Flags in the terminal, a forge admin's (#1970): every declared flag with
3 * why it exists, its default, the owner issue and its live value, as
4 * Settings → Flags shows them. Enter sets one from a line like
5 * `forge=on users=u1,u2 roles=admin percent=30` (checked by `checkInput`, the
6 * page's own check); `x` clears it. A change goes through the registry's
7 * `set`, so it is live at once, as on the page. The gate is the page's:
8 * an admin who is not an agent; anyone else sees nothing and sets nothing.
9 */
10
11 import {
12 checkInput,
13 flagRegistry,
14 mayManageFlags,
15 type FlagDecl,
16 type FlagInput,
17 type FlagValue,
18 } from "../../core/flags.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, promptScreen, type ListRow } from "./common.ts";
26
27 const KEYS = ["forge", "users", "roles", "percent"] as const;
28
29 /** `forge=on users=a,b` → the page's form fields; an unknown word is an error. */
30 export function parseLine(line: string): FlagInput | string {
31 const out: FlagInput = { forge: "", users: "", roles: "", percent: "" };
32 for (const word of line.split(/\s+/).filter((w) => w !== "")) {
33 const at = word.indexOf("=");
34 const key = word.slice(0, at);
35 if (at < 0 || !(KEYS as readonly string[]).includes(key))
36 return `"${word.slice(0, 40)}": use forge=on|off users=a,b roles=x percent=30`;
37 out[key as (typeof KEYS)[number]] = word.slice(at + 1);
38 }
39 return out;
40 }
41
42 function show(v: FlagValue | undefined): string {
43 if (v === undefined) return "not set";
44 return [
45 v.forge === undefined ? "" : `forge=${v.forge ? "on" : "off"}`,
46 v.users ? `users=${v.users.join(",")}` : "",
47 v.roles ? `roles=${v.roles.join(",")}` : "",
48 v.percent === undefined ? "" : `percent=${v.percent}`,
49 ]
50 .filter((x) => x !== "")
51 .join(" ");
52 }
53
54 export function flagsScreen(f: Session, ctx: Ctx): Screen {
55 const state = new ListState();
56 const allowed = mayManageFlags(f.user);
57 const names = (): string[] => (allowed ? Object.keys(flagRegistry().decls) : []);
58 let note = "";
59
60 const save = (name: string, value: FlagValue | undefined): void => {
61 if (!allowed || f.user === undefined) return;
62 note = flagRegistry().set(f.db, name, value, f.user.id)
63 ? `${name}: ${value === undefined ? "cleared" : "saved"}.`
64 : "There is no such flag.";
65 ctx.invalidate();
66 };
67
68 const rows = (): ListRow[] =>
69 names().map((name) => {
70 const decl = (flagRegistry().decls as Record<string, FlagDecl>)[name]!;
71 return {
72 title: name,
73 secondary: show(flagRegistry().value(name as never)),
74 meta: `${decl.why} default ${decl.default ? "on" : "off"} owner #${decl.issue}`,
75 };
76 });
77
78 const screen: Screen = {
79 title: "Flags",
80 keys: [
81 binding(["j", "k"], "move cursor"),
82 binding(["enter"], "set"),
83 binding(["x"], "clear"),
84 ],
85 render(area, s) {
86 const c = ctx.theme.c;
87 const [head, list] = split(area, "v", [len(1), fill(1)]);
88 const text =
89 note !== ""
90 ? note
91 : !allowed
92 ? "Flags are set by a forge admin."
93 : names().length === 0
94 ? "No flag is declared yet."
95 : `${names().length} flag${names().length === 1 ? "" : "s"}`;
96 s.text(head!.x, head!.y, truncate(text, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w);
97 drawList(s, list!, c, state, rows());
98 },
99 key(e) {
100 const all = names();
101 if (state.key(e, all.length)) return true;
102 if (e.ctrl || e.alt || !allowed) return false;
103 const name = all[state.index];
104 if (name === undefined) return false;
105 if (e.key === "x") save(name, undefined);
106 else if (e.key === "enter")
107 ctx.push(
108 promptScreen(ctx, screen, {
109 title: name,
110 label: "forge=on|off users=a,b roles=x percent=30",
111 value: show(flagRegistry().value(name as never)).replace("not set", ""),
112 onSubmit: (raw) => {
113 const input = parseLine(raw);
114 if (typeof input === "string") return input;
115 const checked = checkInput(input);
116 if (!checked.ok) return checked.message;
117 save(name, checked.value);
118 return undefined;
119 },
120 }),
121 );
122 else return false;
123 return true;
124 },
125 };
126 return screen;
127 }
src/web/pages/flags.ts added +142
@@ -0,0 +1,142 @@
1 /**
2 * Flags, for a forge admin: every declared flag (`core/flags.ts`) with why it
3 * exists, its default, the issue that owns removing it and its live value,
4 * which an admin sets or clears. A change is stored and reloaded in the same
5 * request, so the next page of anyone's meets it; nothing needs a restart.
6 * Anyone else is told 403, and an agent is never an admin here.
7 */
8
9 import { checkInput, flagRegistry, mayManageFlags, useFlagRegistry, type FlagDecl, type FlagValue } from "../../core/flags.ts";
10 import type { Ctx } from "../../http/context.ts";
11 import { settingsLayout, signInFirst } from "../chrome.ts";
12 import { field, readForm } from "../form.ts";
13 import { html } from "../html.ts";
14 import { flashTo, page } from "../layout.ts";
15 import { btn, card, empty, icon, row } from "../ui.ts";
16 import { accountSections } from "./origins.ts";
17
18 export { useFlagRegistry };

Nit: this re-export exists only so test/flags-settings.test.ts can import the test seam from the page. The terminal test already imports it from core/flags.ts. Import it from there in both tests and drop this line.

19
20 function denied(ctx: Ctx): Response | undefined {
21 const user = ctx.auth.user;
22 if (!user) return signInFirst(ctx);
23 if (mayManageFlags(user)) return undefined;
24 return page(
25 {
26 ctx,
27 title: "Forbidden",
28 body: html`<div class="page-head"><h1>403</h1></div>
29 ${empty({
30 icon: "lock",
31 title: "Admins only",
32 note: "Flags are set by a forge admin.",
33 action: btn("Go home", { href: "/", variant: "primary" }),
34 })}`,
35 },
36 403,
37 );
38 }
39
40 /** The owner issue on the forge's own repository; plain text when it has none. */
41 export function issueLink(repo: string | undefined, n: number) {
42 return repo === undefined
43 ? html`#${String(n)}`
44 : html`<a href="/${repo}/issues/${String(n)}">#${String(n)}</a>`;
45 }
46
47 function summary(v: FlagValue | undefined) {
48 if (v === undefined) return "not set";
49 return [
50 v.forge === undefined ? "" : `forge ${v.forge ? "on" : "off"}`,
51 v.users ? `${v.users.length} user(s)` : "",
52 v.roles ? `roles ${v.roles.join(", ")}` : "",
53 v.percent === undefined ? "" : `${v.percent}%`,
54 ]
55 .filter((x) => x !== "")
56 .join(" · ");
57 }
58
59 // GET /settings/flags
60 export function flagsPage(ctx: Ctx): Response {
61 const no = denied(ctx);
62 if (no) return no;
63 const registry = flagRegistry();
64 const repo = ctx.app.config.forgeRepo;
65 const names = Object.keys(registry.decls);
66 const rows = names.map((name) => {
67 const decl = (registry.decls as Record<string, FlagDecl>)[name]!;
68 const v = registry.value(name as never);
69 return row({
70 lead: icon("activity"),
71 title: html`<span class="mono">${name}</span>`,
72 meta: html`${decl.why}<br />Default
73 <span class="mono">${decl.default ? "on" : "off"}</span> · owner
74 ${issueLink(repo, decl.issue)} ·
75 live: ${summary(v)}`,
76 trail: html`<form class="row-actions" method="post" action="/settings/flags">
77 <input type="hidden" name="name" value="${name}" />
78 <select class="input" name="forge" aria-label="${name} for the forge">
79 <option value="" ${v?.forge === undefined ? "selected" : ""}>forge: default</option>
80 <option value="on" ${v?.forge === true ? "selected" : ""}>forge: on</option>
81 <option value="off" ${v?.forge === false ? "selected" : ""}>forge: off</option>
82 </select>
83 <input class="input mono" name="users" placeholder="user ids" value="${(v?.users ?? []).join(", ")}" aria-label="${name} users" />
84 <input class="input mono" name="roles" placeholder="roles" value="${(v?.roles ?? []).join(", ")}" aria-label="${name} roles" />
85 <input class="input mono" name="percent" placeholder="%" size="3" value="${v?.percent === undefined ? "" : String(v.percent)}" aria-label="${name} percent" />
86 ${btn("Save", { size: "sm", variant: "primary", label: `Save ${name}` })}
87 ${btn("Clear", { size: "sm", name: "clear", value: "1", label: `Clear ${name}` })}
88 </form>`,
89 });
90 });
91 const body = card(
92 names.length === 0
93 ? html`<div class="card-body">
94 <p class="muted small">No flag is declared yet.</p>
95 </div>`
96 : html`<ul class="list">
97 ${rows}
98 </ul>`,
99 {
100 head: html`${icon("activity")}
101 <h2>Flags</h2>`,
102 pad: false,
103 },
104 );
105 return page({
106 ctx,
107 title: "Flags",
108 body: html`<div class="page-head"><h1>Flags</h1></div>
109 ${settingsLayout(accountSections("flags", { admin: true }), body, "Account settings")}`,
110 });
111 }
112
113 // POST /settings/flags
114 export async function flagsSet(ctx: Ctx): Promise<Response> {
115 // Checked before the body is read: a non-admin learns nothing of the door.
116 const no = denied(ctx);
117 if (no) return no;
118 const registry = flagRegistry();
119 const form = await readForm(ctx, "admin");
120 if (form instanceof Response) return form;
121 const name = field(form, "name");
122 const checked =
123 field(form, "clear") === "1"
124 ? ({ ok: true, value: undefined } as const)
125 : checkInput({
126 forge: field(form, "forge"),
127 users: field(form, "users"),
128 roles: field(form, "roles"),
129 percent: field(form, "percent"),
130 });
131 const stored =
132 checked.ok && registry.set(ctx.app.db, name, checked.value, ctx.auth.user!.id);
133 return flashTo(
134 ctx,
135 "/settings/flags",
136 !checked.ok
137 ? { tone: "danger", message: checked.message }
138 : !stored
139 ? { tone: "danger", message: "There is no such flag." }
140 : { message: `${name}: ${checked.value === undefined ? "cleared" : "saved"}.` },
141 );
142 }
src/web/pages/origins.ts +7
@@ -84,6 +84,7 @@ export type AccountSection =
8484 | "access"
8585 | "resilience"
8686 | "limits"
87 | "flags"
8788 | "cloud";
8889
8990 /**
@@ -153,6 +154,12 @@ export function accountSections(
153154 icon: "activity" as const,
154155 current: current === "limits",
155156 },
157 {
158 label: "Flags",
159 href: "/settings/flags",
160 icon: "activity" as const,
161 current: current === "flags",
162 },
156163 {
157164 label: "Backups",
158165 href: "/settings/backups",
test/flags-settings.test.ts added +108
@@ -0,0 +1,108 @@
1 /** Settings → Flags (#1970): admin-only, validated, live at once, kept. */
2
3 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
4 import { mkdtemp, rm } from "node:fs/promises";
5 import { tmpdir } from "node:os";
6 import { join } from "node:path";
7 import { createApp, type App } from "../src/app.ts";
8 import { checkInput, makeFlags } from "../src/core/flags.ts";
9 import { createUser } from "../src/core/users.ts";
10 import { serve, type Servers } from "../src/http/server.ts";
11 import { useFlagRegistry } from "../src/web/pages/flags.ts";
12
13 const decl = { why: "shows the new board", default: false, issue: 1970 };
14 const reg = makeFlags({ alpha: decl });
15
16 describe("settings → flags", () => {
17 let dataDir: string;
18 let app: App;
19 let servers: Servers;
20 let aliceId = "";
21
22 const login = async (username: string): Promise<string> => {
23 const res = await fetch(`${servers.url}/signin`, {
24 method: "POST",
25 redirect: "manual",
26 headers: { "content-type": "application/x-www-form-urlencoded", origin: servers.url },
27 body: new URLSearchParams({ username, password: "a-good-passphrase" }),
28 });
29 return (res.headers.get("set-cookie") ?? "").split(";")[0] ?? "";
30 };
31 const post = (cookie: string, body: Record<string, string>, origin = servers.url) =>
32 fetch(`${servers.url}/settings/flags`, {
33 method: "POST",
34 redirect: "manual",
35 headers: { cookie, "content-type": "application/x-www-form-urlencoded", origin },
36 body: new URLSearchParams(body),
37 });
38
39 beforeAll(async () => {
40 dataDir = await mkdtemp(join(tmpdir(), "forge-flags-"));
41 app = createApp({ dataDir, port: 0, forgeRepo: "acme/forge" });
42 servers = await serve(app);
43 for (const [username, adminIfFirst] of [["alice", true], ["bob", false]] as const) {
44 const u = await createUser(app.db, {
45 trusted: true,
46 username,
47 email: `${username}@forge.local`,
48 password: "a-good-passphrase",
49 adminIfFirst,
50 });
51 if (!u.ok) throw new Error(u.error);
52 if (username === "alice") aliceId = u.value.id;
53 }
54 useFlagRegistry(reg);
55 });
56 afterAll(async () => {
57 useFlagRegistry(undefined);
58 servers.stop();
59 app.close();
60 await rm(dataDir, { recursive: true, force: true });
61 });
62
63 test("a non-admin gets 403 on the page and the POST, and nothing changes", async () => {
64 const bob = await login("bob");
65 expect((await fetch(`${servers.url}/settings/flags`, { headers: { cookie: bob } })).status).toBe(403);
66 expect((await post(bob, { name: "alpha", forge: "on" })).status).toBe(403);
67 expect(reg.flag("alpha")).toBe(false);
68 });
69
70 test("an admin sees the declared flag, sets it live, and clears it", async () => {
71 const cookie = await login("alice");
72 const text = await (await fetch(`${servers.url}/settings/flags`, { headers: { cookie } })).text();
73 expect(text).toContain("alpha");
74 expect(text).toContain("shows the new board");
75 expect(text).toContain('href="/acme/forge/issues/1970"');
76
77 const set = await post(cookie, { name: "alpha", forge: "on", users: "u1, u2", roles: "admin", percent: "30" });
78 expect(set.status).toBe(303);
79 expect(reg.value("alpha")).toEqual({ forge: true, users: ["u1", "u2"], roles: ["admin"], percent: 30 });
80 expect(reg.flag("alpha")).toBe(true);
81 const log = app.db.query("SELECT actor_id FROM forge_setting_change WHERE key = 'flag.alpha'").all();
82 expect(log).toEqual([{ actor_id: aliceId }]);
83
84 await post(cookie, { name: "alpha", clear: "1" });
85 expect(reg.value("alpha")).toBeUndefined();
86 expect(reg.flag("alpha")).toBe(false);
87 });
88
89 test("a bad value, an unknown flag and a foreign origin change nothing", async () => {
90 const cookie = await login("alice");
91 await post(cookie, { name: "alpha", percent: "101" });
92 await post(cookie, { name: "alpha", users: "a;drop" });
93 await post(cookie, { name: "nope", forge: "on" });
94 expect((await post(cookie, { name: "alpha", forge: "on" }, "https://evil.example")).status).toBeGreaterThanOrEqual(400);
95 expect(reg.value("alpha")).toBeUndefined();
96 expect(app.db.query("SELECT key FROM forge_setting WHERE key LIKE 'flag.%'").all()).toEqual([]);
97 });
98 });
99
100 describe("checkInput", () => {
101 const base = { forge: "", users: "", roles: "", percent: "" };
102 test("validates against the declared shape", () => {
103 expect(checkInput(base)).toEqual({ ok: true, value: undefined });
104 expect(checkInput({ ...base, forge: "off", percent: "0" })).toEqual({ ok: true, value: { forge: false, percent: 0 } });
105 for (const bad of [{ forge: "maybe" }, { percent: "-1" }, { percent: "1.5" }, { percent: "101" }, { users: "a b/c" }, { roles: "Admin" }])
106 expect(checkInput({ ...base, ...bad }).ok).toBe(false);
107 });
108 });
test/tui-flags.test.ts added +140
@@ -0,0 +1,140 @@
1 /**
2 * Flags in the terminal (#1970): an admin who is not an agent sees the
3 * declared flags and sets or clears one, live; anyone else has no command
4 * and, driving the screen directly, 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 { makeFlags, useFlagRegistry } from "../src/core/flags.ts";
12 import { createUser, getUserByName } from "../src/core/users.ts";
13 import { createForgeApp } from "../src/tui/forge.ts";
14 import { flagsScreen, parseLine } from "../src/tui/screens/flags.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 const reg = makeFlags({ alpha: { why: "shows the new board", default: false, issue: 1970 } });
23
24 describe("flags in the terminal", () => {
25 let dataDir: string;
26 let forge: Forge;
27
28 function boot(username: string) {
29 const terminal = headlessTerminal(140, 32);
30 const user = getUserByName(forge.db, username)!;
31 const identity: TuiIdentity = { sshUsername: "t", remoteAddress: "127.0.0.1", user };
32 const { app } = createForgeApp(forge, identity, { terminal, exitHooks: false });
33 app.start();
34 const key = (k: string) =>
35 app.dispatch({ type: "key", key: k, ctrl: false, alt: false, shift: false });
36 return {
37 text: () => app.screen.toText(),
38 press: async (...keys: string[]) => {
39 keys.forEach(key);
40 await settleApp(app);
41 },
42 ctrl: async (k: string) => {
43 app.dispatch({ type: "key", key: k, ctrl: true, alt: false, shift: false });
44 await settleApp(app);
45 },
46 type: async (t: string) => {
47 for (const ch of t) key(ch);
48 await settleApp(app);
49 },
50 stop: () => app.stop(),
51 };
52 }
53
54 beforeAll(async () => {
55 dataDir = await mkdtemp(join(tmpdir(), "forge-tui-flags-"));
56 forge = createApp({ dataDir, port: 0 });
57 for (const [username, adminIfFirst] of [["alice", true], ["bob", false], ["carol", false]] as const) {
58 const made = await createUser(forge.db, {
59 trusted: true,
60 username,
61 email: `${username}@forge.local`,
62 password: "a-good-passphrase",
63 adminIfFirst,
64 });
65 if (!made.ok) throw new Error(made.error);
66 }
67 forge.db.query("UPDATE user SET is_admin = 1, is_agent = 1 WHERE username = 'carol'").run();
68 useFlagRegistry(reg);
69 });
70 afterAll(async () => {
71 useFlagRegistry(undefined);
72 forge.close();
73 await rm(dataDir, { recursive: true, force: true });
74 });
75
76 const open = async (h: ReturnType<typeof boot>) => {
77 await h.ctrl("k");
78 await h.type("Flags");
79 await h.press("enter");
80 };
81
82 test("an admin sees a flag's why, default and owner, sets it live, and clears it", async () => {
83 const h = boot("alice");
84 await open(h);
85 expect(h.text()).toContain("alpha");
86 expect(h.text()).toContain("shows the new board");
87 expect(h.text()).toContain("default off");
88 expect(h.text()).toContain("owner #1970");
89
90 await h.press("enter");
91 await h.type("forge=on percent=30");
92 await h.press("enter");
93 expect(reg.value("alpha")).toEqual({ forge: true, percent: 30 });
94 expect(reg.flag("alpha")).toBe(true);
95 expect(h.text()).toContain("alpha: saved.");
96
97 await h.press("x");
98 expect(reg.value("alpha")).toBeUndefined();
99 expect(h.text()).toContain("alpha: cleared.");
100 h.stop();
101 });
102
103 test("a bad line is refused and nothing changes", async () => {
104 const h = boot("alice");
105 await open(h);
106 await h.press("enter");
107 await h.type("percent=101");
108 await h.press("enter");
109 expect(h.text()).toContain("percent is a whole number");
110 expect(reg.value("alpha")).toBeUndefined();
111 expect(parseLine("bogus")).toContain("use forge=");
112 h.stop();
113 });
114
115 test("a non-admin and an admin agent have no command", async () => {
116 for (const who of ["bob", "carol"]) {
117 const h = boot(who);
118 await h.ctrl("k");
119 await h.type("Flags");
120 expect(h.text()).not.toContain("declared flags and their live values");
121 h.stop();
122 }
123 });
124
125 test("a non-admin driving the screen reads nothing and writes nothing", async () => {
126 for (const who of ["bob", "carol"]) {
127 const user = getUserByName(forge.db, who)!;
128 const session = new Session(forge, { sshUsername: "t", remoteAddress: "127.0.0.1", user });
129 const pushed: unknown[] = [];
130 const ctx = { push: (s: unknown) => pushed.push(s), invalidate() {} } as never;
131 const screen = flagsScreen(session, ctx);
132 const key = (k: string) => screen.key?.({ type: "key", key: k, ctrl: false, alt: false, shift: false } as never);
133 key("enter");
134 key("x");
135 expect(pushed).toEqual([]);
136 expect(reg.value("alpha")).toBeUndefined();
137 expect(forge.db.query("SELECT key FROM forge_setting_change WHERE key = 'flag.alpha' AND actor_id = ?1").all(user.id).length).toBe(0);
138 }
139 });
140 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs