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) {
{{ account.label }} ({{ 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) {
- {{ acct.label }}
+ @for (acct of linkedInAccountOptions(); track acct.accountId) {
+ {{ acct.label }}
}
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) {
- {{ acct.label }}
+ @for (acct of linkedInAccountOptions(); track acct.accountId) {
+ {{ acct.label }}
}
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;