Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
2 changes: 2 additions & 0 deletions docs/openapi/api.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -464,6 +464,8 @@ paths:
$ref: './llmo-api.yaml#/llmo-brand-claims'
/sites/{siteId}/llmo/edge-optimize-config:
$ref: './llmo-api.yaml#/site-llmo-edge-optimize-config'
/sites/{siteId}/llmo/edge-optimize-config/stage:
$ref: './llmo-api.yaml#/site-llmo-edge-optimize-config-stage'
/sites/{siteId}/llmo/edge-optimize-status:
$ref: './llmo-api.yaml#/llmo-edge-optimize-status'
/sites/{siteId}/llmo/edge-optimize-routing:
Expand Down
52 changes: 52 additions & 0 deletions docs/openapi/llmo-api.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1513,6 +1513,58 @@ site-llmo-edge-optimize-config:
security:
- ims_key: [ ]

site-llmo-edge-optimize-config-stage:
parameters:
- $ref: './parameters.yaml#/siteId'
post:
operationId: createOrUpdateStageEdgeConfig
summary: Add staging domains for edge optimize (stage environment support)
description: |
Adds one or more staging domains for the specified LLMO prod site. For each domain:

- Validates that the apex of the staging domain matches the apex of the prod site base URL.
- Creates or finds a stage site in Spacecat in the same organization.
- Creates edge optimize metaconfig for the stage site with prerender enabled for the whole domain.
- Persists the staging domain to site ID mapping in the prod site's edgeOptimizeConfig.

Returns the complete metaconfig for each stage site in an array (stageConfigs). Each item includes domain, siteId, and all metaconfig properties (apiKeys, enabled, prerender, etc.).

The staging domains list can be retrieved via GET /sites/{siteId} under config.edgeOptimizeConfig.stagingDomains.

**Note:** This endpoint requires LLMO administrator access for the site.
tags:
- llmo
requestBody:
required: true
content:
application/json:
schema:
$ref: './schemas.yaml#/StageDomainsRequest'
responses:
'200':
description: Staging domains added successfully. Returns an array of stage configs; each element is the staging domain plus full metaconfig (siteId, apiKeys, enabled, enhancements, patches, prerender) for that stage site.
content:
application/json:
schema:
$ref: './schemas.yaml#/StageConfigsResponse'
'400':
description: Invalid request (e.g. empty stagingDomains, apex mismatch)
$ref: './responses.yaml#/400'
'401':
$ref: './responses.yaml#/401'
'403':
$ref: './responses.yaml#/403'
'404':
$ref: './responses.yaml#/404'
'429':
$ref: './responses.yaml#/429'
'500':
$ref: './responses.yaml#/500'
'503':
$ref: './responses.yaml#/503'
security:
- ims_key: [ ]

site-llmo-edge-optimize-routing:
parameters:
- $ref: './parameters.yaml#/siteId'
Expand Down
76 changes: 76 additions & 0 deletions docs/openapi/schemas.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,38 @@ Config:
description: Optional. The Brand Profile determined during on-boarding.
type: object
additionalProperties: true
edgeOptimizeConfig:
description: Optional. The edge optimize configuration for the site.
$ref: '#/EdgeOptimizeConfig'
EdgeOptimizeConfig:
type: object
description: Edge optimize configuration for a site
properties:
enabled:
description: Whether edge optimize is enabled for the site
type: boolean
opted:
description: Timestamp (milliseconds since epoch) when the site opted into edge optimize
type: number
example: 1772531669121
stagingDomains:
description: Staging domains associated with this site for edge optimize
type: array
items:
type: object
required:
- domain
- id
properties:
domain:
type: string
description: Staging domain hostname
example: 'staging.example.com'
id:
type: string
format: uuid
description: Spacecat site ID for the stage site
additionalProperties: true
ContentAiConfig:
type: object
properties:
Expand Down Expand Up @@ -6129,3 +6161,47 @@ SentimentConfig:
items:
$ref: '#/SentimentGuideline'
description: Guidelines (optionally filtered by audit type)

StageDomainsRequest:
type: object
description: Request body for adding staging domains to a prod LLMO site
required:
- stagingDomains
properties:
stagingDomains:
type: array
items:
type: string
description: Staging domain (hostname, e.g. staging.lovesac.com)
minItems: 1
example: ['staging.lovesac.com', 'stage1.lovesac.com']

StageConfigItem:
type: object
description: Full edge optimize metaconfig for one stage site (domain plus metaconfig fields)
required:
- domain
- siteId
properties:
domain:
type: string
description: Staging domain (e.g. staging.lovesac.com)
example: 'staging.lovesac.com'
siteId:
type: string
format: uuid
description: Spacecat site ID for the stage site
apiKeys:
type: array
items:
type: string
description: API keys for the stage site
tokowakaEnabled:
type: boolean
additionalProperties: true

StageConfigsResponse:
type: array
description: Response is the array of stage configs (one per staging domain). Each item is domain plus full edge optimize metaconfig for that stage site.
items:
$ref: '#/StageConfigItem'
124 changes: 124 additions & 0 deletions src/controllers/llmo/llmo.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
} from '@adobe/spacecat-shared-utils';
import { Config } from '@adobe/spacecat-shared-data-access/src/models/site/config.js';
import crypto from 'crypto';
import { getDomain } from 'tldts';
import { Entitlement as EntitlementModel } from '@adobe/spacecat-shared-data-access';
import TokowakaClient, { calculateForwardedHost } from '@adobe/spacecat-shared-tokowaka-client';
import AccessControlUtil from '../../support/access-control-util.js';
Expand Down Expand Up @@ -1518,6 +1519,128 @@ function LlmoController(ctx) {
}
};

/**
* Check if all URLs in urlList have the same base domain as prodBaseURL.
* @param {string[]} urlList the list of URLs/domains to check.
* @param {string} prodBaseURL the production base URL to match against.
* @returns {boolean} true if all URLs share the same domain as prodBaseURL
*/
function areDomainsSameAsBase(urlList, prodBaseURL) {
const prodDomain = getDomain(prodBaseURL);
return urlList.every((stageBaseURL) => getDomain(stageBaseURL) === prodDomain);
}

/**
* POST /sites/{siteId}/llmo/edge-optimize-config/stage
* Adds staging domains for edge optimize (stage environment support).
* Creates or finds stage sites in Spacecat (same org), creates Tokowaka metaconfig per stage site
* with prerender for whole domain, and persists stagingDomains on prod site's edgeOptimizeConfig.
* Returns the complete S3 metaconfig for each stage site in an array.
* @param {object} context - Request context
* @returns {Promise<Response>} 200 with stageConfigs array (full S3 metaconfig per stage)
*/
const createOrUpdateStageEdgeConfig = async (context) => {
const { log, dataAccess } = context;
const { siteId } = context.params;
const { authInfo: { profile } } = context.attributes;
const { Site } = dataAccess;
const { stagingDomains: rawStagingDomains } = context.data || {};

if (!accessControlUtil.isLLMOAdministrator()) {
return forbidden('Only LLMO administrators can add staging domains');
}

if (!Array.isArray(rawStagingDomains) || rawStagingDomains.length === 0) {
return badRequest('stagingDomains must be a non-empty array');
}

const stagingDomains = rawStagingDomains
.map((d) => (typeof d === 'string' ? d.trim() : ''))
.filter((d) => hasText(d));
if (stagingDomains.length === 0) {
return badRequest('stagingDomains must contain at least one non-empty domain string');
}

try {
const site = await Site.findById(siteId);
if (!site) {
return notFound('Site not found');
}
if (!await accessControlUtil.hasAccess(site)) {
return forbidden('User does not have access to this site');
}

if (!areDomainsSameAsBase(stagingDomains, site.getBaseURL())) {
return badRequest('Staging domains must belong to the same base domain as the production site');
}

const tokowakaClient = TokowakaClient.createFrom(context);
const lastModifiedBy = profile?.email || 'tokowaka-stage-edge-optimize-config';
const organizationId = site.getOrganizationId();
const newEntries = [];
const stageConfigs = [];

/* eslint-disable no-await-in-loop */
for (const domain of stagingDomains) {
const stageBaseURL = composeBaseURL(domain);
let stageSite = await Site.findByBaseURL(stageBaseURL);
if (!stageSite) {
stageSite = await Site.create({
baseURL: stageBaseURL,
organizationId,
});
}

let metaconfig = await tokowakaClient.fetchMetaconfig(stageBaseURL);
if (!metaconfig || !Array.isArray(metaconfig?.apiKeys) || metaconfig.apiKeys.length === 0) {
metaconfig = await tokowakaClient.createMetaconfig(
stageBaseURL,
stageSite.getId(),
{
tokowakaEnabled: true,
},
{ lastModifiedBy, isStageDomain: true },
);
} else {
await tokowakaClient.updateMetaconfig(
stageBaseURL,
stageSite.getId(),
{},
{ lastModifiedBy, isStageDomain: true },
);
metaconfig = await tokowakaClient.fetchMetaconfig(stageBaseURL);
}

newEntries.push({ domain, id: stageSite.getId() });
stageConfigs.push({
domain,
...metaconfig,
});
}
/* eslint-enable no-await-in-loop */

const currentConfig = site.getConfig();
const existingEdgeConfig = currentConfig.getEdgeOptimizeConfig() || {};
const existingList = existingEdgeConfig.stagingDomains || [];
const byDomain = new Map(existingList.map((e) => [e.domain, e]));
for (const entry of newEntries) {
byDomain.set(entry.domain, { domain: entry.domain, id: entry.id });
}
const mergedStagingDomains = [...byDomain.values()];

currentConfig.updateEdgeOptimizeConfig({
...existingEdgeConfig,
stagingDomains: mergedStagingDomains,
});
await saveSiteConfig(site, currentConfig, log, 'updating edge optimize staging domains');
log.info(`[edge-optimize-config/stage] Updated staging domains for site ${siteId}, count=${mergedStagingDomains.length}`);
return ok(stageConfigs);
} catch (error) {
log.error(`Failed to add staging domains for site ${siteId}:`, error);
return badRequest(error.message);
}
};

return {
getLlmoSheetData,
queryLlmoSheetData,
Expand All @@ -1541,6 +1664,7 @@ function LlmoController(ctx) {
getBrandClaims,
createOrUpdateEdgeConfig,
getEdgeConfig,
createOrUpdateStageEdgeConfig,
getStrategy,
saveStrategy,
checkEdgeOptimizeStatus,
Expand Down
1 change: 1 addition & 0 deletions src/routes/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -374,6 +374,7 @@ export default function getRouteHandlers(
'POST /sites/:siteId/llmo/offboard': llmoController.offboardCustomer,
'POST /sites/:siteId/llmo/edge-optimize-config': llmoController.createOrUpdateEdgeConfig,
'GET /sites/:siteId/llmo/edge-optimize-config': llmoController.getEdgeConfig,
'POST /sites/:siteId/llmo/edge-optimize-config/stage': llmoController.createOrUpdateStageEdgeConfig,
'GET /sites/:siteId/llmo/strategy': llmoController.getStrategy,
'PUT /sites/:siteId/llmo/strategy': llmoController.saveStrategy,
'GET /sites/:siteId/llmo/edge-optimize-status': llmoController.checkEdgeOptimizeStatus,
Expand Down
Loading