diff --git a/.github/workflows/scan-repo.yml b/.github/workflows/scan-repo.yml index 590415cdde..a1e28bd7d7 100644 --- a/.github/workflows/scan-repo.yml +++ b/.github/workflows/scan-repo.yml @@ -18,7 +18,7 @@ jobs: uses: actions/checkout@v4 - name: Run Trivy vulnerability scanner in repo mode - uses: aquasecurity/trivy-action@0.32.0 + uses: aquasecurity/trivy-action@v0.35.0 env: TRIVY_DB_REPOSITORY: public.ecr.aws/aquasecurity/trivy-db:2 TRIVY_JAVA_DB_REPOSITORY: public.ecr.aws/aquasecurity/trivy-java-db:1 diff --git a/.gitignore b/.gitignore index 1e886f0d4f..b5ba8632b4 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,9 @@ site/ .act.secrets .openchallenges.toml +# Generated app config files (from templates via envsubst) +**/config/config.json + # Generated Dockerfiles from centralized container image system **/Dockerfile.generated diff --git a/apps/agora/app/.env.example b/apps/agora/app/.env.example index a76e86ae01..4dd02b19e5 100644 --- a/apps/agora/app/.env.example +++ b/apps/agora/app/.env.example @@ -4,5 +4,6 @@ COMMIT_SHA="" CSR_API_URL="http://localhost:8000/api/v1" SSR_API_URL="http://agora-api:3333/v1" GOOGLE_TAG_MANAGER_ID="" -SENTRY_AUTH_TOKEN="" +SENTRY_DSN="" +SENTRY_ENVIRONMENT="" SENTRY_RELEASE="" diff --git a/apps/agora/app/project.json b/apps/agora/app/project.json index 19c8e1b2b0..d40c7dbc8d 100644 --- a/apps/agora/app/project.json +++ b/apps/agora/app/project.json @@ -14,6 +14,13 @@ "cwd": "{projectRoot}" } }, + "create-config-json": { + "executor": "nx:run-commands", + "options": { + "command": "node ../../../tools/create-config-json.js", + "cwd": "{projectRoot}" + } + }, "build": { "executor": "@nx/angular:application", "outputs": ["{options.outputPath}"], diff --git a/apps/agora/app/src/config/config.json b/apps/agora/app/src/config/config.json deleted file mode 100644 index 41a22c9be1..0000000000 --- a/apps/agora/app/src/config/config.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "apiDocsUrl": "http://localhost:8000/api-docs", - "appVersion": "local", - "commitSha": "", - "csrApiUrl": "http://localhost:8000/api/v1", - "ssrApiUrl": "http://agora-api:3333/v1", - "googleTagManagerId": "", - "sentryRelease": "" -} diff --git a/apps/agora/app/src/config/config.json.template b/apps/agora/app/src/config/config.json.template index 5a195f3ab9..8d67967856 100644 --- a/apps/agora/app/src/config/config.json.template +++ b/apps/agora/app/src/config/config.json.template @@ -1,9 +1,11 @@ { - "apiDocsUrl": "${API_DOCS_URL}", - "appVersion": "${APP_VERSION}", - "commitSha": "${COMMIT_SHA}", - "csrApiUrl": "${CSR_API_URL}", - "ssrApiUrl": "${SSR_API_URL}", - "googleTagManagerId": "${GOOGLE_TAG_MANAGER_ID}", - "sentryRelease": "${SENTRY_RELEASE}" + "apiDocsUrl": "${API_DOCS_URL:-http://localhost:8000/api-docs}", + "appVersion": "${APP_VERSION:-local}", + "commitSha": "${COMMIT_SHA:-}", + "csrApiUrl": "${CSR_API_URL:-http://localhost:8000/api/v1}", + "ssrApiUrl": "${SSR_API_URL:-http://agora-api:3333/v1}", + "googleTagManagerId": "${GOOGLE_TAG_MANAGER_ID:-}", + "sentryDSN": "${SENTRY_DSN:-}", + "sentryEnvironment": "${SENTRY_ENVIRONMENT:-localhost}", + "sentryRelease": "${SENTRY_RELEASE:-}" } diff --git a/apps/agora/app/src/main.ts b/apps/agora/app/src/main.ts index c9e875c611..f6e78e3b1e 100644 --- a/apps/agora/app/src/main.ts +++ b/apps/agora/app/src/main.ts @@ -8,12 +8,8 @@ import { AppComponent } from './app/app.component'; prefetchConfig().then((config) => { initSentry({ - dsn: 'https://3cfc84951936511803f5c86d82eb9cad@o4510881207418880.ingest.us.sentry.io/4510897622679552', - hostEnvironmentMap: { - 'agora-dev.adknowledgeportal.org': 'dev', - 'agora-stage.adknowledgeportal.org': 'stage', - 'agora.adknowledgeportal.org': 'prod', - }, + dsn: config?.sentryDSN, + environment: config?.sentryEnvironment, release: config?.sentryRelease, }); diff --git a/apps/model-ad/app/.env.example b/apps/model-ad/app/.env.example index ec8c2e22cb..0123a75c3b 100644 --- a/apps/model-ad/app/.env.example +++ b/apps/model-ad/app/.env.example @@ -4,5 +4,6 @@ COMMIT_SHA="" CSR_API_URL="http://localhost:8000/api/v1" SSR_API_URL="http://model-ad-api:3333/v1" GOOGLE_TAG_MANAGER_ID="" -SENTRY_AUTH_TOKEN="" +SENTRY_DSN="" +SENTRY_ENVIRONMENT="" SENTRY_RELEASE="" diff --git a/apps/model-ad/app/project.json b/apps/model-ad/app/project.json index 1716652515..22d36985ee 100644 --- a/apps/model-ad/app/project.json +++ b/apps/model-ad/app/project.json @@ -14,6 +14,13 @@ "cwd": "{projectRoot}" } }, + "create-config-json": { + "executor": "nx:run-commands", + "options": { + "command": "node ../../../tools/create-config-json.js", + "cwd": "{projectRoot}" + } + }, "build": { "executor": "@nx/angular:application", "outputs": ["{options.outputPath}"], diff --git a/apps/model-ad/app/src/config/config.json b/apps/model-ad/app/src/config/config.json deleted file mode 100644 index 04dd6061fc..0000000000 --- a/apps/model-ad/app/src/config/config.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "apiDocsUrl": "http://localhost:8000/api-docs", - "appVersion": "local", - "commitSha": "", - "csrApiUrl": "http://localhost:8000/api/v1", - "googleTagManagerId": "", - "sentryRelease": "", - "ssrApiUrl": "http://model-ad-api:3333/v1" -} diff --git a/apps/model-ad/app/src/config/config.json.template b/apps/model-ad/app/src/config/config.json.template index fc598bc613..e90bcf4df3 100644 --- a/apps/model-ad/app/src/config/config.json.template +++ b/apps/model-ad/app/src/config/config.json.template @@ -1,9 +1,11 @@ { - "apiDocsUrl": "${API_DOCS_URL}", - "appVersion": "${APP_VERSION}", - "commitSha": "${COMMIT_SHA}", - "csrApiUrl": "${CSR_API_URL}", - "googleTagManagerId": "${GOOGLE_TAG_MANAGER_ID}", - "sentryRelease": "${SENTRY_RELEASE}", - "ssrApiUrl": "${SSR_API_URL}" + "apiDocsUrl": "${API_DOCS_URL:-http://localhost:8000/api-docs}", + "appVersion": "${APP_VERSION:-local}", + "commitSha": "${COMMIT_SHA:-}", + "csrApiUrl": "${CSR_API_URL:-http://localhost:8000/api/v1}", + "ssrApiUrl": "${SSR_API_URL:-http://model-ad-api:3333/v1}", + "googleTagManagerId": "${GOOGLE_TAG_MANAGER_ID:-}", + "sentryDSN": "${SENTRY_DSN:-}", + "sentryEnvironment": "${SENTRY_ENVIRONMENT:-localhost}", + "sentryRelease": "${SENTRY_RELEASE:-}" } diff --git a/apps/model-ad/app/src/main.ts b/apps/model-ad/app/src/main.ts index e7c44a064b..0123031785 100644 --- a/apps/model-ad/app/src/main.ts +++ b/apps/model-ad/app/src/main.ts @@ -8,12 +8,8 @@ import { AppComponent } from './app/app.component'; prefetchConfig().then((config) => { initSentry({ - dsn: 'https://bbf0d51ab53013fe73d95348a6bffe61@o4510881207418880.ingest.us.sentry.io/4510896864559104', - hostEnvironmentMap: { - 'dev.modeladexplorer.org': 'dev', - 'stage.modeladexplorer.org': 'stage', - 'modeladexplorer.org': 'prod', - }, + dsn: config?.sentryDSN, + environment: config?.sentryEnvironment, release: config?.sentryRelease, }); diff --git a/libs/agora/config/src/lib/app.config.ts b/libs/agora/config/src/lib/app.config.ts index 50ddcfeea2..21e9d60f0d 100644 --- a/libs/agora/config/src/lib/app.config.ts +++ b/libs/agora/config/src/lib/app.config.ts @@ -1,13 +1,15 @@ import { InjectionToken } from '@angular/core'; export interface AppConfig { + apiDocsUrl: string; appVersion: string; commitSha: string; - apiDocsUrl: string; csrApiUrl: string; ssrApiUrl: string; googleTagManagerId: string; isPlatformServer: boolean; + sentryDSN: string; + sentryEnvironment: string; sentryRelease: string; } diff --git a/libs/agora/testing/src/lib/mocks/config-mocks.ts b/libs/agora/testing/src/lib/mocks/config-mocks.ts index 82b661ed07..f5d2266dcf 100644 --- a/libs/agora/testing/src/lib/mocks/config-mocks.ts +++ b/libs/agora/testing/src/lib/mocks/config-mocks.ts @@ -8,5 +8,7 @@ export const configMock: AppConfig = { ssrApiUrl: 'http://agora-api:3333/v1', isPlatformServer: false, googleTagManagerId: '', + sentryDSN: '', + sentryEnvironment: '', sentryRelease: '', }; diff --git a/libs/explorers/sentry/src/lib/init-sentry.spec.ts b/libs/explorers/sentry/src/lib/init-sentry.spec.ts index f506237f74..b1f5ad00a6 100644 --- a/libs/explorers/sentry/src/lib/init-sentry.spec.ts +++ b/libs/explorers/sentry/src/lib/init-sentry.spec.ts @@ -1,72 +1,73 @@ import * as Sentry from '@sentry/angular'; -import { getSentryEnvironment, initSentry, SentryConfig } from './init-sentry'; +import { initSentry, SentryConfig } from './init-sentry'; jest.mock('@sentry/angular', () => ({ init: jest.fn(), })); -const hostEnvironmentMap: Record = { - 'app-dev.example.com': 'dev', - 'app-stage.example.com': 'stage', - 'app.example.com': 'prod', -}; - const mockConfig: SentryConfig = { dsn: 'https://test-dsn@sentry.io/123', - hostEnvironmentMap, }; -describe('getSentryEnvironment', () => { - // The "server" (SSR) path cannot be tested in jsdom since window is always defined, - // and passing undefined explicitly triggers the default parameter which reads window.location.hostname. - - it('should return "localhost" for localhost', () => { - expect(getSentryEnvironment(hostEnvironmentMap, 'localhost')).toBe('localhost'); +describe('initSentry', () => { + afterEach(() => { + (Sentry.init as jest.Mock).mockClear(); }); - it('should return "localhost" for 127.0.0.1', () => { - expect(getSentryEnvironment(hostEnvironmentMap, '127.0.0.1')).toBe('localhost'); - }); + it('should initialize Sentry with environment and release', () => { + initSentry({ ...mockConfig, environment: 'prod', release: '1.0.0+abc1234' }); - it('should resolve a mapped hostname to its environment', () => { - expect(getSentryEnvironment(hostEnvironmentMap, 'app-dev.example.com')).toBe('dev'); - expect(getSentryEnvironment(hostEnvironmentMap, 'app-stage.example.com')).toBe('stage'); - expect(getSentryEnvironment(hostEnvironmentMap, 'app.example.com')).toBe('prod'); + expect(Sentry.init).toHaveBeenCalledWith({ + dsn: mockConfig.dsn, + environment: 'prod', + release: '1.0.0+abc1234', + sendDefaultPii: false, + }); }); - it('should fall back to the raw hostname when not in the map', () => { - expect(getSentryEnvironment(hostEnvironmentMap, 'unknown-host.example.com')).toBe( - 'unknown-host.example.com', - ); - }); -}); + it('should fallback to window.location.hostname when environment not provided', () => { + initSentry(mockConfig); -describe('initSentry', () => { - afterEach(() => { - (Sentry.init as jest.Mock).mockClear(); + expect(Sentry.init).toHaveBeenCalledWith( + expect.objectContaining({ environment: window.location.hostname }), + ); }); - it('should initialize Sentry with release from config', () => { - initSentry({ ...mockConfig, release: '1.0.0+abc1234' }); + it('should fallback to window.location.hostname when environment is empty string', () => { + initSentry({ ...mockConfig, environment: '' }); expect(Sentry.init).toHaveBeenCalledWith( - expect.objectContaining({ - dsn: mockConfig.dsn, - release: '1.0.0+abc1234', - sendDefaultPii: false, - }), + expect.objectContaining({ environment: window.location.hostname }), ); }); - it('should initialize Sentry without release when release is not provided', () => { + it('should initialize Sentry without release when not provided', () => { initSentry(mockConfig); expect(Sentry.init).toHaveBeenCalledWith(expect.objectContaining({ release: undefined })); }); - it('should initialize Sentry without release when release is empty string', () => { + it('should initialize Sentry without release when empty string', () => { initSentry({ ...mockConfig, release: '' }); expect(Sentry.init).toHaveBeenCalledWith(expect.objectContaining({ release: undefined })); }); + + it('should warn and not initialize Sentry when dsn is not provided', () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(); + initSentry({}); + + expect(console.warn).toHaveBeenCalledWith('Sentry DSN is not configured.'); + expect(Sentry.init).not.toHaveBeenCalled(); + warnSpy.mockRestore(); + }); + + it('should warn and not initialize Sentry when dsn is empty string', () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(); + initSentry({ dsn: '' }); + + expect(console.warn).toHaveBeenCalledWith('Sentry DSN is not configured.'); + expect(Sentry.init).not.toHaveBeenCalled(); + warnSpy.mockRestore(); + }); }); diff --git a/libs/explorers/sentry/src/lib/init-sentry.ts b/libs/explorers/sentry/src/lib/init-sentry.ts index 94697953eb..6749543198 100644 --- a/libs/explorers/sentry/src/lib/init-sentry.ts +++ b/libs/explorers/sentry/src/lib/init-sentry.ts @@ -1,27 +1,21 @@ import * as Sentry from '@sentry/angular'; export interface SentryConfig { - dsn: string; - hostEnvironmentMap: Record; + dsn?: string; + environment?: string; release?: string; } -export function getSentryEnvironment( - hostEnvironmentMap: Record, - hostname = typeof window !== 'undefined' ? window.location.hostname : undefined, -): string { - if (!hostname) return 'server'; - if (hostname === 'localhost' || hostname === '127.0.0.1') return 'localhost'; - - return hostEnvironmentMap[hostname] ?? hostname; -} - export function initSentry(config: SentryConfig): void { if (typeof window === 'undefined') return; + if (!config.dsn) { + console.warn('Sentry DSN is not configured.'); + return; + } Sentry.init({ dsn: config.dsn, - environment: getSentryEnvironment(config.hostEnvironmentMap), + environment: config.environment || window.location.hostname, release: config.release || undefined, sendDefaultPii: false, }); diff --git a/libs/model-ad/config/src/lib/app.config.ts b/libs/model-ad/config/src/lib/app.config.ts index 50ddcfeea2..268b21eeb4 100644 --- a/libs/model-ad/config/src/lib/app.config.ts +++ b/libs/model-ad/config/src/lib/app.config.ts @@ -8,6 +8,8 @@ export interface AppConfig { ssrApiUrl: string; googleTagManagerId: string; isPlatformServer: boolean; + sentryDSN: string; + sentryEnvironment: string; sentryRelease: string; } diff --git a/libs/model-ad/testing/src/lib/mocks/config-mocks.ts b/libs/model-ad/testing/src/lib/mocks/config-mocks.ts index c49a159305..74fa173a32 100644 --- a/libs/model-ad/testing/src/lib/mocks/config-mocks.ts +++ b/libs/model-ad/testing/src/lib/mocks/config-mocks.ts @@ -8,5 +8,7 @@ export const configMock: AppConfig = { googleTagManagerId: '', ssrApiUrl: 'http://model-ad-api:3333/v1', isPlatformServer: false, + sentryDSN: '', + sentryEnvironment: '', sentryRelease: '', }; diff --git a/tools/create-config-json.js b/tools/create-config-json.js new file mode 100644 index 0000000000..076ed94892 --- /dev/null +++ b/tools/create-config-json.js @@ -0,0 +1,32 @@ +#!/usr/bin/env node +/** + * Generates config.json from config.json.template by substituting environment variables. + * + * Supports POSIX shell parameter expansion syntax: ${VAR:-default} + * - If VAR is set, uses its value + * - If VAR is unset or empty, uses the default value + * + * Usage: node tools/create-config-json.js + * (Run from project root, e.g., apps/agora/app) + */ + +const fs = require('fs'); +const path = require('path'); + +const templatePath = path.join(process.cwd(), 'src/config/config.json.template'); +const outputPath = path.join(process.cwd(), 'src/config/config.json'); + +if (!fs.existsSync(templatePath)) { + console.error(`Template not found: ${templatePath}`); + process.exit(1); +} + +const template = fs.readFileSync(templatePath, 'utf8'); + +const result = template.replace(/\${([^}]+)}/g, (_, expr) => { + const [varName, defaultVal] = expr.split(':-'); + return process.env[varName] || defaultVal || ''; +}); + +fs.writeFileSync(outputPath, result); +console.log(`Created ${outputPath}`);