fix(gateway): load dotenv before federation tier gate (#1138)

This commit is contained in:
shaggy (mosaic-dev box)
2026-08-09 20:50:37 -05:00
parent b4753a75cd
commit b82a51da80
4 changed files with 114 additions and 15 deletions
+96
View File
@@ -0,0 +1,96 @@
import 'reflect-metadata';
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { MODULE_METADATA } from '@nestjs/common/constants.js';
import { describe, expect, it, vi } from 'vitest';
interface ComposedModuleGraph {
imports: readonly unknown[];
federationModule: unknown;
}
const MODULE_IMPORT_TIMEOUT_MS = 30_000;
function restoreEnvironmentVariable(name: string, value: string | undefined): void {
if (value === undefined) {
delete process.env[name];
return;
}
process.env[name] = value;
}
async function loadModuleGraphFromDotenv(
tier: 'local' | 'standalone' | 'federated',
): Promise<ComposedModuleGraph> {
const originalHome = process.env['HOME'];
const originalTier = process.env['MOSAIC_STORAGE_TIER'];
const originalDatabaseUrl = process.env['DATABASE_URL'];
const fixtureRoot = await mkdtemp(join(tmpdir(), 'mosaic-gateway-module-'));
const gatewayCwd = join(fixtureRoot, 'apps', 'gateway');
await mkdir(gatewayCwd, { 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 {
expect(process.env['MOSAIC_STORAGE_TIER']).toBeUndefined();
await import('./env.js');
expect(process.env['MOSAIC_STORAGE_TIER']).toBe(tier);
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 };
} finally {
cwdSpy.mockRestore();
restoreEnvironmentVariable('HOME', originalHome);
restoreEnvironmentVariable('MOSAIC_STORAGE_TIER', originalTier);
restoreEnvironmentVariable('DATABASE_URL', originalDatabaseUrl);
await rm(fixtureRoot, { recursive: true, force: true });
}
}
describe('AppModule federation gating', (): void => {
it('loads dotenv before tracing and AppModule evaluation', async (): Promise<void> => {
const mainSource = await readFile(new URL('./main.ts', import.meta.url), 'utf8');
const envImportIndex = mainSource.indexOf("import './env.js';");
const tracingImportIndex = mainSource.indexOf("import './tracing.js';");
const appModuleImportIndex = mainSource.indexOf("import { AppModule } from './app.module.js';");
expect(envImportIndex).toBeGreaterThan(-1);
expect(envImportIndex).toBeLessThan(tracingImportIndex);
expect(envImportIndex).toBeLessThan(appModuleImportIndex);
});
it.each(['local', 'standalone'] as const)(
'does not register FederationModule for the %s tier',
async (tier): Promise<void> => {
const graph = await loadModuleGraphFromDotenv(tier);
expect(graph.imports).not.toContain(graph.federationModule);
},
MODULE_IMPORT_TIMEOUT_MS,
);
it(
'registers FederationModule when federated tier is supplied only by a dotenv file',
async (): Promise<void> => {
const graph = await loadModuleGraphFromDotenv('federated');
expect(graph.imports).toContain(graph.federationModule);
},
MODULE_IMPORT_TIMEOUT_MS,
);
});
+3 -1
View File
@@ -31,7 +31,9 @@ import { loadConfig } from '@mosaicstack/config';
// Federation (step-ca client, enrollment, federation verbs) is only wired for // Federation (step-ca client, enrollment, federation verbs) is only wired for
// tier 'federated' — CaService hard-requires STEP_CA_* at construction, which // tier 'federated' — CaService hard-requires STEP_CA_* at construction, which
// must not gate standalone/local boots (docker-compose.federated.yml: the // must not gate standalone/local boots (docker-compose.federated.yml: the
// federation profile "must not start in non-federated dev"). // federation profile "must not start in non-federated dev"). The gateway
// entrypoint loads env.ts before evaluating this module so dotenv-backed tier
// configuration is visible here.
const federationEnabled = loadConfig().tier === 'federated'; const federationEnabled = loadConfig().tier === 'federated';
@Module({ @Module({
+14
View File
@@ -0,0 +1,14 @@
import { config } from 'dotenv';
import { existsSync } from 'node:fs';
import { homedir } from 'node:os';
import { join, resolve } from 'node:path';
// Load .env from daemon config dir (global install / daemon mode).
// It takes precedence over file-based local-dev configuration.
const daemonEnv = join(homedir(), '.config', 'mosaic', 'gateway', '.env');
if (existsSync(daemonEnv)) config({ path: daemonEnv });
// Load .env from monorepo root (cwd is apps/gateway when run via pnpm filter),
// then fill any remaining values from apps/gateway/.env when present.
config({ path: resolve(process.cwd(), '../../.env') });
config();
+1 -14
View File
@@ -1,18 +1,5 @@
#!/usr/bin/env node #!/usr/bin/env node
import { config } from 'dotenv'; import './env.js';
import { existsSync } from 'node:fs';
import { resolve, join } from 'node:path';
import { homedir } from 'node:os';
// Load .env from daemon config dir (global install / daemon mode).
// Loaded first so monorepo .env can override for local dev.
const daemonEnv = join(homedir(), '.config', 'mosaic', 'gateway', '.env');
if (existsSync(daemonEnv)) config({ path: daemonEnv });
// Load .env from monorepo root (cwd is apps/gateway when run via pnpm filter)
config({ path: resolve(process.cwd(), '../../.env') });
config(); // Also load apps/gateway/.env if present (overrides)
import './tracing.js'; import './tracing.js';
import 'reflect-metadata'; import 'reflect-metadata';
import { NestFactory } from '@nestjs/core'; import { NestFactory } from '@nestjs/core';