fix(gateway): anchor dotenv discovery to module (#1138)

This commit is contained in:
shaggy (mosaic-dev box)
2026-08-09 22:21:17 -05:00
parent b82a51da80
commit 884d527cc8
2 changed files with 418 additions and 49 deletions
+344 -41
View File
@@ -1,64 +1,210 @@
import 'reflect-metadata'; import 'reflect-metadata';
import { existsSync } from 'node:fs';
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import { join } from 'node:path'; import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { MODULE_METADATA } from '@nestjs/common/constants.js'; import { MODULE_METADATA } from '@nestjs/common/constants.js';
import { describe, expect, it, vi } from 'vitest'; import { describe, expect, it, vi } from 'vitest';
interface ComposedModuleGraph { interface ComposedModuleGraph {
imports: readonly unknown[]; imports: readonly unknown[];
federationModule: unknown; federationModule: unknown;
bootLogLines: readonly string[];
}
interface FileSnapshot {
exists: boolean;
contents: Buffer | null;
}
type StorageTier = 'local' | 'standalone' | 'federated';
interface ModuleGraphFixtureOptions {
cwdPath: string;
rootEnvMode?: 'present' | 'absent';
rootTier?: StorageTier;
rootEnvContents?: string;
secret?: string;
gatewayLocalTier?: StorageTier;
gatewayLocalEnvContents?: string;
daemonEnvContents?: string;
inheritedTier?: StorageTier;
expectedProcessTier?: StorageTier;
setup?: () => Promise<void>;
} }
const MODULE_IMPORT_TIMEOUT_MS = 30_000; const MODULE_IMPORT_TIMEOUT_MS = 30_000;
const MONOREPO_ROOT_DOTENV_LABEL = 'monorepo-root .env';
const DAEMON_DOTENV_LABEL = 'daemon .env';
const specDir = dirname(fileURLToPath(import.meta.url));
const monorepoRootDir = resolve(specDir, '../../..');
const gatewayDir = resolve(specDir, '..');
const rootEnvPath = join(monorepoRootDir, '.env');
const gatewayLocalEnvPath = join(gatewayDir, '.env');
function restoreEnvironmentVariable(name: string, value: string | undefined): void { function snapshotProcessEnv(): Record<string, string | undefined> {
if (value === undefined) { return { ...process.env };
delete process.env[name]; }
function restoreProcessEnv(snapshot: Record<string, string | undefined>): void {
for (const key of Object.keys(process.env)) {
if (!(key in snapshot)) {
delete process.env[key];
}
}
for (const [key, value] of Object.entries(snapshot)) {
if (value === undefined) {
delete process.env[key];
continue;
}
process.env[key] = value;
}
}
async function snapshotFile(path: string): Promise<FileSnapshot> {
if (!existsSync(path)) {
return { exists: false, contents: null };
}
return { exists: true, contents: await readFile(path) };
}
async function restoreFile(path: string, snapshot: FileSnapshot): Promise<void> {
if (!snapshot.exists) {
await rm(path, { force: true });
return; return;
} }
process.env[name] = value; await writeFile(path, snapshot.contents ?? Buffer.alloc(0));
}
function singleBootLogLine(bootLogLines: readonly string[]): string {
expect(bootLogLines).toHaveLength(1);
const [bootLogLine] = bootLogLines;
if (bootLogLine === undefined) {
throw new Error('Expected a single boot log line');
}
return bootLogLine;
}
function expectBootLogLine(
bootLogLines: readonly string[],
tier: StorageTier,
source: string,
): void {
const bootLogLine = singleBootLogLine(bootLogLines);
expect(bootLogLine).toContain(`storage tier=${tier}`);
expect(bootLogLine).toContain(`source=${source}`);
} }
async function loadModuleGraphFromDotenv( async function loadModuleGraphFromDotenv(
tier: 'local' | 'standalone' | 'federated', options: ModuleGraphFixtureOptions,
): Promise<ComposedModuleGraph> { ): Promise<ComposedModuleGraph> {
const originalHome = process.env['HOME']; const originalEnv = snapshotProcessEnv();
const originalTier = process.env['MOSAIC_STORAGE_TIER']; const rootSnapshot = await snapshotFile(rootEnvPath);
const originalDatabaseUrl = process.env['DATABASE_URL']; const gatewayLocalSnapshot = await snapshotFile(gatewayLocalEnvPath);
const fixtureRoot = await mkdtemp(join(tmpdir(), 'mosaic-gateway-module-')); const isolatedHome = await mkdtemp(join(tmpdir(), 'mosaic-gateway-home-'));
const gatewayCwd = join(fixtureRoot, 'apps', 'gateway'); const consoleInfoSpy = vi.spyOn(console, 'info').mockImplementation((): void => undefined);
await mkdir(gatewayCwd, { recursive: true }); await mkdir(options.cwdPath, { recursive: true });
await writeFile(join(fixtureRoot, '.env'), `MOSAIC_STORAGE_TIER=${tier}\n`, 'utf8');
process.env['HOME'] = join(fixtureRoot, 'home');
delete process.env['MOSAIC_STORAGE_TIER'];
delete process.env['DATABASE_URL'];
vi.resetModules();
const cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(gatewayCwd);
try { try {
expect(process.env['MOSAIC_STORAGE_TIER']).toBeUndefined(); if ((options.rootEnvMode ?? 'present') === 'absent') {
await import('./env.js'); if (
expect(process.env['MOSAIC_STORAGE_TIER']).toBe(tier); options.rootEnvContents !== undefined ||
options.rootTier !== undefined ||
options.secret !== undefined
) {
throw new Error('Expected no root env fixture values when rootEnvMode is absent');
}
const { AppModule } = await import('./app.module.js'); await rm(rootEnvPath, { force: true });
const { FederationModule } = await import('./federation/federation.module.js'); } else {
const imports: unknown = Reflect.getMetadata(MODULE_METADATA.IMPORTS, AppModule); if (options.rootEnvContents === undefined && options.rootTier === undefined) {
throw new Error('Expected rootTier or rootEnvContents');
}
if (!Array.isArray(imports)) { const rootFixture = options.rootEnvContents ?? `MOSAIC_STORAGE_TIER=${options.rootTier}\n`;
throw new Error('AppModule imports metadata is not an array'); const rootFixtureWithSecret = options.secret
? `${rootFixture}BETTER_AUTH_SECRET=${options.secret}\n`
: rootFixture;
await writeFile(rootEnvPath, rootFixtureWithSecret, 'utf8');
} }
return { imports, federationModule: FederationModule }; if (options.daemonEnvContents !== undefined) {
const daemonEnvPath = join(isolatedHome, '.config', 'mosaic', 'gateway', '.env');
await mkdir(dirname(daemonEnvPath), { recursive: true });
await writeFile(daemonEnvPath, options.daemonEnvContents, 'utf8');
}
if (options.gatewayLocalEnvContents !== undefined) {
await writeFile(gatewayLocalEnvPath, options.gatewayLocalEnvContents, 'utf8');
} else if (options.gatewayLocalTier !== undefined) {
await writeFile(
gatewayLocalEnvPath,
`MOSAIC_STORAGE_TIER=${options.gatewayLocalTier}\n`,
'utf8',
);
} else if (existsSync(gatewayLocalEnvPath)) {
await rm(gatewayLocalEnvPath, { force: true });
}
process.env['HOME'] = isolatedHome;
delete process.env['MOSAIC_STORAGE_TIER'];
delete process.env['DATABASE_URL'];
delete process.env['VALKEY_URL'];
await options.setup?.();
if (options.inheritedTier !== undefined) {
process.env['MOSAIC_STORAGE_TIER'] = options.inheritedTier;
}
vi.resetModules();
const cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(options.cwdPath);
try {
if (options.inheritedTier === undefined) {
expect(process.env['MOSAIC_STORAGE_TIER']).toBeUndefined();
} else {
expect(process.env['MOSAIC_STORAGE_TIER']).toBe(options.inheritedTier);
}
await import('./env.js');
expect(process.env['MOSAIC_STORAGE_TIER']).toBe(
options.expectedProcessTier ?? options.rootTier,
);
const { AppModule } = await import('./app.module.js');
const { FederationModule } = await import('./federation/federation.module.js');
const imports: unknown = Reflect.getMetadata(MODULE_METADATA.IMPORTS, AppModule);
if (!Array.isArray(imports)) {
throw new Error('AppModule imports metadata is not an array');
}
return {
imports,
federationModule: FederationModule,
bootLogLines: consoleInfoSpy.mock.calls.map((args: readonly unknown[]): string =>
args.map((value: unknown): string => String(value)).join(' '),
),
};
} finally {
cwdSpy.mockRestore();
}
} finally { } finally {
cwdSpy.mockRestore(); consoleInfoSpy.mockRestore();
restoreEnvironmentVariable('HOME', originalHome); restoreProcessEnv(originalEnv);
restoreEnvironmentVariable('MOSAIC_STORAGE_TIER', originalTier); await restoreFile(rootEnvPath, rootSnapshot);
restoreEnvironmentVariable('DATABASE_URL', originalDatabaseUrl); await restoreFile(gatewayLocalEnvPath, gatewayLocalSnapshot);
await rm(fixtureRoot, { recursive: true, force: true }); await rm(isolatedHome, { recursive: true, force: true });
} }
} }
@@ -74,22 +220,179 @@ describe('AppModule federation gating', (): void => {
expect(envImportIndex).toBeLessThan(appModuleImportIndex); expect(envImportIndex).toBeLessThan(appModuleImportIndex);
}); });
it.each(['local', 'standalone'] as const)( it(
'does not register FederationModule for the %s tier', 'ignores attacker-writable ambient cwd dotenv files',
async (tier): Promise<void> => { async (): Promise<void> => {
const graph = await loadModuleGraphFromDotenv(tier); const ambientRoot = await mkdtemp(join(tmpdir(), 'mosaic-gateway-ambient-'));
const ambientCwd = join(ambientRoot, 'sandbox', 'cwd');
expect(graph.imports).not.toContain(graph.federationModule); try {
const graph = await loadModuleGraphFromDotenv({
cwdPath: ambientCwd,
rootTier: 'local',
setup: async (): Promise<void> => {
await writeFile(join(ambientCwd, '.env'), 'MOSAIC_STORAGE_TIER=federated\n', 'utf8');
await writeFile(join(ambientRoot, '.env'), 'MOSAIC_STORAGE_TIER=federated\n', 'utf8');
},
});
expect(graph.imports).not.toContain(graph.federationModule);
expectBootLogLine(graph.bootLogLines, 'local', MONOREPO_ROOT_DOTENV_LABEL);
} finally {
await rm(ambientRoot, { recursive: true, force: true });
}
}, },
MODULE_IMPORT_TIMEOUT_MS, MODULE_IMPORT_TIMEOUT_MS,
); );
it( it(
'registers FederationModule when federated tier is supplied only by a dotenv file', 'logs standalone from a monorepo-root .env DATABASE_URL fallback',
async (): Promise<void> => { async (): Promise<void> => {
const graph = await loadModuleGraphFromDotenv('federated'); const cwdPath = await mkdtemp(join(tmpdir(), 'mosaic-gateway-cwd-'));
expect(graph.imports).toContain(graph.federationModule); try {
const graph = await loadModuleGraphFromDotenv({
cwdPath,
rootEnvContents: 'DATABASE_URL=fixture-database-url\n',
});
expect(graph.imports).not.toContain(graph.federationModule);
expectBootLogLine(graph.bootLogLines, 'standalone', MONOREPO_ROOT_DOTENV_LABEL);
} finally {
await rm(cwdPath, { recursive: true, force: true });
}
},
MODULE_IMPORT_TIMEOUT_MS,
);
it(
'daemon .env wins over monorepo-root and gateway-local tier values',
async (): Promise<void> => {
const cwdPath = await mkdtemp(join(tmpdir(), 'mosaic-gateway-cwd-'));
try {
const graph = await loadModuleGraphFromDotenv({
cwdPath,
rootTier: 'local',
gatewayLocalTier: 'federated',
daemonEnvContents: 'MOSAIC_STORAGE_TIER=standalone\n',
expectedProcessTier: 'standalone',
});
expect(graph.imports).not.toContain(graph.federationModule);
expectBootLogLine(graph.bootLogLines, 'standalone', DAEMON_DOTENV_LABEL);
} finally {
await rm(cwdPath, { recursive: true, force: true });
}
},
MODULE_IMPORT_TIMEOUT_MS,
);
it(
'inherits process.env.MOSAIC_STORAGE_TIER over daemon, monorepo-root, and gateway-local dotenv values',
async (): Promise<void> => {
const cwdPath = await mkdtemp(join(tmpdir(), 'mosaic-gateway-cwd-'));
try {
const graph = await loadModuleGraphFromDotenv({
cwdPath,
rootTier: 'local',
gatewayLocalTier: 'federated',
daemonEnvContents: 'MOSAIC_STORAGE_TIER=federated\n',
inheritedTier: 'standalone',
expectedProcessTier: 'standalone',
});
expect(graph.imports).not.toContain(graph.federationModule);
expectBootLogLine(graph.bootLogLines, 'standalone', 'process environment');
} finally {
await rm(cwdPath, { recursive: true, force: true });
}
},
MODULE_IMPORT_TIMEOUT_MS,
);
it(
'gateway-local .env configures the tier and source when the monorepo-root .env is absent',
async (): Promise<void> => {
const cwdPath = await mkdtemp(join(tmpdir(), 'mosaic-gateway-cwd-'));
try {
const graph = await loadModuleGraphFromDotenv({
cwdPath,
rootEnvMode: 'absent',
gatewayLocalTier: 'federated',
expectedProcessTier: 'federated',
});
expect(graph.imports).toContain(graph.federationModule);
expectBootLogLine(graph.bootLogLines, 'federated', 'gateway-local .env');
} finally {
await rm(cwdPath, { recursive: true, force: true });
}
},
MODULE_IMPORT_TIMEOUT_MS,
);
it(
'monorepo-root .env wins over gateway-local tier values',
async (): Promise<void> => {
const cwdPath = await mkdtemp(join(tmpdir(), 'mosaic-gateway-cwd-'));
try {
const graph = await loadModuleGraphFromDotenv({
cwdPath,
rootTier: 'standalone',
gatewayLocalTier: 'federated',
});
expect(graph.imports).not.toContain(graph.federationModule);
expectBootLogLine(graph.bootLogLines, 'standalone', MONOREPO_ROOT_DOTENV_LABEL);
} finally {
await rm(cwdPath, { recursive: true, force: true });
}
},
MODULE_IMPORT_TIMEOUT_MS,
);
it.each(['local', 'standalone'] as const)(
'does not register FederationModule for the %s tier',
async (tier): Promise<void> => {
const cwdPath = await mkdtemp(join(tmpdir(), 'mosaic-gateway-cwd-'));
try {
const graph = await loadModuleGraphFromDotenv({
cwdPath,
rootTier: tier,
});
expect(graph.imports).not.toContain(graph.federationModule);
expectBootLogLine(graph.bootLogLines, tier, MONOREPO_ROOT_DOTENV_LABEL);
} finally {
await rm(cwdPath, { recursive: true, force: true });
}
},
MODULE_IMPORT_TIMEOUT_MS,
);
it(
'registers FederationModule when federated tier is supplied by the anchored monorepo root .env',
async (): Promise<void> => {
const cwdPath = await mkdtemp(join(tmpdir(), 'mosaic-gateway-cwd-'));
try {
const graph = await loadModuleGraphFromDotenv({
cwdPath,
rootTier: 'federated',
secret: 'super-secret-fixture-value',
});
expect(graph.imports).toContain(graph.federationModule);
expectBootLogLine(graph.bootLogLines, 'federated', MONOREPO_ROOT_DOTENV_LABEL);
expect(singleBootLogLine(graph.bootLogLines)).not.toContain('super-secret-fixture-value');
} finally {
await rm(cwdPath, { recursive: true, force: true });
}
}, },
MODULE_IMPORT_TIMEOUT_MS, MODULE_IMPORT_TIMEOUT_MS,
); );
+74 -8
View File
@@ -1,14 +1,80 @@
import { config } from 'dotenv'; import { config } from 'dotenv';
import { existsSync } from 'node:fs'; import { existsSync } from 'node:fs';
import { homedir } from 'node:os'; import { homedir } from 'node:os';
import { join, resolve } from 'node:path'; import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { detectFromEnv, loadConfig } from '@mosaicstack/config';
// Load .env from daemon config dir (global install / daemon mode). type TierSource =
// It takes precedence over file-based local-dev configuration. | 'process environment'
| 'daemon .env'
| 'monorepo-root .env'
| 'gateway-local .env'
| 'default';
type BootSource = TierSource | 'mosaic.config.json';
const here = dirname(fileURLToPath(import.meta.url));
const daemonEnv = join(homedir(), '.config', 'mosaic', 'gateway', '.env'); const daemonEnv = join(homedir(), '.config', 'mosaic', 'gateway', '.env');
if (existsSync(daemonEnv)) config({ path: daemonEnv }); const monorepoRootEnv = resolve(here, '../../..', '.env');
const gatewayLocalEnv = resolve(here, '..', '.env');
// Load .env from monorepo root (cwd is apps/gateway when run via pnpm filter), const inheritedTier = process.env['MOSAIC_STORAGE_TIER'];
// then fill any remaining values from apps/gateway/.env when present. let tierSource: TierSource = inheritedTier === undefined ? 'default' : 'process environment';
config({ path: resolve(process.cwd(), '../../.env') }); const inheritedDatabaseUrl = process.env['DATABASE_URL'];
config(); let databaseUrlSource: TierSource =
inheritedDatabaseUrl === undefined ? 'default' : 'process environment';
function loadAnchoredDotenv(
path: string,
sourceLabel: Exclude<TierSource, 'process environment' | 'default'>,
): void {
if (!existsSync(path)) {
return;
}
const beforeTier = process.env['MOSAIC_STORAGE_TIER'];
const beforeDatabaseUrl = process.env['DATABASE_URL'];
config({ path, quiet: true });
if (
beforeTier === undefined &&
process.env['MOSAIC_STORAGE_TIER'] !== undefined &&
tierSource === 'default'
) {
tierSource = sourceLabel;
}
if (
beforeDatabaseUrl === undefined &&
process.env['DATABASE_URL'] !== undefined &&
databaseUrlSource === 'default'
) {
databaseUrlSource = sourceLabel;
}
}
// Load .env from daemon config dir (global install / daemon mode) first.
// It takes precedence over file-based local-dev configuration.
loadAnchoredDotenv(daemonEnv, 'daemon .env');
// Load .env from the anchored monorepo root, then fill any remaining values
// from apps/gateway/.env when present.
loadAnchoredDotenv(monorepoRootEnv, 'monorepo-root .env');
loadAnchoredDotenv(gatewayLocalEnv, 'gateway-local .env');
const envOnlyTier = detectFromEnv().tier;
const resolvedTier = loadConfig().tier;
let source: BootSource;
if (resolvedTier !== envOnlyTier) {
source = 'mosaic.config.json';
} else if (tierSource !== 'default') {
source = tierSource;
} else if (envOnlyTier === 'standalone' && databaseUrlSource !== 'default') {
source = databaseUrlSource;
} else {
source = 'default';
}
console.info(`[gateway env] storage tier=${resolvedTier} source=${source}`);