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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/1970-settings-flags-lets-an-admin-see-every-d → main 5 commits fork 2 threads · 2 unresolved
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 | ||
| 1104 | 1104 | |
| 1105 | 1105 | 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. |
| 1106 | 1106 | |
| 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 | ||
| 1107 | 1111 | ## Limits |
| 1108 | 1112 | |
| 1109 | 1113 | 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( | ||
| 108 | 108 | return value.forge ?? def; |
| 109 | 109 | } |
| 110 | 110 | |
| 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 | ||
| 111 | 156 | /** Declared names that no source reads as `flag("name"` (or `'name'`). */ |
| 112 | 157 | export function unreadFlags( |
| 113 | 158 | names: readonly string[], |
| @@ -145,6 +190,45 @@ export function makeFlags<const D extends Record<string, FlagDecl>>(decls: D) { | ||
| 145 | 190 | values = new Map(); |
| 146 | 191 | } |
| 147 | 192 | }, |
| 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 | }, | |
| 148 | 232 | flag(name: keyof D & string, viewer?: Viewer): boolean { |
| 149 | 233 | return evaluate(name, decls[name]!.default, values.get(name), viewer); |
| 150 | 234 | }, |
| @@ -158,3 +242,24 @@ export const loadFlags = forgeFlags.load; | ||
| 158 | 242 | |
| 159 | 243 | /** Whether `name` is on for `viewer` (none: signed out). */ |
| 160 | 244 | export const flag = forgeFlags.flag; |
| 245 | ||
| 246 | /** The forge's registry, for the settings page. */ | |
| 247 | export const forgeRegistry = forgeFlags; | |
| 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"; | ||
| 11 | 11 | import { accessPage } from "../web/pages/access.ts"; |
| 12 | 12 | import { capacityPage, capacitySet } from "../web/pages/capacity.ts"; |
| 13 | 13 | import { flowPage } from "../web/pages/flow.ts"; |
| 14 | import { flagsPage, flagsSet } from "../web/pages/flags.ts"; | |
| 14 | 15 | import { domainsPage } from "../web/pages/domains.ts"; |
| 15 | 16 | import { resiliencePage } from "../web/pages/resilience.ts"; |
| 16 | 17 | import { limitsPage, limitsSet, quotasSet } from "../web/pages/admin-limits.ts"; |
| @@ -749,6 +750,8 @@ export function buildRouter(): Router<Handler> { | ||
| 749 | 750 | router.add("GET", "/boards/:slug", boardShow); |
| 750 | 751 | router.add("GET", "/boards/:slug/live", boardLive); |
| 751 | 752 | router.add("POST", "/boards/:slug/:op", boardPost); |
| 753 | router.add("GET", "/settings/flags", flagsPage); | |
| 754 | router.add("POST", "/settings/flags", flagsSet); | |
| 752 | 755 | router.add("GET", "/admin/limits", limitsPage); |
| 753 | 756 | router.add("POST", "/admin/limits", limitsSet); |
| 754 | 757 | router.add("POST", "/admin/quotas", quotasSet); |
src/main.ts +2
| @@ -11,6 +11,7 @@ import { parentUrlProblem } from "./core/parent_releases.ts"; | ||
| 11 | 11 | import { releaseEnv } from "./release/env.ts"; |
| 12 | 12 | import { createApp } from "./app.ts"; |
| 13 | 13 | import { backfillCarried } from "./core/carried.ts"; |
| 14 | import { loadFlags } from "./core/flags.ts"; | |
| 14 | 15 | import { reconcile } from "./core/reconcile.ts"; |
| 15 | 16 | import { syncAllPillars } from "./actions/pillars.ts"; |
| 16 | 17 | import { bootHostedChildren } from "./spawn/host.ts"; |
| @@ -233,6 +234,7 @@ const app = createApp({ | ||
| 233 | 234 | }), |
| 234 | 235 | }); |
| 235 | 236 | |
| 237 | loadFlags(app.db); | |
| 236 | 238 | await reconcile(app); |
| 237 | 239 | // Issues linked under a pillar's umbrella before pillars had labels. |
| 238 | 240 | const labelled = syncAllPillars(app); |
src/tui/forge.ts +10
| @@ -37,7 +37,9 @@ import { issueScreen } from "./screens/issue.ts"; | ||
| 37 | 37 | import { pullScreen } from "./screens/pull.ts"; |
| 38 | 38 | import { blobScreen } from "./screens/repo.ts"; |
| 39 | 39 | import { dashboardScreen } from "./screens/dashboard.ts"; |
| 40 | import { mayManageFlags } from "../core/flags.ts"; | |
| 40 | 41 | import { accessScreen } from "./screens/access.ts"; |
| 42 | import { flagsScreen } from "./screens/flags.ts"; | |
| 41 | 43 | import { domainsScreen } from "./screens/domains.ts"; |
| 42 | 44 | import { resilienceLines, resilienceScreen } from "./screens/resilience.ts"; |
| 43 | 45 | import { flowLines, flowScreen } from "./screens/flow.ts"; |
| @@ -268,6 +270,14 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] { | ||
| 268 | 270 | hint: "what outside agents tried", |
| 269 | 271 | run: () => ctx.push(accessScreen(session, ctx)), |
| 270 | 272 | }); |
| 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 | }); | |
| 271 | 281 | // Each failure domain and its last drill, as Settings → Failure domains says it (#1945). |
| 272 | 282 | if (session.user?.isAdmin === true && session.user.kind !== "agent") |
| 273 | 283 | 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 |
||
| 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 = | ||
| 84 | 84 | | "access" |
| 85 | 85 | | "resilience" |
| 86 | 86 | | "limits" |
| 87 | | "flags" | |
| 87 | 88 | | "cloud"; |
| 88 | 89 | |
| 89 | 90 | /** |
| @@ -153,6 +154,12 @@ export function accountSections( | ||
| 153 | 154 | icon: "activity" as const, |
| 154 | 155 | current: current === "limits", |
| 155 | 156 | }, |
| 157 | { | |
| 158 | label: "Flags", | |
| 159 | href: "/settings/flags", | |
| 160 | icon: "activity" as const, | |
| 161 | current: current === "flags", | |
| 162 | }, | |
| 156 | 163 | { |
| 157 | 164 | label: "Backups", |
| 158 | 165 | 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 | }); |
new above
offline
Nit:
forgeRegistryis only read in this file (as the default foractive). UseforgeFlagsdirectly and drop the export, so the module exposesflagRegistry()as its only way in.