mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 22:46:32 +00:00
ba1504d6e9
Eight registerAs tokens existed while every consumer read the same values through readEnv() instead, so the boot-stable layer the repo rules describe was unused and nothing said which path owned a variable. FORCE_HTTPS, for instance, was frozen by the middleware through the http token and read live by the cookie code at the same time. A variable is now owned by exactly one side. TRUST_PROXY and HSTS_INCLUDE_SUBDOMAINS belong to httpConfig, which buildApp() already injects into the pre-init middleware, and TREK_PLACE_PHOTO_DIR to the new storageConfig, which the storage registry now injects instead of reading it through RuntimeEnvService. Their derive functions moved to boot-derive.ts, which deriveAll() never calls. The six tokens nothing injected are gone: their values are needed before the container exists (config.ts, the database, src/mcp, the SSRF guard) and stay on readEnv(). config-ownership.test.ts records the keys each side reads through a Proxy and fails on a variable read through both, on a token nothing injects and on a variable the schema does not validate. env-reference.test.ts holds server/.env.example to the schema, which had drifted by 34 variables (SMTP, WebAuthn, plugins, MCP session tuning, TREK_MANAGED and others); they are documented now. jwt-verify.ts no longer cites a middleware file that is gone.
102 lines
4.2 KiB
TypeScript
102 lines
4.2 KiB
TypeScript
/**
|
|
* Every environment variable has exactly one owner: either `readEnv()` (live,
|
|
* per call, reachable from anywhere) or one registerAs token (boot-stable,
|
|
* injected). A variable read through both drifts the moment one side's
|
|
* semantics change, and its dependency is invisible to whoever reads the
|
|
* other. These cases record which keys each side actually touches, through a
|
|
* Proxy over the raw environment, so a new field in the wrong derive function
|
|
* fails here rather than in review.
|
|
*/
|
|
import { describe, it, expect } from 'vitest';
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import { deriveAll, type RawEnv } from '../../../src/app-config/derive';
|
|
import { BOOT_DERIVERS, type BootNamespace } from '../../../src/app-config/boot-derive';
|
|
import { envSchema } from '../../../src/app-config/env.schema';
|
|
import { BOOT_STABLE_TOKENS } from '../../../src/nest/app-config/tokens';
|
|
|
|
const SRC = path.join(__dirname, '..', '..', '..', 'src');
|
|
const TOKENS_FILE = path.join(SRC, 'nest', 'app-config', 'tokens.ts');
|
|
|
|
/**
|
|
* Variables Node itself and the outbound proxy support read. They are not TREK
|
|
* configuration, so the schema does not list them; deriveNet passes them on.
|
|
*/
|
|
const PLATFORM_VARIABLES = new Set(['HTTP_PROXY', 'http_proxy', 'HTTPS_PROXY', 'https_proxy', 'NO_PROXY', 'no_proxy']);
|
|
|
|
/** The keys `derive` reads from an empty environment, in the order it reads them. */
|
|
function keysReadBy(derive: (raw: RawEnv) => unknown): Set<string> {
|
|
const read = new Set<string>();
|
|
const raw = new Proxy({} as RawEnv, {
|
|
get(_target, key) {
|
|
if (typeof key === 'string') read.add(key);
|
|
return undefined;
|
|
},
|
|
});
|
|
derive(raw);
|
|
return read;
|
|
}
|
|
|
|
function sourceFiles(dir: string): string[] {
|
|
return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
|
|
const full = path.join(dir, entry.name);
|
|
if (entry.isDirectory()) return sourceFiles(full);
|
|
return entry.name.endsWith('.ts') ? [full] : [];
|
|
});
|
|
}
|
|
|
|
const namespaces = Object.keys(BOOT_DERIVERS) as BootNamespace[];
|
|
const liveKeys = keysReadBy(deriveAll);
|
|
|
|
describe('config ownership', () => {
|
|
it('CONFIG-OWN-001: the recorder sees what readEnv() derives (sanity)', () => {
|
|
expect(liveKeys.has('DEMO_MODE')).toBe(true);
|
|
expect(liveKeys.has('ALLOWED_ORIGINS')).toBe(true);
|
|
expect(keysReadBy(BOOT_DERIVERS.http).has('TRUST_PROXY')).toBe(true);
|
|
});
|
|
|
|
it.each(namespaces)('CONFIG-OWN-002: no variable the %s token owns is also derived by readEnv()', (ns) => {
|
|
const both = [...keysReadBy(BOOT_DERIVERS[ns])].filter((key) => liveKeys.has(key));
|
|
expect(both).toEqual([]);
|
|
});
|
|
|
|
it('CONFIG-OWN-003: no variable is owned by two tokens', () => {
|
|
const owner = new Map<string, string>();
|
|
const clashes: string[] = [];
|
|
for (const ns of namespaces) {
|
|
for (const key of keysReadBy(BOOT_DERIVERS[ns])) {
|
|
const previous = owner.get(key);
|
|
if (previous) clashes.push(`${key}: ${previous} and ${ns}`);
|
|
owner.set(key, ns);
|
|
}
|
|
}
|
|
expect(clashes).toEqual([]);
|
|
});
|
|
|
|
it('CONFIG-OWN-004: every token registers under its namespace and derives with its BOOT_DERIVERS entry', () => {
|
|
expect(BOOT_STABLE_TOKENS.map((token) => String(token.KEY)).sort()).toEqual(
|
|
namespaces.map((ns) => `CONFIGURATION(${ns})`).sort(),
|
|
);
|
|
for (const token of BOOT_STABLE_TOKENS) {
|
|
const ns = /^CONFIGURATION\((.+)\)$/.exec(String(token.KEY))![1] as BootNamespace;
|
|
expect(token()).toEqual(BOOT_DERIVERS[ns](process.env as RawEnv));
|
|
}
|
|
});
|
|
|
|
it('CONFIG-OWN-005: every token is injected somewhere, so none is dead scaffolding', () => {
|
|
const consumers = sourceFiles(SRC)
|
|
.filter((file) => file !== TOKENS_FILE)
|
|
.map((file) => fs.readFileSync(file, 'utf8'))
|
|
.join('\n');
|
|
const unused = namespaces.filter((ns) => !consumers.includes(`${ns}Config.KEY`));
|
|
expect(unused).toEqual([]);
|
|
});
|
|
|
|
it('CONFIG-OWN-006: every variable either side reads is in the validated schema', () => {
|
|
const schemaKeys = new Set(Object.keys(envSchema.shape));
|
|
const read = new Set([...liveKeys, ...namespaces.flatMap((ns) => [...keysReadBy(BOOT_DERIVERS[ns])])]);
|
|
const unvalidated = [...read].filter((key) => !schemaKeys.has(key) && !PLATFORM_VARIABLES.has(key));
|
|
expect(unvalidated).toEqual([]);
|
|
});
|
|
});
|