From b4753a75cd0c0da3bcd2b0cdc1dc8eee191832ec Mon Sep 17 00:00:00 2001 From: "shaggy (mosaic-dev box)" Date: Sun, 9 Aug 2026 17:26:28 -0500 Subject: [PATCH 1/7] fix(gateway): gate FederationModule on tier federated (#1138) CaService hard-requires STEP_CA_URL/provisioner config at construction, so an unconditional FederationModule import makes every standalone/local boot die at DI time. Gate the module on loadConfig().tier === federated, matching the documented intent of the federation compose profile (must not start in non-federated dev). Verified in mosaic-dev box: standalone tier boots to "Gateway listening on port 14242" with bootstrap/socket.io/auth surfaces responding; federated tier path unchanged. --- apps/gateway/src/app.module.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/apps/gateway/src/app.module.ts b/apps/gateway/src/app.module.ts index dc9a6e32..fd0016ad 100644 --- a/apps/gateway/src/app.module.ts +++ b/apps/gateway/src/app.module.ts @@ -26,6 +26,13 @@ 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'; + +// 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"). +const federationEnabled = loadConfig().tier === 'federated'; @Module({ imports: [ @@ -53,7 +60,7 @@ import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler'; QueueModule, ReloadModule, WorkspaceModule, - FederationModule, + ...(federationEnabled ? [FederationModule] : []), ], controllers: [HealthController], providers: [ 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 2/7] 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'; From 884d527cc8d535417c5c5fb10363365ce67374e3 Mon Sep 17 00:00:00 2001 From: "shaggy (mosaic-dev box)" Date: Sun, 9 Aug 2026 22:16:02 -0500 Subject: [PATCH 3/7] fix(gateway): anchor dotenv discovery to module (#1138) --- apps/gateway/src/app.module.spec.ts | 385 +++++++++++++++++++++++++--- apps/gateway/src/env.ts | 82 +++++- 2 files changed, 418 insertions(+), 49 deletions(-) diff --git a/apps/gateway/src/app.module.spec.ts b/apps/gateway/src/app.module.spec.ts index 42fdf470..4096acb7 100644 --- a/apps/gateway/src/app.module.spec.ts +++ b/apps/gateway/src/app.module.spec.ts @@ -1,64 +1,210 @@ import 'reflect-metadata'; +import { existsSync } from 'node:fs'; import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; import { MODULE_METADATA } from '@nestjs/common/constants.js'; import { describe, expect, it, vi } from 'vitest'; interface ComposedModuleGraph { imports: readonly unknown[]; federationModule: unknown; + bootLogLines: readonly string[]; +} + +interface FileSnapshot { + exists: boolean; + contents: Buffer | null; +} + +type StorageTier = 'local' | 'standalone' | 'federated'; + +interface ModuleGraphFixtureOptions { + cwdPath: string; + rootEnvMode?: 'present' | 'absent'; + rootTier?: StorageTier; + rootEnvContents?: string; + secret?: string; + gatewayLocalTier?: StorageTier; + gatewayLocalEnvContents?: string; + daemonEnvContents?: string; + inheritedTier?: StorageTier; + expectedProcessTier?: StorageTier; + setup?: () => 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 restoreEnvironmentVariable(name: string, value: string | undefined): void { - if (value === undefined) { - delete process.env[name]; +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; + } +} + +async function snapshotFile(path: string): Promise { + if (!existsSync(path)) { + return { exists: false, contents: null }; + } + + return { exists: true, contents: await readFile(path) }; +} + +async function restoreFile(path: string, snapshot: FileSnapshot): Promise { + if (!snapshot.exists) { + await rm(path, { force: true }); return; } - process.env[name] = value; + await writeFile(path, snapshot.contents ?? Buffer.alloc(0)); +} + +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( - tier: 'local' | 'standalone' | 'federated', + options: ModuleGraphFixtureOptions, ): 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'); + 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(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); + await mkdir(options.cwdPath, { recursive: true }); try { - expect(process.env['MOSAIC_STORAGE_TIER']).toBeUndefined(); - await import('./env.js'); - expect(process.env['MOSAIC_STORAGE_TIER']).toBe(tier); + if ((options.rootEnvMode ?? 'present') === 'absent') { + if ( + options.rootEnvContents !== undefined || + options.rootTier !== undefined || + options.secret !== undefined + ) { + throw new Error('Expected no root env fixture values when rootEnvMode is absent'); + } - const { AppModule } = await import('./app.module.js'); - const { FederationModule } = await import('./federation/federation.module.js'); - const imports: unknown = Reflect.getMetadata(MODULE_METADATA.IMPORTS, AppModule); + await rm(rootEnvPath, { force: true }); + } else { + if (options.rootEnvContents === undefined && options.rootTier === undefined) { + throw new Error('Expected rootTier or rootEnvContents'); + } - if (!Array.isArray(imports)) { - throw new Error('AppModule imports metadata is not an array'); + const rootFixture = options.rootEnvContents ?? `MOSAIC_STORAGE_TIER=${options.rootTier}\n`; + const rootFixtureWithSecret = options.secret + ? `${rootFixture}BETTER_AUTH_SECRET=${options.secret}\n` + : rootFixture; + + await writeFile(rootEnvPath, rootFixtureWithSecret, 'utf8'); } - return { imports, federationModule: FederationModule }; + if (options.daemonEnvContents !== undefined) { + const daemonEnvPath = join(isolatedHome, '.config', 'mosaic', 'gateway', '.env'); + await mkdir(dirname(daemonEnvPath), { recursive: true }); + await writeFile(daemonEnvPath, options.daemonEnvContents, 'utf8'); + } + + if (options.gatewayLocalEnvContents !== undefined) { + await writeFile(gatewayLocalEnvPath, options.gatewayLocalEnvContents, 'utf8'); + } else if (options.gatewayLocalTier !== undefined) { + await writeFile( + gatewayLocalEnvPath, + `MOSAIC_STORAGE_TIER=${options.gatewayLocalTier}\n`, + 'utf8', + ); + } else if (existsSync(gatewayLocalEnvPath)) { + await rm(gatewayLocalEnvPath, { force: true }); + } + + process.env['HOME'] = isolatedHome; + delete process.env['MOSAIC_STORAGE_TIER']; + delete process.env['DATABASE_URL']; + delete process.env['VALKEY_URL']; + + await options.setup?.(); + + if (options.inheritedTier !== undefined) { + process.env['MOSAIC_STORAGE_TIER'] = options.inheritedTier; + } + + vi.resetModules(); + const cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(options.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(); + } } finally { - cwdSpy.mockRestore(); - restoreEnvironmentVariable('HOME', originalHome); - restoreEnvironmentVariable('MOSAIC_STORAGE_TIER', originalTier); - restoreEnvironmentVariable('DATABASE_URL', originalDatabaseUrl); - await rm(fixtureRoot, { recursive: true, force: true }); + consoleInfoSpy.mockRestore(); + restoreProcessEnv(originalEnv); + await restoreFile(rootEnvPath, rootSnapshot); + await restoreFile(gatewayLocalEnvPath, gatewayLocalSnapshot); + await rm(isolatedHome, { recursive: true, force: true }); } } @@ -74,22 +220,179 @@ describe('AppModule federation gating', (): void => { 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); + it( + 'ignores attacker-writable ambient cwd dotenv files', + async (): Promise => { + const ambientRoot = await mkdtemp(join(tmpdir(), 'mosaic-gateway-ambient-')); + const ambientCwd = join(ambientRoot, 'sandbox', 'cwd'); - expect(graph.imports).not.toContain(graph.federationModule); + 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); + } finally { + await rm(ambientRoot, { recursive: true, force: true }); + } }, MODULE_IMPORT_TIMEOUT_MS, ); it( - 'registers FederationModule when federated tier is supplied only by a dotenv file', + 'logs standalone from a monorepo-root .env DATABASE_URL fallback', async (): Promise => { - const graph = await loadModuleGraphFromDotenv('federated'); + const cwdPath = await mkdtemp(join(tmpdir(), 'mosaic-gateway-cwd-')); - expect(graph.imports).toContain(graph.federationModule); + 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); + } finally { + await rm(cwdPath, { recursive: true, force: true }); + } + }, + MODULE_IMPORT_TIMEOUT_MS, + ); + + it( + 'daemon .env wins over monorepo-root and gateway-local tier values', + async (): Promise => { + const cwdPath = await mkdtemp(join(tmpdir(), 'mosaic-gateway-cwd-')); + + 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 }); + } + }, + MODULE_IMPORT_TIMEOUT_MS, + ); + + 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-')); + + 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 }); + } + }, + MODULE_IMPORT_TIMEOUT_MS, + ); + + 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-')); + + 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 }); + } + }, + MODULE_IMPORT_TIMEOUT_MS, + ); + + it( + 'monorepo-root .env wins over gateway-local tier values', + async (): Promise => { + const cwdPath = await mkdtemp(join(tmpdir(), 'mosaic-gateway-cwd-')); + + 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 }); + } + }, + MODULE_IMPORT_TIMEOUT_MS, + ); + + 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-')); + + 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 }); + } + }, + MODULE_IMPORT_TIMEOUT_MS, + ); + + 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-')); + + 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 }); + } }, MODULE_IMPORT_TIMEOUT_MS, ); diff --git a/apps/gateway/src/env.ts b/apps/gateway/src/env.ts index 840c6118..adfeaa77 100644 --- a/apps/gateway/src/env.ts +++ b/apps/gateway/src/env.ts @@ -1,14 +1,80 @@ import { config } from 'dotenv'; import { existsSync } from 'node:fs'; import { homedir } from 'node:os'; -import { join, resolve } from 'node:path'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { detectFromEnv, loadConfig } from '@mosaicstack/config'; -// Load .env from daemon config dir (global install / daemon mode). -// It takes precedence over file-based local-dev configuration. +type TierSource = + | 'process environment' + | 'daemon .env' + | 'monorepo-root .env' + | 'gateway-local .env' + | 'default'; + +type BootSource = TierSource | 'mosaic.config.json'; + +const here = dirname(fileURLToPath(import.meta.url)); const daemonEnv = join(homedir(), '.config', 'mosaic', 'gateway', '.env'); -if (existsSync(daemonEnv)) config({ path: daemonEnv }); +const monorepoRootEnv = resolve(here, '../../..', '.env'); +const gatewayLocalEnv = resolve(here, '..', '.env'); -// 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(); +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 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'; +} + +console.info(`[gateway env] storage tier=${resolvedTier} source=${source}`); 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 4/7] 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(); 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 5/7] 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) { From 677aeb0c934386c1d03f7c0904f181d21fd1aef2 Mon Sep 17 00:00:00 2001 From: "shaggy (mosaic-dev box)" Date: Mon, 10 Aug 2026 00:24:14 -0500 Subject: [PATCH 6/7] fix(gateway): restore daemon config discovery (#1138) --- apps/gateway/src/app.module.spec.ts | 44 ++++++++++++++++++++++++++--- apps/gateway/src/env.ts | 14 +++++++-- 2 files changed, 51 insertions(+), 7 deletions(-) diff --git a/apps/gateway/src/app.module.spec.ts b/apps/gateway/src/app.module.spec.ts index 191a00ad..87472b90 100644 --- a/apps/gateway/src/app.module.spec.ts +++ b/apps/gateway/src/app.module.spec.ts @@ -12,6 +12,7 @@ interface ComposedModuleGraph { federationModule: unknown; bootLogLines: readonly string[]; mosaicConfig: MosaicConfig; + resolvedConfigPath: string; } type StorageTier = 'local' | 'standalone' | 'federated'; @@ -205,6 +206,7 @@ async function loadModuleGraphFromDotenv( 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); @@ -235,7 +237,7 @@ async function loadModuleGraphFromDotenv( expect(process.env['MOSAIC_STORAGE_TIER']).toBe(options.inheritedTier); } - await import('./env.js'); + const envModule = await import('./env.js'); expect(process.env['MOSAIC_STORAGE_TIER']).toBe( options.expectedProcessTier ?? options.rootTier, ); @@ -270,6 +272,7 @@ async function loadModuleGraphFromDotenv( args.map((value: unknown): string => String(value)).join(' '), ), mosaicConfig: configProvider.useFactory(), + resolvedConfigPath: envModule.resolveGatewayConfigPath(), }; } finally { cwdSpy?.mockRestore(); @@ -360,20 +363,53 @@ describe('AppModule federation gating', (): void => { ); it( - 'anchored monorepo-root config wins gateway-local config and registers FederationModule', + '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.monorepoRootConfigPath, + fixture.gatewayLocalConfigPath, configJson('federated'), fixture.tempRoot, ); - await writeFixture(fixture.gatewayLocalConfigPath, configJson('local'), 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'); }, diff --git a/apps/gateway/src/env.ts b/apps/gateway/src/env.ts index 17211003..4422343f 100644 --- a/apps/gateway/src/env.ts +++ b/apps/gateway/src/env.ts @@ -34,15 +34,23 @@ export function resolveGatewayDotenvPaths( } export function resolveGatewayConfigPath(anchor: string = here): string { - const monorepoRootConfig = resolve(anchor, '../../..', 'mosaic.config.json'); + // 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(monorepoRootConfig)) { - return monorepoRootConfig; + if (existsSync(daemonConfig)) { + return daemonConfig; } if (existsSync(gatewayLocalConfig)) { return gatewayLocalConfig; } + if (existsSync(monorepoRootConfig)) { + return monorepoRootConfig; + } return monorepoRootConfig; } From 406e40584dd110dc00fdc9b68f8b0d6e9b1d53f4 Mon Sep 17 00:00:00 2001 From: "shaggy (mosaic-dev box)" Date: Mon, 10 Aug 2026 01:34:24 -0500 Subject: [PATCH 7/7] test(gateway): raise module-graph import timeout for CI load robustness (#1138) --- apps/gateway/src/app.module.spec.ts | 3 ++- apps/gateway/src/main.spec.ts | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/gateway/src/app.module.spec.ts b/apps/gateway/src/app.module.spec.ts index 87472b90..66a2d237 100644 --- a/apps/gateway/src/app.module.spec.ts +++ b/apps/gateway/src/app.module.spec.ts @@ -42,7 +42,8 @@ interface ModuleGraphFixtureOptions { setup?: (fixture: ModuleGraphFixture) => Promise; } -const MODULE_IMPORT_TIMEOUT_MS = 30_000; +// 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'; diff --git a/apps/gateway/src/main.spec.ts b/apps/gateway/src/main.spec.ts index b482f193..10999917 100644 --- a/apps/gateway/src/main.spec.ts +++ b/apps/gateway/src/main.spec.ts @@ -7,7 +7,8 @@ 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; +// 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 };