From 28ce3f05f63c0d2c031782a4ea2ca46dc1d92327 Mon Sep 17 00:00:00 2001 From: David Deal Date: Tue, 9 Jun 2026 21:01:23 -0700 Subject: [PATCH 01/11] feat(campaigns): externalize LinkedIn config to runtime ConfigMap (LFXV2-2023) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move vendor-specific LinkedIn identifiers (ad accounts, org IDs, employer exclusion URNs, targeting skill/group URNs) out of the source tree and into a runtime-loaded ConfigMap. The chart now exposes a generic `staticConfigMaps` hook that can be reused for any non-secret structured data we don't want shipping in the public chart repo. Companion change in linuxfoundation/lfx-v2-argocd populates the `staticConfigMaps.linkedin-config` block in values/global/lfx-self-serve.yaml. Service (apps/lfx-one/src/server/services/linkedin-ads.service.ts): - Lazy-singleton config load via getLinkedInConfig() — module import no longer hits the filesystem, so unit tests can stub LINKEDIN_CONFIG_PATH and call __resetLinkedInConfigForTesting() to inject fixtures. - Runtime shape validation (validateLinkedInConfig + isLinkedInAccount / isLinkedInTargetingProfile) so a malformed ConfigMap surfaces at load time rather than as a TypeError deep inside .find() at first request. - Distinguish file-not-found (logger.warning, ENOENT) from malformed config (logger.error, reason: 'malformed' | 'read_failed') — both still degrade to EMPTY_LINKEDIN_CONFIG so unrelated routes keep working. - getOrgId() now fails closed when LINKEDIN_AD_ACCOUNT_ID env points to an account not in the runtime config's accounts[]. Previous behavior silently fell back to defaultOrgId, which would mint a cross-tenant account/org pairing LinkedIn rejects mid-flow after partial campaign artifacts already exist. - New validateLinkedInPrerequisites() preflight runs at the very start of executeLinkedInCampaignCreation, before any LinkedIn API call. Without it, an empty/missing targetingProfiles[] would only surface in step 3 (createCampaign), after step 2 had already created an orphan campaign group in LinkedIn that requires manual cleanup. - Debug logs no longer emit raw account/org IDs — replaced with structured metadata (source: 'env' | 'config_default' | 'config_match', accountLabel) so log shipping doesn't carry vendor IDs in plaintext. Helm chart (charts/lfx-self-serve/templates/): - New configmap.yaml renders one ConfigMap per staticConfigMaps entry, with kindIs/regexMatch guards on every field: mountPath must be a string, data must be a non-empty map, every data value must be a string, the key must be a valid DNS-1123 label, and the rendered ConfigMap name must stay <=253 chars. - deployment.yaml mounts the ConfigMap volume(s) when staticConfigMaps is non-empty, and adds a checksum/staticConfigMaps annotation so pods roll on data change even in clusters without Stakater Reloader. - values.yaml doc block expanded: DNS-1123 constraint, string-only data values, LINKEDIN_CONFIG_PATH override, two-layer reload story. Other: - Move LinkedInAccount + LinkedInRuntimeConfig interfaces from a local declaration in the service into @lfx-one/shared/interfaces (matches the convention for all shared types). - Delete apps/lfx-one/src/server/constants/linkedin.constants.ts; the comment in @lfx-one/shared/constants/campaign.constants.ts redirects future readers to the runtime loader. - Add LINKEDIN_CONFIG_PATH to apps/lfx-one/.env.example for local-dev parity. Issue: LFXV2-2023 Co-authored-by: Cursor Signed-off-by: David Deal --- apps/lfx-one/.env.example | 5 + apps/lfx-one/src/server/constants/index.ts | 1 - .../server/constants/linkedin.constants.ts | 78 ------ .../server/services/linkedin-ads.service.ts | 258 +++++++++++++++++- .../lfx-self-serve/templates/configmap.yaml | 39 +++ .../lfx-self-serve/templates/deployment.yaml | 21 ++ charts/lfx-self-serve/values.yaml | 34 +++ .../src/constants/campaign.constants.ts | 8 +- .../src/interfaces/campaign.interface.ts | 23 ++ 9 files changed, 376 insertions(+), 91 deletions(-) delete mode 100644 apps/lfx-one/src/server/constants/linkedin.constants.ts create mode 100644 charts/lfx-self-serve/templates/configmap.yaml diff --git a/apps/lfx-one/.env.example b/apps/lfx-one/.env.example index 4f9188045..90ec0cfb7 100644 --- a/apps/lfx-one/.env.example +++ b/apps/lfx-one/.env.example @@ -161,3 +161,8 @@ 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 diff --git a/apps/lfx-one/src/server/constants/index.ts b/apps/lfx-one/src/server/constants/index.ts index 8001781e3..e331662a0 100644 --- a/apps/lfx-one/src/server/constants/index.ts +++ b/apps/lfx-one/src/server/constants/index.ts @@ -2,5 +2,4 @@ // SPDX-License-Identifier: MIT export * from './gateway.constants'; -export * from './linkedin.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 4dddcdb18..000000000 --- a/apps/lfx-one/src/server/constants/linkedin.constants.ts +++ /dev/null @@ -1,78 +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_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/services/linkedin-ads.service.ts b/apps/lfx-one/src/server/services/linkedin-ads.service.ts index 58160faba..7ad6cc38b 100644 --- a/apps/lfx-one/src/server/services/linkedin-ads.service.ts +++ b/apps/lfx-one/src/server/services/linkedin-ads.service.ts @@ -1,16 +1,171 @@ // Copyright The Linux Foundation and each contributor to LFX. // SPDX-License-Identifier: MIT -import type { LinkedInCampaignCreateRequest, LinkedInCampaignCreateResult, LinkedInGeoTarget, LinkedInTargetingProfile } from '@lfx-one/shared/interfaces'; +import fs from 'node:fs'; + +import type { + LinkedInAccount, + LinkedInCampaignCreateRequest, + LinkedInCampaignCreateResult, + LinkedInGeoTarget, + LinkedInRuntimeConfig, + LinkedInTargetingProfile, + LinkedInTargetingProfileConfig, +} from '@lfx-one/shared/interfaces'; import { LINKEDIN_API_VERSION, LINKEDIN_GEO_RESOLVE_MAP } from '@lfx-one/shared/constants'; -import { LINKEDIN_EMPLOYER_EXCLUSIONS, LINKEDIN_TARGETING_PROFILES } from '../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: [], +}; + +function isLinkedInAccount(value: unknown): value is LinkedInAccount { + if (!value || typeof value !== 'object') { + return false; + } + const v = value as Record; + return typeof v['accountId'] === 'string' && typeof v['label'] === 'string' && typeof v['orgId'] === 'string'; +} + +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 defaultAccountId = typeof p['defaultAccountId'] === 'string' ? p['defaultAccountId'] : ''; + const defaultOrgId = typeof p['defaultOrgId'] === 'string' ? p['defaultOrgId'] : ''; + + const rawAccounts = p['accounts'] ?? []; + if (!Array.isArray(rawAccounts)) { + throw new TypeError(`LinkedIn config "accounts" must be an array, got ${typeof rawAccounts}`); + } + if (!rawAccounts.every(isLinkedInAccount)) { + throw new TypeError('LinkedIn config "accounts[]" entries must each have string accountId, label, and orgId'); + } + + const rawExclusions = p['employerExclusions'] ?? []; + if (!Array.isArray(rawExclusions)) { + throw new TypeError(`LinkedIn config "employerExclusions" must be an array, got ${typeof rawExclusions}`); + } + if (!rawExclusions.every((s) => typeof s === 'string')) { + throw new TypeError('LinkedIn config "employerExclusions[]" entries must all be strings'); + } + + const rawProfiles = p['targetingProfiles'] ?? []; + if (!Array.isArray(rawProfiles)) { + throw new TypeError(`LinkedIn config "targetingProfiles" must be an array, got ${typeof rawProfiles}`); + } + if (!rawProfiles.every(isLinkedInTargetingProfile)) { + throw new TypeError('LinkedIn config "targetingProfiles[]" entries must each have string id, label, skills[], groups[]'); + } + + return { + defaultAccountId, + defaultOrgId, + accounts: rawAccounts as readonly LinkedInAccount[], + employerExclusions: rawExclusions as readonly string[], + targetingProfiles: rawProfiles 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; + +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 // --------------------------------------------------------------------------- @@ -38,11 +193,55 @@ function getLinkedInEnv(key: string): string { } function getAccountId(): string { - return getLinkedInEnv('LINKEDIN_AD_ACCOUNT_ID'); + const envValue = process.env['LINKEDIN_AD_ACCOUNT_ID']; + if (envValue) { + logger.debug(undefined, 'linkedin_config', 'Using LinkedIn account from env', { source: 'env' }); + return envValue; + } + 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'); + const envOrgId = process.env['LINKEDIN_ORG_ID']; + if (envOrgId) { + 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 = 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 env-supplied account', { + source: 'config_match', + accountLabel: match.label, + }); + return match.orgId; + } + throw new Error( + `LINKEDIN_AD_ACCOUNT_ID=${accountId} 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` + ); + } + + 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 { @@ -322,12 +521,18 @@ export function buildTargetingCriteria(profile: LinkedInTargetingProfile, geoUrn let skills: readonly string[] = []; let groups: readonly string[] = []; + const config = getLinkedInConfig(); if (profile === 'custom') { throw new Error('Custom targeting profile is not yet supported — use a named profile (cloud-native, mcp)'); } 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 { @@ -346,7 +551,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, }, }, @@ -374,6 +579,36 @@ 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): void { + if (profile === 'custom') { + throw new Error('Custom targeting profile is not yet supported — use a named profile (cloud-native, mcp)'); + } + // Resolves the org URN (env override, auto-resolved from accounts[], or + // defaultOrgId). Throws cleanly if no path is configured, or if an env + // account override isn't in the runtime config's accounts[] list. + getOrgId(); + const config = getLinkedInConfig(); + if (!config.targetingProfiles.find((p) => p.id === profile)) { + throw new Error( + `LinkedIn targeting profile "${profile}" 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 }); @@ -385,6 +620,11 @@ export async function executeLinkedInCampaignCreation(req: Request | undefined, } 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); + const account = await verifyAccount(); steps.push(`Verified account: ${account.name} (${account.status})`); diff --git a/charts/lfx-self-serve/templates/configmap.yaml b/charts/lfx-self-serve/templates/configmap.yaml new file mode 100644 index 000000000..6501ae46c --- /dev/null +++ b/charts/lfx-self-serve/templates/configmap.yaml @@ -0,0 +1,39 @@ +# Copyright The Linux Foundation and each contributor to LFX. +# SPDX-License-Identifier: MIT + +{{- range $name, $cfg := .Values.staticConfigMaps }} +{{- if not (kindIs "string" $cfg.mountPath) }} +{{- fail (printf "staticConfigMaps.%s.mountPath is required and must be a string" $name) }} +{{- 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" $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" $) $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 }} +{{- 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 }} +--- +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..edb3ac245 100644 --- a/charts/lfx-self-serve/templates/deployment.yaml +++ b/charts/lfx-self-serve/templates/deployment.yaml @@ -24,6 +24,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 +81,19 @@ spec: readinessProbe: {{- toYaml . | nindent 12 }} {{- end }} + {{- if .Values.staticConfigMaps }} + volumeMounts: + {{- range $name, $cfg := .Values.staticConfigMaps }} + - name: {{ $name }} + mountPath: {{ $cfg.mountPath }} + readOnly: true + {{- end }} + {{- end }} + {{- if .Values.staticConfigMaps }} + volumes: + {{- range $name, $cfg := .Values.staticConfigMaps }} + - 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 a60e31cb0..1c3841166 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 b6576155b..a435aeb67 100644 --- a/packages/shared/src/constants/campaign.constants.ts +++ b/packages/shared/src/constants/campaign.constants.ts @@ -114,9 +114,11 @@ export const LINKEDIN_CHAR_LIMITS = { headline: 200, } as const; -// NOTE: LINKEDIN_ACCOUNTS, LINKEDIN_EMPLOYER_EXCLUSIONS, and LINKEDIN_TARGETING_PROFILES -// live in apps/lfx-one/src/server/constants/linkedin.constants.ts -// to keep ad-account IDs, org IDs, and targeting URNs out of the client bundle. +// 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 e37228697..8cb4d8138 100644 --- a/packages/shared/src/interfaces/campaign.interface.ts +++ b/packages/shared/src/interfaces/campaign.interface.ts @@ -141,6 +141,29 @@ export interface LinkedInTargetingProfileConfig { groups: readonly string[]; } +/** + * One ad account / org pairing in the runtime LinkedIn config. + * Loaded server-side from the mounted ConfigMap; not used in the client bundle. + */ +export interface LinkedInAccount { + accountId: string; + label: string; + orgId: string; +} + +/** + * 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) // --------------------------------------------------------------------------- From d1251f0d092daf390f553b9e3ab6cfb5e8912957 Mon Sep 17 00:00:00 2001 From: David Deal Date: Tue, 9 Jun 2026 21:14:06 -0700 Subject: [PATCH 02/11] fix(review): address PR #916 review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review comments from copilot-pull-request-reviewer[bot]: - charts/lfx-self-serve/templates/_helpers.tpl: extract a new `lfx-self-serve.staticConfigMaps.validate` define that runs all staticConfigMaps shape guards in one place. Both configmap.yaml and deployment.yaml now call this include — previously the guards lived only in configmap.yaml, but Helm renders all templates, so a malformed entry would surface as an opaque "can't evaluate field mountPath in type interface {}" from deployment.yaml:88 instead of our intended fail() message (per copilot[bot]). - charts/lfx-self-serve/templates/_helpers.tpl: add an explicit `gt (len $name) 63` guard. The chart docs say each staticConfigMaps key must be a DNS-1123 label (<=63 chars), but the previous regex check only enforced the character set. Since the key is also used as the volume name in deployment.yaml, a key >63 chars rendered successfully but would be rejected by the apiserver at apply time (per copilot[bot]). - charts/lfx-self-serve/templates/_helpers.tpl: add a `kindIs "map" $cfg` guard up front so a non-map entry (e.g. `staticConfigMaps: { foo: "bar" }`) consistently produces a clear "must be a map with mountPath and data keys (got string)" fail() message instead of an opaque template-evaluation error (per copilot[bot]). - charts/lfx-self-serve/templates/configmap.yaml: replace the inline guard cascade with a single `include "...validate"` call. - charts/lfx-self-serve/templates/deployment.yaml: include the validator in both the volumeMounts and volumes ranges so a malformed entry fails-fast regardless of which template Helm renders first. Verified the full guard matrix with `helm template`: happy path renders cleanly; non-map cfg, key >63 chars, key = 63 chars (boundary, passes), non-DNS-1123 key, missing mountPath, non-string data value, and empty data all fail with their dedicated message; default `staticConfigMaps: {}` still renders nothing. Resolves 2 review threads. Signed-off-by: David Deal Co-authored-by: Cursor --- charts/lfx-self-serve/templates/_helpers.tpl | 43 +++++++++++++++++++ .../lfx-self-serve/templates/configmap.yaml | 21 +-------- .../lfx-self-serve/templates/deployment.yaml | 2 + 3 files changed, 46 insertions(+), 20 deletions(-) diff --git a/charts/lfx-self-serve/templates/_helpers.tpl b/charts/lfx-self-serve/templates/_helpers.tpl index 2abe4d04f..d29e9ddf2 100644 --- a/charts/lfx-self-serve/templates/_helpers.tpl +++ b/charts/lfx-self-serve/templates/_helpers.tpl @@ -149,3 +149,46 @@ ExternalSecret-specific annotations override global ones on key conflicts {{- toYaml $notations }} {{- 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 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" $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 index 6501ae46c..0dd761da0 100644 --- a/charts/lfx-self-serve/templates/configmap.yaml +++ b/charts/lfx-self-serve/templates/configmap.yaml @@ -2,27 +2,8 @@ # SPDX-License-Identifier: MIT {{- range $name, $cfg := .Values.staticConfigMaps }} -{{- if not (kindIs "string" $cfg.mountPath) }} -{{- fail (printf "staticConfigMaps.%s.mountPath is required and must be a string" $name) }} -{{- 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" $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 }} +{{- include "lfx-self-serve.staticConfigMaps.validate" (dict "name" $name "cfg" $cfg "root" $) }} {{- $cmName := printf "%s-%s" (include "lfx-self-serve.fullname" $) $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 }} -{{- 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 }} --- apiVersion: v1 kind: ConfigMap diff --git a/charts/lfx-self-serve/templates/deployment.yaml b/charts/lfx-self-serve/templates/deployment.yaml index edb3ac245..6f367d723 100644 --- a/charts/lfx-self-serve/templates/deployment.yaml +++ b/charts/lfx-self-serve/templates/deployment.yaml @@ -84,6 +84,7 @@ spec: {{- 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 @@ -92,6 +93,7 @@ spec: {{- 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 }} From 6bce7d0d414ce73f01c3ec31b0652573e3c9cb78 Mon Sep 17 00:00:00 2001 From: David Deal Date: Tue, 9 Jun 2026 21:27:06 -0700 Subject: [PATCH 03/11] chore(format): run prettier on linkedin-ads.service.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The earlier commit (28ce3f05) introduced a few multi-line throw new Error(...) and logger.warning(...) calls that prettier collapses back onto single lines under this file's print-width config. Without this fix, `yarn format:check` fails in CI on the PR #916 branch. Pure whitespace / line-wrapping diff — no semantic changes. Issue: LFXV2-2023 Signed-off-by: David Deal Co-authored-by: Cursor --- .../server/services/linkedin-ads.service.ts | 21 ++++++------------- 1 file changed, 6 insertions(+), 15 deletions(-) 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 7ad6cc38b..3a413c1dc 100644 --- a/apps/lfx-one/src/server/services/linkedin-ads.service.ts +++ b/apps/lfx-one/src/server/services/linkedin-ads.service.ts @@ -110,12 +110,9 @@ function loadLinkedInConfig(): LinkedInRuntimeConfig { } 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 } - ); + 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, @@ -203,9 +200,7 @@ function getAccountId(): string { 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' - ); + 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 { @@ -239,9 +234,7 @@ function getOrgId(): string { 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' - ); + throw new Error('No LinkedIn org configured: set LINKEDIN_ORG_ID env or provide defaultOrgId in the LinkedIn config file'); } function getAccessToken(): string { @@ -527,9 +520,7 @@ export function buildTargetingCriteria(profile: LinkedInTargetingProfile, geoUrn } else { 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` - ); + throw new Error(`LinkedIn targeting profile "${profile}" not found in runtime config — check the linkedin-config ConfigMap`); } skills = profileConfig.skills; groups = profileConfig.groups; From fa20db5738a76baa8f6db6614127ca251062b4e5 Mon Sep 17 00:00:00 2001 From: David Deal Date: Tue, 9 Jun 2026 21:30:04 -0700 Subject: [PATCH 04/11] fix(review): address PR #916 review feedback (round 2) Address review comments from coderabbitai[bot]: - charts/lfx-self-serve/templates/_helpers.tpl: validate mountPath *content*, not just type. The previous guard only enforced `kindIs "string"`, so an empty string ("" or whitespace) or a relative path slipped through Helm and only failed at kubectl apply time. New guard fails-fast with `mountPath %q must be a non-empty absolute path (must start with '/')` using `trim` + `hasPrefix "/"` (per coderabbitai[bot]). - charts/lfx-self-serve/templates/_helpers.tpl: validate ConfigMap data-key charset before rendering. The previous loop only checked the value type; an invalid key like "foo/bar" or "bad key.json" rendered cleanly but apiserver rejects them at apply. New guard enforces `^[A-Za-z0-9._-]+$` (the same set Kubernetes' validation.IsConfigMapKey accepts) before the value-type check (per coderabbitai[bot]). Verified the extended guard matrix with `helm template`: - new: empty mountPath, whitespace-only mountPath, relative mountPath all fail with the new content message; - new: data keys with spaces or "/" all fail with the new charset message; - new boundary: data key "a.b_c-d.json" (alphanumeric + .-_) renders cleanly; - all existing guards (non-map cfg, key >63 chars, missing mountPath, non-string value, empty data, oversized cmName) still fire as before; - happy path against the real values block from the GitOps companion PR still renders ConfigMap + Deployment cleanly. Resolves 2 review threads. Signed-off-by: David Deal Co-authored-by: Cursor --- charts/lfx-self-serve/templates/_helpers.tpl | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/charts/lfx-self-serve/templates/_helpers.tpl b/charts/lfx-self-serve/templates/_helpers.tpl index d29e9ddf2..7789abcc2 100644 --- a/charts/lfx-self-serve/templates/_helpers.tpl +++ b/charts/lfx-self-serve/templates/_helpers.tpl @@ -176,6 +176,9 @@ Args (dict): {{- 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 -}} @@ -183,6 +186,9 @@ Args (dict): {{- fail (printf "staticConfigMaps.%s.data must contain at least one file" $name) -}} {{- end -}} {{- range $key, $value := $cfg.data -}} +{{- 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 -}} From 08c2ab8de6a73b855c61a57eeb45a1a93fabb72c Mon Sep 17 00:00:00 2001 From: David Deal Date: Tue, 9 Jun 2026 21:37:01 -0700 Subject: [PATCH 05/11] fix(review): address PR #916 review feedback (round 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review comments from copilot-pull-request-reviewer[bot]: - charts/lfx-self-serve/templates/_helpers.tpl: new `lfx-self-serve.staticConfigMaps.rootValidate` define checks that `.Values.staticConfigMaps` itself is a map (or empty/nil) before any caller iterates with `range` or runs `toJson` for the checksum annotation. Previously, a typo like `staticConfigMaps: "foo"` would surface as an opaque type error from inside `range` or sprig functions instead of a clear fail() message (per copilot[bot]). - charts/lfx-self-serve/templates/_helpers.tpl: add a `kindIs "string" $key` guard inside the data range so non-string keys (theoretically reachable via non-YAML input paths) fail-fast with a clear message before reaching `regexMatch` (per copilot[bot]). - charts/lfx-self-serve/templates/configmap.yaml: include `rootValidate` at the top so a non-map root fails before the range. - charts/lfx-self-serve/templates/deployment.yaml: include `rootValidate` at the top so the checksum annotation, volumeMounts range, and volumes range all benefit from the same root-shape guard (per copilot[bot]). - apps/lfx-one/src/server/services/linkedin-ads.service.ts: drop the raw `LINKEDIN_AD_ACCOUNT_ID` value from the cross-tenant-protection throw at line 228. The error is logged via `logger.error(..., err)` and `SENSITIVE_FIELDS` doesn't redact account IDs, so the vendor identifier was leaking into log shipping. Now matches the redaction pattern applied to debug logs in round 1 — the env var name + a pointer to the linkedin-config ConfigMap is enough for an operator to debug without exposing the value (per copilot[bot]). Verified the extended guard matrix with `helm template`: - new: `staticConfigMaps: "oops"` (string) and `staticConfigMaps: [a, b]` (slice) both fail at render with `staticConfigMaps must be a map of -> { mountPath, data } (got string|slice)`; - YAML inputs with numeric/boolean data keys (e.g. `data: { 123: "..." }`) are auto-stringified by the YAML parser before reaching Helm, so the new `kindIs "string" $key` guard is unreachable in practice for YAML — kept as defense-in-depth for any future non-YAML loader path; - all round-1 + round-2 guards still fire (non-map cfg, key >63, DNS-1123 charset, missing/empty/relative mountPath, charset on data keys, non-string data values, empty data, oversized cmName); - happy path against the real values block from linuxfoundation/lfx-v2-argocd#938 still renders ConfigMap + Deployment cleanly. Service-side: `tsc --noEmit`, `eslint`, `prettier --check` all clean. Resolves 4 review threads. Signed-off-by: David Deal Co-authored-by: Cursor --- .../server/services/linkedin-ads.service.ts | 2 +- charts/lfx-self-serve/templates/_helpers.tpl | 20 +++++++++++++++++++ .../lfx-self-serve/templates/configmap.yaml | 1 + .../lfx-self-serve/templates/deployment.yaml | 1 + 4 files changed, 23 insertions(+), 1 deletion(-) 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 3a413c1dc..4b8b5bd74 100644 --- a/apps/lfx-one/src/server/services/linkedin-ads.service.ts +++ b/apps/lfx-one/src/server/services/linkedin-ads.service.ts @@ -226,7 +226,7 @@ function getOrgId(): string { return match.orgId; } throw new Error( - `LINKEDIN_AD_ACCOUNT_ID=${accountId} 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` + 'LINKEDIN_AD_ACCOUNT_ID is set to an account that 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.' ); } diff --git a/charts/lfx-self-serve/templates/_helpers.tpl b/charts/lfx-self-serve/templates/_helpers.tpl index 7789abcc2..a05023c32 100644 --- a/charts/lfx-self-serve/templates/_helpers.tpl +++ b/charts/lfx-self-serve/templates/_helpers.tpl @@ -150,6 +150,23 @@ ExternalSecret-specific annotations override global ones on key conflicts {{- end }} {{- end }} +{{/* +Validate the staticConfigMaps root value itself is a map (or empty/nil). +Catches bad-shape inputs like `staticConfigMaps: "foo"` or +`staticConfigMaps: [a,b]` 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. + +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 and .Values.staticConfigMaps (not (kindIs "map" .Values.staticConfigMaps)) -}} +{{- fail (printf "staticConfigMaps must be a map of -> { mountPath, data } (got %s)" (kindOf .Values.staticConfigMaps)) -}} +{{- 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 @@ -186,6 +203,9 @@ Args (dict): {{- 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 -}} diff --git a/charts/lfx-self-serve/templates/configmap.yaml b/charts/lfx-self-serve/templates/configmap.yaml index 0dd761da0..ff48051dc 100644 --- a/charts/lfx-self-serve/templates/configmap.yaml +++ b/charts/lfx-self-serve/templates/configmap.yaml @@ -1,6 +1,7 @@ # 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 }} diff --git a/charts/lfx-self-serve/templates/deployment.yaml b/charts/lfx-self-serve/templates/deployment.yaml index 6f367d723..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: From 8f8b29dc4bb4108a8454a11305d5b3b28ba17976 Mon Sep 17 00:00:00 2001 From: David Deal Date: Tue, 9 Jun 2026 22:18:17 -0700 Subject: [PATCH 06/11] fix(review): address PR #916 review feedback (round 4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review comment from copilot-pull-request-reviewer[bot]: - apps/lfx-one/src/server/services/linkedin-ads.service.ts: validateLinkedInConfig() previously coerced non-string `defaultAccountId` / `defaultOrgId` values to '' silently. A malformed JSON payload (e.g. `{ "defaultAccountId": 12345 }`) would pass validation, mark the runtime config as "successfully loaded", and only blow up later as a generic "No LinkedIn … configured" error from getAccountId/getOrgId — masking the real cause. Inconsistent with every other field in the validator, which throws on type mismatch. Now throws `LinkedIn config "defaultAccountId" must be a string when present, got ` (and same for defaultOrgId) when the key is present but not a string. Absent keys still default to '' (so env-var-only deployments continue to work). Caught at load time, wrapped by the existing try/catch in loadLinkedInConfig which logs `reason: 'malformed'` and falls back to EMPTY_LINKEDIN_CONFIG (per copilot[bot]). Service-side: `tsc --noEmit`, `eslint`, `prettier --check` all clean. Resolves 1 review thread. Signed-off-by: David Deal Co-authored-by: Cursor --- .../src/server/services/linkedin-ads.service.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) 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 4b8b5bd74..51005d3bf 100644 --- a/apps/lfx-one/src/server/services/linkedin-ads.service.ts +++ b/apps/lfx-one/src/server/services/linkedin-ads.service.ts @@ -65,8 +65,17 @@ function validateLinkedInConfig(parsed: unknown): LinkedInRuntimeConfig { } const p = parsed as Record; - const defaultAccountId = typeof p['defaultAccountId'] === 'string' ? p['defaultAccountId'] : ''; - const defaultOrgId = typeof p['defaultOrgId'] === 'string' ? p['defaultOrgId'] : ''; + 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 ?? ''; + + 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 ?? ''; const rawAccounts = p['accounts'] ?? []; if (!Array.isArray(rawAccounts)) { From 3a044e838efed7389161b16be6d00a55fb02197b Mon Sep 17 00:00:00 2001 From: David Deal Date: Fri, 12 Jun 2026 15:06:16 -0700 Subject: [PATCH 07/11] fix(campaigns): address PR #916 review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review comments from copilot-pull-request-reviewer: - linkedin-ads.service.ts: remove raw account ID from getAccountId() error message to prevent vendor ID from appearing in logs; add /^\d+$/ numeric validation for LINKEDIN_AD_ACCOUNT_ID env var (per copilot-pull-request-reviewer) - campaign.controller.ts: migrate getLinkedInAccounts() and getLinkedInMonitor() from removed LINKEDIN_ACCOUNTS constant to runtime getLinkedInConfig() (per copilot-pull-request-reviewer) - campaign.service.ts: replace removed LinkedInAccountOption type with LinkedInAccount for getLinkedInAccounts() return type (per copilot-pull-request-reviewer) - implementation-tab.component.ts: replace removed LinkedInAdAccount / LINKEDIN_AD_ACCOUNTS / LINKEDIN_DEFAULT_ACCOUNT_ID with runtime account loading via campaignService.getLinkedInAccounts() on ngOnInit; update template to call linkedInAccounts() signal and use orgId field (per copilot-pull-request-reviewer) - monitoring-tab.component.ts: replace LinkedInAccountOption with LinkedInAccount; update account key references from .key to .accountId (per copilot-pull-request-reviewer) - optimization-tab.component.ts: same LinkedInAccountOption → LinkedInAccount migration; update account key references (per copilot-pull-request-reviewer) Resolves 5 review threads. Signed-off-by: David Deal --- .../implementation-tab.component.html | 4 +- .../implementation-tab.component.ts | 39 ++++++++++++++----- .../monitoring-tab.component.html | 4 +- .../monitoring-tab.component.ts | 6 +-- .../optimization-tab.component.html | 4 +- .../optimization-tab.component.ts | 6 +-- .../app/shared/services/campaign.service.ts | 6 +-- .../server/controllers/campaign.controller.ts | 11 ++++-- .../server/services/linkedin-ads.service.ts | 5 ++- 9 files changed, 55 insertions(+), 30 deletions(-) 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 de129da8d..d180ea6b4 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 c20bc155f..be7653abf 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, @@ -40,7 +38,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); @@ -52,7 +50,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]; @@ -87,14 +84,17 @@ 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(''); // === Computed Signals === protected readonly showGoogleSection = computed(() => this.selectedPlatforms().includes('google-ads')); protected readonly showLinkedInSection = computed(() => this.selectedPlatforms().includes('linkedin-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(); @@ -144,6 +144,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); + }, + }); + } + // === Protected Methods === protected addHeadline(): void { (this.campaignForm.controls.headlines as FormArray).push( 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 11d637a36..6b05bb5cc 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 @@ -358,8 +358,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 dc8beeab9..6aaa5c3c5 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, } from '@lfx-one/shared/interfaces'; @@ -52,7 +52,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); @@ -104,7 +104,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 76da7afe9..e02ecf2cd 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 7df7efa93..41ec84fed 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, } from '@lfx-one/shared/interfaces'; @@ -73,7 +73,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); @@ -92,7 +92,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 b25904baa..ce1fb3548 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, SSEEvent, } from '@lfx-one/shared/interfaces'; @@ -69,8 +69,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/controllers/campaign.controller.ts b/apps/lfx-one/src/server/controllers/campaign.controller.ts index 6f70fca23..a22337115 100644 --- a/apps/lfx-one/src/server/controllers/campaign.controller.ts +++ b/apps/lfx-one/src/server/controllers/campaign.controller.ts @@ -11,9 +11,9 @@ import type { FlushableResponse, } from '@lfx-one/shared/interfaces'; -import { LINKEDIN_ACCOUNTS } from '../constants'; import { ServiceValidationError } from '../errors'; import { CampaignMetricsService, LinkedInMetricsService } from '../services/campaign-metrics.service'; +import { getLinkedInConfig } from '../services/linkedin-ads.service'; import { validateScrapeUrl } from '../helpers/url-validation'; import { CampaignProxyService } from '../services/campaign-proxy.service'; import { logger } from '../services/logger.service'; @@ -313,8 +313,10 @@ 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].sort((a) => (a.accountId === config.defaultAccountId ? -1 : 0)); + res.json(sorted); } public async getLinkedInMonitor(req: Request, res: Response, next: NextFunction): Promise { @@ -322,7 +324,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 6d4f2beb1..4a4b3dd00 100644 --- a/apps/lfx-one/src/server/services/linkedin-ads.service.ts +++ b/apps/lfx-one/src/server/services/linkedin-ads.service.ts @@ -207,13 +207,16 @@ 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: "${override}" — not in the runtime config. Check the linkedin-config ConfigMap.`); + 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; } 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; } From 43b9b2de26786918d39f0cef6d8f49260cfcc105 Mon Sep 17 00:00:00 2001 From: David Deal Date: Fri, 12 Jun 2026 15:15:36 -0700 Subject: [PATCH 08/11] fix(campaigns): address PR #916 review feedback Address review comments from coderabbitai[bot]: - campaign.controller.ts: replace one-argument sort comparator with two filter calls to guarantee default account lands at index 0; the previous `sort((a) => ...)` is not a valid comparator and produces unpredictable ordering (per coderabbitai[bot]) Resolves 1 review thread. Signed-off-by: David Deal --- apps/lfx-one/src/server/controllers/campaign.controller.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/lfx-one/src/server/controllers/campaign.controller.ts b/apps/lfx-one/src/server/controllers/campaign.controller.ts index a22337115..9569db83e 100644 --- a/apps/lfx-one/src/server/controllers/campaign.controller.ts +++ b/apps/lfx-one/src/server/controllers/campaign.controller.ts @@ -315,7 +315,10 @@ export class CampaignController { public getLinkedInAccounts(_req: Request, res: Response): void { const config = getLinkedInConfig(); // Return default account first so clients defaulting to accounts[0] honour the configured default. - const sorted = [...config.accounts].sort((a) => (a.accountId === config.defaultAccountId ? -1 : 0)); + const sorted = [ + ...config.accounts.filter((a) => a.accountId === config.defaultAccountId), + ...config.accounts.filter((a) => a.accountId !== config.defaultAccountId), + ]; res.json(sorted); } From 7abfc815c13cf136bfda2c155a3107c7db3364dc Mon Sep 17 00:00:00 2001 From: David Deal Date: Mon, 15 Jun 2026 15:27:38 -0500 Subject: [PATCH 09/11] fix(review): address PR #916 review feedback (round 5) Address review comments from copilot-pull-request-reviewer[bot]: - apps/lfx-one/src/server/services/linkedin-ads.service.ts: tighten isLinkedInAccount() to require digit-only accountId/orgId and an enum-or-undefined status, mirroring the LINKEDIN_AD_ACCOUNT_ID env-var check and the LinkedInAccount.status interface contract (per copilot-pull-request-reviewer[bot]) - apps/lfx-one/src/server/services/linkedin-ads.service.ts: validate defaultAccountId/defaultOrgId as digit-only strings when non-empty in validateLinkedInConfig(); empty stays the documented "use env var instead" opt-out (per copilot-pull-request-reviewer[bot]) - apps/lfx-one/src/server/services/linkedin-ads.service.ts: add describeAccountForLog() helper and swap getLinkedInAnalytics() log metadata from { accountId } to { accountLabel } in startOperation/error/warning paths so raw vendor IDs no longer leak into logs on the analytics path (per copilot-pull-request-reviewer[bot]) - charts/lfx-self-serve/templates/_helpers.tpl: switch staticConfigMaps.rootValidate from a truthiness gate to hasKey + kindIs "invalid" so empty-but-mistyped values (staticConfigMaps: "", staticConfigMaps: []) fail the type check instead of being silently ignored (per copilot-pull-request-reviewer[bot]) Issue: LFXV2-2023 Resolves 4 review threads. Signed-off-by: David Deal Co-authored-by: Cursor --- .../server/services/linkedin-ads.service.ts | 69 +++++++++++++++++-- charts/lfx-self-serve/templates/_helpers.tpl | 24 ++++--- 2 files changed, 80 insertions(+), 13 deletions(-) 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 4a4b3dd00..3eff1817c 100644 --- a/apps/lfx-one/src/server/services/linkedin-ads.service.ts +++ b/apps/lfx-one/src/server/services/linkedin-ads.service.ts @@ -41,12 +41,32 @@ const EMPTY_LINKEDIN_CONFIG: LinkedInRuntimeConfig = { 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; - return typeof v['accountId'] === 'string' && typeof v['label'] === 'string' && typeof v['orgId'] === 'string'; + 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 { @@ -75,19 +95,29 @@ function validateLinkedInConfig(parsed: unknown): LinkedInRuntimeConfig { 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)'); + } const rawAccounts = p['accounts'] ?? []; if (!Array.isArray(rawAccounts)) { throw new TypeError(`LinkedIn config "accounts" must be an array, got ${typeof rawAccounts}`); } if (!rawAccounts.every(isLinkedInAccount)) { - throw new TypeError('LinkedIn config "accounts[]" entries must each have string accountId, label, and orgId'); + 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'] ?? []; @@ -266,6 +296,30 @@ 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 // --------------------------------------------------------------------------- @@ -751,7 +805,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; @@ -784,7 +843,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[] }; @@ -826,7 +885,7 @@ export async function getLinkedInAnalytics(req: Request | undefined, accountId: const analyticsMap = new Map(); if (!analyticsResp.ok) { - logger.warning(req, 'linkedin_analytics', `LinkedIn adAnalytics returned ${analyticsResp.status} — campaign metrics will show zero`, { accountId }); + logger.warning(req, 'linkedin_analytics', `LinkedIn adAnalytics returned ${analyticsResp.status} — campaign metrics will show zero`, { accountLabel }); } if (analyticsResp.ok) { const analyticsData = (await analyticsResp.json()) as { diff --git a/charts/lfx-self-serve/templates/_helpers.tpl b/charts/lfx-self-serve/templates/_helpers.tpl index a05023c32..ab0bbe1f5 100644 --- a/charts/lfx-self-serve/templates/_helpers.tpl +++ b/charts/lfx-self-serve/templates/_helpers.tpl @@ -151,19 +151,27 @@ ExternalSecret-specific annotations override global ones on key conflicts {{- end }} {{/* -Validate the staticConfigMaps root value itself is a map (or empty/nil). -Catches bad-shape inputs like `staticConfigMaps: "foo"` or -`staticConfigMaps: [a,b]` 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. +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 and .Values.staticConfigMaps (not (kindIs "map" .Values.staticConfigMaps)) -}} -{{- fail (printf "staticConfigMaps must be a map of -> { mountPath, data } (got %s)" (kindOf .Values.staticConfigMaps)) -}} +{{- 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 }} From 8915d32294deb9fbff80a6c169d3f35caba7ddbb Mon Sep 17 00:00:00 2001 From: David Deal Date: Tue, 16 Jun 2026 12:22:27 -0500 Subject: [PATCH 10/11] fix(review): address PR #916 review feedback (round 6) Address review comments from copilot-pull-request-reviewer[bot]: - apps/lfx-one/src/server/services/linkedin-ads.service.ts: validateLinkedInPrerequisites() previously hard-threw for `profile === 'custom'`, but buildTargetingCriteria() at the same file's :622-638 silently aliases 'custom' -> 'cloud-native' (pulls cloud-native skills/groups as the fallback). The brief generation flow (planning-tab.component.ts:504) can legitimately set targetingProfile to 'custom' when the AI doesn't recommend a named profile, which would die at preflight even though the runtime path supports it. Aligned preflight with the actual runtime behavior: when profile is 'custom', validate that the 'cloud-native' fallback entry exists in runtime config; when named, validate that exact entry. Error message disambiguates which entry is missing for the 'custom' case (per copilot[bot]). - apps/lfx-one/src/server/services/linkedin-ads.service.ts: getOrgId() returned LINKEDIN_ORG_ID env value without validation, while LINKEDIN_AD_ACCOUNT_ID (line 247), defaultOrgId, and every accounts[].orgId entry in the runtime config are all digit-only validated (round 5). A typo or full-URN value (e.g. "urn:li:organization:208777") slipped through and got interpolated into `urn:li:organization:${envOrgId}`, producing an invalid URN that LinkedIn rejects mid-flow with an opaque API error instead of a clear config-validation error at startup. Added the same /^\d+$/ guard pattern (per copilot[bot]). Service-side validation: tsc --noEmit, eslint, prettier --check all clean. Resolves 2 review threads. Issue: LFXV2-2023 Signed-off-by: David Deal Co-authored-by: Cursor --- .../server/services/linkedin-ads.service.ts | 27 +++++++++++++++---- 1 file changed, 22 insertions(+), 5 deletions(-) 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 bbae7246f..546562704 100644 --- a/apps/lfx-one/src/server/services/linkedin-ads.service.ts +++ b/apps/lfx-one/src/server/services/linkedin-ads.service.ts @@ -261,6 +261,16 @@ function getAccountId(override?: string): string { 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; } @@ -696,17 +706,24 @@ export function buildLinkedInUtmUrl(baseUrl: string, hsToken: string | undefined * otherwise only run inside `createCampaign`. */ function validateLinkedInPrerequisites(profile: LinkedInTargetingProfile, adAccountId?: string): void { - if (profile === 'custom') { - throw new Error('Custom targeting profile is not yet supported — use a named profile (cloud-native, mcp)'); - } // 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(); - if (!config.targetingProfiles.find((p) => p.id === profile)) { + + // '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 "${profile}" not found in runtime config — refusing to start campaign creation to avoid partial LinkedIn artifacts. Check the linkedin-config ConfigMap.` + `LinkedIn targeting profile ${missing} not found in runtime config — refusing to start campaign creation to avoid partial LinkedIn artifacts. Check the linkedin-config ConfigMap.` ); } } From 9e08d9a63c20dce361b81da9737cd6582c3f7ad7 Mon Sep 17 00:00:00 2001 From: David Deal Date: Tue, 16 Jun 2026 12:43:09 -0500 Subject: [PATCH 11/11] fix(review): address PR #916 review feedback (round 7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review comments from copilot-pull-request-reviewer[bot]: - packages/shared/src/interfaces/campaign.interface.ts: update the LinkedInAccount doc comment. The comment claimed the interface is "not used in the client bundle", but this PR migrated 7 client files to import it as the response type for GET /api/campaigns/linkedin/accounts (campaign.service.ts, implementation-tab, monitoring-tab, optimization-tab — both .ts and templates). The VALUES (account/org IDs) are still server-side runtime-loaded; only the type shape is shared with the client. Comment rewritten to reflect the values-vs-type split accurately (per copilot[bot]). - apps/lfx-one/src/server/services/linkedin-ads.service.ts: validateLinkedInConfig() previously used `p['accounts'] ?? []` for accounts, employerExclusions, and targetingProfiles. The ?? fallback silently coerces an explicit `null` to `[]`, which lets a malformed ConfigMap like `{"accounts": null, ...}` load as "no accounts configured" instead of surfacing as malformed at load time. Inconsistent with the round-4 pattern for defaultAccountId / defaultOrgId, which explicitly check `!== undefined && typeof !== 'string'` so `null` fails the type check. Now mirrors that pattern: absent (`undefined`) defaults to []; explicit `null` or other non-array values throw TypeError with the type surfaced in the message (special-cased so the error reads `got null` instead of the misleading `got object`). Applied identically to accounts, employerExclusions, and targetingProfiles. Renamed post-default vars to *List to make the "guaranteed array" distinction explicit at the call sites (per copilot[bot], 3 threads). Service-side validation: tsc --noEmit, eslint, prettier --check all clean. Resolves 4 review threads. Issue: LFXV2-2023 Signed-off-by: David Deal Co-authored-by: Cursor --- .../server/services/linkedin-ads.service.ts | 41 ++++++++++++------- .../src/interfaces/campaign.interface.ts | 8 +++- 2 files changed, 33 insertions(+), 16 deletions(-) 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 546562704..c698e47a6 100644 --- a/apps/lfx-one/src/server/services/linkedin-ads.service.ts +++ b/apps/lfx-one/src/server/services/linkedin-ads.service.ts @@ -110,38 +110,49 @@ function validateLinkedInConfig(parsed: unknown): LinkedInRuntimeConfig { throw new TypeError('LinkedIn config "defaultOrgId" must be a digit-only string when non-empty (LinkedIn organization IDs are numeric)'); } - const rawAccounts = p['accounts'] ?? []; - if (!Array.isArray(rawAccounts)) { - throw new TypeError(`LinkedIn config "accounts" must be an array, got ${typeof rawAccounts}`); + // 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}`); } - if (!rawAccounts.every(isLinkedInAccount)) { + 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 (!Array.isArray(rawExclusions)) { - throw new TypeError(`LinkedIn config "employerExclusions" must be an array, got ${typeof rawExclusions}`); + 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}`); } - if (!rawExclusions.every((s) => typeof s === 'string')) { + 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 (!Array.isArray(rawProfiles)) { - throw new TypeError(`LinkedIn config "targetingProfiles" must be an array, got ${typeof rawProfiles}`); + 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}`); } - if (!rawProfiles.every(isLinkedInTargetingProfile)) { + 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: rawAccounts as readonly LinkedInAccount[], - employerExclusions: rawExclusions as readonly string[], - targetingProfiles: rawProfiles as readonly LinkedInTargetingProfileConfig[], + accounts: accountsList as readonly LinkedInAccount[], + employerExclusions: exclusionsList as readonly string[], + targetingProfiles: profilesList as readonly LinkedInTargetingProfileConfig[], }; } diff --git a/packages/shared/src/interfaces/campaign.interface.ts b/packages/shared/src/interfaces/campaign.interface.ts index e06d171bc..7976bae6b 100644 --- a/packages/shared/src/interfaces/campaign.interface.ts +++ b/packages/shared/src/interfaces/campaign.interface.ts @@ -164,7 +164,13 @@ export interface LinkedInBriefCopy { /** * One ad account / org pairing in the runtime LinkedIn config. - * Loaded server-side from the mounted ConfigMap; not used in the client bundle. + * + * 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. */