diff --git a/apps/lfx-one/.env.example b/apps/lfx-one/.env.example index 0dc7971f7..003248014 100644 --- a/apps/lfx-one/.env.example +++ b/apps/lfx-one/.env.example @@ -161,6 +161,11 @@ CHANGELOG_PRODUCT_ID=your-changelog-product-uuid LINKEDIN_ACCESS_TOKEN=your-linkedin-access-token LINKEDIN_AD_ACCOUNT_ID=your-linkedin-ad-account-id LINKEDIN_ORG_ID=your-linkedin-org-id +# Path to the runtime LinkedIn config file (account/org/targeting/exclusion data). +# In production this is mounted from the staticConfigMaps.linkedin-config ConfigMap. +# For local dev, point this at a JSON file matching the LinkedInRuntimeConfig shape +# (defaultAccountId, defaultOrgId, accounts[], employerExclusions[], targetingProfiles[]). +# LINKEDIN_CONFIG_PATH=/etc/lfx-self-serve/linkedin/linkedin.json # Stripe publishable key # This value can be obtained from our AWS Systems Manager Parameter Store: diff --git a/apps/lfx-one/src/app/modules/dashboards/campaigns/components/implementation-tab/implementation-tab.component.html b/apps/lfx-one/src/app/modules/dashboards/campaigns/components/implementation-tab/implementation-tab.component.html index 4e3ed4a1f..3a556dd4c 100644 --- a/apps/lfx-one/src/app/modules/dashboards/campaigns/components/implementation-tab/implementation-tab.component.html +++ b/apps/lfx-one/src/app/modules/dashboards/campaigns/components/implementation-tab/implementation-tab.component.html @@ -189,12 +189,12 @@

LinkedIn Sponsored Content

(change)="onLinkedInAccountChange($event)" class="rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-900 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500" data-testid="implementation-linkedin-account"> - @for (account of linkedInAccounts; track account.accountId) { + @for (account of linkedInAccounts(); track account.accountId) { } @if (selectedLinkedInAccount()) { -

Org: {{ selectedLinkedInAccount().organizationId }} · Status: {{ selectedLinkedInAccount().status }}

+

Org: {{ selectedLinkedInAccount().orgId }} · Status: {{ selectedLinkedInAccount().status }}

} diff --git a/apps/lfx-one/src/app/modules/dashboards/campaigns/components/implementation-tab/implementation-tab.component.ts b/apps/lfx-one/src/app/modules/dashboards/campaigns/components/implementation-tab/implementation-tab.component.ts index 2263add40..7015690f4 100644 --- a/apps/lfx-one/src/app/modules/dashboards/campaigns/components/implementation-tab/implementation-tab.component.ts +++ b/apps/lfx-one/src/app/modules/dashboards/campaigns/components/implementation-tab/implementation-tab.component.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: MIT import { SlicePipe } from '@angular/common'; -import { Component, computed, DestroyRef, effect, inject, input, signal } from '@angular/core'; +import { Component, computed, DestroyRef, effect, inject, input, OnInit, signal } from '@angular/core'; import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; import { FormArray, FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; import { ButtonComponent } from '@components/button/button.component'; @@ -10,9 +10,7 @@ import { CAMPAIGN_BUDGET_DEFAULTS, CAMPAIGN_CHAR_LIMITS, CAMPAIGN_JOB_POLL_INTERVAL_MS, - LINKEDIN_AD_ACCOUNTS, LINKEDIN_CHAR_LIMITS, - LINKEDIN_DEFAULT_ACCOUNT_ID, LINKEDIN_GEO_RESOLVE_MAP, } from '@lfx-one/shared/constants'; import { CampaignService } from '@services/campaign.service'; @@ -26,7 +24,7 @@ import type { CampaignKeyword, CampaignPlatform, CampaignType, - LinkedInAdAccount, + LinkedInAccount, LinkedInCreativeVariant, LinkedInGeoTarget, LinkedInTargetingProfile, @@ -41,7 +39,7 @@ type ImplementationStep = 'form' | 'creating' | 'results'; templateUrl: './implementation-tab.component.html', styleUrl: './implementation-tab.component.scss', }) -export class ImplementationTabComponent { +export class ImplementationTabComponent implements OnInit { // === Services === private readonly campaignService = inject(CampaignService); private readonly fb = inject(FormBuilder); @@ -53,7 +51,6 @@ export class ImplementationTabComponent { // === Constants === protected readonly charLimits = CAMPAIGN_CHAR_LIMITS; protected readonly linkedInCharLimits = LINKEDIN_CHAR_LIMITS; - protected readonly linkedInAccounts: readonly LinkedInAdAccount[] = LINKEDIN_AD_ACCOUNTS; protected readonly allKnownGeos: LinkedInGeoTarget[] = [...new Map(Object.values(LINKEDIN_GEO_RESOLVE_MAP).map((g) => [g.urn, g])).values()]; protected readonly todayDate = new Date().toISOString().split('T')[0]; protected readonly defaultEndDate = new Date(Date.now() + 30 * 86_400_000).toISOString().split('T')[0]; @@ -88,7 +85,9 @@ export class ImplementationTabComponent { protected readonly linkedInVariants = signal([]); protected readonly linkedInBudgetUsd = signal(500); protected readonly linkedInLifetimeBudget = signal(false); - protected readonly linkedInAccountId = signal(LINKEDIN_DEFAULT_ACCOUNT_ID); + protected readonly linkedInAccounts = signal([]); + protected readonly linkedInAccountsLoading = signal(false); + protected readonly linkedInAccountId = signal(''); protected readonly redditVariants = signal([]); protected readonly redditSubreddits = signal([]); protected readonly redditInterests = signal([]); @@ -100,9 +99,10 @@ export class ImplementationTabComponent { protected readonly showGoogleSection = computed(() => this.selectedPlatforms().includes('google-ads')); protected readonly showLinkedInSection = computed(() => this.selectedPlatforms().includes('linkedin-ads')); protected readonly showRedditSection = computed(() => this.selectedPlatforms().includes('reddit-ads')); - protected readonly selectedLinkedInAccount = computed( - () => this.linkedInAccounts.find((a) => a.accountId === this.linkedInAccountId()) ?? this.linkedInAccounts[0] - ); + protected readonly selectedLinkedInAccount = computed(() => { + const accounts = this.linkedInAccounts(); + return accounts.find((a) => a.accountId === this.linkedInAccountId()) ?? accounts[0]; + }); protected readonly canSubmit = computed(() => { const platforms = this.selectedPlatforms(); @@ -153,6 +153,25 @@ export class ImplementationTabComponent { }); } + public ngOnInit(): void { + this.linkedInAccountsLoading.set(true); + this.campaignService + .getLinkedInAccounts() + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: (accounts) => { + this.linkedInAccounts.set(accounts); + if (accounts.length > 0 && !this.linkedInAccountId()) { + this.linkedInAccountId.set(accounts[0].accountId); + } + this.linkedInAccountsLoading.set(false); + }, + error: () => { + this.linkedInAccountsLoading.set(false); + }, + }); + } + // === Public Methods === public reset(): void { this.jobSubscription?.unsubscribe(); diff --git a/apps/lfx-one/src/app/modules/dashboards/campaigns/components/monitoring-tab/monitoring-tab.component.html b/apps/lfx-one/src/app/modules/dashboards/campaigns/components/monitoring-tab/monitoring-tab.component.html index ed59de78e..e831c6b05 100644 --- a/apps/lfx-one/src/app/modules/dashboards/campaigns/components/monitoring-tab/monitoring-tab.component.html +++ b/apps/lfx-one/src/app/modules/dashboards/campaigns/components/monitoring-tab/monitoring-tab.component.html @@ -371,8 +371,8 @@

Keyword Performance

aria-label="LinkedIn ad account" class="rounded-md border border-gray-300 px-3 py-1.5 text-sm text-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500" data-testid="linkedin-account-select"> - @for (acct of linkedInAccountOptions(); track acct.key) { - + @for (acct of linkedInAccountOptions(); track acct.accountId) { + }
diff --git a/apps/lfx-one/src/app/modules/dashboards/campaigns/components/monitoring-tab/monitoring-tab.component.ts b/apps/lfx-one/src/app/modules/dashboards/campaigns/components/monitoring-tab/monitoring-tab.component.ts index 106b82ebf..c9b6952ad 100644 --- a/apps/lfx-one/src/app/modules/dashboards/campaigns/components/monitoring-tab/monitoring-tab.component.ts +++ b/apps/lfx-one/src/app/modules/dashboards/campaigns/components/monitoring-tab/monitoring-tab.component.ts @@ -13,7 +13,7 @@ import type { CampaignMonitorResponse, KeywordMetrics, KeywordMetricsResponse, - LinkedInAccountOption, + LinkedInAccount, LinkedInMonitorResponse, LinkedInPacingLabel, RedditAccountOption, @@ -56,7 +56,7 @@ export class MonitoringTabComponent implements OnInit { // Platform switcher protected readonly selectedPlatform = signal('google'); - protected readonly linkedInAccountOptions = signal([]); + protected readonly linkedInAccountOptions = signal([]); protected readonly selectedLinkedInAccountKey = signal(''); protected readonly linkedInLoading = signal(false); protected readonly linkedInData = signal(null); @@ -121,7 +121,7 @@ export class MonitoringTabComponent implements OnInit { next: (accounts) => { this.linkedInAccountOptions.set(accounts); if (accounts.length > 0 && !this.selectedLinkedInAccountKey()) { - this.selectedLinkedInAccountKey.set(accounts[0].key); + this.selectedLinkedInAccountKey.set(accounts[0].accountId); if (this.selectedPlatform() === 'linkedin') { this.fetchLinkedInData(); } diff --git a/apps/lfx-one/src/app/modules/dashboards/campaigns/components/optimization-tab/optimization-tab.component.html b/apps/lfx-one/src/app/modules/dashboards/campaigns/components/optimization-tab/optimization-tab.component.html index 3e4ae8014..df5ad8bfc 100644 --- a/apps/lfx-one/src/app/modules/dashboards/campaigns/components/optimization-tab/optimization-tab.component.html +++ b/apps/lfx-one/src/app/modules/dashboards/campaigns/components/optimization-tab/optimization-tab.component.html @@ -411,8 +411,8 @@

aria-label="LinkedIn ad account" class="rounded-md border border-gray-300 px-2 py-1 text-xs text-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500" data-testid="linkedin-account-select-opt"> - @for (acct of linkedInAccountOptions(); track acct.key) { - + @for (acct of linkedInAccountOptions(); track acct.accountId) { + }

diff --git a/apps/lfx-one/src/app/modules/dashboards/campaigns/components/optimization-tab/optimization-tab.component.ts b/apps/lfx-one/src/app/modules/dashboards/campaigns/components/optimization-tab/optimization-tab.component.ts index 5e5700f29..6e1710a94 100644 --- a/apps/lfx-one/src/app/modules/dashboards/campaigns/components/optimization-tab/optimization-tab.component.ts +++ b/apps/lfx-one/src/app/modules/dashboards/campaigns/components/optimization-tab/optimization-tab.component.ts @@ -10,7 +10,7 @@ import type { KeywordActionType, KeywordMetrics, KeywordMetricsResponse, - LinkedInAccountOption, + LinkedInAccount, LinkedInActionItem, LinkedInMonitorResponse, RedditAccountOption, @@ -77,7 +77,7 @@ export class OptimizationTabComponent implements OnInit { protected readonly hasDisplayCampaigns = computed(() => this.displayCampaigns().length > 0); // LinkedIn optimization - protected readonly linkedInAccountOptions = signal([]); + protected readonly linkedInAccountOptions = signal([]); protected readonly selectedLinkedInAccountKey = signal(''); protected readonly linkedInLoading = signal(false); protected readonly linkedInData = signal(null); @@ -104,7 +104,7 @@ export class OptimizationTabComponent implements OnInit { next: (accounts) => { this.linkedInAccountOptions.set(accounts); if (accounts.length > 0) { - this.selectedLinkedInAccountKey.set(accounts[0].key); + this.selectedLinkedInAccountKey.set(accounts[0].accountId); this.fetchLinkedInOptimization(); } }, diff --git a/apps/lfx-one/src/app/shared/services/campaign.service.ts b/apps/lfx-one/src/app/shared/services/campaign.service.ts index 1af66bdba..1da4c8ec3 100644 --- a/apps/lfx-one/src/app/shared/services/campaign.service.ts +++ b/apps/lfx-one/src/app/shared/services/campaign.service.ts @@ -18,7 +18,7 @@ import { HubSpotUtmCreateResult, HubSpotUtmLookupResult, KeywordMetricsResponse, - LinkedInAccountOption, + LinkedInAccount, LinkedInMonitorResponse, RedditAccountOption, RedditMonitorResponse, @@ -71,8 +71,8 @@ export class CampaignService { return this.http.get('/api/campaigns/monitor', { params: { days } }); } - public getLinkedInAccounts(): Observable { - return this.http.get('/api/campaigns/linkedin/accounts'); + public getLinkedInAccounts(): Observable { + return this.http.get('/api/campaigns/linkedin/accounts'); } public getLinkedInMonitorData(accountKey: string, days: number = 30): Observable { diff --git a/apps/lfx-one/src/server/constants/index.ts b/apps/lfx-one/src/server/constants/index.ts index 7fcd9c401..2fd189dc5 100644 --- a/apps/lfx-one/src/server/constants/index.ts +++ b/apps/lfx-one/src/server/constants/index.ts @@ -2,6 +2,5 @@ // SPDX-License-Identifier: MIT export * from './gateway.constants'; -export * from './linkedin.constants'; export * from './reddit.constants'; export * from './rewards.constants'; diff --git a/apps/lfx-one/src/server/constants/linkedin.constants.ts b/apps/lfx-one/src/server/constants/linkedin.constants.ts deleted file mode 100644 index 6b6a2708f..000000000 --- a/apps/lfx-one/src/server/constants/linkedin.constants.ts +++ /dev/null @@ -1,80 +0,0 @@ -// Copyright The Linux Foundation and each contributor to LFX. -// SPDX-License-Identifier: MIT - -import type { LinkedInTargetingProfileConfig } from '@lfx-one/shared/interfaces'; - -// --------------------------------------------------------------------------- -// LinkedIn Ads — Server-Only Constants -// --------------------------------------------------------------------------- -// These constants contain internal ad-account IDs, org IDs, employer-exclusion -// URNs, and targeting URN lists that must NOT ship to the client bundle. -// UI-safe constants (geo resolve map, char limits) remain in @lfx-one/shared. -// --------------------------------------------------------------------------- - -export const LINKEDIN_ACCOUNTS: readonly { accountId: string; label: string; orgId: string }[] = [ - { accountId: '538170226', label: 'The Linux Foundation', orgId: '208777' }, - { accountId: '509430019', label: 'LF Events', orgId: '208777' }, -] as const; - -export const LINKEDIN_REQUEST_TIMEOUT_MS = 30_000; - -export const LINKEDIN_EMPLOYER_EXCLUSIONS: readonly string[] = ['urn:li:company:33275771', 'urn:li:company:12893459'] as const; - -export const LINKEDIN_TARGETING_PROFILES: readonly LinkedInTargetingProfileConfig[] = [ - { - id: 'cloud-native', - label: 'Cloud Native / CNCF', - skills: [ - 'urn:li:skill:55158', - 'urn:li:skill:56347', - 'urn:li:skill:56319', - 'urn:li:skill:18442', - 'urn:li:skill:1500290', - 'urn:li:skill:55734', - 'urn:li:skill:55383', - 'urn:li:skill:1500358', - 'urn:li:skill:56908', - 'urn:li:skill:58498', - 'urn:li:skill:55644', - 'urn:li:skill:55102', - 'urn:li:skill:56912', - 'urn:li:skill:18443', - 'urn:li:skill:25168', - 'urn:li:skill:56320', - 'urn:li:skill:25154', - 'urn:li:skill:56580', - 'urn:li:skill:56581', - 'urn:li:skill:55385', - ], - groups: [ - 'urn:li:group:6821178', - 'urn:li:group:9375272', - 'urn:li:group:12405624', - 'urn:li:group:12391549', - 'urn:li:group:8553150', - 'urn:li:group:13681295', - 'urn:li:group:4490628', - 'urn:li:group:2602008', - 'urn:li:group:50985', - 'urn:li:group:6585490', - 'urn:li:group:3779791', - 'urn:li:group:13799412', - ], - }, - { - id: 'mcp', - label: 'MCP / Agentic AI', - skills: [ - 'urn:li:skill:59695', - 'urn:li:skill:59040', - 'urn:li:skill:61790', - 'urn:li:skill:2407', - 'urn:li:skill:3289', - 'urn:li:skill:56912', - 'urn:li:skill:61642', - 'urn:li:skill:59698', - 'urn:li:skill:5835', - ], - groups: ['urn:li:group:6672014', 'urn:li:group:6608681', 'urn:li:group:6773450', 'urn:li:group:10321152', 'urn:li:group:6731624', 'urn:li:group:961087'], - }, -] as const; diff --git a/apps/lfx-one/src/server/controllers/campaign.controller.ts b/apps/lfx-one/src/server/controllers/campaign.controller.ts index 3440adbc6..9004e1ea2 100644 --- a/apps/lfx-one/src/server/controllers/campaign.controller.ts +++ b/apps/lfx-one/src/server/controllers/campaign.controller.ts @@ -11,10 +11,11 @@ import type { FlushableResponse, } from '@lfx-one/shared/interfaces'; -import { LINKEDIN_ACCOUNTS, REDDIT_ACCOUNTS } from '../constants'; +import { REDDIT_ACCOUNTS } from '../constants'; import { ServiceValidationError } from '../errors'; import { CampaignMetricsService, LinkedInMetricsService, RedditMetricsService } from '../services/campaign-metrics.service'; import { validateScrapeUrl } from '../helpers/url-validation'; +import { getLinkedInConfig } from '../services/linkedin-ads.service'; import { CampaignProxyService } from '../services/campaign-proxy.service'; import { logger } from '../services/logger.service'; import { addShutdownHook, isShuttingDown } from '../utils/shutdown'; @@ -314,8 +315,13 @@ export class CampaignController { } public getLinkedInAccounts(_req: Request, res: Response): void { - const accounts = LINKEDIN_ACCOUNTS.map((a) => ({ key: a.accountId, label: a.label })); - res.json(accounts); + const config = getLinkedInConfig(); + // Return default account first so clients defaulting to accounts[0] honour the configured default. + const sorted = [ + ...config.accounts.filter((a) => a.accountId === config.defaultAccountId), + ...config.accounts.filter((a) => a.accountId !== config.defaultAccountId), + ]; + res.json(sorted); } public async getLinkedInMonitor(req: Request, res: Response, next: NextFunction): Promise { @@ -323,7 +329,8 @@ export class CampaignController { const parsedDays = /^\d+$/.test(rawDays) ? Number(rawDays) : NaN; const days = Number.isFinite(parsedDays) ? Math.min(Math.max(parsedDays, 7), 90) : 30; const rawKey = String(req.query['accountKey'] ?? ''); - const account = LINKEDIN_ACCOUNTS.find((a) => a.accountId === rawKey) ?? LINKEDIN_ACCOUNTS[0]; + const config = getLinkedInConfig(); + const account = config.accounts.find((a) => a.accountId === rawKey) ?? config.accounts[0]; if (!account) { next( ServiceValidationError.forField('accountKey', 'Invalid LinkedIn account key', { diff --git a/apps/lfx-one/src/server/services/linkedin-ads.service.ts b/apps/lfx-one/src/server/services/linkedin-ads.service.ts index 8bc073595..c698e47a6 100644 --- a/apps/lfx-one/src/server/services/linkedin-ads.service.ts +++ b/apps/lfx-one/src/server/services/linkedin-ads.service.ts @@ -1,7 +1,10 @@ // Copyright The Linux Foundation and each contributor to LFX. // SPDX-License-Identifier: MIT +import fs from 'node:fs'; + import type { + LinkedInAccount, LinkedInActionItem, LinkedInCampaignCreateRequest, LinkedInCampaignCreateResult, @@ -10,16 +13,211 @@ import type { LinkedInGeoTarget, LinkedInMonitorResponse, LinkedInPacingLabel, + LinkedInRuntimeConfig, LinkedInTargetingProfile, + LinkedInTargetingProfileConfig, } from '@lfx-one/shared/interfaces'; -import { LINKEDIN_AD_ACCOUNTS, LINKEDIN_API_VERSION, LINKEDIN_GEO_RESOLVE_MAP } from '@lfx-one/shared/constants'; -import { LINKEDIN_ACCOUNTS, LINKEDIN_EMPLOYER_EXCLUSIONS, LINKEDIN_REQUEST_TIMEOUT_MS, LINKEDIN_TARGETING_PROFILES } from '../constants'; +import { LINKEDIN_API_VERSION, LINKEDIN_GEO_RESOLVE_MAP } from '@lfx-one/shared/constants'; import type { Request } from 'express'; import { logger } from './logger.service'; +// --------------------------------------------------------------------------- +// Runtime config — loaded from a mounted ConfigMap (see lfx-v2-argocd +// values/global/lfx-self-serve.yaml `staticConfigMaps.linkedin-config`). +// Vendor-specific identifiers (ad accounts, org IDs, employer exclusion URNs, +// targeting skill/group URNs) live in the private GitOps repo, not in source. +// Shape is exported from `@lfx-one/shared/interfaces` so a future admin UI +// or test harness can introspect the same types. +// --------------------------------------------------------------------------- + +const EMPTY_LINKEDIN_CONFIG: LinkedInRuntimeConfig = { + defaultAccountId: '', + defaultOrgId: '', + accounts: [], + employerExclusions: [], + targetingProfiles: [], +}; + +// LinkedIn ad-account and organization IDs are numeric (digit-only). Mirrors +// the LINKEDIN_AD_ACCOUNT_ID env-var check in `getAccountId()` so ConfigMap +// values get the same shape guarantees as env values. +const NUMERIC_ID_RE = /^\d+$/; +const LINKEDIN_ACCOUNT_STATUS_VALUES: ReadonlySet> = new Set(['ACTIVE', 'BILLING_HOLD']); + +function isLinkedInAccount(value: unknown): value is LinkedInAccount { + if (!value || typeof value !== 'object') { + return false; + } + const v = value as Record; + if (typeof v['accountId'] !== 'string' || !NUMERIC_ID_RE.test(v['accountId'])) { + return false; + } + if (typeof v['orgId'] !== 'string' || !NUMERIC_ID_RE.test(v['orgId'])) { + return false; + } + if (typeof v['label'] !== 'string') { + return false; + } + // status is optional; if present it must match the documented enum. + const rawStatus = v['status']; + if (rawStatus !== undefined && !LINKEDIN_ACCOUNT_STATUS_VALUES.has(rawStatus as NonNullable)) { + return false; + } + return true; +} + +function isLinkedInTargetingProfile(value: unknown): value is LinkedInTargetingProfileConfig { + if (!value || typeof value !== 'object') { + return false; + } + const v = value as Record; + return ( + typeof v['id'] === 'string' && + typeof v['label'] === 'string' && + Array.isArray(v['skills']) && + v['skills'].every((s) => typeof s === 'string') && + Array.isArray(v['groups']) && + v['groups'].every((g) => typeof g === 'string') + ); +} + +function validateLinkedInConfig(parsed: unknown): LinkedInRuntimeConfig { + if (!parsed || typeof parsed !== 'object') { + throw new TypeError('LinkedIn config root must be a JSON object'); + } + const p = parsed as Record; + + const rawDefaultAccountId = p['defaultAccountId']; + if (rawDefaultAccountId !== undefined && typeof rawDefaultAccountId !== 'string') { + throw new TypeError(`LinkedIn config "defaultAccountId" must be a string when present, got ${typeof rawDefaultAccountId}`); + } + const defaultAccountId = rawDefaultAccountId ?? ''; + // Empty string is a documented opt-out (env var takes precedence); only + // validate the regex when a value was actually provided. + if (defaultAccountId !== '' && !NUMERIC_ID_RE.test(defaultAccountId)) { + throw new TypeError('LinkedIn config "defaultAccountId" must be a digit-only string when non-empty (LinkedIn ad account IDs are numeric)'); + } + + const rawDefaultOrgId = p['defaultOrgId']; + if (rawDefaultOrgId !== undefined && typeof rawDefaultOrgId !== 'string') { + throw new TypeError(`LinkedIn config "defaultOrgId" must be a string when present, got ${typeof rawDefaultOrgId}`); + } + const defaultOrgId = rawDefaultOrgId ?? ''; + if (defaultOrgId !== '' && !NUMERIC_ID_RE.test(defaultOrgId)) { + throw new TypeError('LinkedIn config "defaultOrgId" must be a digit-only string when non-empty (LinkedIn organization IDs are numeric)'); + } + + // For each of the three array fields below, mirror the round-4 pattern from + // defaultAccountId / defaultOrgId above: absent (undefined) defaults to [], but + // an explicit `null` or other non-array value FAILS the type check rather than + // being silently coerced to []. Without this, a malformed ConfigMap with + // `"accounts": null` would silently load as "no accounts configured" and + // surface much later as confusing "no LinkedIn account configured" errors + // instead of a clean reason:'malformed' at load time. + + const rawAccounts = p['accounts']; + if (rawAccounts !== undefined && !Array.isArray(rawAccounts)) { + throw new TypeError(`LinkedIn config "accounts" must be an array when present, got ${rawAccounts === null ? 'null' : typeof rawAccounts}`); + } + const accountsList = (rawAccounts ?? []) as unknown[]; + if (!accountsList.every(isLinkedInAccount)) { + throw new TypeError( + 'LinkedIn config "accounts[]" entries must each have a digit-only accountId (string), digit-only orgId (string), string label, and (when present) status in {ACTIVE, BILLING_HOLD}' + ); + } + + const rawExclusions = p['employerExclusions']; + if (rawExclusions !== undefined && !Array.isArray(rawExclusions)) { + throw new TypeError(`LinkedIn config "employerExclusions" must be an array when present, got ${rawExclusions === null ? 'null' : typeof rawExclusions}`); + } + const exclusionsList = (rawExclusions ?? []) as unknown[]; + if (!exclusionsList.every((s) => typeof s === 'string')) { + throw new TypeError('LinkedIn config "employerExclusions[]" entries must all be strings'); + } + + const rawProfiles = p['targetingProfiles']; + if (rawProfiles !== undefined && !Array.isArray(rawProfiles)) { + throw new TypeError(`LinkedIn config "targetingProfiles" must be an array when present, got ${rawProfiles === null ? 'null' : typeof rawProfiles}`); + } + const profilesList = (rawProfiles ?? []) as unknown[]; + if (!profilesList.every(isLinkedInTargetingProfile)) { + throw new TypeError('LinkedIn config "targetingProfiles[]" entries must each have string id, label, skills[], groups[]'); + } + + return { + defaultAccountId, + defaultOrgId, + accounts: accountsList as readonly LinkedInAccount[], + employerExclusions: exclusionsList as readonly string[], + targetingProfiles: profilesList as readonly LinkedInTargetingProfileConfig[], + }; +} + +function loadLinkedInConfig(): LinkedInRuntimeConfig { + const configPath = process.env['LINKEDIN_CONFIG_PATH'] ?? '/etc/lfx-self-serve/linkedin/linkedin.json'; + const startTime = Date.now(); + let raw: string; + try { + raw = fs.readFileSync(configPath, 'utf8'); + } catch (error: unknown) { + const code = (error as NodeJS.ErrnoException | undefined)?.code; + if (code === 'ENOENT') { + logger.warning(undefined, 'linkedin_config_load', `LinkedIn config file not found at ${configPath} — LinkedIn campaign features will be disabled`, { + configPath, + }); + } else { + logger.error(undefined, 'linkedin_config_load', startTime, error, { + configPath, + reason: 'read_failed', + message: `Failed to read LinkedIn config from ${configPath} — LinkedIn campaign features will be disabled`, + }); + } + return EMPTY_LINKEDIN_CONFIG; + } + + try { + const config = validateLinkedInConfig(JSON.parse(raw)); + logger.debug(undefined, 'linkedin_config_load', `Loaded LinkedIn config from ${configPath}`, { + accounts: config.accounts.length, + profiles: config.targetingProfiles.length, + }); + return config; + } catch (error: unknown) { + logger.error(undefined, 'linkedin_config_load', startTime, error, { + configPath, + reason: 'malformed', + message: `LinkedIn config at ${configPath} is malformed — LinkedIn campaign features will be disabled`, + }); + return EMPTY_LINKEDIN_CONFIG; + } +} + +// Lazy singleton: defer the readFileSync until the first lookup. Importing this +// module (e.g. in unit tests) no longer triggers a filesystem read or a stray +// "config not found" warning. Tests that need a different fixture can call +// `__resetLinkedInConfigForTesting()` after pointing LINKEDIN_CONFIG_PATH at +// their own file. +let cachedLinkedInConfig: LinkedInRuntimeConfig | undefined; + +export function getLinkedInConfig(): LinkedInRuntimeConfig { + if (!cachedLinkedInConfig) { + cachedLinkedInConfig = loadLinkedInConfig(); + } + return cachedLinkedInConfig; +} + +/** + * Test-only: clear the cached config so the next `getLinkedInConfig()` call + * re-reads from disk. Pair with a stubbed `LINKEDIN_CONFIG_PATH` to inject + * fixtures. Not exported from the package's public surface. + */ +export function __resetLinkedInConfigForTesting(): void { + cachedLinkedInConfig = undefined; +} + // --------------------------------------------------------------------------- // LinkedIn Marketing API Constants // --------------------------------------------------------------------------- @@ -32,6 +230,8 @@ const SENIORITY_EXCLUSIONS = ['urn:li:seniority:1', 'urn:li:seniority:3']; const SKIP_STATUSES = new Set(['ARCHIVED', 'CANCELED', 'COMPLETED', 'DRAFT', 'REMOVED', 'DELETED']); +const LINKEDIN_REQUEST_TIMEOUT_MS = 30_000; + // --------------------------------------------------------------------------- // Environment // --------------------------------------------------------------------------- @@ -44,25 +244,103 @@ function getLinkedInEnv(key: string): string { return value; } -function resolveAccountId(override?: string): string { - const id = override || getLinkedInEnv('LINKEDIN_AD_ACCOUNT_ID'); - if (!/^\d+$/.test(id)) { - throw new Error(`Invalid LinkedIn account ID: must be numeric, got "${id}"`); +function getAccountId(override?: string): string { + if (override) { + const config = getLinkedInConfig(); + if (!config.accounts.some((a) => a.accountId === override)) { + throw new Error('Unsupported LinkedIn ad account ID — not in the runtime config. Check the linkedin-config ConfigMap.'); + } + logger.debug(undefined, 'linkedin_config', 'Using LinkedIn account from request', { source: 'request' }); + return override; } - if (override && !LINKEDIN_AD_ACCOUNTS.some((a) => a.accountId === id)) { - throw new Error(`Unsupported LinkedIn ad account ID: "${id}"`); + const envValue = process.env['LINKEDIN_AD_ACCOUNT_ID']; + if (envValue) { + if (!/^\d+$/.test(envValue)) { + throw new Error('LINKEDIN_AD_ACCOUNT_ID must be a numeric string (LinkedIn ad account IDs are digit-only)'); + } + logger.debug(undefined, 'linkedin_config', 'Using LinkedIn account from env', { source: 'env' }); + return envValue; } - return id; + const config = getLinkedInConfig(); + if (config.defaultAccountId) { + logger.debug(undefined, 'linkedin_config', 'Using default LinkedIn account', { source: 'config_default' }); + return config.defaultAccountId; + } + throw new Error('No LinkedIn ad account configured: set LINKEDIN_AD_ACCOUNT_ID env or provide defaultAccountId in the LinkedIn config file'); } -function getOrgId(): string { - return getLinkedInEnv('LINKEDIN_ORG_ID'); +function getOrgId(overrideAccountId?: string): string { + const envOrgId = process.env['LINKEDIN_ORG_ID']; + if (envOrgId) { + // Mirror the digit-only guard applied to LINKEDIN_AD_ACCOUNT_ID above and to + // every orgId field in the runtime config (defaultOrgId, accounts[].orgId). + // Without this, a typo or full-URN value (e.g. "urn:li:organization:208777" + // or "abc123") slips through and gets interpolated into + // `urn:li:organization:${envOrgId}` downstream, producing an invalid URN + // that LinkedIn rejects mid-flow with an opaque API error instead of a + // clear config-validation error at startup. + if (!/^\d+$/.test(envOrgId)) { + throw new Error('LINKEDIN_ORG_ID must be a numeric string (LinkedIn organization IDs are digit-only)'); + } + logger.debug(undefined, 'linkedin_config', 'Using LinkedIn org from env', { source: 'env' }); + return envOrgId; + } + + // Auto-resolve org ID from the accounts list when a non-default account is set. + // Falling back to defaultOrgId here would silently pair an override account with + // the default org's URN — a cross-tenant write that LinkedIn rejects mid-flow + // after partial campaign artifacts have already been created. Fail closed. + const config = getLinkedInConfig(); + const accountId = overrideAccountId ?? getAccountId(); + if (accountId !== config.defaultAccountId) { + const match = config.accounts.find((a) => a.accountId === accountId); + if (match) { + logger.debug(undefined, 'linkedin_config', 'Auto-resolved LinkedIn org for account', { + source: 'config_match', + accountLabel: match.label, + }); + return match.orgId; + } + throw new Error( + 'LinkedIn ad account is not in the configured accounts list and LINKEDIN_ORG_ID is not set — refusing to fall back to default org to avoid cross-tenant pairing. Check the linkedin-config ConfigMap.' + ); + } + + if (config.defaultOrgId) { + logger.debug(undefined, 'linkedin_config', 'Using default LinkedIn org', { source: 'config_default' }); + return config.defaultOrgId; + } + throw new Error('No LinkedIn org configured: set LINKEDIN_ORG_ID env or provide defaultOrgId in the LinkedIn config file'); } function getAccessToken(): string { return getLinkedInEnv('LINKEDIN_ACCESS_TOKEN'); } +// Mask a LinkedIn vendor identifier for log context: keep the last 4 digits +// for triage, redact the rest. LinkedIn IDs are typically 8-10 digits, so a +// 4-digit suffix is short enough to avoid effectively logging the whole ID. +function maskAccountId(id: string): string { + if (id.length <= 4) return '***'; + return `***${id.slice(-4)}`; +} + +// Resolve a safe label for log metadata. Prefer the human label from the +// runtime config (already non-sensitive by design); fall back to a masked +// suffix when the account isn't in the loaded config (e.g. config not yet +// loaded, or the caller passed an unknown account override). Avoids leaking +// raw vendor identifiers into logs even on uncommon error paths. +function describeAccountForLog(accountId: string): string { + try { + const match = getLinkedInConfig().accounts.find((a) => a.accountId === accountId); + if (match) return match.label; + } catch { + // Config not loadable — fall through to the masked-ID branch so logging + // stays best-effort and never throws from inside a logger metadata call. + } + return maskAccountId(accountId); +} + // --------------------------------------------------------------------------- // HTTP Helpers // --------------------------------------------------------------------------- @@ -187,9 +465,10 @@ function accountUrn(accountId: string): string { } function resolveOrgId(accountId: string): string { - const account = LINKEDIN_AD_ACCOUNTS.find((a) => a.accountId === accountId); - if (account) return account.organizationId; - return getOrgId(); + const config = getLinkedInConfig(); + const match = config.accounts.find((a) => a.accountId === accountId); + if (match) return match.orgId; + return getOrgId(accountId); } function orgUrn(accountId: string): string { @@ -365,14 +644,18 @@ export function buildTargetingCriteria(profile: LinkedInTargetingProfile, geoUrn let skills: readonly string[] = []; let groups: readonly string[] = []; + const config = getLinkedInConfig(); if (profile === 'custom') { - const cloudNative = LINKEDIN_TARGETING_PROFILES.find((p) => p.id === 'cloud-native'); + const cloudNative = config.targetingProfiles.find((p) => p.id === 'cloud-native'); skills = cloudNative?.skills || []; groups = cloudNative?.groups || []; } else { - const profileConfig = LINKEDIN_TARGETING_PROFILES.find((p) => p.id === profile); - skills = profileConfig?.skills || []; - groups = profileConfig?.groups || []; + const profileConfig = config.targetingProfiles.find((p) => p.id === profile); + if (!profileConfig) { + throw new Error(`LinkedIn targeting profile "${profile}" not found in runtime config — check the linkedin-config ConfigMap`); + } + skills = profileConfig.skills; + groups = profileConfig.groups; } return { @@ -391,7 +674,7 @@ export function buildTargetingCriteria(profile: LinkedInTargetingProfile, geoUrn }, exclude: { or: { - 'urn:li:adTargetingFacet:employers': [...LINKEDIN_EMPLOYER_EXCLUSIONS], + 'urn:li:adTargetingFacet:employers': [...config.employerExclusions], 'urn:li:adTargetingFacet:seniorities': SENIORITY_EXCLUSIONS, }, }, @@ -419,12 +702,54 @@ export function buildLinkedInUtmUrl(baseUrl: string, hsToken: string | undefined // Orchestrator — full campaign creation flow // --------------------------------------------------------------------------- +/** + * Preflight check: probe every runtime-config-dependent helper before we + * touch the LinkedIn API. Without this, a config gap — missing targeting + * profile, env-override account that isn't in the config's `accounts[]`, + * empty `defaultOrgId`, etc. — would only surface in step 3 of + * `executeLinkedInCampaignCreation` (`createCampaign` → `buildTargetingCriteria`), + * after step 2 has already created a campaign group in LinkedIn. That + * leaves an orphan campaign group that has to be cleaned up by hand. + * + * `verifyAccount()` (step 1) is a GET-only probe that exercises + * `getAccountId()` and `getAccessToken()`, so we don't repeat those here. + * We focus on the org-resolution and targeting-profile lookups, which + * otherwise only run inside `createCampaign`. + */ +function validateLinkedInPrerequisites(profile: LinkedInTargetingProfile, adAccountId?: string): void { + // Resolves the org URN for the specific account (or default if none specified). + // Throws cleanly if no path is configured, or if an account override isn't in + // the runtime config's accounts[] list. + getOrgId(adAccountId); + const config = getLinkedInConfig(); + + // 'custom' is treated as an alias for 'cloud-native' inside buildTargetingCriteria() + // (see the `if (profile === 'custom')` branch — it pulls cloud-native skills/groups + // as the fallback). The brief-generation flow can legitimately emit 'custom' when + // the AI doesn't recommend a named profile, so the preflight must validate the + // SAME entry the runtime path will read, not hard-fail. Without this alias, a + // brief that sets targetingProfile: 'custom' would die at preflight even though + // the actual campaign-creation code path supports it. + const profileToValidate = profile === 'custom' ? 'cloud-native' : profile; + if (!config.targetingProfiles.find((p) => p.id === profileToValidate)) { + const missing = profile === 'custom' ? `'cloud-native' (the fallback for 'custom')` : `'${profile}'`; + throw new Error( + `LinkedIn targeting profile ${missing} not found in runtime config — refusing to start campaign creation to avoid partial LinkedIn artifacts. Check the linkedin-config ConfigMap.` + ); + } +} + export async function executeLinkedInCampaignCreation(req: Request | undefined, params: LinkedInCampaignCreateRequest): Promise { const steps: string[] = []; const startTime = logger.startOperation(req, 'linkedin_campaign_create', { event: params.eventName }); - const accountId = resolveAccountId(params.adAccountId); + const accountId = getAccountId(params.adAccountId); try { + // Validate runtime-config dependencies BEFORE any side-effecting LinkedIn + // call, so a missing/malformed ConfigMap can't leave orphan campaign + // groups behind in step 2. + validateLinkedInPrerequisites(params.targetingProfile, accountId); + const account = await verifyAccount(accountId); steps.push(`Verified account: ${account.name} (${account.status})`); @@ -508,7 +833,12 @@ interface LinkedInCampaignElement { } export async function getLinkedInAnalytics(req: Request | undefined, accountId: string, days: number): Promise { - const startTime = logger.startOperation(req, 'linkedin_analytics', { accountId, days }); + // Derive once so every log line in this function uses the same redacted + // identifier. PR description guarantees raw account/org IDs do not appear + // in logs after externalization; this preserves that guarantee on the + // analytics path. + const accountLabel = describeAccountForLog(accountId); + const startTime = logger.startOperation(req, 'linkedin_analytics', { accountLabel, days }); const token = getAccessToken(); const version = LINKEDIN_API_VERSION; @@ -535,7 +865,7 @@ export async function getLinkedInAnalytics(req: Request | undefined, accountId: if (!campaignsResp.ok) { const text = await campaignsResp.text().catch(() => ''); const err = new Error(`LinkedIn adCampaigns fetch failed: ${campaignsResp.status}: ${text.slice(0, 400)}`); - logger.error(req, 'linkedin_analytics', startTime, err, { accountId }); + logger.error(req, 'linkedin_analytics', startTime, err, { accountLabel }); throw err; } const campaignsData = (await campaignsResp.json()) as { elements?: LinkedInCampaignElement[] }; @@ -546,7 +876,7 @@ export async function getLinkedInAnalytics(req: Request | undefined, accountId: } if (campaigns.length === 0) { - const account = LINKEDIN_ACCOUNTS.find((a) => a.accountId === accountId); + const account = getLinkedInConfig().accounts.find((a) => a.accountId === accountId); const result: LinkedInMonitorResponse = { accountLabel: account?.label ?? accountId, pulledAt: new Date().toISOString(), @@ -569,7 +899,7 @@ export async function getLinkedInAnalytics(req: Request | undefined, accountId: const analyticsMap = new Map(); if (!analyticsResp.ok) { - logger.warning(req, 'linkedin_analytics', `LinkedIn adAnalyticsV2 returned ${analyticsResp.status} — campaign metrics will show zero`, { accountId }); + logger.warning(req, 'linkedin_analytics', `LinkedIn adAnalyticsV2 returned ${analyticsResp.status} — campaign metrics will show zero`, { accountLabel }); } if (analyticsResp.ok) { const analyticsData = (await analyticsResp.json()) as { @@ -777,7 +1107,7 @@ export async function getLinkedInAnalytics(req: Request | undefined, accountId: { spend: 0, impressions: 0, clicks: 0, conversions: 0, campaignCount: 0 } ); - const account = LINKEDIN_ACCOUNTS.find((a) => a.accountId === accountId); + const account = getLinkedInConfig().accounts.find((a) => a.accountId === accountId); const result: LinkedInMonitorResponse = { accountLabel: account?.label ?? accountId, pulledAt: new Date().toISOString(), diff --git a/charts/lfx-self-serve/templates/_helpers.tpl b/charts/lfx-self-serve/templates/_helpers.tpl index 2abe4d04f..ab0bbe1f5 100644 --- a/charts/lfx-self-serve/templates/_helpers.tpl +++ b/charts/lfx-self-serve/templates/_helpers.tpl @@ -149,3 +149,80 @@ ExternalSecret-specific annotations override global ones on key conflicts {{- toYaml $notations }} {{- end }} {{- end }} + +{{/* +Validate the staticConfigMaps root value itself is a map (or nil). +Catches bad-shape inputs like `staticConfigMaps: "foo"`, +`staticConfigMaps: [a,b]`, `staticConfigMaps: ""`, or `staticConfigMaps: []` +before any caller iterates with `range` or runs `toJson` for the checksum +annotation, so failures surface with a clear message instead of an opaque +template type error from inside a range or sprig function. + +Uses `hasKey` + `kindIs "invalid"` (Helm's "kind" for nil) instead of a +truthiness gate so empty-but-mistyped values (`""`, `[]`, `0`) still fail +the type check; only an absent key or an explicit `null` is treated as +"no static ConfigMaps configured". + +Call once at the top of any template that reads .Values.staticConfigMaps: + {{- include "lfx-self-serve.staticConfigMaps.rootValidate" . }} +*/}} +{{- define "lfx-self-serve.staticConfigMaps.rootValidate" -}} +{{- if hasKey .Values "staticConfigMaps" -}} +{{- $scm := .Values.staticConfigMaps -}} +{{- if and (not (kindIs "invalid" $scm)) (not (kindIs "map" $scm)) -}} +{{- fail (printf "staticConfigMaps must be a map of -> { mountPath, data } (got %s)" (kindOf $scm)) -}} +{{- end -}} +{{- end -}} +{{- end }} + +{{/* +Validate one staticConfigMaps entry. Called from both configmap.yaml and +deployment.yaml so any caller that touches a malformed entry fails with a +clear error message — regardless of which template Helm renders first. + +Args (dict): + name — the staticConfigMaps key (becomes ConfigMap suffix + volume name) + cfg — the staticConfigMaps value (must be a map with mountPath + data) + root — the chart root context, used to derive the rendered ConfigMap name +*/}} +{{- define "lfx-self-serve.staticConfigMaps.validate" -}} +{{- $name := .name -}} +{{- $cfg := .cfg -}} +{{- $root := .root -}} +{{- if not (regexMatch "^[a-z0-9]([a-z0-9-]*[a-z0-9])?$" $name) -}} +{{- fail (printf "staticConfigMaps key %q must be a valid DNS-1123 label (lowercase alphanumerics and '-', start/end with alphanumeric)" $name) -}} +{{- end -}} +{{- if gt (len $name) 63 -}} +{{- fail (printf "staticConfigMaps key %q exceeds the 63-char DNS-1123 label limit (the key is also used as the pod volume name, which Kubernetes rejects above 63 chars)" $name) -}} +{{- end -}} +{{- if not (kindIs "map" $cfg) -}} +{{- fail (printf "staticConfigMaps.%s must be a map with mountPath and data keys (got %s)" $name (kindOf $cfg)) -}} +{{- end -}} +{{- if not (kindIs "string" $cfg.mountPath) -}} +{{- fail (printf "staticConfigMaps.%s.mountPath is required and must be a string" $name) -}} +{{- end -}} +{{- if or (eq (trim $cfg.mountPath) "") (not (hasPrefix "/" $cfg.mountPath)) -}} +{{- fail (printf "staticConfigMaps.%s.mountPath %q must be a non-empty absolute path (must start with '/')" $name $cfg.mountPath) -}} +{{- end -}} +{{- if not (kindIs "map" $cfg.data) -}} +{{- fail (printf "staticConfigMaps.%s.data is required and must be a map of file-name -> string content" $name) -}} +{{- end -}} +{{- if eq (len $cfg.data) 0 -}} +{{- fail (printf "staticConfigMaps.%s.data must contain at least one file" $name) -}} +{{- end -}} +{{- range $key, $value := $cfg.data -}} +{{- if not (kindIs "string" $key) -}} +{{- fail (printf "staticConfigMaps.%s.data has a non-string key (got %s) — ConfigMap data keys must be strings; quote numeric or boolean-looking keys in YAML" $name (kindOf $key)) -}} +{{- end -}} +{{- if not (regexMatch "^[A-Za-z0-9._-]+$" $key) -}} +{{- fail (printf "staticConfigMaps.%s.data key %q is invalid; ConfigMap data keys must match [A-Za-z0-9._-]+ (Kubernetes apiserver rejects others at apply time)" $name $key) -}} +{{- end -}} +{{- if not (kindIs "string" $value) -}} +{{- fail (printf "staticConfigMaps.%s.data.%s must be a string (use a YAML literal block scalar like '|' for multi-line content)" $name $key) -}} +{{- end -}} +{{- end -}} +{{- $cmName := printf "%s-%s" (include "lfx-self-serve.fullname" $root) $name -}} +{{- if gt (len $cmName) 253 -}} +{{- fail (printf "ConfigMap name %q exceeds the 253-char DNS-1123 subdomain limit (release fullname + staticConfigMaps key %q is too long)" $cmName $name) -}} +{{- end -}} +{{- end }} diff --git a/charts/lfx-self-serve/templates/configmap.yaml b/charts/lfx-self-serve/templates/configmap.yaml new file mode 100644 index 000000000..ff48051dc --- /dev/null +++ b/charts/lfx-self-serve/templates/configmap.yaml @@ -0,0 +1,21 @@ +# Copyright The Linux Foundation and each contributor to LFX. +# SPDX-License-Identifier: MIT + +{{- include "lfx-self-serve.staticConfigMaps.rootValidate" . }} +{{- range $name, $cfg := .Values.staticConfigMaps }} +{{- include "lfx-self-serve.staticConfigMaps.validate" (dict "name" $name "cfg" $cfg "root" $) }} +{{- $cmName := printf "%s-%s" (include "lfx-self-serve.fullname" $) $name }} +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ $cmName }} + labels: + {{- include "lfx-self-serve.labels" $ | nindent 4 }} + {{- with (include "lfx-self-serve.annotations" $) }} + annotations: + {{- . | nindent 4 }} + {{- end }} +data: + {{- toYaml $cfg.data | nindent 2 }} +{{- end }} diff --git a/charts/lfx-self-serve/templates/deployment.yaml b/charts/lfx-self-serve/templates/deployment.yaml index 94c93dad2..d95f32464 100644 --- a/charts/lfx-self-serve/templates/deployment.yaml +++ b/charts/lfx-self-serve/templates/deployment.yaml @@ -1,6 +1,7 @@ # Copyright The Linux Foundation and each contributor to LFX. # SPDX-License-Identifier: MIT +{{- include "lfx-self-serve.staticConfigMaps.rootValidate" . }} apiVersion: apps/v1 kind: Deployment metadata: @@ -24,6 +25,11 @@ spec: metadata: annotations: {{- include "lfx-self-serve.podAnnotations" . | nindent 8 }} + {{- if .Values.staticConfigMaps }} + # Force a rolling restart whenever any staticConfigMaps content changes, + # so the chart works correctly even in clusters without Stakater Reloader. + checksum/staticConfigMaps: {{ toJson .Values.staticConfigMaps | sha256sum }} + {{- end }} labels: {{- include "lfx-self-serve.labels" . | nindent 8 }} {{- with .Values.podLabels }} @@ -76,3 +82,21 @@ spec: readinessProbe: {{- toYaml . | nindent 12 }} {{- end }} + {{- if .Values.staticConfigMaps }} + volumeMounts: + {{- range $name, $cfg := .Values.staticConfigMaps }} + {{- include "lfx-self-serve.staticConfigMaps.validate" (dict "name" $name "cfg" $cfg "root" $) }} + - name: {{ $name }} + mountPath: {{ $cfg.mountPath }} + readOnly: true + {{- end }} + {{- end }} + {{- if .Values.staticConfigMaps }} + volumes: + {{- range $name, $cfg := .Values.staticConfigMaps }} + {{- include "lfx-self-serve.staticConfigMaps.validate" (dict "name" $name "cfg" $cfg "root" $) }} + - name: {{ $name }} + configMap: + name: {{ include "lfx-self-serve.fullname" $ }}-{{ $name }} + {{- end }} + {{- end }} diff --git a/charts/lfx-self-serve/values.yaml b/charts/lfx-self-serve/values.yaml index 4bf5cb78d..3e77e6959 100644 --- a/charts/lfx-self-serve/values.yaml +++ b/charts/lfx-self-serve/values.yaml @@ -228,6 +228,40 @@ readinessProbe: failureThreshold: 3 successThreshold: 1 +# Static ConfigMaps rendered by the chart and mounted as files into the container. +# Use for non-secret structured data that should not live in source (vendor IDs, +# allowlists, profile definitions, etc.). Each entry produces one ConfigMap named +# "-" and one volume + volumeMount. +# +# Constraints: +# - Each map key must be a valid DNS-1123 label (lowercase, [a-z0-9-], <=63 chars). +# The rendered name "-" must also stay <=253 chars. +# - data values must be strings (use a YAML literal block scalar like '|' for +# multi-line content). Nested maps/numbers/booleans will be rejected at +# render time by the chart's `kindIs "string"` guard. +# +# Example: +# staticConfigMaps: +# linkedin-config: +# mountPath: /etc/lfx-self-serve/linkedin +# data: +# linkedin.json: | +# { "defaultAccountId": "...", "accounts": [...] } +# +# Application override: services that read from a static-config mount typically +# expose an env var to point at a different path during local dev. The +# lfx-one server reads the LinkedIn config from /etc/lfx-self-serve/linkedin/linkedin.json +# by default; set LINKEDIN_CONFIG_PATH to override (handy if you change `mountPath` +# above, or want to swap in a fixture for tests). +# +# Reload-on-change is handled in two layers: +# 1. The existing reloader.stakater.com/auto annotation (when Reloader is +# installed in the cluster), and +# 2. A `checksum/staticConfigMaps` annotation on the Deployment pod template, +# which forces a rolling restart whenever the rendered ConfigMap content +# changes — even if Reloader isn't installed. +staticConfigMaps: {} + # Environment variables for the application # Uses map/object format for deep merging support environment: diff --git a/packages/shared/src/constants/campaign.constants.ts b/packages/shared/src/constants/campaign.constants.ts index 2c4ddeb3e..e5e11626b 100644 --- a/packages/shared/src/constants/campaign.constants.ts +++ b/packages/shared/src/constants/campaign.constants.ts @@ -6,7 +6,6 @@ import type { CampaignPlatformOption, CampaignStatus, CampaignTabOption, - LinkedInAdAccount, LinkedInGeoTarget, ParsedCampaignName, } from '../interfaces/campaign.interface'; @@ -115,16 +114,11 @@ export const LINKEDIN_CHAR_LIMITS = { headline: 200, } as const; -export const LINKEDIN_AD_ACCOUNTS: readonly LinkedInAdAccount[] = [ - { accountId: '538170226', label: 'The Linux Foundation', organizationId: '208777', status: 'ACTIVE' }, - { accountId: '509430019', label: 'LF Events', organizationId: '208777', status: 'ACTIVE' }, - { accountId: '510263296', label: 'CNCF', organizationId: '12893459', status: 'ACTIVE' }, - { accountId: '510263297', label: 'LF Networking', organizationId: '208777', status: 'ACTIVE' }, - { accountId: '510263298', label: 'LF AI & Data', organizationId: '208777', status: 'ACTIVE' }, - { accountId: '510263299', label: 'LF Energy', organizationId: '208777', status: 'ACTIVE' }, -] as const; - -export const LINKEDIN_DEFAULT_ACCOUNT_ID = '538170226'; +// NOTE: LinkedIn ad accounts, default account/org IDs, employer exclusions, and +// targeting profile URN lists are loaded at runtime from a mounted ConfigMap +// (see apps/lfx-one/src/server/services/linkedin-ads.service.ts → loadLinkedInConfig). +// They are kept out of source control entirely so vendor IDs never ship in the +// client bundle or the public chart repo. export const LINKEDIN_GEO_RESOLVE_MAP: Readonly> = { japan: { label: 'Japan', urn: 'urn:li:geo:101355337' }, diff --git a/packages/shared/src/interfaces/campaign.interface.ts b/packages/shared/src/interfaces/campaign.interface.ts index a49c8cb0b..7976bae6b 100644 --- a/packages/shared/src/interfaces/campaign.interface.ts +++ b/packages/shared/src/interfaces/campaign.interface.ts @@ -18,13 +18,6 @@ export interface LinkedInTargetingProfileConfig { groups: readonly string[]; } -export interface LinkedInAdAccount { - accountId: string; - label: string; - organizationId: string; - status: 'ACTIVE' | 'BILLING_HOLD'; -} - export type CampaignStatus = 'draft' | 'paused' | 'enabled' | 'removed' | 'limited' | 'unknown'; export type CampaignType = 'search' | 'demand-gen' | 'sponsored' | 'social'; @@ -169,6 +162,42 @@ export interface LinkedInBriefCopy { strategy?: LinkedInTargetingStrategy; } +/** + * One ad account / org pairing in the runtime LinkedIn config. + * + * Values (accountId, orgId, label, status) are loaded server-side from the + * mounted ConfigMap and never embedded in the client bundle. The type itself + * lives in the shared package because the client consumes it as the response + * shape of `GET /api/campaigns/linkedin/accounts` (see CampaignService. + * getLinkedInAccounts and the campaigns dashboard tabs). + * + * `status` is optional to preserve graceful degradation if the ConfigMap + * omits it; production ConfigMaps always supply it. + */ +export interface LinkedInAccount { + accountId: string; + label: string; + orgId: string; + status?: 'ACTIVE' | 'BILLING_HOLD'; +} + +/** + * Shape of /etc/lfx-self-serve/linkedin/linkedin.json (configurable via the + * LINKEDIN_CONFIG_PATH env var). Mounted by the chart's `staticConfigMaps` + * hook; populated from the private GitOps repo. + */ +export interface LinkedInRuntimeConfig { + defaultAccountId: string; + defaultOrgId: string; + accounts: readonly LinkedInAccount[]; + employerExclusions: readonly string[]; + targetingProfiles: readonly LinkedInTargetingProfileConfig[]; +} + +// --------------------------------------------------------------------------- +// Campaign Creation (Implementation Phase) +// --------------------------------------------------------------------------- + export interface LinkedInCampaignCreateRequest { eventName: string; eventSlug: string; @@ -590,11 +619,6 @@ export interface LinkedInActionItem { action: string; } -export interface LinkedInAccountOption { - key: string; - label: string; -} - export interface LinkedInMonitorResponse { accountLabel: string; pulledAt: string;