Pin the recipe's secret declarations, and declare every app secret file

Soul S2: no test read deployment/idunn/recipe.toml, so a plaintext secret
name could be declared again (mutant ME) or a published _FILE name dropped
(mutant MF) with the suite green.

tests/recipe-secrets.test.ts reads the service's declared names and asserts
that no secret-shaped name is declared except as _FILE, that none of the
secrets Heimdall reads is declared under its plaintext name, and that every
provider client-secret file the witness publishes, every published app
profile's shared-secret file, and the database URL, token key and patron
secret files are declared.

The app-profile rule found two gaps: the witness publishes the repixelizer
and streampixels profiles and Heimdall reads their shared-secret files, but
the recipe declared neither, so an Idunn binding could not configure them.
Both are declared as optional, as GitHub and Spotify were for Soul F5.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-30 14:40:21 +02:00
commit 2800d88d10
2 changed files with 80 additions and 0 deletions

View file

@ -142,6 +142,8 @@ optional_environment = [
"GC_ACCESS_APP_GHOSTLIGHT_BACKEND_CALLBACK_URLS",
"GC_ACCESS_APP_GHOSTLIGHT_RUNTIME_IDS",
"GC_ACCESS_APP_GHOSTLIGHT_SHARED_SECRET_FILE",
"GC_ACCESS_APP_REPIXELIZER_SHARED_SECRET_FILE",
"GC_ACCESS_APP_STREAMPIXELS_SHARED_SECRET_FILE",
"GC_ACCESS_APPLY_SCHEMA_ON_STARTUP",
"GC_ACCESS_BIFROST_PATRON_SUPPORT_ENDPOINT",
"GC_ACCESS_BIFROST_PATRON_SUPPORT_SECRET_FILE",

View file

@ -0,0 +1,78 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import { appProfiles } from "../src/app-profiles.js";
import { providers } from "../src/contracts.js";
import { providerCatalog } from "../src/providers.js";
// The Idunn recipe decides which environment names a binding may set, and
// Idunn refuses any other. So the recipe is where "a secret is only ever a
// file" is enforced for a deployment: a plaintext secret name declared here
// can carry the secret into the binding, the unit and every plan.
const recipe = readFileSync(new URL("../deployment/idunn/recipe.toml", import.meta.url), "utf8");
/** The names in one `KEY = [ "A", "B" ]` array of the given TOML text. */
function stringArray(text: string, key: string): string[] {
const match = new RegExp(`^${key}\\s*=\\s*\\[([^\\]]*)\\]`, "m").exec(text);
return match ? [...match[1]!.matchAll(/"([^"]+)"/g)].map((name) => name[1]!) : [];
}
const serviceSection = /^\[service\]\n([\s\S]*?)(?=^\[)/m.exec(recipe.replace(/\r\n/g, "\n"))?.[1] ?? "";
const serviceNames = [
...stringArray(serviceSection, "optional_environment"),
...stringArray(serviceSection, "required_environment"),
];
const everyDeclaredName = [...recipe.replace(/\r\n/g, "\n").matchAll(/^\w+_environment\s*=\s*\[([^\]]*)\]/gm)].flatMap(
(match) => [...match[1]!.matchAll(/"([^"]+)"/g)].map((name) => name[1]!)
);
const serviceSecretNames = [
"GC_ACCESS_DATABASE_URL",
"GC_ACCESS_TOKEN_ENCRYPTION_KEY_BASE64",
"GC_ACCESS_BIFROST_PATRON_SUPPORT_SECRET",
];
/** Secrets Heimdall reads through readSecretInput, by their plaintext name. */
const secretNames = [
...serviceSecretNames,
...providers.map((provider) => `GC_ACCESS_PROVIDER_${provider.toUpperCase()}_CLIENT_SECRET`),
...Object.values(appProfiles).map((profile) => `GC_ACCESS_APP_${profile.slug.toUpperCase()}_SHARED_SECRET`),
];
const secretShaped = /SECRET|PASSWORD|DATABASE_URL|ENCRYPTION_KEY|_PEM$/;
describe("Idunn recipe secret declarations", () => {
it("reads the service's declared environment", () => {
expect(serviceNames).toContain("GC_ACCESS_DATABASE_URL_FILE");
expect(serviceNames).toContain("GC_ACCESS_BASE_URL");
});
it("declares no secret-shaped name except as a _FILE credential path", () => {
const plaintext = everyDeclaredName.filter((name) => secretShaped.test(name) && !name.endsWith("_FILE"));
expect(plaintext).toEqual([]);
});
it("declares none of the secrets Heimdall reads under its plaintext name", () => {
for (const name of secretNames) {
expect(everyDeclaredName).not.toContain(name);
}
});
it("declares every provider client-secret file the witness publishes", () => {
for (const provider of providers) {
expect(serviceNames).toContain(providerCatalog[provider].clientSecretEnv);
}
});
it("declares the shared-secret file of every app profile the witness publishes", () => {
for (const profile of Object.values(appProfiles)) {
expect(serviceNames).toContain(`GC_ACCESS_APP_${profile.slug.toUpperCase()}_SHARED_SECRET_FILE`);
}
});
it("declares the database URL, token key and patron secret files", () => {
for (const name of serviceSecretNames) {
expect(serviceNames).toContain(`${name}_FILE`);
}
});
});