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

One place for a secret: an app uses a sealed secret by name, and rotating it… #12184

Merged opened this in answer to

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

Merged

mason/forge:mason/12180-one-place-for-a-secret-an-app-uses-a-sea → main 14 commits fork 1 thread · 1 unresolved

  • Merged. mason/12180-one-place-for-a-secret-an-app-uses-a-sea was merged into main as 7cfd21c.

Review

Sign in to review this change.
55 files changed +1576 −137
docs/APPS.md +18 −5
@@ -921,10 +921,12 @@ Environment** (or `PUT /api/v1/repos/:owner/:name/app/env/:name` with a
921921 `{ "value" }` body). A name is `ADMIN_PASSWORD`-shaped: capital letters,
922922 digits and underscores; the names the forge sets itself (`PORT`, `HOST`,
923923 `PATH`, `FORGE_*` and their kin) are refused, and the forge's own values
924 win regardless. Values are write-only: set one, and it is never shown
925 again — the page, the API and the event outbox carry the name and when it
926 was set, nothing more. At rest each value is sealed with the data
927 directory's key (`kernel/secret.ts`), bound to the app and the name.
924 win regardless. A setting is one of two **kinds**. A *variable* is a plain setting (a URL,
925 a switch): kept and listed in the clear, safe in a log. A *secret* is
926 sealed with the data directory's key (`kernel/secret.ts`), bound to the app
927 and the name, part of the sealing-key rotation, and write-only: the page,
928 the API and the event outbox carry its name and when it was set, nothing
929 more. Left unchosen, a setting is a secret; say `kind: variable` to keep one in the clear. Setting a name again replaces it, kind included.
928930 An app that prints one does not show it either: what an app writes to its
929931 output is searched for the values it was handed (its variables, its identity
930932 key and its forge token, each plain, base64 and URL-encoded) before the line
@@ -950,6 +952,10 @@ where two apply the narrower one wins: a test key for
950952 previews, the live one for production, and neither process ever holds the
951953 other's. Every preview is one scope, whichever pull it serves.
952954
955 ### One place for a secret
956
957 A secret lives where it matches who may use it, and a forge admin grants a wider one to an app by name: see [features/app-secrets.md](features/app-secrets.md).
958
953959 ### Forge secrets
954960
955961 A credential the forge itself needs (a DNS token to spawn forges, a bucket
@@ -973,7 +979,14 @@ with write access, declares what a run may hold in front-matter at the top:
973979 secrets: [email protected], TF_BUCKET_KEY
974980 ---
975981
976 Only declared names are resolved (`crew/grant.ts`); malformed names and names
982 Only declared names are resolved (`crew/grant.ts`), and from the repository's
983 own secrets or from a name the forge's owner granted to that repository
984 (`secret_grant`, the grant an app's start reads). A declared name that is set
985 at the organization or the forge and not granted is never opened: the run's
986 log and one comment on the issue say "this run asked for X; the forge's owner
987 has not granted X to owner/repo", with a link to grant it, whether X is set
988 higher up or nowhere (a declaration learns nothing about which names exist);
989 the run goes on without it. Malformed names and names
977990 that would shadow the run's environment (`FORGE_*`, `GIT_*`, `PATH`, …) are
978991 refused in the run's log. Values go into the run's env beside `FORGE_TOKEN`,
979992 and `@host` widens the shell sandbox's egress for that run only. From the
docs/MCP.md +1 −1
@@ -53,7 +53,7 @@ bytes in `tools/list`. After changing a tool run `bun scripts/mcp-table.ts`;
5353 | `app_data_import` | — | Load a SQL dump into an app's database and report per-table counts and checksums; admins only | 339 |
5454 | `app_deploy` | `redeploy`, `stop`, `pull`, `schedules`, `run_schedule`, `pause_schedule`, `resume_schedule`, `jobs`, `cancel_job`, `retry_job` | Redeploy or stop an env, pull, list and steer schedules and jobs | 408 |
5555 | `app_domains` | — | App domains: list, add, check, keep, remove; dns_proof, dns_cut, dns_revert | 266 |
56 | `app_env` | `list`, `set`, `unset` | An app's environment variables: list names, set or unset by scope; write-only | 393 |
56 | `app_env` | `list`, `set`, `unset`, `variable`, `secret` | An app's environment variables: list, set or unset by scope; a secret is write-only, a variable is shown | 474 |
5757 | `app_members` | — | People a private app names, who may enter it and its previews | 297 |
5858 | `app_notes` | `hide`, `ban`, `unban` | Notes from an app's forge button and banned senders; hide a note, ban or unban its sender | 306 |
5959 | `apps` | — | Apps you can see: live or not, where served, how each runs, what works | 384 |
docs/design/areas/secrets.md +2
@@ -6,3 +6,5 @@ a `<section class="settings-block">` with an `<h2>`, one muted sentence (sealed
66 was set, when it was last opened, Remove), and the set form as an `.edit-row`. No card around it, and no icon in a
77 row's lead. Values are write-only: the value field is a password input that is never filled. `test/secrets-card.test.ts`
88 renders all three places and finds the same class names. Classes used are named in `docs/design/areas/settings.md`.
9
10 Each row also says which apps use the secret and whether each started with its current value (`usedBy()` in `secrets.ts`, plain text in the row's meta; no new class). The app's settings carry a `settings-block` of `row()`s, "Secrets this app uses" (`web/pages/app_secrets.ts`), likewise with existing classes only.
docs/features/app-secrets.md added +24
@@ -0,0 +1,24 @@
1 # One place for a secret (`Settings → App → Secrets this app uses`)
2
3 Put a secret where it matches who may use it: on the new-app page or the
4 app's Environment (this app), an organization's page, or **Settings →
5 Secrets** (the whole forge). An app lists the names it needs in `forge.json`
6 (`"env": ["SIMPLEFIN_TOKEN"]`); at start each arrives from the nearest place
7 that has it — the app, then the organization, then the forge — the last two
8 only after a forge admin grants that name to that repository (**Settings →
9 App → Secrets this app uses**, one click, revocable; the grant is the
10 repository's, so a repository with no app holds one too and its crew's runs
11 read through the same row; the table says
12 needs / has / missing). A guest's preview nobody approved receives none of
13 it. Setting a wider secret again restarts every app it was granted to, and
14 its row on the Secrets page lists them and whether each started with the
15 current value. A secret typed on the new-app page is the app's own at once
16 and the first issue names it, never its value. Every open is a `secret.read`
17 event (the app and environment, the name, never the value).
18
19
20 ## In the terminal
21
22 From a repository that is an app, the palette offers **Variables** (`src/tui/screens/appenv.ts`: `a` sets one, choosing its scope and whether it is a secret or a variable; the list shows each kind and a variable's value) and **Secrets this app uses** (`src/tui/screens/appsecrets.ts`: needs, has, missing; `a` grants, `x` revokes, a forge admin only).
23
24 Code: `src/core/app_secrets.ts`, `src/actions/secrets.ts` (`grantToRepo`, `revokeFromRepo`), `src/web/pages/app_secrets.ts`. Tests: `test/app-secrets.test.ts`, `test/app-secrets-web.test.ts`, `test/guest-pull.test.ts`.
src/actions/app_env_words.ts +8
@@ -30,6 +30,7 @@ export const APP_ENV_ERRORS: Record<string, string> = {
3030 "That name is the forge's own (PORT, HOST, PATH, FORGE_…); the app is already told it.",
3131 invalid_value: "A value is one line or many, never empty and under 64 KiB.",
3232 invalid_scope: "A variable goes everywhere, to production, or to previews.",
33 invalid_kind: "A setting is a variable (plain, shown) or a secret (sealed, write-only).",
3334 };
3435
3536 /** `setAppEnvVar` refused with a code the table does not name. */
@@ -37,3 +38,10 @@ export const APP_ENV_NOT_SET = "That variable could not be set.";
3738
3839 /** `deleteAppEnvVar` refused as `no_such_var`. */
3940 export const APP_ENV_NO_SUCH = "No variable by that name.";
41
42 /** Why `grantToRepo` or `revokeFromRepo` refused, by code; page and terminal say it alike. */
43 export const GRANT_ERRORS: Record<string, string> = {
44 not_found: "Only a forge admin can change a grant, and the repository must exist.",
45 invalid_scope: "A grant draws from the forge or from the repository's organization.",
46 no_such_secret: "No such secret is set there, or no such grant is held.",
47 };
src/actions/apps.ts +40 −10
@@ -49,6 +49,7 @@ import {
4949 import {
5050 DEFAULT_ENV_SCOPE,
5151 deleteAppEnv,
52 isReservedAppEnvName,
5253 isAppEnvScope,
5354 listAppEnvNames,
5455 setAppEnv,
@@ -56,6 +57,7 @@ import {
5657 type AppEnvName,
5758 type AppEnvScope,
5859 } from "../core/app_env.ts";
60 import { isSecretName, VALUE_MAX } from "../core/secret_store.ts";
5961 import { addCrew, getAgentByName } from "../core/agents.ts";
6062 import { listAllAgents } from "./crew.ts";
6163 import { plan, planIssueBody, planningAvailable } from "../crew/plan.ts";
@@ -170,30 +172,32 @@ export async function setAppEnvVar(
170172 name: string,
171173 value: string,
172174 scope: string = DEFAULT_ENV_SCOPE,
175 /**
176 * `variable` or `secret`; left out, a secret.
177 */
178 kind?: string,
173179 ): Promise<Result<AppEnvName, "not_found" | AppEnvError>> {
174180 const admin = requireRepoAdmin(app, actor, repo);
175181 if (!admin.ok) return admin;
176182 const record = getAppByRepo(app.db, repo.id);
177183 if (!record) return err("not_found");
178 const set = setAppEnv(
179 app.db,
180 app.config.dataDir,
181 record.id,
182 name,
183 value,
184 scope,
185 );
184 const set = setAppEnv(app.db, app.config.dataDir, record.id, name, value, scope, kindFor(kind));
186185 if (!set.ok) return set;
187186 app.bus.append({
188187 kind: "app.env_set",
189188 repoId: repo.id,
190189 actorId: admin.value.id,
191 payload: { name, scope: set.value.scope },
190 payload: { name, scope: set.value.scope, kind: set.value.kind },
192191 });
193192 await redeployScope(app, record, repo, set.value.scope);
194193 return set;
195194 }
196195
196 /** The kind a person chose; left out, a secret: a plain variable is something one asks for. */
197 function kindFor(chosen: string | undefined): string {
198 return chosen === undefined || chosen === "" || chosen === "auto" ? "secret" : chosen;
199 }
200
197201 /** Remove a variable from a scope and redeploy the environments that read it. */
198202 export async function deleteAppEnvVar(
199203 app: App,
@@ -277,6 +281,11 @@ export interface NewAppInput {
277281 pickAgent?: boolean;
278282 /** `owner/name` of a repository the actor can read: its tip is copied instead of the starter app. */
279283 template?: string;
284 /**
285 * Secrets typed while making the app: sealed at the app's own scope, so the first start has
286 * them.
287 */
288 secrets?: readonly { readonly name: string; readonly value: string }[];
280289 }
281290
282291 export interface NewAppResult {
@@ -295,7 +304,8 @@ export type NewAppError =
295304 | TemplateError
296305 | "no_such_agent"
297306 | "not_a_builder"
298 | "app_limit";
307 | "app_limit"
308 | "invalid_secret";
299309
300310 /**
301311 * The golden path's first step, as one call: a repository with a working
@@ -320,6 +330,12 @@ export async function newApp(
320330 if (input.agent && !agent) return err("no_such_agent");
321331 if (agent && !agent.roles.includes("builder")) return err("not_a_builder");
322332
333 // A secret that cannot be sealed is refused before anything exists to take back.
334 const secrets = (input.secrets ?? []).filter((x) => x.name !== "" || x.value !== "");
335 for (const x of secrets)
336 if (!isSecretName(x.name) || isReservedAppEnvName(x.name) || x.value === "" || x.value.length > VALUE_MAX || x.value.includes("\0"))
337 return err("invalid_secret");
338
323339 // Refuse at the limit before a repository exists to take back.
324340 // The owner is resolved with the actor's rights, as createRepo does, so
325341 // the count of an account they may not create under is never told.
@@ -384,6 +400,16 @@ export async function newApp(
384400 return err(record.error === "not_found" ? "scaffold_failed" : record.error);
385401 }
386402
403 // Stored at the app's own scope: the first start has them, no second step.
404 for (const x of secrets) setAppEnv(app.db, app.config.dataDir, record.value.id, x.name, x.value, "all", "secret");
405 if (secrets.length > 0)
406 app.bus.append({
407 kind: "secret.set",
408 repoId: repo.value.id,
409 actorId: user.value.id,
410 payload: { names: secrets.map((x) => x.name), scope: "all" },
411 });
412
387413 const description = (input.description ?? "").trim();
388414 let chosen = agent;
389415 let body = description;
@@ -436,6 +462,8 @@ export async function newApp(
436462 }
437463 // The sentence is the one thing the person wrote: it opens the first
438464 // issue whether or not anyone was picked for it, unassigned when not.
465 if (body !== "" && secrets.length > 0)
466 body += `\n\nSecrets already set for this app (names only; read each from \`process.env\` and list it in forge.json \`env\`): ${secrets.map((x) => x.name).join(", ")}.`;
439467 if (body !== "") {
440468 const opened = openIssue(app, user.value, repo.value, {
441469 title: `Build ${input.name}`,
@@ -479,6 +507,8 @@ export const NEW_APP_ERRORS: Record<string, string> = {
479507 git_init_failed: REPO_INIT_FAILED,
480508 invalid_slug:
481509 "That name makes no usable hostname — start it with a letter or digit.",
510 invalid_secret:
511 "A secret needs a name (capital letters, digits and underscores, like SIMPLEFIN_TOKEN) and a value under 64 KiB.",
482512 slug_taken:
483513 "Another app already answers to that hostname. Pick another name.",
484514 already_app: "That repository is already an app.",
src/actions/secrets.ts +113 −1
@@ -8,8 +8,16 @@
88 */
99
1010 import type { App } from "../app.ts";
11 import { createLog } from "../kernel/log.ts";
1112 import { getOrg } from "../core/orgs.ts";
12 import { getRepo } from "../core/repos.ts";
13 import { getRepo, getRepoById } from "../core/repos.ts";
14 import { appsRuntime } from "../apps/runtime.ts";
15 import { getAppById, getAppByRepo } from "../core/apps.ts";
16 import { readerFor } from "../git/read.ts";
17 import { repoDir } from "../git/repos.ts";
18 import type { Repo } from "../core/repos.ts";
19 import { requireRepoAdmin } from "./access.ts";
20 import { neededNames, needRows, type NeedRow, appsUsing, grantable, grantSecret, revokeGrant, usesOf, type SecretUse } from "../core/app_secrets.ts";
1321 import {
1422 deleteSecret,
1523 isSecretScope,
@@ -51,6 +59,8 @@ function admin(actor: User | undefined): Result<User, "not_found"> {
5159 return actor?.isAdmin ? ok(actor) : err("not_found");
5260 }
5361
62 const log = createLog("secrets");
63
5464 export function listSecrets(
5565 app: App,
5666 actor: User | undefined,
@@ -91,9 +101,90 @@ export function putSecret(
91101 actorId: who.value.id,
92102 payload: { name, scope: target.value.scope },
93103 });
104 restartUsers(app, target.value.scope, target.value.scopeId, name);
94105 return set;
95106 }
96107
108 /**
109 * Setting a secret again is one change in one place; a process reads its
110 * variables once at start, so each app it reaches starts again (previews
111 * and production alike). Off the request: the person asked to store a value.
112 */
113 export function restartUsers(app: App, scope: SecretScope, scopeId: string, name: string): void {
114 for (const appId of appsUsing(app.db, scope, scopeId, name)) restartApp(app, appId);
115 }
116
117 function restartApp(app: App, appId: string): void {
118 const record = getAppById(app.db, appId);
119 const repo = record && getRepoById(app.db, record.repoId);
120 if (!record || !repo) return;
121 void appsRuntime(app)
122 ?.reconcileApp(record, repo, "redeploy", undefined)
123 .catch((error: unknown) => log.warn("app restart after a secret change failed", { app: appId, error: String(error) }));
124 }
125
126 /** Who a secret reaches and whether each started with its current value. */
127 export function secretUses(app: App, scope: SecretScope, scopeId: string, name: string): SecretUse[] {
128 return usesOf(app.db, scope, scopeId, name);
129 }
130
131 /**
132 * Grant a forge- or org-scope secret to one repository by name — its app and its crew's runs
133 * both read through it: a forge admin's one click.
134 */
135 export function grantToRepo(
136 app: App,
137 actor: User | undefined,
138 scope: string,
139 ref: string,
140 name: string,
141 repoId: string,
142 ): Result<void, "not_found" | "invalid_scope" | "no_such_secret"> {
143 const who = admin(actor);
144 if (!who.ok) return who;
145 if (scope !== "forge" && scope !== "org") return err("invalid_scope");
146 const target = resolveTarget(app, scope, ref);
147 if (!target.ok) return target;
148 const repo = getRepoById(app.db, repoId);
149 if (!repo) return err("not_found");
150 if (!grantSecret(app.db, repo.id, scope, target.value.scopeId, name, who.value.id)) return err("no_such_secret");
151 app.bus.append({
152 kind: "secret.granted",
153 repoId: repo.id,
154 actorId: who.value.id,
155 payload: { name, scope, repo: `${repo.ownerName}/${repo.name}` },
156 });
157 restartRepoApp(app, repo.id);
158 return ok();
159 }
160
161 /** Take a grant back; an app stops receiving the name at its next start, which this brings now. */
162 export function revokeFromRepo(
163 app: App,
164 actor: User | undefined,
165 name: string,
166 repoId: string,
167 ): Result<void, "not_found" | "no_such_secret"> {
168 const who = admin(actor);
169 if (!who.ok) return who;
170 const repo = getRepoById(app.db, repoId);
171 if (!repo) return err("not_found");
172 if (!revokeGrant(app.db, repo.id, name)) return err("no_such_secret");
173 app.bus.append({
174 kind: "secret.revoked",
175 repoId: repo.id,
176 actorId: who.value.id,
177 payload: { name, repo: `${repo.ownerName}/${repo.name}` },
178 });
179 restartRepoApp(app, repo.id);
180 return ok();
181 }
182
183 function restartRepoApp(app: App, repoId: string): void {
184 const record = getAppByRepo(app.db, repoId);
185 if (record) restartApp(app, record.id);
186 }
187
97188 export function removeSecret(
98189 app: App,
99190 actor: User | undefined,
@@ -115,3 +206,24 @@ export function removeSecret(
115206 });
116207 return ok();
117208 }
209
210 /**
211 * What an app asks for in `forge.json` `env`, against what is set: needs / has / missing, never
212 * a value.
213 */
214 export async function appSecretNeeds(
215 app: App,
216 actor: User | undefined,
217 repo: Repo,
218 ): Promise<Result<{ needs: NeedRow[]; grantable: { name: string; scope: "forge" | "org" }[] }, "not_found">> {
219 const gate = requireRepoAdmin(app, actor, repo);
220 if (!gate.ok) return gate;
221 const record = getAppByRepo(app.db, repo.id);
222 const file = await readerFor(repoDir(app.reposRoot, repo.ownerName, repo.name)).object("HEAD:forge.json");
223 const text = file.ok && file.value.type === "blob" ? new TextDecoder().decode(file.value.body.slice(0, 65_536)) : undefined;
224 const needs = needRows(app.db, { appId: record?.id ?? "", repoId: repo.id, ownerId: repo.ownerId, needs: neededNames(text) });
225 const asked = new Set(needs.map((n) => n.name));
226 // Names only, and only to a forge admin: what the crew or the app could be granted by name.
227 const offer = actor?.isAdmin === true ? grantable(app.db, repo.id, repo.ownerId).filter((g) => !asked.has(g.name)) : [];
228 return ok({ needs, grantable: offer });
229 }
src/apps/manifest.ts +9
@@ -10,6 +10,7 @@
1010 * simply not an app yet, which the surfaces say in those words.
1111 */
1212
13 import { parseNeeds } from "../core/app_secrets.ts";
1314 import { isIP } from "node:net";
1415 import { err, ok, type Result } from "../kernel/result.ts";
1516 import { parseService, type ServiceSpec } from "./service.ts";
@@ -79,6 +80,11 @@ export interface AppManifest {
7980 * out of the app's HTML pages. Unset is on.
8081 */
8182 readonly toolbar?: boolean | undefined;
83 /**
84 * `env` (top level of `forge.json`): the secret names the app reads from its environment
85 * (`core/app_secrets.ts`).
86 */
87 readonly env?: readonly string[] | undefined;
8288 /**
8389 * `adapter` (top level of `forge.json`): the app is pulled from on a
8490 * schedule (`apps/adapter.ts`). Unset is not an adapter.
@@ -186,6 +192,8 @@ export function parseManifest(
186192 if (!egress.ok) return egress;
187193 const limits = limitsField(app, "app");
188194 if (!limits.ok) return limits;
195 const needs = parseNeeds(parsed.value["env"]);
196 if (!needs.ok) return invalid(needs.error);
189197 const toolbar = parsed.value["toolbar"];
190198 if (toolbar !== undefined && typeof toolbar !== "boolean")
191199 return invalid("forge.json: toolbar must be true or false");
@@ -235,6 +243,7 @@ export function parseManifest(
235243 egress: egress.value,
236244 limits: limits.value,
237245 toolbar: toolbar ?? true,
246 ...(needs.value.length === 0 ? {} : { env: needs.value }),
238247 ...(adapter.value === undefined ? {} : { adapter: adapter.value }),
239248 ...(toolchain.value === undefined ? {} : { toolchain: toolchain.value }),
240249 ...(schedules.value.length === 0 ? {} : { schedules: schedules.value }),
src/apps/runtime.ts +24 −1
@@ -30,6 +30,7 @@ import { join } from "node:path";
3030 import type { App } from "../app.ts";
3131 import { getAppByRepo, listApps, type AppRecord } from "../core/apps.ts";
3232 import { resolveAppEnv } from "../core/app_env.ts";
33 import { neededNames, resolveAppSecrets } from "../core/app_secrets.ts";
3334 import { featureVarsAt } from "./feature_env.ts";
3435 import { getVariant, listVariants, variantOfBranch } from "../core/variants.ts";
3536 import { readVariantValues } from "./variant_file.ts";
@@ -1878,6 +1879,27 @@ export class AppsRuntime {
18781879 return featureVarsAt(this.app.db, this.app.config.dataDir, appId, forgeJson);
18791880 }
18801881
1882 /**
1883 * The app's sealed secrets for a start: its own, and each granted name its forge.json asks
1884 * for.
1885 */
1886 sealedFor(repo: Repo, appId: string, env: EnvName, dir?: string, start = true): Record<string, string> {
1887 let forgeJson: string | undefined;
1888 try {
1889 forgeJson = dir === undefined ? undefined : readFileSync(join(dir, "forge.json"), "utf8");
1890 } catch {
1891 forgeJson = undefined;
1892 }
1893 return resolveAppSecrets(this.app.db, this.app.config.dataDir, this.app.bus, {
1894 appId,
1895 repoId: repo.id,
1896 ownerId: repo.ownerId,
1897 env,
1898 needs: neededNames(forgeJson),
1899 start,
1900 });
1901 }
1902
18811903 /**
18821904 * What a process for `env` starts holding: the operator's variables, read
18831905 * at start, and the forge token. A preview of a guest pull nobody has
@@ -1891,6 +1913,7 @@ export class AppsRuntime {
18911913 env: EnvName,
18921914 forgeToken: string | undefined,
18931915 dir?: string,
1916 start = true,
18941917 ): { vars: Record<string, string>; forgeToken: string | undefined } {
18951918 const number = envPull(env);
18961919 const pull =
@@ -1901,7 +1924,7 @@ export class AppsRuntime {
19011924 // app's defaults; they are plain settings, never secrets.
19021925 const own = dir !== undefined && envVariant(env) !== undefined ? readVariantValues(dir) : {};
19031926 return {
1904 vars: { ...resolveAppEnv(this.app.db, this.app.config.dataDir, appId, env), ...own },
1927 vars: { ...this.sealedFor(repo, appId, env, dir, start), ...resolveAppEnv(this.app.db, this.app.config.dataDir, appId, env), ...own },
19051928 forgeToken,
19061929 };
19071930 }
src/apps/tty.ts +1 −1
@@ -179,7 +179,7 @@ export async function runAppTty(
179179 // same secrets, so it is handed the same environment, by the one reader
180180 // the app's own process has. A guest pull's preview nobody has approved
181181 // holds none there, so its terminal (a command the pull names) holds none.
182 vars: runtime.secretsFor(repo, target.app.id, target.env, undefined, live.dir).vars,
182 vars: runtime.secretsFor(repo, target.app.id, target.env, undefined, live.dir, false).vars,
183183 // An absorbed app's settings, as its process was started with.
184184 feature: runtime.featureFor(repo, target.app.id, target.env, live.dir),
185185 });
src/core/app_env.ts +44 −8
@@ -41,10 +41,24 @@ export function scopeOfEnv(env: string): Exclude<AppEnvScope, "all"> {
4141 return env === "production" ? "production" : "preview";
4242 }
4343
44 /**
45 * A variable is a plain setting, kept and shown in the clear; a secret is sealed and
46 * write-only.
47 */
48 export type AppEnvKind = "variable" | "secret";
49 export const APP_ENV_KINDS: readonly AppEnvKind[] = ["variable", "secret"];
50
51 export function isAppEnvKind(value: string): value is AppEnvKind {
52 return (APP_ENV_KINDS as readonly string[]).includes(value);
53 }
54
4455 export interface AppEnvName {
4556 readonly name: string;
4657 readonly scope: AppEnvScope;
4758 readonly updatedAt: string;
59 readonly kind: AppEnvKind;
60 /** A variable's value; a secret has none to show. */
61 readonly value?: string;
4862 }
4963
5064 /** `ADMIN_PASSWORD`, `STRIPE_SECRET_KEY`: the shell's own rule for a name. */
@@ -73,7 +87,8 @@ export type AppEnvError =
7387 | "invalid_name"
7488 | "reserved_name"
7589 | "invalid_value"
76 | "invalid_scope";
90 | "invalid_scope"
91 | "invalid_kind";
7792
7893 export function isAppEnvName(name: string): boolean {
7994 return name.length > 0 && name.length <= NAME_MAX && NAME.test(name);
@@ -86,8 +101,11 @@ export function isReservedAppEnvName(name: string): boolean {
86101 /** The names an app has set, alphabetical, widest scope first — never a value. */
87102 export function listAppEnvNames(db: Database, appId: string): AppEnvName[] {
88103 return db
89 .query<{ name: string; scope: string; updated_at: string }, [string]>(
90 `SELECT name, scope, updated_at FROM app_env WHERE app_id = ?1
104 .query<
105 { name: string; scope: string; updated_at: string; kind: string; value_plain: string | null },
106 [string]
107 >(
108 `SELECT name, scope, updated_at, kind, value_plain FROM app_env WHERE app_id = ?1
91109 ORDER BY name, CASE scope WHEN 'all' THEN 0 WHEN 'production' THEN 1 ELSE 2 END`,
92110 )
93111 .all(appId)
@@ -95,6 +113,8 @@ export function listAppEnvNames(db: Database, appId: string): AppEnvName[] {
95113 name: r.name,
96114 scope: isAppEnvScope(r.scope) ? r.scope : "all",
97115 updatedAt: r.updated_at,
116 kind: r.kind === "variable" ? "variable" : "secret",
117 ...(r.kind === "variable" ? { value: r.value_plain ?? "" } : {}),
98118 }));
99119 }
100120
@@ -106,26 +126,38 @@ export function setAppEnv(
106126 name: string,
107127 value: string,
108128 scope: string = "all",
129 kind: string = "secret",
109130 ): Result<AppEnvName, AppEnvError> {
131 if (!isAppEnvKind(kind)) return err("invalid_kind");
110132 if (!isAppEnvName(name)) return err("invalid_name");
111133 if (isReservedAppEnvName(name)) return err("reserved_name");
112134 if (!isAppEnvScope(scope)) return err("invalid_scope");
113135 // Empty would silently blank a secret; a NUL cannot cross execve.
114136 if (value === "" || value.length > VALUE_MAX || value.includes("\0"))
115137 return err("invalid_value");
138 const sealed = kind === "secret";
116139 db.query(
117 `INSERT INTO app_env (app_id, name, scope, value_enc) VALUES (?1, ?2, ?3, ?4)
140 `INSERT INTO app_env (app_id, name, scope, value_enc, kind, value_plain) VALUES (?1, ?2, ?3, ?4, ?5, ?6)
118141 ON CONFLICT(app_id, name, scope) DO UPDATE SET
119142 value_enc = excluded.value_enc,
143 kind = excluded.kind,
144 value_plain = excluded.value_plain,
120145 updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')`,
121 ).run(appId, name, scope, seal(dataDir, value, aad(appId, name)));
146 ).run(
147 appId,
148 name,
149 scope,
150 sealed ? seal(dataDir, value, aad(appId, name)) : "",
151 kind,
152 sealed ? null : value,
153 );
122154 const row = db
123155 .query<
124156 { updated_at: string },
125157 [string, string, string]
126158 >("SELECT updated_at FROM app_env WHERE app_id = ?1 AND name = ?2 AND scope = ?3")
127159 .get(appId, name, scope)!;
128 return ok({ name, scope, updatedAt: row.updated_at });
160 return ok({ name, scope, updatedAt: row.updated_at, kind, ...(sealed ? {} : { value }) });
129161 }
130162
131163 /** Remove a variable from one scope; the others keep theirs. */
@@ -161,15 +193,19 @@ export function resolveAppEnv(
161193 const own = scopeOfEnv(env);
162194 const rows = db
163195 .query<
164 { name: string; scope: string; value_enc: string },
196 { name: string; scope: string; value_enc: string; kind: string; value_plain: string | null },
165197 [string, string]
166198 >(
167 `SELECT name, scope, value_enc FROM app_env
199 `SELECT name, scope, value_enc, kind, value_plain FROM app_env
168200 WHERE app_id = ?1 AND scope IN ('all', ?2)
169201 ORDER BY name, CASE scope WHEN 'all' THEN 0 ELSE 1 END`,
170202 )
171203 .all(appId, own);
172204 for (const row of rows) {
205 if (row.kind === "variable") {
206 out[row.name] = row.value_plain ?? "";
207 continue;
208 }
173209 const opened = open(dataDir, row.value_enc, aad(appId, row.name));
174210 if (opened.ok) out[row.name] = opened.value;
175211 else
src/core/app_secrets.ts added +265
@@ -0,0 +1,265 @@
1 /**
2 * One place for a secret (#12180) — the rows. An app names what it needs
3 * (`forge.json` `env: ["SIMPLEFIN_TOKEN"]`); at start each name arrives
4 * from the nearest place that has it: the app's own secret, then — only
5 * once someone granted that name to this app (`secret_grant`) — the
6 * organization's, then the forge's. The grant is the repository's: its app and its crew's runs
7 * read through it, and a repository with no app can hold one. `secret_start` remembers which value
8 * each environment last started with, so a rotation can say who has it.
9 * Reads go through `resolveScoped`, so each is still an event.
10 */
11
12 import type { Database } from "bun:sqlite";
13 import type { EventBus } from "./events.ts";
14 import {
15 isSecretName,
16 resolveScoped,
17 type SecretScope,
18 } from "./secret_store.ts";
19
20 /** At most this many names an app may ask for. */
21 export const NEEDS_MAX = 64;
22
23 /** Validate `forge.json`'s `env`: an array of distinct secret-style names. */
24 export function parseNeeds(raw: unknown): { ok: true; value: string[] } | { ok: false; error: string } {
25 if (raw === undefined) return { ok: true, value: [] };
26 if (!Array.isArray(raw) || raw.length > NEEDS_MAX)
27 return { ok: false, error: `forge.json: env must be an array of at most ${NEEDS_MAX} names` };
28 const seen = new Set<string>();
29 for (const n of raw) {
30 if (typeof n !== "string" || !isSecretName(n) || n.startsWith("FORGE_"))
31 return { ok: false, error: "forge.json: env names are capital letters, digits and underscores (SIMPLEFIN_TOKEN), never FORGE_…" };
32 seen.add(n);
33 }
34 return { ok: true, value: [...seen] };
35 }
36
37 /** The names `forge.json` text asks for; nothing when it is absent or unreadable. */
38 export function neededNames(forgeJson: string | undefined): string[] {
39 if (forgeJson === undefined) return [];
40 try {
41 const parsed: unknown = JSON.parse(forgeJson);
42 if (typeof parsed !== "object" || parsed === null) return [];
43 const needs = parseNeeds((parsed as Record<string, unknown>)["env"]);
44 return needs.ok ? needs.value : [];
45 } catch {
46 return [];
47 }
48 }
49
50 export interface Grant {
51 readonly repoId: string;
52 readonly name: string;
53 readonly scope: "forge" | "org";
54 readonly scopeId: string;
55 readonly grantedBy: string;
56 readonly grantedAt: string;
57 }
58
59 interface GrantRow {
60 repo_id: string;
61 name: string;
62 scope: string;
63 scope_id: string;
64 granted_by: string;
65 granted_at: string;
66 }
67 const toGrant = (r: GrantRow): Grant => ({
68 repoId: r.repo_id,
69 name: r.name,
70 scope: r.scope as "forge" | "org",
71 scopeId: r.scope_id,
72 grantedBy: r.granted_by,
73 grantedAt: r.granted_at,
74 });
75
76 export function listGrants(db: Database, repoId: string): Grant[] {
77 return db
78 .query<GrantRow, [string]>("SELECT * FROM secret_grant WHERE repo_id = ?1 ORDER BY name")
79 .all(repoId)
80 .map(toGrant);
81 }
82
83 /** Grant a forge- or org-scope name to a repository; false when no such secret is set there. */
84 export function grantSecret(
85 db: Database,
86 repoId: string,
87 scope: "forge" | "org",
88 scopeId: string,
89 name: string,
90 by: string,
91 ): boolean {
92 const owner = scope === "forge" ? "" : scopeId;
93 const has = db
94 .query("SELECT 1 FROM secret WHERE name = ?1 AND scope = ?2 AND scope_id = ?3")
95 .get(name, scope, owner);
96 if (!has) return false;
97 db.query(
98 `INSERT INTO secret_grant (repo_id, name, scope, scope_id, granted_by) VALUES (?1, ?2, ?3, ?4, ?5)
99 ON CONFLICT(repo_id, name) DO UPDATE SET scope = excluded.scope, scope_id = excluded.scope_id,
100 granted_by = excluded.granted_by, granted_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')`,
101 ).run(repoId, name, scope, owner, by);
102 return true;
103 }
104
105 export function revokeGrant(db: Database, repoId: string, name: string): boolean {
106 return db.query("DELETE FROM secret_grant WHERE repo_id = ?1 AND name = ?2").run(repoId, name).changes > 0;
107 }
108
109 /**
110 * The app ids a secret reaches: the app that owns it, or each app of a repository it was granted
111 * to.
112 */
113 export function appsUsing(db: Database, scope: SecretScope, scopeId: string, name: string): string[] {
114 if (scope === "repo")
115 return db.query<{ id: string }, [string]>("SELECT id FROM app WHERE repo_id = ?1").all(scopeId).map((r) => r.id);
116 return db
117 .query<{ app_id: string }, [string, string, string]>(
118 `SELECT app.id AS app_id FROM secret_grant g JOIN app ON app.repo_id = g.repo_id
119 WHERE g.name = ?1 AND g.scope = ?2 AND g.scope_id = ?3 ORDER BY app.id`,
120 )
121 .all(name, scope, scope === "forge" ? "" : scopeId)
122 .map((r) => r.app_id);
123 }
124
125 /** Who a secret reaches and what each environment last started with. */
126 export interface SecretUse {
127 readonly appId: string;
128 readonly slug: string;
129 /** Environments that started with the current value, and when the latest did. */
130 readonly current: boolean;
131 readonly startedAt: string | null;
132 }
133
134 export function usesOf(db: Database, scope: SecretScope, scopeId: string, name: string): SecretUse[] {
135 const owner = scope === "forge" ? "" : scopeId;
136 const updated = db
137 .query<{ updated_at: string }, [string, string, string]>(
138 "SELECT updated_at FROM secret WHERE name = ?1 AND scope = ?2 AND scope_id = ?3",
139 )
140 .get(name, scope, owner)?.updated_at;
141 return appsUsing(db, scope, scopeId, name).map((appId) => {
142 const slug = db.query<{ slug: string }, [string]>("SELECT slug FROM app WHERE id = ?1").get(appId)?.slug ?? appId;
143 const starts = db
144 .query<{ value_updated_at: string; started_at: string }, [string, string, string]>(
145 "SELECT value_updated_at, started_at FROM secret_start WHERE app_id = ?1 AND name = ?2 AND scope = ?3 ORDER BY started_at DESC",
146 )
147 .all(appId, name, scope);
148 const latest = starts[0];
149 return {
150 appId,
151 slug,
152 current: latest !== undefined && updated !== undefined && latest.value_updated_at >= updated,
153 startedAt: latest?.started_at ?? null,
154 };
155 });
156 }
157
158 export interface AppSecretInput {
159 readonly appId: string;
160 /** Where its grants are held. */
161 readonly repoId: string;
162 /** The repository's owner: the organization whose secrets may be granted. */
163 readonly ownerId: string;
164 readonly env: string;
165 /** Names `forge.json` asks for. */
166 readonly needs: readonly string[];
167 /**
168 * A start of the process (default): remember which value it began with. A terminal only
169 * reads.
170 */
171 readonly start?: boolean;
172 }
173
174 /**
175 * What an app's process starts with from the secret store: its own secrets
176 * (every one, they are its settings), and each needed name that was granted
177 * from the organization or the forge. Opens each through the resolver, so
178 * each leaves a `secret.read` event, and records the start.
179 */
180 export function resolveAppSecrets(
181 db: Database,
182 dataDir: string,
183 bus: EventBus,
184 input: AppSecretInput,
185 ): Record<string, string> {
186 const granted = new Map(listGrants(db, input.repoId).map((g) => [g.name, g]));
187 const out: Record<string, string> = {};
188 const who = { actor: undefined, run: `app:${input.appId}:${input.env}` };
189 for (const name of input.needs) {
190 const grant = granted.get(name);
191 if (grant === undefined) continue;
192 const found = resolveScoped(db, dataDir, bus, [name], {
193 ...who,
194 org: input.ownerId,
195 only: [grant.scope],
196 })[name];
197 if (found === undefined) continue;
198 out[name] = found.value;
199 if (input.start === false) continue;
200 const scopeId = found.scope === "org" ? input.ownerId : "";
201 const row = db
202 .query<{ updated_at: string }, [string, string, string]>(
203 "SELECT updated_at FROM secret WHERE name = ?1 AND scope = ?2 AND scope_id = ?3",
204 )
205 .get(name, found.scope, scopeId);
206 db.query(
207 `INSERT INTO secret_start (app_id, name, env, scope, value_updated_at) VALUES (?1, ?2, ?3, ?4, ?5)
208 ON CONFLICT(app_id, name, env) DO UPDATE SET scope = excluded.scope,
209 value_updated_at = excluded.value_updated_at, started_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')`,
210 ).run(input.appId, name, input.env, found.scope, row?.updated_at ?? "");
211 }
212 return out;
213 }
214
215 export type NeedSource = "app" | "org" | "forge" | "available" | "missing";
216 export interface NeedRow {
217 readonly name: string;
218 /** Where the value comes from; `available` is set higher up but not granted yet. */
219 readonly source: NeedSource;
220 /** The scope a grant would draw from, when `available`. */
221 readonly offer?: "forge" | "org";
222 }
223
224 const hasOwn = (db: Database, appId: string, name: string): boolean =>
225 db.query("SELECT 1 FROM app_env WHERE app_id = ?1 AND name = ?2").get(appId, name) !== null;
226
227 /** needs / has / missing for each name the app asks for. */
228 export function needRows(db: Database, input: Omit<AppSecretInput, "env">): NeedRow[] {
229 const has = (scope: SecretScope, id: string, name: string): boolean =>
230 db.query("SELECT 1 FROM secret WHERE name = ?1 AND scope = ?2 AND scope_id = ?3").get(name, scope, id) !== null;
231 const grants = new Map(listGrants(db, input.repoId).map((g) => [g.name, g]));
232 return input.needs.map((name): NeedRow => {
233 if (hasOwn(db, input.appId, name)) return { name, source: "app" };
234 const g = grants.get(name);
235 if (g !== undefined && has(g.scope, g.scopeId, name)) return { name, source: g.scope };
236 if (has("org", input.ownerId, name)) return { name, source: "available", offer: "org" };
237 if (has("forge", "", name)) return { name, source: "available", offer: "forge" };
238 return { name, source: "missing" };
239 });
240 }
241
242 /**
243 * Names set at the organization or the forge that this repository has not been granted: what its
244 * owner may grant, by name, from its settings. Names only.
245 */
246 export function grantable(
247 db: Database,
248 repoId: string,
249 ownerId: string,
250 ): { name: string; scope: "forge" | "org" }[] {
251 const held = new Set(listGrants(db, repoId).map((g) => g.name));
252 const own = new Set(
253 db
254 .query<{ name: string }, [string]>("SELECT name FROM secret WHERE scope = 'repo' AND scope_id = ?1")
255 .all(repoId)
256 .map((r) => r.name),
257 );
258 return db
259 .query<{ name: string; scope: "forge" | "org" }, [string]>(
260 `SELECT name, scope FROM secret WHERE (scope = 'org' AND scope_id = ?1) OR scope = 'forge'
261 ORDER BY name, CASE scope WHEN 'org' THEN 0 ELSE 1 END`,
262 )
263 .all(ownerId)
264 .filter((r, i, all) => !held.has(r.name) && !own.has(r.name) && all.findIndex((x) => x.name === r.name) === i);
265 }
src/core/migration-pins/20261114070000-secret-grants.pin added +1
@@ -0,0 +1,1 @@
1 5e1b10dfc01758406da0a22faba885256eb5b3e4be5d2c36d8c70c60f797bbab
src/core/migration-pins/20261114070100-app-env-kind.pin added +1
@@ -0,0 +1,1 @@
1 553ffb85a4d2595e8c1a8b0ceb7527206c15762ff047d2a44ba302d518bc113c
src/core/migrations/20261114070000-secret-grants.ts added +26
@@ -0,0 +1,26 @@
1 // One place for a secret (#12180). A secret at the forge or an organization reaches an app only
2 // after it is granted to that repository by name (`secret_grant`): its app and its crew's runs both
3 // read it, and a repository with no app can hold one. The repository's own scope (`secret` at scope
4 // 'repo') needs no grant. `secret_start` is the rotation ledger: which value of which secret each
5 // environment of each app last started with. Additive.
6 export default `
7 CREATE TABLE IF NOT EXISTS secret_grant (
8 repo_id TEXT NOT NULL,
9 name TEXT NOT NULL,
10 scope TEXT NOT NULL CHECK (scope IN ('forge','org')),
11 scope_id TEXT NOT NULL DEFAULT '',
12 granted_by TEXT NOT NULL,
13 granted_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
14 PRIMARY KEY (repo_id, name)
15 );
16 CREATE INDEX IF NOT EXISTS secret_grant_by_secret ON secret_grant(name, scope, scope_id);
17 CREATE TABLE IF NOT EXISTS secret_start (
18 app_id TEXT NOT NULL,
19 name TEXT NOT NULL,
20 env TEXT NOT NULL,
21 scope TEXT NOT NULL,
22 value_updated_at TEXT NOT NULL,
23 started_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
24 PRIMARY KEY (app_id, name, env)
25 );
26 `;
src/core/migrations/20261114070100-app-env-kind.ts added +9
@@ -0,0 +1,9 @@
1 // Two kinds in one list (#12180): an app's variables are plain settings (a URL, a switch), kept
2 // in the clear in `value_plain` and shown; its secrets are sealed in `value_enc`, write-only and
3 // in the sealing-key rotation. Every row that existed was sealed and stays a secret; setting one
4 // again as a variable converts it. A variable's `value_enc` is '' so the rotation, which skips
5 // what does not look sealed, never touches it. Additive.
6 export default `
7 ALTER TABLE app_env ADD COLUMN kind TEXT NOT NULL DEFAULT 'secret' CHECK (kind IN ('variable','secret'));
8 ALTER TABLE app_env ADD COLUMN value_plain TEXT;
9 `;
src/core/secret_store.ts +7
@@ -138,6 +138,8 @@ export interface SecretReader {
138138 readonly run?: string | undefined;
139139 /** Open without a `secret.read` event or a `last_read_at` stamp; the caller audits what it chooses to. */
140140 readonly quiet?: boolean | undefined;
141 /** Look only at these scopes (an app reads the wider ones only where granted). */
142 readonly only?: readonly SecretScope[] | undefined;
141143 }
142144
143145 /**
@@ -174,6 +176,11 @@ export function resolveScoped(
174176 if (who.repo) ladder.push(["repo", who.repo]);
175177 if (who.org) ladder.push(["org", who.org]);
176178 ladder.push(["forge", ""]);
179 if (who.only) {
180 const allowed = who.only;
181 for (let i = ladder.length - 1; i >= 0; i--)
182 if (!allowed.includes(ladder[i]![0])) ladder.splice(i, 1);
183 }
177184
178185 for (const name of names) {
179186 if (!isSecretName(name) || name in out) continue;
src/crew/grant.ts +70 −13
@@ -10,14 +10,24 @@
1010 * reach exactly those hosts, for that run only. Whatever is not declared is
1111 * not resolved, whatever is malformed or would shadow the run's own
1212 * environment is refused by name, and the values are held for redaction from
13 * the moment they are opened until the grant is dropped.
13 * the moment they are opened until the grant is dropped. A name reaches a
14 * run from the repository's own secrets, or from the organization or the
15 * forge only once the forge's owner granted that name to the repository
16 * (`secret_grant`, the one grant an app's start reads too); a name asked for
17 * and not granted is listed in `ungranted`, never opened.
1418 */
1519
1620 import type { Database } from "bun:sqlite";
21 import type { App } from "../app.ts";
22 import { addComment } from "../actions/issues.ts";
23 import { listComments, type Issue } from "../core/issues.ts";
24 import type { Repo } from "../core/repos.ts";
25 import type { User } from "../core/users.ts";
1726 import type { EventBus } from "../core/events.ts";
27 import { listGrants } from "../core/app_secrets.ts";
1828 import { recordGrant, releaseGrant } from "../core/run_secret.ts";
1929 import { writeRefused } from "../core/cloud_account.ts";
20 import { isSecretName, resolveScoped } from "../core/secret_store.ts";
30 import { isSecretName, resolveScoped, type SecretScope } from "../core/secret_store.ts";
2131 import { holdValues, releaseValues } from "../kernel/log.ts";
2232
2333 export interface Declaration {
@@ -87,8 +97,10 @@ export interface Grant {
8797 readonly names: readonly string[];
8898 /** Cloud credentials withheld because the run needs writes and they are not write allowed. */
8999 readonly writeRefused: readonly string[];
90 /** Declared but set at no scope. */
100 /** Declared but set at no scope this run may read, and at none higher up either. */
91101 readonly missing: readonly string[];
102 /** Declared, set at the organization or the forge, and not granted to this repository. */
103 readonly ungranted: readonly string[];
92104 /** Extra egress hosts for this run only. */
93105 readonly hosts: readonly string[];
94106 /** Drop the grant: values forgotten, record closed. Safe to call twice. */
@@ -107,15 +119,32 @@ export function grantSecrets(
107119 ? declared.names.filter((n) => writeRefused(db, n))
108120 : [];
109121 const asked = declared.names.filter((n) => !denied.includes(n));
110 const found =
111 asked.length === 0
112 ? {}
113 : resolveScoped(db, dataDir, bus, asked, {
114 repo: run.repoId,
115 org: run.ownerId,
116 actor: run.actorId,
117 run: run.id,
118 });
122 const who = { repo: run.repoId, actor: run.actorId, run: run.id };
123 // Before anything is opened: the repository's own scope, then each name granted to it from
124 // the one scope its grant names. Nothing else is read, so a declaration cannot reach further.
125 const found: Record<string, { value: string; scope: SecretScope }> =
126 asked.length === 0 ? {} : resolveScoped(db, dataDir, bus, asked, { ...who, only: ["repo"] });
127 for (const g of listGrants(db, run.repoId)) {
128 if (!asked.includes(g.name) || g.name in found) continue;
129 Object.assign(
130 found,
131 resolveScoped(db, dataDir, bus, [g.name], {
132 ...who,
133 org: g.scopeId,
134 only: [g.scope],
135 }),
136 );
137 }
138 const unset = asked.filter((n) => !(n in found));
139 const higher = new Set(
140 unset.filter((n) =>
141 db
142 .query(
143 "SELECT 1 FROM secret WHERE name = ?1 AND (scope = 'forge' OR (scope = 'org' AND scope_id = ?2))",
144 )
145 .get(n, run.ownerId),
146 ),
147 );
119148 const names = Object.keys(found);
120149 const env = Object.fromEntries(names.map((n) => [n, found[n]!.value]));
121150 // Held before anything can log: a value is redacted from the first line.
@@ -131,7 +160,8 @@ export function grantSecrets(
131160 env,
132161 names,
133162 writeRefused: denied,
134 missing: asked.filter((n) => !(n in found)),
163 missing: unset.filter((n) => !higher.has(n)),
164 ungranted: unset.filter((n) => higher.has(n)),
135165 // Only a secret that was actually granted widens the egress.
136166 hosts: [...new Set(names.flatMap((n) => declared.hostOf[n] ?? []))],
137167 release: () => {
@@ -140,3 +170,30 @@ export function grantSecrets(
140170 },
141171 };
142172 }
173
174 /** The one sentence a run says for a name it asked for and the forge's owner has not granted. */
175 export function ungrantedSentence(name: string, repo: { ownerName: string; name: string }): string {
176 return `this run asked for ${name}; the forge's owner has not granted ${name} to ${repo.ownerName}/${repo.name}`;
177 }
178
179 /**
180 * Say it where the owner will see it: one comment on the issue, with the link that grants it,
181 * once per name. The run goes on without the value.
182 */
183 export function needsYou(
184 app: App,
185 agent: User,
186 repo: Repo,
187 issue: Issue,
188 name: string,
189 ): void {
190 const sentence = ungrantedSentence(name, repo);
191 if (listComments(app.db, issue.id).some((c) => c.body.startsWith(sentence))) return;
192 addComment(
193 app,
194 agent,
195 repo,
196 issue,
197 `${sentence}: [Grant it](/${repo.ownerName}/${repo.name}/settings/app#secrets)`,
198 );
199 }
src/crew/runner.ts +7 −2
@@ -181,7 +181,7 @@ import {
181181 import { trackAgentRun, untrackAgentRun } from "./running.ts";
182182 import { installCacheEnv, sandboxPolicy } from "./sandbox.ts";
183183 import { runGitAuth } from "./git_auth.ts";
184 import { grantSecrets, parseDeclaration, type Grant } from "./grant.ts";
184 import { grantSecrets, needsYou, parseDeclaration, ungrantedSentence, type Grant } from "./grant.ts";
185185 import { historyHolds, scrubTree } from "./leak.ts";
186186 import {
187187 installCacheDir,
@@ -1152,7 +1152,12 @@ async function work(
11521152 }
11531153 if (grant.names.length > 0)
11541154 write(`secrets: holding ${grant.names.join(", ")} for this run`);
1155 for (const name of grant.missing) write(`secret not set: ${name}`);
1155 // One sentence for a name that is set higher up and one that is set nowhere: a declaration
1156 // must not tell its writer which names exist at the organization or the forge.
1157 for (const name of [...grant.missing, ...grant.ungranted]) {
1158 write(`${ungrantedSentence(name, repo)}: Grant it (Settings → App → Secrets)`);
1159 needsYou(app, agent.user, repo, issue, name);
1160 }
11561161 for (const host of grant.hosts) write(`egress: +${host} for this run`);
11571162 const bare = repoDir(app.reposRoot, repo.ownerName, repo.name);
11581163 const forkBare = repoDir(
src/http/api/app.ts +1
@@ -258,6 +258,7 @@ export function appRoutes(router: Router<Handler>): void {
258258 ctx.params.var ?? "",
259259 str(input.value),
260260 str(input.scope) || DEFAULT_ENV_SCOPE,
261 str(input.kind),
261262 );
262263 if (!set.ok) return respondAccess(set.error, 422);
263264 return json(set.value);
src/http/mcp/tools/app_env.ts +4 −3
@@ -5,9 +5,9 @@ import { DEFAULT_ENV_SCOPE } from "../../../core/app_env.ts";
55
66 export const tool: Tool = {
77 name: "app_env",
8 doc: "`{ repo, action, name?, value?, scope? }`: `list` an app's variable names and scopes, `set` one, or `unset` it, in `production` (default), `all` or `preview`; the API's rules and errors, an admin of the repository with an admin-scope token; a value is never read back",
8 doc: "`{ repo, action, name?, value?, scope?, kind? }`: `list` an app's variables and secrets, `set` one, or `unset` it, in `production` (default), `all` or `preview`; `kind` is `variable` (plain, listed with its value) or `secret` (sealed, never read back; the default); the API's rules and errors, an admin of the repository with an admin-scope token",
99 description:
10 "An app's environment variables: list names, set or unset by scope; write-only. Admin.",
10 "An app's environment variables: list, set or unset by scope; a secret is write-only, a variable is shown. Admin.",
1111 inputSchema: {
1212 type: "object",
1313 properties: {
@@ -16,6 +16,7 @@ export const tool: Tool = {
1616 name: { type: "string" },
1717 value: { type: "string" },
1818 scope: { type: "string", enum: ["all", "production", "preview"] },
19 kind: { type: "string", enum: ["variable", "secret"] },
1920 },
2021 required: ["repo", "action"],
2122 },
@@ -39,7 +40,7 @@ export const tool: Tool = {
3940 }
4041 if (action === "set") {
4142 const value = typeof args["value"] === "string" ? args["value"] : "";
42 const set = await setAppEnvVar(ctx.app, user, repo, name, value, scope);
43 const set = await setAppEnvVar(ctx.app, user, repo, name, value, scope, typeof args["kind"] === "string" ? args["kind"] : undefined);
4344 return set.ok ? good(set.value) : refused(set.error);
4445 }
4546 if (action === "unset") {
src/http/server.ts +3
@@ -5,6 +5,7 @@
55 * verdict. Auth is resolved once per request and handed to handlers as data.
66 */
77
8 import { appSecretGrant, appSecretRevoke } from "../web/pages/app_secrets.ts";
89 import { apiRoot } from "./api_root.ts";
910 import { isTokenScheme, tokenSecret } from "./token_header.ts";
1011 import {
@@ -1624,6 +1625,8 @@ export function buildRouter(): Router<Handler> {
16241625 router.add("POST", "/:owner/:name/settings/app/disable", appDisable);
16251626 router.add("POST", "/:owner/:name/settings/app/members", appMembersUpdate);
16261627 router.add("POST", "/:owner/:name/settings/app/env", appEnvSet);
1628 router.add("POST", "/:owner/:name/settings/app/secrets/grant", appSecretGrant);
1629 router.add("POST", "/:owner/:name/settings/app/secrets/revoke", appSecretRevoke);
16271630 router.add(
16281631 "POST",
16291632 "/:owner/:name/settings/app/env/:var/delete",
src/snapshot/drill.ts +1 −1
@@ -265,7 +265,7 @@ export async function runDrill(deps: DrillDeps): Promise<DrillResult> {
265265 ? undefined
266266 : db
267267 .query<{ app_id: string; name: string; value_enc: string }, []>(
268 "SELECT app_id, name, value_enc FROM app_env ORDER BY app_id, name LIMIT 1",
268 "SELECT app_id, name, value_enc FROM app_env WHERE value_enc <> '' ORDER BY app_id, name LIMIT 1",
269269 )
270270 .get();
271271 // A forge with nothing sealed has nothing to open; that is not red.
src/snapshot/restore.ts +1 −1
@@ -96,7 +96,7 @@ async function sealedNames(dbFile: string): Promise<string[]> {
9696 .map((r) => `${r.name} (${r.scope} secret)`);
9797 const env = db
9898 .query<{ name: string; slug: string }, []>(
99 "SELECT e.name AS name, a.slug AS slug FROM app_env e JOIN app a ON a.id = e.app_id ORDER BY a.slug, e.name",
99 "SELECT e.name AS name, a.slug AS slug FROM app_env e JOIN app a ON a.id = e.app_id WHERE e.value_enc <> '' ORDER BY a.slug, e.name",
100100 )
101101 .all()
102102 .map((r) => `${r.name} (app ${r.slug})`);
src/tui/screens/appenv.ts +23 −11
@@ -45,7 +45,7 @@ export function appEnvCommands(f: Session, ctx: Ctx, repo: Repo | undefined): Co
4545 {
4646 id: "app-env",
4747 title: `${repo.ownerName}/${repo.name} — Variables`,
48 hint: "the app's environment: names and scopes, never a value",
48 hint: "the app's environment: variables shown, secrets never",
4949 run: () => ctx.push(appEnvScreen(f, ctx, repo)),
5050 },
5151 ];
@@ -90,14 +90,24 @@ export function appEnvScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
9090 title: `Where ${name} goes`,
9191 items: SCOPE_CHOICES.map((c) => ({ id: c.scope, label: c.label })),
9292 onPick: ([scope]) =>
93 runAsync(ctx, async () => {
94 const set = await setAppEnvVar(f.app, f.user, repo, name, value, scope);
95 say(
96 set.ok
97 ? `${set.value.name} is set ${where(set.value)}.`
98 : (APP_ENV_ERRORS[set.error] ?? APP_ENV_NOT_SET),
99 );
100 }),
93 later(ctx, () =>
94 pickerScreen(ctx, screen, {
95 title: `What ${name} is`,
96 items: [
97 { id: "secret", label: "Secret", hint: "sealed, never shown again" },
98 { id: "variable", label: "Variable", hint: "plain, shown" },
99 ],
100 onPick: ([kind]) =>
101 runAsync(ctx, async () => {
102 const set = await setAppEnvVar(f.app, f.user, repo, name, value, scope, kind);
103 say(
104 set.ok
105 ? `${set.value.name} is set ${where(set.value)}.`
106 : (APP_ENV_ERRORS[set.error] ?? APP_ENV_NOT_SET),
107 );
108 }),
109 }),
110 ),
101111 }),
102112 ),
103113 }),
@@ -122,7 +132,9 @@ export function appEnvScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
122132 const lines = (): ListRow[] =>
123133 rows.map((v) => ({
124134 title: v.name,
125 meta: `${SCOPE_WORDS[v.scope]} · set ${v.updatedAt.slice(0, 16).replace("T", " ")} UTC`,
135 meta: `${v.kind} · ${SCOPE_WORDS[v.scope]} · set ${v.updatedAt.slice(0, 16).replace("T", " ")} UTC${
136 v.kind === "variable" ? ` · ${v.value ?? ""}` : ""
137 }`,
126138 }));
127139
128140 const screen: Screen = {
@@ -142,7 +154,7 @@ export function appEnvScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
142154 ? "An app's variables are its admin's."
143155 : rows.length === 0
144156 ? `${repo.ownerName}/${repo.name} sets no variable. Press a to set one.`
145 : `${rows.length} set for ${repo.ownerName}/${repo.name}; a value is never shown.`;
157 : `${rows.length} set for ${repo.ownerName}/${repo.name}; a secret's value is never shown.`;
146158 // A refusal is the page's sentence, whole: it wraps rather than cuts.
147159 const said = wrap(text, Math.max(1, area.w)).slice(0, 3);
148160 const [head, list] = split(area, "v", [len(said.length), fill(1)]);
src/tui/screens/appsecrets.ts added +107
@@ -0,0 +1,107 @@
1 /**
2 * What an app's secrets come from, in the terminal: the names its forge.json
3 * asks for, and whether each is the app's own, granted from the organization
4 * or the forge, set higher up and not granted, or missing. A forge admin grants
5 * (`a`) or takes a grant back (`x`) through the same actions as the settings
6 * page, refused in the same words; a repository's admin sees. Names only.
7 */
8
9 import { GRANT_ERRORS } from "../../actions/app_env_words.ts";
10 import { appSecretNeeds, grantToRepo, revokeFromRepo } from "../../actions/secrets.ts";
11 import type { NeedRow } from "../../core/app_secrets.ts";
12 import { getAppByRepo } from "../../core/apps.ts";
13 import { canAdmin, type Repo } from "../../core/repos.ts";
14 import type { Ctx, Screen } from "../app/app.ts";
15 import { binding } from "../app/keys.ts";
16 import type { Command } from "../app/palette.ts";
17 import { style } from "../term/style.ts";
18 import { wrap } from "../term/width.ts";
19 import { fill, len, split } from "../ui/layout.ts";
20 import type { Session } from "../session.ts";
21 import { ListState, drawList, runAsync, type ListRow } from "./common.ts";
22
23 const FROM: Record<NeedRow["source"], string> = {
24 app: "this app's own",
25 org: "granted from the organization",
26 forge: "granted from the forge",
27 available: "set higher up, not granted",
28 missing: "missing",
29 };
30
31 /** The palette's "Secrets", for an admin of a repository that is an app. */
32 export function appSecretCommands(f: Session, ctx: Ctx, repo: Repo | undefined): Command[] {
33 if (repo === undefined || f.user === undefined) return [];
34 if (getAppByRepo(f.app.db, repo.id) === undefined || !canAdmin(f.app.db, repo, f.user)) return [];
35 return [
36 {
37 id: "app-secrets",
38 title: `${repo.ownerName}/${repo.name} — Secrets this app uses`,
39 hint: "needs, has, missing; a forge admin grants",
40 run: () => ctx.push(appSecretsScreen(f, ctx, repo)),
41 },
42 ];
43 }
44
45 export function appSecretsScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
46 const state = new ListState();
47 let rows: NeedRow[] = [];
48 let readable = false;
49 let note = "";
50 const load = (): void => {
51 runAsync(ctx, async () => {
52 const got = await appSecretNeeds(f.app, f.user, repo);
53 readable = got.ok;
54 rows = got.ok ? [...got.value.needs, ...got.value.grantable.map((g): NeedRow => ({ name: g.name, source: "available", offer: g.scope }))] : [];
55 state.clamp(rows.length);
56 ctx.invalidate();
57 });
58 };
59 load();
60 const say = (text: string): void => {
61 note = text;
62 load();
63 };
64 const canGrant = f.user?.isAdmin === true;
65
66 const screen: Screen = {
67 title: "Secrets this app uses",
68 get keys() {
69 return [...(canGrant ? [binding(["a"], "grant"), binding(["x"], "revoke")] : []), binding(["j", "k"], "move cursor")];
70 },
71 render(area, s) {
72 const c = ctx.theme.c;
73 const text =
74 note !== ""
75 ? note
76 : !readable
77 ? "An app's secrets are its admin's."
78 : rows.length === 0
79 ? "Nothing is asked for or set higher up."
80 : `${rows.length} for ${repo.ownerName}/${repo.name}; a value is never shown.`;
81 const said = wrap(text, Math.max(1, area.w)).slice(0, 3);
82 const [head, list] = split(area, "v", [len(said.length), fill(1)]);
83 said.forEach((line, i) => s.text(head!.x, head!.y + i, line, style(c.mutedFg, c.bg), head!.x + head!.w));
84 const lines: ListRow[] = rows.map((r) => ({ title: r.name, meta: FROM[r.source] }));
85 drawList(s, list!, c, state, lines);
86 },
87 key(e) {
88 if (state.key(e, rows.length)) return true;
89 if (e.ctrl || e.alt || !readable) return false;
90 const row = rows[state.index];
91 if (row === undefined) return false;
92 if (e.key === "a" && row.source === "available") {
93 const scope = row.offer ?? "forge";
94 const done = grantToRepo(f.app, f.user, scope, scope === "org" ? repo.ownerName : "", row.name, repo.id);
95 say(done.ok ? `${row.name} is granted; the app starts again with it.` : (GRANT_ERRORS[done.error] ?? GRANT_ERRORS["not_found"]!));
96 return true;
97 }
98 if (e.key === "x" && (row.source === "org" || row.source === "forge")) {
99 const done = revokeFromRepo(f.app, f.user, row.name, repo.id);
100 say(done.ok ? `${row.name} is no longer granted.` : (GRANT_ERRORS[done.error] ?? GRANT_ERRORS["not_found"]!));
101 return true;
102 }
103 return false;
104 },
105 };
106 return screen;
107 }
src/tui/screens/workspace.ts +2
@@ -166,6 +166,7 @@ import { displayCommands } from "./display.ts";
166166 import { iterationsCommands } from "./iterations.ts";
167167 import { labelsCommands } from "./labels.ts";
168168 import { appEnvCommands } from "./appenv.ts";
169 import { appSecretCommands } from "./appsecrets.ts";
169170 import { appBackupCommands } from "./appbackup.ts";
170171 import { variantsCommands } from "./variants.ts";
171172 import { autonomyCommands } from "./autonomy.ts";
@@ -2000,6 +2001,7 @@ export function workspaceScreen(
20002001 ...iterationsCommands(f, ctx, repo),
20012002 ...displayCommands(f, ctx, repo),
20022003 ...appEnvCommands(f, ctx, repo),
2004 ...appSecretCommands(f, ctx, repo),
20032005 ...appBackupCommands(f, ctx, repo),
20042006 ...variantsCommands(f, ctx, repo),
20052007 ...autonomyCommands(f, ctx, repo),
src/web/admin_form.ts added +26
@@ -0,0 +1,26 @@
1 /**
2 * The gate a repository's admin-only settings pages share: the repository
3 * only for someone who administers it, and a POST that is same-origin and
4 * admin-scoped. A non-admin is told nothing.
5 */
6
7 import { canAdmin, type Repo } from "../core/repos.ts";
8 import type { Ctx } from "../http/context.ts";
9 import { loadRepo, repoNotFound } from "./chrome.ts";
10 import { readForm, type Form } from "./form.ts";
11
12 /** The repo, but only for someone who administers it. */
13 export function loadAdminRepo(ctx: Ctx): Repo | undefined {
14 const repo = loadRepo(ctx);
15 if (!repo || !canAdmin(ctx.app.db, repo, ctx.auth.user)) return undefined;
16 return repo;
17 }
18
19 /** The gate every settings POST passes: admin, same-origin, admin scope. */
20 export async function adminForm(ctx: Ctx): Promise<{ repo: Repo; form: Form } | Response> {
21 const repo = loadAdminRepo(ctx);
22 if (!repo) return repoNotFound(ctx);
23 const form = await readForm(ctx, "admin");
24 if (form instanceof Response) return form;
25 return { repo, form };
26 }
src/web/pages/app_secrets.ts added +94
@@ -0,0 +1,94 @@
1 /**
2 * An app's secrets, as its settings say them: each name `forge.json` asks
3 * for and where its value comes from, with one click for a forge admin to
4 * grant one set higher up. Names only, never a value.
5 */
6
7 import { GRANT_ERRORS } from "../../actions/app_env_words.ts";
8 import { grantToRepo, revokeFromRepo } from "../../actions/secrets.ts";
9 import type { NeedRow } from "../../core/app_secrets.ts";
10 import type { Ctx } from "../../http/context.ts";
11 import { field } from "../form.ts";
12 import { html, when, type Html } from "../html.ts";
13 import { flashTo } from "../layout.ts";
14 import { btn, row } from "../ui.ts";
15 import { repoBase } from "../chrome.ts";
16 import { adminForm } from "../admin_form.ts";
17
18 const FROM: Record<string, string> = {
19 app: "this app's own secret",
20 org: "the organization's, granted to this app",
21 forge: "the forge's, granted to this app",
22 available: "set higher up, not granted to this app",
23 missing: "missing: not set anywhere this app may read",
24 };
25
26 /** The `needs / has / missing` table; `canGrant` is a forge admin. */
27 export function appSecretsView(
28 base: string,
29 needs: readonly NeedRow[],
30 canGrant: boolean,
31 grantable: readonly { name: string; scope: "forge" | "org" }[] = [],
32 ): Html {
33 const offered: NeedRow[] = grantable.map((g) => ({ name: g.name, source: "available", offer: g.scope }));
34 const rows = [...needs, ...offered].map((n) =>
35 row({
36 title: html`<span class="mono">${n.name}</span>`,
37 meta: html`${FROM[n.source]}`,
38 trail: html`${when(
39 n.source === "available" && canGrant,
40 () => html`<form class="row-actions" method="post" action="${base}/settings/app/secrets/grant">
41 <input type="hidden" name="name" value="${n.name}" />
42 <input type="hidden" name="scope" value="${n.offer ?? "forge"}" />
43 ${btn("Grant", { size: "sm", icon: "plus", label: `Grant ${n.name} to this app` })}
44 </form>`,
45 )}${when(
46 (n.source === "org" || n.source === "forge") && canGrant,
47 () => html`<form class="row-actions" method="post" action="${base}/settings/app/secrets/revoke">
48 <input type="hidden" name="name" value="${n.name}" />
49 ${btn("Revoke", { size: "sm", icon: "x", label: `Revoke ${n.name} from this app` })}
50 </form>`,
51 )}`,
52 }),
53 );
54 return html`<section class="settings-block" id="secrets">
55 <h2>Secrets this app uses</h2>
56 <p class="muted small">Names this app asks for in <span class="mono">forge.json</span>, and where each comes from.</p>
57 ${rows.length > 0
outdated

Principle 1 and 8: this paragraph explains the mechanism a second time in long words. Replace it with one sentence, for example: "Names this app asks for in forge.json, and where each comes from."

58 ? html`<ul class="list setting-list">${rows}</ul>`
59 : html`<p class="muted small">Nothing is asked for or set higher up.</p>`}
60 </section>`;
61 }
62
63 /** POST /:owner/:name/settings/app/secrets/grant */
64 export async function appSecretGrant(ctx: Ctx): Promise<Response> {
65 const gate = await adminForm(ctx);
66 if (gate instanceof Response) return gate;
67 const { repo, form } = gate;
68 const name = field(form, "name").trim();
69 const scope = field(form, "scope");
70 const done = grantToRepo(ctx.app, ctx.auth.user, scope, scope === "org" ? repo.ownerName : "", name, repo.id);
71 return flashTo(
72 ctx,
73 `${repoBase(repo)}/settings/app#secrets`,
74 done.ok
75 ? { message: `${name} is granted to this repository: its app starts again with it, and its crew's next run may hold it.` }
76 : { tone: "danger", message: GRANT_ERRORS[done.error] ?? GRANT_ERRORS["not_found"]! },
77 );
78 }
79
80 /** POST /:owner/:name/settings/app/secrets/revoke */
81 export async function appSecretRevoke(ctx: Ctx): Promise<Response> {
82 const gate = await adminForm(ctx);
83 if (gate instanceof Response) return gate;
84 const { repo, form } = gate;
85 const name = field(form, "name").trim();
86 const done = revokeFromRepo(ctx.app, ctx.auth.user, name, repo.id);
87 return flashTo(
88 ctx,
89 `${repoBase(repo)}/settings/app#secrets`,
90 done?.ok === true
91 ? { message: `${name} is no longer granted; the app starts again without it, and the crew's next run goes without.` }
92 : { tone: "danger", message: GRANT_ERRORS[done?.error ?? "not_found"] ?? GRANT_ERRORS["not_found"]! },
93 );
94 }
src/web/pages/apps.ts +14
@@ -443,6 +443,15 @@ function newAppForm(
443443 })),
444444 ],
445445 })}
446 <fieldset class="stack mb">
447 <legend class="muted small">Secrets the app needs (optional)</legend>
448 ${[1, 2, 3].map(
449 (n) => html`<div class="edit-row">
450 <input class="input mono" type="text" name="secret_name_${String(n)}" placeholder="NAME" aria-label="Secret ${String(n)} name" pattern="[A-Z][A-Z0-9_]*" maxlength="128" autocomplete="off" spellcheck="false" />
451 <input class="input" type="password" name="secret_value_${String(n)}" placeholder="Value" aria-label="Secret ${String(n)} value" autocomplete="off" />
452 </div>`,
453 )}
454 </fieldset>
446455 <div class="form-actions">
447456 ${btn("Create app", { variant: "primary", icon: "rocket" })}
448457 ${btn("Cancel", { href: "/apps", variant: "ghost" })}
@@ -491,6 +500,10 @@ export async function newAppSubmit(ctx: Ctx): Promise<Response> {
491500 agent: field(form, "agent"),
492501 template: field(form, "template"),
493502 };
503 const secrets = [1, 2, 3].map((n) => ({
504 name: field(form, `secret_name_${n}`).trim(),
505 value: field(form, `secret_value_${n}`),
506 }));
494507 const pickAgent = values.agent === PICK_AGENT;
495508 // An explicit name is never second-guessed; left blank, the sentence
496509 // names the app — and the server derives that name itself (never trusting
@@ -499,6 +512,7 @@ export async function newAppSubmit(ctx: Ctx): Promise<Response> {
499512 const rest = {
500513 owner: values.owner,
501514 visibility: values.visibility === "public" ? "public" : "private",
515 secrets,
502516 ...(values.template ? { template: values.template } : {}),
503517 ...(pickAgent
504518 ? { pickAgent: true }
src/web/pages/crew-settings.ts +1 −1
@@ -14,7 +14,7 @@ import type { Ctx } from "../../http/context.ts";
1414 import { loadRepo, repoBase, repoNotFound } from "../chrome.ts";
1515 import { field } from "../form.ts";
1616 import { flashTo, redirect } from "../layout.ts";
17 import { adminForm } from "./settings.ts";
17 import { adminForm } from "../admin_form.ts";
1818 import { saveGrants } from "./subscriptions.ts";
1919
2020 // GET /:owner/:name/settings/crew
src/web/pages/danger.ts +2 −1
@@ -15,7 +15,8 @@ import { html } from "../html.ts";
1515 import { flashTo } from "../layout.ts";
1616 import { confirmAction } from "../ui.ts";
1717 import { detachSection } from "./detach.ts";
18 import { adminForm, settingsPageResponse, timedAdminRepo } from "./settings.ts";
18 import { adminForm } from "../admin_form.ts";
19 import { settingsPageResponse, timedAdminRepo } from "./settings.ts";
1920
2021 export function dangerPage(ctx: Ctx): Response {
2122 const repo = timedAdminRepo(ctx);
src/web/pages/detach.ts +1 −1
@@ -18,7 +18,7 @@ import { icon } from "../icons.ts";
1818 import { flashTo } from "../layout.ts";
1919 import { confirmAction, timeAgo } from "../ui.ts";
2020 import type { Database } from "bun:sqlite";
21 import { adminForm } from "./settings.ts";
21 import { adminForm } from "../admin_form.ts";
2222
2323 /** What the detach section shows: plain data, read by `detachSection`. */
2424 export interface DetachView {
src/web/pages/file_holds.ts +1 −1
@@ -14,7 +14,7 @@ import { err, type Result } from "../../kernel/result.ts";
1414 import { repoBase } from "../chrome.ts";
1515 import { field, type Form } from "../form.ts";
1616 import { flashTo } from "../layout.ts";
17 import { adminForm } from "./settings.ts";
17 import { adminForm } from "../admin_form.ts";
1818
1919 export async function fileHoldUpdate(ctx: Ctx): Promise<Response> {
2020 const gate = await adminForm(ctx);
src/web/pages/hooks.ts +2 −1
@@ -25,7 +25,8 @@ import {
2525 row,
2626 time,
2727 } from "../ui.ts";
28 import { adminForm, settingsPageResponse, timedAdminRepo } from "./settings.ts";
28 import { adminForm } from "../admin_form.ts";
29 import { settingsPageResponse, timedAdminRepo } from "./settings.ts";
2930
3031 /**
3132 * A writer who is not an admin follows the feed's failing-webhook line here:
src/web/pages/labels.ts +2 −1
@@ -15,7 +15,8 @@ import { field, type Form } from "../form.ts";
1515 import { html, type Html } from "../html.ts";
1616 import { flashTo } from "../layout.ts";
1717 import { btn, confirmAction, empty, labelChip, labelCss, row } from "../ui.ts";
18 import { adminForm, settingsPageResponse, timedAdminRepo } from "./settings.ts";
18 import { adminForm } from "../admin_form.ts";
19 import { settingsPageResponse, timedAdminRepo } from "./settings.ts";
1920 import { BIND, countAttr } from "../bind.ts";
2021
2122 /** `<input type="color">` submits `#rrggbb`; the store keeps six hex digits. */
src/web/pages/prompts.ts +2 −1
@@ -29,7 +29,8 @@ import {
2929 time,
3030 timeAgo,
3131 } from "../ui.ts";
32 import { adminForm, settingsPageResponse, timedAdminRepo } from "./settings.ts";
32 import { adminForm } from "../admin_form.ts";
33 import { settingsPageResponse, timedAdminRepo } from "./settings.ts";
3334
3435 const PROMPT_ERRORS: Record<string, string> = {
3536 invalid_at: "The time is hours and minutes in UTC, like 15:00.",
src/web/pages/secrets.ts +24 −3
@@ -7,7 +7,8 @@
77 * when something last opened it.
88 */
99
10 import { listSecrets, putSecret, removeSecret } from "../../actions/secrets.ts";
10 import type { SecretUse } from "../../core/app_secrets.ts";
11 import { listSecrets, putSecret, removeSecret, secretUses } from "../../actions/secrets.ts";
1112 import type { Ctx } from "../../http/context.ts";
1213 import {
1314 notePhase,
@@ -45,11 +46,27 @@ function backTo(scope: string, ref: string): string {
4546 return "/settings/secrets";
4647 }
4748
49 /**
50 * `· used by notes (started with this value 2 min ago), budget (restarting)`: who a secret
51 * reaches.
52 */
53 function usedBy(users: readonly SecretUse[]): Html {
54 if (users.length === 0) return html``;
55 return html` · used by ${users.map((u, i) => html`${i > 0 ? ", " : ""}<span class="mono">${u.slug}</span> ${
56 u.startedAt === null
57 ? "(not started with it yet)"
58 : u.current
59 ? html`(started with this value ${time(u.startedAt)})`
60 : html`(still on an older value since ${time(u.startedAt)})`
61 }`)}`;
62 }
63
4864 /** The card: names, never values, and a form to set one. */
4965 export function secretsCard(
5066 scope: SecretScope,
5167 ref: string,
5268 names: SecretName[],
69 uses: ReadonlyMap<string, readonly SecretUse[]> = new Map(),
5370 ): Html {
5471 const rows = names.map((s) =>
5572 row({
@@ -59,7 +76,7 @@ export function secretsCard(
5976 s.lastReadAt === null
6077 ? "never used"
6178 : html`last used ${time(s.lastReadAt)}`
62 }`,
79 }${usedBy(uses.get(s.name) ?? [])}`,
6380 trail: html`<form
6481 class="row-actions"
6582 method="post"
@@ -122,7 +139,11 @@ export function secretsSection(
122139 ref: string,
123140 ): Html | null {
124141 const names = listSecrets(ctx.app, ctx.auth.user, scope, ref);
125 return names.ok ? secretsCard(scope, ref, names.value) : null;
142 if (!names.ok) return null;
143 const uses = new Map(
144 names.value.map((n) => [n.name, secretUses(ctx.app, n.scope, n.scopeId, n.name)] as const),
145 );
146 return secretsCard(scope, ref, names.value, uses);
126147 }
127148
128149 function requireAdmin(ctx: Ctx): Response | undefined {
src/web/pages/settings.ts +21 −24
@@ -16,6 +16,7 @@ import {
1616 deleteAppEnvVar,
1717 disableApp,
1818 enableApp,
19 listAppEnv,
1920 setAppEnvVar,
2021 updateAppSettings,
2122 } from "../../actions/apps.ts";
@@ -43,7 +44,6 @@ import { blockedNote } from "../studio/blocked.ts";
4344 import type { DnsRecord } from "../../apps/domains.ts";
4445 import {
4546 DEFAULT_ENV_SCOPE,
46 listAppEnvNames,
4747 NAME_MAX,
4848 type AppEnvName,
4949 } from "../../core/app_env.ts";
@@ -72,7 +72,7 @@ import {
7272 readAppMembers,
7373 setAppMembersThrough,
7474 } from "../../actions/app_members.ts";
75 import { canAdmin, type Repo } from "../../core/repos.ts";
75 import type { Repo } from "../../core/repos.ts";
7676 import { stopMirroring } from "../../actions/mirror_target.ts";
7777 import { getMirrorTarget, mirrorLine } from "../../core/migrate_mirror.ts";
7878 import { listRefs } from "../../git/read.ts";
@@ -87,11 +87,15 @@ import {
8787 repoPalette,
8888 settingsLayout,
8989 } from "../chrome.ts";
90 import { adminForm, loadAdminRepo } from "../admin_form.ts";
9091 import { daySpark } from "../studio/kpi_row.ts";
9192 import { settingsLead, teamJs } from "../studio/crew.ts";
9293 import { pageTitle } from "../studio/nouns.ts";
9394 import { appHref } from "./apps.ts";
94 import { field, readForm, type Form } from "../form.ts";
95 import { appSecretsView } from "./app_secrets.ts";
96 import { appSecretNeeds } from "../../actions/secrets.ts";
97 import type { NeedRow } from "../../core/app_secrets.ts";
98 import { field } from "../form.ts";
9599 import { html, raw, when, type Html } from "../html.ts";
96100 import { settingLine, toggleLine } from "../setting_line.ts";
97101 import { NOT_CHANGED } from "../edit_words.ts";
@@ -128,14 +132,6 @@ type Section =
128132 | "machines"
129133 | "danger";
130134
131 /** The repo, but only for someone who administers it. A non-admin is told
132 * nothing — the settings tab is not even rendered for them. */
133 function loadAdminRepo(ctx: Ctx): Repo | undefined {
134 const repo = loadRepo(ctx);
135 if (!repo || !canAdmin(ctx.app.db, repo, ctx.auth.user)) return undefined;
136 return repo;
137 }
138
139135 /** `loadAdminRepo` for a GET page: names the session, then the repo read as `repo`. */
140136 export function timedAdminRepo(ctx: Ctx): Repo | undefined {
141137 nameFrame(ctx.request);
@@ -215,17 +211,6 @@ export function settingsPageResponse(
215211 );
216212 }
217213
218 /** The gate every settings POST passes: admin, same-origin, admin scope. */
219 export async function adminForm(
220 ctx: Ctx,
221 ): Promise<{ repo: Repo; form: Form } | Response> {
222 const repo = loadAdminRepo(ctx);
223 if (!repo) return repoNotFound(ctx);
224 const form = await readForm(ctx, "admin");
225 if (form instanceof Response) return form;
226 return { repo, form };
227 }
228
229214 // ---- general ----
230215
231216 /**
@@ -663,6 +648,7 @@ function appSettingsView(
663648 error?: string,
664649 egress?: EgressView,
665650 blocked?: BlockedView,
651 needs?: { needs: readonly NeedRow[]; grantable: { name: string; scope: "forge" | "org" }[] },
666652 ): Html {
667653 const base = repoBase(repo);
668654 const runtime = appsRuntime(ctx.app);
@@ -777,8 +763,9 @@ function appSettingsView(
777763 <div class="mt">${appVisitorsView(vitals)}</div>
778764 <div class="mt">${statement}</div>
779765 <div class="mt">
780 ${appEnvView(base, listAppEnvNames(ctx.app.db, record.id))}
766 ${appEnvView(base, (() => { const l = listAppEnv(ctx.app, ctx.auth.user, repo); return l.ok ? l.value : []; })())}
781767 </div>
768 ${when(needs !== undefined, () => html`<div class="mt">${appSecretsView(base, needs!.needs, ctx.auth.user?.isAdmin === true, needs!.grantable)}</div>`)}
782769 <div class="mt">
783770 ${appDomainsView(base, domains.ok ? domains.value : undefined)}
784771 </div>
@@ -842,7 +829,9 @@ export function appEnvView(base: string, names: AppEnvName[]): Html {
842829 row({
843830 lead: icon("key"),
844831 title: html`<span class="mono">${v.name}</span>`,
845 meta: html`${SCOPE_WORDS[v.scope]} · set ${time(v.updatedAt)}`,
832 meta: html`${v.kind === "secret" ? "secret" : "variable"} · ${SCOPE_WORDS[v.scope]} · set ${time(v.updatedAt)}${
833 v.kind === "variable" ? html` · <span class="mono">${v.value ?? ""}</span>` : html``
834 }`,
846835 trail: html`<form
847836 class="row-actions"
848837 method="post"
@@ -888,6 +877,10 @@ export function appEnvView(base: string, names: AppEnvName[]): Html {
888877 autocomplete="off"
889878 required
890879 />
880 <select class="input" name="kind" aria-label="Kind">
881 <option value="secret">Secret (sealed, never shown)</option>
882 <option value="variable">Variable (plain, shown)</option>
883 </select>
891884 <select class="input" name="scope" aria-label="Where it applies">
892885 <option value="production">Production only</option>
893886 <option value="all">Also in previews</option>
@@ -895,6 +888,7 @@ export function appEnvView(base: string, names: AppEnvName[]): Html {
895888 </select>
896889 ${btn("Set", { variant: "primary", size: "sm", icon: "plus" })}
897890 </form>
891 <p class="muted small mt">A secret is sealed and never shown again; choose Variable only for a value that is safe to read.</p>
898892 </section>`;
899893 }
900894
@@ -1067,6 +1061,7 @@ export async function appSettingsPage(ctx: Ctx): Promise<Response> {
10671061 : await egressView(ctx.app, ctx.auth.user, repo);
10681062 const blockedBase = record === undefined ? undefined : blockedView(ctx.app, ctx.auth.user, repo);
10691063 const blocked = blockedBase?.ok ? { ok: true as const, value: await withLiveOnly(ctx.app, repo, blockedBase.value) } : blockedBase;
1064 const needs = await appSecretNeeds(ctx.app, ctx.auth.user, repo);
10701065 const view = appSettingsView(
10711066 ctx,
10721067 repo,
@@ -1075,6 +1070,7 @@ export async function appSettingsPage(ctx: Ctx): Promise<Response> {
10751070 undefined,
10761071 egress?.ok ? egress.value : undefined,
10771072 blocked?.ok ? blocked.value : undefined,
1073 needs?.ok ? needs.value : undefined,
10781074 );
10791075 const rendering = performance.now();
10801076 const response = settingsPageResponse(ctx, repo, "app", "App", view);
@@ -1197,6 +1193,7 @@ export async function appEnvSet(ctx: Ctx): Promise<Response> {
11971193 name,
11981194 field(form, "value"),
11991195 field(form, "scope") || DEFAULT_ENV_SCOPE,
1196 field(form, "kind"),
12001197 );
12011198 if (!set.ok && set.error === "not_found") return repoNotFound(ctx);
12021199 return flashTo(
test/app-secrets-web.test.ts added +123
@@ -0,0 +1,123 @@
1 /**
2 * One place for a secret, on the page (#12180): the app's settings say
3 * needs / has / missing from what forge.json asks for; a forge admin
4 * grants a forge secret with one click and it is listed on the Secrets
5 * page with the app that uses it; a person who is not a forge admin is
6 * shown the table but cannot grant, and an app that was not granted
7 * stays without it.
8 */
9
10 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
11 import { mkdtemp, rm } from "node:fs/promises";
12 import { tmpdir } from "node:os";
13 import { join } from "node:path";
14 import { createApp, type App } from "../src/app.ts";
15 import { newApp } from "../src/actions/apps.ts";
16 import { commitToBranch } from "../src/actions/repos.ts";
17 import { putSecret } from "../src/actions/secrets.ts";
18 import { listGrants } from "../src/core/app_secrets.ts";
19 import { createSession } from "../src/core/sessions.ts";
20 import { createUser, type User } from "../src/core/users.ts";
21 import { serve, type Servers } from "../src/http/server.ts";
22 import { quietLogs } from "./helpers/globals.ts";
23 import { seedAdmin } from "./helpers/admin.ts";
24
25 quietLogs();
26
27 const words = (html: string): string => html.replace(/\s+/g, " ");
28
29 describe("secrets on the app's settings page", () => {
30 let dataDir = "";
31 let app: App;
32 let servers: Servers;
33 let base = "";
34 let admin: User;
35 let bob: User;
36 let adminCookie = "";
37 let bobCookie = "";
38 let appId = "";
39
40 const call = (cookie: string, path: string, form?: Record<string, string>): Promise<Response> =>
41 fetch(`${base}${path}`, {
42 method: form ? "POST" : "GET",
43 headers: { cookie, ...(form ? { origin: base } : {}) },
44 redirect: "manual",
45 ...(form ? { body: new URLSearchParams(form) } : {}),
46 });
47
48 beforeAll(async () => {
49 dataDir = await mkdtemp(join(tmpdir(), "forge-app-secrets-web-"));
50 app = createApp({ dataDir, port: 0 });
51 servers = await serve(app);
52 base = servers.url;
53 const a = await seedAdmin(app, { username: "alice", email: "[email protected]", password: "a-good-passphrase" });
54 if (!a.ok) throw new Error(a.error);
55 admin = a.value;
56 const o = await createUser(app.db, { origin: "seed", trusted: true, username: "bob", email: "[email protected]", password: "a-good-passphrase" });
57 if (!o.ok) throw new Error(o.error);
58 bob = o.value;
59 adminCookie = `forge_session=${createSession(app.db, admin.id).token}`;
60 bobCookie = `forge_session=${createSession(app.db, bob.id).token}`;
61 const made = await newApp(app, admin, { name: "budgeting", secrets: [{ name: "OWN_KEY", value: "own" }] });
62 if (!made.ok) throw new Error(made.error);
63 appId = made.value.repo.id;
64 const files = await commitToBranch(app, admin, made.value.repo, {
65 message: "Ask for secrets",
66 files: [
67 {
68 path: "forge.json",
69 content: JSON.stringify({ app: { run: "bun run src/server.ts" }, env: ["SIMPLEFIN_TOKEN", "OWN_KEY", "NOWHERE_KEY"] }),
70 },
71 ],
72 });
73 if (!files.ok) throw new Error(String(files.error));
74 expect(putSecret(app, admin, "forge", "", "SIMPLEFIN_TOKEN", "sf-secret").ok).toBe(true);
75 });
76 afterAll(async () => {
77 servers.stop();
78 app.close();
79 await rm(dataDir, { recursive: true, force: true });
80 });
81
82 test("the table says app, available and missing; a person who cannot administer the app is shown nothing and cannot grant", async () => {
83 const page = words(await (await call(adminCookie, "/alice/budgeting/settings/app")).text());
84 expect(page).toContain("Secrets this app uses");
85 expect(page).toContain("OWN_KEY");
86 expect(page).toContain("own secret");
87 expect(page).toContain("set higher up, not granted to this app");
88 expect(page).toContain("missing: not set anywhere this app may read");
89 expect(page).not.toContain("sf-secret");
90 expect((await call(bobCookie, "/alice/budgeting/settings/app")).status).toBe(404);
91 const refused = await call(bobCookie, "/alice/budgeting/settings/app/secrets/grant", { name: "SIMPLEFIN_TOKEN", scope: "forge" });
92 expect(refused.status).toBe(404);
93 expect(listGrants(app.db, appId)).toEqual([]);
94 });
95
96 test("a forge admin grants with one click, and the Secrets page lists the app", async () => {
97 const before = words(await (await call(adminCookie, "/alice/budgeting/settings/app")).text());
98 expect(before).toContain("Grant SIMPLEFIN_TOKEN to this app");
99 const granted = await call(adminCookie, "/alice/budgeting/settings/app/secrets/grant", { name: "SIMPLEFIN_TOKEN", scope: "forge" });
100 expect(granted.status).toBe(303);
101 expect(listGrants(app.db, appId).map((g) => g.name)).toEqual(["SIMPLEFIN_TOKEN"]);
102 const after = words(await (await call(adminCookie, "/alice/budgeting/settings/app")).text());
103 expect(after).toContain("granted to this app");
104 const secrets = words(await (await call(adminCookie, "/settings/secrets")).text());
105 expect(secrets).toContain("used by");
106 expect(secrets).toContain("budgeting");
107 expect(secrets).not.toContain("sf-secret");
108 const revoked = await call(adminCookie, "/alice/budgeting/settings/app/secrets/revoke", { name: "SIMPLEFIN_TOKEN" });
109 expect(revoked.status).toBe(303);
110 expect(listGrants(app.db, appId)).toEqual([]);
111 });
112
113 test("a cross-origin grant is refused", async () => {
114 const response = await fetch(`${base}/alice/budgeting/settings/app/secrets/grant`, {
115 method: "POST",
116 headers: { cookie: adminCookie, origin: "http://evil.test" },
117 redirect: "manual",
118 body: new URLSearchParams({ name: "SIMPLEFIN_TOKEN", scope: "forge" }),
119 });
120 expect(response.status).toBeGreaterThanOrEqual(400);
121 expect(listGrants(app.db, appId)).toEqual([]);
122 });
123 });
test/app-secrets.test.ts added +232
@@ -0,0 +1,232 @@
1 /**
2 * One place for a secret (#12180): a secret typed on the new-app page reaches
3 * the app's process with no second step; a forge-scope one reaches only the
4 * apps it was granted to, and setting it again reaches each of them, the
5 * row saying who and whether they started with the current value; the app's
6 * settings and the Secrets page list the same rows.
7 */
8
9 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
10 import { mkdtemp, rm } from "node:fs/promises";
11 import { tmpdir } from "node:os";
12 import { join } from "node:path";
13 import { createApp, type App } from "../src/app.ts";
14 import { listAppEnv, newApp, setAppEnvVar, deleteAppEnvVar } from "../src/actions/apps.ts";
15 import { grantToRepo, putSecret, revokeFromRepo } from "../src/actions/secrets.ts";
16 import { resolveAppEnv, setAppEnv } from "../src/core/app_env.ts";
17 import {
18 needRows,
19 neededNames,
20 parseNeeds,
21 resolveAppSecrets,
22 usesOf,
23 } from "../src/core/app_secrets.ts";
24 import { getAppByRepo, type AppRecord } from "../src/core/apps.ts";
25 import { listIssues } from "../src/core/issues.ts";
26 import { parseManifest } from "../src/apps/manifest.ts";
27 import type { Repo } from "../src/core/repos.ts";
28 import { createUser, type User } from "../src/core/users.ts";
29 import { quietLogs } from "./helpers/globals.ts";
30
31 quietLogs();
32
33 describe("one place for a secret", () => {
34 let dataDir = "";
35 let app: App;
36 let alice: User;
37 let bob: User;
38 const repos: Record<string, Repo> = {};
39 const apps: Record<string, AppRecord> = {};
40
41 const received = (name: string, needs: string[], env = "production"): Record<string, string> => {
42 const repo = repos[name]!;
43 return resolveAppSecrets(app.db, dataDir, app.bus, {
44 appId: apps[name]!.id,
45 repoId: repo.id,
46 ownerId: repo.ownerId,
47 env,
48 needs,
49 });
50 };
51
52 beforeAll(async () => {
53 dataDir = await mkdtemp(join(tmpdir(), "forge-app-secrets-"));
54 app = createApp({ dataDir, port: 0 });
55 const a = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true });
56 const b = await createUser(app.db, { origin: "seed", trusted: true, username: "bob", email: "[email protected]", password: "a-good-passphrase" });
57 if (!a.ok || !b.ok) throw new Error("users");
58 alice = a.value;
59 bob = b.value;
60 for (const name of ["budget", "notes", "other"]) {
61 const made = await newApp(app, alice, { name });
62 if (!made.ok) throw new Error(made.error);
63 repos[name] = made.value.repo;
64 apps[name] = made.value.app;
65 }
66 });
67 afterAll(async () => {
68 await rm(dataDir, { recursive: true, force: true });
69 });
70
71 test("a secret typed while making an app is in its process environment, and the crew is told its name", async () => {
72 const made = await newApp(app, alice, {
73 name: "simplefin",
74 description: "show my balances",
75 secrets: [{ name: "SIMPLEFIN_TOKEN", value: "tok-123" }, { name: "", value: "" }],
76 });
77 if (!made.ok) throw new Error(made.error);
78 const repo = made.value.repo;
79 expect(resolveAppEnv(app.db, dataDir, made.value.app.id, "production")).toEqual({ SIMPLEFIN_TOKEN: "tok-123" });
80 const body = listIssues(app.db, repo.id).map((i) => i.body).join("\n");
81 expect(body).toContain("SIMPLEFIN_TOKEN");
82 expect(body).not.toContain("tok-123");
83 const listed = listAppEnv(app, alice, repo);
84 expect(listed.ok && listed.value.map((v) => [v.name, v.kind, v.value])).toEqual([["SIMPLEFIN_TOKEN", "secret", undefined]]);
85 const row = app.db.query<{ value_enc: string; value_plain: string | null }, [string]>("SELECT value_enc, value_plain FROM app_env WHERE app_id = ?1").get(made.value.app.id)!;
86 expect(row.value_enc).not.toContain("tok-123");
87 expect(row.value_plain).toBeNull();
88 });
89
90 test("a bad secret refuses the app before anything exists", async () => {
91 const made = await newApp(app, alice, { name: "refused", secrets: [{ name: "lower", value: "x" }] });
92 expect(!made.ok && made.error).toBe("invalid_secret");
93 expect(getAppByRepo(app.db, "none")).toBeUndefined();
94 });
95
96 test("a forge secret reaches only the apps it was granted to; setting it again reaches both", () => {
97 expect(putSecret(app, alice, "forge", "", "BANK_TOKEN", "v1").ok).toBe(true);
98 // Not granted: nobody gets it, even when the app asks for the name.
99 expect(received("budget", ["BANK_TOKEN"])).toEqual({});
100 expect(received("notes", ["BANK_TOKEN"])).toEqual({});
101 // Only a forge admin grants.
102 expect(grantToRepo(app, bob, "forge", "", "BANK_TOKEN", repos["budget"]!.id).ok).toBe(false);
103 expect(grantToRepo(app, alice, "forge", "", "BANK_TOKEN", repos["budget"]!.id).ok).toBe(true);
104 expect(grantToRepo(app, alice, "forge", "", "BANK_TOKEN", repos["notes"]!.id).ok).toBe(true);
105 expect(grantToRepo(app, alice, "forge", "", "NOT_SET", repos["notes"]!.id).ok).toBe(false);
106 // Granted but not asked for: not handed over either.
107 expect(received("budget", [])).toEqual({});
108 expect(received("budget", ["BANK_TOKEN"])).toEqual({ BANK_TOKEN: "v1" });
109 expect(received("notes", ["BANK_TOKEN"])).toEqual({ BANK_TOKEN: "v1" });
110 expect(received("other", ["BANK_TOKEN"])).toEqual({});
111
112 // Rotate once: the row says both apps use it, and which have started with the new value.
113 const uses = () => usesOf(app.db, "forge", "", "BANK_TOKEN");
114 expect(uses().map((u) => [u.slug, u.current])).toEqual([["budget", true], ["notes", true]]);
115 app.db.query("UPDATE secret SET updated_at = '2000-01-01T00:00:00.000Z' WHERE name = 'BANK_TOKEN'").run();
116 received("budget", ["BANK_TOKEN"]);
117 received("notes", ["BANK_TOKEN"]);
118 expect(putSecret(app, alice, "forge", "", "BANK_TOKEN", "v2").ok).toBe(true);
119 // Set once: both apps are on the old value until each starts again.
120 expect(uses().map((u) => u.current)).toEqual([false, false]);
121 expect(received("budget", ["BANK_TOKEN"])).toEqual({ BANK_TOKEN: "v2" });
122 expect(received("notes", ["BANK_TOKEN"])).toEqual({ BANK_TOKEN: "v2" });
123 expect(uses().map((u) => [u.slug, u.current])).toEqual([["budget", true], ["notes", true]]);
124 });
125
126 test("a row whose app has not restarted since the rotation says so", () => {
127 expect(putSecret(app, alice, "forge", "", "STALE_KEY", "a").ok).toBe(true);
128 expect(grantToRepo(app, alice, "forge", "", "STALE_KEY", repos["other"]!.id).ok).toBe(true);
129 received("other", ["STALE_KEY"]);
130 app.db.query("UPDATE secret_start SET value_updated_at = '2000-01-01T00:00:00.000Z' WHERE name = 'STALE_KEY'").run();
131 expect(usesOf(app.db, "forge", "", "STALE_KEY")[0]?.current).toBe(false);
132 });
133
134 test("the app's own secret wins over a granted one, and revoking takes the name away", () => {
135 expect(grantToRepo(app, alice, "forge", "", "BANK_TOKEN", repos["other"]!.id).ok).toBe(true);
136 expect(received("other", ["BANK_TOKEN"])).toEqual({ BANK_TOKEN: "v2" });
137 // The app's own is nearer: the process is handed the forge's only where the app has none.
138 expect(setAppEnv(app.db, dataDir, apps["other"]!.id, "BANK_TOKEN", "mine").ok).toBe(true);
139 expect(needRows(app.db, { appId: apps["other"]!.id, repoId: repos["other"]!.id, ownerId: alice.id, needs: ["BANK_TOKEN"] })[0]?.source).toBe("app");
140 expect(revokeFromRepo(app, alice, "BANK_TOKEN", repos["notes"]!.id).ok).toBe(true);
141 expect(received("notes", ["BANK_TOKEN"])).toEqual({});
142 });
143
144 test("every read is an event naming the app run and the name, never the value", () => {
145 const events = app.db
146 .query<{ payload: string }, []>("SELECT payload FROM event WHERE kind = 'secret.read' ORDER BY id DESC LIMIT 5")
147 .all();
148 expect(events.length).toBeGreaterThan(0);
149 for (const e of events) {
150 expect(e.payload).toContain("app:");
151 expect(e.payload).not.toMatch(/"(v1|v2|tok-123|mine)"|:(v1|v2)/);
152 expect(Object.keys(JSON.parse(e.payload)).sort()).toEqual(["name", "run", "scope"]);
153 }
154 });
155
156 test("needs, has, missing", () => {
157 const repo = repos["notes"]!;
158 const rows = needRows(app.db, { appId: apps["notes"]!.id, repoId: repo.id, ownerId: repo.ownerId, needs: ["BANK_TOKEN", "STALE_KEY", "NOWHERE_KEY"] });
159 expect(rows.map((r) => [r.name, r.source, r.offer])).toEqual([
160 ["BANK_TOKEN", "available", "forge"],
161 ["STALE_KEY", "available", "forge"],
162 ["NOWHERE_KEY", "missing", undefined],
163 ]);
164 });
165
166 test("a variable is plain and shown; a setting is a secret unless chosen otherwise", async () => {
167 const repo = repos["notes"]!;
168 const id = apps["notes"]!.id;
169 expect((await setAppEnvVar(app, alice, repo, "STRIPE_SECRET_KEY", "sk")).ok).toBe(true);
170 expect((await setAppEnvVar(app, alice, repo, "GREETING", "hello", "all", "variable")).ok).toBe(true);
171 expect((await setAppEnvVar(app, alice, repo, "PUBLIC_TOKEN_URL", "u", "all", "secret")).ok).toBe(true);
172 expect((await setAppEnvVar(app, alice, repo, "REGION_KEY", "eu", "all", "variable")).ok).toBe(true);
173 expect((await setAppEnvVar(app, alice, repo, "X", "y", "all", "nonsense")).ok).toBe(false);
174 const listed = listAppEnv(app, alice, repo);
175 expect(listed.ok && listed.value.map((v) => [v.name, v.kind, v.value])).toEqual([
176 ["GREETING", "variable", "hello"],
177 ["PUBLIC_TOKEN_URL", "secret", undefined],
178 ["REGION_KEY", "variable", "eu"],
179 ["STRIPE_SECRET_KEY", "secret", undefined],
180 ]);
181 expect(resolveAppEnv(app.db, dataDir, id, "production")).toEqual({
182 GREETING: "hello",
183 PUBLIC_TOKEN_URL: "u",
184 REGION_KEY: "eu",
185 STRIPE_SECRET_KEY: "sk",
186 });
187 // The variable is in the clear; the secret never is, and the rotation passes over the variable.
188 const stored = (name: string) =>
189 app.db.query<{ value_enc: string; value_plain: string | null }, [string]>("SELECT value_enc, value_plain FROM app_env WHERE name = ?1").get(name)!;
190 expect(stored("GREETING")).toEqual({ value_enc: "", value_plain: "hello" });
191 expect(stored("STRIPE_SECRET_KEY").value_plain).toBeNull();
192 // Setting a secret again as a variable converts it, and back.
193 expect((await setAppEnvVar(app, alice, repo, "STRIPE_SECRET_KEY", "pk", "production", "variable")).ok).toBe(true);
194 expect(stored("STRIPE_SECRET_KEY")).toEqual({ value_enc: "", value_plain: "pk" });
195 expect((await deleteAppEnvVar(app, alice, repo, "STRIPE_SECRET_KEY")).ok).toBe(true);
196 });
197
198 test("forge.json says what it needs", () => {
199 expect(neededNames('{"env":["A_TOKEN","B_KEY","A_TOKEN"]}')).toEqual(["A_TOKEN", "B_KEY"]);
200 expect(neededNames('{"env":"x"}')).toEqual([]);
201 expect(neededNames("not json")).toEqual([]);
202 expect(parseNeeds(["lower"]).ok).toBe(false);
203 expect(parseNeeds(["FORGE_TOKEN"]).ok).toBe(false);
204 const run = { app: { run: "bun run x.ts" } };
205 const ok = parseManifest(JSON.stringify({ ...run, env: ["SIMPLEFIN_TOKEN"] }), undefined);
206 expect(ok.ok && ok.value.env).toEqual(["SIMPLEFIN_TOKEN"]);
207 expect(parseManifest(JSON.stringify({ ...run, env: [1] }), undefined).ok).toBe(false);
208 });
209
210 test("a setting with no kind chosen is sealed, whatever its name, and says so back", async () => {
211 const url = "postgres://user:[email protected]/app";
212 const set = await setAppEnvVar(app, alice, repos["budget"]!, "DATABASE_URL", url);
213 expect(set.ok && set.value.kind).toBe("secret");
214 const plain = app.db.query<{ value_plain: string | null; value_enc: string }, [string]>("SELECT value_plain, value_enc FROM app_env WHERE app_id = ?1 AND name = 'DATABASE_URL'").get(apps["budget"]!.id)!;
215 expect(plain.value_plain).toBeNull();
216 expect(plain.value_enc).not.toContain("hunter2");
217 const listed = listAppEnv(app, alice, repos["budget"]!);
218 expect(JSON.stringify(listed)).not.toContain("hunter2");
219 // A variable is an explicit choice.
220 const greeting = await setAppEnvVar(app, alice, repos["budget"]!, "GREETING", "hello", "all", "variable");
221 expect(greeting.ok && greeting.value.kind).toBe("variable");
222 });
223
224 test("a variable's value is not in the events outbox, nor a secret's", async () => {
225 await setAppEnvVar(app, alice, repos["notes"]!, "SHOWN_SETTING", "plain-value-4242", "all", "variable");
226 await setAppEnvVar(app, alice, repos["notes"]!, "HIDDEN_SETTING", "sealed-value-4242");
227 const events = app.db.query<{ payload: string }, []>("SELECT payload FROM event").all().map((e) => e.payload).join("\n");
228 expect(events).toContain("SHOWN_SETTING");
229 expect(events).not.toContain("plain-value-4242");
230 expect(events).not.toContain("sealed-value-4242");
231 });
232 });
test/app-state-etag.test.ts +4 −4
@@ -121,17 +121,17 @@ test("starting, stopping and deploying a new commit each move the /app tag", asy
121121
122122 test("setting and removing a variable move the /env tag; the value never feeds it", async () => {
123123 const empty = await tagOf(ENV);
124 const set = await call("PUT", `${ENV}/GREETING`, adminTok, {}, { value: "secret-one" });
124 const set = await call("PUT", `${ENV}/GREETING_TOKEN`, adminTok, {}, { value: "hunter-one" });
125125 expect(set.status).toBe(200);
126126 await set.text();
127127 const added = await tagOf(ENV);
128128 expect(added).not.toBe(empty);
129129 expect(await status(ENV, added)).toBe(304);
130 const again = await call("PUT", `${ENV}/GREETING`, adminTok, {}, { value: "secret-two" });
130 const again = await call("PUT", `${ENV}/GREETING_TOKEN`, adminTok, {}, { value: "hunter-two" });
131131 await again.text();
132132 const body = await (await call("GET", ENV, adminTok)).text();
133 expect(body).not.toContain("secret");
134 const gone = await call("DELETE", `${ENV}/GREETING`, adminTok);
133 expect(body).not.toContain("hunter");
134 const gone = await call("DELETE", `${ENV}/GREETING_TOKEN`, adminTok);
135135 expect(gone.status).toBe(200);
136136 await gone.text();
137137 expect(await tagOf(ENV)).toBe(empty);
test/apps-api.test.ts +17 −17
@@ -559,7 +559,7 @@ printf '%s\\n' '{"is_error":false,"result":"{\\"summary\\":\\"Build it\\",\\"che
559559 ' hostname: "127.0.0.1",',
560560 " fetch(request) {",
561561 ' return new URL(request.url).pathname === "/env"',
562 ' ? new Response(process.env["GREETING"] ?? "unset")',
562 ' ? new Response(process.env["GREETING_TOKEN"] ?? "unset")',
563563 ' : new Response("ok");',
564564 " },",
565565 "});",
@@ -599,8 +599,8 @@ printf '%s\\n' '{"is_error":false,"result":"{\\"summary\\":\\"Build it\\",\\"che
599599 ).toEqual([]);
600600 expect((await api(env, { token: rw })).status).toBe(403);
601601 expect((await api(env, { token: null })).status).toBe(401);
602 expect((await put("GREETING", "x", rw)).status).toBe(403);
603 expect((await put("GREETING", "x", null)).status).toBe(401);
602 expect((await put("GREETING_TOKEN", "x", rw)).status).toBe(403);
603 expect((await put("GREETING_TOKEN", "x", null)).status).toBe(401);
604604 await post(
605605 "/api/v1/users",
606606 {
@@ -612,9 +612,9 @@ printf '%s\\n' '{"is_error":false,"result":"{\\"summary\\":\\"Build it\\",\\"che
612612 );
613613 const mallory = tokenFor(app, "mallory", "admin", "admin");
614614 expect((await api(env, { token: mallory })).status).toBe(404);
615 expect((await put("GREETING", "x", mallory)).status).toBe(404);
615 expect((await put("GREETING_TOKEN", "x", mallory)).status).toBe(404);
616616 expect(
617 (await api(`${env}/GREETING`, { method: "DELETE", token: mallory }))
617 (await api(`${env}/GREETING_TOKEN`, { method: "DELETE", token: mallory }))
618618 .status,
619619 ).toBe(404);
620620
@@ -629,19 +629,19 @@ printf '%s\\n' '{"is_error":false,"result":"{\\"summary\\":\\"Build it\\",\\"che
629629 expect(((await refused.json()) as { error: string }).error).toBe(error);
630630 }
631631 expect(
632 ((await (await put("GREETING", "")).json()) as { error: string }).error,
632 ((await (await put("GREETING_TOKEN", "")).json()) as { error: string }).error,
633633 ).toBe("invalid_value");
634634
635635 // Set: the answer, the listing, the row and the event carry the name
636636 // and never the value.
637637 const secret = "hello-from-the-operator-9f1c";
638 const set = await put("GREETING", secret);
638 const set = await put("GREETING_TOKEN", secret);
639639 expect(set.status).toBe(200);
640640 const answer = await set.text();
641 expect(answer).toContain('"name":"GREETING"');
641 expect(answer).toContain('"name":"GREETING_TOKEN"');
642642 expect(answer).not.toContain(secret);
643643 const listed = await (await api(env)).text();
644 expect(listed).toContain('"name":"GREETING"');
644 expect(listed).toContain('"name":"GREETING_TOKEN"');
645645 expect(listed).not.toContain(secret);
646646 const row = app.db
647647 .query<{ value_enc: string }, []>("SELECT value_enc FROM app_env")
@@ -655,12 +655,12 @@ printf '%s\\n' '{"is_error":false,"result":"{\\"summary\\":\\"Build it\\",\\"che
655655 >("SELECT payload FROM event WHERE kind = 'app.env_set' ORDER BY id DESC")
656656 .get();
657657 // The name and where it applies; never the value.
658 expect(event?.payload).toBe('{"name":"GREETING","scope":"production"}');
658 expect(event?.payload).toBe('{"name":"GREETING_TOKEN","scope":"production","kind":"secret"}');
659659 expect(answer).toContain('"scope":"production"');
660660
661661 // A scope is one of three words, and a preview-only value never
662662 // reaches production: the process that serves keeps the one for all.
663 const staging = await api(`${env}/GREETING`, {
663 const staging = await api(`${env}/GREETING_TOKEN`, {
664664 method: "PUT",
665665 body: JSON.stringify({ value: "x", scope: "staging" }),
666666 });
@@ -668,7 +668,7 @@ printf '%s\\n' '{"is_error":false,"result":"{\\"summary\\":\\"Build it\\",\\"che
668668 expect(((await staging.json()) as { error: string }).error).toBe(
669669 "invalid_scope",
670670 );
671 const forPreviews = await api(`${env}/GREETING`, {
671 const forPreviews = await api(`${env}/GREETING_TOKEN`, {
672672 method: "PUT",
673673 body: JSON.stringify({ value: "only-in-previews", scope: "preview" }),
674674 });
@@ -679,9 +679,9 @@ printf '%s\\n' '{"is_error":false,"result":"{\\"summary\\":\\"Build it\\",\\"che
679679 items: { name: string; scope: string }[];
680680 }
681681 ).items.map((i) => `${i.name}@${i.scope}`),
682 ).toEqual(["GREETING@production", "GREETING@preview"]);
682 ).toEqual(["GREETING_TOKEN@production", "GREETING_TOKEN@preview"]);
683683 expect(
684 (await api(`${env}/GREETING?scope=preview`, { method: "DELETE" })).status,
684 (await api(`${env}/GREETING_TOKEN?scope=preview`, { method: "DELETE" })).status,
685685 ).toBe(200);
686686
687687 // The redeploy that follows is what hands it to the app — and its log
@@ -696,12 +696,12 @@ printf '%s\\n' '{"is_error":false,"result":"{\\"summary\\":\\"Build it\\",\\"che
696696
697697 // Remove: admin only, exact, and the next process is without it.
698698 expect(
699 (await api(`${env}/GREETING`, { method: "DELETE", token: rw })).status,
699 (await api(`${env}/GREETING_TOKEN`, { method: "DELETE", token: rw })).status,
700700 ).toBe(403);
701 expect((await api(`${env}/GREETING`, { method: "DELETE" })).status).toBe(
701 expect((await api(`${env}/GREETING_TOKEN`, { method: "DELETE" })).status).toBe(
702702 200,
703703 );
704 const gone = await api(`${env}/GREETING`, { method: "DELETE" });
704 const gone = await api(`${env}/GREETING_TOKEN`, { method: "DELETE" });
705705 expect(gone.status).toBe(422);
706706 expect(((await gone.json()) as { error: string }).error).toBe(
707707 "no_such_var",
test/boundary-secrets-store.test.ts +27 −10
@@ -20,7 +20,7 @@ import { join } from "node:path";
2020 import { createApp, type App } from "../src/app.ts";
2121 import { deleteAppEnvVar, newApp, setAppEnvVar } from "../src/actions/apps.ts";
2222 import { createRepo } from "../src/actions/repos.ts";
23 import { putSecret, removeSecret } from "../src/actions/secrets.ts";
23 import { grantToRepo, putSecret, removeSecret } from "../src/actions/secrets.ts";
2424 import { deleteRepo } from "../src/core/repos.ts";
2525 import { grantSecrets, parseDeclaration } from "../src/crew/grant.ts";
2626 import { promote } from "./helpers/members.ts";
@@ -198,25 +198,25 @@ describe("both stores", () => {
198198 app,
199199 alice,
200200 repo,
201 "EVENTED_VAR",
201 "EVENTED_TOKEN",
202202 ENV_PLAIN,
203203 "preview",
204204 );
205205 expect(set.ok).toBe(true);
206206 const sealed = [
207207 sealedOf("secret", "EVENTED"),
208 sealedOf("app_env", "EVENTED_VAR"),
208 sealedOf("app_env", "EVENTED_TOKEN"),
209209 ];
210210 resolveSecrets(app.db, dataDir, app.bus, ["EVENTED"], {
211211 actor: alice.id,
212212 run: "run-2",
213213 });
214214 expect(resolveAppEnv(app.db, dataDir, record.id, "pull-1")).toMatchObject({
215 EVENTED_VAR: ENV_PLAIN,
215 EVENTED_TOKEN: ENV_PLAIN,
216216 });
217217 expect(removeSecret(app, alice, "forge", "", "EVENTED").ok).toBe(true);
218218 expect(
219 (await deleteAppEnvVar(app, alice, repo, "EVENTED_VAR", "preview")).ok,
219 (await deleteAppEnvVar(app, alice, repo, "EVENTED_TOKEN", "preview")).ok,
220220 ).toBe(true);
221221 const kinds = new Set(
222222 [...events("secret.%"), ...events("app.env%")].map((e) => e.kind),
@@ -260,10 +260,10 @@ describe("both stores", () => {
260260
261261 test("the data browser shows an admin neither table's sealed column, by row or by query, and anyone else no forge database", async () => {
262262 setSecret(app.db, dataDir, "forge", "", "BROWSED", SECRET_PLAIN);
263 setAppEnv(app.db, dataDir, record.id, "BROWSED_VAR", ENV_PLAIN);
263 setAppEnv(app.db, dataDir, record.id, "BROWSED_TOKEN", ENV_PLAIN);
264264 const sealed = [
265265 sealedOf("secret", "BROWSED"),
266 sealedOf("app_env", "BROWSED_VAR"),
266 sealedOf("app_env", "BROWSED_TOKEN"),
267267 ];
268268 expect(findSource(app.db, dataDir, bob, "forge")).toBeUndefined();
269269 expect(findSource(app.db, dataDir, undefined, "forge")).toBeUndefined();
@@ -274,7 +274,7 @@ describe("both stores", () => {
274274 ];
275275 // The names are listed: the browser reads the tables, and still shows no value.
276276 expect(seen[0]).toContain("BROWSED");
277 expect(seen[1]).toContain("BROWSED_VAR");
277 expect(seen[1]).toContain("BROWSED_TOKEN");
278278 for (const sql of [
279279 "SELECT * FROM secret",
280280 "SELECT value_enc AS v FROM secret",
@@ -345,8 +345,8 @@ describe("both stores", () => {
345345 // What does not hold today. The body is the protection as it should read:
346346 // `bun test --todo` runs it, and a pass says its gap is closed (crew pull #12184).
347347
348 test.todo(
349 "a run on a repository of someone who is not a forge admin is not handed the forge's own credentials: `grantSecrets` ends its ladder at the forge scope for every repository, so the mail, billing or backup key opens for any writer's issue that declares it",
348 test(
349 "a run on a repository of someone who is not a forge admin is not handed the forge's own credentials until the forge's owner grants that name to it",
350350 async () => {
351351 promote(app.db, bob);
352352 const theirs = await createRepo(app, bob, {
@@ -374,10 +374,27 @@ describe("both stores", () => {
374374 });
375375 try {
376376 expect(grant.names).toEqual([]);
377 expect(grant.ungranted).toEqual(["BACKUP_BUCKET_KEY"]);
377378 expect(Object.values(grant.env)).not.toContain(SECRET_PLAIN);
378379 } finally {
379380 grant.release();
380381 }
382 // The owner's one click, by repository, and the next run holds it.
383 expect(
384 grantToRepo(app, alice, "forge", "", "BACKUP_BUCKET_KEY", theirs.value.id).ok,
385 ).toBe(true);
386 const after = grantSecrets(app.db, dataDir, app.bus, declared, {
387 id: "run-of-a-member-2",
388 repoId: theirs.value.id,
389 ownerId: bob.id,
390 actorId: bob.id,
391 });
392 try {
393 expect(after.names).toEqual(["BACKUP_BUCKET_KEY"]);
394 expect(after.ungranted).toEqual([]);
395 } finally {
396 after.release();
397 }
381398 },
382399 );
383400 });
test/cloud-gate.test.ts +3
@@ -21,6 +21,7 @@ import { usedFor } from "../src/cloud/declared.ts";
2121 import { bar, gatedFetch, handOut } from "../src/cloud/gate.ts";
2222 import { PROVIDERS, VENDOR_OF } from "../src/cloud/providers.ts";
2323 import { CLOUD_VENDORS } from "../src/apps/manifest.ts";
24 import { grantSecret } from "../src/core/app_secrets.ts";
2425 import { grantSecrets, parseDeclaration } from "../src/crew/grant.ts";
2526 import { auditToday, pruneAudit, recordAudit } from "../src/core/cloud_audit.ts";
2627 import { listCloudAccounts } from "../src/core/cloud_account.ts";
@@ -153,6 +154,8 @@ describe("cloud gate", () => {
153154 const put = (name: string) => setSecret(app.db, dataDir, "forge", "", name, "sealed-value-1234567");
154155 put("CLOUD_AWS_X");
155156 put("CLOUD_GITHUB_Y");
157 // The forge's owner granted the key to this repository; a declaration alone opens nothing.
158 grantSecret(app.db, repoId, "forge", "", "CLOUD_AWS_X", alice.id);
156159 const asked = parseDeclaration("---\nsecrets: CLOUD_AWS_X, CLOUD_GITHUB_Y, PLAIN\n---");
157160 const s3 = [{ vendor: "s3", use: "backup", why: "off-machine copy" }] as const;
158161 const run = { id: "r6", repoId, ownerId: alice.id, actorId: alice.id };
test/cloud.test.ts +2
@@ -23,6 +23,7 @@ import { verify } from "../src/cloud/providers.ts";
2323 import { listCloudAccounts, recentCloudReads } from "../src/core/cloud_account.ts";
2424 import { listSecretNames } from "../src/core/secret_store.ts";
2525 import { createUser, type User } from "../src/core/users.ts";
26 import { grantSecret } from "../src/core/app_secrets.ts";
2627 import { grantSecrets, parseDeclaration } from "../src/crew/grant.ts";
2728 import { serve, type Servers } from "../src/http/server.ts";
2829 import { quietLogs } from "./helpers/globals.ts";
@@ -129,6 +130,7 @@ describe("cloud accounts", () => {
129130
130131 test("a run that needs writes is refused while the switch is off, and says so", () => {
131132 const run = { id: "r1", repoId, ownerId: alice.id, actorId: alice.id };
133 grantSecret(app.db, repoId, "forge", "", "CLOUD_DIGITALOCEAN_MAIN", alice.id);
132134 const reads = () => recentCloudReads(app.db).length;
133135 const before = reads();
134136 const refused = grantSecrets(app.db, dataDir, app.bus, parseDeclaration("---\nsecrets: CLOUD_DIGITALOCEAN_MAIN\nwrites: yes\n---"), run);
test/guest-pull.test.ts +23
@@ -22,6 +22,8 @@ import { local } from "../src/tui/screens/where.ts";
2222 import { headlessTerminal } from "../src/tui/term/terminal.ts";
2323 import { settleApp } from "./helpers/tui.ts";
2424 import { setAppEnv } from "../src/core/app_env.ts";
25 import { grantToRepo, putSecret } from "../src/actions/secrets.ts";
26 import { writeFileSync } from "node:fs";
2527 import { createApp as createAppRecord } from "../src/core/apps.ts";
2628 import { getPull, type Pull } from "../src/core/pulls.ts";
2729 import type { Repo } from "../src/core/repos.ts";
@@ -128,6 +130,27 @@ describe("a pull from another forge is a guest until a writer approves it", () =
128130 expect(held(guest)).toEqual({ vars: { API_KEY: "s3cret" }, forgeToken: "tok" });
129131 });
130132
133 test("a waiting guest's preview and its terminal receive neither a granted secret nor a variable; an approved one gets them", async () => {
134 const dir = await mkdtemp(join(tmpdir(), "forge-guest-dir-"));
135 writeFileSync(join(dir, "forge.json"), JSON.stringify({ app: { run: "x" }, env: ["GRANTED_TOKEN"] }));
136 const put = putSecret(app, alice, "forge", "", "GRANTED_TOKEN", "granted-value");
137 if (!put.ok) throw new Error(String(put.error));
138 expect(grantToRepo(app, alice, "forge", "", "GRANTED_TOKEN", lib.id).ok).toBe(true);
139 try {
140 const guest = await pull("head");
141 for (const start of [true, false]) {
142 const waiting = runtime.secretsFor(lib, appId, previewEnv(guest.number), "tok", dir, start);
143 expect(waiting).toEqual({ vars: {}, forgeToken: undefined });
144 }
145 expect(approveGuest(app.db, lib, guest, alice).ok).toBe(true);
146 const approved = runtime.secretsFor(lib, appId, previewEnv(guest.number), "tok", dir).vars;
147 expect(approved["GRANTED_TOKEN"]).toBe("granted-value");
148 expect(approved["API_KEY"]).toBe("s3cret");
149 } finally {
150 await rm(dir, { recursive: true, force: true });
151 }
152 });
153
131154 test("the approval is per pull: a later push by the guest keeps it", async () => {
132155 const guest = await pull("head");
133156 expect(approveGuest(app.db, lib, guest, alice).ok).toBe(true);
test/mcp-app-env.test.ts +2 −2
@@ -81,7 +81,7 @@ describe("MCP app_env", () => {
8181
8282 test("an admin sets a variable per scope, lists the names and unsets them", async () => {
8383 for (const scope of ["all", "production", "preview"]) {
84 const set = await tool(aliceAdmin, { repo: "alice/game", action: "set", name: "GREETING", value: SECRET, scope });
84 const set = await tool(aliceAdmin, { repo: "alice/game", action: "set", name: "GREETING_KEY", value: SECRET, scope });
8585 expect(set.value.scope).toBe(scope);
8686 expect(set.text).not.toContain(SECRET);
8787 }
@@ -90,7 +90,7 @@ describe("MCP app_env", () => {
9090 expect(listed.value.next_cursor).toBeNull();
9191 expect(listed.value.items.map((i: any) => i.scope).sort()).toEqual(["all", "preview", "production"]);
9292 for (const scope of ["all", "production", "preview"])
93 expect((await tool(aliceAdmin, { repo: "alice/game", action: "unset", name: "GREETING", scope })).value).toEqual({});
93 expect((await tool(aliceAdmin, { repo: "alice/game", action: "unset", name: "GREETING_KEY", scope })).value).toEqual({});
9494 expect((await tool(aliceAdmin, { repo: "alice/game", action: "list" })).value.items).toEqual([]);
9595 });
9696
test/run-secrets.test.ts +35 −1
@@ -10,7 +10,11 @@ import { join } from "node:path";
1010 import { createRepo } from "../src/actions/repos.ts";
1111 import { createUser } from "../src/core/users.ts";
1212 import { createApp, type App } from "../src/app.ts";
13 import { grantSecrets, parseDeclaration } from "../src/crew/grant.ts";
13 import { grantSecret } from "../src/core/app_secrets.ts";
14 import { getRepoById } from "../src/core/repos.ts";
15 import { getUserById } from "../src/core/users.ts";
16 import { createIssue, listComments } from "../src/core/issues.ts";
17 import { needsYou, grantSecrets, parseDeclaration } from "../src/crew/grant.ts";
1418 import { historyHolds, scrubTree } from "../src/crew/leak.ts";
1519 import { listHeldSecrets, releaseOrphans } from "../src/core/run_secret.ts";
1620 import { setSecret } from "../src/core/secret_store.ts";
@@ -66,6 +70,8 @@ describe("a grant", () => {
6670 run = { id: "run1", repoId: r.value.id, ownerId: r.value.ownerId, actorId: u.value.id };
6771 setSecret(app.db, dataDir, "forge", "", "CF_TOKEN", VALUE);
6872 setSecret(app.db, dataDir, "forge", "", "UNDECLARED", "other-value-1234");
73 // The forge's owner granted CF_TOKEN to this repository by name; UNDECLARED is not granted.
74 expect(grantSecret(app.db, r.value.id, "forge", "", "CF_TOKEN", u.value.id)).toBe(true);
6975 });
7076 afterAll(async () => {
7177 app.close();
@@ -120,6 +126,34 @@ describe("a grant", () => {
120126 expect(redactHeld(`plain ${VALUE}`)).toContain(VALUE);
121127 });
122128
129 test("a name the forge has and was not granted to this repository is asked, not opened", () => {
130 const d = parseDeclaration("---\nsecrets: UNDECLARED, NOWHERE\n---");
131 const grant = grantSecrets(app.db, dataDir, app.bus, d, { ...run, id: "run3" });
132 expect(grant.env).toEqual({});
133 expect(grant.ungranted).toEqual(["UNDECLARED"]);
134 expect(grant.missing).toEqual(["NOWHERE"]);
135 expect(listHeldSecrets(app.db, "run3")).toEqual([]);
136 const opened = app.db
137 .query<{ payload: string }, []>("SELECT payload FROM event WHERE kind = 'secret.read'")
138 .all()
139 .filter((e) => e.payload.includes("UNDECLARED"));
140 expect(opened).toEqual([]);
141 });
142
143 test("the run says once, on the issue, what it asked for and was not granted, with the link that grants it", async () => {
144 const repo = getRepoById(app.db, run.repoId)!;
145 const agent = getUserById(app.db, run.actorId)!;
146 const made = createIssue(app.db, { repoId: repo.id, authorId: agent.id, title: "tidy", body: "" });
147 if (!made.ok) throw new Error(made.error);
148 const issue = made.value;
149 needsYou(app, agent, repo, issue, "UNDECLARED");
150 needsYou(app, agent, repo, issue, "UNDECLARED");
151 const said = listComments(app.db, issue.id).map((c) => c.body);
152 expect(said).toEqual([
153 `this run asked for UNDECLARED; the forge's owner has not granted UNDECLARED to alice/site: [Grant it](/alice/site/settings/app#secrets)`,
154 ]);
155 });
156
123157 test("a crash's open grant is closed by the sweep", () => {
124158 const d = parseDeclaration("---\nsecrets: CF_TOKEN\n---");
125159 grantSecrets(app.db, dataDir, app.bus, d, { ...run, id: "run2" });
test/snapshot-drill.test.ts +1
@@ -106,6 +106,7 @@ describe("the restore drill", () => {
106106 "SECRET_WORD",
107107 "swordfish",
108108 "all",
109 "secret",
109110 );
110111 if (!set.ok) throw new Error(String(set.error));
111112 await takeSnapshot(
test/snapshot.test.ts +4 −4
@@ -106,7 +106,7 @@ describe("a forge back from a snapshot", () => {
106106 app,
107107 alice,
108108 repo.value,
109 "SECRET_WORD",
109 "SECRET_WORD_KEY",
110110 "swordfish",
111111 "all",
112112 );
@@ -224,7 +224,7 @@ describe("a forge back from a snapshot", () => {
224224 // The sealed variable opens with the restored key.
225225 const record = getAppByRepo(restored.db, repo.id)!;
226226 const env = resolveAppEnv(restored.db, back, record.id, "production");
227 expect(env["SECRET_WORD"]).toBe("swordfish");
227 expect(env["SECRET_WORD_KEY"]).toBe("swordfish");
228228 // The app's data came back, database rows included.
229229 const { Database } = await import("bun:sqlite");
230230 const appDb = new Database(
@@ -248,7 +248,7 @@ describe("a forge back from a snapshot", () => {
248248 const back = fresh("back-nokey");
249249 const done = await restoreSnapshot(store, NAME, back);
250250 expect(done.key).toBe("missing");
251 expect(done.sealedMissing).toEqual(["SECRET_WORD (app notes)"]);
251 expect(done.sealedMissing).toEqual(["SECRET_WORD_KEY (app notes)"]);
252252 expect(existsSync(join(back, "secret.key.wrapped"))).toBe(false);
253253 const restored = createApp({ dataDir: back, port: 0 });
254254 try {
@@ -258,7 +258,7 @@ describe("a forge back from a snapshot", () => {
258258 getRepo(restored.db, "alice", "notes")!.id,
259259 )!;
260260 const env = resolveAppEnv(restored.db, back, record.id, "production");
261 expect(env["SECRET_WORD"]).toBeUndefined();
261 expect(env["SECRET_WORD_KEY"]).toBeUndefined();
262262 } finally {
263263 restored.close();
264264 }
test/tui-app-env.test.ts +4 −4
@@ -96,7 +96,7 @@ afterAll(async () => {
9696
9797 describe("an app's variables, in the terminal", () => {
9898 test("an admin sets one: masked as it is typed, Enter on the first choice is production, then listed by name and scope", async () => {
99 const text = await drive(alice, [...open, "a", "STRIPE_KEY", "\r", SECRET, "\r", "\r"]);
99 const text = await drive(alice, [...open, "a", "STRIPE_KEY", "\r", SECRET, "\r", "\r", "\r"]);
100100 expect(vars().map((v) => [v.name, v.scope])).toEqual([["STRIPE_KEY", "production"]]);
101101 expect(text).toContain("STRIPE_KEY is set for production.");
102102 expect(text).toContain("STRIPE_KEY");
@@ -113,7 +113,7 @@ describe("an app's variables, in the terminal", () => {
113113 });
114114
115115 test("a bad name is refused in the page's words, and nothing is set", async () => {
116 const text = await drive(alice, [...open, "a", "lower", "\r", SECRET, "\r", "\r"]);
116 const text = await drive(alice, [...open, "a", "lower", "\r", SECRET, "\r", "\r", "\r"]);
117117 // The page's sentence wraps across the head; its start is on the screen.
118118 expect(text).toContain("A name is capital letters, digits and underscores");
119119 expect(APP_ENV_ERRORS["invalid_name"]).toStartWith("A name is capital letters, digits and underscores");
@@ -122,7 +122,7 @@ describe("an app's variables, in the terminal", () => {
122122
123123 test("a value too long is refused in the page's words", async () => {
124124 const long = `\x1b[200~${"x".repeat(64 * 1024 + 1)}\x1b[201~`;
125 const text = await drive(alice, [...open, "a", "TOO_LONG", "\r", long, "\r", "\r"]);
125 const text = await drive(alice, [...open, "a", "TOO_LONG", "\r", long, "\r", "\r", "\r"]);
126126 expect(text).toContain(APP_ENV_ERRORS["invalid_value"]!);
127127 expect(vars().map((v) => v.name)).toEqual(["STRIPE_KEY"]);
128128 });
@@ -141,7 +141,7 @@ describe("an app's variables, in the terminal", () => {
141141 test("a writer who is not an admin has no entry, and can set nothing", async () => {
142142 expect(await drive(bob, [":", "Variables"])).not.toContain("acme/shop — Variables");
143143 expect(await drive(alice, [":", "Variables"])).toContain("acme/shop — Variables");
144 await drive(bob, [...open, "a", "BOB_KEY", "\r", SECRET, "\r", "\r"]);
144 await drive(bob, [...open, "a", "BOB_KEY", "\r", SECRET, "\r", "\r", "\r"]);
145145 expect(vars()).toEqual([]);
146146 });
147147
test/tui-appenv.test.ts +2 −2
@@ -92,7 +92,7 @@ afterAll(async () => {
9292
9393 describe("the terminal's variables screen", () => {
9494 test("an admin adds a masked value: name and scope listed, the value nowhere", async () => {
95 const text = await drive(admin, [...open, "a", "API_TOKEN", "\r", SECRET, "\r", "\r"]);
95 const text = await drive(admin, [...open, "a", "API_TOKEN", "\r", SECRET, "\r", "\r", "\r"]);
9696 expect(vars().map((v) => [v.name, v.scope])).toEqual([["API_TOKEN", "production"]]);
9797 expect(text).toContain("API_TOKEN is set for production.");
9898 expect(text).toContain("production · set");
@@ -115,7 +115,7 @@ describe("the terminal's variables screen", () => {
115115 test("a non-admin is not offered the entry; a bad name is refused in MCP's words", async () => {
116116 expect(await drive(writer, [":", "Variables"])).not.toContain("— Variables");
117117 expect(await drive(admin, [":", "Variables"])).toContain("— Variables");
118 const text = await drive(admin, [...open, "a", "bad name", "\r", SECRET, "\r", "\r"]);
118 const text = await drive(admin, [...open, "a", "bad name", "\r", SECRET, "\r", "\r", "\r"]);
119119 expect(text).toContain(APP_ENV_ERRORS["invalid_name"]!.slice(0, 50));
120120 expect(vars()).toEqual([]);
121121 for (const f of frames) expect(f).not.toContain(SECRET);
test/tui-appsecrets.test.ts added +94
@@ -0,0 +1,94 @@
1 /**
2 * The terminal's "Secrets this app uses" (src/tui/screens/appsecrets.ts): the names forge.json
3 * asks for and where each comes from; a forge admin grants and revokes with `a` and `x`, a
4 * repository's admin who is not one cannot.
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 { newApp } from "../src/actions/apps.ts";
11 import { commitToBranch } from "../src/actions/repos.ts";
12 import { putSecret } from "../src/actions/secrets.ts";
13 import { createApp, type App } from "../src/app.ts";
14 import { listGrants } from "../src/core/app_secrets.ts";
15 import { getRepo, type Repo } from "../src/core/repos.ts";
16 import { createUser, type User } from "../src/core/users.ts";
17 import { createForgeApp } from "../src/tui/forge.ts";
18 import { repoScreen } from "../src/tui/screens/repo.ts";
19 import { local } from "../src/tui/screens/where.ts";
20 import { headlessTerminal } from "../src/tui/term/terminal.ts";
21 import { settleApp } from "./helpers/tui.ts";
22 import { proveAll } from "./helpers/verified.ts";
23
24 let dir: string;
25 let app: App;
26 let admin: User;
27 let shop: Repo;
28
29 async function drive(user: User, keys: string[]): Promise<string> {
30 const terminal = headlessTerminal(120, 40);
31 const { app: tui, session } = createForgeApp(
32 app,
33 { sshUsername: user.username, remoteAddress: "127.0.0.1", fingerprint: "SHA256:test", user },
34 { terminal, exitHooks: false },
35 );
36 tui.start();
37 tui.ctx.push(repoScreen(session, tui.ctx, local(session, shop)));
38 await settleApp(tui);
39 for (const k of keys) {
40 terminal.feed(k);
41 await settleApp(tui);
42 }
43 const text = tui.screen.toText();
44 tui.stop?.();
45 return text;
46 }
47
48 const open = [":", "Secrets this app", "\r"];
49
50 beforeAll(async () => {
51 dir = await mkdtemp(join(tmpdir(), "forge-tui-appsecrets-"));
52 app = createApp({ dataDir: dir, port: 0 });
53 const r = await createUser(app.db, {
54 origin: "seed", trusted: true, username: "alice", email: "[email protected]",
55 password: "a-good-passphrase", adminIfFirst: true,
56 });
57 if (!r.ok) throw new Error(r.error);
58 admin = r.value;
59 proveAll(app);
60 const made = await newApp(app, admin, { name: "shop", visibility: "public" });
61 if (!made.ok) throw new Error(String(made.error));
62 shop = getRepo(app.db, admin.username, "shop")!;
63 const c = await commitToBranch(app, admin, shop, {
64 message: "needs",
65 files: [{ path: "forge.json", content: JSON.stringify({ app: { run: "x" }, env: ["SHOP_TOKEN"] }) }],
66 });
67 if (!c.ok) throw new Error(JSON.stringify(c.error));
68 const put = putSecret(app, admin, "forge", "", "SHOP_TOKEN", "the-value-1");
69 if (!put.ok) throw new Error(String(put.error));
70 });
71 afterAll(async () => {
72 app.db.close();
73 await rm(dir, { recursive: true, force: true });
74 });
75
76 describe("the terminal's secrets screen", () => {
77 test("lists a name set higher up as not granted; g grants it, x takes it back; the value is never drawn", async () => {
78 const before = await drive(admin, open);
79 expect(before).toContain("SHOP_TOKEN");
80 expect(before).toContain("set higher up, not granted");
81 expect(before).not.toContain("the-value-1");
82
83 const granted = await drive(admin, [...open, "a"]);
84 expect(granted).toContain("SHOP_TOKEN is granted");
85 expect(listGrants(app.db, shop.id).map((g) => g.name)).toEqual(["SHOP_TOKEN"]);
86
87 const shown = await drive(admin, open);
88 expect(shown).toContain("granted from the forge");
89
90 const revoked = await drive(admin, [...open, "x"]);
91 expect(revoked).toContain("no longer granted");
92 expect(listGrants(app.db, shop.id)).toEqual([]);
93 });
94 });
test/tui-system.test.ts +1 −1
@@ -313,7 +313,7 @@ describe("tui system telemetry grant", () => {
313313 });
314314
315315 test("the System command passes the session, so the admin has the line and g", async () => {
316 const { app: tui, session } = createForgeApp(app, identity(carol), { terminal: headlessTerminal(120, 50), exitHooks: false });
316 const { app: tui, session } = createForgeApp(app, identity(carol), { terminal: headlessTerminal(120, 70), exitHooks: false });
317317 tui.start();
318318 const system = forgeCommands(session)(tui.ctx).find((c) => c.id === "system");
319319 system!.run();

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs