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

Absorb: keep secret-type feature settings #9909

Merged opened this

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

Merged

scout/forge:scout/9886-absorb-keep-secret-type-feature-settings → main 7 commits fork 2 threads · 2 unresolved

  • Merged. scout/9886-absorb-keep-secret-type-feature-settings was merged into main as 4867031.

Review

Sign in to review this change.
11 files changed +142 −26
docs/features/absorb.md +5 −3
@@ -59,9 +59,11 @@ What the feed holds about an app's repository (runs, pulls, issues, looks) shows
5959
6060 Absorbing, settings, the menu entry and the page use no model and start no agent run. They work with the crew off and no model credential on the forge. `test/apps-features.test.ts` runs the whole flow that way. The forge never hands an app the crew's credential. A feature's schedule is the app's own: the app reads `FORGE_SETTING_*` and runs its own timer. An app that wants a model's judgment on top, such as an issue written from what it found, must do its work without one and treat the model as optional.
6161
62 ## Settings are not secrets
62 ## Settings are not secrets, except `secret`
outdated

State that a secret reaches previews of the app too (code in a pull can read it), so an admin knows before keeping one.

6363
64 Settings are stored as plain values. Anyone who can read the app's repository can read them through `GET /api/v1/features/:slug`, and each preview of the app is given them. Put keys and passwords in the app's secrets (`/settings/secrets` or the app's variables), not in a setting.
64 A setting of type `secret` is kept sealed (AES-GCM, bound to the app and the name), handed to the app as `FORGE_SETTING_*` and never shown again: pages, the API and the MCP say only whether one is kept. It has no default (forge.json is not a place for one); a blank in the page's form or the terminal's prompt keeps what is kept, while a blank sent through the API or MCP clears it (it then reads as not set). The terminal masks what is typed and shows `kept` or `not set`. Sealed is not hidden from the app: a secret reaches every preview of the app that gets settings, as a plain setting does, so code in a pull can read it. Keep one only for an app whose previews you trust. The page shows `Kept` or `Not set` as text tied to the field and says that clearing is the API's or the MCP's. Every other type is stored as a plain value.
65
66 Other settings are stored as plain values. Anyone who can read the app's repository can read them through `GET /api/v1/features/:slug`, and each preview of the app is given them. Put keys and passwords in the app's secrets (`/settings/secrets` or the app's variables), not in a setting.
6567
6668 ## Review
6769
@@ -80,7 +82,7 @@ Settings stay behind on a fork. A fork of another forge's app carries its `featu
8082
8183 ## Not done yet
8284
83 Until then an absorbed app is a menu link with kept settings (Settings › Features says so). The forge does not yet fire a feature's schedule (#9883) or keep `secret` settings (#9886).
85 Until then an absorbed app is a menu link with kept settings (Settings › Features says so). The forge does not yet fire a feature's schedule (#9883).
8486
8587 ## MCP tools
8688
src/actions/features.ts +13 −4
@@ -20,11 +20,13 @@
2020 import type { App } from "../app.ts";
2121 import { readFileAt } from "../apps/checkout.ts";
2222 import { checkSetting, featureEnv, parseFeature, settingEnvName, type FeatureSpec } from "../apps/feature.ts";
23 import { keptSettings as openSettings, sealSetting } from "../apps/feature_env.ts";
2324 import type { ToolSpec } from "../apps/feature_tools.ts";
2425 import { appsRuntime } from "../apps/runtime.ts";
2526 import { getAppByRepo, getAppById, getAppBySlug, listApps, type AppRecord } from "../core/apps.ts";
2627 import {
2728 absorb,
29 clearFeatureSettings,
2830 keepDeclaration,
2931 featureSettings,
3032 getAbsorbed,
@@ -215,7 +217,7 @@ function viewOf(
215217 problem: string | null,
216218 provenance: Provenance,
217219 ): FeatureView {
218 const kept = featureSettings(app.db, record.id);
220 const kept = spec === undefined ? featureSettings(app.db, record.id) : openSettings(app.db, app.config.dataDir, record.id, spec);
219221 const absorbed = getAbsorbed(app.db, record.id);
220222 const env = spec === undefined ? {} : featureEnv(spec, kept);
221223 return {
@@ -243,7 +245,8 @@ function viewOf(
243245 type: s.type,
244246 label: s.label,
245247 help: s.help ?? null,
246 value: env[name] ?? s.default,
248 // A secret is never shown again: only whether one is kept.
249 value: s.type === "secret" ? "" : (env[name] ?? s.default),
247250 default: s.default,
248251 set: kept.has(s.key),
249252 min: s.min ?? null,
@@ -432,9 +435,15 @@ export async function changeFeatureSettings(
432435 if (!checked.ok) return refuse("invalid_setting", `${spec.label} ${checked.error}.`, key);
433436 changes.set(key, checked.value);
434437 }
435 const before = featureSettings(app.db, found.record.id);
438 const before = openSettings(app.db, app.config.dataDir, found.record.id, declared.value);
436439 const changed = [...changes].filter(([k, v]) => (before.get(k) ?? declared.value!.settings.find((s) => s.key === k)!.default) !== v);
437 if (changes.size > 0) keepSettings(app.db, found.record.id, changes, actor.id);
440 const isSecret = (k: string): boolean => declared.value!.settings.find((s) => s.key === k)?.type === "secret";
441 // A blank secret clears it: the row goes, so `set` says not set rather than a sealed empty string.
442 const cleared = [...changes].filter(([k, v]) => v === "" && isSecret(k)).map(([k]) => k);
443 const stored = new Map<string, string>();
444 for (const [k, v] of changes) if (!cleared.includes(k)) stored.set(k, isSecret(k) ? sealSetting(app.config.dataDir, found.record.id, k, v) : v);
445 if (stored.size > 0) keepSettings(app.db, found.record.id, stored, actor.id);
446 if (cleared.length > 0) clearFeatureSettings(app.db, found.record.id, cleared);
438447 if (changed.length > 0) {
439448 app.bus.append({
440449 kind: "app.feature_settings",
src/apps/feature.ts +6 −2
@@ -25,9 +25,9 @@
2525 import { err, ok, type Result } from "../kernel/result.ts";
2626 import { parseTools, type ToolSpec } from "./feature_tools.ts";
2727
28 export type SettingType = "bool" | "int" | "string" | "choice" | "days" | "list";
28 export type SettingType = "bool" | "int" | "string" | "choice" | "days" | "list" | "secret";
2929
30 export const SETTING_TYPES: readonly SettingType[] = ["bool", "int", "string", "choice", "days", "list"];
30 export const SETTING_TYPES: readonly SettingType[] = ["bool", "int", "string", "choice", "days", "list", "secret"];
3131
3232 /** The labels the forge's own menus use: a feature may not borrow one. */
3333 const BUILT_IN_MENU_LABELS = [
@@ -160,6 +160,8 @@ function parseSetting(key: string, raw: unknown): Result<SettingSpec, string> {
160160 return bad(`setting ${key}: only a choice has choices`);
161161 }
162162 const spec = { key, type: type as SettingType, label: label.trim(), help: typeof help === "string" ? help : undefined, min, max, choices, default: "" };
163 if (type === "secret" && raw["default"] !== undefined && raw["default"] !== "")
164 return bad(`setting ${key}: a secret has no default, forge.json is not a place for one`);
163165 const fallback = raw["default"] ?? emptyDefault(spec.type, choices);
164166 const checked = checkSetting(spec, fallback);
165167 if (!checked.ok) return bad(`setting ${key}: default ${checked.error}`);
@@ -178,6 +180,7 @@ function emptyDefault(type: SettingType, choices: readonly string[] | undefined)
178180 case "list":
179181 return [];
180182 case "string":
183 case "secret":
181184 return "";
182185 }
183186 }
@@ -203,6 +206,7 @@ export function checkSetting(spec: Pick<SettingSpec, "type" | "min" | "max" | "c
203206 if (spec.max !== undefined && n > spec.max) return err(`is at most ${spec.max}`);
204207 return ok(String(n));
205208 }
209 case "secret":
206210 case "string": {
207211 if (typeof value !== "string") return err("is text");
208212 if (value.length > STRING_MAX) return err(`is at most ${STRING_MAX} characters`);
src/apps/feature_env.ts +28 −2
@@ -7,11 +7,37 @@
77 */
88
99 import type { Database } from "bun:sqlite";
10 import { featureEnv, parseFeature } from "./feature.ts";
10 import { featureEnv, parseFeature, type FeatureSpec } from "./feature.ts";
1111 import { featureSettings, getAbsorbed } from "../core/features.ts";
12 import { open, seal } from "../kernel/secret.ts";
13
14 /** A secret setting is kept sealed, bound to its app and name, so a copied row opens nowhere else. */
15 const aad = (appId: string, key: string): string => `feature-setting:${appId}:${key}`;
16
17 /** A secret's value sealed for keeping. */
18 export const sealSetting = (dataDir: string, appId: string, key: string, plain: string): string =>
19 seal(dataDir, plain, aad(appId, key));
20
21 /**
22 * The settings kept for an app as the process reads them: secrets opened.
23 * One that will not open (key lost, row altered) is left out, so it falls
24 * back to its empty default rather than to ciphertext.
25 */
26 export function keptSettings(db: Database, dataDir: string, appId: string, spec: FeatureSpec): Map<string, string> {
27 const kept = featureSettings(db, appId);
28 for (const s of spec.settings) {
29 const sealed = kept.get(s.key);
30 if (s.type !== "secret" || sealed === undefined) continue;
31 const opened = open(dataDir, sealed, aad(appId, s.key));
32 if (opened.ok) kept.set(s.key, opened.value);
33 else kept.delete(s.key);
34 }
35 return kept;
36 }
1237
1338 export function featureVarsAt(
1439 db: Database,
40 dataDir: string,
1541 appId: string,
1642 forgeJson: string | undefined,
1743 ): Record<string, string> {
@@ -27,7 +53,7 @@ export function featureVarsAt(
2753 if (!spec.ok || spec.value === undefined) return {};
2854 // The menu keeps the name and page the admin saw at absorb; a changed
2955 // declaration waits for an admin to accept it (`acceptDeclaration`).
30 return featureEnv(spec.value, featureSettings(db, appId));
56 return featureEnv(spec.value, keptSettings(db, dataDir, appId, spec.value));
3157 }
3258
3359 /** Only the names this feature owns: what `appEnv` lets through from `feature`. */
src/apps/runtime.ts +1 −1
@@ -1695,7 +1695,7 @@ export class AppsRuntime {
16951695 } catch {
16961696 forgeJson = undefined;
16971697 }
1698 return featureVarsAt(this.app.db, appId, forgeJson);
1698 return featureVarsAt(this.app.db, this.app.config.dataDir, appId, forgeJson);
16991699 }
17001700
17011701 /**
src/core/features.ts +8
@@ -67,3 +67,11 @@ export function setFeatureSettings(db: Database, appId: string, values: Readonly
6767 for (const [key, value] of values) put.run(appId, key, value, by);
6868 })();
6969 }
70
71 /** Forget these kept settings (a cleared secret): the row goes, so it reads as not set. */
72 export function clearFeatureSettings(db: Database, appId: string, keys: readonly string[]): void {
73 const del = db.query("DELETE FROM app_feature_setting WHERE app_id = ?1 AND key = ?2");
74 db.transaction(() => {
75 for (const key of keys) del.run(appId, key);
76 })();
77 }
src/tui/screens/featureswitch.ts +8 −2
@@ -40,7 +40,7 @@ function featureRow(f: FeatureView): ListRow {
4040 }
4141
4242 function settingRow(s: SettingView): ListRow {
43 return { title: `${s.label} ${s.env}`, meta: s.value === "" ? "empty" : s.value, note: `${s.help ?? s.type}${s.set ? "" : " · not changed"}` };
43 return { title: `${s.label} ${s.env}`, meta: s.type === "secret" ? (s.set ? "kept" : "not set") : s.value === "" ? "empty" : s.value, note: `${s.help ?? s.type}${s.set ? "" : " · not changed"}` };
4444 }
4545
4646 function settingsScreen(f: Session, ctx: Ctx, slug: string): Screen {
@@ -71,9 +71,15 @@ function settingsScreen(f: Session, ctx: Ctx, slug: string): Screen {
7171 promptScreen(ctx, screen, {
7272 title: setting.label,
7373 label: setting.type === "list" || setting.type === "days" ? "comma separated" : setting.type,
74 value: setting.value,
74 value: setting.type === "secret" ? "" : setting.value,
75 mask: setting.type === "secret",
7576 hint: setting.help ?? `${setting.env}; default ${setting.default === "" ? "empty" : setting.default}`,
7677 onSubmit: async (text) => {
78 // As on the page, a blank secret keeps what is kept; clearing is the API's.
79 if (setting.type === "secret" && text === "") {
80 ctx.toast(`${setting.label}: left as it was`);
81 return;
82 }
7783 const saved = await changeFeatureSettings(f.app, f.user, slug, { [setting.key]: text });
7884 if (!saved.ok) {
7985 ctx.toast(saved.error.message, "attn");
src/web/pages/features.ts +7 −2
@@ -144,6 +144,9 @@ function input(s: SettingView, invalid = false) {
144144 }
145145 case "list":
146146 return html`<textarea class="input mono" name="${name}" rows="3" aria-label="${s.label}" ${bad}>${s.value.split(",").filter(Boolean).join("\n")}</textarea>`;
147 case "secret":
outdated

Kept/not-set state lives only in the placeholder. Render it as visible muted text and associate it with the input (aria-describedby); placeholder alone is not accessible state. Add a test for both states.

148 return html`<input class="input mono" type="password" name="${name}" value="" autocomplete="new-password"
149 aria-label="${s.label}" aria-describedby="kept-${s.key}" ${bad} />`;
147150 default:
148151 return html`<input class="input" name="${name}" value="${s.value}" aria-label="${s.label}" ${bad} />`;
149152 }
@@ -185,8 +188,9 @@ function featureView(ctx: Ctx, f: FeatureView, refused?: Refused, items: readonl
185188 const s = refused !== undefined && shown.key in refused.typed ? { ...shown, value: typedValue(shown, refused.typed[shown.key]) } : shown;
186189 return row({
187190 title: html`${s.label} <span class="muted small mono">${s.env}</span>`,
188 meta: html`${bad ? html`<span class="danger" role="alert">${refused.message}</span><br />` : null}${s.help ?? ""}${when(s.help !== null, () => html`<br />`)}Default
189 <span class="mono">${s.default === "" ? "empty" : s.default}</span>${s.set ? "" : " · not changed"}`,
191 meta: html`${bad ? html`<span class="danger" role="alert">${refused.message}</span><br />` : null}${s.help ?? ""}${when(s.help !== null, () => html`<br />`)}${s.type === "secret"
192 ? html`<span id="kept-${s.key}">${s.set ? "Kept" : "Not set"}. Typing replaces it; a blank field keeps it. Clear it through the API or MCP.</span>`
193 : html`Default <span class="mono">${s.default === "" ? "empty" : s.default}</span>${s.set ? "" : " · not changed"}`}`,
190194 trail: input(s, bad),
191195 });
192196 });
@@ -232,6 +236,7 @@ export function settingsFromForm(form: Form, settings: readonly SettingView[]):
232236 const name = `s.${s.key}`;
233237 const all = form.getAll(name).map(String);
234238 if (all.length === 0) continue;
239 if (s.type === "secret" && (all[all.length - 1] ?? "") === "") continue; // blank keeps what is kept
235240 if (s.type === "days") out[s.key] = all.filter((d) => d !== "");
236241 else if (s.type === "bool") out[s.key] = all.includes("on") ? "on" : "off";
237242 else out[s.key] = all[all.length - 1] ?? "";
test/apps-features.test.ts +2 −2
@@ -175,9 +175,9 @@ describe("absorbing a running app", () => {
175175 }, 60_000);
176176
177177 test("a preview's forge.json does not rename the forge's menu entry", () => {
178 featureVarsAt(forge.app.db, record.id, forgeJson("Not Radar"));
178 featureVarsAt(forge.app.db, forge.app.config.dataDir, record.id, forgeJson("Not Radar"));
179179 expect(getAbsorbed(forge.app.db, record.id)?.name).toBe("Radar");
180 featureVarsAt(forge.app.db, record.id, forgeJson("Radar 2"));
180 featureVarsAt(forge.app.db, forge.app.config.dataDir, record.id, forgeJson("Radar 2"));
181181 // A production push does not rename it either: an admin accepts.
182182 expect(getAbsorbed(forge.app.db, record.id)?.name).toBe("Radar");
183183 });
test/features-tui.test.ts +25 −1
@@ -24,7 +24,7 @@ quietLogs();
2424
2525 const FORGE_JSON = JSON.stringify({
2626 app: { run: "bun run src/server.ts" },
27 feature: { name: "Radar", page: "/", settings: { runHourUtc: { type: "int", default: 13, min: 0, max: 23, label: "Hour (UTC)" } } },
27 feature: { name: "Radar", page: "/", settings: { runHourUtc: { type: "int", default: 13, min: 0, max: 23, label: "Hour (UTC)" }, apiToken: { type: "secret", label: "API token" } } },
2828 });
2929
3030 describe("features in the terminal", () => {
@@ -116,6 +116,30 @@ describe("features in the terminal", () => {
116116 t.stop();
117117 });
118118
119 test("a secret is masked, shown as kept, and a blank submit leaves it", async () => {
120 const t = boot(ada);
121 await t.palette();
122 await t.press("enter");
123 await t.press("a");
124 await t.press("enter");
125 expect(t.text()).toContain("not set");
126 await t.press("j");
127 await t.press("enter");
128 await t.type("hunter2-value");
129 expect(t.text()).not.toContain("hunter2-value");
130 await t.press("enter");
131 const kept = featureSettings(forge.db, appId).get("apiToken") ?? "";
132 expect(kept).not.toBe("");
133 expect(kept).not.toContain("hunter2-value");
134 expect(t.text()).toContain("kept");
135 await t.press("enter");
136 await t.press("enter");
137 expect(featureSettings(forge.db, appId).get("apiToken")).toBe(kept);
138 await t.press("esc");
139 await t.press("a");
140 t.stop();
141 });
142
119143 test("a reader opens an absorbed app's feed items from the palette", async () => {
120144 const absorbed = await absorbApp(forge, ada, "radar");
121145 if (!absorbed.ok) throw new Error(absorbed.error);
test/features.test.ts +39 −7
@@ -84,7 +84,7 @@ describe("the declaration", () => {
8484 expect(wrong({ name: "R", page: "//evil.example" })).toContain("page");
8585 expect(wrong({ name: "R", extra: 1 })).toContain("unknown field extra");
8686 expect(wrong({ name: "R", settings: { RunDays: { type: "days" } } })).toContain("camelCase");
87 expect(wrong({ name: "R", settings: { a: { type: "secret" } } })).toContain("type is one of");
87 expect(wrong({ name: "R", settings: { a: { type: "password" } } })).toContain("type is one of");
8888 expect(wrong({ name: "R", settings: { h: { type: "int", min: 5, max: 1 } } })).toContain("min is above max");
8989 expect(wrong({ name: "R", settings: { h: { type: "int", default: 30, max: 23 } } })).toContain("default is at most 23");
9090 expect(wrong({ name: "R", settings: { c: { type: "choice", choices: ["only"] } } })).toContain("choices");
@@ -226,7 +226,7 @@ describe("absorbing an app", () => {
226226 test("declaring is not absorbing: nothing is in the nav and the app is told nothing", async () => {
227227 expect(getAbsorbed(app.db, appId)).toBeUndefined();
228228 expect(absorbedForNav(app, ada)).toEqual([]);
229 expect(featureVarsAt(app.db, appId, FORGE_JSON)).toEqual({});
229 expect(featureVarsAt(app.db, app.config.dataDir, appId, FORGE_JSON)).toEqual({});
230230 const seen = await getFeature(app, cy, "radar");
231231 expect(seen.ok && seen.value.absorbed).toBe(false);
232232 });
@@ -311,7 +311,7 @@ describe("absorbing an app", () => {
311311
312312 test("a production push that renames the feature leaves the menu unchanged until an admin accepts", async () => {
313313 const renamed = JSON.stringify({ app: { run: "x" }, feature: { ...FEATURE, name: "Radar Pro", page: "/pro" } });
314 featureVarsAt(app.db, appId, renamed);
314 featureVarsAt(app.db, app.config.dataDir, appId, renamed);
315315 expect(absorbedForNav(app, ada).map((f) => [f.name, f.page])).toEqual([["Radar", "/"]]);
316316 const c = await commitToBranch(app, cy, repo, { message: "rename", files: [{ path: "forge.json", content: renamed }] });
317317 if (!c.ok) throw new Error(JSON.stringify(c.error));
@@ -533,7 +533,7 @@ describe("absorbing an app", () => {
533533 test("good values are kept and reach the app as FORGE_SETTING_*", async () => {
534534 const res = await api("PATCH", adaToken, "/radar/settings", { settings: { runDays: ["wed", "mon", "fri"], runHourUtc: 9 } });
535535 expect(res.status).toBe(200);
536 expect(featureVarsAt(app.db, appId, FORGE_JSON)).toMatchObject({
536 expect(featureVarsAt(app.db, app.config.dataDir, appId, FORGE_JSON)).toMatchObject({
537537 FORGE_FEATURE: "1",
538538 FORGE_SETTING_RUN_DAYS: "mon,wed,fri",
539539 FORGE_SETTING_RUN_HOUR_UTC: "9",
@@ -552,7 +552,7 @@ describe("absorbing an app", () => {
552552 ]);
553553 const saved = await web(adaCookie, "/settings/features/radar", form);
554554 expect(saved.status).toBe(303);
555 expect(featureVarsAt(app.db, appId, FORGE_JSON)).toMatchObject({
555 expect(featureVarsAt(app.db, app.config.dataDir, appId, FORGE_JSON)).toMatchObject({
556556 FORGE_SETTING_RUN_DAYS: "tue",
557557 FORGE_SETTING_CLAUDE_CODE: "stable",
558558 FORGE_SETTING_SCOUT: "false",
@@ -564,13 +564,13 @@ describe("absorbing an app", () => {
564564 test("un-absorbing puts it back as an ordinary app and keeps its values for the next absorb", async () => {
565565 const out = await unabsorbApp(app, ada, "radar");
566566 expect(out.ok && out.value.absorbed).toBe(false);
567 expect(featureVarsAt(app.db, appId, FORGE_JSON)).toEqual({});
567 expect(featureVarsAt(app.db, app.config.dataDir, appId, FORGE_JSON)).toEqual({});
568568 expect(absorbedForNav(app, ada)).toEqual([]);
569569 expect((await api("DELETE", adaToken, "/radar")).status).toBe(409);
570570 const changeWhileOut = await changeFeatureSettings(app, ada, "radar", { runHourUtc: 1 });
571571 expect(!changeWhileOut.ok && changeWhileOut.error.error).toBe("not_absorbed");
572572 await absorbApp(app, ada, "radar");
573 expect(featureVarsAt(app.db, appId, FORGE_JSON)).toMatchObject({ FORGE_SETTING_RUN_DAYS: "tue", FORGE_SETTING_RUN_HOUR_UTC: "9" });
573 expect(featureVarsAt(app.db, app.config.dataDir, appId, FORGE_JSON)).toMatchObject({ FORGE_SETTING_RUN_DAYS: "tue", FORGE_SETTING_RUN_HOUR_UTC: "9" });
574574 });
575575
576576 test("the MCP tool plans first, and applies only with an admin-scope token", async () => {
@@ -676,6 +676,38 @@ describe("absorbing an app", () => {
676676 expect(after.ok && after.value?.name).toBe("Radar 2");
677677 });
678678
679 test("a secret setting is kept sealed, never shown again, and reaches the app", async () => {
680 expect(parseFeature({ feature: { name: "Z", settings: { token: { type: "secret", default: "x" } } } }).ok).toBe(false);
681 const withSecret = JSON.stringify({ app: { run: "bun run src/server.ts" }, feature: { ...FEATURE, settings: { ...FEATURE.settings, apiToken: { type: "secret", label: "API token" } } } });
682 const done = await commitToBranch(app, cy, repo, { message: "secret", files: [{ path: "forge.json", content: withSecret }] });
683 if (!done.ok) throw new Error(JSON.stringify(done.error));
684 const res = await api("PATCH", adaToken, "/radar/settings", { settings: { apiToken: "hunter2-value" } });
685 expect(res.status).toBe(200);
686 expect(JSON.stringify(await res.json())).not.toContain("hunter2-value");
687 const stored = featureSettings(app.db, appId).get("apiToken") ?? "";
688 expect(stored).not.toContain("hunter2-value");
689 expect(featureVarsAt(app.db, app.config.dataDir, appId, withSecret)["FORGE_SETTING_API_TOKEN"]).toBe("hunter2-value");
690 const page = await (await web(adaCookie, "/settings/features/radar")).text();
691 expect(page).not.toContain("hunter2-value");
692 expect(page).toContain('type="password"');
693 expect(page).toContain('aria-describedby="kept-apiToken"');
694 expect(page).toMatch(/id="kept-apiToken">Kept\./);
695 // A blank form field keeps the secret.
696 const form = new URLSearchParams([["s.apiToken", ""], ["s.runHourUtc", "9"]]);
697 expect((await web(adaCookie, "/settings/features/radar", form)).status).toBe(303);
698 expect(featureVarsAt(app.db, app.config.dataDir, appId, withSecret)["FORGE_SETTING_API_TOKEN"]).toBe("hunter2-value");
699 // A blank through the API clears it: the view says not set.
700 const cleared = await api("PATCH", adaToken, "/radar/settings", { settings: { apiToken: "" } });
701 expect(cleared.status).toBe(200);
702 const view = (await cleared.json()) as { settings: { key: string; set: boolean }[] };
703 expect(view.settings.find((x) => x.key === "apiToken")?.set).toBe(false);
704 expect(featureSettings(app.db, appId).has("apiToken")).toBe(false);
705 expect(await (await web(adaCookie, "/settings/features/radar")).text()).toMatch(/id="kept-apiToken">Not set\./);
706 // The event names the key, not the value.
707 const events = listEvents(app.db, { repoId: repo.id }).filter((e) => e.kind === "app.feature_settings");
708 expect(JSON.stringify(events)).not.toContain("hunter2");
709 });
710
679711 test("with 20 more absorbed apps the menu is built from one pass and renders within budget", async () => {
680712 for (let i = 0; i < 20; i++) {
681713 const r = await createRepo(app, cy, { name: `many-${i}`, visibility: "public", description: "" });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs