Read every secret input from a credential file named by NAME_FILE
The recipe declared Heimdall's secrets as plain environment, so its admit
contract made the operator binding the only place their values could live,
and the binding was printed into a transcript. The recipe now declares each
secret only as its _FILE twin (GC_ACCESS_DATABASE_URL_FILE required, the rest
optional). A binding binds those under [workload.secret_files] to root:root
0400 credential files, and Idunn's rule that a binding binds only declared
names leaves a plaintext secret nowhere to go. GC_ACCESS_APP_SHARED_SECRET is
deleted outright: nothing reads it.
One reader, readSecretInput, serves every secret: it reads NAME_FILE and strips
one trailing newline, refuses NAME and NAME_FILE set together, and its errors
name the variable and path, never a value or file contents. Storage-backend
selection keys off the resolved database URL. The published provider witness
and the provider_not_configured body name the _FILE variables, and custody's
errors name GC_ACCESS_TOKEN_ENCRYPTION_KEY_BASE64_FILE.
The plaintext fallback (NAME read while NAME_FILE is absent) is a named shim
for the legacy EnvironmentFile unit only; secret-files Cut 5 deletes it.
Idunn secret-files cut, Cut 1.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 12:30:57 +02:00
|
|
|
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
|
|
|
import { tmpdir } from "node:os";
|
|
|
|
|
import { join } from "node:path";
|
|
|
|
|
import { afterEach, describe, expect, it } from "vitest";
|
|
|
|
|
import { loadConfig, readSecretInput } from "../src/config.js";
|
|
|
|
|
import { appSlugs, providers } from "../src/contracts.js";
|
|
|
|
|
import { providerCatalog, providerExpectedEnv } from "../src/providers.js";
|
|
|
|
|
|
|
|
|
|
// Secret inputs arrive as files named by `NAME_FILE` (systemd credentials under
|
|
|
|
|
// Idunn). These tests pin the reader's rules and that every secret Heimdall
|
|
|
|
|
// consumes goes through it.
|
|
|
|
|
|
|
|
|
|
const tempDirs: string[] = [];
|
|
|
|
|
|
|
|
|
|
function tempDir(): string {
|
|
|
|
|
const dir = mkdtempSync(join(tmpdir(), "heimdall-secret-input-"));
|
|
|
|
|
tempDirs.push(dir);
|
|
|
|
|
return dir;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function secretFile(contents: string, name = "secret"): string {
|
|
|
|
|
const path = join(tempDir(), name);
|
|
|
|
|
writeFileSync(path, contents);
|
|
|
|
|
return path;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function thrownMessage(action: () => unknown): string {
|
|
|
|
|
try {
|
|
|
|
|
action();
|
|
|
|
|
} catch (error) {
|
|
|
|
|
return (error as Error).message;
|
|
|
|
|
}
|
|
|
|
|
throw new Error("expected the action to throw");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
afterEach(() => {
|
|
|
|
|
for (const dir of tempDirs.splice(0)) {
|
|
|
|
|
rmSync(dir, { recursive: true, force: true });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe("readSecretInput", () => {
|
|
|
|
|
it("reads the value from the file named by NAME_FILE and strips exactly one trailing newline", () => {
|
|
|
|
|
const cases: Array<[string, string]> = [
|
|
|
|
|
["from-the-file\n", "from-the-file"],
|
|
|
|
|
["from-the-file\r\n", "from-the-file"],
|
|
|
|
|
["two-newlines\n\n", "two-newlines\n"],
|
2026-09-30 12:49:23 +02:00
|
|
|
["line-one\nline-two\n", "line-one\nline-two"],
|
Read every secret input from a credential file named by NAME_FILE
The recipe declared Heimdall's secrets as plain environment, so its admit
contract made the operator binding the only place their values could live,
and the binding was printed into a transcript. The recipe now declares each
secret only as its _FILE twin (GC_ACCESS_DATABASE_URL_FILE required, the rest
optional). A binding binds those under [workload.secret_files] to root:root
0400 credential files, and Idunn's rule that a binding binds only declared
names leaves a plaintext secret nowhere to go. GC_ACCESS_APP_SHARED_SECRET is
deleted outright: nothing reads it.
One reader, readSecretInput, serves every secret: it reads NAME_FILE and strips
one trailing newline, refuses NAME and NAME_FILE set together, and its errors
name the variable and path, never a value or file contents. Storage-backend
selection keys off the resolved database URL. The published provider witness
and the provider_not_configured body name the _FILE variables, and custody's
errors name GC_ACCESS_TOKEN_ENCRYPTION_KEY_BASE64_FILE.
The plaintext fallback (NAME read while NAME_FILE is absent) is a named shim
for the legacy EnvironmentFile unit only; secret-files Cut 5 deletes it.
Idunn secret-files cut, Cut 1.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 12:30:57 +02:00
|
|
|
[" inner spaces kept \n", " inner spaces kept "],
|
|
|
|
|
["no-newline", "no-newline"],
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
for (const [contents, expected] of cases) {
|
|
|
|
|
const env = { EXAMPLE_SECRET_FILE: secretFile(contents) };
|
|
|
|
|
expect(readSecretInput(env, "EXAMPLE_SECRET")).toBe(expected);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("returns undefined when neither form is set", () => {
|
|
|
|
|
expect(readSecretInput({}, "EXAMPLE_SECRET")).toBeUndefined();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("falls back to the plaintext name only while NAME_FILE is absent (legacy shim)", () => {
|
|
|
|
|
expect(readSecretInput({ EXAMPLE_SECRET: "legacy-plaintext" }, "EXAMPLE_SECRET")).toBe("legacy-plaintext");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("refuses both forms at once, and the refusal carries neither value", () => {
|
|
|
|
|
const fileCanary = "canary-file-7c1e0f";
|
|
|
|
|
const plainCanary = "canary-plain-93ab2d";
|
|
|
|
|
const env = {
|
|
|
|
|
EXAMPLE_SECRET: plainCanary,
|
|
|
|
|
EXAMPLE_SECRET_FILE: secretFile(fileCanary),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const message = thrownMessage(() => readSecretInput(env, "EXAMPLE_SECRET"));
|
|
|
|
|
expect(message).toContain("EXAMPLE_SECRET_FILE");
|
|
|
|
|
expect(message).toMatch(/both set/);
|
|
|
|
|
expect(message).not.toContain(fileCanary);
|
|
|
|
|
expect(message).not.toContain(plainCanary);
|
|
|
|
|
expect(message).not.toContain("canary");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("refuses both forms even when the plaintext form is empty", () => {
|
|
|
|
|
const env = { EXAMPLE_SECRET: "", EXAMPLE_SECRET_FILE: secretFile("value") };
|
|
|
|
|
expect(() => readSecretInput(env, "EXAMPLE_SECRET")).toThrow(/both set/);
|
|
|
|
|
});
|
|
|
|
|
|
readSecretInput: never print the path, refuse relative paths and blank secrets
Soul F1: the unreadable-file error carried the NAME_FILE value, and Idunn
admits any string for a declared name, so a URL bound into
GC_ACCESS_DATABASE_URL_FILE by mistake was printed in full at startup. Errors
now name only the variable and an error code, and a NAME_FILE that is not an
absolute path is refused naming the variable alone.
Soul F2 and F6: a blank database URL file selected memory storage silently,
and a whitespace one selected postgres with a URL of " ". The reader now
refuses an empty or whitespace-only value from either form, so custody's own
"set but empty" check is redundant and is deleted; a short key still fails its
length check. The legacy service.env holds no blank secret-class value
(checked as name/blank booleans), so the legacy unit is unaffected.
Soul F3: tests pin that an empty NAME_FILE is refused rather than unset (S1),
and that each call site (token key, provider secret, app shared secret,
database URL, patron secret) refuses both forms, so no site can read the
plaintext name ahead of the reader (S6, S7, S8).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 13:50:53 +02:00
|
|
|
it("refuses an unreadable path naming only the variable and the code, not the path", () => {
|
|
|
|
|
// A secret bound into a _FILE slot by mistake must not be printed back.
|
|
|
|
|
const canaryPath = join(tempDir(), "canary-path-3b7e1d");
|
|
|
|
|
const message = thrownMessage(() => readSecretInput({ EXAMPLE_SECRET_FILE: canaryPath }, "EXAMPLE_SECRET"));
|
Read every secret input from a credential file named by NAME_FILE
The recipe declared Heimdall's secrets as plain environment, so its admit
contract made the operator binding the only place their values could live,
and the binding was printed into a transcript. The recipe now declares each
secret only as its _FILE twin (GC_ACCESS_DATABASE_URL_FILE required, the rest
optional). A binding binds those under [workload.secret_files] to root:root
0400 credential files, and Idunn's rule that a binding binds only declared
names leaves a plaintext secret nowhere to go. GC_ACCESS_APP_SHARED_SECRET is
deleted outright: nothing reads it.
One reader, readSecretInput, serves every secret: it reads NAME_FILE and strips
one trailing newline, refuses NAME and NAME_FILE set together, and its errors
name the variable and path, never a value or file contents. Storage-backend
selection keys off the resolved database URL. The published provider witness
and the provider_not_configured body name the _FILE variables, and custody's
errors name GC_ACCESS_TOKEN_ENCRYPTION_KEY_BASE64_FILE.
The plaintext fallback (NAME read while NAME_FILE is absent) is a named shim
for the legacy EnvironmentFile unit only; secret-files Cut 5 deletes it.
Idunn secret-files cut, Cut 1.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 12:30:57 +02:00
|
|
|
expect(message).toContain("EXAMPLE_SECRET_FILE");
|
|
|
|
|
expect(message).toContain("ENOENT");
|
readSecretInput: never print the path, refuse relative paths and blank secrets
Soul F1: the unreadable-file error carried the NAME_FILE value, and Idunn
admits any string for a declared name, so a URL bound into
GC_ACCESS_DATABASE_URL_FILE by mistake was printed in full at startup. Errors
now name only the variable and an error code, and a NAME_FILE that is not an
absolute path is refused naming the variable alone.
Soul F2 and F6: a blank database URL file selected memory storage silently,
and a whitespace one selected postgres with a URL of " ". The reader now
refuses an empty or whitespace-only value from either form, so custody's own
"set but empty" check is redundant and is deleted; a short key still fails its
length check. The legacy service.env holds no blank secret-class value
(checked as name/blank booleans), so the legacy unit is unaffected.
Soul F3: tests pin that an empty NAME_FILE is refused rather than unset (S1),
and that each call site (token key, provider secret, app shared secret,
database URL, patron secret) refuses both forms, so no site can read the
plaintext name ahead of the reader (S6, S7, S8).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 13:50:53 +02:00
|
|
|
expect(message).not.toContain("canary");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("refuses a _FILE value that is not an absolute path, without echoing it", () => {
|
|
|
|
|
for (const value of ["postgres://heimdall:canary-pw-6f02aa@db/heimdall", "relative/canary-rel-0c9d", ""]) {
|
|
|
|
|
const message = thrownMessage(() => readSecretInput({ EXAMPLE_SECRET_FILE: value }, "EXAMPLE_SECRET"));
|
|
|
|
|
expect(message).toBe("EXAMPLE_SECRET_FILE is not an absolute path.");
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("does not treat an empty _FILE as unset, even with no plaintext form", () => {
|
|
|
|
|
expect(() => readSecretInput({ EXAMPLE_SECRET_FILE: "" }, "EXAMPLE_SECRET")).toThrow(/EXAMPLE_SECRET_FILE/);
|
Read every secret input from a credential file named by NAME_FILE
The recipe declared Heimdall's secrets as plain environment, so its admit
contract made the operator binding the only place their values could live,
and the binding was printed into a transcript. The recipe now declares each
secret only as its _FILE twin (GC_ACCESS_DATABASE_URL_FILE required, the rest
optional). A binding binds those under [workload.secret_files] to root:root
0400 credential files, and Idunn's rule that a binding binds only declared
names leaves a plaintext secret nowhere to go. GC_ACCESS_APP_SHARED_SECRET is
deleted outright: nothing reads it.
One reader, readSecretInput, serves every secret: it reads NAME_FILE and strips
one trailing newline, refuses NAME and NAME_FILE set together, and its errors
name the variable and path, never a value or file contents. Storage-backend
selection keys off the resolved database URL. The published provider witness
and the provider_not_configured body name the _FILE variables, and custody's
errors name GC_ACCESS_TOKEN_ENCRYPTION_KEY_BASE64_FILE.
The plaintext fallback (NAME read while NAME_FILE is absent) is a named shim
for the legacy EnvironmentFile unit only; secret-files Cut 5 deletes it.
Idunn secret-files cut, Cut 1.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 12:30:57 +02:00
|
|
|
});
|
|
|
|
|
|
readSecretInput: never print the path, refuse relative paths and blank secrets
Soul F1: the unreadable-file error carried the NAME_FILE value, and Idunn
admits any string for a declared name, so a URL bound into
GC_ACCESS_DATABASE_URL_FILE by mistake was printed in full at startup. Errors
now name only the variable and an error code, and a NAME_FILE that is not an
absolute path is refused naming the variable alone.
Soul F2 and F6: a blank database URL file selected memory storage silently,
and a whitespace one selected postgres with a URL of " ". The reader now
refuses an empty or whitespace-only value from either form, so custody's own
"set but empty" check is redundant and is deleted; a short key still fails its
length check. The legacy service.env holds no blank secret-class value
(checked as name/blank booleans), so the legacy unit is unaffected.
Soul F3: tests pin that an empty NAME_FILE is refused rather than unset (S1),
and that each call site (token key, provider secret, app shared secret,
database URL, patron secret) refuses both forms, so no site can read the
plaintext name ahead of the reader (S6, S7, S8).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 13:50:53 +02:00
|
|
|
it("refuses an unreadable file, and carries no byte of what sits in or beside it", () => {
|
Read every secret input from a credential file named by NAME_FILE
The recipe declared Heimdall's secrets as plain environment, so its admit
contract made the operator binding the only place their values could live,
and the binding was printed into a transcript. The recipe now declares each
secret only as its _FILE twin (GC_ACCESS_DATABASE_URL_FILE required, the rest
optional). A binding binds those under [workload.secret_files] to root:root
0400 credential files, and Idunn's rule that a binding binds only declared
names leaves a plaintext secret nowhere to go. GC_ACCESS_APP_SHARED_SECRET is
deleted outright: nothing reads it.
One reader, readSecretInput, serves every secret: it reads NAME_FILE and strips
one trailing newline, refuses NAME and NAME_FILE set together, and its errors
name the variable and path, never a value or file contents. Storage-backend
selection keys off the resolved database URL. The published provider witness
and the provider_not_configured body name the _FILE variables, and custody's
errors name GC_ACCESS_TOKEN_ENCRYPTION_KEY_BASE64_FILE.
The plaintext fallback (NAME read while NAME_FILE is absent) is a named shim
for the legacy EnvironmentFile unit only; secret-files Cut 5 deletes it.
Idunn secret-files cut, Cut 1.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 12:30:57 +02:00
|
|
|
const dir = tempDir();
|
|
|
|
|
const canary = "canary-sibling-5d2f88";
|
|
|
|
|
writeFileSync(join(dir, "sibling"), canary);
|
|
|
|
|
const notAFile = join(dir, "credential");
|
|
|
|
|
mkdirSync(notAFile);
|
|
|
|
|
writeFileSync(join(notAFile, "inside"), canary);
|
|
|
|
|
|
|
|
|
|
const message = thrownMessage(() => readSecretInput({ EXAMPLE_SECRET_FILE: notAFile }, "EXAMPLE_SECRET"));
|
readSecretInput: never print the path, refuse relative paths and blank secrets
Soul F1: the unreadable-file error carried the NAME_FILE value, and Idunn
admits any string for a declared name, so a URL bound into
GC_ACCESS_DATABASE_URL_FILE by mistake was printed in full at startup. Errors
now name only the variable and an error code, and a NAME_FILE that is not an
absolute path is refused naming the variable alone.
Soul F2 and F6: a blank database URL file selected memory storage silently,
and a whitespace one selected postgres with a URL of " ". The reader now
refuses an empty or whitespace-only value from either form, so custody's own
"set but empty" check is redundant and is deleted; a short key still fails its
length check. The legacy service.env holds no blank secret-class value
(checked as name/blank booleans), so the legacy unit is unaffected.
Soul F3: tests pin that an empty NAME_FILE is refused rather than unset (S1),
and that each call site (token key, provider secret, app shared secret,
database URL, patron secret) refuses both forms, so no site can read the
plaintext name ahead of the reader (S6, S7, S8).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 13:50:53 +02:00
|
|
|
expect(message).toBe("EXAMPLE_SECRET_FILE could not be read (EISDIR).");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("refuses a blank secret file rather than treating it as unset", () => {
|
|
|
|
|
for (const contents of ["", "\n", " \n", "\t\r\n", " "]) {
|
|
|
|
|
const env = { EXAMPLE_SECRET_FILE: secretFile(contents) };
|
|
|
|
|
expect(thrownMessage(() => readSecretInput(env, "EXAMPLE_SECRET"))).toBe("EXAMPLE_SECRET_FILE is empty.");
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("refuses a blank plaintext secret too", () => {
|
|
|
|
|
for (const value of ["", " ", "\n"]) {
|
|
|
|
|
expect(thrownMessage(() => readSecretInput({ EXAMPLE_SECRET: value }, "EXAMPLE_SECRET"))).toBe(
|
|
|
|
|
"EXAMPLE_SECRET is empty."
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("keeps a value whose only whitespace is inside or around real content", () => {
|
|
|
|
|
const env = { EXAMPLE_SECRET_FILE: secretFile(" padded value \n") };
|
|
|
|
|
expect(readSecretInput(env, "EXAMPLE_SECRET")).toBe(" padded value ");
|
Read every secret input from a credential file named by NAME_FILE
The recipe declared Heimdall's secrets as plain environment, so its admit
contract made the operator binding the only place their values could live,
and the binding was printed into a transcript. The recipe now declares each
secret only as its _FILE twin (GC_ACCESS_DATABASE_URL_FILE required, the rest
optional). A binding binds those under [workload.secret_files] to root:root
0400 credential files, and Idunn's rule that a binding binds only declared
names leaves a plaintext secret nowhere to go. GC_ACCESS_APP_SHARED_SECRET is
deleted outright: nothing reads it.
One reader, readSecretInput, serves every secret: it reads NAME_FILE and strips
one trailing newline, refuses NAME and NAME_FILE set together, and its errors
name the variable and path, never a value or file contents. Storage-backend
selection keys off the resolved database URL. The published provider witness
and the provider_not_configured body name the _FILE variables, and custody's
errors name GC_ACCESS_TOKEN_ENCRYPTION_KEY_BASE64_FILE.
The plaintext fallback (NAME read while NAME_FILE is absent) is a named shim
for the legacy EnvironmentFile unit only; secret-files Cut 5 deletes it.
Idunn secret-files cut, Cut 1.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 12:30:57 +02:00
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe("loadConfig secret inputs", () => {
|
|
|
|
|
it("configures every secret Heimdall consumes from its _FILE twin alone", () => {
|
|
|
|
|
const env: Record<string, string> = {};
|
|
|
|
|
const expected = new Map<string, string>();
|
|
|
|
|
function bind(name: string): string {
|
|
|
|
|
const value = `value-of-${name.toLowerCase()}`;
|
|
|
|
|
env[`${name}_FILE`] = secretFile(`${value}\n`, name);
|
|
|
|
|
expected.set(name, value);
|
|
|
|
|
return value;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const databaseUrl = bind("GC_ACCESS_DATABASE_URL");
|
|
|
|
|
const tokenKey = bind("GC_ACCESS_TOKEN_ENCRYPTION_KEY_BASE64");
|
|
|
|
|
const patronSecret = bind("GC_ACCESS_BIFROST_PATRON_SUPPORT_SECRET");
|
|
|
|
|
const providerSecrets = Object.fromEntries(
|
|
|
|
|
providers.map((provider) => [provider, bind(`GC_ACCESS_PROVIDER_${provider.toUpperCase()}_CLIENT_SECRET`)])
|
|
|
|
|
);
|
|
|
|
|
const appSecrets = Object.fromEntries(
|
|
|
|
|
appSlugs.map((slug) => [slug, bind(`GC_ACCESS_APP_${slug.toUpperCase()}_SHARED_SECRET`)])
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const config = loadConfig(env, []);
|
|
|
|
|
|
|
|
|
|
expect(config.storage.databaseUrl).toBe(databaseUrl);
|
|
|
|
|
expect(config.tokenEncryptionKeyBase64).toBe(tokenKey);
|
|
|
|
|
expect(config.bifrostPatronSupportSecret).toBe(patronSecret);
|
|
|
|
|
for (const provider of providers) {
|
|
|
|
|
expect(config.providers[provider].clientSecret).toBe(providerSecrets[provider]);
|
|
|
|
|
}
|
|
|
|
|
expect(config.appSharedSecrets).toEqual(appSecrets);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("selects postgres storage from a database URL given only as a file", () => {
|
|
|
|
|
const config = loadConfig(
|
|
|
|
|
{ GC_ACCESS_DATABASE_URL_FILE: secretFile("postgres://heimdall@localhost/heimdall\n") },
|
|
|
|
|
[]
|
|
|
|
|
);
|
|
|
|
|
expect(config.storage.backend).toBe("postgres");
|
|
|
|
|
});
|
|
|
|
|
|
2026-09-30 12:49:23 +02:00
|
|
|
it("keeps memory storage when no database URL is bound, and postgres when it is asked for", () => {
|
|
|
|
|
expect(loadConfig({}, []).storage.backend).toBe("memory");
|
|
|
|
|
expect(loadConfig({ GC_ACCESS_STORAGE_BACKEND: "memory" }, []).storage.backend).toBe("memory");
|
|
|
|
|
expect(loadConfig({ GC_ACCESS_STORAGE_BACKEND: "postgres" }, []).storage.backend).toBe("postgres");
|
|
|
|
|
});
|
|
|
|
|
|
Read every secret input from a credential file named by NAME_FILE
The recipe declared Heimdall's secrets as plain environment, so its admit
contract made the operator binding the only place their values could live,
and the binding was printed into a transcript. The recipe now declares each
secret only as its _FILE twin (GC_ACCESS_DATABASE_URL_FILE required, the rest
optional). A binding binds those under [workload.secret_files] to root:root
0400 credential files, and Idunn's rule that a binding binds only declared
names leaves a plaintext secret nowhere to go. GC_ACCESS_APP_SHARED_SECRET is
deleted outright: nothing reads it.
One reader, readSecretInput, serves every secret: it reads NAME_FILE and strips
one trailing newline, refuses NAME and NAME_FILE set together, and its errors
name the variable and path, never a value or file contents. Storage-backend
selection keys off the resolved database URL. The published provider witness
and the provider_not_configured body name the _FILE variables, and custody's
errors name GC_ACCESS_TOKEN_ENCRYPTION_KEY_BASE64_FILE.
The plaintext fallback (NAME read while NAME_FILE is absent) is a named shim
for the legacy EnvironmentFile unit only; secret-files Cut 5 deletes it.
Idunn secret-files cut, Cut 1.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 12:30:57 +02:00
|
|
|
it("refuses to start when the database URL file is absent", () => {
|
|
|
|
|
const missing = join(tempDir(), "database-url");
|
|
|
|
|
const message = thrownMessage(() => loadConfig({ GC_ACCESS_DATABASE_URL_FILE: missing }, []));
|
readSecretInput: never print the path, refuse relative paths and blank secrets
Soul F1: the unreadable-file error carried the NAME_FILE value, and Idunn
admits any string for a declared name, so a URL bound into
GC_ACCESS_DATABASE_URL_FILE by mistake was printed in full at startup. Errors
now name only the variable and an error code, and a NAME_FILE that is not an
absolute path is refused naming the variable alone.
Soul F2 and F6: a blank database URL file selected memory storage silently,
and a whitespace one selected postgres with a URL of " ". The reader now
refuses an empty or whitespace-only value from either form, so custody's own
"set but empty" check is redundant and is deleted; a short key still fails its
length check. The legacy service.env holds no blank secret-class value
(checked as name/blank booleans), so the legacy unit is unaffected.
Soul F3: tests pin that an empty NAME_FILE is refused rather than unset (S1),
and that each call site (token key, provider secret, app shared secret,
database URL, patron secret) refuses both forms, so no site can read the
plaintext name ahead of the reader (S6, S7, S8).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 13:50:53 +02:00
|
|
|
expect(message).toBe("GC_ACCESS_DATABASE_URL_FILE could not be read (ENOENT).");
|
Read every secret input from a credential file named by NAME_FILE
The recipe declared Heimdall's secrets as plain environment, so its admit
contract made the operator binding the only place their values could live,
and the binding was printed into a transcript. The recipe now declares each
secret only as its _FILE twin (GC_ACCESS_DATABASE_URL_FILE required, the rest
optional). A binding binds those under [workload.secret_files] to root:root
0400 credential files, and Idunn's rule that a binding binds only declared
names leaves a plaintext secret nowhere to go. GC_ACCESS_APP_SHARED_SECRET is
deleted outright: nothing reads it.
One reader, readSecretInput, serves every secret: it reads NAME_FILE and strips
one trailing newline, refuses NAME and NAME_FILE set together, and its errors
name the variable and path, never a value or file contents. Storage-backend
selection keys off the resolved database URL. The published provider witness
and the provider_not_configured body name the _FILE variables, and custody's
errors name GC_ACCESS_TOKEN_ENCRYPTION_KEY_BASE64_FILE.
The plaintext fallback (NAME read while NAME_FILE is absent) is a named shim
for the legacy EnvironmentFile unit only; secret-files Cut 5 deletes it.
Idunn secret-files cut, Cut 1.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 12:30:57 +02:00
|
|
|
});
|
|
|
|
|
|
readSecretInput: never print the path, refuse relative paths and blank secrets
Soul F1: the unreadable-file error carried the NAME_FILE value, and Idunn
admits any string for a declared name, so a URL bound into
GC_ACCESS_DATABASE_URL_FILE by mistake was printed in full at startup. Errors
now name only the variable and an error code, and a NAME_FILE that is not an
absolute path is refused naming the variable alone.
Soul F2 and F6: a blank database URL file selected memory storage silently,
and a whitespace one selected postgres with a URL of " ". The reader now
refuses an empty or whitespace-only value from either form, so custody's own
"set but empty" check is redundant and is deleted; a short key still fails its
length check. The legacy service.env holds no blank secret-class value
(checked as name/blank booleans), so the legacy unit is unaffected.
Soul F3: tests pin that an empty NAME_FILE is refused rather than unset (S1),
and that each call site (token key, provider secret, app shared secret,
database URL, patron secret) refuses both forms, so no site can read the
plaintext name ahead of the reader (S6, S7, S8).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 13:50:53 +02:00
|
|
|
it("refuses to start on a blank database URL file instead of falling back to memory storage", () => {
|
|
|
|
|
for (const contents of ["", "\n", " \n"]) {
|
|
|
|
|
const env = { GC_ACCESS_DATABASE_URL_FILE: secretFile(contents) };
|
|
|
|
|
expect(() => loadConfig(env, [])).toThrow("GC_ACCESS_DATABASE_URL_FILE is empty.");
|
|
|
|
|
}
|
Read every secret input from a credential file named by NAME_FILE
The recipe declared Heimdall's secrets as plain environment, so its admit
contract made the operator binding the only place their values could live,
and the binding was printed into a transcript. The recipe now declares each
secret only as its _FILE twin (GC_ACCESS_DATABASE_URL_FILE required, the rest
optional). A binding binds those under [workload.secret_files] to root:root
0400 credential files, and Idunn's rule that a binding binds only declared
names leaves a plaintext secret nowhere to go. GC_ACCESS_APP_SHARED_SECRET is
deleted outright: nothing reads it.
One reader, readSecretInput, serves every secret: it reads NAME_FILE and strips
one trailing newline, refuses NAME and NAME_FILE set together, and its errors
name the variable and path, never a value or file contents. Storage-backend
selection keys off the resolved database URL. The published provider witness
and the provider_not_configured body name the _FILE variables, and custody's
errors name GC_ACCESS_TOKEN_ENCRYPTION_KEY_BASE64_FILE.
The plaintext fallback (NAME read while NAME_FILE is absent) is a named shim
for the legacy EnvironmentFile unit only; secret-files Cut 5 deletes it.
Idunn secret-files cut, Cut 1.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 12:30:57 +02:00
|
|
|
});
|
|
|
|
|
|
readSecretInput: never print the path, refuse relative paths and blank secrets
Soul F1: the unreadable-file error carried the NAME_FILE value, and Idunn
admits any string for a declared name, so a URL bound into
GC_ACCESS_DATABASE_URL_FILE by mistake was printed in full at startup. Errors
now name only the variable and an error code, and a NAME_FILE that is not an
absolute path is refused naming the variable alone.
Soul F2 and F6: a blank database URL file selected memory storage silently,
and a whitespace one selected postgres with a URL of " ". The reader now
refuses an empty or whitespace-only value from either form, so custody's own
"set but empty" check is redundant and is deleted; a short key still fails its
length check. The legacy service.env holds no blank secret-class value
(checked as name/blank booleans), so the legacy unit is unaffected.
Soul F3: tests pin that an empty NAME_FILE is refused rather than unset (S1),
and that each call site (token key, provider secret, app shared secret,
database URL, patron secret) refuses both forms, so no site can read the
plaintext name ahead of the reader (S6, S7, S8).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 13:50:53 +02:00
|
|
|
// One per call site: a site that reads the plaintext name itself before the
|
|
|
|
|
// reader would let the plaintext win silently instead of refusing.
|
|
|
|
|
const bothSetSites: Array<[string, string]> = [
|
|
|
|
|
["token encryption key", "GC_ACCESS_TOKEN_ENCRYPTION_KEY_BASE64"],
|
|
|
|
|
["provider client secret", "GC_ACCESS_PROVIDER_DISCORD_CLIENT_SECRET"],
|
|
|
|
|
["app shared secret", "GC_ACCESS_APP_GHOSTLIGHT_SHARED_SECRET"],
|
|
|
|
|
["database URL", "GC_ACCESS_DATABASE_URL"],
|
|
|
|
|
["patron support secret", "GC_ACCESS_BIFROST_PATRON_SUPPORT_SECRET"],
|
|
|
|
|
];
|
|
|
|
|
for (const [site, name] of bothSetSites) {
|
|
|
|
|
it(`refuses the ${site} bound both ways at startup`, () => {
|
|
|
|
|
const env = { [name]: "plain", [`${name}_FILE`]: secretFile("file") };
|
|
|
|
|
expect(() => loadConfig(env, [])).toThrow(`${name} and ${name}_FILE are both set`);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
Read every secret input from a credential file named by NAME_FILE
The recipe declared Heimdall's secrets as plain environment, so its admit
contract made the operator binding the only place their values could live,
and the binding was printed into a transcript. The recipe now declares each
secret only as its _FILE twin (GC_ACCESS_DATABASE_URL_FILE required, the rest
optional). A binding binds those under [workload.secret_files] to root:root
0400 credential files, and Idunn's rule that a binding binds only declared
names leaves a plaintext secret nowhere to go. GC_ACCESS_APP_SHARED_SECRET is
deleted outright: nothing reads it.
One reader, readSecretInput, serves every secret: it reads NAME_FILE and strips
one trailing newline, refuses NAME and NAME_FILE set together, and its errors
name the variable and path, never a value or file contents. Storage-backend
selection keys off the resolved database URL. The published provider witness
and the provider_not_configured body name the _FILE variables, and custody's
errors name GC_ACCESS_TOKEN_ENCRYPTION_KEY_BASE64_FILE.
The plaintext fallback (NAME read while NAME_FILE is absent) is a named shim
for the legacy EnvironmentFile unit only; secret-files Cut 5 deletes it.
Idunn secret-files cut, Cut 1.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 12:30:57 +02:00
|
|
|
it("publishes provider secret variable names that actually configure the secret", () => {
|
|
|
|
|
for (const provider of providers) {
|
|
|
|
|
const secretVariable = providerCatalog[provider].clientSecretEnv;
|
|
|
|
|
expect(providerExpectedEnv(provider)).toContain(secretVariable);
|
|
|
|
|
|
|
|
|
|
const config = loadConfig({ [secretVariable]: secretFile(`${provider}-secret\n`) }, []);
|
|
|
|
|
expect(config.providers[provider].clientSecret).toBe(`${provider}-secret`);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
});
|