Public: anyone can read this; the crew can change it.
Absorb: keep secret-type feature settings #9909
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/9886-absorb-keep-secret-type-feature-settings → main 7 commits fork 2 threads · 2 unresolved
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 | ||
| 59 | 59 | |
| 60 | 60 | 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. |
| 61 | 61 | |
| 62 | ## Settings are not secrets | |
| 62 | ## Settings are not secrets, except `secret` | |
| 63 | 63 | |
| 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. | |
| 65 | 67 | |
| 66 | 68 | ## Review |
| 67 | 69 | |
| @@ -80,7 +82,7 @@ Settings stay behind on a fork. A fork of another forge's app carries its `featu | ||
| 80 | 82 | |
| 81 | 83 | ## Not done yet |
| 82 | 84 | |
| 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). | |
| 84 | 86 | |
| 85 | 87 | ## MCP tools |
| 86 | 88 |
src/actions/features.ts +13 −4
| @@ -20,11 +20,13 @@ | ||
| 20 | 20 | import type { App } from "../app.ts"; |
| 21 | 21 | import { readFileAt } from "../apps/checkout.ts"; |
| 22 | 22 | import { checkSetting, featureEnv, parseFeature, settingEnvName, type FeatureSpec } from "../apps/feature.ts"; |
| 23 | import { keptSettings as openSettings, sealSetting } from "../apps/feature_env.ts"; | |
| 23 | 24 | import type { ToolSpec } from "../apps/feature_tools.ts"; |
| 24 | 25 | import { appsRuntime } from "../apps/runtime.ts"; |
| 25 | 26 | import { getAppByRepo, getAppById, getAppBySlug, listApps, type AppRecord } from "../core/apps.ts"; |
| 26 | 27 | import { |
| 27 | 28 | absorb, |
| 29 | clearFeatureSettings, | |
| 28 | 30 | keepDeclaration, |
| 29 | 31 | featureSettings, |
| 30 | 32 | getAbsorbed, |
| @@ -215,7 +217,7 @@ function viewOf( | ||
| 215 | 217 | problem: string | null, |
| 216 | 218 | provenance: Provenance, |
| 217 | 219 | ): 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); | |
| 219 | 221 | const absorbed = getAbsorbed(app.db, record.id); |
| 220 | 222 | const env = spec === undefined ? {} : featureEnv(spec, kept); |
| 221 | 223 | return { |
| @@ -243,7 +245,8 @@ function viewOf( | ||
| 243 | 245 | type: s.type, |
| 244 | 246 | label: s.label, |
| 245 | 247 | 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), | |
| 247 | 250 | default: s.default, |
| 248 | 251 | set: kept.has(s.key), |
| 249 | 252 | min: s.min ?? null, |
| @@ -432,9 +435,15 @@ export async function changeFeatureSettings( | ||
| 432 | 435 | if (!checked.ok) return refuse("invalid_setting", `${spec.label} ${checked.error}.`, key); |
| 433 | 436 | changes.set(key, checked.value); |
| 434 | 437 | } |
| 435 | const before = featureSettings(app.db, found.record.id); | |
| 438 | const before = openSettings(app.db, app.config.dataDir, found.record.id, declared.value); | |
| 436 | 439 | 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); | |
| 438 | 447 | if (changed.length > 0) { |
| 439 | 448 | app.bus.append({ |
| 440 | 449 | kind: "app.feature_settings", |
src/apps/feature.ts +6 −2
| @@ -25,9 +25,9 @@ | ||
| 25 | 25 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 26 | 26 | import { parseTools, type ToolSpec } from "./feature_tools.ts"; |
| 27 | 27 | |
| 28 | export type SettingType = "bool" | "int" | "string" | "choice" | "days" | "list"; | |
| 28 | export type SettingType = "bool" | "int" | "string" | "choice" | "days" | "list" | "secret"; | |
| 29 | 29 | |
| 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"]; | |
| 31 | 31 | |
| 32 | 32 | /** The labels the forge's own menus use: a feature may not borrow one. */ |
| 33 | 33 | const BUILT_IN_MENU_LABELS = [ |
| @@ -160,6 +160,8 @@ function parseSetting(key: string, raw: unknown): Result<SettingSpec, string> { | ||
| 160 | 160 | return bad(`setting ${key}: only a choice has choices`); |
| 161 | 161 | } |
| 162 | 162 | 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`); | |
| 163 | 165 | const fallback = raw["default"] ?? emptyDefault(spec.type, choices); |
| 164 | 166 | const checked = checkSetting(spec, fallback); |
| 165 | 167 | if (!checked.ok) return bad(`setting ${key}: default ${checked.error}`); |
| @@ -178,6 +180,7 @@ function emptyDefault(type: SettingType, choices: readonly string[] | undefined) | ||
| 178 | 180 | case "list": |
| 179 | 181 | return []; |
| 180 | 182 | case "string": |
| 183 | case "secret": | |
| 181 | 184 | return ""; |
| 182 | 185 | } |
| 183 | 186 | } |
| @@ -203,6 +206,7 @@ export function checkSetting(spec: Pick<SettingSpec, "type" | "min" | "max" | "c | ||
| 203 | 206 | if (spec.max !== undefined && n > spec.max) return err(`is at most ${spec.max}`); |
| 204 | 207 | return ok(String(n)); |
| 205 | 208 | } |
| 209 | case "secret": | |
| 206 | 210 | case "string": { |
| 207 | 211 | if (typeof value !== "string") return err("is text"); |
| 208 | 212 | if (value.length > STRING_MAX) return err(`is at most ${STRING_MAX} characters`); |
src/apps/feature_env.ts +28 −2
| @@ -7,11 +7,37 @@ | ||
| 7 | 7 | */ |
| 8 | 8 | |
| 9 | 9 | import type { Database } from "bun:sqlite"; |
| 10 | import { featureEnv, parseFeature } from "./feature.ts"; | |
| 10 | import { featureEnv, parseFeature, type FeatureSpec } from "./feature.ts"; | |
| 11 | 11 | 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 | } | |
| 12 | 37 | |
| 13 | 38 | export function featureVarsAt( |
| 14 | 39 | db: Database, |
| 40 | dataDir: string, | |
| 15 | 41 | appId: string, |
| 16 | 42 | forgeJson: string | undefined, |
| 17 | 43 | ): Record<string, string> { |
| @@ -27,7 +53,7 @@ export function featureVarsAt( | ||
| 27 | 53 | if (!spec.ok || spec.value === undefined) return {}; |
| 28 | 54 | // The menu keeps the name and page the admin saw at absorb; a changed |
| 29 | 55 | // 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)); | |
| 31 | 57 | } |
| 32 | 58 | |
| 33 | 59 | /** 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 { | ||
| 1695 | 1695 | } catch { |
| 1696 | 1696 | forgeJson = undefined; |
| 1697 | 1697 | } |
| 1698 | return featureVarsAt(this.app.db, appId, forgeJson); | |
| 1698 | return featureVarsAt(this.app.db, this.app.config.dataDir, appId, forgeJson); | |
| 1699 | 1699 | } |
| 1700 | 1700 | |
| 1701 | 1701 | /** |
src/core/features.ts +8
| @@ -67,3 +67,11 @@ export function setFeatureSettings(db: Database, appId: string, values: Readonly | ||
| 67 | 67 | for (const [key, value] of values) put.run(appId, key, value, by); |
| 68 | 68 | })(); |
| 69 | 69 | } |
| 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 { | ||
| 40 | 40 | } |
| 41 | 41 | |
| 42 | 42 | 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"}` }; | |
| 44 | 44 | } |
| 45 | 45 | |
| 46 | 46 | function settingsScreen(f: Session, ctx: Ctx, slug: string): Screen { |
| @@ -71,9 +71,15 @@ function settingsScreen(f: Session, ctx: Ctx, slug: string): Screen { | ||
| 71 | 71 | promptScreen(ctx, screen, { |
| 72 | 72 | title: setting.label, |
| 73 | 73 | 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", | |
| 75 | 76 | hint: setting.help ?? `${setting.env}; default ${setting.default === "" ? "empty" : setting.default}`, |
| 76 | 77 | 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 | } | |
| 77 | 83 | const saved = await changeFeatureSettings(f.app, f.user, slug, { [setting.key]: text }); |
| 78 | 84 | if (!saved.ok) { |
| 79 | 85 | ctx.toast(saved.error.message, "attn"); |
src/web/pages/features.ts +7 −2
| @@ -144,6 +144,9 @@ function input(s: SettingView, invalid = false) { | ||
| 144 | 144 | } |
| 145 | 145 | case "list": |
| 146 | 146 | 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} />`; | |
| 147 | 150 | default: |
| 148 | 151 | return html`<input class="input" name="${name}" value="${s.value}" aria-label="${s.label}" ${bad} />`; |
| 149 | 152 | } |
| @@ -185,8 +188,9 @@ function featureView(ctx: Ctx, f: FeatureView, refused?: Refused, items: readonl | ||
| 185 | 188 | const s = refused !== undefined && shown.key in refused.typed ? { ...shown, value: typedValue(shown, refused.typed[shown.key]) } : shown; |
| 186 | 189 | return row({ |
| 187 | 190 | 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"}`}`, | |
| 190 | 194 | trail: input(s, bad), |
| 191 | 195 | }); |
| 192 | 196 | }); |
| @@ -232,6 +236,7 @@ export function settingsFromForm(form: Form, settings: readonly SettingView[]): | ||
| 232 | 236 | const name = `s.${s.key}`; |
| 233 | 237 | const all = form.getAll(name).map(String); |
| 234 | 238 | if (all.length === 0) continue; |
| 239 | if (s.type === "secret" && (all[all.length - 1] ?? "") === "") continue; // blank keeps what is kept | |
| 235 | 240 | if (s.type === "days") out[s.key] = all.filter((d) => d !== ""); |
| 236 | 241 | else if (s.type === "bool") out[s.key] = all.includes("on") ? "on" : "off"; |
| 237 | 242 | else out[s.key] = all[all.length - 1] ?? ""; |
test/apps-features.test.ts +2 −2
| @@ -175,9 +175,9 @@ describe("absorbing a running app", () => { | ||
| 175 | 175 | }, 60_000); |
| 176 | 176 | |
| 177 | 177 | 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")); | |
| 179 | 179 | 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")); | |
| 181 | 181 | // A production push does not rename it either: an admin accepts. |
| 182 | 182 | expect(getAbsorbed(forge.app.db, record.id)?.name).toBe("Radar"); |
| 183 | 183 | }); |
test/features-tui.test.ts +25 −1
| @@ -24,7 +24,7 @@ quietLogs(); | ||
| 24 | 24 | |
| 25 | 25 | const FORGE_JSON = JSON.stringify({ |
| 26 | 26 | 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" } } }, | |
| 28 | 28 | }); |
| 29 | 29 | |
| 30 | 30 | describe("features in the terminal", () => { |
| @@ -116,6 +116,30 @@ describe("features in the terminal", () => { | ||
| 116 | 116 | t.stop(); |
| 117 | 117 | }); |
| 118 | 118 | |
| 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 | ||
| 119 | 143 | test("a reader opens an absorbed app's feed items from the palette", async () => { |
| 120 | 144 | const absorbed = await absorbApp(forge, ada, "radar"); |
| 121 | 145 | if (!absorbed.ok) throw new Error(absorbed.error); |
test/features.test.ts +39 −7
| @@ -84,7 +84,7 @@ describe("the declaration", () => { | ||
| 84 | 84 | expect(wrong({ name: "R", page: "//evil.example" })).toContain("page"); |
| 85 | 85 | expect(wrong({ name: "R", extra: 1 })).toContain("unknown field extra"); |
| 86 | 86 | 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"); | |
| 88 | 88 | expect(wrong({ name: "R", settings: { h: { type: "int", min: 5, max: 1 } } })).toContain("min is above max"); |
| 89 | 89 | expect(wrong({ name: "R", settings: { h: { type: "int", default: 30, max: 23 } } })).toContain("default is at most 23"); |
| 90 | 90 | expect(wrong({ name: "R", settings: { c: { type: "choice", choices: ["only"] } } })).toContain("choices"); |
| @@ -226,7 +226,7 @@ describe("absorbing an app", () => { | ||
| 226 | 226 | test("declaring is not absorbing: nothing is in the nav and the app is told nothing", async () => { |
| 227 | 227 | expect(getAbsorbed(app.db, appId)).toBeUndefined(); |
| 228 | 228 | 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({}); | |
| 230 | 230 | const seen = await getFeature(app, cy, "radar"); |
| 231 | 231 | expect(seen.ok && seen.value.absorbed).toBe(false); |
| 232 | 232 | }); |
| @@ -311,7 +311,7 @@ describe("absorbing an app", () => { | ||
| 311 | 311 | |
| 312 | 312 | test("a production push that renames the feature leaves the menu unchanged until an admin accepts", async () => { |
| 313 | 313 | 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); | |
| 315 | 315 | expect(absorbedForNav(app, ada).map((f) => [f.name, f.page])).toEqual([["Radar", "/"]]); |
| 316 | 316 | const c = await commitToBranch(app, cy, repo, { message: "rename", files: [{ path: "forge.json", content: renamed }] }); |
| 317 | 317 | if (!c.ok) throw new Error(JSON.stringify(c.error)); |
| @@ -533,7 +533,7 @@ describe("absorbing an app", () => { | ||
| 533 | 533 | test("good values are kept and reach the app as FORGE_SETTING_*", async () => { |
| 534 | 534 | const res = await api("PATCH", adaToken, "/radar/settings", { settings: { runDays: ["wed", "mon", "fri"], runHourUtc: 9 } }); |
| 535 | 535 | 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({ | |
| 537 | 537 | FORGE_FEATURE: "1", |
| 538 | 538 | FORGE_SETTING_RUN_DAYS: "mon,wed,fri", |
| 539 | 539 | FORGE_SETTING_RUN_HOUR_UTC: "9", |
| @@ -552,7 +552,7 @@ describe("absorbing an app", () => { | ||
| 552 | 552 | ]); |
| 553 | 553 | const saved = await web(adaCookie, "/settings/features/radar", form); |
| 554 | 554 | 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({ | |
| 556 | 556 | FORGE_SETTING_RUN_DAYS: "tue", |
| 557 | 557 | FORGE_SETTING_CLAUDE_CODE: "stable", |
| 558 | 558 | FORGE_SETTING_SCOUT: "false", |
| @@ -564,13 +564,13 @@ describe("absorbing an app", () => { | ||
| 564 | 564 | test("un-absorbing puts it back as an ordinary app and keeps its values for the next absorb", async () => { |
| 565 | 565 | const out = await unabsorbApp(app, ada, "radar"); |
| 566 | 566 | 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({}); | |
| 568 | 568 | expect(absorbedForNav(app, ada)).toEqual([]); |
| 569 | 569 | expect((await api("DELETE", adaToken, "/radar")).status).toBe(409); |
| 570 | 570 | const changeWhileOut = await changeFeatureSettings(app, ada, "radar", { runHourUtc: 1 }); |
| 571 | 571 | expect(!changeWhileOut.ok && changeWhileOut.error.error).toBe("not_absorbed"); |
| 572 | 572 | 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" }); | |
| 574 | 574 | }); |
| 575 | 575 | |
| 576 | 576 | test("the MCP tool plans first, and applies only with an admin-scope token", async () => { |
| @@ -676,6 +676,38 @@ describe("absorbing an app", () => { | ||
| 676 | 676 | expect(after.ok && after.value?.name).toBe("Radar 2"); |
| 677 | 677 | }); |
| 678 | 678 | |
| 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 | ||
| 679 | 711 | test("with 20 more absorbed apps the menu is built from one pass and renders within budget", async () => { |
| 680 | 712 | for (let i = 0; i < 20; i++) { |
| 681 | 713 | const r = await createRepo(app, cy, { name: `many-${i}`, visibility: "public", description: "" }); |
State that a secret reaches previews of the app too (code in a pull can read it), so an admin knows before keeping one.