From bd0ef2ab25afc8f2846e373e7aed392c5c96d5b3 Mon Sep 17 00:00:00 2001 From: "shaggy (mosaic-dev box)" Date: Sun, 9 Aug 2026 23:54:53 -0500 Subject: [PATCH] fix(gateway): anchor remaining config loads (#1138) Co-Authored-By: Claude Haiku 4.5 --- apps/gateway/src/app.module.spec.ts | 88 ++++++++++++ apps/gateway/src/config/config.module.ts | 3 +- apps/gateway/src/main.spec.ts | 163 +++++++++++++++++++++++ apps/gateway/src/main.ts | 3 +- 4 files changed, 255 insertions(+), 2 deletions(-) create mode 100644 apps/gateway/src/main.spec.ts diff --git a/apps/gateway/src/app.module.spec.ts b/apps/gateway/src/app.module.spec.ts index 02887fd1..191a00ad 100644 --- a/apps/gateway/src/app.module.spec.ts +++ b/apps/gateway/src/app.module.spec.ts @@ -5,11 +5,13 @@ 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'; +import type { MosaicConfig } from '@mosaicstack/config'; interface ComposedModuleGraph { imports: readonly unknown[]; federationModule: unknown; bootLogLines: readonly string[]; + mosaicConfig: MosaicConfig; } type StorageTier = 'local' | 'standalone' | 'federated'; @@ -95,6 +97,23 @@ async function writeFixture(path: string, contents: string, tempRoot: string): P await writeFile(path, contents, 'utf8'); } +interface ConfigModuleProvider { + provide: string; + useFactory: () => MosaicConfig; +} + +function isConfigModuleProvider(value: unknown): value is ConfigModuleProvider { + if (typeof value !== 'object' || value === null) { + return false; + } + + if (!('provide' in value) || typeof value.provide !== 'string') { + return false; + } + + return 'useFactory' in value && typeof value.useFactory === 'function'; +} + function singleBootLogLine(bootLogLines: readonly string[]): string { expect(bootLogLines).toHaveLength(1); const [bootLogLine] = bootLogLines; @@ -229,12 +248,28 @@ async function loadModuleGraphFromDotenv( throw new Error('AppModule imports metadata is not an array'); } + const { ConfigModule, MOSAIC_CONFIG } = await import('./config/config.module.js'); + const providers: unknown = Reflect.getMetadata(MODULE_METADATA.PROVIDERS, ConfigModule); + + if (!Array.isArray(providers)) { + throw new Error('ConfigModule providers metadata is not an array'); + } + + const configProvider = providers + .filter(isConfigModuleProvider) + .find((provider: ConfigModuleProvider): boolean => provider.provide === MOSAIC_CONFIG); + + if (!configProvider) { + throw new Error('MOSAIC_CONFIG provider factory not found'); + } + return { imports, federationModule: FederationModule, bootLogLines: consoleInfoSpy.mock.calls.map((args: readonly unknown[]): string => args.map((value: unknown): string => String(value)).join(' '), ), + mosaicConfig: configProvider.useFactory(), }; } finally { cwdSpy?.mockRestore(); @@ -496,4 +531,57 @@ describe('AppModule federation gating', (): void => { }, MODULE_IMPORT_TIMEOUT_MS, ); + + it( + 'MOSAIC_CONFIG provider ignores an ambient cwd/mosaic.config.json config', + async (): Promise => { + const graph = await loadModuleGraphFromDotenv({ + rootTier: 'local', + setup: async (fixture: ModuleGraphFixture): Promise => { + await writeFixture( + join(fixture.cwdPath, 'mosaic.config.json'), + JSON.stringify({ + tier: 'federated', + storage: { + type: 'postgres', + url: 'postgresql://ambient-attacker.invalid/mosaic', + enableVector: true, + }, + queue: { type: 'bullmq' }, + memory: { type: 'pgvector' }, + }), + fixture.tempRoot, + ); + }, + }); + + expect(graph.mosaicConfig.tier).toBe('local'); + expect(graph.mosaicConfig.storage).not.toEqual( + expect.objectContaining({ url: 'postgresql://ambient-attacker.invalid/mosaic' }), + ); + }, + MODULE_IMPORT_TIMEOUT_MS, + ); + + it( + 'MOSAIC_CONFIG provider resolves from the anchored monorepo-root mosaic.config.json', + async (): Promise => { + const graph = await loadModuleGraphFromDotenv({ + rootTier: 'local', + setup: async (fixture: ModuleGraphFixture): Promise => { + await writeFixture( + fixture.monorepoRootConfigPath, + configJson('federated'), + fixture.tempRoot, + ); + }, + }); + + expect(graph.mosaicConfig.tier).toBe('federated'); + expect(graph.mosaicConfig.storage).toEqual( + expect.objectContaining({ url: 'postgresql://fixture.invalid/mosaic' }), + ); + }, + MODULE_IMPORT_TIMEOUT_MS, + ); }); diff --git a/apps/gateway/src/config/config.module.ts b/apps/gateway/src/config/config.module.ts index 5b65137a..d18cdd59 100644 --- a/apps/gateway/src/config/config.module.ts +++ b/apps/gateway/src/config/config.module.ts @@ -1,5 +1,6 @@ import { Global, Module } from '@nestjs/common'; import { loadConfig, type MosaicConfig } from '@mosaicstack/config'; +import { resolveGatewayConfigPath } from '../env.js'; export const MOSAIC_CONFIG = 'MOSAIC_CONFIG'; @@ -8,7 +9,7 @@ export const MOSAIC_CONFIG = 'MOSAIC_CONFIG'; providers: [ { provide: MOSAIC_CONFIG, - useFactory: (): MosaicConfig => loadConfig(), + useFactory: (): MosaicConfig => loadConfig(resolveGatewayConfigPath()), }, ], exports: [MOSAIC_CONFIG], diff --git a/apps/gateway/src/main.spec.ts b/apps/gateway/src/main.spec.ts new file mode 100644 index 00000000..b482f193 --- /dev/null +++ b/apps/gateway/src/main.spec.ts @@ -0,0 +1,163 @@ +import 'reflect-metadata'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import * as nodeOs from 'node:os'; +import { dirname, isAbsolute, join, relative, resolve } from 'node:path'; +import * as nodeUrl from 'node:url'; +import type { MosaicConfig } from '@mosaicstack/config'; +import type * as MosaicStorage from '@mosaicstack/storage'; +import { describe, expect, it, vi, type MockInstance } from 'vitest'; + +const MODULE_IMPORT_TIMEOUT_MS = 30_000; + +function snapshotProcessEnv(): Record { + return { ...process.env }; +} + +function restoreProcessEnv(snapshot: Record): 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; + } +} + +function expectPathUnderTempRoot(path: string, tempRoot: string): void { + const relativePath = relative(tempRoot, path); + expect(relativePath === '' || (!relativePath.startsWith('..') && !isAbsolute(relativePath))).toBe( + true, + ); +} + +async function writeFixture(path: string, contents: string, tempRoot: string): Promise { + expectPathUnderTempRoot(path, tempRoot); + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, contents, 'utf8'); +} + +interface BootstrapPreflightResult { + capturedConfig: MosaicConfig | undefined; +} + +async function runBootstrapPreflight( + anchoredConfigContents: string, + ambientConfigContents: string, +): Promise { + const originalEnv = snapshotProcessEnv(); + const tempRoot = await mkdtemp(join(nodeOs.tmpdir(), 'mosaic-gateway-main-preflight-')); + let cwdSpy: ReturnType | undefined; + let exitSpy: MockInstance | undefined; + let consoleInfoSpy: ReturnType | undefined; + let capturedConfig: MosaicConfig | undefined; + + try { + const anchor = join(tempRoot, 'anchored', 'apps', 'gateway', 'src'); + const homePath = join(tempRoot, 'home'); + const cwdPath = join(tempRoot, 'ambient', 'cwd'); + const monorepoRootConfigPath = resolve(anchor, '../../..', 'mosaic.config.json'); + + await mkdir(anchor, { recursive: true }); + await mkdir(cwdPath, { recursive: true }); + + await writeFixture(monorepoRootConfigPath, anchoredConfigContents, tempRoot); + await writeFixture(join(cwdPath, 'mosaic.config.json'), ambientConfigContents, tempRoot); + + process.env['HOME'] = homePath; + process.env['BETTER_AUTH_SECRET'] = 'fixture-secret'; + delete process.env['MOSAIC_STORAGE_TIER']; + delete process.env['DATABASE_URL']; + delete process.env['VALKEY_URL']; + + consoleInfoSpy = vi.spyOn(console, 'info').mockImplementation((): void => undefined); + const exitMock = vi.fn(); + exitSpy = vi.spyOn(process, 'exit').mockImplementation(exitMock); + + vi.resetModules(); + 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); + vi.doMock('./tracing.js', () => ({})); + + const preflightSentinel = new Error('preflight-capture-sentinel'); + vi.doMock('@mosaicstack/storage', async () => { + const actual = await vi.importActual('@mosaicstack/storage'); + return { + ...actual, + detectAndAssertTier: vi.fn((config: MosaicConfig): Promise => { + capturedConfig = config; + throw preflightSentinel; + }), + }; + }); + + await import('./main.js'); + await vi.waitFor((): void => { + expect(exitSpy).toHaveBeenCalled(); + }); + + return { capturedConfig }; + } finally { + cwdSpy?.mockRestore(); + exitSpy?.mockRestore(); + consoleInfoSpy?.mockRestore(); + vi.doUnmock('@mosaicstack/storage'); + vi.doUnmock('./tracing.js'); + vi.doUnmock('node:url'); + vi.doUnmock('node:os'); + vi.resetModules(); + restoreProcessEnv(originalEnv); + await rm(tempRoot, { recursive: true, force: true }); + } +} + +describe('main bootstrap preflight config anchoring', (): void => { + it( + 'passes the anchored monorepo-root config to detectAndAssertTier, not an ambient cwd config', + async (): Promise => { + const anchoredConfig = JSON.stringify({ + tier: 'local', + storage: { type: 'pglite', dataDir: '.mosaic/storage-pglite' }, + queue: { type: 'local', dataDir: '.mosaic/queue' }, + memory: { type: 'keyword' }, + }); + const ambientConfig = JSON.stringify({ + tier: 'federated', + storage: { + type: 'postgres', + url: 'postgresql://ambient-attacker.invalid/mosaic', + enableVector: true, + }, + queue: { type: 'bullmq' }, + memory: { type: 'pgvector' }, + }); + + const { capturedConfig } = await runBootstrapPreflight(anchoredConfig, ambientConfig); + + expect(capturedConfig?.tier).toBe('local'); + expect(capturedConfig?.storage).not.toEqual( + expect.objectContaining({ url: 'postgresql://ambient-attacker.invalid/mosaic' }), + ); + }, + MODULE_IMPORT_TIMEOUT_MS, + ); +}); diff --git a/apps/gateway/src/main.ts b/apps/gateway/src/main.ts index 2d9e4271..d1eacf5f 100644 --- a/apps/gateway/src/main.ts +++ b/apps/gateway/src/main.ts @@ -13,6 +13,7 @@ import { mountAuthHandler } from './auth/auth.controller.js'; import { mountMcpHandler } from './mcp/mcp.controller.js'; import { McpService } from './mcp/mcp.service.js'; import { detectAndAssertTier, TierDetectionError } from '@mosaicstack/storage'; +import { resolveGatewayConfigPath } from './env.js'; async function bootstrap(): Promise { const logger = new Logger('Bootstrap'); @@ -24,7 +25,7 @@ async function bootstrap(): Promise { // Pre-flight: assert all external services required by the configured tier // are reachable. Runs before NestFactory.create() so failures are visible // immediately with actionable remediation hints. - const mosaicConfig = loadConfig(); + const mosaicConfig = loadConfig(resolveGatewayConfigPath()); try { await detectAndAssertTier(mosaicConfig); } catch (err) {