From 2d5a8c81ec2a17a0ae4aee13ecdee9145c2b8f22 Mon Sep 17 00:00:00 2001 From: "shaggy (mosaic-dev box)" Date: Sun, 9 Aug 2026 22:53:43 -0500 Subject: [PATCH] fix(gateway): anchor config discovery and isolate env tests (#1138) --- apps/gateway/src/app.module.spec.ts | 502 +++++++++++++++++----------- apps/gateway/src/app.module.ts | 3 +- apps/gateway/src/env.ts | 157 +++++---- 3 files changed, 404 insertions(+), 258 deletions(-) diff --git a/apps/gateway/src/app.module.spec.ts b/apps/gateway/src/app.module.spec.ts index 4096acb7..02887fd1 100644 --- a/apps/gateway/src/app.module.spec.ts +++ b/apps/gateway/src/app.module.spec.ts @@ -1,9 +1,8 @@ import 'reflect-metadata'; -import { existsSync } from 'node:fs'; import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { dirname, join, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; +import * as nodeOs from 'node:os'; +import { dirname, isAbsolute, join, relative, resolve } from 'node:path'; +import * as nodeUrl from 'node:url'; import { MODULE_METADATA } from '@nestjs/common/constants.js'; import { describe, expect, it, vi } from 'vitest'; @@ -13,35 +12,54 @@ interface ComposedModuleGraph { bootLogLines: readonly string[]; } -interface FileSnapshot { - exists: boolean; - contents: Buffer | null; -} - type StorageTier = 'local' | 'standalone' | 'federated'; -interface ModuleGraphFixtureOptions { +interface ModuleGraphFixture { + tempRoot: string; + anchor: string; + homePath: string; cwdPath: string; + monorepoRootEnvPath: string; + gatewayLocalEnvPath: string; + daemonEnvPath: string; + monorepoRootConfigPath: string; + gatewayLocalConfigPath: string; +} + +interface ModuleGraphFixtureOptions { rootEnvMode?: 'present' | 'absent'; rootTier?: StorageTier; rootEnvContents?: string; - secret?: string; + redactionMarker?: string; gatewayLocalTier?: StorageTier; gatewayLocalEnvContents?: string; daemonEnvContents?: string; inheritedTier?: StorageTier; - expectedProcessTier?: StorageTier; - setup?: () => Promise; + expectedProcessTier?: string; + setup?: (fixture: ModuleGraphFixture) => Promise; } 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 configJson(tier: StorageTier): string { + if (tier === 'local') { + return JSON.stringify({ + tier, + storage: { type: 'pglite', dataDir: '.mosaic/storage-pglite' }, + queue: { type: 'local', dataDir: '.mosaic/queue' }, + memory: { type: 'keyword' }, + }); + } + + return JSON.stringify({ + tier, + storage: { type: 'postgres', url: 'postgresql://fixture.invalid/mosaic' }, + queue: { type: 'bullmq' }, + memory: { type: tier === 'federated' ? 'pgvector' : 'keyword' }, + }); +} function snapshotProcessEnv(): Record { return { ...process.env }; @@ -64,21 +82,17 @@ function restoreProcessEnv(snapshot: Record): void { } } -async function snapshotFile(path: string): Promise { - if (!existsSync(path)) { - return { exists: false, contents: null }; - } - - return { exists: true, contents: await readFile(path) }; +function expectPathUnderTempRoot(path: string, tempRoot: string): void { + const relativePath = relative(tempRoot, path); + expect(relativePath === '' || (!relativePath.startsWith('..') && !isAbsolute(relativePath))).toBe( + true, + ); } -async function restoreFile(path: string, snapshot: FileSnapshot): Promise { - if (!snapshot.exists) { - await rm(path, { force: true }); - return; - } - - await writeFile(path, snapshot.contents ?? Buffer.alloc(0)); +async function writeFixture(path: string, contents: string, tempRoot: string): Promise { + expectPathUnderTempRoot(path, tempRoot); + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, contents, 'utf8'); } function singleBootLogLine(bootLogLines: readonly string[]): string { @@ -106,105 +120,130 @@ async function loadModuleGraphFromDotenv( options: ModuleGraphFixtureOptions, ): Promise { const originalEnv = snapshotProcessEnv(); - const rootSnapshot = await snapshotFile(rootEnvPath); - const gatewayLocalSnapshot = await snapshotFile(gatewayLocalEnvPath); - const isolatedHome = await mkdtemp(join(tmpdir(), 'mosaic-gateway-home-')); - const consoleInfoSpy = vi.spyOn(console, 'info').mockImplementation((): void => undefined); - - await mkdir(options.cwdPath, { recursive: true }); + const tempRoot = await mkdtemp(join(nodeOs.tmpdir(), 'mosaic-gateway-module-')); + let consoleInfoSpy: ReturnType | undefined; + let cwdSpy: ReturnType | undefined; try { + const anchor = join(tempRoot, 'anchored', 'apps', 'gateway', 'src'); + const homePath = join(tempRoot, 'home'); + const cwdPath = join(tempRoot, 'ambient', 'parent', 'cwd'); + const fixture: ModuleGraphFixture = { + tempRoot, + anchor, + homePath, + cwdPath, + monorepoRootEnvPath: resolve(anchor, '../../..', '.env'), + gatewayLocalEnvPath: resolve(anchor, '..', '.env'), + daemonEnvPath: join(homePath, '.config', 'mosaic', 'gateway', '.env'), + monorepoRootConfigPath: resolve(anchor, '../../..', 'mosaic.config.json'), + gatewayLocalConfigPath: resolve(anchor, '..', 'mosaic.config.json'), + }; + consoleInfoSpy = vi.spyOn(console, 'info').mockImplementation((): void => undefined); + + for (const path of Object.values(fixture)) { + expectPathUnderTempRoot(path, tempRoot); + } + + await mkdir(anchor, { recursive: true }); + await mkdir(cwdPath, { recursive: true }); + if ((options.rootEnvMode ?? 'present') === 'absent') { if ( options.rootEnvContents !== undefined || options.rootTier !== undefined || - options.secret !== undefined + options.redactionMarker !== undefined ) { throw new Error('Expected no root env fixture values when rootEnvMode is absent'); } - - await rm(rootEnvPath, { force: true }); } else { if (options.rootEnvContents === undefined && options.rootTier === undefined) { throw new Error('Expected rootTier or rootEnvContents'); } const rootFixture = options.rootEnvContents ?? `MOSAIC_STORAGE_TIER=${options.rootTier}\n`; - const rootFixtureWithSecret = options.secret - ? `${rootFixture}BETTER_AUTH_SECRET=${options.secret}\n` + const rootFixtureWithMarker = options.redactionMarker + ? `${rootFixture}BETTER_AUTH_SECRET=${options.redactionMarker}\n` : rootFixture; - - await writeFile(rootEnvPath, rootFixtureWithSecret, 'utf8'); + await writeFixture(fixture.monorepoRootEnvPath, rootFixtureWithMarker, tempRoot); } if (options.daemonEnvContents !== undefined) { - const daemonEnvPath = join(isolatedHome, '.config', 'mosaic', 'gateway', '.env'); - await mkdir(dirname(daemonEnvPath), { recursive: true }); - await writeFile(daemonEnvPath, options.daemonEnvContents, 'utf8'); + await writeFixture(fixture.daemonEnvPath, options.daemonEnvContents, tempRoot); } if (options.gatewayLocalEnvContents !== undefined) { - await writeFile(gatewayLocalEnvPath, options.gatewayLocalEnvContents, 'utf8'); + await writeFixture(fixture.gatewayLocalEnvPath, options.gatewayLocalEnvContents, tempRoot); } else if (options.gatewayLocalTier !== undefined) { - await writeFile( - gatewayLocalEnvPath, + await writeFixture( + fixture.gatewayLocalEnvPath, `MOSAIC_STORAGE_TIER=${options.gatewayLocalTier}\n`, - 'utf8', + tempRoot, ); - } else if (existsSync(gatewayLocalEnvPath)) { - await rm(gatewayLocalEnvPath, { force: true }); } - process.env['HOME'] = isolatedHome; + process.env['HOME'] = homePath; delete process.env['MOSAIC_STORAGE_TIER']; delete process.env['DATABASE_URL']; delete process.env['VALKEY_URL']; - await options.setup?.(); + await options.setup?.(fixture); if (options.inheritedTier !== undefined) { process.env['MOSAIC_STORAGE_TIER'] = options.inheritedTier; } vi.resetModules(); - const cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(options.cwdPath); + vi.doMock('node:os', () => ({ ...nodeOs, homedir: (): string => homePath })); + vi.doMock('node:url', () => ({ + ...nodeUrl, + fileURLToPath: (url: string | URL): string => { + const actualPath = nodeUrl.fileURLToPath(url); + if ( + actualPath.endsWith('/apps/gateway/src/env.ts') || + actualPath.endsWith('/apps/gateway/src/env.js') + ) { + return join(anchor, 'env.ts'); + } + return actualPath; + }, + })); + cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(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(); + 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 { - consoleInfoSpy.mockRestore(); + cwdSpy?.mockRestore(); + vi.doUnmock('node:url'); + vi.doUnmock('node:os'); + vi.resetModules(); + consoleInfoSpy?.mockRestore(); restoreProcessEnv(originalEnv); - await restoreFile(rootEnvPath, rootSnapshot); - await restoreFile(gatewayLocalEnvPath, gatewayLocalSnapshot); - await rm(isolatedHome, { recursive: true, force: true }); + await rm(tempRoot, { recursive: true, force: true }); } } @@ -221,26 +260,107 @@ describe('AppModule federation gating', (): void => { }); it( - 'ignores attacker-writable ambient cwd dotenv files', + 'ignores ambient cwd/.env and cwd/../.env files', async (): Promise => { - const ambientRoot = await mkdtemp(join(tmpdir(), 'mosaic-gateway-ambient-')); - const ambientCwd = join(ambientRoot, 'sandbox', 'cwd'); + const graph = await loadModuleGraphFromDotenv({ + rootTier: 'local', + setup: async (fixture: ModuleGraphFixture): Promise => { + await writeFixture( + join(fixture.cwdPath, '.env'), + 'MOSAIC_STORAGE_TIER=federated\n', + fixture.tempRoot, + ); + await writeFixture( + resolve(fixture.cwdPath, '..', '.env'), + 'MOSAIC_STORAGE_TIER=federated\n', + fixture.tempRoot, + ); + }, + }); - try { - const graph = await loadModuleGraphFromDotenv({ - cwdPath: ambientCwd, - rootTier: 'local', - setup: async (): Promise => { - 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); + }, + MODULE_IMPORT_TIMEOUT_MS, + ); - expect(graph.imports).not.toContain(graph.federationModule); - expectBootLogLine(graph.bootLogLines, 'local', MONOREPO_ROOT_DOTENV_LABEL); - } finally { - await rm(ambientRoot, { recursive: true, force: true }); - } + it( + 'ignores an ambient cwd/mosaic.config.json federated config', + async (): Promise => { + const graph = await loadModuleGraphFromDotenv({ + rootTier: 'local', + setup: async (fixture: ModuleGraphFixture): Promise => { + await writeFixture( + join(fixture.cwdPath, 'mosaic.config.json'), + configJson('federated'), + fixture.tempRoot, + ); + }, + }); + + expect(graph.imports).not.toContain(graph.federationModule); + expectBootLogLine(graph.bootLogLines, 'local', MONOREPO_ROOT_DOTENV_LABEL); + }, + MODULE_IMPORT_TIMEOUT_MS, + ); + + it( + 'ignores an ambient cwd/../../mosaic.config.json federated config', + async (): Promise => { + const graph = await loadModuleGraphFromDotenv({ + rootTier: 'local', + setup: async (fixture: ModuleGraphFixture): Promise => { + await writeFixture( + resolve(fixture.cwdPath, '../..', 'mosaic.config.json'), + configJson('federated'), + fixture.tempRoot, + ); + }, + }); + + expect(graph.imports).not.toContain(graph.federationModule); + expectBootLogLine(graph.bootLogLines, 'local', MONOREPO_ROOT_DOTENV_LABEL); + }, + MODULE_IMPORT_TIMEOUT_MS, + ); + + it( + 'anchored monorepo-root config wins gateway-local config and registers FederationModule', + async (): Promise => { + const graph = await loadModuleGraphFromDotenv({ + rootTier: 'local', + setup: async (fixture: ModuleGraphFixture): Promise => { + await writeFixture( + fixture.monorepoRootConfigPath, + configJson('federated'), + fixture.tempRoot, + ); + await writeFixture(fixture.gatewayLocalConfigPath, configJson('local'), fixture.tempRoot); + }, + }); + + expect(graph.imports).toContain(graph.federationModule); + expectBootLogLine(graph.bootLogLines, 'federated', 'mosaic.config.json'); + }, + MODULE_IMPORT_TIMEOUT_MS, + ); + + it( + 'logs mosaic.config.json when anchored config and env tiers are both federated', + async (): Promise => { + const graph = await loadModuleGraphFromDotenv({ + rootTier: 'federated', + setup: async (fixture: ModuleGraphFixture): Promise => { + await writeFixture( + fixture.monorepoRootConfigPath, + configJson('federated'), + fixture.tempRoot, + ); + }, + }); + + expect(graph.imports).toContain(graph.federationModule); + expectBootLogLine(graph.bootLogLines, 'federated', 'mosaic.config.json'); }, MODULE_IMPORT_TIMEOUT_MS, ); @@ -248,19 +368,42 @@ describe('AppModule federation gating', (): void => { it( 'logs standalone from a monorepo-root .env DATABASE_URL fallback', async (): Promise => { - const cwdPath = await mkdtemp(join(tmpdir(), 'mosaic-gateway-cwd-')); + const graph = await loadModuleGraphFromDotenv({ + rootEnvContents: 'DATABASE_URL=fixture-database-url\n', + }); - 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); + }, + MODULE_IMPORT_TIMEOUT_MS, + ); - expect(graph.imports).not.toContain(graph.federationModule); - expectBootLogLine(graph.bootLogLines, 'standalone', MONOREPO_ROOT_DOTENV_LABEL); - } finally { - await rm(cwdPath, { recursive: true, force: true }); - } + it( + 'attributes an invalid monorepo-root dotenv tier to the default', + async (): Promise => { + const graph = await loadModuleGraphFromDotenv({ + rootEnvContents: 'MOSAIC_STORAGE_TIER=invalid\n', + expectedProcessTier: 'invalid', + }); + + expect(graph.imports).not.toContain(graph.federationModule); + expectBootLogLine(graph.bootLogLines, 'local', 'default'); + }, + MODULE_IMPORT_TIMEOUT_MS, + ); + + it( + 'attributes DATABASE_URL fallback to daemon .env ahead of inherited local tier', + async (): Promise => { + const graph = await loadModuleGraphFromDotenv({ + rootEnvMode: 'absent', + daemonEnvContents: 'DATABASE_URL=fixture-database-url\n', + inheritedTier: 'local', + expectedProcessTier: 'local', + }); + + expect(graph.imports).not.toContain(graph.federationModule); + expectBootLogLine(graph.bootLogLines, 'standalone', DAEMON_DOTENV_LABEL); }, MODULE_IMPORT_TIMEOUT_MS, ); @@ -268,22 +411,15 @@ describe('AppModule federation gating', (): void => { it( 'daemon .env wins over monorepo-root and gateway-local tier values', async (): Promise => { - const cwdPath = await mkdtemp(join(tmpdir(), 'mosaic-gateway-cwd-')); + const graph = await loadModuleGraphFromDotenv({ + rootTier: 'local', + gatewayLocalTier: 'federated', + daemonEnvContents: 'MOSAIC_STORAGE_TIER=standalone\n', + expectedProcessTier: 'standalone', + }); - 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 }); - } + expect(graph.imports).not.toContain(graph.federationModule); + expectBootLogLine(graph.bootLogLines, 'standalone', DAEMON_DOTENV_LABEL); }, MODULE_IMPORT_TIMEOUT_MS, ); @@ -291,23 +427,16 @@ describe('AppModule federation gating', (): void => { it( 'inherits process.env.MOSAIC_STORAGE_TIER over daemon, monorepo-root, and gateway-local dotenv values', async (): Promise => { - const cwdPath = await mkdtemp(join(tmpdir(), 'mosaic-gateway-cwd-')); + const graph = await loadModuleGraphFromDotenv({ + rootTier: 'local', + gatewayLocalTier: 'federated', + daemonEnvContents: 'MOSAIC_STORAGE_TIER=federated\n', + inheritedTier: 'standalone', + expectedProcessTier: 'standalone', + }); - 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 }); - } + expect(graph.imports).not.toContain(graph.federationModule); + expectBootLogLine(graph.bootLogLines, 'standalone', 'process environment'); }, MODULE_IMPORT_TIMEOUT_MS, ); @@ -315,21 +444,14 @@ describe('AppModule federation gating', (): void => { it( 'gateway-local .env configures the tier and source when the monorepo-root .env is absent', async (): Promise => { - const cwdPath = await mkdtemp(join(tmpdir(), 'mosaic-gateway-cwd-')); + const graph = await loadModuleGraphFromDotenv({ + rootEnvMode: 'absent', + gatewayLocalTier: 'federated', + expectedProcessTier: 'federated', + }); - 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 }); - } + expect(graph.imports).toContain(graph.federationModule); + expectBootLogLine(graph.bootLogLines, 'federated', 'gateway-local .env'); }, MODULE_IMPORT_TIMEOUT_MS, ); @@ -337,20 +459,13 @@ describe('AppModule federation gating', (): void => { it( 'monorepo-root .env wins over gateway-local tier values', async (): Promise => { - const cwdPath = await mkdtemp(join(tmpdir(), 'mosaic-gateway-cwd-')); + const graph = await loadModuleGraphFromDotenv({ + rootTier: 'standalone', + gatewayLocalTier: 'federated', + }); - 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 }); - } + expect(graph.imports).not.toContain(graph.federationModule); + expectBootLogLine(graph.bootLogLines, 'standalone', MONOREPO_ROOT_DOTENV_LABEL); }, MODULE_IMPORT_TIMEOUT_MS, ); @@ -358,19 +473,10 @@ describe('AppModule federation gating', (): void => { it.each(['local', 'standalone'] as const)( 'does not register FederationModule for the %s tier', async (tier): Promise => { - const cwdPath = await mkdtemp(join(tmpdir(), 'mosaic-gateway-cwd-')); + const graph = await loadModuleGraphFromDotenv({ rootTier: tier }); - 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 }); - } + expect(graph.imports).not.toContain(graph.federationModule); + expectBootLogLine(graph.bootLogLines, tier, MONOREPO_ROOT_DOTENV_LABEL); }, MODULE_IMPORT_TIMEOUT_MS, ); @@ -378,21 +484,15 @@ describe('AppModule federation gating', (): void => { it( 'registers FederationModule when federated tier is supplied by the anchored monorepo root .env', async (): Promise => { - const cwdPath = await mkdtemp(join(tmpdir(), 'mosaic-gateway-cwd-')); + const redactionMarker = 'redaction-fixture-marker'; + const graph = await loadModuleGraphFromDotenv({ + rootTier: 'federated', + redactionMarker, + }); - 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 }); - } + expect(graph.imports).toContain(graph.federationModule); + expectBootLogLine(graph.bootLogLines, 'federated', MONOREPO_ROOT_DOTENV_LABEL); + expect(singleBootLogLine(graph.bootLogLines)).not.toContain(redactionMarker); }, MODULE_IMPORT_TIMEOUT_MS, ); diff --git a/apps/gateway/src/app.module.ts b/apps/gateway/src/app.module.ts index a0ff2912..8adfabe8 100644 --- a/apps/gateway/src/app.module.ts +++ b/apps/gateway/src/app.module.ts @@ -27,6 +27,7 @@ import { QueueModule } from './queue/queue.module.js'; import { FederationModule } from './federation/federation.module.js'; import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler'; import { loadConfig } from '@mosaicstack/config'; +import { resolveGatewayConfigPath } from './env.js'; // Federation (step-ca client, enrollment, federation verbs) is only wired for // tier 'federated' — CaService hard-requires STEP_CA_* at construction, which @@ -34,7 +35,7 @@ import { loadConfig } from '@mosaicstack/config'; // 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(resolveGatewayConfigPath()).tier === 'federated'; @Module({ imports: [ diff --git a/apps/gateway/src/env.ts b/apps/gateway/src/env.ts index adfeaa77..17211003 100644 --- a/apps/gateway/src/env.ts +++ b/apps/gateway/src/env.ts @@ -14,67 +14,112 @@ type TierSource = type BootSource = TierSource | 'mosaic.config.json'; +export interface GatewayDotenvPaths { + daemonEnv: string; + monorepoRootEnv: string; + gatewayLocalEnv: string; +} + const here = dirname(fileURLToPath(import.meta.url)); -const daemonEnv = join(homedir(), '.config', 'mosaic', 'gateway', '.env'); -const monorepoRootEnv = resolve(here, '../../..', '.env'); -const gatewayLocalEnv = resolve(here, '..', '.env'); -const inheritedTier = process.env['MOSAIC_STORAGE_TIER']; -let tierSource: TierSource = inheritedTier === undefined ? 'default' : 'process environment'; -const inheritedDatabaseUrl = process.env['DATABASE_URL']; -let databaseUrlSource: TierSource = - inheritedDatabaseUrl === undefined ? 'default' : 'process environment'; - -function loadAnchoredDotenv( - path: string, - sourceLabel: Exclude, -): 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; - } +export function resolveGatewayDotenvPaths( + anchor: string = here, + homeBase: string = homedir(), +): GatewayDotenvPaths { + return { + daemonEnv: join(homeBase, '.config', 'mosaic', 'gateway', '.env'), + monorepoRootEnv: resolve(anchor, '../../..', '.env'), + gatewayLocalEnv: resolve(anchor, '..', '.env'), + }; } -// Load .env from daemon config dir (global install / daemon mode) first. -// It takes precedence over file-based local-dev configuration. -loadAnchoredDotenv(daemonEnv, 'daemon .env'); +export function resolveGatewayConfigPath(anchor: string = here): string { + const monorepoRootConfig = resolve(anchor, '../../..', 'mosaic.config.json'); + const gatewayLocalConfig = resolve(anchor, '..', 'mosaic.config.json'); -// 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'); + if (existsSync(monorepoRootConfig)) { + return monorepoRootConfig; + } + if (existsSync(gatewayLocalConfig)) { + return gatewayLocalConfig; + } -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'; + return monorepoRootConfig; } -console.info(`[gateway env] storage tier=${resolvedTier} source=${source}`); +export function loadGatewayEnv(anchor: string = here, homeBase: string = homedir()): void { + const { daemonEnv, monorepoRootEnv, gatewayLocalEnv } = resolveGatewayDotenvPaths( + anchor, + homeBase, + ); + const inheritedTier = process.env['MOSAIC_STORAGE_TIER']; + let tierSource: TierSource = inheritedTier === undefined ? 'default' : 'process environment'; + const inheritedDatabaseUrl = process.env['DATABASE_URL']; + let databaseUrlSource: TierSource = + inheritedDatabaseUrl === undefined ? 'default' : 'process environment'; + + function loadAnchoredDotenv( + path: string, + sourceLabel: Exclude, + ): 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 configPath = resolveGatewayConfigPath(anchor); + const anchoredConfigExists = existsSync(configPath); + const resolvedTier = loadConfig(configPath).tier; + const configuredTier = process.env['MOSAIC_STORAGE_TIER']; + const databaseUrlDeterminesTier = envOnlyTier === 'standalone' && configuredTier !== 'standalone'; + const recognizedTierDeterminesTier = + (configuredTier === 'federated' || + configuredTier === 'standalone' || + configuredTier === 'local') && + configuredTier === envOnlyTier; + + let source: BootSource; + if (anchoredConfigExists) { + source = 'mosaic.config.json'; + } else if (databaseUrlDeterminesTier && databaseUrlSource !== 'default') { + source = databaseUrlSource; + } else if (recognizedTierDeterminesTier && tierSource !== 'default') { + source = tierSource; + } else { + source = 'default'; + } + + console.info(`[gateway env] storage tier=${resolvedTier} source=${source}`); +} + +loadGatewayEnv();