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
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/12180-one-place-for-a-secret-an-app-uses-a-sea → main 14 commits fork 1 thread · 1 unresolved
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 | ||
| 921 | 921 | `{ "value" }` body). A name is `ADMIN_PASSWORD`-shaped: capital letters, |
| 922 | 922 | digits and underscores; the names the forge sets itself (`PORT`, `HOST`, |
| 923 | 923 | `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. | |
| 928 | 930 | An app that prints one does not show it either: what an app writes to its |
| 929 | 931 | output is searched for the values it was handed (its variables, its identity |
| 930 | 932 | 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 | ||
| 950 | 952 | previews, the live one for production, and neither process ever holds the |
| 951 | 953 | other's. Every preview is one scope, whichever pull it serves. |
| 952 | 954 | |
| 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). | |
| 958 | ||
| 953 | 959 | ### Forge secrets |
| 954 | 960 | |
| 955 | 961 | 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: | ||
| 973 | 979 | secrets: [email protected], TF_BUCKET_KEY |
| 974 | 980 | --- |
| 975 | 981 | |
| 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 | |
| 977 | 990 | that would shadow the run's environment (`FORGE_*`, `GIT_*`, `PATH`, …) are |
| 978 | 991 | refused in the run's log. Values go into the run's env beside `FORGE_TOKEN`, |
| 979 | 992 | 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`; | ||
| 53 | 53 | | `app_data_import` | — | Load a SQL dump into an app's database and report per-table counts and checksums; admins only | 339 | |
| 54 | 54 | | `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 | |
| 55 | 55 | | `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 | | |
| 57 | 57 | | `app_members` | — | People a private app names, who may enter it and its previews | 297 | |
| 58 | 58 | | `app_notes` | `hide`, `ban`, `unban` | Notes from an app's forge button and banned senders; hide a note, ban or unban its sender | 306 | |
| 59 | 59 | | `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 | ||
| 6 | 6 | 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 |
| 7 | 7 | row's lead. Values are write-only: the value field is a password input that is never filled. `test/secrets-card.test.ts` |
| 8 | 8 | 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> = { | ||
| 30 | 30 | "That name is the forge's own (PORT, HOST, PATH, FORGE_…); the app is already told it.", |
| 31 | 31 | invalid_value: "A value is one line or many, never empty and under 64 KiB.", |
| 32 | 32 | 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).", | |
| 33 | 34 | }; |
| 34 | 35 | |
| 35 | 36 | /** `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."; | ||
| 37 | 38 | |
| 38 | 39 | /** `deleteAppEnvVar` refused as `no_such_var`. */ |
| 39 | 40 | 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 { | ||
| 49 | 49 | import { |
| 50 | 50 | DEFAULT_ENV_SCOPE, |
| 51 | 51 | deleteAppEnv, |
| 52 | isReservedAppEnvName, | |
| 52 | 53 | isAppEnvScope, |
| 53 | 54 | listAppEnvNames, |
| 54 | 55 | setAppEnv, |
| @@ -56,6 +57,7 @@ import { | ||
| 56 | 57 | type AppEnvName, |
| 57 | 58 | type AppEnvScope, |
| 58 | 59 | } from "../core/app_env.ts"; |
| 60 | import { isSecretName, VALUE_MAX } from "../core/secret_store.ts"; | |
| 59 | 61 | import { addCrew, getAgentByName } from "../core/agents.ts"; |
| 60 | 62 | import { listAllAgents } from "./crew.ts"; |
| 61 | 63 | import { plan, planIssueBody, planningAvailable } from "../crew/plan.ts"; |
| @@ -170,30 +172,32 @@ export async function setAppEnvVar( | ||
| 170 | 172 | name: string, |
| 171 | 173 | value: string, |
| 172 | 174 | scope: string = DEFAULT_ENV_SCOPE, |
| 175 | /** | |
| 176 | * `variable` or `secret`; left out, a secret. | |
| 177 | */ | |
| 178 | kind?: string, | |
| 173 | 179 | ): Promise<Result<AppEnvName, "not_found" | AppEnvError>> { |
| 174 | 180 | const admin = requireRepoAdmin(app, actor, repo); |
| 175 | 181 | if (!admin.ok) return admin; |
| 176 | 182 | const record = getAppByRepo(app.db, repo.id); |
| 177 | 183 | 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)); | |
| 186 | 185 | if (!set.ok) return set; |
| 187 | 186 | app.bus.append({ |
| 188 | 187 | kind: "app.env_set", |
| 189 | 188 | repoId: repo.id, |
| 190 | 189 | actorId: admin.value.id, |
| 191 | payload: { name, scope: set.value.scope }, | |
| 190 | payload: { name, scope: set.value.scope, kind: set.value.kind }, | |
| 192 | 191 | }); |
| 193 | 192 | await redeployScope(app, record, repo, set.value.scope); |
| 194 | 193 | return set; |
| 195 | 194 | } |
| 196 | 195 | |
| 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 | ||
| 197 | 201 | /** Remove a variable from a scope and redeploy the environments that read it. */ |
| 198 | 202 | export async function deleteAppEnvVar( |
| 199 | 203 | app: App, |
| @@ -277,6 +281,11 @@ export interface NewAppInput { | ||
| 277 | 281 | pickAgent?: boolean; |
| 278 | 282 | /** `owner/name` of a repository the actor can read: its tip is copied instead of the starter app. */ |
| 279 | 283 | 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 }[]; | |
| 280 | 289 | } |
| 281 | 290 | |
| 282 | 291 | export interface NewAppResult { |
| @@ -295,7 +304,8 @@ export type NewAppError = | ||
| 295 | 304 | | TemplateError |
| 296 | 305 | | "no_such_agent" |
| 297 | 306 | | "not_a_builder" |
| 298 | | "app_limit"; | |
| 307 | | "app_limit" | |
| 308 | | "invalid_secret"; | |
| 299 | 309 | |
| 300 | 310 | /** |
| 301 | 311 | * The golden path's first step, as one call: a repository with a working |
| @@ -320,6 +330,12 @@ export async function newApp( | ||
| 320 | 330 | if (input.agent && !agent) return err("no_such_agent"); |
| 321 | 331 | if (agent && !agent.roles.includes("builder")) return err("not_a_builder"); |
| 322 | 332 | |
| 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 | ||
| 323 | 339 | // Refuse at the limit before a repository exists to take back. |
| 324 | 340 | // The owner is resolved with the actor's rights, as createRepo does, so |
| 325 | 341 | // the count of an account they may not create under is never told. |
| @@ -384,6 +400,16 @@ export async function newApp( | ||
| 384 | 400 | return err(record.error === "not_found" ? "scaffold_failed" : record.error); |
| 385 | 401 | } |
| 386 | 402 | |
| 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 | ||
| 387 | 413 | const description = (input.description ?? "").trim(); |
| 388 | 414 | let chosen = agent; |
| 389 | 415 | let body = description; |
| @@ -436,6 +462,8 @@ export async function newApp( | ||
| 436 | 462 | } |
| 437 | 463 | // The sentence is the one thing the person wrote: it opens the first |
| 438 | 464 | // 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(", ")}.`; | |
| 439 | 467 | if (body !== "") { |
| 440 | 468 | const opened = openIssue(app, user.value, repo.value, { |
| 441 | 469 | title: `Build ${input.name}`, |
| @@ -479,6 +507,8 @@ export const NEW_APP_ERRORS: Record<string, string> = { | ||
| 479 | 507 | git_init_failed: REPO_INIT_FAILED, |
| 480 | 508 | invalid_slug: |
| 481 | 509 | "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.", | |
| 482 | 512 | slug_taken: |
| 483 | 513 | "Another app already answers to that hostname. Pick another name.", |
| 484 | 514 | already_app: "That repository is already an app.", |
src/actions/secrets.ts +113 −1
| @@ -8,8 +8,16 @@ | ||
| 8 | 8 | */ |
| 9 | 9 | |
| 10 | 10 | import type { App } from "../app.ts"; |
| 11 | import { createLog } from "../kernel/log.ts"; | |
| 11 | 12 | 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"; | |
| 13 | 21 | import { |
| 14 | 22 | deleteSecret, |
| 15 | 23 | isSecretScope, |
| @@ -51,6 +59,8 @@ function admin(actor: User | undefined): Result<User, "not_found"> { | ||
| 51 | 59 | return actor?.isAdmin ? ok(actor) : err("not_found"); |
| 52 | 60 | } |
| 53 | 61 | |
| 62 | const log = createLog("secrets"); | |
| 63 | ||
| 54 | 64 | export function listSecrets( |
| 55 | 65 | app: App, |
| 56 | 66 | actor: User | undefined, |
| @@ -91,9 +101,90 @@ export function putSecret( | ||
| 91 | 101 | actorId: who.value.id, |
| 92 | 102 | payload: { name, scope: target.value.scope }, |
| 93 | 103 | }); |
| 104 | restartUsers(app, target.value.scope, target.value.scopeId, name); | |
| 94 | 105 | return set; |
| 95 | 106 | } |
| 96 | 107 | |
| 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 | ||
| 97 | 188 | export function removeSecret( |
| 98 | 189 | app: App, |
| 99 | 190 | actor: User | undefined, |
| @@ -115,3 +206,24 @@ export function removeSecret( | ||
| 115 | 206 | }); |
| 116 | 207 | return ok(); |
| 117 | 208 | } |
| 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 @@ | ||
| 10 | 10 | * simply not an app yet, which the surfaces say in those words. |
| 11 | 11 | */ |
| 12 | 12 | |
| 13 | import { parseNeeds } from "../core/app_secrets.ts"; | |
| 13 | 14 | import { isIP } from "node:net"; |
| 14 | 15 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 15 | 16 | import { parseService, type ServiceSpec } from "./service.ts"; |
| @@ -79,6 +80,11 @@ export interface AppManifest { | ||
| 79 | 80 | * out of the app's HTML pages. Unset is on. |
| 80 | 81 | */ |
| 81 | 82 | 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; | |
| 82 | 88 | /** |
| 83 | 89 | * `adapter` (top level of `forge.json`): the app is pulled from on a |
| 84 | 90 | * schedule (`apps/adapter.ts`). Unset is not an adapter. |
| @@ -186,6 +192,8 @@ export function parseManifest( | ||
| 186 | 192 | if (!egress.ok) return egress; |
| 187 | 193 | const limits = limitsField(app, "app"); |
| 188 | 194 | if (!limits.ok) return limits; |
| 195 | const needs = parseNeeds(parsed.value["env"]); | |
| 196 | if (!needs.ok) return invalid(needs.error); | |
| 189 | 197 | const toolbar = parsed.value["toolbar"]; |
| 190 | 198 | if (toolbar !== undefined && typeof toolbar !== "boolean") |
| 191 | 199 | return invalid("forge.json: toolbar must be true or false"); |
| @@ -235,6 +243,7 @@ export function parseManifest( | ||
| 235 | 243 | egress: egress.value, |
| 236 | 244 | limits: limits.value, |
| 237 | 245 | toolbar: toolbar ?? true, |
| 246 | ...(needs.value.length === 0 ? {} : { env: needs.value }), | |
| 238 | 247 | ...(adapter.value === undefined ? {} : { adapter: adapter.value }), |
| 239 | 248 | ...(toolchain.value === undefined ? {} : { toolchain: toolchain.value }), |
| 240 | 249 | ...(schedules.value.length === 0 ? {} : { schedules: schedules.value }), |
src/apps/runtime.ts +24 −1
| @@ -30,6 +30,7 @@ import { join } from "node:path"; | ||
| 30 | 30 | import type { App } from "../app.ts"; |
| 31 | 31 | import { getAppByRepo, listApps, type AppRecord } from "../core/apps.ts"; |
| 32 | 32 | import { resolveAppEnv } from "../core/app_env.ts"; |
| 33 | import { neededNames, resolveAppSecrets } from "../core/app_secrets.ts"; | |
| 33 | 34 | import { featureVarsAt } from "./feature_env.ts"; |
| 34 | 35 | import { getVariant, listVariants, variantOfBranch } from "../core/variants.ts"; |
| 35 | 36 | import { readVariantValues } from "./variant_file.ts"; |
| @@ -1878,6 +1879,27 @@ export class AppsRuntime { | ||
| 1878 | 1879 | return featureVarsAt(this.app.db, this.app.config.dataDir, appId, forgeJson); |
| 1879 | 1880 | } |
| 1880 | 1881 | |
| 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 | ||
| 1881 | 1903 | /** |
| 1882 | 1904 | * What a process for `env` starts holding: the operator's variables, read |
| 1883 | 1905 | * at start, and the forge token. A preview of a guest pull nobody has |
| @@ -1891,6 +1913,7 @@ export class AppsRuntime { | ||
| 1891 | 1913 | env: EnvName, |
| 1892 | 1914 | forgeToken: string | undefined, |
| 1893 | 1915 | dir?: string, |
| 1916 | start = true, | |
| 1894 | 1917 | ): { vars: Record<string, string>; forgeToken: string | undefined } { |
| 1895 | 1918 | const number = envPull(env); |
| 1896 | 1919 | const pull = |
| @@ -1901,7 +1924,7 @@ export class AppsRuntime { | ||
| 1901 | 1924 | // app's defaults; they are plain settings, never secrets. |
| 1902 | 1925 | const own = dir !== undefined && envVariant(env) !== undefined ? readVariantValues(dir) : {}; |
| 1903 | 1926 | 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 }, | |
| 1905 | 1928 | forgeToken, |
| 1906 | 1929 | }; |
| 1907 | 1930 | } |
src/apps/tty.ts +1 −1
| @@ -179,7 +179,7 @@ export async function runAppTty( | ||
| 179 | 179 | // same secrets, so it is handed the same environment, by the one reader |
| 180 | 180 | // the app's own process has. A guest pull's preview nobody has approved |
| 181 | 181 | // 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, | |
| 183 | 183 | // An absorbed app's settings, as its process was started with. |
| 184 | 184 | feature: runtime.featureFor(repo, target.app.id, target.env, live.dir), |
| 185 | 185 | }); |
src/core/app_env.ts +44 −8
| @@ -41,10 +41,24 @@ export function scopeOfEnv(env: string): Exclude<AppEnvScope, "all"> { | ||
| 41 | 41 | return env === "production" ? "production" : "preview"; |
| 42 | 42 | } |
| 43 | 43 | |
| 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 | ||
| 44 | 55 | export interface AppEnvName { |
| 45 | 56 | readonly name: string; |
| 46 | 57 | readonly scope: AppEnvScope; |
| 47 | 58 | readonly updatedAt: string; |
| 59 | readonly kind: AppEnvKind; | |
| 60 | /** A variable's value; a secret has none to show. */ | |
| 61 | readonly value?: string; | |
| 48 | 62 | } |
| 49 | 63 | |
| 50 | 64 | /** `ADMIN_PASSWORD`, `STRIPE_SECRET_KEY`: the shell's own rule for a name. */ |
| @@ -73,7 +87,8 @@ export type AppEnvError = | ||
| 73 | 87 | | "invalid_name" |
| 74 | 88 | | "reserved_name" |
| 75 | 89 | | "invalid_value" |
| 76 | | "invalid_scope"; | |
| 90 | | "invalid_scope" | |
| 91 | | "invalid_kind"; | |
| 77 | 92 | |
| 78 | 93 | export function isAppEnvName(name: string): boolean { |
| 79 | 94 | return name.length > 0 && name.length <= NAME_MAX && NAME.test(name); |
| @@ -86,8 +101,11 @@ export function isReservedAppEnvName(name: string): boolean { | ||
| 86 | 101 | /** The names an app has set, alphabetical, widest scope first — never a value. */ |
| 87 | 102 | export function listAppEnvNames(db: Database, appId: string): AppEnvName[] { |
| 88 | 103 | 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 | |
| 91 | 109 | ORDER BY name, CASE scope WHEN 'all' THEN 0 WHEN 'production' THEN 1 ELSE 2 END`, |
| 92 | 110 | ) |
| 93 | 111 | .all(appId) |
| @@ -95,6 +113,8 @@ export function listAppEnvNames(db: Database, appId: string): AppEnvName[] { | ||
| 95 | 113 | name: r.name, |
| 96 | 114 | scope: isAppEnvScope(r.scope) ? r.scope : "all", |
| 97 | 115 | updatedAt: r.updated_at, |
| 116 | kind: r.kind === "variable" ? "variable" : "secret", | |
| 117 | ...(r.kind === "variable" ? { value: r.value_plain ?? "" } : {}), | |
| 98 | 118 | })); |
| 99 | 119 | } |
| 100 | 120 | |
| @@ -106,26 +126,38 @@ export function setAppEnv( | ||
| 106 | 126 | name: string, |
| 107 | 127 | value: string, |
| 108 | 128 | scope: string = "all", |
| 129 | kind: string = "secret", | |
| 109 | 130 | ): Result<AppEnvName, AppEnvError> { |
| 131 | if (!isAppEnvKind(kind)) return err("invalid_kind"); | |
| 110 | 132 | if (!isAppEnvName(name)) return err("invalid_name"); |
| 111 | 133 | if (isReservedAppEnvName(name)) return err("reserved_name"); |
| 112 | 134 | if (!isAppEnvScope(scope)) return err("invalid_scope"); |
| 113 | 135 | // Empty would silently blank a secret; a NUL cannot cross execve. |
| 114 | 136 | if (value === "" || value.length > VALUE_MAX || value.includes("\0")) |
| 115 | 137 | return err("invalid_value"); |
| 138 | const sealed = kind === "secret"; | |
| 116 | 139 | 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) | |
| 118 | 141 | ON CONFLICT(app_id, name, scope) DO UPDATE SET |
| 119 | 142 | value_enc = excluded.value_enc, |
| 143 | kind = excluded.kind, | |
| 144 | value_plain = excluded.value_plain, | |
| 120 | 145 | 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 | ); | |
| 122 | 154 | const row = db |
| 123 | 155 | .query< |
| 124 | 156 | { updated_at: string }, |
| 125 | 157 | [string, string, string] |
| 126 | 158 | >("SELECT updated_at FROM app_env WHERE app_id = ?1 AND name = ?2 AND scope = ?3") |
| 127 | 159 | .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 }) }); | |
| 129 | 161 | } |
| 130 | 162 | |
| 131 | 163 | /** Remove a variable from one scope; the others keep theirs. */ |
| @@ -161,15 +193,19 @@ export function resolveAppEnv( | ||
| 161 | 193 | const own = scopeOfEnv(env); |
| 162 | 194 | const rows = db |
| 163 | 195 | .query< |
| 164 | { name: string; scope: string; value_enc: string }, | |
| 196 | { name: string; scope: string; value_enc: string; kind: string; value_plain: string | null }, | |
| 165 | 197 | [string, string] |
| 166 | 198 | >( |
| 167 | `SELECT name, scope, value_enc FROM app_env | |
| 199 | `SELECT name, scope, value_enc, kind, value_plain FROM app_env | |
| 168 | 200 | WHERE app_id = ?1 AND scope IN ('all', ?2) |
| 169 | 201 | ORDER BY name, CASE scope WHEN 'all' THEN 0 ELSE 1 END`, |
| 170 | 202 | ) |
| 171 | 203 | .all(appId, own); |
| 172 | 204 | for (const row of rows) { |
| 205 | if (row.kind === "variable") { | |
| 206 | out[row.name] = row.value_plain ?? ""; | |
| 207 | continue; | |
| 208 | } | |
| 173 | 209 | const opened = open(dataDir, row.value_enc, aad(appId, row.name)); |
| 174 | 210 | if (opened.ok) out[row.name] = opened.value; |
| 175 | 211 | 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 { | ||
| 138 | 138 | readonly run?: string | undefined; |
| 139 | 139 | /** Open without a `secret.read` event or a `last_read_at` stamp; the caller audits what it chooses to. */ |
| 140 | 140 | 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; | |
| 141 | 143 | } |
| 142 | 144 | |
| 143 | 145 | /** |
| @@ -174,6 +176,11 @@ export function resolveScoped( | ||
| 174 | 176 | if (who.repo) ladder.push(["repo", who.repo]); |
| 175 | 177 | if (who.org) ladder.push(["org", who.org]); |
| 176 | 178 | 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 | } | |
| 177 | 184 | |
| 178 | 185 | for (const name of names) { |
| 179 | 186 | if (!isSecretName(name) || name in out) continue; |
src/crew/grant.ts +70 −13
| @@ -10,14 +10,24 @@ | ||
| 10 | 10 | * reach exactly those hosts, for that run only. Whatever is not declared is |
| 11 | 11 | * not resolved, whatever is malformed or would shadow the run's own |
| 12 | 12 | * 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. | |
| 14 | 18 | */ |
| 15 | 19 | |
| 16 | 20 | 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"; | |
| 17 | 26 | import type { EventBus } from "../core/events.ts"; |
| 27 | import { listGrants } from "../core/app_secrets.ts"; | |
| 18 | 28 | import { recordGrant, releaseGrant } from "../core/run_secret.ts"; |
| 19 | 29 | 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"; | |
| 21 | 31 | import { holdValues, releaseValues } from "../kernel/log.ts"; |
| 22 | 32 | |
| 23 | 33 | export interface Declaration { |
| @@ -87,8 +97,10 @@ export interface Grant { | ||
| 87 | 97 | readonly names: readonly string[]; |
| 88 | 98 | /** Cloud credentials withheld because the run needs writes and they are not write allowed. */ |
| 89 | 99 | 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. */ | |
| 91 | 101 | readonly missing: readonly string[]; |
| 102 | /** Declared, set at the organization or the forge, and not granted to this repository. */ | |
| 103 | readonly ungranted: readonly string[]; | |
| 92 | 104 | /** Extra egress hosts for this run only. */ |
| 93 | 105 | readonly hosts: readonly string[]; |
| 94 | 106 | /** Drop the grant: values forgotten, record closed. Safe to call twice. */ |
| @@ -107,15 +119,32 @@ export function grantSecrets( | ||
| 107 | 119 | ? declared.names.filter((n) => writeRefused(db, n)) |
| 108 | 120 | : []; |
| 109 | 121 | 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 | ); | |
| 119 | 148 | const names = Object.keys(found); |
| 120 | 149 | const env = Object.fromEntries(names.map((n) => [n, found[n]!.value])); |
| 121 | 150 | // Held before anything can log: a value is redacted from the first line. |
| @@ -131,7 +160,8 @@ export function grantSecrets( | ||
| 131 | 160 | env, |
| 132 | 161 | names, |
| 133 | 162 | 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)), | |
| 135 | 165 | // Only a secret that was actually granted widens the egress. |
| 136 | 166 | hosts: [...new Set(names.flatMap((n) => declared.hostOf[n] ?? []))], |
| 137 | 167 | release: () => { |
| @@ -140,3 +170,30 @@ export function grantSecrets( | ||
| 140 | 170 | }, |
| 141 | 171 | }; |
| 142 | 172 | } |
| 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 { | ||
| 181 | 181 | import { trackAgentRun, untrackAgentRun } from "./running.ts"; |
| 182 | 182 | import { installCacheEnv, sandboxPolicy } from "./sandbox.ts"; |
| 183 | 183 | 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"; | |
| 185 | 185 | import { historyHolds, scrubTree } from "./leak.ts"; |
| 186 | 186 | import { |
| 187 | 187 | installCacheDir, |
| @@ -1152,7 +1152,12 @@ async function work( | ||
| 1152 | 1152 | } |
| 1153 | 1153 | if (grant.names.length > 0) |
| 1154 | 1154 | 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 | } | |
| 1156 | 1161 | for (const host of grant.hosts) write(`egress: +${host} for this run`); |
| 1157 | 1162 | const bare = repoDir(app.reposRoot, repo.ownerName, repo.name); |
| 1158 | 1163 | const forkBare = repoDir( |
src/http/api/app.ts +1
| @@ -258,6 +258,7 @@ export function appRoutes(router: Router<Handler>): void { | ||
| 258 | 258 | ctx.params.var ?? "", |
| 259 | 259 | str(input.value), |
| 260 | 260 | str(input.scope) || DEFAULT_ENV_SCOPE, |
| 261 | str(input.kind), | |
| 261 | 262 | ); |
| 262 | 263 | if (!set.ok) return respondAccess(set.error, 422); |
| 263 | 264 | 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"; | ||
| 5 | 5 | |
| 6 | 6 | export const tool: Tool = { |
| 7 | 7 | 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", | |
| 9 | 9 | 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.", | |
| 11 | 11 | inputSchema: { |
| 12 | 12 | type: "object", |
| 13 | 13 | properties: { |
| @@ -16,6 +16,7 @@ export const tool: Tool = { | ||
| 16 | 16 | name: { type: "string" }, |
| 17 | 17 | value: { type: "string" }, |
| 18 | 18 | scope: { type: "string", enum: ["all", "production", "preview"] }, |
| 19 | kind: { type: "string", enum: ["variable", "secret"] }, | |
| 19 | 20 | }, |
| 20 | 21 | required: ["repo", "action"], |
| 21 | 22 | }, |
| @@ -39,7 +40,7 @@ export const tool: Tool = { | ||
| 39 | 40 | } |
| 40 | 41 | if (action === "set") { |
| 41 | 42 | 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); | |
| 43 | 44 | return set.ok ? good(set.value) : refused(set.error); |
| 44 | 45 | } |
| 45 | 46 | if (action === "unset") { |
src/http/server.ts +3
| @@ -5,6 +5,7 @@ | ||
| 5 | 5 | * verdict. Auth is resolved once per request and handed to handlers as data. |
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | import { appSecretGrant, appSecretRevoke } from "../web/pages/app_secrets.ts"; | |
| 8 | 9 | import { apiRoot } from "./api_root.ts"; |
| 9 | 10 | import { isTokenScheme, tokenSecret } from "./token_header.ts"; |
| 10 | 11 | import { |
| @@ -1624,6 +1625,8 @@ export function buildRouter(): Router<Handler> { | ||
| 1624 | 1625 | router.add("POST", "/:owner/:name/settings/app/disable", appDisable); |
| 1625 | 1626 | router.add("POST", "/:owner/:name/settings/app/members", appMembersUpdate); |
| 1626 | 1627 | 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); | |
| 1627 | 1630 | router.add( |
| 1628 | 1631 | "POST", |
| 1629 | 1632 | "/: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> { | ||
| 265 | 265 | ? undefined |
| 266 | 266 | : db |
| 267 | 267 | .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", | |
| 269 | 269 | ) |
| 270 | 270 | .get(); |
| 271 | 271 | // 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[]> { | ||
| 96 | 96 | .map((r) => `${r.name} (${r.scope} secret)`); |
| 97 | 97 | const env = db |
| 98 | 98 | .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", | |
| 100 | 100 | ) |
| 101 | 101 | .all() |
| 102 | 102 | .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 | ||
| 45 | 45 | { |
| 46 | 46 | id: "app-env", |
| 47 | 47 | 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", | |
| 49 | 49 | run: () => ctx.push(appEnvScreen(f, ctx, repo)), |
| 50 | 50 | }, |
| 51 | 51 | ]; |
| @@ -90,14 +90,24 @@ export function appEnvScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 90 | 90 | title: `Where ${name} goes`, |
| 91 | 91 | items: SCOPE_CHOICES.map((c) => ({ id: c.scope, label: c.label })), |
| 92 | 92 | 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 | ), | |
| 101 | 111 | }), |
| 102 | 112 | ), |
| 103 | 113 | }), |
| @@ -122,7 +132,9 @@ export function appEnvScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 122 | 132 | const lines = (): ListRow[] => |
| 123 | 133 | rows.map((v) => ({ |
| 124 | 134 | 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 | }`, | |
| 126 | 138 | })); |
| 127 | 139 | |
| 128 | 140 | const screen: Screen = { |
| @@ -142,7 +154,7 @@ export function appEnvScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 142 | 154 | ? "An app's variables are its admin's." |
| 143 | 155 | : rows.length === 0 |
| 144 | 156 | ? `${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.`; | |
| 146 | 158 | // A refusal is the page's sentence, whole: it wraps rather than cuts. |
| 147 | 159 | const said = wrap(text, Math.max(1, area.w)).slice(0, 3); |
| 148 | 160 | 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"; | ||
| 166 | 166 | import { iterationsCommands } from "./iterations.ts"; |
| 167 | 167 | import { labelsCommands } from "./labels.ts"; |
| 168 | 168 | import { appEnvCommands } from "./appenv.ts"; |
| 169 | import { appSecretCommands } from "./appsecrets.ts"; | |
| 169 | 170 | import { appBackupCommands } from "./appbackup.ts"; |
| 170 | 171 | import { variantsCommands } from "./variants.ts"; |
| 171 | 172 | import { autonomyCommands } from "./autonomy.ts"; |
| @@ -2000,6 +2001,7 @@ export function workspaceScreen( | ||
| 2000 | 2001 | ...iterationsCommands(f, ctx, repo), |
| 2001 | 2002 | ...displayCommands(f, ctx, repo), |
| 2002 | 2003 | ...appEnvCommands(f, ctx, repo), |
| 2004 | ...appSecretCommands(f, ctx, repo), | |
| 2003 | 2005 | ...appBackupCommands(f, ctx, repo), |
| 2004 | 2006 | ...variantsCommands(f, ctx, repo), |
| 2005 | 2007 | ...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 | |
| 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`, | |
| 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( | ||
| 443 | 443 | })), |
| 444 | 444 | ], |
| 445 | 445 | })} |
| 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> | |
| 446 | 455 | <div class="form-actions"> |
| 447 | 456 | ${btn("Create app", { variant: "primary", icon: "rocket" })} |
| 448 | 457 | ${btn("Cancel", { href: "/apps", variant: "ghost" })} |
| @@ -491,6 +500,10 @@ export async function newAppSubmit(ctx: Ctx): Promise<Response> { | ||
| 491 | 500 | agent: field(form, "agent"), |
| 492 | 501 | template: field(form, "template"), |
| 493 | 502 | }; |
| 503 | const secrets = [1, 2, 3].map((n) => ({ | |
| 504 | name: field(form, `secret_name_${n}`).trim(), | |
| 505 | value: field(form, `secret_value_${n}`), | |
| 506 | })); | |
| 494 | 507 | const pickAgent = values.agent === PICK_AGENT; |
| 495 | 508 | // An explicit name is never second-guessed; left blank, the sentence |
| 496 | 509 | // names the app — and the server derives that name itself (never trusting |
| @@ -499,6 +512,7 @@ export async function newAppSubmit(ctx: Ctx): Promise<Response> { | ||
| 499 | 512 | const rest = { |
| 500 | 513 | owner: values.owner, |
| 501 | 514 | visibility: values.visibility === "public" ? "public" : "private", |
| 515 | secrets, | |
| 502 | 516 | ...(values.template ? { template: values.template } : {}), |
| 503 | 517 | ...(pickAgent |
| 504 | 518 | ? { pickAgent: true } |
src/web/pages/crew-settings.ts +1 −1
| @@ -14,7 +14,7 @@ import type { Ctx } from "../../http/context.ts"; | ||
| 14 | 14 | import { loadRepo, repoBase, repoNotFound } from "../chrome.ts"; |
| 15 | 15 | import { field } from "../form.ts"; |
| 16 | 16 | import { flashTo, redirect } from "../layout.ts"; |
| 17 | import { adminForm } from "./settings.ts"; | |
| 17 | import { adminForm } from "../admin_form.ts"; | |
| 18 | 18 | import { saveGrants } from "./subscriptions.ts"; |
| 19 | 19 | |
| 20 | 20 | // GET /:owner/:name/settings/crew |
src/web/pages/danger.ts +2 −1
| @@ -15,7 +15,8 @@ import { html } from "../html.ts"; | ||
| 15 | 15 | import { flashTo } from "../layout.ts"; |
| 16 | 16 | import { confirmAction } from "../ui.ts"; |
| 17 | 17 | 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"; | |
| 19 | 20 | |
| 20 | 21 | export function dangerPage(ctx: Ctx): Response { |
| 21 | 22 | const repo = timedAdminRepo(ctx); |
src/web/pages/detach.ts +1 −1
| @@ -18,7 +18,7 @@ import { icon } from "../icons.ts"; | ||
| 18 | 18 | import { flashTo } from "../layout.ts"; |
| 19 | 19 | import { confirmAction, timeAgo } from "../ui.ts"; |
| 20 | 20 | import type { Database } from "bun:sqlite"; |
| 21 | import { adminForm } from "./settings.ts"; | |
| 21 | import { adminForm } from "../admin_form.ts"; | |
| 22 | 22 | |
| 23 | 23 | /** What the detach section shows: plain data, read by `detachSection`. */ |
| 24 | 24 | export interface DetachView { |
src/web/pages/file_holds.ts +1 −1
| @@ -14,7 +14,7 @@ import { err, type Result } from "../../kernel/result.ts"; | ||
| 14 | 14 | import { repoBase } from "../chrome.ts"; |
| 15 | 15 | import { field, type Form } from "../form.ts"; |
| 16 | 16 | import { flashTo } from "../layout.ts"; |
| 17 | import { adminForm } from "./settings.ts"; | |
| 17 | import { adminForm } from "../admin_form.ts"; | |
| 18 | 18 | |
| 19 | 19 | export async function fileHoldUpdate(ctx: Ctx): Promise<Response> { |
| 20 | 20 | const gate = await adminForm(ctx); |
src/web/pages/hooks.ts +2 −1
| @@ -25,7 +25,8 @@ import { | ||
| 25 | 25 | row, |
| 26 | 26 | time, |
| 27 | 27 | } 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"; | |
| 29 | 30 | |
| 30 | 31 | /** |
| 31 | 32 | * 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"; | ||
| 15 | 15 | import { html, type Html } from "../html.ts"; |
| 16 | 16 | import { flashTo } from "../layout.ts"; |
| 17 | 17 | 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"; | |
| 19 | 20 | import { BIND, countAttr } from "../bind.ts"; |
| 20 | 21 | |
| 21 | 22 | /** `<input type="color">` submits `#rrggbb`; the store keeps six hex digits. */ |
src/web/pages/prompts.ts +2 −1
| @@ -29,7 +29,8 @@ import { | ||
| 29 | 29 | time, |
| 30 | 30 | timeAgo, |
| 31 | 31 | } 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"; | |
| 33 | 34 | |
| 34 | 35 | const PROMPT_ERRORS: Record<string, string> = { |
| 35 | 36 | invalid_at: "The time is hours and minutes in UTC, like 15:00.", |
src/web/pages/secrets.ts +24 −3
| @@ -7,7 +7,8 @@ | ||
| 7 | 7 | * when something last opened it. |
| 8 | 8 | */ |
| 9 | 9 | |
| 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"; | |
| 11 | 12 | import type { Ctx } from "../../http/context.ts"; |
| 12 | 13 | import { |
| 13 | 14 | notePhase, |
| @@ -45,11 +46,27 @@ function backTo(scope: string, ref: string): string { | ||
| 45 | 46 | return "/settings/secrets"; |
| 46 | 47 | } |
| 47 | 48 | |
| 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 | ||
| 48 | 64 | /** The card: names, never values, and a form to set one. */ |
| 49 | 65 | export function secretsCard( |
| 50 | 66 | scope: SecretScope, |
| 51 | 67 | ref: string, |
| 52 | 68 | names: SecretName[], |
| 69 | uses: ReadonlyMap<string, readonly SecretUse[]> = new Map(), | |
| 53 | 70 | ): Html { |
| 54 | 71 | const rows = names.map((s) => |
| 55 | 72 | row({ |
| @@ -59,7 +76,7 @@ export function secretsCard( | ||
| 59 | 76 | s.lastReadAt === null |
| 60 | 77 | ? "never used" |
| 61 | 78 | : html`last used ${time(s.lastReadAt)}` |
| 62 | }`, | |
| 79 | }${usedBy(uses.get(s.name) ?? [])}`, | |
| 63 | 80 | trail: html`<form |
| 64 | 81 | class="row-actions" |
| 65 | 82 | method="post" |
| @@ -122,7 +139,11 @@ export function secretsSection( | ||
| 122 | 139 | ref: string, |
| 123 | 140 | ): Html | null { |
| 124 | 141 | 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); | |
| 126 | 147 | } |
| 127 | 148 | |
| 128 | 149 | function requireAdmin(ctx: Ctx): Response | undefined { |
src/web/pages/settings.ts +21 −24
| @@ -16,6 +16,7 @@ import { | ||
| 16 | 16 | deleteAppEnvVar, |
| 17 | 17 | disableApp, |
| 18 | 18 | enableApp, |
| 19 | listAppEnv, | |
| 19 | 20 | setAppEnvVar, |
| 20 | 21 | updateAppSettings, |
| 21 | 22 | } from "../../actions/apps.ts"; |
| @@ -43,7 +44,6 @@ import { blockedNote } from "../studio/blocked.ts"; | ||
| 43 | 44 | import type { DnsRecord } from "../../apps/domains.ts"; |
| 44 | 45 | import { |
| 45 | 46 | DEFAULT_ENV_SCOPE, |
| 46 | listAppEnvNames, | |
| 47 | 47 | NAME_MAX, |
| 48 | 48 | type AppEnvName, |
| 49 | 49 | } from "../../core/app_env.ts"; |
| @@ -72,7 +72,7 @@ import { | ||
| 72 | 72 | readAppMembers, |
| 73 | 73 | setAppMembersThrough, |
| 74 | 74 | } from "../../actions/app_members.ts"; |
| 75 | import { canAdmin, type Repo } from "../../core/repos.ts"; | |
| 75 | import type { Repo } from "../../core/repos.ts"; | |
| 76 | 76 | import { stopMirroring } from "../../actions/mirror_target.ts"; |
| 77 | 77 | import { getMirrorTarget, mirrorLine } from "../../core/migrate_mirror.ts"; |
| 78 | 78 | import { listRefs } from "../../git/read.ts"; |
| @@ -87,11 +87,15 @@ import { | ||
| 87 | 87 | repoPalette, |
| 88 | 88 | settingsLayout, |
| 89 | 89 | } from "../chrome.ts"; |
| 90 | import { adminForm, loadAdminRepo } from "../admin_form.ts"; | |
| 90 | 91 | import { daySpark } from "../studio/kpi_row.ts"; |
| 91 | 92 | import { settingsLead, teamJs } from "../studio/crew.ts"; |
| 92 | 93 | import { pageTitle } from "../studio/nouns.ts"; |
| 93 | 94 | 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"; | |
| 95 | 99 | import { html, raw, when, type Html } from "../html.ts"; |
| 96 | 100 | import { settingLine, toggleLine } from "../setting_line.ts"; |
| 97 | 101 | import { NOT_CHANGED } from "../edit_words.ts"; |
| @@ -128,14 +132,6 @@ type Section = | ||
| 128 | 132 | | "machines" |
| 129 | 133 | | "danger"; |
| 130 | 134 | |
| 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 | ||
| 139 | 135 | /** `loadAdminRepo` for a GET page: names the session, then the repo read as `repo`. */ |
| 140 | 136 | export function timedAdminRepo(ctx: Ctx): Repo | undefined { |
| 141 | 137 | nameFrame(ctx.request); |
| @@ -215,17 +211,6 @@ export function settingsPageResponse( | ||
| 215 | 211 | ); |
| 216 | 212 | } |
| 217 | 213 | |
| 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 | ||
| 229 | 214 | // ---- general ---- |
| 230 | 215 | |
| 231 | 216 | /** |
| @@ -663,6 +648,7 @@ function appSettingsView( | ||
| 663 | 648 | error?: string, |
| 664 | 649 | egress?: EgressView, |
| 665 | 650 | blocked?: BlockedView, |
| 651 | needs?: { needs: readonly NeedRow[]; grantable: { name: string; scope: "forge" | "org" }[] }, | |
| 666 | 652 | ): Html { |
| 667 | 653 | const base = repoBase(repo); |
| 668 | 654 | const runtime = appsRuntime(ctx.app); |
| @@ -777,8 +763,9 @@ function appSettingsView( | ||
| 777 | 763 | <div class="mt">${appVisitorsView(vitals)}</div> |
| 778 | 764 | <div class="mt">${statement}</div> |
| 779 | 765 | <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 : []; })())} | |
| 781 | 767 | </div> |
| 768 | ${when(needs !== undefined, () => html`<div class="mt">${appSecretsView(base, needs!.needs, ctx.auth.user?.isAdmin === true, needs!.grantable)}</div>`)} | |
| 782 | 769 | <div class="mt"> |
| 783 | 770 | ${appDomainsView(base, domains.ok ? domains.value : undefined)} |
| 784 | 771 | </div> |
| @@ -842,7 +829,9 @@ export function appEnvView(base: string, names: AppEnvName[]): Html { | ||
| 842 | 829 | row({ |
| 843 | 830 | lead: icon("key"), |
| 844 | 831 | 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 | }`, | |
| 846 | 835 | trail: html`<form |
| 847 | 836 | class="row-actions" |
| 848 | 837 | method="post" |
| @@ -888,6 +877,10 @@ export function appEnvView(base: string, names: AppEnvName[]): Html { | ||
| 888 | 877 | autocomplete="off" |
| 889 | 878 | required |
| 890 | 879 | /> |
| 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> | |
| 891 | 884 | <select class="input" name="scope" aria-label="Where it applies"> |
| 892 | 885 | <option value="production">Production only</option> |
| 893 | 886 | <option value="all">Also in previews</option> |
| @@ -895,6 +888,7 @@ export function appEnvView(base: string, names: AppEnvName[]): Html { | ||
| 895 | 888 | </select> |
| 896 | 889 | ${btn("Set", { variant: "primary", size: "sm", icon: "plus" })} |
| 897 | 890 | </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> | |
| 898 | 892 | </section>`; |
| 899 | 893 | } |
| 900 | 894 | |
| @@ -1067,6 +1061,7 @@ export async function appSettingsPage(ctx: Ctx): Promise<Response> { | ||
| 1067 | 1061 | : await egressView(ctx.app, ctx.auth.user, repo); |
| 1068 | 1062 | const blockedBase = record === undefined ? undefined : blockedView(ctx.app, ctx.auth.user, repo); |
| 1069 | 1063 | 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); | |
| 1070 | 1065 | const view = appSettingsView( |
| 1071 | 1066 | ctx, |
| 1072 | 1067 | repo, |
| @@ -1075,6 +1070,7 @@ export async function appSettingsPage(ctx: Ctx): Promise<Response> { | ||
| 1075 | 1070 | undefined, |
| 1076 | 1071 | egress?.ok ? egress.value : undefined, |
| 1077 | 1072 | blocked?.ok ? blocked.value : undefined, |
| 1073 | needs?.ok ? needs.value : undefined, | |
| 1078 | 1074 | ); |
| 1079 | 1075 | const rendering = performance.now(); |
| 1080 | 1076 | const response = settingsPageResponse(ctx, repo, "app", "App", view); |
| @@ -1197,6 +1193,7 @@ export async function appEnvSet(ctx: Ctx): Promise<Response> { | ||
| 1197 | 1193 | name, |
| 1198 | 1194 | field(form, "value"), |
| 1199 | 1195 | field(form, "scope") || DEFAULT_ENV_SCOPE, |
| 1196 | field(form, "kind"), | |
| 1200 | 1197 | ); |
| 1201 | 1198 | if (!set.ok && set.error === "not_found") return repoNotFound(ctx); |
| 1202 | 1199 | 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 | ||
| 121 | 121 | |
| 122 | 122 | test("setting and removing a variable move the /env tag; the value never feeds it", async () => { |
| 123 | 123 | 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" }); | |
| 125 | 125 | expect(set.status).toBe(200); |
| 126 | 126 | await set.text(); |
| 127 | 127 | const added = await tagOf(ENV); |
| 128 | 128 | expect(added).not.toBe(empty); |
| 129 | 129 | 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" }); | |
| 131 | 131 | await again.text(); |
| 132 | 132 | 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); | |
| 135 | 135 | expect(gone.status).toBe(200); |
| 136 | 136 | await gone.text(); |
| 137 | 137 | 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 | ||
| 559 | 559 | ' hostname: "127.0.0.1",', |
| 560 | 560 | " fetch(request) {", |
| 561 | 561 | ' return new URL(request.url).pathname === "/env"', |
| 562 | ' ? new Response(process.env["GREETING"] ?? "unset")', | |
| 562 | ' ? new Response(process.env["GREETING_TOKEN"] ?? "unset")', | |
| 563 | 563 | ' : new Response("ok");', |
| 564 | 564 | " },", |
| 565 | 565 | "});", |
| @@ -599,8 +599,8 @@ printf '%s\\n' '{"is_error":false,"result":"{\\"summary\\":\\"Build it\\",\\"che | ||
| 599 | 599 | ).toEqual([]); |
| 600 | 600 | expect((await api(env, { token: rw })).status).toBe(403); |
| 601 | 601 | 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); | |
| 604 | 604 | await post( |
| 605 | 605 | "/api/v1/users", |
| 606 | 606 | { |
| @@ -612,9 +612,9 @@ printf '%s\\n' '{"is_error":false,"result":"{\\"summary\\":\\"Build it\\",\\"che | ||
| 612 | 612 | ); |
| 613 | 613 | const mallory = tokenFor(app, "mallory", "admin", "admin"); |
| 614 | 614 | 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); | |
| 616 | 616 | expect( |
| 617 | (await api(`${env}/GREETING`, { method: "DELETE", token: mallory })) | |
| 617 | (await api(`${env}/GREETING_TOKEN`, { method: "DELETE", token: mallory })) | |
| 618 | 618 | .status, |
| 619 | 619 | ).toBe(404); |
| 620 | 620 | |
| @@ -629,19 +629,19 @@ printf '%s\\n' '{"is_error":false,"result":"{\\"summary\\":\\"Build it\\",\\"che | ||
| 629 | 629 | expect(((await refused.json()) as { error: string }).error).toBe(error); |
| 630 | 630 | } |
| 631 | 631 | expect( |
| 632 | ((await (await put("GREETING", "")).json()) as { error: string }).error, | |
| 632 | ((await (await put("GREETING_TOKEN", "")).json()) as { error: string }).error, | |
| 633 | 633 | ).toBe("invalid_value"); |
| 634 | 634 | |
| 635 | 635 | // Set: the answer, the listing, the row and the event carry the name |
| 636 | 636 | // and never the value. |
| 637 | 637 | const secret = "hello-from-the-operator-9f1c"; |
| 638 | const set = await put("GREETING", secret); | |
| 638 | const set = await put("GREETING_TOKEN", secret); | |
| 639 | 639 | expect(set.status).toBe(200); |
| 640 | 640 | const answer = await set.text(); |
| 641 | expect(answer).toContain('"name":"GREETING"'); | |
| 641 | expect(answer).toContain('"name":"GREETING_TOKEN"'); | |
| 642 | 642 | expect(answer).not.toContain(secret); |
| 643 | 643 | const listed = await (await api(env)).text(); |
| 644 | expect(listed).toContain('"name":"GREETING"'); | |
| 644 | expect(listed).toContain('"name":"GREETING_TOKEN"'); | |
| 645 | 645 | expect(listed).not.toContain(secret); |
| 646 | 646 | const row = app.db |
| 647 | 647 | .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 | ||
| 655 | 655 | >("SELECT payload FROM event WHERE kind = 'app.env_set' ORDER BY id DESC") |
| 656 | 656 | .get(); |
| 657 | 657 | // 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"}'); | |
| 659 | 659 | expect(answer).toContain('"scope":"production"'); |
| 660 | 660 | |
| 661 | 661 | // A scope is one of three words, and a preview-only value never |
| 662 | 662 | // 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`, { | |
| 664 | 664 | method: "PUT", |
| 665 | 665 | body: JSON.stringify({ value: "x", scope: "staging" }), |
| 666 | 666 | }); |
| @@ -668,7 +668,7 @@ printf '%s\\n' '{"is_error":false,"result":"{\\"summary\\":\\"Build it\\",\\"che | ||
| 668 | 668 | expect(((await staging.json()) as { error: string }).error).toBe( |
| 669 | 669 | "invalid_scope", |
| 670 | 670 | ); |
| 671 | const forPreviews = await api(`${env}/GREETING`, { | |
| 671 | const forPreviews = await api(`${env}/GREETING_TOKEN`, { | |
| 672 | 672 | method: "PUT", |
| 673 | 673 | body: JSON.stringify({ value: "only-in-previews", scope: "preview" }), |
| 674 | 674 | }); |
| @@ -679,9 +679,9 @@ printf '%s\\n' '{"is_error":false,"result":"{\\"summary\\":\\"Build it\\",\\"che | ||
| 679 | 679 | items: { name: string; scope: string }[]; |
| 680 | 680 | } |
| 681 | 681 | ).items.map((i) => `${i.name}@${i.scope}`), |
| 682 | ).toEqual(["GREETING@production", "GREETING@preview"]); | |
| 682 | ).toEqual(["GREETING_TOKEN@production", "GREETING_TOKEN@preview"]); | |
| 683 | 683 | expect( |
| 684 | (await api(`${env}/GREETING?scope=preview`, { method: "DELETE" })).status, | |
| 684 | (await api(`${env}/GREETING_TOKEN?scope=preview`, { method: "DELETE" })).status, | |
| 685 | 685 | ).toBe(200); |
| 686 | 686 | |
| 687 | 687 | // 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 | ||
| 696 | 696 | |
| 697 | 697 | // Remove: admin only, exact, and the next process is without it. |
| 698 | 698 | expect( |
| 699 | (await api(`${env}/GREETING`, { method: "DELETE", token: rw })).status, | |
| 699 | (await api(`${env}/GREETING_TOKEN`, { method: "DELETE", token: rw })).status, | |
| 700 | 700 | ).toBe(403); |
| 701 | expect((await api(`${env}/GREETING`, { method: "DELETE" })).status).toBe( | |
| 701 | expect((await api(`${env}/GREETING_TOKEN`, { method: "DELETE" })).status).toBe( | |
| 702 | 702 | 200, |
| 703 | 703 | ); |
| 704 | const gone = await api(`${env}/GREETING`, { method: "DELETE" }); | |
| 704 | const gone = await api(`${env}/GREETING_TOKEN`, { method: "DELETE" }); | |
| 705 | 705 | expect(gone.status).toBe(422); |
| 706 | 706 | expect(((await gone.json()) as { error: string }).error).toBe( |
| 707 | 707 | "no_such_var", |
test/boundary-secrets-store.test.ts +27 −10
| @@ -20,7 +20,7 @@ import { join } from "node:path"; | ||
| 20 | 20 | import { createApp, type App } from "../src/app.ts"; |
| 21 | 21 | import { deleteAppEnvVar, newApp, setAppEnvVar } from "../src/actions/apps.ts"; |
| 22 | 22 | 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"; | |
| 24 | 24 | import { deleteRepo } from "../src/core/repos.ts"; |
| 25 | 25 | import { grantSecrets, parseDeclaration } from "../src/crew/grant.ts"; |
| 26 | 26 | import { promote } from "./helpers/members.ts"; |
| @@ -198,25 +198,25 @@ describe("both stores", () => { | ||
| 198 | 198 | app, |
| 199 | 199 | alice, |
| 200 | 200 | repo, |
| 201 | "EVENTED_VAR", | |
| 201 | "EVENTED_TOKEN", | |
| 202 | 202 | ENV_PLAIN, |
| 203 | 203 | "preview", |
| 204 | 204 | ); |
| 205 | 205 | expect(set.ok).toBe(true); |
| 206 | 206 | const sealed = [ |
| 207 | 207 | sealedOf("secret", "EVENTED"), |
| 208 | sealedOf("app_env", "EVENTED_VAR"), | |
| 208 | sealedOf("app_env", "EVENTED_TOKEN"), | |
| 209 | 209 | ]; |
| 210 | 210 | resolveSecrets(app.db, dataDir, app.bus, ["EVENTED"], { |
| 211 | 211 | actor: alice.id, |
| 212 | 212 | run: "run-2", |
| 213 | 213 | }); |
| 214 | 214 | expect(resolveAppEnv(app.db, dataDir, record.id, "pull-1")).toMatchObject({ |
| 215 | EVENTED_VAR: ENV_PLAIN, | |
| 215 | EVENTED_TOKEN: ENV_PLAIN, | |
| 216 | 216 | }); |
| 217 | 217 | expect(removeSecret(app, alice, "forge", "", "EVENTED").ok).toBe(true); |
| 218 | 218 | expect( |
| 219 | (await deleteAppEnvVar(app, alice, repo, "EVENTED_VAR", "preview")).ok, | |
| 219 | (await deleteAppEnvVar(app, alice, repo, "EVENTED_TOKEN", "preview")).ok, | |
| 220 | 220 | ).toBe(true); |
| 221 | 221 | const kinds = new Set( |
| 222 | 222 | [...events("secret.%"), ...events("app.env%")].map((e) => e.kind), |
| @@ -260,10 +260,10 @@ describe("both stores", () => { | ||
| 260 | 260 | |
| 261 | 261 | test("the data browser shows an admin neither table's sealed column, by row or by query, and anyone else no forge database", async () => { |
| 262 | 262 | 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); | |
| 264 | 264 | const sealed = [ |
| 265 | 265 | sealedOf("secret", "BROWSED"), |
| 266 | sealedOf("app_env", "BROWSED_VAR"), | |
| 266 | sealedOf("app_env", "BROWSED_TOKEN"), | |
| 267 | 267 | ]; |
| 268 | 268 | expect(findSource(app.db, dataDir, bob, "forge")).toBeUndefined(); |
| 269 | 269 | expect(findSource(app.db, dataDir, undefined, "forge")).toBeUndefined(); |
| @@ -274,7 +274,7 @@ describe("both stores", () => { | ||
| 274 | 274 | ]; |
| 275 | 275 | // The names are listed: the browser reads the tables, and still shows no value. |
| 276 | 276 | expect(seen[0]).toContain("BROWSED"); |
| 277 | expect(seen[1]).toContain("BROWSED_VAR"); | |
| 277 | expect(seen[1]).toContain("BROWSED_TOKEN"); | |
| 278 | 278 | for (const sql of [ |
| 279 | 279 | "SELECT * FROM secret", |
| 280 | 280 | "SELECT value_enc AS v FROM secret", |
| @@ -345,8 +345,8 @@ describe("both stores", () => { | ||
| 345 | 345 | // What does not hold today. The body is the protection as it should read: |
| 346 | 346 | // `bun test --todo` runs it, and a pass says its gap is closed (crew pull #12184). |
| 347 | 347 | |
| 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", | |
| 350 | 350 | async () => { |
| 351 | 351 | promote(app.db, bob); |
| 352 | 352 | const theirs = await createRepo(app, bob, { |
| @@ -374,10 +374,27 @@ describe("both stores", () => { | ||
| 374 | 374 | }); |
| 375 | 375 | try { |
| 376 | 376 | expect(grant.names).toEqual([]); |
| 377 | expect(grant.ungranted).toEqual(["BACKUP_BUCKET_KEY"]); | |
| 377 | 378 | expect(Object.values(grant.env)).not.toContain(SECRET_PLAIN); |
| 378 | 379 | } finally { |
| 379 | 380 | grant.release(); |
| 380 | 381 | } |
| 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 | } | |
| 381 | 398 | }, |
| 382 | 399 | ); |
| 383 | 400 | }); |
test/cloud-gate.test.ts +3
| @@ -21,6 +21,7 @@ import { usedFor } from "../src/cloud/declared.ts"; | ||
| 21 | 21 | import { bar, gatedFetch, handOut } from "../src/cloud/gate.ts"; |
| 22 | 22 | import { PROVIDERS, VENDOR_OF } from "../src/cloud/providers.ts"; |
| 23 | 23 | import { CLOUD_VENDORS } from "../src/apps/manifest.ts"; |
| 24 | import { grantSecret } from "../src/core/app_secrets.ts"; | |
| 24 | 25 | import { grantSecrets, parseDeclaration } from "../src/crew/grant.ts"; |
| 25 | 26 | import { auditToday, pruneAudit, recordAudit } from "../src/core/cloud_audit.ts"; |
| 26 | 27 | import { listCloudAccounts } from "../src/core/cloud_account.ts"; |
| @@ -153,6 +154,8 @@ describe("cloud gate", () => { | ||
| 153 | 154 | const put = (name: string) => setSecret(app.db, dataDir, "forge", "", name, "sealed-value-1234567"); |
| 154 | 155 | put("CLOUD_AWS_X"); |
| 155 | 156 | 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); | |
| 156 | 159 | const asked = parseDeclaration("---\nsecrets: CLOUD_AWS_X, CLOUD_GITHUB_Y, PLAIN\n---"); |
| 157 | 160 | const s3 = [{ vendor: "s3", use: "backup", why: "off-machine copy" }] as const; |
| 158 | 161 | 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"; | ||
| 23 | 23 | import { listCloudAccounts, recentCloudReads } from "../src/core/cloud_account.ts"; |
| 24 | 24 | import { listSecretNames } from "../src/core/secret_store.ts"; |
| 25 | 25 | import { createUser, type User } from "../src/core/users.ts"; |
| 26 | import { grantSecret } from "../src/core/app_secrets.ts"; | |
| 26 | 27 | import { grantSecrets, parseDeclaration } from "../src/crew/grant.ts"; |
| 27 | 28 | import { serve, type Servers } from "../src/http/server.ts"; |
| 28 | 29 | import { quietLogs } from "./helpers/globals.ts"; |
| @@ -129,6 +130,7 @@ describe("cloud accounts", () => { | ||
| 129 | 130 | |
| 130 | 131 | test("a run that needs writes is refused while the switch is off, and says so", () => { |
| 131 | 132 | const run = { id: "r1", repoId, ownerId: alice.id, actorId: alice.id }; |
| 133 | grantSecret(app.db, repoId, "forge", "", "CLOUD_DIGITALOCEAN_MAIN", alice.id); | |
| 132 | 134 | const reads = () => recentCloudReads(app.db).length; |
| 133 | 135 | const before = reads(); |
| 134 | 136 | 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"; | ||
| 22 | 22 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; |
| 23 | 23 | import { settleApp } from "./helpers/tui.ts"; |
| 24 | 24 | import { setAppEnv } from "../src/core/app_env.ts"; |
| 25 | import { grantToRepo, putSecret } from "../src/actions/secrets.ts"; | |
| 26 | import { writeFileSync } from "node:fs"; | |
| 25 | 27 | import { createApp as createAppRecord } from "../src/core/apps.ts"; |
| 26 | 28 | import { getPull, type Pull } from "../src/core/pulls.ts"; |
| 27 | 29 | 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", () = | ||
| 128 | 130 | expect(held(guest)).toEqual({ vars: { API_KEY: "s3cret" }, forgeToken: "tok" }); |
| 129 | 131 | }); |
| 130 | 132 | |
| 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 | ||
| 131 | 154 | test("the approval is per pull: a later push by the guest keeps it", async () => { |
| 132 | 155 | const guest = await pull("head"); |
| 133 | 156 | 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", () => { | ||
| 81 | 81 | |
| 82 | 82 | test("an admin sets a variable per scope, lists the names and unsets them", async () => { |
| 83 | 83 | 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 }); | |
| 85 | 85 | expect(set.value.scope).toBe(scope); |
| 86 | 86 | expect(set.text).not.toContain(SECRET); |
| 87 | 87 | } |
| @@ -90,7 +90,7 @@ describe("MCP app_env", () => { | ||
| 90 | 90 | expect(listed.value.next_cursor).toBeNull(); |
| 91 | 91 | expect(listed.value.items.map((i: any) => i.scope).sort()).toEqual(["all", "preview", "production"]); |
| 92 | 92 | 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({}); | |
| 94 | 94 | expect((await tool(aliceAdmin, { repo: "alice/game", action: "list" })).value.items).toEqual([]); |
| 95 | 95 | }); |
| 96 | 96 |
test/run-secrets.test.ts +35 −1
| @@ -10,7 +10,11 @@ import { join } from "node:path"; | ||
| 10 | 10 | import { createRepo } from "../src/actions/repos.ts"; |
| 11 | 11 | import { createUser } from "../src/core/users.ts"; |
| 12 | 12 | 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"; | |
| 14 | 18 | import { historyHolds, scrubTree } from "../src/crew/leak.ts"; |
| 15 | 19 | import { listHeldSecrets, releaseOrphans } from "../src/core/run_secret.ts"; |
| 16 | 20 | import { setSecret } from "../src/core/secret_store.ts"; |
| @@ -66,6 +70,8 @@ describe("a grant", () => { | ||
| 66 | 70 | run = { id: "run1", repoId: r.value.id, ownerId: r.value.ownerId, actorId: u.value.id }; |
| 67 | 71 | setSecret(app.db, dataDir, "forge", "", "CF_TOKEN", VALUE); |
| 68 | 72 | 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); | |
| 69 | 75 | }); |
| 70 | 76 | afterAll(async () => { |
| 71 | 77 | app.close(); |
| @@ -120,6 +126,34 @@ describe("a grant", () => { | ||
| 120 | 126 | expect(redactHeld(`plain ${VALUE}`)).toContain(VALUE); |
| 121 | 127 | }); |
| 122 | 128 | |
| 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 | ||
| 123 | 157 | test("a crash's open grant is closed by the sweep", () => { |
| 124 | 158 | const d = parseDeclaration("---\nsecrets: CF_TOKEN\n---"); |
| 125 | 159 | grantSecrets(app.db, dataDir, app.bus, d, { ...run, id: "run2" }); |
test/snapshot-drill.test.ts +1
| @@ -106,6 +106,7 @@ describe("the restore drill", () => { | ||
| 106 | 106 | "SECRET_WORD", |
| 107 | 107 | "swordfish", |
| 108 | 108 | "all", |
| 109 | "secret", | |
| 109 | 110 | ); |
| 110 | 111 | if (!set.ok) throw new Error(String(set.error)); |
| 111 | 112 | await takeSnapshot( |
test/snapshot.test.ts +4 −4
| @@ -106,7 +106,7 @@ describe("a forge back from a snapshot", () => { | ||
| 106 | 106 | app, |
| 107 | 107 | alice, |
| 108 | 108 | repo.value, |
| 109 | "SECRET_WORD", | |
| 109 | "SECRET_WORD_KEY", | |
| 110 | 110 | "swordfish", |
| 111 | 111 | "all", |
| 112 | 112 | ); |
| @@ -224,7 +224,7 @@ describe("a forge back from a snapshot", () => { | ||
| 224 | 224 | // The sealed variable opens with the restored key. |
| 225 | 225 | const record = getAppByRepo(restored.db, repo.id)!; |
| 226 | 226 | const env = resolveAppEnv(restored.db, back, record.id, "production"); |
| 227 | expect(env["SECRET_WORD"]).toBe("swordfish"); | |
| 227 | expect(env["SECRET_WORD_KEY"]).toBe("swordfish"); | |
| 228 | 228 | // The app's data came back, database rows included. |
| 229 | 229 | const { Database } = await import("bun:sqlite"); |
| 230 | 230 | const appDb = new Database( |
| @@ -248,7 +248,7 @@ describe("a forge back from a snapshot", () => { | ||
| 248 | 248 | const back = fresh("back-nokey"); |
| 249 | 249 | const done = await restoreSnapshot(store, NAME, back); |
| 250 | 250 | expect(done.key).toBe("missing"); |
| 251 | expect(done.sealedMissing).toEqual(["SECRET_WORD (app notes)"]); | |
| 251 | expect(done.sealedMissing).toEqual(["SECRET_WORD_KEY (app notes)"]); | |
| 252 | 252 | expect(existsSync(join(back, "secret.key.wrapped"))).toBe(false); |
| 253 | 253 | const restored = createApp({ dataDir: back, port: 0 }); |
| 254 | 254 | try { |
| @@ -258,7 +258,7 @@ describe("a forge back from a snapshot", () => { | ||
| 258 | 258 | getRepo(restored.db, "alice", "notes")!.id, |
| 259 | 259 | )!; |
| 260 | 260 | const env = resolveAppEnv(restored.db, back, record.id, "production"); |
| 261 | expect(env["SECRET_WORD"]).toBeUndefined(); | |
| 261 | expect(env["SECRET_WORD_KEY"]).toBeUndefined(); | |
| 262 | 262 | } finally { |
| 263 | 263 | restored.close(); |
| 264 | 264 | } |
test/tui-app-env.test.ts +4 −4
| @@ -96,7 +96,7 @@ afterAll(async () => { | ||
| 96 | 96 | |
| 97 | 97 | describe("an app's variables, in the terminal", () => { |
| 98 | 98 | 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"]); | |
| 100 | 100 | expect(vars().map((v) => [v.name, v.scope])).toEqual([["STRIPE_KEY", "production"]]); |
| 101 | 101 | expect(text).toContain("STRIPE_KEY is set for production."); |
| 102 | 102 | expect(text).toContain("STRIPE_KEY"); |
| @@ -113,7 +113,7 @@ describe("an app's variables, in the terminal", () => { | ||
| 113 | 113 | }); |
| 114 | 114 | |
| 115 | 115 | 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"]); | |
| 117 | 117 | // The page's sentence wraps across the head; its start is on the screen. |
| 118 | 118 | expect(text).toContain("A name is capital letters, digits and underscores"); |
| 119 | 119 | 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", () => { | ||
| 122 | 122 | |
| 123 | 123 | test("a value too long is refused in the page's words", async () => { |
| 124 | 124 | 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"]); | |
| 126 | 126 | expect(text).toContain(APP_ENV_ERRORS["invalid_value"]!); |
| 127 | 127 | expect(vars().map((v) => v.name)).toEqual(["STRIPE_KEY"]); |
| 128 | 128 | }); |
| @@ -141,7 +141,7 @@ describe("an app's variables, in the terminal", () => { | ||
| 141 | 141 | test("a writer who is not an admin has no entry, and can set nothing", async () => { |
| 142 | 142 | expect(await drive(bob, [":", "Variables"])).not.toContain("acme/shop — Variables"); |
| 143 | 143 | 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"]); | |
| 145 | 145 | expect(vars()).toEqual([]); |
| 146 | 146 | }); |
| 147 | 147 |
test/tui-appenv.test.ts +2 −2
| @@ -92,7 +92,7 @@ afterAll(async () => { | ||
| 92 | 92 | |
| 93 | 93 | describe("the terminal's variables screen", () => { |
| 94 | 94 | 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"]); | |
| 96 | 96 | expect(vars().map((v) => [v.name, v.scope])).toEqual([["API_TOKEN", "production"]]); |
| 97 | 97 | expect(text).toContain("API_TOKEN is set for production."); |
| 98 | 98 | expect(text).toContain("production · set"); |
| @@ -115,7 +115,7 @@ describe("the terminal's variables screen", () => { | ||
| 115 | 115 | test("a non-admin is not offered the entry; a bad name is refused in MCP's words", async () => { |
| 116 | 116 | expect(await drive(writer, [":", "Variables"])).not.toContain("— Variables"); |
| 117 | 117 | 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"]); | |
| 119 | 119 | expect(text).toContain(APP_ENV_ERRORS["invalid_name"]!.slice(0, 50)); |
| 120 | 120 | expect(vars()).toEqual([]); |
| 121 | 121 | 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", () => { | ||
| 313 | 313 | }); |
| 314 | 314 | |
| 315 | 315 | 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 }); | |
| 317 | 317 | tui.start(); |
| 318 | 318 | const system = forgeCommands(session)(tui.ctx).find((c) => c.id === "system"); |
| 319 | 319 | system!.run(); |
new above
offline
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."