From b82a51da80ab5dfc477434bb41870606d205e948 Mon Sep 17 00:00:00 2001 From: "shaggy (mosaic-dev box)" Date: Sun, 9 Aug 2026 20:50:37 -0500 Subject: [PATCH] fix(gateway): load dotenv before federation tier gate (#1138) --- apps/gateway/src/app.module.spec.ts | 96 +++++++++++++++++++++++++++++ apps/gateway/src/app.module.ts | 4 +- apps/gateway/src/env.ts | 14 +++++ apps/gateway/src/main.ts | 15 +---- 4 files changed, 114 insertions(+), 15 deletions(-) create mode 100644 apps/gateway/src/app.module.spec.ts create mode 100644 apps/gateway/src/env.ts diff --git a/apps/gateway/src/app.module.spec.ts b/apps/gateway/src/app.module.spec.ts new file mode 100644 index 00000000..42fdf470 --- /dev/null +++ b/apps/gateway/src/app.module.spec.ts @@ -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 { + 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 => { + 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 => { + 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 => { + const graph = await loadModuleGraphFromDotenv('federated'); + + expect(graph.imports).toContain(graph.federationModule); + }, + MODULE_IMPORT_TIMEOUT_MS, + ); +}); diff --git a/apps/gateway/src/app.module.ts b/apps/gateway/src/app.module.ts index fd0016ad..a0ff2912 100644 --- a/apps/gateway/src/app.module.ts +++ b/apps/gateway/src/app.module.ts @@ -31,7 +31,9 @@ import { loadConfig } from '@mosaicstack/config'; // Federation (step-ca client, enrollment, federation verbs) is only wired for // tier 'federated' — CaService hard-requires STEP_CA_* at construction, which // 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'; @Module({ diff --git a/apps/gateway/src/env.ts b/apps/gateway/src/env.ts new file mode 100644 index 00000000..840c6118 --- /dev/null +++ b/apps/gateway/src/env.ts @@ -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(); diff --git a/apps/gateway/src/main.ts b/apps/gateway/src/main.ts index f70e88a4..2d9e4271 100644 --- a/apps/gateway/src/main.ts +++ b/apps/gateway/src/main.ts @@ -1,18 +1,5 @@ #!/usr/bin/env node -import { config } from 'dotenv'; -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 './env.js'; import './tracing.js'; import 'reflect-metadata'; import { NestFactory } from '@nestjs/core';