diff --git a/apps/gateway/src/app.module.spec.ts b/apps/gateway/src/app.module.spec.ts new file mode 100644 index 00000000..66a2d237 --- /dev/null +++ b/apps/gateway/src/app.module.spec.ts @@ -0,0 +1,624 @@ +import 'reflect-metadata'; +import { mkdtemp, mkdir, readFile, 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 { 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; + resolvedConfigPath: string; +} + +type StorageTier = 'local' | 'standalone' | 'federated'; + +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; + redactionMarker?: string; + gatewayLocalTier?: StorageTier; + gatewayLocalEnvContents?: string; + daemonEnvContents?: string; + inheritedTier?: StorageTier; + expectedProcessTier?: string; + setup?: (fixture: ModuleGraphFixture) => Promise; +} + +// Each case uses vi.resetModules() and re-imports the full gateway graph for distinct ambient FS/env; CI needs headroom, while this still guards genuine hangs. +const MODULE_IMPORT_TIMEOUT_MS = 120_000; +const MONOREPO_ROOT_DOTENV_LABEL = 'monorepo-root .env'; +const DAEMON_DOTENV_LABEL = 'daemon .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 }; +} + +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 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; + 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( + options: ModuleGraphFixtureOptions, +): Promise { + const originalEnv = snapshotProcessEnv(); + 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.redactionMarker !== undefined + ) { + throw new Error('Expected no root env fixture values when rootEnvMode is absent'); + } + } 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 rootFixtureWithMarker = options.redactionMarker + ? `${rootFixture}BETTER_AUTH_SECRET=${options.redactionMarker}\n` + : rootFixture; + await writeFixture(fixture.monorepoRootEnvPath, rootFixtureWithMarker, tempRoot); + } + + if (options.daemonEnvContents !== undefined) { + await writeFixture(fixture.daemonEnvPath, options.daemonEnvContents, tempRoot); + } + + if (options.gatewayLocalEnvContents !== undefined) { + await writeFixture(fixture.gatewayLocalEnvPath, options.gatewayLocalEnvContents, tempRoot); + } else if (options.gatewayLocalTier !== undefined) { + await writeFixture( + fixture.gatewayLocalEnvPath, + `MOSAIC_STORAGE_TIER=${options.gatewayLocalTier}\n`, + tempRoot, + ); + } + + process.env['HOME'] = homePath; + delete process.env['MOSAIC_STORAGE_TIER']; + delete process.env['DATABASE_URL']; + delete process.env['VALKEY_URL']; + delete process.env['MOSAIC_GATEWAY_HOME']; + + await options.setup?.(fixture); + + if (options.inheritedTier !== undefined) { + process.env['MOSAIC_STORAGE_TIER'] = options.inheritedTier; + } + + 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); + + if (options.inheritedTier === undefined) { + expect(process.env['MOSAIC_STORAGE_TIER']).toBeUndefined(); + } else { + expect(process.env['MOSAIC_STORAGE_TIER']).toBe(options.inheritedTier); + } + + const envModule = 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'); + } + + 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(), + resolvedConfigPath: envModule.resolveGatewayConfigPath(), + }; + } finally { + cwdSpy?.mockRestore(); + vi.doUnmock('node:url'); + vi.doUnmock('node:os'); + vi.resetModules(); + consoleInfoSpy?.mockRestore(); + restoreProcessEnv(originalEnv); + await rm(tempRoot, { 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( + 'ignores ambient cwd/.env and cwd/../.env files', + async (): Promise => { + 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, + ); + }, + }); + + 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( + 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 gateway-local config wins monorepo-root config and registers FederationModule', + async (): Promise => { + let gatewayLocalConfigPath = ''; + const graph = await loadModuleGraphFromDotenv({ + rootTier: 'local', + setup: async (fixture: ModuleGraphFixture): Promise => { + gatewayLocalConfigPath = fixture.gatewayLocalConfigPath; + await writeFixture( + fixture.gatewayLocalConfigPath, + configJson('federated'), + fixture.tempRoot, + ); + await writeFixture(fixture.monorepoRootConfigPath, configJson('local'), fixture.tempRoot); + }, + }); + + expect(graph.resolvedConfigPath).toBe(gatewayLocalConfigPath); + expect(graph.mosaicConfig.tier).toBe('federated'); + expect(graph.imports).toContain(graph.federationModule); + expectBootLogLine(graph.bootLogLines, 'federated', 'mosaic.config.json'); + }, + MODULE_IMPORT_TIMEOUT_MS, + ); + + it( + 'resolves the daemon-installed GATEWAY_HOME/mosaic.config.json ahead of gateway-local and monorepo-root configs', + async (): Promise => { + let daemonConfigPath = ''; + const graph = await loadModuleGraphFromDotenv({ + rootEnvMode: 'absent', + setup: async (fixture: ModuleGraphFixture): Promise => { + const externalGatewayHome = join(fixture.tempRoot, 'external-gateway-home'); + daemonConfigPath = join(externalGatewayHome, 'mosaic.config.json'); + await writeFixture(daemonConfigPath, configJson('federated'), fixture.tempRoot); + await writeFixture( + fixture.gatewayLocalConfigPath, + configJson('standalone'), + fixture.tempRoot, + ); + await writeFixture(fixture.monorepoRootConfigPath, configJson('local'), fixture.tempRoot); + process.env['MOSAIC_GATEWAY_HOME'] = externalGatewayHome; + process.env['DATABASE_URL'] = 'postgresql://fixture.invalid/mosaic'; + }, + }); + + expect(graph.resolvedConfigPath).toBe(daemonConfigPath); + expect(graph.mosaicConfig.tier).toBe('federated'); + 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, + ); + + it( + 'logs standalone from a monorepo-root .env DATABASE_URL fallback', + async (): Promise => { + const graph = await loadModuleGraphFromDotenv({ + 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, + ); + + 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, + ); + + it( + 'daemon .env wins over monorepo-root and gateway-local tier values', + async (): Promise => { + const graph = await loadModuleGraphFromDotenv({ + 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); + }, + MODULE_IMPORT_TIMEOUT_MS, + ); + + it( + 'inherits process.env.MOSAIC_STORAGE_TIER over daemon, monorepo-root, and gateway-local dotenv values', + async (): Promise => { + const graph = await loadModuleGraphFromDotenv({ + 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'); + }, + MODULE_IMPORT_TIMEOUT_MS, + ); + + it( + 'gateway-local .env configures the tier and source when the monorepo-root .env is absent', + async (): Promise => { + const graph = await loadModuleGraphFromDotenv({ + rootEnvMode: 'absent', + gatewayLocalTier: 'federated', + expectedProcessTier: 'federated', + }); + + expect(graph.imports).toContain(graph.federationModule); + expectBootLogLine(graph.bootLogLines, 'federated', 'gateway-local .env'); + }, + MODULE_IMPORT_TIMEOUT_MS, + ); + + it( + 'monorepo-root .env wins over gateway-local tier values', + async (): Promise => { + const graph = await loadModuleGraphFromDotenv({ + rootTier: 'standalone', + gatewayLocalTier: 'federated', + }); + + expect(graph.imports).not.toContain(graph.federationModule); + expectBootLogLine(graph.bootLogLines, 'standalone', MONOREPO_ROOT_DOTENV_LABEL); + }, + MODULE_IMPORT_TIMEOUT_MS, + ); + + it.each(['local', 'standalone'] as const)( + 'does not register FederationModule for the %s tier', + async (tier): Promise => { + const graph = await loadModuleGraphFromDotenv({ rootTier: tier }); + + expect(graph.imports).not.toContain(graph.federationModule); + expectBootLogLine(graph.bootLogLines, tier, MONOREPO_ROOT_DOTENV_LABEL); + }, + MODULE_IMPORT_TIMEOUT_MS, + ); + + it( + 'registers FederationModule when federated tier is supplied by the anchored monorepo root .env', + async (): Promise => { + const redactionMarker = 'redaction-fixture-marker'; + const graph = await loadModuleGraphFromDotenv({ + rootTier: 'federated', + redactionMarker, + }); + + 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, + ); + + 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/app.module.ts b/apps/gateway/src/app.module.ts index dc9a6e32..8adfabe8 100644 --- a/apps/gateway/src/app.module.ts +++ b/apps/gateway/src/app.module.ts @@ -26,6 +26,16 @@ import { WorkspaceModule } from './workspace/workspace.module.js'; 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 +// must not gate standalone/local boots (docker-compose.federated.yml: the +// 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(resolveGatewayConfigPath()).tier === 'federated'; @Module({ imports: [ @@ -53,7 +63,7 @@ import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler'; QueueModule, ReloadModule, WorkspaceModule, - FederationModule, + ...(federationEnabled ? [FederationModule] : []), ], controllers: [HealthController], providers: [ 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/env.ts b/apps/gateway/src/env.ts new file mode 100644 index 00000000..4422343f --- /dev/null +++ b/apps/gateway/src/env.ts @@ -0,0 +1,133 @@ +import { config } from 'dotenv'; +import { existsSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { detectFromEnv, loadConfig } from '@mosaicstack/config'; + +type TierSource = + | 'process environment' + | 'daemon .env' + | 'monorepo-root .env' + | 'gateway-local .env' + | 'default'; + +type BootSource = TierSource | 'mosaic.config.json'; + +export interface GatewayDotenvPaths { + daemonEnv: string; + monorepoRootEnv: string; + gatewayLocalEnv: string; +} + +const here = dirname(fileURLToPath(import.meta.url)); + +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'), + }; +} + +export function resolveGatewayConfigPath(anchor: string = here): string { + // GATEWAY_HOME is daemon-created 0700; its env override adds no authority because env can set MOSAIC_STORAGE_TIER. + const gatewayHome = resolve( + process.env['MOSAIC_GATEWAY_HOME'] ?? join(homedir(), '.config', 'mosaic', 'gateway'), + ); + const daemonConfig = join(gatewayHome, 'mosaic.config.json'); + const gatewayLocalConfig = resolve(anchor, '..', 'mosaic.config.json'); + const monorepoRootConfig = resolve(anchor, '../../..', 'mosaic.config.json'); + + if (existsSync(daemonConfig)) { + return daemonConfig; + } + if (existsSync(gatewayLocalConfig)) { + return gatewayLocalConfig; + } + if (existsSync(monorepoRootConfig)) { + return monorepoRootConfig; + } + + return monorepoRootConfig; +} + +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(); diff --git a/apps/gateway/src/main.spec.ts b/apps/gateway/src/main.spec.ts new file mode 100644 index 00000000..10999917 --- /dev/null +++ b/apps/gateway/src/main.spec.ts @@ -0,0 +1,164 @@ +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'; + +// Each case uses vi.resetModules() and re-imports the full gateway graph for distinct ambient FS/env; CI needs headroom, while this still guards genuine hangs. +const MODULE_IMPORT_TIMEOUT_MS = 120_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 f70e88a4..d1eacf5f 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'; @@ -26,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'); @@ -37,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) {