Skip to content
Open
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/openapi/llmo-api.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -808,6 +808,12 @@ llmo-onboard:
format: uuid
description: The created or existing site ID
example: "987fcdeb-51a2-43d1-9f12-345678901234"
detectedCdn:
type:
- string
- 'null'
description: CDN provider detected via DNS during onboarding
example: "aem-cs-fastly"
status:
type: string
enum: ["initiated", "in_progress", "completed", "failed"]
Expand Down
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@
"@adobe/spacecat-shared-athena-client": "1.9.9",
"@adobe/spacecat-shared-brand-client": "1.1.39",
"@adobe/spacecat-shared-content-client": "1.8.20",
"@adobe/spacecat-shared-data-access": "^3.31.0",
"@adobe/spacecat-shared-data-access": "https://gist.github.com/AddyKen-ghost/971fd9594bd50443363e6a93d3842145/raw/adobe-spacecat-shared-data-access-3.36.1.tgz",
"@adobe/spacecat-shared-data-access-v2": "npm:@adobe/spacecat-shared-data-access@2.109.0",
"@adobe/spacecat-shared-drs-client": "1.3.1",
"@adobe/spacecat-shared-gpt-client": "1.6.20",
Expand Down
12 changes: 12 additions & 0 deletions src/controllers/llmo/llmo-onboarding.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
LLMO_BRANDALF_FLAG,
} from '../../support/llmo-onboarding-mode.js';
import { upsertFeatureFlag } from '../../support/feature-flags-storage.js';
import { detectCdnForDomain } from '../../support/cdn-detection.js';

// LLMO Constants
const LLMO_PRODUCT_CODE = EntitlementModel.PRODUCT_CODES.LLMO;
Expand Down Expand Up @@ -1210,6 +1211,7 @@ export async function performLlmoOnboarding(params, context, say = () => {}) {
const dataFolder = generateDataFolder(baseURL, env.ENV);

let site;
let detectedCdn = null;
try {
log.info(`Starting LLMO onboarding for IMS org ${imsOrgId}, baseURL ${baseURL}, brand ${brandName}`);

Expand Down Expand Up @@ -1274,6 +1276,15 @@ export async function performLlmoOnboarding(params, context, say = () => {}) {
log.info(`Site ${site.getId()} already has overrideBaseURL: ${currentFetchConfig.overrideBaseURL}, skipping auto-detection`);
}

detectedCdn = await detectCdnForDomain(new URL(baseURL).hostname);
if (detectedCdn) {
siteConfig.updateLlmoDetectedCdn?.(detectedCdn);
log.info(`Detected CDN ${detectedCdn} for site ${site.getId()}`);
say(`:mag: Detected CDN: ${detectedCdn}`);
} else {
log.info(`CDN detection inconclusive for site ${site.getId()}`);
}

// update the site config object
site.setConfig(Config.toDynamoItem(siteConfig));
await site.save();
Expand Down Expand Up @@ -1369,6 +1380,7 @@ export async function performLlmoOnboarding(params, context, say = () => {}) {
organizationId: organization.getId(),
baseURL,
dataFolder,
detectedCdn,
message: 'LLMO onboarding completed successfully',
};
} catch (error) {
Expand Down
1 change: 1 addition & 0 deletions src/controllers/llmo/llmo.js
Original file line number Diff line number Diff line change
Expand Up @@ -951,6 +951,7 @@ function LlmoController(ctx) {
dataFolder: result.dataFolder,
organizationId: result.organizationId,
siteId: result.siteId,
detectedCdn: result.detectedCdn,
status: 'completed',
createdAt: new Date().toISOString(),
brandProfileExecutionName,
Expand Down
3 changes: 2 additions & 1 deletion src/dto/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,12 +40,13 @@ const toListJSON = (config) => {
if (isNonEmptyObject(json.llmo)) {
result.llmo = {};
const {
dataFolder, brand, tags, customerIntent,
dataFolder, brand, tags, customerIntent, detectedCdn,
} = json.llmo;
if (dataFolder) result.llmo.dataFolder = dataFolder;
if (brand) result.llmo.brand = brand;
if (tags) result.llmo.tags = tags;
if (customerIntent) result.llmo.customerIntent = customerIntent;
if (detectedCdn) result.llmo.detectedCdn = detectedCdn;
}
if (isNonEmptyObject(json.edgeOptimizeConfig)) {
result.edgeOptimizeConfig = json.edgeOptimizeConfig;
Expand Down
87 changes: 87 additions & 0 deletions src/support/cdn-detection.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
/*
* Copyright 2026 Adobe. All rights reserved.
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. You may obtain a copy
* of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
* OF ANY KIND, either express or implied. See the License for the specific language
* governing permissions and limitations under the License.
*/

import { promises as dns } from 'dns';

const AEM_CS_FASTLY_CNAME = 'cdn.adobeaemcloud.com';
const AEM_CS_FASTLY_IPS = new Set([
'146.75.123.10',
'151.101.195.10',
'151.101.67.10',
'151.101.3.10',
]);

// ENODATA = record type doesn't exist; ENOTFOUND = domain doesn't exist (NXDOMAIN).
// Both are authoritative answers — safe to treat as "no records" and continue.
const DNS_NO_RECORD_CODES = new Set(['ENODATA', 'ENOTFOUND']);

function catchDnsLookup(err) {
if (DNS_NO_RECORD_CODES.has(err.code)) return [];
return null;
}

/**
* Checks whether a single host resolves to AEM CS Fastly via CNAME or A records.
*
* Returns:
* - 'aem-cs-fastly' when DNS matches known Fastly CNAME / IPs
* - 'other' when DNS resolved but nothing matched
* - null when a DNS lookup failed (inconclusive)
*
* @param {string} host - Hostname to check
* @returns {Promise<string|null>}
*/
async function checkHost(host) {
const cnames = await dns.resolveCname(host).catch(catchDnsLookup);
if (cnames === null) return null;
if (cnames.some((c) => c.includes(AEM_CS_FASTLY_CNAME))) {
return 'aem-cs-fastly';
}

const ips = await dns.resolve4(host).catch(catchDnsLookup);
if (ips === null) return null;
if (ips.some((ip) => AEM_CS_FASTLY_IPS.has(ip))) {
return 'aem-cs-fastly';
}

return 'other';
}

/**
* Detects the CDN for a domain by probing www.{domain} then {domain}.
*
* Returns:
* - 'aem-cs-fastly' — DNS matched known AEM CS Fastly signatures
* - 'other' — DNS resolved for both hosts but neither matched
* - null — at least one DNS lookup failed; result is inconclusive
*
* Never throws.
*
* @param {string} domain - bare domain (e.g. 'example.com')
* @returns {Promise<string|null>}
*/
export async function detectCdnForDomain(domain) {
try {
const wwwResult = await checkHost(`www.${domain}`);
if (wwwResult === 'aem-cs-fastly') return 'aem-cs-fastly';

const bareResult = await checkHost(domain);
if (bareResult === 'aem-cs-fastly') return 'aem-cs-fastly';

if (wwwResult === null || bareResult === null) return null;

return 'other';
/* c8 ignore next 3 */
} catch {
return null;
}
}
187 changes: 187 additions & 0 deletions test/controllers/llmo/llmo-onboarding.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,10 @@ describe('LLMO Onboarding Functions', () => {

deps['../../../src/support/customer-config-v2-storage.js'] = effectiveCustomerConfigV2Storage;

deps['../../../src/support/cdn-detection.js'] = {
detectCdnForDomain: options.mockDetectCdnForDomain || sinon.stub().resolves(null),
};

return deps;
};

Expand Down Expand Up @@ -1451,6 +1455,189 @@ describe('LLMO Onboarding Functions', () => {
expect(mockLog.info).to.have.been.calledWith('Created site site123 for https://example.com using LLMO onboarding mode v2');
});

it('should include detectedCdn in result when CDN is detected', async () => {
const mockOrganization = {
getId: sinon.stub().returns('org123'),
getImsOrgId: sinon.stub().returns('ABC123@AdobeOrg'),
};

const mockSiteConfig = {
updateLlmoBrand: sinon.stub(),
updateLlmoDataFolder: sinon.stub(),
updateLlmoDetectedCdn: sinon.stub(),
getImports: sinon.stub().returns([]),
enableImport: sinon.stub(),
getFetchConfig: sinon.stub().returns({}),
updateFetchConfig: sinon.stub(),
getBrandProfile: sinon.stub().returns({ main_profile: { target_audience: 'Tech-savvy professionals' } }),
};

const mockSite = {
getId: sinon.stub().returns('site123'),
getConfig: sinon.stub().returns(mockSiteConfig),
setConfig: sinon.stub(),
save: sinon.stub().resolves(),
};

const mockConfiguration = {
enableHandlerForSite: sinon.stub(),
save: sinon.stub().resolves(),
getQueues: sinon.stub().returns({ audits: 'audit-queue' }),
};

const maybeSingle = sinon.stub().resolves({ data: { flag_value: true }, error: null });
const eqFlag = sinon.stub().returns({ maybeSingle });
const eqProduct = sinon.stub().returns({ eq: eqFlag });
const eqOrg = sinon.stub().returns({ eq: eqProduct });
const select = sinon.stub().returns({ eq: eqOrg });
const upsertSingle = sinon.stub().resolves({ data: { flag_value: true }, error: null });
const upsertSelect = sinon.stub().returns({ single: upsertSingle });
const upsertStub = sinon.stub().returns({ select: upsertSelect });
mockDataAccess.services.postgrestClient.from.withArgs('feature_flags').returns({ select, upsert: upsertStub });

mockDataAccess.Organization.findByImsOrgId.resolves(mockOrganization);
mockDataAccess.Site.findByBaseURL.resolves(null);
mockDataAccess.Site.create.resolves(mockSite);
mockDataAccess.Configuration.findLatest.resolves(mockConfiguration);

const mockConfig = createMockConfig();
const mockTierClient = createMockTierClient();
const mockTracingFetch = createMockTracingFetch();
originalSetTimeout = mockSetTimeoutImmediate();
const mockComposeBaseURL = createMockComposeBaseURL();
const { mockClient: sharePointClient } = createMockSharePointClient(
sinon,
{ folderExists: false },
);
const mockOctokit = createMockOctokit();
const mockDrsClient = createMockDrsClient();
const mockCustomerConfigV2Storage = createMockCustomerConfigV2Storage();
const mockDetectCdnForDomain = sinon.stub().resolves('aem-cs-fastly');

const { performLlmoOnboarding: performLlmoOnboardingWithMocks } = await esmock(
'../../../src/controllers/llmo/llmo-onboarding.js',
createCommonEsmockDependencies({
mockTierClient,
mockTracingFetch,
mockConfig,
mockComposeBaseURL,
mockSharePointClient: sharePointClient,
mockOctokit,
mockDrsClient,
mockCustomerConfigV2Storage,
mockDetectCdnForDomain,
}),
);

const context = {
dataAccess: mockDataAccess,
log: mockLog,
env: mockEnv,
sqs: { sendMessage: sinon.stub().resolves() },
};

const result = await performLlmoOnboardingWithMocks({
domain: 'example.com',
brandName: 'Test Brand',
imsOrgId: 'ABC123@AdobeOrg',
}, context);

expect(result.detectedCdn).to.equal('aem-cs-fastly');
expect(mockSiteConfig.updateLlmoDetectedCdn).to.have.been.calledWith('aem-cs-fastly');
expect(mockDetectCdnForDomain).to.have.been.calledWith('example.com');
});

it('should store detectedCdn as other when CDN detection resolves but does not match', async () => {
const mockOrganization = {
getId: sinon.stub().returns('org123'),
getImsOrgId: sinon.stub().returns('ABC123@AdobeOrg'),
};

const mockSiteConfig = {
updateLlmoBrand: sinon.stub(),
updateLlmoDataFolder: sinon.stub(),
updateLlmoDetectedCdn: sinon.stub(),
getImports: sinon.stub().returns([]),
enableImport: sinon.stub(),
getFetchConfig: sinon.stub().returns({}),
updateFetchConfig: sinon.stub(),
getBrandProfile: sinon.stub().returns({ main_profile: { target_audience: 'Tech-savvy professionals' } }),
};

const mockSite = {
getId: sinon.stub().returns('site123'),
getConfig: sinon.stub().returns(mockSiteConfig),
setConfig: sinon.stub(),
save: sinon.stub().resolves(),
};

const mockConfiguration = {
enableHandlerForSite: sinon.stub(),
save: sinon.stub().resolves(),
getQueues: sinon.stub().returns({ audits: 'audit-queue' }),
};

const maybeSingle = sinon.stub().resolves({ data: { flag_value: true }, error: null });
const eqFlag = sinon.stub().returns({ maybeSingle });
const eqProduct = sinon.stub().returns({ eq: eqFlag });
const eqOrg = sinon.stub().returns({ eq: eqProduct });
const select = sinon.stub().returns({ eq: eqOrg });
const upsertSingle = sinon.stub().resolves({ data: { flag_value: true }, error: null });
const upsertSelect = sinon.stub().returns({ single: upsertSingle });
const upsertStub = sinon.stub().returns({ select: upsertSelect });
mockDataAccess.services.postgrestClient.from.withArgs('feature_flags').returns({ select, upsert: upsertStub });

mockDataAccess.Organization.findByImsOrgId.resolves(mockOrganization);
mockDataAccess.Site.findByBaseURL.resolves(null);
mockDataAccess.Site.create.resolves(mockSite);
mockDataAccess.Configuration.findLatest.resolves(mockConfiguration);

const mockConfig = createMockConfig();
const mockTierClient = createMockTierClient();
const mockTracingFetch = createMockTracingFetch();
originalSetTimeout = mockSetTimeoutImmediate();
const mockComposeBaseURL = createMockComposeBaseURL();
const { mockClient: sharePointClient } = createMockSharePointClient(
sinon,
{ folderExists: false },
);
const mockOctokit = createMockOctokit();
const mockDrsClient = createMockDrsClient();
const mockCustomerConfigV2Storage = createMockCustomerConfigV2Storage();
const mockDetectCdnForDomain = sinon.stub().resolves('other');

const { performLlmoOnboarding: performLlmoOnboardingWithMocks } = await esmock(
'../../../src/controllers/llmo/llmo-onboarding.js',
createCommonEsmockDependencies({
mockTierClient,
mockTracingFetch,
mockConfig,
mockComposeBaseURL,
mockSharePointClient: sharePointClient,
mockOctokit,
mockDrsClient,
mockCustomerConfigV2Storage,
mockDetectCdnForDomain,
}),
);

const context = {
dataAccess: mockDataAccess,
log: mockLog,
env: mockEnv,
sqs: { sendMessage: sinon.stub().resolves() },
};

const result = await performLlmoOnboardingWithMocks({
domain: 'example.com',
brandName: 'Test Brand',
imsOrgId: 'ABC123@AdobeOrg',
}, context);

expect(result.detectedCdn).to.equal('other');
expect(mockSiteConfig.updateLlmoDetectedCdn).to.have.been.calledWith('other');
});

it('should skip v2 initialization and Brandalf in v1 onboarding mode', async () => {
const mockOrganization = {
getId: sinon.stub().returns('org123'),
Expand Down
Loading
Loading