From cdf817414881bd9c53efe2bcfb9cf0cb57397e30 Mon Sep 17 00:00:00 2001 From: Akash Bhardwaj Date: Fri, 19 Jun 2026 01:03:03 +0530 Subject: [PATCH 01/56] feat(llmo): add edge-optimize-bootstrap-url endpoint POST /sites/:siteId/llmo/edge-optimize-bootstrap-url returns a CloudFormation quick-create URL with a server-side presigned template URL, so a customer can create the cross-account Edge Optimize connector role in their own AWS account without a public S3 bucket and without any S3 access of their own. Presigning is done with the service execution role. Includes route + capability registration, OpenAPI spec, and unit tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/openapi/api.yaml | 2 + docs/openapi/llmo-api.yaml | 77 ++++++++++++++++++++++++++ src/controllers/llmo/llmo.js | 86 +++++++++++++++++++++++++++++ src/routes/index.js | 1 + src/routes/required-capabilities.js | 1 + test/controllers/llmo/llmo.test.js | 65 ++++++++++++++++++++++ 6 files changed, 232 insertions(+) diff --git a/docs/openapi/api.yaml b/docs/openapi/api.yaml index eaf8a73e86..76e355be79 100644 --- a/docs/openapi/api.yaml +++ b/docs/openapi/api.yaml @@ -558,6 +558,8 @@ paths: $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-bootstrap-url: + $ref: './llmo-api.yaml#/site-llmo-edge-optimize-bootstrap-url' /sites/{siteId}/llmo/edge-optimize-status: $ref: './llmo-api.yaml#/llmo-edge-optimize-status' /sites/{siteId}/llmo/probes/edge-optimize: diff --git a/docs/openapi/llmo-api.yaml b/docs/openapi/llmo-api.yaml index c458d372c6..0de96fdbfe 100644 --- a/docs/openapi/llmo-api.yaml +++ b/docs/openapi/llmo-api.yaml @@ -2116,6 +2116,83 @@ llmo-edge-optimize-status: security: - api_key: [ ] +site-llmo-edge-optimize-bootstrap-url: + post: + tags: + - llmo + summary: Generate a CloudFormation quick-create URL for the Edge Optimize connector role + description: | + Returns a one-click AWS CloudFormation quick-create URL (with a server-side + presigned template URL) that the customer uses to create the cross-account + connector role in their own AWS account. The template bucket stays private — + presigning is done with the service execution role — so no public S3 endpoint + is required and the customer needs no S3 access of their own. + operationId: getEdgeOptimizeBootstrapUrl + parameters: + - $ref: './parameters.yaml#/siteId' + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - accountId + properties: + accountId: + type: string + description: The 12-digit AWS account ID hosting the customer's CloudFront distribution + pattern: '^[0-9]{12}$' + example: '682033462621' + responses: + '200': + description: Bootstrap URL generated successfully + content: + application/json: + schema: + type: object + required: + - externalId + - roleArn + - quickCreateUrl + properties: + externalId: + type: string + description: Per-session external ID baked into the role's trust policy + roleName: + type: string + roleArn: + type: string + trustedPrincipalArn: + type: string + stackName: + type: string + quickCreateUrl: + type: string + description: One-click CloudFormation quick-create URL (presigned template) + presignTtlSeconds: + type: integer + example: + externalId: '7ff9518a-cf59-40b4-aa53-68a3cb2e24a5' + roleName: AdobeLLMOptimizerCloudFrontConnectorRole + roleArn: 'arn:aws:iam::682033462621:role/AdobeLLMOptimizerCloudFrontConnectorRole' + trustedPrincipalArn: 'arn:aws:iam::120569600543:root' + stackName: adobe-edgeoptimize-connector-role + quickCreateUrl: 'https://us-east-1.console.aws.amazon.com/cloudformation/home?region=us-east-1#/stacks/quickcreate?templateURL=...' + presignTtlSeconds: 3600 + '400': + $ref: './responses.yaml#/400' + '401': + $ref: './responses.yaml#/401' + '403': + $ref: './responses.yaml#/403' + '404': + $ref: './responses.yaml#/404' + '500': + $ref: './responses.yaml#/500' + security: + - api_key: [ ] + llmo-probe-edge-optimize: get: tags: diff --git a/src/controllers/llmo/llmo.js b/src/controllers/llmo/llmo.js index d832242a95..4afaafe25e 100644 --- a/src/controllers/llmo/llmo.js +++ b/src/controllers/llmo/llmo.js @@ -2005,7 +2005,93 @@ function LlmoController(ctx) { return ok(result); }; + /** + * POST /sites/{siteId}/llmo/edge-optimize-bootstrap-url + * Builds a one-click CloudFormation quick-create URL (with a server-side + * presigned template URL) the customer uses to create the cross-account + * connector role in their own AWS account. Presigning runs with the service + * execution role, so the template bucket stays private (no public endpoint) + * and the customer needs no S3 access. + * @param {object} context - Request context + * @returns {Promise} Bootstrap details + CloudFormation quick-create URL + */ + const getEdgeOptimizeBootstrapUrl = async (context) => { + const { + log, dataAccess, env, s3, + } = context; + const { siteId } = context.params; + const { Site } = dataAccess; + const accountId = String(context.data?.accountId || '').replace(/\D/g, ''); + + if (accountId.length !== 12) { + return badRequest('accountId must be a 12-digit AWS account ID'); + } + + 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 (!accessControlUtil.isLLMOAdministrator()) { + return forbidden('Only LLMO administrators can generate the edge optimize bootstrap URL'); + } + + const bucket = env.EDGE_OPTIMIZE_TEMPLATE_BUCKET; + if (!hasText(bucket) || !s3?.s3Client) { + return badRequest('Edge optimize template hosting is not configured for this environment'); + } + + const key = env.EDGE_OPTIMIZE_TEMPLATE_KEY || 'customer-bootstrap-role.yaml'; + const region = 'us-east-1'; + const roleName = env.EDGE_OPTIMIZE_ROLE_NAME || 'AdobeLLMOptimizerCloudFrontConnectorRole'; + const stackName = env.EDGE_OPTIMIZE_STACK_NAME || 'adobe-edgeoptimize-connector-role'; + const presignTtlSeconds = Number(env.EDGE_OPTIMIZE_PRESIGN_TTL || 3600); + const externalId = crypto.randomUUID(); + const roleArn = `arn:aws:iam::${accountId}:role/${roleName}`; + const trustedPrincipalArn = env.EDGE_OPTIMIZE_TRUSTED_PRINCIPAL_ARN + || `arn:aws:iam::${accountId}:root`; + + // Presign the (private) template so the customer's CloudFormation can read it + // cross-account via the signature — no public bucket, no customer S3 access. + const templateUrl = await s3.getSignedUrl( + s3.s3Client, + new s3.GetObjectCommand({ Bucket: bucket, Key: key }), + { expiresIn: presignTtlSeconds }, + ); + + const params = { + TrustedPrincipalArn: trustedPrincipalArn, + ExternalId: externalId, + RoleName: roleName, + }; + const qs = new URLSearchParams(); + qs.set('templateURL', templateUrl); + qs.set('stackName', stackName); + Object.entries(params).forEach(([k, v]) => qs.set(`param_${k}`, v)); + const quickCreateUrl = `https://${region}.console.aws.amazon.com/cloudformation/home?region=${region}#/stacks/quickcreate?${qs.toString()}`; + + log.info(`[edge-optimize-bootstrap-url] Generated bootstrap URL for site ${siteId}, account ${accountId}`); + + return ok({ + externalId, + roleName, + roleArn, + trustedPrincipalArn, + stackName, + quickCreateUrl, + presignTtlSeconds, + }); + } catch (error) { + log.error(`Failed to generate edge optimize bootstrap URL for site ${siteId}:`, error); + return badRequest(cleanupHeaderValue(error.message)); + } + }; + return { + getEdgeOptimizeBootstrapUrl, getLlmoSheetData, queryLlmoSheetData, getLlmoGlobalSheetData, diff --git a/src/routes/index.js b/src/routes/index.js index 3ea56fec6e..982432ea34 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -437,6 +437,7 @@ export default function getRouteHandlers( '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, + 'POST /sites/:siteId/llmo/edge-optimize-bootstrap-url': llmoController.getEdgeOptimizeBootstrapUrl, 'GET /sites/:siteId/llmo/strategy': llmoController.getStrategy, 'PUT /sites/:siteId/llmo/strategy': llmoController.saveStrategy, 'GET /sites/:siteId/llmo/edge-optimize-status': llmoController.checkEdgeOptimizeStatus, diff --git a/src/routes/required-capabilities.js b/src/routes/required-capabilities.js index 66d94a6120..9ea01303f7 100644 --- a/src/routes/required-capabilities.js +++ b/src/routes/required-capabilities.js @@ -126,6 +126,7 @@ export const INTERNAL_ROUTES = [ 'POST /sites/:siteId/llmo/offboard', 'POST /sites/:siteId/llmo/edge-optimize-config', 'POST /sites/:siteId/llmo/edge-optimize-config/stage', + 'POST /sites/:siteId/llmo/edge-optimize-bootstrap-url', 'PUT /sites/:siteId/llmo/opportunities-reviewed', // PLG onboarding - IMS token auth, self-service flow, not S2S diff --git a/test/controllers/llmo/llmo.test.js b/test/controllers/llmo/llmo.test.js index 4e2e216b59..90746721bc 100644 --- a/test/controllers/llmo/llmo.test.js +++ b/test/controllers/llmo/llmo.test.js @@ -7158,6 +7158,71 @@ describe('LlmoController', () => { }); }); + describe('getEdgeOptimizeBootstrapUrl', () => { + let bootstrapContext; + let getSignedUrlStub; + + beforeEach(() => { + getSignedUrlStub = sinon.stub().resolves('https://llmo-edgeoptimize-cf-template-stage.s3.us-east-1.amazonaws.com/customer-bootstrap-role.yaml?X-Amz-Signature=abc'); + bootstrapContext = { + ...mockContext, + params: { siteId: TEST_SITE_ID }, + data: { accountId: '682033462621' }, + env: { EDGE_OPTIMIZE_TEMPLATE_BUCKET: 'llmo-edgeoptimize-cf-template-stage' }, + s3: { + s3Client: {}, + getSignedUrl: getSignedUrlStub, + GetObjectCommand: class GetObjectCommand {}, + }, + }; + }); + + it('returns a quick-create URL with a presigned template for a valid account', async () => { + const result = await controller.getEdgeOptimizeBootstrapUrl(bootstrapContext); + + expect(result.status).to.equal(200); + const body = await result.json(); + expect(body.quickCreateUrl).to.include('stacks/quickcreate'); + expect(body.quickCreateUrl).to.include('templateURL='); + expect(body.quickCreateUrl).to.include('param_RoleName=AdobeLLMOptimizerCloudFrontConnectorRole'); + expect(body.roleArn).to.equal('arn:aws:iam::682033462621:role/AdobeLLMOptimizerCloudFrontConnectorRole'); + expect(body.externalId).to.be.a('string'); + expect(getSignedUrlStub.calledOnce).to.equal(true); + }); + + it('returns 400 for an invalid account id', async () => { + const result = await controller.getEdgeOptimizeBootstrapUrl({ ...bootstrapContext, data: { accountId: '123' } }); + + expect(result.status).to.equal(400); + const body = await result.json(); + expect(body.message).to.include('12-digit'); + }); + + it('returns 400 when the template bucket is not configured', async () => { + const result = await controller.getEdgeOptimizeBootstrapUrl({ ...bootstrapContext, env: {} }); + + expect(result.status).to.equal(400); + const body = await result.json(); + expect(body.message).to.include('not configured'); + }); + + it('returns 404 when the site is not found', async () => { + mockDataAccess.Site.findById.resolves(null); + + const result = await controller.getEdgeOptimizeBootstrapUrl(bootstrapContext); + + expect(result.status).to.equal(404); + }); + + it('returns 403 when the user lacks access to the site', async () => { + const deniedController = controllerWithAccessDenied(mockContext); + + const result = await deniedController.getEdgeOptimizeBootstrapUrl(bootstrapContext); + + expect(result.status).to.equal(403); + }); + }); + describe('getStrategy', () => { const mockStrategyData = { opportunities: [ From ee482c293f5a6ff7d159b76a249faed973145533 Mon Sep 17 00:00:00 2001 From: Akash Bhardwaj Date: Fri, 19 Jun 2026 01:42:00 +0530 Subject: [PATCH 02/56] test: register edge-optimize-bootstrap-url route in route-handler test The getRouteHandlers "segregates static and dynamic routes" test asserts the exact set of routes; add the new dynamic route to the expected list. Co-Authored-By: Claude Opus 4.8 (1M context) --- test/routes/index.test.js | 1 + 1 file changed, 1 insertion(+) diff --git a/test/routes/index.test.js b/test/routes/index.test.js index 227f712766..eabb70caf5 100755 --- a/test/routes/index.test.js +++ b/test/routes/index.test.js @@ -1061,6 +1061,7 @@ describe('getRouteHandlers', () => { 'POST /sites/:siteId/llmo/edge-optimize-config', 'GET /sites/:siteId/llmo/edge-optimize-config', 'POST /sites/:siteId/llmo/edge-optimize-config/stage', + 'POST /sites/:siteId/llmo/edge-optimize-bootstrap-url', 'GET /sites/:siteId/llmo/edge-optimize-status', 'GET /sites/:siteId/llmo/probes/edge-optimize', 'GET /sites/:siteId/llmo/strategy', From 76db1533c1a14021b3507610099751a60d2da39b Mon Sep 17 00:00:00 2001 From: Akash Bhardwaj Date: Fri, 19 Jun 2026 12:32:40 +0530 Subject: [PATCH 03/56] chore(llmo): TEMP testing defaults for edge-optimize bootstrap (dev/ci) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hardcode EDGE_OPTIMIZE_TEMPLATE_BUCKET and EDGE_OPTIMIZE_TRUSTED_PRINCIPAL_ARN fallbacks so the dev/ci branch deploy returns a quick-create URL before those env vars are wired into Vault/secrets. Marked TEMPORARY / TODO REMOVE — revert before merge/prod (values must come from env config). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/controllers/llmo/llmo.js | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/controllers/llmo/llmo.js b/src/controllers/llmo/llmo.js index cc57de6314..22226a0d3f 100644 --- a/src/controllers/llmo/llmo.js +++ b/src/controllers/llmo/llmo.js @@ -2098,7 +2098,10 @@ function LlmoController(ctx) { return forbidden('Only LLMO administrators can generate the edge optimize bootstrap URL'); } - const bucket = env.EDGE_OPTIMIZE_TEMPLATE_BUCKET; + // TEMPORARY (testing only): hardcoded fallback so the dev/ci deploy returns a URL + // before EDGE_OPTIMIZE_TEMPLATE_BUCKET is wired into Vault/secrets. + // TODO: REMOVE this default before merge/prod — the value must come from env config. + const bucket = env.EDGE_OPTIMIZE_TEMPLATE_BUCKET || 'llmo-edgeoptimize-cf-template-stage'; if (!hasText(bucket) || !s3?.s3Client) { return badRequest('Edge optimize template hosting is not configured for this environment'); } @@ -2110,8 +2113,11 @@ function LlmoController(ctx) { const presignTtlSeconds = Number(env.EDGE_OPTIMIZE_PRESIGN_TTL || 3600); const externalId = crypto.randomUUID(); const roleArn = `arn:aws:iam::${accountId}:role/${roleName}`; + // TEMPORARY (testing only): default the trust to the dev signer account so the + // cross-account test works (dev signs, stage is the customer where the role is made). + // TODO: REMOVE this default before merge/prod — set EDGE_OPTIMIZE_TRUSTED_PRINCIPAL_ARN via env. const trustedPrincipalArn = env.EDGE_OPTIMIZE_TRUSTED_PRINCIPAL_ARN - || `arn:aws:iam::${accountId}:root`; + || 'arn:aws:iam::682033462621:root'; // Presign the (private) template so the customer's CloudFormation can read it // cross-account via the signature — no public bucket, no customer S3 access. From 9b12537f2ef9df51d075da84cd05b0bdc0f8e67d Mon Sep 17 00:00:00 2001 From: Akash Bhardwaj Date: Fri, 19 Jun 2026 12:34:42 +0530 Subject: [PATCH 04/56] chore(llmo): point temp testing bucket default to the dev-account bucket Use llmo-edgeoptimize-cf-template (in 682033462621, where the service deploys and signs) so the dev role reads it same-account; stage customer fetches via the presigned URL. Still TEMPORARY / TODO REMOVE before merge. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/controllers/llmo/llmo.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/controllers/llmo/llmo.js b/src/controllers/llmo/llmo.js index 22226a0d3f..80293d53e8 100644 --- a/src/controllers/llmo/llmo.js +++ b/src/controllers/llmo/llmo.js @@ -2099,9 +2099,11 @@ function LlmoController(ctx) { } // TEMPORARY (testing only): hardcoded fallback so the dev/ci deploy returns a URL - // before EDGE_OPTIMIZE_TEMPLATE_BUCKET is wired into Vault/secrets. + // before EDGE_OPTIMIZE_TEMPLATE_BUCKET is wired into Vault/secrets. This bucket lives + // in the dev account (682033462621) where the service deploys and signs, so the dev + // role reads it same-account; the stage customer fetches it via the presigned URL. // TODO: REMOVE this default before merge/prod — the value must come from env config. - const bucket = env.EDGE_OPTIMIZE_TEMPLATE_BUCKET || 'llmo-edgeoptimize-cf-template-stage'; + const bucket = env.EDGE_OPTIMIZE_TEMPLATE_BUCKET || 'llmo-edgeoptimize-cf-template'; if (!hasText(bucket) || !s3?.s3Client) { return badRequest('Edge optimize template hosting is not configured for this environment'); } From edb097ce7c0e91e54a0263bbbd7cb6399c878bae Mon Sep 17 00:00:00 2001 From: Akash Bhardwaj Date: Fri, 19 Jun 2026 12:52:28 +0530 Subject: [PATCH 05/56] fix(llmo): shorten temp-default comment to satisfy max-len lint Co-Authored-By: Claude Opus 4.8 (1M context) --- src/controllers/llmo/llmo.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/controllers/llmo/llmo.js b/src/controllers/llmo/llmo.js index 80293d53e8..3026392d1f 100644 --- a/src/controllers/llmo/llmo.js +++ b/src/controllers/llmo/llmo.js @@ -2117,7 +2117,7 @@ function LlmoController(ctx) { const roleArn = `arn:aws:iam::${accountId}:role/${roleName}`; // TEMPORARY (testing only): default the trust to the dev signer account so the // cross-account test works (dev signs, stage is the customer where the role is made). - // TODO: REMOVE this default before merge/prod — set EDGE_OPTIMIZE_TRUSTED_PRINCIPAL_ARN via env. + // TODO: REMOVE before merge/prod — set EDGE_OPTIMIZE_TRUSTED_PRINCIPAL_ARN via env. const trustedPrincipalArn = env.EDGE_OPTIMIZE_TRUSTED_PRINCIPAL_ARN || 'arn:aws:iam::682033462621:root'; From fb857d4dec5ddeb4ff50c2d1b9d839393a97239b Mon Sep 17 00:00:00 2001 From: Akash Bhardwaj Date: Fri, 19 Jun 2026 13:12:08 +0530 Subject: [PATCH 06/56] test(llmo): fix bootstrap-url not-configured test for temp bucket default The TEMPORARY hardcoded EDGE_OPTIMIZE_TEMPLATE_BUCKET default makes the bucket always set, so the 'not configured' guard can no longer be hit via an empty env. Exercise the same guard via the missing S3 client instead. TODO: restore the empty-bucket variant when the temp default is removed. Co-Authored-By: Claude Opus 4.8 (1M context) --- test/controllers/llmo/llmo.test.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/test/controllers/llmo/llmo.test.js b/test/controllers/llmo/llmo.test.js index c1c455f477..f6ff316f2e 100644 --- a/test/controllers/llmo/llmo.test.js +++ b/test/controllers/llmo/llmo.test.js @@ -7594,8 +7594,11 @@ describe('LlmoController', () => { expect(body.message).to.include('12-digit'); }); - it('returns 400 when the template bucket is not configured', async () => { - const result = await controller.getEdgeOptimizeBootstrapUrl({ ...bootstrapContext, env: {} }); + it('returns 400 when template hosting is not configured (no S3 client)', async () => { + // While the TEMPORARY hardcoded bucket default is in place the bucket is always + // set, so the "not configured" guard is exercised via the missing S3 client. + // TODO: restore the `env: {}` (empty-bucket) variant once the temp default is removed. + const result = await controller.getEdgeOptimizeBootstrapUrl({ ...bootstrapContext, s3: {} }); expect(result.status).to.equal(400); const body = await result.json(); From d77eaa16ca9a0f937a9d9a22c673502d0b5dfd58 Mon Sep 17 00:00:00 2001 From: Akash Bhardwaj Date: Fri, 19 Jun 2026 14:36:01 +0530 Subject: [PATCH 07/56] fix(llmo): lower edge-optimize presign TTL default to 900s (15m) Tighten the default lifetime of the bootstrap template presigned URL from 1h to 15m. The customer opens the quick-create link immediately, so a shorter TTL shrinks the leak window. A leaked URL only grants GetObject on the single template object until expiry; still override via EDGE_OPTIMIZE_PRESIGN_TTL. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/openapi/llmo-api.yaml | 2 +- src/controllers/llmo/llmo.js | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/openapi/llmo-api.yaml b/docs/openapi/llmo-api.yaml index f202bb430b..877bd3a195 100644 --- a/docs/openapi/llmo-api.yaml +++ b/docs/openapi/llmo-api.yaml @@ -2395,7 +2395,7 @@ site-llmo-edge-optimize-bootstrap-url: trustedPrincipalArn: 'arn:aws:iam::120569600543:root' stackName: adobe-edgeoptimize-connector-role quickCreateUrl: 'https://us-east-1.console.aws.amazon.com/cloudformation/home?region=us-east-1#/stacks/quickcreate?templateURL=...' - presignTtlSeconds: 3600 + presignTtlSeconds: 900 '400': $ref: './responses.yaml#/400' '401': diff --git a/src/controllers/llmo/llmo.js b/src/controllers/llmo/llmo.js index 3026392d1f..1d5136289f 100644 --- a/src/controllers/llmo/llmo.js +++ b/src/controllers/llmo/llmo.js @@ -2112,7 +2112,10 @@ function LlmoController(ctx) { const region = 'us-east-1'; const roleName = env.EDGE_OPTIMIZE_ROLE_NAME || 'AdobeLLMOptimizerCloudFrontConnectorRole'; const stackName = env.EDGE_OPTIMIZE_STACK_NAME || 'adobe-edgeoptimize-connector-role'; - const presignTtlSeconds = Number(env.EDGE_OPTIMIZE_PRESIGN_TTL || 3600); + // Short-lived presign: the customer opens the link immediately, so a tight TTL + // shrinks the exposure window if the URL leaks (it only grants GetObject on this + // one template object until expiry — see security notes). Override via env. + const presignTtlSeconds = Number(env.EDGE_OPTIMIZE_PRESIGN_TTL || 900); const externalId = crypto.randomUUID(); const roleArn = `arn:aws:iam::${accountId}:role/${roleName}`; // TEMPORARY (testing only): default the trust to the dev signer account so the From f7c227612f08379d58b16ca9f1101d0bf5c0bdae Mon Sep 17 00:00:00 2001 From: Akash Bhardwaj Date: Sat, 20 Jun 2026 08:41:50 +0530 Subject: [PATCH 08/56] feat(llmo): CloudFront wizard connect + list-distributions endpoints (Phase 2) Backend for the CloudFront 'Deploy routing' wizard's read steps. The api-service assumes the customer's cross-account connector role server-side (no AWS creds in the browser): - New src/support/edge-optimize.js: assumeConnectorRole (STS AssumeRole with the per-session external ID) + listCloudFrontDistributions. - POST /sites/:siteId/llmo/edge-optimize/connect - verifies the role is assumable (returns { connected } so the UI can poll while the customer creates the role); POST .../edge-optimize/distributions - lists the account's CloudFront distributions. Both gated by site access + LLMO admin and added to INTERNAL_ROUTES (not exposed to S2S). - Adds @aws-sdk/client-sts and @aws-sdk/client-cloudfront. - Unit tests for the support module (mocked SDK) and both handlers; route + capability lists updated. Co-Authored-By: Claude Opus 4.8 (1M context) --- package-lock.json | 271 ++++++++++++++++++++++------ package.json | 2 + src/controllers/llmo/llmo.js | 91 ++++++++++ src/routes/index.js | 2 + src/routes/required-capabilities.js | 2 + src/support/edge-optimize.js | 96 ++++++++++ test/controllers/llmo/llmo.test.js | 180 ++++++++++++++++++ test/routes/index.test.js | 4 + test/support/edge-optimize.test.js | 181 +++++++++++++++++++ 9 files changed, 778 insertions(+), 51 deletions(-) create mode 100644 src/support/edge-optimize.js create mode 100644 test/support/edge-optimize.test.js diff --git a/package-lock.json b/package-lock.json index 3d2ae1c847..443e97eb2f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -34,10 +34,12 @@ "@adobe/spacecat-shared-tokowaka-client": "1.19.0", "@adobe/spacecat-shared-utils": "1.119.2", "@adobe/spacecat-shared-vault-secrets": "1.3.5", + "@aws-sdk/client-cloudfront": "3.1045.0", "@aws-sdk/client-s3": "3.1045.0", "@aws-sdk/client-secrets-manager": "3.1045.0", "@aws-sdk/client-sfn": "3.1045.0", "@aws-sdk/client-sqs": "3.1045.0", + "@aws-sdk/client-sts": "3.1045.0", "@aws-sdk/s3-request-presigner": "3.1045.0", "@bufbuild/protobuf": "2.7.0", "@connectrpc/connect": "^2.1.1", @@ -364,6 +366,58 @@ "node": ">=20.0.0" } }, + "node_modules/@adobe/helix-deploy/node_modules/@aws-sdk/client-sts": { + "version": "3.1042.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sts/-/client-sts-3.1042.0.tgz", + "integrity": "sha512-CTpdtuLEdClT31afs8eabb9Yhm1fRU8DRLak/+5VTrVSZcWzEvzVhA4ZVHvf4w7EPah2yve//14zwyDYa4IxVQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.974.8", + "@aws-sdk/credential-provider-node": "^3.972.39", + "@aws-sdk/middleware-host-header": "^3.972.10", + "@aws-sdk/middleware-logger": "^3.972.10", + "@aws-sdk/middleware-recursion-detection": "^3.972.11", + "@aws-sdk/middleware-user-agent": "^3.972.38", + "@aws-sdk/region-config-resolver": "^3.972.13", + "@aws-sdk/signature-v4-multi-region": "^3.996.25", + "@aws-sdk/types": "^3.973.8", + "@aws-sdk/util-endpoints": "^3.996.8", + "@aws-sdk/util-user-agent-browser": "^3.972.10", + "@aws-sdk/util-user-agent-node": "^3.973.24", + "@smithy/config-resolver": "^4.4.17", + "@smithy/core": "^3.23.17", + "@smithy/fetch-http-handler": "^5.3.17", + "@smithy/hash-node": "^4.2.14", + "@smithy/invalid-dependency": "^4.2.14", + "@smithy/middleware-content-length": "^4.2.14", + "@smithy/middleware-endpoint": "^4.4.32", + "@smithy/middleware-retry": "^4.5.7", + "@smithy/middleware-serde": "^4.2.20", + "@smithy/middleware-stack": "^4.2.14", + "@smithy/node-config-provider": "^4.3.14", + "@smithy/node-http-handler": "^4.6.1", + "@smithy/protocol-http": "^5.3.14", + "@smithy/smithy-client": "^4.12.13", + "@smithy/types": "^4.14.1", + "@smithy/url-parser": "^4.2.14", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-body-length-browser": "^4.2.2", + "@smithy/util-body-length-node": "^4.2.3", + "@smithy/util-defaults-mode-browser": "^4.3.49", + "@smithy/util-defaults-mode-node": "^4.2.54", + "@smithy/util-endpoints": "^3.4.2", + "@smithy/util-middleware": "^4.2.14", + "@smithy/util-retry": "^4.3.6", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@adobe/helix-deploy/node_modules/@aws-sdk/middleware-host-header": { "version": "3.972.13", "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.972.13.tgz", @@ -708,7 +762,6 @@ "resolved": "https://registry.npmjs.org/@adobe/helix-universal/-/helix-universal-5.4.1.tgz", "integrity": "sha512-wgmjwo0xJkYhFQUmv6GTPvCFjDYBoT7zP3OuAxLN+FlHgS6kDkbJOtKxwQn9SrWbhoIfM8GdCnRDpBn6BmkASw==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@adobe/fetch": "4.3.0", "aws4": "1.13.2" @@ -8986,6 +9039,40 @@ } } }, + "node_modules/@adobe/spacecat-shared-tokowaka-client/node_modules/@aws-sdk/client-cloudfront": { + "version": "3.1057.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-cloudfront/-/client-cloudfront-3.1057.0.tgz", + "integrity": "sha512-SSDMS+oxch09VWLBeQDEnHcQ7v1Q/YVBrA/YarjYKnqWzJO9bgoLOIN0mYG9SdcRXnyH8Bn6aIEtosanGjU5wQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.974.15", + "@aws-sdk/credential-provider-node": "^3.972.47", + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.5", + "@smithy/fetch-http-handler": "^5.4.5", + "@smithy/node-http-handler": "^4.7.5", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@adobe/spacecat-shared-tokowaka-client/node_modules/@aws-sdk/client-cloudfront/node_modules/@aws-sdk/types": { + "version": "3.973.13", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.13.tgz", + "integrity": "sha512-pEHZqRkAlHfnfAU9tK+WpKv/gBNjGJrHMgA3A0iYRGyswBS2t0pfez+lWlwktb3Bqa0ovh7w/QJTFwp3fDxLNg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@adobe/spacecat-shared-tokowaka-client/node_modules/@aws-sdk/client-s3": { "version": "3.1057.0", "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1057.0.tgz", @@ -10335,20 +10422,103 @@ } }, "node_modules/@aws-sdk/client-cloudfront": { - "version": "3.1057.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-cloudfront/-/client-cloudfront-3.1057.0.tgz", - "integrity": "sha512-SSDMS+oxch09VWLBeQDEnHcQ7v1Q/YVBrA/YarjYKnqWzJO9bgoLOIN0mYG9SdcRXnyH8Bn6aIEtosanGjU5wQ==", + "version": "3.1045.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-cloudfront/-/client-cloudfront-3.1045.0.tgz", + "integrity": "sha512-84RIiLrMXcinBK1JXnP1bOavvQ+jxTxN4xsB20e39MfOZMyr7wIxMNn35kTYTg8UTJgN7zwEgzGJo64sU3mtPw==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.974.15", - "@aws-sdk/credential-provider-node": "^3.972.47", - "@aws-sdk/types": "^3.973.9", - "@smithy/core": "^3.24.5", - "@smithy/fetch-http-handler": "^5.4.5", - "@smithy/node-http-handler": "^4.7.5", - "@smithy/types": "^4.14.2", + "@aws-sdk/core": "^3.974.8", + "@aws-sdk/credential-provider-node": "^3.972.39", + "@aws-sdk/middleware-host-header": "^3.972.10", + "@aws-sdk/middleware-logger": "^3.972.10", + "@aws-sdk/middleware-recursion-detection": "^3.972.11", + "@aws-sdk/middleware-user-agent": "^3.972.38", + "@aws-sdk/region-config-resolver": "^3.972.13", + "@aws-sdk/types": "^3.973.8", + "@aws-sdk/util-endpoints": "^3.996.8", + "@aws-sdk/util-user-agent-browser": "^3.972.10", + "@aws-sdk/util-user-agent-node": "^3.973.24", + "@smithy/config-resolver": "^4.4.17", + "@smithy/core": "^3.23.17", + "@smithy/fetch-http-handler": "^5.3.17", + "@smithy/hash-node": "^4.2.14", + "@smithy/invalid-dependency": "^4.2.14", + "@smithy/middleware-content-length": "^4.2.14", + "@smithy/middleware-endpoint": "^4.4.32", + "@smithy/middleware-retry": "^4.5.7", + "@smithy/middleware-serde": "^4.2.20", + "@smithy/middleware-stack": "^4.2.14", + "@smithy/node-config-provider": "^4.3.14", + "@smithy/node-http-handler": "^4.6.1", + "@smithy/protocol-http": "^5.3.14", + "@smithy/smithy-client": "^4.12.13", + "@smithy/types": "^4.14.1", + "@smithy/url-parser": "^4.2.14", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-body-length-browser": "^4.2.2", + "@smithy/util-body-length-node": "^4.2.3", + "@smithy/util-defaults-mode-browser": "^4.3.49", + "@smithy/util-defaults-mode-node": "^4.2.54", + "@smithy/util-endpoints": "^3.4.2", + "@smithy/util-middleware": "^4.2.14", + "@smithy/util-retry": "^4.3.6", + "@smithy/util-stream": "^4.5.25", + "@smithy/util-utf8": "^4.2.2", + "@smithy/util-waiter": "^4.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-cloudfront/node_modules/@aws-sdk/middleware-host-header": { + "version": "3.972.23", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.972.23.tgz", + "integrity": "sha512-sWeojvaTI86wGRIt5HQb9o50V6AExi+NV3VrH2AwBlzV47a+m4JAejaC8CObaLXGVqSRqGt1B9AGYONp+NcTXg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.22", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-cloudfront/node_modules/@aws-sdk/middleware-logger": { + "version": "3.972.22", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.972.22.tgz", + "integrity": "sha512-opYE2yUoQKJJz5QrGylnZeaBRmUaRzUSxlW40cX4LnmwO5XI61IWC3k/LDbJwF15AvmlNbome+glhIlFJzCWJw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.22", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-cloudfront/node_modules/@aws-sdk/middleware-recursion-detection": { + "version": "3.972.24", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.972.24.tgz", + "integrity": "sha512-M0gO9RgAppZx7yDvFRB1pdnyzd34Gnt/JjpT9HoMZxxDYnAkYNa3eabbjsf2A6ffxDFAvZKFdkjthrQxlQgcRg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.22", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-cloudfront/node_modules/@aws-sdk/region-config-resolver": { + "version": "3.972.26", + "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.972.26.tgz", + "integrity": "sha512-wDR6i4YBvJTGGElCQlXQd42f6AraatbU9ckvrjsKu1wFBwH0vTUBxFo6kqRWfuleKhDkMqSkQujCL1eWbFFLtw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.22", "tslib": "^2.6.2" }, "engines": { @@ -10368,6 +10538,30 @@ "node": ">=20.0.0" } }, + "node_modules/@aws-sdk/client-cloudfront/node_modules/@aws-sdk/util-endpoints": { + "version": "3.996.21", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.996.21.tgz", + "integrity": "sha512-mZbKi9+3h3BtGkph4eYPMCoxiyYHM4q1rYZILfCE4a1uszKaXCRrI0n8nPqcOQDGc2fTwpXm7jLAs/sok4MxVg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.22", + "@smithy/core": "^3.24.6", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-cloudfront/node_modules/@aws-sdk/util-user-agent-browser": { + "version": "3.972.23", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.972.23.tgz", + "integrity": "sha512-5jF9hUGr6vplVanji97KRmbbTUlStXOp/WSz7xArD9fIWxId7tZoq0fPVRNJC5xOuaGzkwO3jHZn8BicrwfwSw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.22", + "tslib": "^2.6.2" + } + }, "node_modules/@aws-sdk/client-dynamodb": { "version": "3.1069.0", "resolved": "https://registry.npmjs.org/@aws-sdk/client-dynamodb/-/client-dynamodb-3.1069.0.tgz", @@ -10399,6 +10593,7 @@ "integrity": "sha512-pEHZqRkAlHfnfAU9tK+WpKv/gBNjGJrHMgA3A0iYRGyswBS2t0pfez+lWlwktb3Bqa0ovh7w/QJTFwp3fDxLNg==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@smithy/types": "^4.14.3", "tslib": "^2.6.2" @@ -11601,10 +11796,9 @@ } }, "node_modules/@aws-sdk/client-sts": { - "version": "3.1042.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-sts/-/client-sts-3.1042.0.tgz", - "integrity": "sha512-CTpdtuLEdClT31afs8eabb9Yhm1fRU8DRLak/+5VTrVSZcWzEvzVhA4ZVHvf4w7EPah2yve//14zwyDYa4IxVQ==", - "dev": true, + "version": "3.1045.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sts/-/client-sts-3.1045.0.tgz", + "integrity": "sha512-oDJJ7rM1osvfBdfZuhQ5DM6lHD9iuypL9m2LsEiA/lB8xuE5uPYsftNDcS0J9VRXFSvYTqC14K7Y5vMMKMg0vw==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", @@ -11656,7 +11850,6 @@ "version": "3.972.13", "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.972.13.tgz", "integrity": "sha512-EA3+u2LD3kGcfRNmCSjyJuzX4XvG4zYv57i4ZksH+1IEciuSyHQGvzivEz7vZ+jbRPdAAe7WWKy/4M8InCKDcw==", - "dev": true, "license": "Apache-2.0", "dependencies": { "@aws-sdk/core": "^3.974.12", @@ -11670,7 +11863,6 @@ "version": "3.972.12", "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.972.12.tgz", "integrity": "sha512-NxB2dS4/mV3380hNkC72TkhMaLLjWGGBeTAEucqlJptVVovTbNmQWZLwaMC74ICo9NZHmFiBVVTHzDfAh/3y6Q==", - "dev": true, "license": "Apache-2.0", "dependencies": { "@aws-sdk/core": "^3.974.12", @@ -11684,7 +11876,6 @@ "version": "3.972.14", "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.972.14.tgz", "integrity": "sha512-bqL+upATpOJ/7px4IVfMVxcM6Lyt9uRizmEx3mNg4N6+IQlnOaYXXOJ4TNX6P0mKPPW0lwn9ZW8QEhXwQuCH9A==", - "dev": true, "license": "Apache-2.0", "dependencies": { "@aws-sdk/core": "^3.974.12", @@ -11698,7 +11889,6 @@ "version": "3.972.16", "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.972.16.tgz", "integrity": "sha512-/YaivCvKUkEeMN9VTKBSvBn5w/4osAM1YboM58DKaLF/vqFGf/FdJCLmppqiPPJWZaXcASqByVjc3evE7KHKdA==", - "dev": true, "license": "Apache-2.0", "dependencies": { "@aws-sdk/core": "^3.974.12", @@ -11712,7 +11902,6 @@ "version": "3.973.8", "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.8.tgz", "integrity": "sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw==", - "dev": true, "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.14.1", @@ -11726,7 +11915,6 @@ "version": "3.996.11", "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.996.11.tgz", "integrity": "sha512-BUMJ6VoL54r6Udj/wKy8uKRIndL04rGbaS/wTIV0dM1ewxSrR8yARBHdvZKQsK55ZSW2JrmAPk3KP15kBDxJMw==", - "dev": true, "license": "Apache-2.0", "dependencies": { "@aws-sdk/core": "^3.974.12", @@ -11741,7 +11929,6 @@ "version": "3.972.13", "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.972.13.tgz", "integrity": "sha512-wfk9ZdVwh187gdGXB1EyAoprwjSMt/bSfVtva+OaZx+LyNdKD7smlZf611yMd42UpfQ9vaS8NOftjSajgpdd+w==", - "dev": true, "license": "Apache-2.0", "dependencies": { "@aws-sdk/core": "^3.974.12", @@ -11749,9 +11936,9 @@ } }, "node_modules/@aws-sdk/core": { - "version": "3.974.21", - "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.21.tgz", - "integrity": "sha512-P5JAHvn4dTi96UsAGS67LVOqqpUNNRhnfFXqzCYtdBIGZtqBue4CXvRr9YenOO7PALj/Pn8uuyw53FBCiCYw8w==", + "version": "3.974.22", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.22.tgz", + "integrity": "sha512-YofH63shc6YRdXjz80BJkpJW+Bkn0Cuu2dn4Rv7s9G2Idt58tgtzQEWxrR2xVljlVfIBeUjPuULnSVYLke3sUQ==", "license": "Apache-2.0", "dependencies": { "@aws-sdk/types": "^3.973.13", @@ -12207,6 +12394,7 @@ "integrity": "sha512-wfAWZ6oIrsDOFyYm9bDQNva/WCmvIrVqP3dSCePN5YYWCGWWXkikn5YC0wPSxF92M8kQFPfdVpMaTTV1mRk4Lw==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@aws-sdk/core": "^3.974.21", "@smithy/core": "^3.24.6", @@ -12223,6 +12411,7 @@ "integrity": "sha512-bBmkG0Dnhfq0/T4Z0PpUr7HkncBVaWvvCbvafeaUM+yC9wa8GGjLJmonq0QL17REB9WivgGeYgWQ5A80Uw5UnQ==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "mnemonist": "0.38.3", "tslib": "^2.6.2" @@ -12286,6 +12475,7 @@ "integrity": "sha512-FMgyzUq3Jh+ONRYxryBRNdBd+FUX8PwRl07ccQknNdoms6KCeAEusCkl6whqpDrPQ6OH0ddeSifKyqYSs2DLIw==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@aws-sdk/endpoint-cache": "^3.972.8", "@aws-sdk/types": "^3.973.13", @@ -12303,6 +12493,7 @@ "integrity": "sha512-pEHZqRkAlHfnfAU9tK+WpKv/gBNjGJrHMgA3A0iYRGyswBS2t0pfez+lWlwktb3Bqa0ovh7w/QJTFwp3fDxLNg==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@smithy/types": "^4.14.3", "tslib": "^2.6.2" @@ -12958,8 +13149,7 @@ "version": "2.7.0", "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.7.0.tgz", "integrity": "sha512-qn6tAIZEw5i/wiESBF4nQxZkl86aY4KoO0IkUa2Lh+rya64oTOdJQFlZuMwI1Qz9VBJQrQC4QlSA2DNek5gCOA==", - "license": "(Apache-2.0 AND BSD-3-Clause)", - "peer": true + "license": "(Apache-2.0 AND BSD-3-Clause)" }, "node_modules/@cfworker/json-schema": { "version": "4.1.1", @@ -12983,7 +13173,6 @@ "resolved": "https://registry.npmjs.org/@connectrpc/connect/-/connect-2.1.1.tgz", "integrity": "sha512-JzhkaTvM73m2K1URT6tv53k2RwngSmCXLZJgK580qNQOXRzZRR/BCMfZw3h+90JpnG6XksP5bYT+cz0rpUzUWQ==", "license": "Apache-2.0", - "peer": true, "peerDependencies": { "@bufbuild/protobuf": "^2.7.0" } @@ -13961,7 +14150,6 @@ "resolved": "https://registry.npmjs.org/@langchain/core/-/core-0.3.80.tgz", "integrity": "sha512-vcJDV2vk1AlCwSh3aBm/urQ1ZrlXFFBocv11bz/NBUfLWD5/UDNMzwPdaAd2dKvNmTWa9FM2lirLU3+JCf4cRA==", "license": "MIT", - "peer": true, "dependencies": { "@cfworker/json-schema": "^4.0.2", "ansi-styles": "^5.0.0", @@ -14259,7 +14447,6 @@ "resolved": "https://registry.npmjs.org/@octokit/core/-/core-7.0.6.tgz", "integrity": "sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q==", "license": "MIT", - "peer": true, "dependencies": { "@octokit/auth-token": "^6.0.0", "@octokit/graphql": "^9.0.3", @@ -14466,7 +14653,6 @@ "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=8.0.0" } @@ -14631,7 +14817,6 @@ "integrity": "sha512-r86ut4T1e8vNwB35CqCcKd45yzqH6/6Wzvpk2/cZB8PsPLlZFTvrh8yfOS3CYZYcUmAx4hHTZJ8AO8Dj8nrdhw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/resources": "2.6.1", @@ -16649,7 +16834,6 @@ "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", "license": "MIT", - "peer": true, "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^5.0.0", @@ -16897,7 +17081,6 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -16944,7 +17127,6 @@ "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -17409,7 +17591,6 @@ "resolved": "https://registry.npmjs.org/aws-xray-sdk-core/-/aws-xray-sdk-core-3.12.0.tgz", "integrity": "sha512-lwalRdxXRy+Sn49/vN7W507qqmBRk5Fy2o0a9U6XTjL9IV+oR5PUiiptoBrOcaYCiVuGld8OEbNqhm6wvV3m6A==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-sdk/types": "^3.4.1", "@smithy/service-error-classification": "^2.0.4", @@ -18159,7 +18340,6 @@ "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -20389,7 +20569,6 @@ "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -24711,7 +24890,6 @@ "integrity": "sha512-PRsaiG84bK+AMvxziE/lCFss8juXjNaWzVbN5tXAm4XjeaS9NAHhop+PjQxz2A9h8Q4M/xGmzP8vqNwy6JeK0A==", "dev": true, "license": "MIT", - "peer": true, "bin": { "marked": "bin/marked.js" }, @@ -25768,6 +25946,7 @@ "integrity": "sha512-2K9QYubXx/NAjv4VLq1d1Ly8pWNC5L3BrixtdkyTegXWJIqY+zLNDhhX/A+ZwWt70tB1S8H4BE8FLYEFyNoOBw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "obliterator": "^1.6.1" } @@ -25778,7 +25957,6 @@ "integrity": "sha512-UczzB+0nnwGotYSgllfARAqWCJ5e/skuV2K/l+Zyck/H6pJIhLXuBnz+6vn2i211o7DtbE78HQtsYEKICHGI+g==", "dev": true, "license": "MIT", - "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/mobx" @@ -28359,7 +28537,6 @@ "dev": true, "inBundle": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -28833,7 +29010,8 @@ "resolved": "https://registry.npmjs.org/obliterator/-/obliterator-1.6.1.tgz", "integrity": "sha512-9WXswnqINnnhOG/5SLimUlzuU1hFJUc8zkwyD59Sd+dPOMf05PmnYG/d6Q7HZ+KmgkZJa1PxRso6QdM3sTNHig==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/on-finished": { "version": "2.4.1", @@ -28887,7 +29065,6 @@ "resolved": "https://registry.npmjs.org/openai/-/openai-5.12.2.tgz", "integrity": "sha512-xqzHHQch5Tws5PcKR2xsZGX9xtch+JQFz5zb14dGqlshmmDAFBFEWmeIpf7wVqWV+w7Emj7jRgkNJakyKE0tYQ==", "license": "Apache-2.0", - "peer": true, "bin": { "openai": "bin/cli" }, @@ -30075,7 +30252,6 @@ "integrity": "sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==", "devOptional": true, "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -30086,7 +30262,6 @@ "integrity": "sha512-ibrK8llX2a4eOskq1mXKu/TGZj9qzomO+sNfO98M6d9zIPOEhlBkMkBUBLd1vgS0gQsLDBzA+8jJBVXDnfHmJg==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -30850,7 +31025,6 @@ "integrity": "sha512-WRgl5GcypwramYX4HV+eQGzUbD7UUbljVmS+5G1uMwX/wLgYuJAxGeerXJDMO2xshng4+FXqCgyB5QfClV6WjA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@semantic-release/commit-analyzer": "^13.0.1", "@semantic-release/error": "^4.0.0", @@ -32215,7 +32389,6 @@ "integrity": "sha512-ADu2dF53esUzzM4I0ewxhxFtsDd6v4V6dNkg3vG0iFKhnt06sJneTZnRvujAosZwW0XD58IKgGMQoqri4wHRqg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@emotion/is-prop-valid": "1.4.0", "css-to-react-native": "3.2.0", @@ -33222,7 +33395,6 @@ "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", "license": "MIT", - "peer": true, "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", @@ -34029,7 +34201,6 @@ "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", "license": "MIT", - "peer": true, "engines": { "node": ">=10.0.0" }, @@ -34321,7 +34492,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -34331,7 +34501,6 @@ "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.0.tgz", "integrity": "sha512-HvWtU2UG41LALjajJrML6uQejQhNJx+JBO9IflpSja4R03iNWfKXrj6W2h7ljuLyc1nKS+9yDyL/9tD1U/yBnQ==", "license": "ISC", - "peer": true, "peerDependencies": { "zod": "^3.25 || ^4" } diff --git a/package.json b/package.json index 61e3aad518..05d5019f10 100644 --- a/package.json +++ b/package.json @@ -98,10 +98,12 @@ "@adobe/spacecat-shared-tokowaka-client": "1.19.0", "@adobe/spacecat-shared-utils": "1.119.2", "@adobe/spacecat-shared-vault-secrets": "1.3.5", + "@aws-sdk/client-cloudfront": "3.1045.0", "@aws-sdk/client-s3": "3.1045.0", "@aws-sdk/client-secrets-manager": "3.1045.0", "@aws-sdk/client-sfn": "3.1045.0", "@aws-sdk/client-sqs": "3.1045.0", + "@aws-sdk/client-sts": "3.1045.0", "@aws-sdk/s3-request-presigner": "3.1045.0", "@bufbuild/protobuf": "2.7.0", "@connectrpc/connect": "^2.1.1", diff --git a/src/controllers/llmo/llmo.js b/src/controllers/llmo/llmo.js index 1d5136289f..5c157407ae 100644 --- a/src/controllers/llmo/llmo.js +++ b/src/controllers/llmo/llmo.js @@ -33,6 +33,7 @@ import { Entitlement as EntitlementModel } from '@adobe/spacecat-shared-data-acc import TokowakaClient, { calculateForwardedHost } from '@adobe/spacecat-shared-tokowaka-client'; import { ImsClient } from '@adobe/spacecat-shared-ims-client'; import AccessControlUtil from '../../support/access-control-util.js'; +import { assumeConnectorRole, listCloudFrontDistributions } from '../../support/edge-optimize.js'; import { UnauthorizedProductError } from '../../support/errors.js'; import { cachedOk } from '../../support/cached-response.js'; import { @@ -2160,8 +2161,98 @@ function LlmoController(ctx) { } }; + // Shared access gate for the CloudFront "Deploy routing" wizard endpoints: the caller + // must have access to the site and be an LLMO administrator. Returns { error } (a Response) + // when denied, or {} when allowed. + const gateEdgeOptimizeWizard = async (siteId, Site, action) => { + const site = await Site.findById(siteId); + if (!site) { + return { error: notFound('Site not found') }; + } + if (!await accessControlUtil.hasAccess(site)) { + return { error: forbidden('User does not have access to this site') }; + } + if (!accessControlUtil.isLLMOAdministrator()) { + return { error: forbidden(`Only LLMO administrators can ${action}`) }; + } + return {}; + }; + + // Verify the customer's cross-account connector role is assumable. Used by the wizard's + // "Allow access" step, which polls this after the customer creates the role via CloudFormation. + const connectEdgeOptimize = async (context) => { + const { log, dataAccess, env } = context; + const { siteId } = context.params; + const { Site } = dataAccess; + const accountId = String(context.data?.accountId || '').replace(/\D/g, ''); + const externalId = String(context.data?.externalId || '').trim(); + const roleName = env?.EDGE_OPTIMIZE_ROLE_NAME || undefined; + + if (accountId.length !== 12) { + return badRequest('accountId must be a 12-digit AWS account ID'); + } + if (!hasText(externalId)) { + return badRequest('externalId is required'); + } + + try { + const { error } = await gateEdgeOptimizeWizard(siteId, Site, 'connect the edge optimize role'); + if (error) { + return error; + } + + try { + const { roleArn } = await assumeConnectorRole({ accountId, externalId, roleName }); + log.info(`[edge-optimize-connect] Connected site ${siteId} to account ${accountId}`); + return ok({ connected: true, accountId, roleArn }); + } catch (assumeError) { + // The role may not exist yet (customer still creating it) or the external ID may not + // match — surface as not-connected so the wizard can keep polling rather than erroring. + log.info(`[edge-optimize-connect] Role not yet assumable for site ${siteId}: ${assumeError.message}`); + return ok({ connected: false, reason: cleanupHeaderValue(assumeError.message) }); + } + } catch (error) { + log.error(`Failed to connect edge optimize role for site ${siteId}:`, error); + return badRequest(cleanupHeaderValue(error.message)); + } + }; + + // List the customer's CloudFront distributions (read-only) via the connector role, so the + // wizard's "Choose distribution" step can let the customer pick one to configure. + const getEdgeOptimizeDistributions = async (context) => { + const { log, dataAccess, env } = context; + const { siteId } = context.params; + const { Site } = dataAccess; + const accountId = String(context.data?.accountId || '').replace(/\D/g, ''); + const externalId = String(context.data?.externalId || '').trim(); + const roleName = env?.EDGE_OPTIMIZE_ROLE_NAME || undefined; + + if (accountId.length !== 12) { + return badRequest('accountId must be a 12-digit AWS account ID'); + } + if (!hasText(externalId)) { + return badRequest('externalId is required'); + } + + try { + const { error } = await gateEdgeOptimizeWizard(siteId, Site, 'list CloudFront distributions'); + if (error) { + return error; + } + + const { credentials } = await assumeConnectorRole({ accountId, externalId, roleName }); + const distributions = await listCloudFrontDistributions(credentials); + return ok({ distributions }); + } catch (error) { + log.error(`Failed to list CloudFront distributions for site ${siteId}:`, error); + return badRequest(cleanupHeaderValue(error.message)); + } + }; + return { getEdgeOptimizeBootstrapUrl, + connectEdgeOptimize, + getEdgeOptimizeDistributions, getLlmoSheetData, queryLlmoSheetData, getLlmoGlobalSheetData, diff --git a/src/routes/index.js b/src/routes/index.js index ced21a9aef..03bbb0a3f4 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -481,6 +481,8 @@ export default function getRouteHandlers( 'GET /sites/:siteId/llmo/edge-optimize-config': llmoController.getEdgeConfig, 'POST /sites/:siteId/llmo/edge-optimize-config/stage': llmoController.createOrUpdateStageEdgeConfig, 'POST /sites/:siteId/llmo/edge-optimize-bootstrap-url': llmoController.getEdgeOptimizeBootstrapUrl, + 'POST /sites/:siteId/llmo/edge-optimize/connect': llmoController.connectEdgeOptimize, + 'POST /sites/:siteId/llmo/edge-optimize/distributions': llmoController.getEdgeOptimizeDistributions, 'GET /sites/:siteId/llmo/strategy': llmoController.getStrategy, 'PUT /sites/:siteId/llmo/strategy': llmoController.saveStrategy, 'GET /sites/:siteId/llmo/edge-optimize-status': llmoController.checkEdgeOptimizeStatus, diff --git a/src/routes/required-capabilities.js b/src/routes/required-capabilities.js index d7c9e3d4e4..ef7572de15 100644 --- a/src/routes/required-capabilities.js +++ b/src/routes/required-capabilities.js @@ -128,6 +128,8 @@ export const INTERNAL_ROUTES = [ 'POST /sites/:siteId/llmo/edge-optimize-config', 'POST /sites/:siteId/llmo/edge-optimize-config/stage', 'POST /sites/:siteId/llmo/edge-optimize-bootstrap-url', + 'POST /sites/:siteId/llmo/edge-optimize/connect', + 'POST /sites/:siteId/llmo/edge-optimize/distributions', 'PUT /sites/:siteId/llmo/opportunities-reviewed', // PLG onboarding - IMS token auth, self-service flow, not S2S diff --git a/src/support/edge-optimize.js b/src/support/edge-optimize.js new file mode 100644 index 0000000000..6507ab5aa1 --- /dev/null +++ b/src/support/edge-optimize.js @@ -0,0 +1,96 @@ +/* + * 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 { STSClient, AssumeRoleCommand } from '@aws-sdk/client-sts'; +import { CloudFrontClient, ListDistributionsCommand } from '@aws-sdk/client-cloudfront'; +import { hasText } from '@adobe/spacecat-shared-utils'; + +// CloudFront is a global service; its control plane lives in us-east-1. +export const EDGE_OPTIMIZE_REGION = 'us-east-1'; +export const EDGE_OPTIMIZE_DEFAULT_ROLE_NAME = 'AdobeLLMOptimizerCloudFrontConnectorRole'; +const SESSION_NAME = 'llmo-edge-optimize'; +const SESSION_DURATION_SECONDS = 900; + +/** + * Assume the customer's cross-account connector role and return short-lived credentials. + * + * The api-service Lambda execution role (the default credential chain) assumes the role the + * customer created via the CloudFormation bootstrap, scoped by the per-session external ID. + * Credentials are short-lived — callers should use them immediately for a single operation + * and never persist them in the browser. + * + * @param {object} params + * @param {string} params.accountId - 12-digit customer AWS account ID. + * @param {string} params.externalId - external ID baked into the connector role trust policy. + * @param {string} [params.roleName] - connector role name (defaults to the standard name). + * @param {string} [params.region] - STS region. + * @returns {Promise<{roleArn: string, accountId: string, credentials: object}>} + */ +export async function assumeConnectorRole({ + accountId, + externalId, + roleName = EDGE_OPTIMIZE_DEFAULT_ROLE_NAME, + region = EDGE_OPTIMIZE_REGION, +}) { + if (!/^[0-9]{12}$/.test(String(accountId))) { + throw new Error('accountId must be a 12-digit AWS account ID'); + } + if (!hasText(externalId)) { + throw new Error('externalId is required'); + } + + const roleArn = `arn:aws:iam::${accountId}:role/${roleName}`; + const sts = new STSClient({ region }); + const response = await sts.send(new AssumeRoleCommand({ + RoleArn: roleArn, + RoleSessionName: SESSION_NAME, + ExternalId: externalId, + DurationSeconds: SESSION_DURATION_SECONDS, + })); + + const creds = response?.Credentials; + if (!creds?.AccessKeyId || !creds?.SecretAccessKey || !creds?.SessionToken) { + throw new Error('Failed to assume connector role: no credentials returned'); + } + + return { + roleArn, + accountId: String(accountId), + credentials: { + accessKeyId: creds.AccessKeyId, + secretAccessKey: creds.SecretAccessKey, + sessionToken: creds.SessionToken, + expiration: creds.Expiration, + }, + }; +} + +/** + * List the CloudFront distributions in the customer account using assumed-role credentials. + * + * @param {object} credentials - temporary credentials from {@link assumeConnectorRole}. + * @param {string} [region] - CloudFront control-plane region. + * @returns {Promise>} distributions projected to the fields the wizard needs. + */ +export async function listCloudFrontDistributions(credentials, region = EDGE_OPTIMIZE_REGION) { + const client = new CloudFrontClient({ region, credentials }); + const response = await client.send(new ListDistributionsCommand({})); + const items = response?.DistributionList?.Items || []; + return items.map((dist) => ({ + id: dist.Id, + domainName: dist.DomainName, + aliases: dist.Aliases?.Items || [], + status: dist.Status, + enabled: dist.Enabled === true, + comment: dist.Comment || '', + })); +} diff --git a/test/controllers/llmo/llmo.test.js b/test/controllers/llmo/llmo.test.js index f6ff316f2e..54ac17fc92 100644 --- a/test/controllers/llmo/llmo.test.js +++ b/test/controllers/llmo/llmo.test.js @@ -86,6 +86,8 @@ describe('LlmoController', () => { let llmoConfigSchemaStub; let triggerBrandProfileAgentStub; let updateModifiedByDetailsStub; + let assumeConnectorRoleStub; + let listCloudFrontDistributionsStub; let mockTokowakaClient; let readStrategyStub; let writeStrategyStub; @@ -194,6 +196,8 @@ describe('LlmoController', () => { this.timeout(120000); triggerBrandProfileAgentStub = sinon.stub().resolves('exec-123'); updateModifiedByDetailsStub = sinon.stub(); + assumeConnectorRoleStub = sinon.stub(); + listCloudFrontDistributionsStub = sinon.stub(); // Initialize mock TokowakaClient mockTokowakaClient = { @@ -262,6 +266,10 @@ describe('LlmoController', () => { getImsTokenFromPromiseToken: (...args) => getImsTokenFromPromiseTokenStub(...args), authorizeEdgeCdnRouting: (...args) => authorizeEdgeCdnRoutingStub(...args), }, + '../../../src/support/edge-optimize.js': { + assumeConnectorRole: (...args) => assumeConnectorRoleStub(...args), + listCloudFrontDistributions: (...args) => listCloudFrontDistributionsStub(...args), + }, '@adobe/spacecat-shared-ims-client': { ImsClient: function MockImsClient() { this.getServicePrincipalAccessToken = (...args) => ( @@ -7622,6 +7630,178 @@ describe('LlmoController', () => { }); }); + describe('connectEdgeOptimize', () => { + let connectContext; + + beforeEach(() => { + assumeConnectorRoleStub = sinon.stub().resolves({ + roleArn: 'arn:aws:iam::120569600543:role/AdobeLLMOptimizerCloudFrontConnectorRole', + accountId: '120569600543', + credentials: { accessKeyId: 'AKIA', secretAccessKey: 'secret', sessionToken: 'token' }, + }); + connectContext = { + ...mockContext, + params: { siteId: TEST_SITE_ID }, + data: { accountId: '120569600543', externalId: '7ff9518a-cf59-40b4-aa53-68a3cb2e24a5' }, + env: {}, + }; + }); + + it('returns connected true when the connector role is assumable', async () => { + const result = await controller.connectEdgeOptimize(connectContext); + + expect(result.status).to.equal(200); + const body = await result.json(); + expect(body.connected).to.equal(true); + expect(body.accountId).to.equal('120569600543'); + expect(body.roleArn).to.include('AdobeLLMOptimizerCloudFrontConnectorRole'); + expect(assumeConnectorRoleStub.calledOnce).to.equal(true); + }); + + it('returns connected false (not erroring) when the role is not yet assumable', async () => { + assumeConnectorRoleStub = sinon.stub().rejects(new Error('AccessDenied: not authorized to assume')); + + const result = await controller.connectEdgeOptimize(connectContext); + + expect(result.status).to.equal(200); + const body = await result.json(); + expect(body.connected).to.equal(false); + expect(body.reason).to.include('AccessDenied'); + }); + + it('returns 400 for an invalid account id', async () => { + const result = await controller.connectEdgeOptimize({ ...connectContext, data: { accountId: '123', externalId: 'ext' } }); + + expect(result.status).to.equal(400); + const body = await result.json(); + expect(body.message).to.include('12-digit'); + }); + + it('returns 400 when the external id is missing', async () => { + const result = await controller.connectEdgeOptimize({ ...connectContext, data: { accountId: '120569600543' } }); + + expect(result.status).to.equal(400); + const body = await result.json(); + expect(body.message).to.include('externalId'); + }); + + it('returns 404 when the site is not found', async () => { + mockDataAccess.Site.findById.resolves(null); + + const result = await controller.connectEdgeOptimize(connectContext); + + expect(result.status).to.equal(404); + }); + + it('returns 403 when the user lacks access to the site', async () => { + const deniedController = controllerWithAccessDenied(mockContext); + + const result = await deniedController.connectEdgeOptimize(connectContext); + + expect(result.status).to.equal(403); + }); + + it('returns 403 when the user is not an LLMO administrator', async () => { + const LlmoControllerNoAdmin = await esmock('../../../src/controllers/llmo/llmo.js', { + '../../../src/support/access-control-util.js': createMockAccessControlUtil(true, true, false), + '@adobe/spacecat-shared-http-utils': mockHttpUtils, + '../../../src/support/cached-response.js': mockCachedResponse, + ...getCommonMocks(), + }); + + const result = await LlmoControllerNoAdmin(mockContext).connectEdgeOptimize(connectContext); + + expect(result.status).to.equal(403); + }); + + it('returns 400 when an unexpected error occurs', async () => { + mockDataAccess.Site.findById.rejects(new Error('db boom')); + + const result = await controller.connectEdgeOptimize(connectContext); + + expect(result.status).to.equal(400); + const body = await result.json(); + expect(body.message).to.include('db boom'); + }); + }); + + describe('getEdgeOptimizeDistributions', () => { + let distributionsContext; + + beforeEach(() => { + assumeConnectorRoleStub = sinon.stub().resolves({ + roleArn: 'arn:aws:iam::120569600543:role/AdobeLLMOptimizerCloudFrontConnectorRole', + accountId: '120569600543', + credentials: { accessKeyId: 'AKIA', secretAccessKey: 'secret', sessionToken: 'token' }, + }); + listCloudFrontDistributionsStub = sinon.stub().resolves([ + { + id: 'E2EXAMPLE123', + domainName: 'd111111abcdef8.cloudfront.net', + aliases: ['www.example.com'], + status: 'Deployed', + enabled: true, + comment: '', + }, + ]); + distributionsContext = { + ...mockContext, + params: { siteId: TEST_SITE_ID }, + data: { accountId: '120569600543', externalId: '7ff9518a-cf59-40b4-aa53-68a3cb2e24a5' }, + env: {}, + }; + }); + + it('returns the list of CloudFront distributions', async () => { + const result = await controller.getEdgeOptimizeDistributions(distributionsContext); + + expect(result.status).to.equal(200); + const body = await result.json(); + expect(body.distributions).to.have.length(1); + expect(body.distributions[0].id).to.equal('E2EXAMPLE123'); + expect(assumeConnectorRoleStub.calledOnce).to.equal(true); + expect(listCloudFrontDistributionsStub.calledOnce).to.equal(true); + }); + + it('returns 400 for an invalid account id', async () => { + const result = await controller.getEdgeOptimizeDistributions({ ...distributionsContext, data: { accountId: '123', externalId: 'ext' } }); + + expect(result.status).to.equal(400); + }); + + it('returns 400 when the external id is missing', async () => { + const result = await controller.getEdgeOptimizeDistributions({ ...distributionsContext, data: { accountId: '120569600543' } }); + + expect(result.status).to.equal(400); + }); + + it('returns 400 when the AWS call fails', async () => { + listCloudFrontDistributionsStub = sinon.stub().rejects(new Error('ListDistributions failed')); + + const result = await controller.getEdgeOptimizeDistributions(distributionsContext); + + expect(result.status).to.equal(400); + const body = await result.json(); + expect(body.message).to.include('ListDistributions failed'); + }); + + it('returns 404 when the site is not found', async () => { + mockDataAccess.Site.findById.resolves(null); + + const result = await controller.getEdgeOptimizeDistributions(distributionsContext); + + expect(result.status).to.equal(404); + }); + + it('returns 403 when the user lacks access to the site', async () => { + const deniedController = controllerWithAccessDenied(mockContext); + + const result = await deniedController.getEdgeOptimizeDistributions(distributionsContext); + + expect(result.status).to.equal(403); + }); + }); + describe('getStrategy', () => { const mockStrategyData = { opportunities: [ diff --git a/test/routes/index.test.js b/test/routes/index.test.js index eabb70caf5..1c3579579a 100755 --- a/test/routes/index.test.js +++ b/test/routes/index.test.js @@ -327,6 +327,8 @@ describe('getRouteHandlers', () => { getLlmoRationale: () => null, getBrandClaims: () => null, createOrUpdateEdgeConfig: () => null, + connectEdgeOptimize: () => null, + getEdgeOptimizeDistributions: () => null, getEdgeConfig: () => null, createOrUpdateStageEdgeConfig: () => null, checkEdgeOptimizeStatus: () => null, @@ -1062,6 +1064,8 @@ describe('getRouteHandlers', () => { 'GET /sites/:siteId/llmo/edge-optimize-config', 'POST /sites/:siteId/llmo/edge-optimize-config/stage', 'POST /sites/:siteId/llmo/edge-optimize-bootstrap-url', + 'POST /sites/:siteId/llmo/edge-optimize/connect', + 'POST /sites/:siteId/llmo/edge-optimize/distributions', 'GET /sites/:siteId/llmo/edge-optimize-status', 'GET /sites/:siteId/llmo/probes/edge-optimize', 'GET /sites/:siteId/llmo/strategy', diff --git a/test/support/edge-optimize.test.js b/test/support/edge-optimize.test.js new file mode 100644 index 0000000000..6dcbf5ca55 --- /dev/null +++ b/test/support/edge-optimize.test.js @@ -0,0 +1,181 @@ +/* + * 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 { expect } from 'chai'; +import sinon from 'sinon'; +import esmock from 'esmock'; + +describe('edge-optimize support', () => { + let stsSendStub; + let cfSendStub; + let edgeOptimize; + + beforeEach(async function setup() { + this.timeout(30000); + stsSendStub = sinon.stub(); + cfSendStub = sinon.stub(); + edgeOptimize = await esmock('../../src/support/edge-optimize.js', { + '@aws-sdk/client-sts': { + STSClient: function STSClient() { + this.send = (cmd) => stsSendStub(cmd); + }, + AssumeRoleCommand: function AssumeRoleCommand(input) { + this.input = input; + }, + }, + '@aws-sdk/client-cloudfront': { + CloudFrontClient: function CloudFrontClient(config) { + this.config = config; + this.send = (cmd) => cfSendStub(cmd); + }, + ListDistributionsCommand: function ListDistributionsCommand(input) { + this.input = input; + }, + }, + }); + }); + + afterEach(() => { + sinon.restore(); + }); + + describe('assumeConnectorRole', () => { + it('assumes the role and returns mapped credentials', async () => { + stsSendStub.resolves({ + Credentials: { + AccessKeyId: 'AKIA', + SecretAccessKey: 'secret', + SessionToken: 'token', + Expiration: new Date('2030-01-01T00:00:00Z'), + }, + }); + + const result = await edgeOptimize.assumeConnectorRole({ + accountId: '120569600543', + externalId: 'ext-123', + }); + + expect(result.roleArn).to.equal('arn:aws:iam::120569600543:role/AdobeLLMOptimizerCloudFrontConnectorRole'); + expect(result.accountId).to.equal('120569600543'); + expect(result.credentials.accessKeyId).to.equal('AKIA'); + expect(result.credentials.secretAccessKey).to.equal('secret'); + expect(result.credentials.sessionToken).to.equal('token'); + expect(stsSendStub.calledOnce).to.equal(true); + }); + + it('uses a custom role name when provided', async () => { + stsSendStub.resolves({ + Credentials: { AccessKeyId: 'A', SecretAccessKey: 'S', SessionToken: 'T' }, + }); + + const result = await edgeOptimize.assumeConnectorRole({ + accountId: '120569600543', + externalId: 'ext', + roleName: 'CustomRole', + }); + + expect(result.roleArn).to.equal('arn:aws:iam::120569600543:role/CustomRole'); + }); + + it('throws for an invalid account id', async () => { + let error; + try { + await edgeOptimize.assumeConnectorRole({ accountId: '123', externalId: 'ext' }); + } catch (e) { + error = e; + } + expect(error).to.be.an('error'); + expect(error.message).to.include('12-digit'); + expect(stsSendStub.called).to.equal(false); + }); + + it('throws when the external id is missing', async () => { + let error; + try { + await edgeOptimize.assumeConnectorRole({ accountId: '120569600543', externalId: '' }); + } catch (e) { + error = e; + } + expect(error).to.be.an('error'); + expect(error.message).to.include('externalId'); + }); + + it('throws when STS returns no credentials', async () => { + stsSendStub.resolves({}); + let error; + try { + await edgeOptimize.assumeConnectorRole({ accountId: '120569600543', externalId: 'ext' }); + } catch (e) { + error = e; + } + expect(error).to.be.an('error'); + expect(error.message).to.include('no credentials'); + }); + }); + + describe('listCloudFrontDistributions', () => { + it('maps the distribution list to the wizard projection', async () => { + cfSendStub.resolves({ + DistributionList: { + Items: [ + { + Id: 'E123', + DomainName: 'd.cloudfront.net', + Aliases: { Items: ['www.example.com'] }, + Status: 'Deployed', + Enabled: true, + Comment: 'prod', + }, + ], + }, + }); + + const result = await edgeOptimize.listCloudFrontDistributions({ + accessKeyId: 'A', secretAccessKey: 'S', sessionToken: 'T', + }); + + expect(result).to.have.length(1); + expect(result[0]).to.deep.equal({ + id: 'E123', + domainName: 'd.cloudfront.net', + aliases: ['www.example.com'], + status: 'Deployed', + enabled: true, + comment: 'prod', + }); + }); + + it('returns an empty array when there are no distributions', async () => { + cfSendStub.resolves({ DistributionList: {} }); + + const result = await edgeOptimize.listCloudFrontDistributions({}); + + expect(result).to.deep.equal([]); + }); + + it('defaults aliases and comment when absent and reflects disabled state', async () => { + cfSendStub.resolves({ + DistributionList: { + Items: [{ + Id: 'E2', DomainName: 'd2.cloudfront.net', Status: 'InProgress', Enabled: false, + }], + }, + }); + + const result = await edgeOptimize.listCloudFrontDistributions({}); + + expect(result[0].aliases).to.deep.equal([]); + expect(result[0].comment).to.equal(''); + expect(result[0].enabled).to.equal(false); + }); + }); +}); From d58cd4d822058cea5fb03feefae1b0f51b0c65d6 Mon Sep 17 00:00:00 2001 From: Akash Bhardwaj Date: Sat, 20 Jun 2026 08:50:28 +0530 Subject: [PATCH 09/56] docs(llmo): OpenAPI for CloudFront wizard edge-optimize endpoints Document the already-shipped connect and distributions endpoints plus the new read-only prerequisites, origins, and behaviors endpoints for the CloudFront "Deploy routing" wizard. Adds shared connector/distribution request schemas and per-endpoint response definitions. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/openapi/api.yaml | 10 ++ docs/openapi/llmo-api.yaml | 351 +++++++++++++++++++++++++++++++++++++ 2 files changed, 361 insertions(+) diff --git a/docs/openapi/api.yaml b/docs/openapi/api.yaml index 4d4f5997e4..b51d813118 100644 --- a/docs/openapi/api.yaml +++ b/docs/openapi/api.yaml @@ -625,6 +625,16 @@ paths: $ref: './llmo-api.yaml#/site-llmo-edge-optimize-config-stage' /sites/{siteId}/llmo/edge-optimize-bootstrap-url: $ref: './llmo-api.yaml#/site-llmo-edge-optimize-bootstrap-url' + /sites/{siteId}/llmo/edge-optimize/connect: + $ref: './llmo-api.yaml#/site-llmo-edge-optimize-connect' + /sites/{siteId}/llmo/edge-optimize/distributions: + $ref: './llmo-api.yaml#/site-llmo-edge-optimize-distributions' + /sites/{siteId}/llmo/edge-optimize/prerequisites: + $ref: './llmo-api.yaml#/site-llmo-edge-optimize-prerequisites' + /sites/{siteId}/llmo/edge-optimize/origins: + $ref: './llmo-api.yaml#/site-llmo-edge-optimize-origins' + /sites/{siteId}/llmo/edge-optimize/behaviors: + $ref: './llmo-api.yaml#/site-llmo-edge-optimize-behaviors' /sites/{siteId}/llmo/edge-optimize-status: $ref: './llmo-api.yaml#/llmo-edge-optimize-status' /sites/{siteId}/llmo/probes/edge-optimize: diff --git a/docs/openapi/llmo-api.yaml b/docs/openapi/llmo-api.yaml index 877bd3a195..6465412fdd 100644 --- a/docs/openapi/llmo-api.yaml +++ b/docs/openapi/llmo-api.yaml @@ -2409,6 +2409,357 @@ site-llmo-edge-optimize-bootstrap-url: security: - api_key: [ ] +site-llmo-edge-optimize-connect: + post: + tags: + - llmo + summary: Verify the Edge Optimize cross-account connector role is assumable + description: | + Used by the CloudFront "Deploy routing" wizard's "Allow access" step. Attempts to assume + the customer's cross-account connector role (created via the CloudFormation bootstrap) using + the per-session external ID. Returns `connected: true` when the role is assumable; otherwise + returns `connected: false` with a `reason` so the wizard can keep polling while the customer + finishes creating the role. + operationId: connectEdgeOptimize + parameters: + - $ref: './parameters.yaml#/siteId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/edge-optimize-connector-request' + responses: + '200': + description: Connection probe result (inspect the `connected` field). + content: + application/json: + schema: + type: object + required: + - connected + properties: + connected: + type: boolean + description: Whether the connector role was assumable. + accountId: + type: string + description: The 12-digit AWS account ID (present when connected). + roleArn: + type: string + description: The connector role ARN (present when connected). + reason: + type: string + description: Why the role was not assumable (present when not connected). + example: + connected: true + accountId: '682033462621' + roleArn: 'arn:aws:iam::682033462621:role/AdobeLLMOptimizerCloudFrontConnectorRole' + '400': + $ref: './responses.yaml#/400' + '401': + $ref: './responses.yaml#/401' + '403': + $ref: './responses.yaml#/403' + '404': + $ref: './responses.yaml#/404' + '500': + $ref: './responses.yaml#/500' + security: + - api_key: [ ] + +site-llmo-edge-optimize-distributions: + post: + tags: + - llmo + summary: List the customer's CloudFront distributions + description: | + Used by the CloudFront "Deploy routing" wizard's "Choose distribution" step. Assumes the + cross-account connector role and lists the customer's CloudFront distributions (read-only) + so the wizard can let the customer pick one to configure. + operationId: getEdgeOptimizeDistributions + parameters: + - $ref: './parameters.yaml#/siteId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/edge-optimize-connector-request' + responses: + '200': + description: CloudFront distributions in the customer account. + content: + application/json: + schema: + type: object + required: + - distributions + properties: + distributions: + type: array + items: + type: object + properties: + id: + type: string + domainName: + type: string + aliases: + type: array + items: + type: string + status: + type: string + enabled: + type: boolean + comment: + type: string + example: + distributions: + - id: E2EXAMPLE123 + domainName: d111111abcdef8.cloudfront.net + aliases: + - www.example.com + status: Deployed + enabled: true + comment: prod distribution + '400': + $ref: './responses.yaml#/400' + '401': + $ref: './responses.yaml#/401' + '403': + $ref: './responses.yaml#/403' + '404': + $ref: './responses.yaml#/404' + '500': + $ref: './responses.yaml#/500' + security: + - api_key: [ ] + +site-llmo-edge-optimize-prerequisites: + post: + tags: + - llmo + summary: Run the Edge Optimize wizard pre-flight checks + description: | + Used by the CloudFront "Deploy routing" wizard to confirm the connector role is assumable + and grants CloudFront read access. Each check reports its `ok` status individually (with a + `detail` message on failure) so the wizard can show a per-check status rather than failing + the whole step. Always returns HTTP 200 — inspect each check's `ok` field. + operationId: checkEdgeOptimizePrerequisites + parameters: + - $ref: './parameters.yaml#/siteId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/edge-optimize-connector-request' + responses: + '200': + description: Prerequisite check results (inspect each check's `ok` field). + content: + application/json: + schema: + type: object + required: + - checks + properties: + checks: + type: array + items: + type: object + required: + - name + - ok + properties: + name: + type: string + ok: + type: boolean + detail: + type: string + description: Failure detail (present when `ok` is false). + example: + checks: + - name: connectorRole + ok: true + - name: cloudFrontRead + ok: true + '400': + $ref: './responses.yaml#/400' + '401': + $ref: './responses.yaml#/401' + '403': + $ref: './responses.yaml#/403' + '404': + $ref: './responses.yaml#/404' + '500': + $ref: './responses.yaml#/500' + security: + - api_key: [ ] + +site-llmo-edge-optimize-origins: + post: + tags: + - llmo + summary: Read the origins configured on a CloudFront distribution + description: | + Used by the CloudFront "Deploy routing" wizard's "Review origins" step. Assumes the connector + role, reads the distribution config (read-only), and returns its origins plus a flag + indicating whether an Edge Optimize origin already exists. + operationId: getEdgeOptimizeOrigins + parameters: + - $ref: './parameters.yaml#/siteId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/edge-optimize-distribution-request' + responses: + '200': + description: The distribution's origins. + content: + application/json: + schema: + type: object + required: + - origins + - hasEdgeOptimizeOrigin + properties: + origins: + type: array + items: + type: object + properties: + id: + type: string + domainName: + type: string + originPath: + type: string + hasEdgeOptimizeOrigin: + type: boolean + description: Whether an Edge Optimize origin is already configured. + example: + origins: + - id: origin-aem + domainName: origin.example.com + originPath: /content + hasEdgeOptimizeOrigin: false + '400': + $ref: './responses.yaml#/400' + '401': + $ref: './responses.yaml#/401' + '403': + $ref: './responses.yaml#/403' + '404': + $ref: './responses.yaml#/404' + '500': + $ref: './responses.yaml#/500' + security: + - api_key: [ ] + +site-llmo-edge-optimize-behaviors: + post: + tags: + - llmo + summary: Read the cache behaviors configured on a CloudFront distribution + description: | + Used by the CloudFront "Deploy routing" wizard's "Review routing" step. Assumes the connector + role, reads the distribution config (read-only), and returns the default cache behavior + followed by each ordered cache behavior so the wizard can show how traffic is currently + routed. + operationId: getEdgeOptimizeBehaviors + parameters: + - $ref: './parameters.yaml#/siteId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/edge-optimize-distribution-request' + responses: + '200': + description: The distribution's cache behaviors. + content: + application/json: + schema: + type: object + required: + - behaviors + properties: + behaviors: + type: array + items: + type: object + properties: + pathPattern: + type: string + targetOriginId: + type: string + isDefault: + type: boolean + example: + behaviors: + - pathPattern: Default (*) + targetOriginId: origin-aem + isDefault: true + - pathPattern: /api/* + targetOriginId: origin-api + isDefault: false + '400': + $ref: './responses.yaml#/400' + '401': + $ref: './responses.yaml#/401' + '403': + $ref: './responses.yaml#/403' + '404': + $ref: './responses.yaml#/404' + '500': + $ref: './responses.yaml#/500' + security: + - api_key: [ ] + +edge-optimize-connector-request: + type: object + required: + - accountId + - externalId + properties: + accountId: + type: string + description: The 12-digit AWS account ID hosting the customer's CloudFront distribution. + pattern: '^[0-9]{12}$' + example: '682033462621' + externalId: + type: string + description: Per-session external ID baked into the connector role's trust policy. + example: '7ff9518a-cf59-40b4-aa53-68a3cb2e24a5' + +edge-optimize-distribution-request: + type: object + required: + - accountId + - externalId + - distributionId + properties: + accountId: + type: string + description: The 12-digit AWS account ID hosting the customer's CloudFront distribution. + pattern: '^[0-9]{12}$' + example: '682033462621' + externalId: + type: string + description: Per-session external ID baked into the connector role's trust policy. + example: '7ff9518a-cf59-40b4-aa53-68a3cb2e24a5' + distributionId: + type: string + description: The CloudFront distribution ID to inspect. + example: E2EXAMPLE123 + llmo-probe-edge-optimize: get: tags: From 9a4f0495fdc1dfd69f822ba29eafeaa6976554f9 Mon Sep 17 00:00:00 2001 From: Akash Bhardwaj Date: Sat, 20 Jun 2026 08:50:45 +0530 Subject: [PATCH 10/56] feat(llmo): CloudFront wizard prerequisites, origins, behaviors endpoints (Phase 2) Add three read-only CloudFront wizard endpoints, mirroring the existing connect/distributions handlers (12-digit accountId + externalId validation, site/access/LLMO-admin gate, assumed-role calls, badRequest on failure): - POST /sites/:siteId/llmo/edge-optimize/prerequisites -> checkEdgeOptimizePrerequisites reports connectorRole + cloudFrontRead checks (ok/false + detail, never 500) - POST /sites/:siteId/llmo/edge-optimize/origins -> getEdgeOptimizeOrigins returns origins + hasEdgeOptimizeOrigin detection - POST /sites/:siteId/llmo/edge-optimize/behaviors -> getEdgeOptimizeBehaviors returns default + ordered cache behaviors Adds getDistributionConfig() support fn (GetDistributionConfigCommand) and unit tests for the support fn, controller handlers, and route registration. All three routes added to INTERNAL_ROUTES (admin/IMS-only, not S2S). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/controllers/llmo/llmo.js | 142 ++++++++++- src/routes/index.js | 3 + src/routes/required-capabilities.js | 3 + src/support/edge-optimize.js | 50 +++- test/controllers/llmo/llmo.test.js | 358 ++++++++++++++++++++++++++++ test/routes/index.test.js | 6 + test/support/edge-optimize.test.js | 62 +++++ 7 files changed, 622 insertions(+), 2 deletions(-) diff --git a/src/controllers/llmo/llmo.js b/src/controllers/llmo/llmo.js index 5c157407ae..bafc9b4636 100644 --- a/src/controllers/llmo/llmo.js +++ b/src/controllers/llmo/llmo.js @@ -33,7 +33,11 @@ import { Entitlement as EntitlementModel } from '@adobe/spacecat-shared-data-acc import TokowakaClient, { calculateForwardedHost } from '@adobe/spacecat-shared-tokowaka-client'; import { ImsClient } from '@adobe/spacecat-shared-ims-client'; import AccessControlUtil from '../../support/access-control-util.js'; -import { assumeConnectorRole, listCloudFrontDistributions } from '../../support/edge-optimize.js'; +import { + assumeConnectorRole, + listCloudFrontDistributions, + getDistributionConfig, +} from '../../support/edge-optimize.js'; import { UnauthorizedProductError } from '../../support/errors.js'; import { cachedOk } from '../../support/cached-response.js'; import { @@ -2249,10 +2253,146 @@ function LlmoController(ctx) { } }; + // Run the wizard's pre-flight checks: confirm the connector role is assumable and that it grants + // CloudFront read access. Each check reports ok/false individually so the wizard can show a + // per-check status rather than failing the whole step on a single problem. + const checkEdgeOptimizePrerequisites = async (context) => { + const { log, dataAccess, env } = context; + const { siteId } = context.params; + const { Site } = dataAccess; + const accountId = String(context.data?.accountId || '').replace(/\D/g, ''); + const externalId = String(context.data?.externalId || '').trim(); + const roleName = env?.EDGE_OPTIMIZE_ROLE_NAME || undefined; + + if (accountId.length !== 12) { + return badRequest('accountId must be a 12-digit AWS account ID'); + } + if (!hasText(externalId)) { + return badRequest('externalId is required'); + } + + try { + const { error } = await gateEdgeOptimizeWizard(siteId, Site, 'check edge optimize prerequisites'); + if (error) { + return error; + } + + const connectorRoleCheck = { name: 'connectorRole', ok: true }; + const cloudFrontReadCheck = { name: 'cloudFrontRead', ok: true }; + + try { + const { credentials } = await assumeConnectorRole({ accountId, externalId, roleName }); + try { + await listCloudFrontDistributions(credentials); + } catch (listError) { + cloudFrontReadCheck.ok = false; + cloudFrontReadCheck.detail = cleanupHeaderValue(listError.message); + } + } catch (assumeError) { + connectorRoleCheck.ok = false; + connectorRoleCheck.detail = cleanupHeaderValue(assumeError.message); + // Can't read CloudFront without the role, so mark it failed too. + cloudFrontReadCheck.ok = false; + cloudFrontReadCheck.detail = 'connector role not assumable'; + } + + // TODO: also validate the Edge Optimize API key here (was part of the standalone wizard). + return ok({ checks: [connectorRoleCheck, cloudFrontReadCheck] }); + } catch (error) { + log.error(`Failed to check edge optimize prerequisites for site ${siteId}:`, error); + return badRequest(cleanupHeaderValue(error.message)); + } + }; + + // Read the origins configured on a customer's CloudFront distribution so the wizard's + // "Review origins" step can show them and flag whether an Edge Optimize origin already exists. + const getEdgeOptimizeOrigins = async (context) => { + const { log, dataAccess, env } = context; + const { siteId } = context.params; + const { Site } = dataAccess; + const accountId = String(context.data?.accountId || '').replace(/\D/g, ''); + const externalId = String(context.data?.externalId || '').trim(); + const distributionId = String(context.data?.distributionId || '').trim(); + const roleName = env?.EDGE_OPTIMIZE_ROLE_NAME || undefined; + + if (accountId.length !== 12) { + return badRequest('accountId must be a 12-digit AWS account ID'); + } + if (!hasText(externalId)) { + return badRequest('externalId is required'); + } + if (!hasText(distributionId)) { + return badRequest('distributionId is required'); + } + + try { + const { error } = await gateEdgeOptimizeWizard(siteId, Site, 'read CloudFront origins'); + if (error) { + return error; + } + + const { credentials } = await assumeConnectorRole({ accountId, externalId, roleName }); + const { origins } = await getDistributionConfig(credentials, distributionId); + const hasEdgeOptimizeOrigin = origins.some((origin) => /edgeoptimize/i.test(origin.id) + || /edgeoptimize/i.test(origin.domainName || '')); + return ok({ origins, hasEdgeOptimizeOrigin }); + } catch (error) { + log.error(`Failed to read CloudFront origins for site ${siteId}:`, error); + return badRequest(cleanupHeaderValue(error.message)); + } + }; + + // Read the cache behaviors (default + ordered) configured on a customer's CloudFront + // distribution so the wizard's "Review routing" step can show how traffic is currently routed. + const getEdgeOptimizeBehaviors = async (context) => { + const { log, dataAccess, env } = context; + const { siteId } = context.params; + const { Site } = dataAccess; + const accountId = String(context.data?.accountId || '').replace(/\D/g, ''); + const externalId = String(context.data?.externalId || '').trim(); + const distributionId = String(context.data?.distributionId || '').trim(); + const roleName = env?.EDGE_OPTIMIZE_ROLE_NAME || undefined; + + if (accountId.length !== 12) { + return badRequest('accountId must be a 12-digit AWS account ID'); + } + if (!hasText(externalId)) { + return badRequest('externalId is required'); + } + if (!hasText(distributionId)) { + return badRequest('distributionId is required'); + } + + try { + const { error } = await gateEdgeOptimizeWizard(siteId, Site, 'read CloudFront behaviors'); + if (error) { + return error; + } + + const { credentials } = await assumeConnectorRole({ accountId, externalId, roleName }); + const { defaultCacheBehavior, cacheBehaviors } = await getDistributionConfig( + credentials, + distributionId, + ); + const behaviors = []; + if (defaultCacheBehavior) { + behaviors.push({ ...defaultCacheBehavior, isDefault: true }); + } + cacheBehaviors.forEach((behavior) => behaviors.push({ ...behavior, isDefault: false })); + return ok({ behaviors }); + } catch (error) { + log.error(`Failed to read CloudFront behaviors for site ${siteId}:`, error); + return badRequest(cleanupHeaderValue(error.message)); + } + }; + return { getEdgeOptimizeBootstrapUrl, connectEdgeOptimize, getEdgeOptimizeDistributions, + checkEdgeOptimizePrerequisites, + getEdgeOptimizeOrigins, + getEdgeOptimizeBehaviors, getLlmoSheetData, queryLlmoSheetData, getLlmoGlobalSheetData, diff --git a/src/routes/index.js b/src/routes/index.js index 03bbb0a3f4..53ee361b15 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -483,6 +483,9 @@ export default function getRouteHandlers( 'POST /sites/:siteId/llmo/edge-optimize-bootstrap-url': llmoController.getEdgeOptimizeBootstrapUrl, 'POST /sites/:siteId/llmo/edge-optimize/connect': llmoController.connectEdgeOptimize, 'POST /sites/:siteId/llmo/edge-optimize/distributions': llmoController.getEdgeOptimizeDistributions, + 'POST /sites/:siteId/llmo/edge-optimize/prerequisites': llmoController.checkEdgeOptimizePrerequisites, + 'POST /sites/:siteId/llmo/edge-optimize/origins': llmoController.getEdgeOptimizeOrigins, + 'POST /sites/:siteId/llmo/edge-optimize/behaviors': llmoController.getEdgeOptimizeBehaviors, 'GET /sites/:siteId/llmo/strategy': llmoController.getStrategy, 'PUT /sites/:siteId/llmo/strategy': llmoController.saveStrategy, 'GET /sites/:siteId/llmo/edge-optimize-status': llmoController.checkEdgeOptimizeStatus, diff --git a/src/routes/required-capabilities.js b/src/routes/required-capabilities.js index ef7572de15..75ff5cb37a 100644 --- a/src/routes/required-capabilities.js +++ b/src/routes/required-capabilities.js @@ -130,6 +130,9 @@ export const INTERNAL_ROUTES = [ 'POST /sites/:siteId/llmo/edge-optimize-bootstrap-url', 'POST /sites/:siteId/llmo/edge-optimize/connect', 'POST /sites/:siteId/llmo/edge-optimize/distributions', + 'POST /sites/:siteId/llmo/edge-optimize/prerequisites', + 'POST /sites/:siteId/llmo/edge-optimize/origins', + 'POST /sites/:siteId/llmo/edge-optimize/behaviors', 'PUT /sites/:siteId/llmo/opportunities-reviewed', // PLG onboarding - IMS token auth, self-service flow, not S2S diff --git a/src/support/edge-optimize.js b/src/support/edge-optimize.js index 6507ab5aa1..cd6ee7a69d 100644 --- a/src/support/edge-optimize.js +++ b/src/support/edge-optimize.js @@ -11,7 +11,11 @@ */ import { STSClient, AssumeRoleCommand } from '@aws-sdk/client-sts'; -import { CloudFrontClient, ListDistributionsCommand } from '@aws-sdk/client-cloudfront'; +import { + CloudFrontClient, + ListDistributionsCommand, + GetDistributionConfigCommand, +} from '@aws-sdk/client-cloudfront'; import { hasText } from '@adobe/spacecat-shared-utils'; // CloudFront is a global service; its control plane lives in us-east-1. @@ -94,3 +98,47 @@ export async function listCloudFrontDistributions(credentials, region = EDGE_OPT comment: dist.Comment || '', })); } + +/** + * Fetch a single CloudFront distribution's configuration using assumed-role credentials. + * + * Returns the parsed origins, default cache behavior, and ordered cache behaviors projected to + * the fields the wizard needs to inspect routing. Read-only — uses GetDistributionConfig. + * + * @param {object} credentials - temporary credentials from {@link assumeConnectorRole}. + * @param {string} distributionId - the CloudFront distribution ID. + * @param {string} [region] - CloudFront control-plane region. + * @returns {Promise<{origins: Array, defaultCacheBehavior: object|null, + * cacheBehaviors: Array}>} + */ +export async function getDistributionConfig( + credentials, + distributionId, + region = EDGE_OPTIMIZE_REGION, +) { + if (!hasText(distributionId)) { + throw new Error('distributionId is required'); + } + const client = new CloudFrontClient({ region, credentials }); + const response = await client.send(new GetDistributionConfigCommand({ Id: distributionId })); + const config = response?.DistributionConfig || {}; + + const origins = (config.Origins?.Items || []).map((origin) => ({ + id: origin.Id, + domainName: origin.DomainName, + originPath: origin.OriginPath || '', + })); + + const mapBehavior = (behavior) => ({ + pathPattern: behavior.PathPattern, + targetOriginId: behavior.TargetOriginId, + }); + + const defaultCacheBehavior = config.DefaultCacheBehavior + ? mapBehavior({ ...config.DefaultCacheBehavior, PathPattern: 'Default (*)' }) + : null; + + const cacheBehaviors = (config.CacheBehaviors?.Items || []).map(mapBehavior); + + return { origins, defaultCacheBehavior, cacheBehaviors }; +} diff --git a/test/controllers/llmo/llmo.test.js b/test/controllers/llmo/llmo.test.js index 54ac17fc92..cfe9c47c63 100644 --- a/test/controllers/llmo/llmo.test.js +++ b/test/controllers/llmo/llmo.test.js @@ -88,6 +88,7 @@ describe('LlmoController', () => { let updateModifiedByDetailsStub; let assumeConnectorRoleStub; let listCloudFrontDistributionsStub; + let getDistributionConfigStub; let mockTokowakaClient; let readStrategyStub; let writeStrategyStub; @@ -198,6 +199,7 @@ describe('LlmoController', () => { updateModifiedByDetailsStub = sinon.stub(); assumeConnectorRoleStub = sinon.stub(); listCloudFrontDistributionsStub = sinon.stub(); + getDistributionConfigStub = sinon.stub(); // Initialize mock TokowakaClient mockTokowakaClient = { @@ -269,6 +271,7 @@ describe('LlmoController', () => { '../../../src/support/edge-optimize.js': { assumeConnectorRole: (...args) => assumeConnectorRoleStub(...args), listCloudFrontDistributions: (...args) => listCloudFrontDistributionsStub(...args), + getDistributionConfig: (...args) => getDistributionConfigStub(...args), }, '@adobe/spacecat-shared-ims-client': { ImsClient: function MockImsClient() { @@ -7802,6 +7805,361 @@ describe('LlmoController', () => { }); }); + describe('checkEdgeOptimizePrerequisites', () => { + let prereqContext; + + beforeEach(() => { + assumeConnectorRoleStub = sinon.stub().resolves({ + roleArn: 'arn:aws:iam::120569600543:role/AdobeLLMOptimizerCloudFrontConnectorRole', + accountId: '120569600543', + credentials: { accessKeyId: 'AKIA', secretAccessKey: 'secret', sessionToken: 'token' }, + }); + listCloudFrontDistributionsStub = sinon.stub().resolves([]); + prereqContext = { + ...mockContext, + params: { siteId: TEST_SITE_ID }, + data: { accountId: '120569600543', externalId: '7ff9518a-cf59-40b4-aa53-68a3cb2e24a5' }, + env: {}, + }; + }); + + it('returns all checks ok when the role assumes and CloudFront is readable', async () => { + const result = await controller.checkEdgeOptimizePrerequisites(prereqContext); + + expect(result.status).to.equal(200); + const body = await result.json(); + expect(body.checks).to.deep.equal([ + { name: 'connectorRole', ok: true }, + { name: 'cloudFrontRead', ok: true }, + ]); + expect(assumeConnectorRoleStub.calledOnce).to.equal(true); + expect(listCloudFrontDistributionsStub.calledOnce).to.equal(true); + }); + + it('reports connectorRole false (not erroring) when the role is not assumable', async () => { + assumeConnectorRoleStub = sinon.stub().rejects(new Error('AccessDenied: cannot assume')); + + const result = await controller.checkEdgeOptimizePrerequisites(prereqContext); + + expect(result.status).to.equal(200); + const body = await result.json(); + expect(body.checks[0]).to.include({ name: 'connectorRole', ok: false }); + expect(body.checks[0].detail).to.include('AccessDenied'); + expect(body.checks[1]).to.include({ name: 'cloudFrontRead', ok: false }); + }); + + it('reports cloudFrontRead false (not erroring) when the list call fails', async () => { + listCloudFrontDistributionsStub = sinon.stub().rejects(new Error('AccessDenied: cloudfront:ListDistributions')); + + const result = await controller.checkEdgeOptimizePrerequisites(prereqContext); + + expect(result.status).to.equal(200); + const body = await result.json(); + expect(body.checks[0]).to.deep.equal({ name: 'connectorRole', ok: true }); + expect(body.checks[1]).to.include({ name: 'cloudFrontRead', ok: false }); + expect(body.checks[1].detail).to.include('ListDistributions'); + }); + + it('returns 400 for an invalid account id', async () => { + const result = await controller.checkEdgeOptimizePrerequisites({ ...prereqContext, data: { accountId: '123', externalId: 'ext' } }); + + expect(result.status).to.equal(400); + }); + + it('returns 400 when the external id is missing', async () => { + const result = await controller.checkEdgeOptimizePrerequisites({ ...prereqContext, data: { accountId: '120569600543' } }); + + expect(result.status).to.equal(400); + }); + + it('returns 404 when the site is not found', async () => { + mockDataAccess.Site.findById.resolves(null); + + const result = await controller.checkEdgeOptimizePrerequisites(prereqContext); + + expect(result.status).to.equal(404); + }); + + it('returns 403 when the user lacks access to the site', async () => { + const deniedController = controllerWithAccessDenied(mockContext); + + const result = await deniedController.checkEdgeOptimizePrerequisites(prereqContext); + + expect(result.status).to.equal(403); + }); + + it('returns 403 when the user is not an LLMO administrator', async () => { + const LlmoControllerNoAdmin = await esmock('../../../src/controllers/llmo/llmo.js', { + '../../../src/support/access-control-util.js': createMockAccessControlUtil(true, true, false), + '@adobe/spacecat-shared-http-utils': mockHttpUtils, + '../../../src/support/cached-response.js': mockCachedResponse, + ...getCommonMocks(), + }); + + const controllerNoAdmin = LlmoControllerNoAdmin(mockContext); + const result = await controllerNoAdmin.checkEdgeOptimizePrerequisites(prereqContext); + + expect(result.status).to.equal(403); + }); + + it('returns 400 when an unexpected error occurs', async () => { + mockDataAccess.Site.findById.rejects(new Error('db boom')); + + const result = await controller.checkEdgeOptimizePrerequisites(prereqContext); + + expect(result.status).to.equal(400); + const body = await result.json(); + expect(body.message).to.include('db boom'); + }); + }); + + describe('getEdgeOptimizeOrigins', () => { + let originsContext; + + beforeEach(() => { + assumeConnectorRoleStub = sinon.stub().resolves({ + roleArn: 'arn:aws:iam::120569600543:role/AdobeLLMOptimizerCloudFrontConnectorRole', + accountId: '120569600543', + credentials: { accessKeyId: 'AKIA', secretAccessKey: 'secret', sessionToken: 'token' }, + }); + getDistributionConfigStub = sinon.stub().resolves({ + origins: [ + { id: 'origin-aem', domainName: 'origin.example.com', originPath: '/content' }, + ], + defaultCacheBehavior: { pathPattern: 'Default (*)', targetOriginId: 'origin-aem' }, + cacheBehaviors: [], + }); + originsContext = { + ...mockContext, + params: { siteId: TEST_SITE_ID }, + data: { + accountId: '120569600543', + externalId: '7ff9518a-cf59-40b4-aa53-68a3cb2e24a5', + distributionId: 'E2EXAMPLE123', + }, + env: {}, + }; + }); + + it('returns the origins and hasEdgeOptimizeOrigin false when none match', async () => { + const result = await controller.getEdgeOptimizeOrigins(originsContext); + + expect(result.status).to.equal(200); + const body = await result.json(); + expect(body.origins).to.have.length(1); + expect(body.origins[0].id).to.equal('origin-aem'); + expect(body.hasEdgeOptimizeOrigin).to.equal(false); + expect(assumeConnectorRoleStub.calledOnce).to.equal(true); + expect(getDistributionConfigStub.calledOnceWith(sinon.match.any, 'E2EXAMPLE123')).to.equal(true); + }); + + it('detects an Edge Optimize origin by id', async () => { + getDistributionConfigStub = sinon.stub().resolves({ + origins: [ + { id: 'EdgeOptimizeOrigin', domainName: 'something.example.com', originPath: '' }, + ], + defaultCacheBehavior: null, + cacheBehaviors: [], + }); + + const result = await controller.getEdgeOptimizeOrigins(originsContext); + + const body = await result.json(); + expect(body.hasEdgeOptimizeOrigin).to.equal(true); + }); + + it('detects an Edge Optimize origin by domain', async () => { + getDistributionConfigStub = sinon.stub().resolves({ + origins: [ + { id: 'custom', domainName: 'live.edgeoptimize.net', originPath: '' }, + ], + defaultCacheBehavior: null, + cacheBehaviors: [], + }); + + const result = await controller.getEdgeOptimizeOrigins(originsContext); + + const body = await result.json(); + expect(body.hasEdgeOptimizeOrigin).to.equal(true); + }); + + it('returns 400 for an invalid account id', async () => { + const result = await controller.getEdgeOptimizeOrigins({ ...originsContext, data: { ...originsContext.data, accountId: '123' } }); + + expect(result.status).to.equal(400); + }); + + it('returns 400 when the external id is missing', async () => { + const result = await controller.getEdgeOptimizeOrigins({ ...originsContext, data: { accountId: '120569600543', distributionId: 'E2EXAMPLE123' } }); + + expect(result.status).to.equal(400); + }); + + it('returns 400 when the distributionId is missing', async () => { + const result = await controller.getEdgeOptimizeOrigins({ ...originsContext, data: { accountId: '120569600543', externalId: 'ext' } }); + + expect(result.status).to.equal(400); + const body = await result.json(); + expect(body.message).to.include('distributionId'); + }); + + it('returns 400 when the AWS call fails', async () => { + getDistributionConfigStub = sinon.stub().rejects(new Error('GetDistributionConfig failed')); + + const result = await controller.getEdgeOptimizeOrigins(originsContext); + + expect(result.status).to.equal(400); + const body = await result.json(); + expect(body.message).to.include('GetDistributionConfig failed'); + }); + + it('returns 404 when the site is not found', async () => { + mockDataAccess.Site.findById.resolves(null); + + const result = await controller.getEdgeOptimizeOrigins(originsContext); + + expect(result.status).to.equal(404); + }); + + it('returns 403 when the user lacks access to the site', async () => { + const deniedController = controllerWithAccessDenied(mockContext); + + const result = await deniedController.getEdgeOptimizeOrigins(originsContext); + + expect(result.status).to.equal(403); + }); + + it('returns 403 when the user is not an LLMO administrator', async () => { + const LlmoControllerNoAdmin = await esmock('../../../src/controllers/llmo/llmo.js', { + '../../../src/support/access-control-util.js': createMockAccessControlUtil(true, true, false), + '@adobe/spacecat-shared-http-utils': mockHttpUtils, + '../../../src/support/cached-response.js': mockCachedResponse, + ...getCommonMocks(), + }); + + const controllerNoAdmin = LlmoControllerNoAdmin(mockContext); + const result = await controllerNoAdmin.getEdgeOptimizeOrigins(originsContext); + + expect(result.status).to.equal(403); + }); + }); + + describe('getEdgeOptimizeBehaviors', () => { + let behaviorsContext; + + beforeEach(() => { + assumeConnectorRoleStub = sinon.stub().resolves({ + roleArn: 'arn:aws:iam::120569600543:role/AdobeLLMOptimizerCloudFrontConnectorRole', + accountId: '120569600543', + credentials: { accessKeyId: 'AKIA', secretAccessKey: 'secret', sessionToken: 'token' }, + }); + getDistributionConfigStub = sinon.stub().resolves({ + origins: [], + defaultCacheBehavior: { pathPattern: 'Default (*)', targetOriginId: 'origin-aem' }, + cacheBehaviors: [ + { pathPattern: '/api/*', targetOriginId: 'origin-api' }, + ], + }); + behaviorsContext = { + ...mockContext, + params: { siteId: TEST_SITE_ID }, + data: { + accountId: '120569600543', + externalId: '7ff9518a-cf59-40b4-aa53-68a3cb2e24a5', + distributionId: 'E2EXAMPLE123', + }, + env: {}, + }; + }); + + it('returns the default behavior plus ordered cache behaviors', async () => { + const result = await controller.getEdgeOptimizeBehaviors(behaviorsContext); + + expect(result.status).to.equal(200); + const body = await result.json(); + expect(body.behaviors).to.deep.equal([ + { pathPattern: 'Default (*)', targetOriginId: 'origin-aem', isDefault: true }, + { pathPattern: '/api/*', targetOriginId: 'origin-api', isDefault: false }, + ]); + expect(getDistributionConfigStub.calledOnceWith(sinon.match.any, 'E2EXAMPLE123')).to.equal(true); + }); + + it('omits the default entry when the distribution has none', async () => { + getDistributionConfigStub = sinon.stub().resolves({ + origins: [], + defaultCacheBehavior: null, + cacheBehaviors: [{ pathPattern: '/api/*', targetOriginId: 'origin-api' }], + }); + + const result = await controller.getEdgeOptimizeBehaviors(behaviorsContext); + + const body = await result.json(); + expect(body.behaviors).to.deep.equal([ + { pathPattern: '/api/*', targetOriginId: 'origin-api', isDefault: false }, + ]); + }); + + it('returns 400 for an invalid account id', async () => { + const result = await controller.getEdgeOptimizeBehaviors({ ...behaviorsContext, data: { ...behaviorsContext.data, accountId: '123' } }); + + expect(result.status).to.equal(400); + }); + + it('returns 400 when the external id is missing', async () => { + const result = await controller.getEdgeOptimizeBehaviors({ ...behaviorsContext, data: { accountId: '120569600543', distributionId: 'E2EXAMPLE123' } }); + + expect(result.status).to.equal(400); + }); + + it('returns 400 when the distributionId is missing', async () => { + const result = await controller.getEdgeOptimizeBehaviors({ ...behaviorsContext, data: { accountId: '120569600543', externalId: 'ext' } }); + + expect(result.status).to.equal(400); + const body = await result.json(); + expect(body.message).to.include('distributionId'); + }); + + it('returns 400 when the AWS call fails', async () => { + getDistributionConfigStub = sinon.stub().rejects(new Error('GetDistributionConfig failed')); + + const result = await controller.getEdgeOptimizeBehaviors(behaviorsContext); + + expect(result.status).to.equal(400); + const body = await result.json(); + expect(body.message).to.include('GetDistributionConfig failed'); + }); + + it('returns 404 when the site is not found', async () => { + mockDataAccess.Site.findById.resolves(null); + + const result = await controller.getEdgeOptimizeBehaviors(behaviorsContext); + + expect(result.status).to.equal(404); + }); + + it('returns 403 when the user lacks access to the site', async () => { + const deniedController = controllerWithAccessDenied(mockContext); + + const result = await deniedController.getEdgeOptimizeBehaviors(behaviorsContext); + + expect(result.status).to.equal(403); + }); + + it('returns 403 when the user is not an LLMO administrator', async () => { + const LlmoControllerNoAdmin = await esmock('../../../src/controllers/llmo/llmo.js', { + '../../../src/support/access-control-util.js': createMockAccessControlUtil(true, true, false), + '@adobe/spacecat-shared-http-utils': mockHttpUtils, + '../../../src/support/cached-response.js': mockCachedResponse, + ...getCommonMocks(), + }); + + const controllerNoAdmin = LlmoControllerNoAdmin(mockContext); + const result = await controllerNoAdmin.getEdgeOptimizeBehaviors(behaviorsContext); + + expect(result.status).to.equal(403); + }); + }); + describe('getStrategy', () => { const mockStrategyData = { opportunities: [ diff --git a/test/routes/index.test.js b/test/routes/index.test.js index 1c3579579a..675e6fce7c 100755 --- a/test/routes/index.test.js +++ b/test/routes/index.test.js @@ -329,6 +329,9 @@ describe('getRouteHandlers', () => { createOrUpdateEdgeConfig: () => null, connectEdgeOptimize: () => null, getEdgeOptimizeDistributions: () => null, + checkEdgeOptimizePrerequisites: () => null, + getEdgeOptimizeOrigins: () => null, + getEdgeOptimizeBehaviors: () => null, getEdgeConfig: () => null, createOrUpdateStageEdgeConfig: () => null, checkEdgeOptimizeStatus: () => null, @@ -1066,6 +1069,9 @@ describe('getRouteHandlers', () => { 'POST /sites/:siteId/llmo/edge-optimize-bootstrap-url', 'POST /sites/:siteId/llmo/edge-optimize/connect', 'POST /sites/:siteId/llmo/edge-optimize/distributions', + 'POST /sites/:siteId/llmo/edge-optimize/prerequisites', + 'POST /sites/:siteId/llmo/edge-optimize/origins', + 'POST /sites/:siteId/llmo/edge-optimize/behaviors', 'GET /sites/:siteId/llmo/edge-optimize-status', 'GET /sites/:siteId/llmo/probes/edge-optimize', 'GET /sites/:siteId/llmo/strategy', diff --git a/test/support/edge-optimize.test.js b/test/support/edge-optimize.test.js index 6dcbf5ca55..96d3508e22 100644 --- a/test/support/edge-optimize.test.js +++ b/test/support/edge-optimize.test.js @@ -40,6 +40,9 @@ describe('edge-optimize support', () => { ListDistributionsCommand: function ListDistributionsCommand(input) { this.input = input; }, + GetDistributionConfigCommand: function GetDistributionConfigCommand(input) { + this.input = input; + }, }, }); }); @@ -178,4 +181,63 @@ describe('edge-optimize support', () => { expect(result[0].enabled).to.equal(false); }); }); + + describe('getDistributionConfig', () => { + it('maps origins, default cache behavior, and ordered cache behaviors', async () => { + cfSendStub.resolves({ + DistributionConfig: { + Origins: { + Items: [ + { Id: 'origin-aem', DomainName: 'origin.example.com', OriginPath: '/content' }, + { Id: 'EdgeOptimizeOrigin', DomainName: 'live.edgeoptimize.net' }, + ], + }, + DefaultCacheBehavior: { TargetOriginId: 'origin-aem' }, + CacheBehaviors: { + Items: [ + { PathPattern: '/api/*', TargetOriginId: 'origin-aem' }, + ], + }, + }, + }); + + const result = await edgeOptimize.getDistributionConfig({}, 'E2EXAMPLE'); + + expect(cfSendStub.calledOnce).to.equal(true); + expect(cfSendStub.firstCall.args[0].input).to.deep.equal({ Id: 'E2EXAMPLE' }); + expect(result.origins).to.deep.equal([ + { id: 'origin-aem', domainName: 'origin.example.com', originPath: '/content' }, + { id: 'EdgeOptimizeOrigin', domainName: 'live.edgeoptimize.net', originPath: '' }, + ]); + expect(result.defaultCacheBehavior).to.deep.equal({ + pathPattern: 'Default (*)', + targetOriginId: 'origin-aem', + }); + expect(result.cacheBehaviors).to.deep.equal([ + { pathPattern: '/api/*', targetOriginId: 'origin-aem' }, + ]); + }); + + it('defaults to empty collections when the config is sparse', async () => { + cfSendStub.resolves({ DistributionConfig: {} }); + + const result = await edgeOptimize.getDistributionConfig({}, 'E2EXAMPLE'); + + expect(result.origins).to.deep.equal([]); + expect(result.defaultCacheBehavior).to.equal(null); + expect(result.cacheBehaviors).to.deep.equal([]); + }); + + it('throws when the distribution id is missing', async () => { + let error; + try { + await edgeOptimize.getDistributionConfig({}, ''); + } catch (e) { + error = e; + } + expect(error).to.be.an('error'); + expect(error.message).to.include('distributionId'); + expect(cfSendStub.called).to.equal(false); + }); + }); }); From bdab820b92b393f849345943caa37dfe9795a485 Mon Sep 17 00:00:00 2001 From: Akash Bhardwaj Date: Sat, 20 Jun 2026 19:45:26 +0530 Subject: [PATCH 11/56] feat(llmo): add CloudFront edge-optimize mutation wizard steps Sync per-step endpoints that assume the customer connector role server-side and perform one CloudFront write each (no AWS creds in the browser): - create-origin: add the EdgeOptimize_Origin (env EDGE_OPTIMIZE_ORIGIN_DOMAIN, default dev.edgeoptimize.net) via UpdateDistribution (ETag IfMatch). - create-function: create/update + publish the edgeoptimize-routing CF Function (bot-routing JS ported from the standalone wizard). - apply-cache: add EO headers to the behavior's custom cache policy (common path; legacy ForwardedValues / managed-policy clone left as TODO). - create-lambda: create exec role (bounded IAM-propagation retry) + the edgeoptimize-origin Lambda@Edge and publish a version. - apply-associations: wire the function (viewer-request) + Lambda (origin- request/response) onto the selected behavior. - verify: server-side bot-vs-human probe; passed requires x-edgeoptimize-request-id (x-edgeoptimize-fo = failover, not success). Adds @aws-sdk/client-iam + @aws-sdk/client-lambda. All AWS ops use ETag read-modify-write. Embedded function/Lambda code ported verbatim from the connect-aws-wizard; Lambda code inlined per the helix-deploy bundling rule. Mocked-SDK unit tests for all 6 support fns + handlers; routes/capabilities + OpenAPI updated. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/openapi/api.yaml | 12 + docs/openapi/llmo-api.yaml | 395 +++++++++++++++ package-lock.json | 212 +++++++- package.json | 2 + src/controllers/llmo/llmo.js | 271 ++++++++++ src/routes/index.js | 6 + src/routes/required-capabilities.js | 6 + src/support/edge-optimize.js | 761 ++++++++++++++++++++++++++++ test/controllers/llmo/llmo.test.js | 588 +++++++++++++++++++++ test/routes/index.test.js | 12 + test/support/edge-optimize.test.js | 506 +++++++++++++++++- 11 files changed, 2756 insertions(+), 15 deletions(-) diff --git a/docs/openapi/api.yaml b/docs/openapi/api.yaml index b51d813118..99663880df 100644 --- a/docs/openapi/api.yaml +++ b/docs/openapi/api.yaml @@ -635,6 +635,18 @@ paths: $ref: './llmo-api.yaml#/site-llmo-edge-optimize-origins' /sites/{siteId}/llmo/edge-optimize/behaviors: $ref: './llmo-api.yaml#/site-llmo-edge-optimize-behaviors' + /sites/{siteId}/llmo/edge-optimize/create-origin: + $ref: './llmo-api.yaml#/site-llmo-edge-optimize-create-origin' + /sites/{siteId}/llmo/edge-optimize/create-function: + $ref: './llmo-api.yaml#/site-llmo-edge-optimize-create-function' + /sites/{siteId}/llmo/edge-optimize/apply-cache: + $ref: './llmo-api.yaml#/site-llmo-edge-optimize-apply-cache' + /sites/{siteId}/llmo/edge-optimize/create-lambda: + $ref: './llmo-api.yaml#/site-llmo-edge-optimize-create-lambda' + /sites/{siteId}/llmo/edge-optimize/apply-associations: + $ref: './llmo-api.yaml#/site-llmo-edge-optimize-apply-associations' + /sites/{siteId}/llmo/edge-optimize/verify: + $ref: './llmo-api.yaml#/site-llmo-edge-optimize-verify' /sites/{siteId}/llmo/edge-optimize-status: $ref: './llmo-api.yaml#/llmo-edge-optimize-status' /sites/{siteId}/llmo/probes/edge-optimize: diff --git a/docs/openapi/llmo-api.yaml b/docs/openapi/llmo-api.yaml index 6465412fdd..00ab3d109c 100644 --- a/docs/openapi/llmo-api.yaml +++ b/docs/openapi/llmo-api.yaml @@ -2723,6 +2723,344 @@ site-llmo-edge-optimize-behaviors: security: - api_key: [ ] +site-llmo-edge-optimize-create-origin: + post: + tags: + - llmo + summary: Add the Edge Optimize origin to a CloudFront distribution + description: | + Used by the CloudFront "Deploy routing" wizard's "Create Edge Optimize origin" step. Assumes + the connector role and, if no Edge Optimize origin exists yet, adds a custom HTTPS origin + pointing at the Edge Optimize target domain via UpdateDistribution. Idempotent — returns + `created: false, alreadyExisted: true` when the origin is already present. (CloudFront deploy + propagates in the background; this call does not block on it.) + operationId: createEdgeOptimizeOrigin + parameters: + - $ref: './parameters.yaml#/siteId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/edge-optimize-distribution-request' + responses: + '200': + description: Whether the Edge Optimize origin was created or already existed. + content: + application/json: + schema: + type: object + required: + - created + - alreadyExisted + - originId + properties: + created: + type: boolean + alreadyExisted: + type: boolean + originId: + type: string + example: + created: true + alreadyExisted: false + originId: EdgeOptimize_Origin + '400': + $ref: './responses.yaml#/400' + '401': + $ref: './responses.yaml#/401' + '403': + $ref: './responses.yaml#/403' + '404': + $ref: './responses.yaml#/404' + '500': + $ref: './responses.yaml#/500' + security: + - api_key: [ ] + +site-llmo-edge-optimize-create-function: + post: + tags: + - llmo + summary: Create or update the Edge Optimize routing CloudFront Function + description: | + Used by the CloudFront "Deploy routing" wizard's "Create routing function" step. Assumes the + connector role, derives the distribution's default-behavior target origin, and creates (or + updates) the `edgeoptimize-routing` CloudFront Function, then publishes it to LIVE. Idempotent. + operationId: createEdgeOptimizeRoutingFunction + parameters: + - $ref: './parameters.yaml#/siteId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/edge-optimize-distribution-request' + responses: + '200': + description: The published routing function. + content: + application/json: + schema: + type: object + required: + - name + - created + - stage + properties: + name: + type: string + created: + type: boolean + stage: + type: string + example: + name: edgeoptimize-routing + created: true + stage: LIVE + '400': + $ref: './responses.yaml#/400' + '401': + $ref: './responses.yaml#/401' + '403': + $ref: './responses.yaml#/403' + '404': + $ref: './responses.yaml#/404' + '500': + $ref: './responses.yaml#/500' + security: + - api_key: [ ] + +site-llmo-edge-optimize-apply-cache: + post: + tags: + - llmo + summary: Forward the Edge Optimize headers in a behavior's cache policy + description: | + Used by the CloudFront "Deploy routing" wizard's "Apply cache headers" step. Assumes the + connector role and ensures the Edge Optimize routing headers + (`x-edgeoptimize-config`, `x-edgeoptimize-url`) are forwarded by the target behavior's + (custom) cache policy, setting the policy MinTTL to 0. Idempotent. + operationId: applyEdgeOptimizeCache + parameters: + - $ref: './parameters.yaml#/siteId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/edge-optimize-behavior-request' + responses: + '200': + description: Whether the cache policy was updated. + content: + application/json: + schema: + type: object + required: + - policyId + - updated + - alreadyForwarded + properties: + policyId: + type: string + updated: + type: boolean + alreadyForwarded: + type: boolean + example: + policyId: 1234abcd-12ab-34cd-56ef-1234567890ab + updated: true + alreadyForwarded: false + '400': + $ref: './responses.yaml#/400' + '401': + $ref: './responses.yaml#/401' + '403': + $ref: './responses.yaml#/403' + '404': + $ref: './responses.yaml#/404' + '500': + $ref: './responses.yaml#/500' + security: + - api_key: [ ] + +site-llmo-edge-optimize-create-lambda: + post: + tags: + - llmo + summary: Create or update the Edge Optimize Lambda@Edge function + description: | + Used by the CloudFront "Deploy routing" wizard's "Create Lambda@Edge function" step. Assumes + the connector role, ensures the `edgeoptimize-origin-role` execution role exists (with a + bounded propagation wait), creates (or updates) the `edgeoptimize-origin` Lambda function, and + publishes a numbered version (required for Lambda@Edge). Returns the versioned ARN the + "Associate" step needs. Idempotent. + operationId: createEdgeOptimizeLambda + parameters: + - $ref: './parameters.yaml#/siteId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/edge-optimize-connector-request' + responses: + '200': + description: The published Lambda@Edge function version. + content: + application/json: + schema: + type: object + required: + - functionArn + - versionArn + - version + - roleArn + - created + properties: + functionArn: + type: string + versionArn: + type: string + description: The published, versioned Lambda ARN (use this for associations). + version: + type: string + roleArn: + type: string + created: + type: boolean + example: + functionArn: arn:aws:lambda:us-east-1:682033462621:function:edgeoptimize-origin + versionArn: arn:aws:lambda:us-east-1:682033462621:function:edgeoptimize-origin:1 + version: '1' + roleArn: arn:aws:iam::682033462621:role/edgeoptimize-origin-role + created: true + '400': + $ref: './responses.yaml#/400' + '401': + $ref: './responses.yaml#/401' + '403': + $ref: './responses.yaml#/403' + '404': + $ref: './responses.yaml#/404' + '500': + $ref: './responses.yaml#/500' + security: + - api_key: [ ] + +site-llmo-edge-optimize-apply-associations: + post: + tags: + - llmo + summary: Associate the routing function and Lambda@Edge onto a behavior + description: | + Used by the CloudFront "Deploy routing" wizard's "Associate" step. Assumes the connector role + and wires the `edgeoptimize-routing` CloudFront Function (viewer-request) plus the + `edgeoptimize-origin` Lambda@Edge (origin-request and origin-response, versioned ARN) onto the + user-selected cache behavior via UpdateDistribution. Surfaces a clear error if a conflicting + viewer-request function is already associated. + operationId: applyEdgeOptimizeAssociations + parameters: + - $ref: './parameters.yaml#/siteId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/edge-optimize-associate-request' + responses: + '200': + description: The applied associations. + content: + application/json: + schema: + type: object + required: + - cfFunctionArn + - lambdaArn + properties: + cfFunctionArn: + type: string + lambdaArn: + type: string + example: + cfFunctionArn: arn:aws:cloudfront::682033462621:function/edgeoptimize-routing + lambdaArn: arn:aws:lambda:us-east-1:682033462621:function:edgeoptimize-origin:1 + '400': + $ref: './responses.yaml#/400' + '401': + $ref: './responses.yaml#/401' + '403': + $ref: './responses.yaml#/403' + '404': + $ref: './responses.yaml#/404' + '500': + $ref: './responses.yaml#/500' + security: + - api_key: [ ] + +site-llmo-edge-optimize-verify: + post: + tags: + - llmo + summary: Verify Edge Optimize routing end-to-end + description: | + Used by the CloudFront "Deploy routing" wizard's "Verify routing" step. Fetches the + distribution domain server-side as an agentic bot and as a human, then inspects the + `x-edgeoptimize-*` response headers. `passed` is true only when the bot response carries + `x-edgeoptimize-request-id` (page served from the Edge Optimize prerender cache) and the human + response is not optimized. `x-edgeoptimize-fo` means failover to origin — NOT success. + operationId: verifyEdgeOptimizeRouting + parameters: + - $ref: './parameters.yaml#/siteId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/edge-optimize-distribution-request' + responses: + '200': + description: The verification result (inspect `passed`). + content: + application/json: + schema: + type: object + required: + - passed + properties: + passed: + type: boolean + requestId: + type: string + details: + type: object + description: The bot and human probe results (status + x-edgeoptimize-* headers). + example: + passed: true + requestId: req-123 + details: + bot: + status: 200 + headers: + x-edgeoptimize-request-id: req-123 + human: + status: 200 + headers: {} + '400': + $ref: './responses.yaml#/400' + '401': + $ref: './responses.yaml#/401' + '403': + $ref: './responses.yaml#/403' + '404': + $ref: './responses.yaml#/404' + '500': + $ref: './responses.yaml#/500' + security: + - api_key: [ ] + edge-optimize-connector-request: type: object required: @@ -2760,6 +3098,63 @@ edge-optimize-distribution-request: description: The CloudFront distribution ID to inspect. example: E2EXAMPLE123 +edge-optimize-behavior-request: + type: object + required: + - accountId + - externalId + - distributionId + properties: + accountId: + type: string + description: The 12-digit AWS account ID hosting the customer's CloudFront distribution. + pattern: '^[0-9]{12}$' + example: '682033462621' + externalId: + type: string + description: Per-session external ID baked into the connector role's trust policy. + example: '7ff9518a-cf59-40b4-aa53-68a3cb2e24a5' + distributionId: + type: string + description: The CloudFront distribution ID to modify. + example: E2EXAMPLE123 + pathPattern: + type: string + description: The cache behavior to target; use `default` for the default behavior. + default: default + example: /api/* + +edge-optimize-associate-request: + type: object + required: + - accountId + - externalId + - distributionId + - lambdaVersionArn + properties: + accountId: + type: string + description: The 12-digit AWS account ID hosting the customer's CloudFront distribution. + pattern: '^[0-9]{12}$' + example: '682033462621' + externalId: + type: string + description: Per-session external ID baked into the connector role's trust policy. + example: '7ff9518a-cf59-40b4-aa53-68a3cb2e24a5' + distributionId: + type: string + description: The CloudFront distribution ID to modify. + example: E2EXAMPLE123 + pathPattern: + type: string + description: The cache behavior to wire; use `default` for the default behavior. + default: default + example: /api/* + lambdaVersionArn: + type: string + description: The published, versioned Lambda@Edge ARN from the create-lambda step. + example: arn:aws:lambda:us-east-1:682033462621:function:edgeoptimize-origin:1 + llmo-probe-edge-optimize: get: tags: diff --git a/package-lock.json b/package-lock.json index 443e97eb2f..586a60c0bb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,6 +35,8 @@ "@adobe/spacecat-shared-utils": "1.119.2", "@adobe/spacecat-shared-vault-secrets": "1.3.5", "@aws-sdk/client-cloudfront": "3.1045.0", + "@aws-sdk/client-iam": "3.1045.0", + "@aws-sdk/client-lambda": "3.1045.0", "@aws-sdk/client-s3": "3.1045.0", "@aws-sdk/client-secrets-manager": "3.1045.0", "@aws-sdk/client-sfn": "3.1045.0", @@ -248,6 +250,62 @@ "@adobe/helix-shared-async": "2.0.2" } }, + "node_modules/@adobe/helix-deploy/node_modules/@aws-sdk/client-lambda": { + "version": "3.1042.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-lambda/-/client-lambda-3.1042.0.tgz", + "integrity": "sha512-g2NJMMGjQ18LvPapz75s8UzRaxJ2P5bF2Y025/eyVuBtzdCuW6XYoJxP29Tp39BzYgFb+HEtwATyZss/V6KdZg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.974.8", + "@aws-sdk/credential-provider-node": "^3.972.39", + "@aws-sdk/middleware-host-header": "^3.972.10", + "@aws-sdk/middleware-logger": "^3.972.10", + "@aws-sdk/middleware-recursion-detection": "^3.972.11", + "@aws-sdk/middleware-user-agent": "^3.972.38", + "@aws-sdk/region-config-resolver": "^3.972.13", + "@aws-sdk/types": "^3.973.8", + "@aws-sdk/util-endpoints": "^3.996.8", + "@aws-sdk/util-user-agent-browser": "^3.972.10", + "@aws-sdk/util-user-agent-node": "^3.973.24", + "@smithy/config-resolver": "^4.4.17", + "@smithy/core": "^3.23.17", + "@smithy/eventstream-serde-browser": "^4.2.14", + "@smithy/eventstream-serde-config-resolver": "^4.3.14", + "@smithy/eventstream-serde-node": "^4.2.14", + "@smithy/fetch-http-handler": "^5.3.17", + "@smithy/hash-node": "^4.2.14", + "@smithy/invalid-dependency": "^4.2.14", + "@smithy/middleware-content-length": "^4.2.14", + "@smithy/middleware-endpoint": "^4.4.32", + "@smithy/middleware-retry": "^4.5.7", + "@smithy/middleware-serde": "^4.2.20", + "@smithy/middleware-stack": "^4.2.14", + "@smithy/node-config-provider": "^4.3.14", + "@smithy/node-http-handler": "^4.6.1", + "@smithy/protocol-http": "^5.3.14", + "@smithy/smithy-client": "^4.12.13", + "@smithy/types": "^4.14.1", + "@smithy/url-parser": "^4.2.14", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-body-length-browser": "^4.2.2", + "@smithy/util-body-length-node": "^4.2.3", + "@smithy/util-defaults-mode-browser": "^4.3.49", + "@smithy/util-defaults-mode-node": "^4.2.54", + "@smithy/util-endpoints": "^3.4.2", + "@smithy/util-middleware": "^4.2.14", + "@smithy/util-retry": "^4.3.6", + "@smithy/util-stream": "^4.5.25", + "@smithy/util-utf8": "^4.2.2", + "@smithy/util-waiter": "^4.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@adobe/helix-deploy/node_modules/@aws-sdk/client-s3": { "version": "3.1042.0", "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1042.0.tgz", @@ -10602,11 +10660,150 @@ "node": ">=20.0.0" } }, + "node_modules/@aws-sdk/client-iam": { + "version": "3.1045.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-iam/-/client-iam-3.1045.0.tgz", + "integrity": "sha512-1kCDjvkOUyZ7MxQu2ybgXxYDW+8PG+cHjVCk0IXZwYy5R2dhAeHLLlDGJB9jA/KeSh6+XcN5uMlApqJGP1zbUg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.974.8", + "@aws-sdk/credential-provider-node": "^3.972.39", + "@aws-sdk/middleware-host-header": "^3.972.10", + "@aws-sdk/middleware-logger": "^3.972.10", + "@aws-sdk/middleware-recursion-detection": "^3.972.11", + "@aws-sdk/middleware-user-agent": "^3.972.38", + "@aws-sdk/region-config-resolver": "^3.972.13", + "@aws-sdk/types": "^3.973.8", + "@aws-sdk/util-endpoints": "^3.996.8", + "@aws-sdk/util-user-agent-browser": "^3.972.10", + "@aws-sdk/util-user-agent-node": "^3.973.24", + "@smithy/config-resolver": "^4.4.17", + "@smithy/core": "^3.23.17", + "@smithy/fetch-http-handler": "^5.3.17", + "@smithy/hash-node": "^4.2.14", + "@smithy/invalid-dependency": "^4.2.14", + "@smithy/middleware-content-length": "^4.2.14", + "@smithy/middleware-endpoint": "^4.4.32", + "@smithy/middleware-retry": "^4.5.7", + "@smithy/middleware-serde": "^4.2.20", + "@smithy/middleware-stack": "^4.2.14", + "@smithy/node-config-provider": "^4.3.14", + "@smithy/node-http-handler": "^4.6.1", + "@smithy/protocol-http": "^5.3.14", + "@smithy/smithy-client": "^4.12.13", + "@smithy/types": "^4.14.1", + "@smithy/url-parser": "^4.2.14", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-body-length-browser": "^4.2.2", + "@smithy/util-body-length-node": "^4.2.3", + "@smithy/util-defaults-mode-browser": "^4.3.49", + "@smithy/util-defaults-mode-node": "^4.2.54", + "@smithy/util-endpoints": "^3.4.2", + "@smithy/util-middleware": "^4.2.14", + "@smithy/util-retry": "^4.3.6", + "@smithy/util-utf8": "^4.2.2", + "@smithy/util-waiter": "^4.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-iam/node_modules/@aws-sdk/middleware-host-header": { + "version": "3.972.23", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.972.23.tgz", + "integrity": "sha512-sWeojvaTI86wGRIt5HQb9o50V6AExi+NV3VrH2AwBlzV47a+m4JAejaC8CObaLXGVqSRqGt1B9AGYONp+NcTXg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.22", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-iam/node_modules/@aws-sdk/middleware-logger": { + "version": "3.972.22", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.972.22.tgz", + "integrity": "sha512-opYE2yUoQKJJz5QrGylnZeaBRmUaRzUSxlW40cX4LnmwO5XI61IWC3k/LDbJwF15AvmlNbome+glhIlFJzCWJw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.22", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-iam/node_modules/@aws-sdk/middleware-recursion-detection": { + "version": "3.972.24", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.972.24.tgz", + "integrity": "sha512-M0gO9RgAppZx7yDvFRB1pdnyzd34Gnt/JjpT9HoMZxxDYnAkYNa3eabbjsf2A6ffxDFAvZKFdkjthrQxlQgcRg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.22", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-iam/node_modules/@aws-sdk/region-config-resolver": { + "version": "3.972.26", + "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.972.26.tgz", + "integrity": "sha512-wDR6i4YBvJTGGElCQlXQd42f6AraatbU9ckvrjsKu1wFBwH0vTUBxFo6kqRWfuleKhDkMqSkQujCL1eWbFFLtw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.22", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-iam/node_modules/@aws-sdk/types": { + "version": "3.973.13", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.13.tgz", + "integrity": "sha512-pEHZqRkAlHfnfAU9tK+WpKv/gBNjGJrHMgA3A0iYRGyswBS2t0pfez+lWlwktb3Bqa0ovh7w/QJTFwp3fDxLNg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-iam/node_modules/@aws-sdk/util-endpoints": { + "version": "3.996.21", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.996.21.tgz", + "integrity": "sha512-mZbKi9+3h3BtGkph4eYPMCoxiyYHM4q1rYZILfCE4a1uszKaXCRrI0n8nPqcOQDGc2fTwpXm7jLAs/sok4MxVg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.22", + "@smithy/core": "^3.24.6", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-iam/node_modules/@aws-sdk/util-user-agent-browser": { + "version": "3.972.23", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.972.23.tgz", + "integrity": "sha512-5jF9hUGr6vplVanji97KRmbbTUlStXOp/WSz7xArD9fIWxId7tZoq0fPVRNJC5xOuaGzkwO3jHZn8BicrwfwSw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.22", + "tslib": "^2.6.2" + } + }, "node_modules/@aws-sdk/client-lambda": { - "version": "3.1042.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-lambda/-/client-lambda-3.1042.0.tgz", - "integrity": "sha512-g2NJMMGjQ18LvPapz75s8UzRaxJ2P5bF2Y025/eyVuBtzdCuW6XYoJxP29Tp39BzYgFb+HEtwATyZss/V6KdZg==", - "dev": true, + "version": "3.1045.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-lambda/-/client-lambda-3.1045.0.tgz", + "integrity": "sha512-9EDPinh03XanJQssTBdTY+9E7PkyQ0NLLJiaOAM71/g4DI+0OZboGqhX7KKizwUGqKkj0paKEAwgWaMLgEkQFQ==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", @@ -10662,7 +10859,6 @@ "version": "3.972.13", "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.972.13.tgz", "integrity": "sha512-EA3+u2LD3kGcfRNmCSjyJuzX4XvG4zYv57i4ZksH+1IEciuSyHQGvzivEz7vZ+jbRPdAAe7WWKy/4M8InCKDcw==", - "dev": true, "license": "Apache-2.0", "dependencies": { "@aws-sdk/core": "^3.974.12", @@ -10676,7 +10872,6 @@ "version": "3.972.12", "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.972.12.tgz", "integrity": "sha512-NxB2dS4/mV3380hNkC72TkhMaLLjWGGBeTAEucqlJptVVovTbNmQWZLwaMC74ICo9NZHmFiBVVTHzDfAh/3y6Q==", - "dev": true, "license": "Apache-2.0", "dependencies": { "@aws-sdk/core": "^3.974.12", @@ -10690,7 +10885,6 @@ "version": "3.972.14", "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.972.14.tgz", "integrity": "sha512-bqL+upATpOJ/7px4IVfMVxcM6Lyt9uRizmEx3mNg4N6+IQlnOaYXXOJ4TNX6P0mKPPW0lwn9ZW8QEhXwQuCH9A==", - "dev": true, "license": "Apache-2.0", "dependencies": { "@aws-sdk/core": "^3.974.12", @@ -10704,7 +10898,6 @@ "version": "3.972.16", "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.972.16.tgz", "integrity": "sha512-/YaivCvKUkEeMN9VTKBSvBn5w/4osAM1YboM58DKaLF/vqFGf/FdJCLmppqiPPJWZaXcASqByVjc3evE7KHKdA==", - "dev": true, "license": "Apache-2.0", "dependencies": { "@aws-sdk/core": "^3.974.12", @@ -10718,7 +10911,6 @@ "version": "3.973.8", "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.8.tgz", "integrity": "sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw==", - "dev": true, "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.14.1", @@ -10732,7 +10924,6 @@ "version": "3.996.11", "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.996.11.tgz", "integrity": "sha512-BUMJ6VoL54r6Udj/wKy8uKRIndL04rGbaS/wTIV0dM1ewxSrR8yARBHdvZKQsK55ZSW2JrmAPk3KP15kBDxJMw==", - "dev": true, "license": "Apache-2.0", "dependencies": { "@aws-sdk/core": "^3.974.12", @@ -10747,7 +10938,6 @@ "version": "3.972.13", "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.972.13.tgz", "integrity": "sha512-wfk9ZdVwh187gdGXB1EyAoprwjSMt/bSfVtva+OaZx+LyNdKD7smlZf611yMd42UpfQ9vaS8NOftjSajgpdd+w==", - "dev": true, "license": "Apache-2.0", "dependencies": { "@aws-sdk/core": "^3.974.12", diff --git a/package.json b/package.json index 05d5019f10..1199574eb5 100644 --- a/package.json +++ b/package.json @@ -99,6 +99,8 @@ "@adobe/spacecat-shared-utils": "1.119.2", "@adobe/spacecat-shared-vault-secrets": "1.3.5", "@aws-sdk/client-cloudfront": "3.1045.0", + "@aws-sdk/client-iam": "3.1045.0", + "@aws-sdk/client-lambda": "3.1045.0", "@aws-sdk/client-s3": "3.1045.0", "@aws-sdk/client-secrets-manager": "3.1045.0", "@aws-sdk/client-sfn": "3.1045.0", diff --git a/src/controllers/llmo/llmo.js b/src/controllers/llmo/llmo.js index bafc9b4636..39d2ecb30e 100644 --- a/src/controllers/llmo/llmo.js +++ b/src/controllers/llmo/llmo.js @@ -37,6 +37,12 @@ import { assumeConnectorRole, listCloudFrontDistributions, getDistributionConfig, + createEdgeOptimizeOrigin, + createEdgeOptimizeRoutingFunction, + applyEdgeOptimizeCacheHeaders, + createEdgeOptimizeLambda, + applyEdgeOptimizeAssociations, + verifyEdgeOptimizeRouting, } from '../../support/edge-optimize.js'; import { UnauthorizedProductError } from '../../support/errors.js'; import { cachedOk } from '../../support/cached-response.js'; @@ -2386,6 +2392,265 @@ function LlmoController(ctx) { } }; + // Add the Edge Optimize origin to the selected distribution (mutation). Idempotent: returns + // { created: false, alreadyExisted: true } when the origin is already present. Used by the + // wizard's "Create Edge Optimize origin" step. + const createEdgeOptimizeOriginHandler = async (context) => { + const { log, dataAccess, env } = context; + const { siteId } = context.params; + const { Site } = dataAccess; + const accountId = String(context.data?.accountId || '').replace(/\D/g, ''); + const externalId = String(context.data?.externalId || '').trim(); + const distributionId = String(context.data?.distributionId || '').trim(); + const roleName = env?.EDGE_OPTIMIZE_ROLE_NAME || undefined; + const originDomain = env?.EDGE_OPTIMIZE_ORIGIN_DOMAIN || 'dev.edgeoptimize.net'; + + if (accountId.length !== 12) { + return badRequest('accountId must be a 12-digit AWS account ID'); + } + if (!hasText(externalId)) { + return badRequest('externalId is required'); + } + if (!hasText(distributionId)) { + return badRequest('distributionId is required'); + } + + try { + const { error } = await gateEdgeOptimizeWizard(siteId, Site, 'create the edge optimize origin'); + if (error) { + return error; + } + + const { credentials } = await assumeConnectorRole({ accountId, externalId, roleName }); + const result = await createEdgeOptimizeOrigin(credentials, distributionId, originDomain); + log.info(`[edge-optimize-origin] ${result.created ? 'Created' : 'Origin already existed for'} site ${siteId}, distribution ${distributionId}`); + return ok(result); + } catch (error) { + log.error(`Failed to create CloudFront Edge Optimize origin for site ${siteId}:`, error); + return badRequest(cleanupHeaderValue(error.message)); + } + }; + + // Create/update + publish the `edgeoptimize-routing` CloudFront Function (mutation, idempotent). + // Needs the default-behavior target origin id so the function's failover origin group is correct. + const createEdgeOptimizeRoutingFunctionHandler = async (context) => { + const { log, dataAccess, env } = context; + const { siteId } = context.params; + const { Site } = dataAccess; + const accountId = String(context.data?.accountId || '').replace(/\D/g, ''); + const externalId = String(context.data?.externalId || '').trim(); + const distributionId = String(context.data?.distributionId || '').trim(); + const targetedPaths = Array.isArray(context.data?.targetedPaths) + ? context.data.targetedPaths + : null; + const roleName = env?.EDGE_OPTIMIZE_ROLE_NAME || undefined; + + if (accountId.length !== 12) { + return badRequest('accountId must be a 12-digit AWS account ID'); + } + if (!hasText(externalId)) { + return badRequest('externalId is required'); + } + if (!hasText(distributionId)) { + return badRequest('distributionId is required'); + } + + try { + const { error } = await gateEdgeOptimizeWizard(siteId, Site, 'create the edge optimize routing function'); + if (error) { + return error; + } + + const { credentials } = await assumeConnectorRole({ accountId, externalId, roleName }); + // Derive the default-behavior target origin id from the live distribution config. + const { defaultCacheBehavior } = await getDistributionConfig(credentials, distributionId); + const defaultOriginId = defaultCacheBehavior?.targetOriginId; + if (!hasText(defaultOriginId)) { + return badRequest('Could not determine the default cache behavior target origin'); + } + + const result = await createEdgeOptimizeRoutingFunction( + credentials, + defaultOriginId, + targetedPaths, + ); + log.info(`[edge-optimize-function] ${result.created ? 'Created' : 'Updated'} routing function for site ${siteId}`); + return ok(result); + } catch (error) { + log.error(`Failed to create CloudFront routing function for site ${siteId}:`, error); + return badRequest(cleanupHeaderValue(error.message)); + } + }; + + // Ensure the Edge Optimize headers are forwarded by the selected behavior's cache policy + // (mutation, idempotent). Used by the wizard's "Apply cache headers" step. + const applyEdgeOptimizeCacheHandler = async (context) => { + const { log, dataAccess, env } = context; + const { siteId } = context.params; + const { Site } = dataAccess; + const accountId = String(context.data?.accountId || '').replace(/\D/g, ''); + const externalId = String(context.data?.externalId || '').trim(); + const distributionId = String(context.data?.distributionId || '').trim(); + const pathPattern = String(context.data?.pathPattern || '').trim() || 'default'; + const roleName = env?.EDGE_OPTIMIZE_ROLE_NAME || undefined; + + if (accountId.length !== 12) { + return badRequest('accountId must be a 12-digit AWS account ID'); + } + if (!hasText(externalId)) { + return badRequest('externalId is required'); + } + if (!hasText(distributionId)) { + return badRequest('distributionId is required'); + } + + try { + const { error } = await gateEdgeOptimizeWizard(siteId, Site, 'apply edge optimize cache headers'); + if (error) { + return error; + } + + const { credentials } = await assumeConnectorRole({ accountId, externalId, roleName }); + const result = await applyEdgeOptimizeCacheHeaders(credentials, distributionId, pathPattern); + log.info(`[edge-optimize-cache] Applied cache headers for site ${siteId}, behavior ${pathPattern}`); + return ok(result); + } catch (error) { + log.error(`Failed to apply CloudFront cache headers for site ${siteId}:`, error); + return badRequest(cleanupHeaderValue(error.message)); + } + }; + + // Create/update + publish the `edgeoptimize-origin` Lambda@Edge function and its exec role + // (mutation, idempotent). Returns the versioned ARN the associate step needs. + const createEdgeOptimizeLambdaHandler = async (context) => { + const { log, dataAccess, env } = context; + const { siteId } = context.params; + const { Site } = dataAccess; + const accountId = String(context.data?.accountId || '').replace(/\D/g, ''); + const externalId = String(context.data?.externalId || '').trim(); + const roleName = env?.EDGE_OPTIMIZE_ROLE_NAME || undefined; + + if (accountId.length !== 12) { + return badRequest('accountId must be a 12-digit AWS account ID'); + } + if (!hasText(externalId)) { + return badRequest('externalId is required'); + } + + try { + const { error } = await gateEdgeOptimizeWizard(siteId, Site, 'create the edge optimize Lambda@Edge function'); + if (error) { + return error; + } + + const { credentials, accountId: resolvedAccountId } = await assumeConnectorRole({ + accountId, externalId, roleName, + }); + const result = await createEdgeOptimizeLambda(credentials, resolvedAccountId); + log.info(`[edge-optimize-lambda] ${result.created ? 'Created' : 'Updated'} Lambda@Edge for site ${siteId}, published version ${result.version}`); + return ok(result); + } catch (error) { + log.error(`Failed to create Lambda@Edge function for site ${siteId}:`, error); + return badRequest(cleanupHeaderValue(error.message)); + } + }; + + // Associate the routing CloudFront Function (viewer-request) and Lambda@Edge (origin-request/ + // response, versioned ARN) onto the user-selected behavior (mutation). Used by "Associate". + const applyEdgeOptimizeAssociationsHandler = async (context) => { + const { log, dataAccess, env } = context; + const { siteId } = context.params; + const { Site } = dataAccess; + const accountId = String(context.data?.accountId || '').replace(/\D/g, ''); + const externalId = String(context.data?.externalId || '').trim(); + const distributionId = String(context.data?.distributionId || '').trim(); + const pathPattern = String(context.data?.pathPattern || '').trim() || 'default'; + const lambdaVersionArn = String(context.data?.lambdaVersionArn || '').trim(); + const roleName = env?.EDGE_OPTIMIZE_ROLE_NAME || undefined; + + if (accountId.length !== 12) { + return badRequest('accountId must be a 12-digit AWS account ID'); + } + if (!hasText(externalId)) { + return badRequest('externalId is required'); + } + if (!hasText(distributionId)) { + return badRequest('distributionId is required'); + } + if (!hasText(lambdaVersionArn)) { + return badRequest('lambdaVersionArn is required'); + } + + try { + const { error } = await gateEdgeOptimizeWizard(siteId, Site, 'associate edge optimize routing'); + if (error) { + return error; + } + + const { credentials } = await assumeConnectorRole({ accountId, externalId, roleName }); + const result = await applyEdgeOptimizeAssociations( + credentials, + distributionId, + pathPattern, + lambdaVersionArn, + ); + log.info(`[edge-optimize-associate] Associated routing for site ${siteId}, behavior ${pathPattern}`); + return ok(result); + } catch (error) { + log.error(`Failed to associate CloudFront routing for site ${siteId}:`, error); + return badRequest(cleanupHeaderValue(error.message)); + } + }; + + // Verify end-to-end routing by probing the distribution as a bot vs a human and inspecting the + // x-edgeoptimize-* headers. Always returns 200 with { passed }; success requires a request-id. + const verifyEdgeOptimizeRoutingHandler = async (context) => { + const { log, dataAccess, env } = context; + const { siteId } = context.params; + const { Site } = dataAccess; + const accountId = String(context.data?.accountId || '').replace(/\D/g, ''); + const externalId = String(context.data?.externalId || '').trim(); + const distributionId = String(context.data?.distributionId || '').trim(); + const roleName = env?.EDGE_OPTIMIZE_ROLE_NAME || undefined; + + if (accountId.length !== 12) { + return badRequest('accountId must be a 12-digit AWS account ID'); + } + if (!hasText(externalId)) { + return badRequest('externalId is required'); + } + if (!hasText(distributionId)) { + return badRequest('distributionId is required'); + } + + try { + const { error } = await gateEdgeOptimizeWizard(siteId, Site, 'verify edge optimize routing'); + if (error) { + return error; + } + + // Determine the URL to probe: prefer an explicit domain, else resolve from the distribution. + let domain = String(context.data?.domain || '').trim(); + if (!hasText(domain)) { + const { credentials } = await assumeConnectorRole({ accountId, externalId, roleName }); + const distributions = await listCloudFrontDistributions(credentials); + const match = distributions.find((d) => d.id === distributionId); + domain = match?.domainName || ''; + } + if (!hasText(domain)) { + return badRequest('Could not determine the distribution domain to verify'); + } + + const url = /^https?:\/\//.test(domain) ? domain : `https://${domain}/`; + const result = await verifyEdgeOptimizeRouting(url); + log.info(`[edge-optimize-verify] Verified routing for site ${siteId}: passed=${result.passed}`); + return ok(result); + } catch (error) { + log.error(`Failed to verify CloudFront routing for site ${siteId}:`, error); + return badRequest(cleanupHeaderValue(error.message)); + } + }; + return { getEdgeOptimizeBootstrapUrl, connectEdgeOptimize, @@ -2393,6 +2658,12 @@ function LlmoController(ctx) { checkEdgeOptimizePrerequisites, getEdgeOptimizeOrigins, getEdgeOptimizeBehaviors, + createEdgeOptimizeOrigin: createEdgeOptimizeOriginHandler, + createEdgeOptimizeRoutingFunction: createEdgeOptimizeRoutingFunctionHandler, + applyEdgeOptimizeCache: applyEdgeOptimizeCacheHandler, + createEdgeOptimizeLambda: createEdgeOptimizeLambdaHandler, + applyEdgeOptimizeAssociations: applyEdgeOptimizeAssociationsHandler, + verifyEdgeOptimizeRouting: verifyEdgeOptimizeRoutingHandler, getLlmoSheetData, queryLlmoSheetData, getLlmoGlobalSheetData, diff --git a/src/routes/index.js b/src/routes/index.js index 53ee361b15..3f34b2e4ed 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -486,6 +486,12 @@ export default function getRouteHandlers( 'POST /sites/:siteId/llmo/edge-optimize/prerequisites': llmoController.checkEdgeOptimizePrerequisites, 'POST /sites/:siteId/llmo/edge-optimize/origins': llmoController.getEdgeOptimizeOrigins, 'POST /sites/:siteId/llmo/edge-optimize/behaviors': llmoController.getEdgeOptimizeBehaviors, + 'POST /sites/:siteId/llmo/edge-optimize/create-origin': llmoController.createEdgeOptimizeOrigin, + 'POST /sites/:siteId/llmo/edge-optimize/create-function': llmoController.createEdgeOptimizeRoutingFunction, + 'POST /sites/:siteId/llmo/edge-optimize/apply-cache': llmoController.applyEdgeOptimizeCache, + 'POST /sites/:siteId/llmo/edge-optimize/create-lambda': llmoController.createEdgeOptimizeLambda, + 'POST /sites/:siteId/llmo/edge-optimize/apply-associations': llmoController.applyEdgeOptimizeAssociations, + 'POST /sites/:siteId/llmo/edge-optimize/verify': llmoController.verifyEdgeOptimizeRouting, 'GET /sites/:siteId/llmo/strategy': llmoController.getStrategy, 'PUT /sites/:siteId/llmo/strategy': llmoController.saveStrategy, 'GET /sites/:siteId/llmo/edge-optimize-status': llmoController.checkEdgeOptimizeStatus, diff --git a/src/routes/required-capabilities.js b/src/routes/required-capabilities.js index 75ff5cb37a..cf4ac42c05 100644 --- a/src/routes/required-capabilities.js +++ b/src/routes/required-capabilities.js @@ -133,6 +133,12 @@ export const INTERNAL_ROUTES = [ 'POST /sites/:siteId/llmo/edge-optimize/prerequisites', 'POST /sites/:siteId/llmo/edge-optimize/origins', 'POST /sites/:siteId/llmo/edge-optimize/behaviors', + 'POST /sites/:siteId/llmo/edge-optimize/create-origin', + 'POST /sites/:siteId/llmo/edge-optimize/create-function', + 'POST /sites/:siteId/llmo/edge-optimize/apply-cache', + 'POST /sites/:siteId/llmo/edge-optimize/create-lambda', + 'POST /sites/:siteId/llmo/edge-optimize/apply-associations', + 'POST /sites/:siteId/llmo/edge-optimize/verify', 'PUT /sites/:siteId/llmo/opportunities-reviewed', // PLG onboarding - IMS token auth, self-service flow, not S2S diff --git a/src/support/edge-optimize.js b/src/support/edge-optimize.js index cd6ee7a69d..3227fb80f1 100644 --- a/src/support/edge-optimize.js +++ b/src/support/edge-optimize.js @@ -10,12 +10,35 @@ * governing permissions and limitations under the License. */ +import { deflateRawSync } from 'node:zlib'; import { STSClient, AssumeRoleCommand } from '@aws-sdk/client-sts'; import { CloudFrontClient, ListDistributionsCommand, GetDistributionConfigCommand, + GetCachePolicyConfigCommand, + UpdateCachePolicyCommand, + CreateFunctionCommand, + UpdateFunctionCommand, + DescribeFunctionCommand, + PublishFunctionCommand, + UpdateDistributionCommand, } from '@aws-sdk/client-cloudfront'; +import { + IAMClient, + CreateRoleCommand, + GetRoleCommand, + PutRolePolicyCommand, + UpdateAssumeRolePolicyCommand, +} from '@aws-sdk/client-iam'; +import { + LambdaClient, + CreateFunctionCommand as LambdaCreateFunctionCommand, + UpdateFunctionCodeCommand, + GetFunctionCommand, + GetFunctionConfigurationCommand, + PublishVersionCommand, +} from '@aws-sdk/client-lambda'; import { hasText } from '@adobe/spacecat-shared-utils'; // CloudFront is a global service; its control plane lives in us-east-1. @@ -24,6 +47,20 @@ export const EDGE_OPTIMIZE_DEFAULT_ROLE_NAME = 'AdobeLLMOptimizerCloudFrontConne const SESSION_NAME = 'llmo-edge-optimize'; const SESSION_DURATION_SECONDS = 900; +// The connector role only permits writes to these exact resource names — keep them in sync +// with the standalone connect-aws-wizard (server.mjs) and the customer-bootstrap-role policy. +export const EDGE_OPTIMIZE_ORIGIN_ID = 'EdgeOptimize_Origin'; +export const EDGE_OPTIMIZE_DEFAULT_ORIGIN_DOMAIN = 'dev.edgeoptimize.net'; +export const EDGE_OPTIMIZE_FUNCTION_NAME = 'edgeoptimize-routing'; +export const EDGE_OPTIMIZE_LAMBDA_FUNCTION_NAME = 'edgeoptimize-origin'; +export const EDGE_OPTIMIZE_LAMBDA_ROLE_NAME = 'edgeoptimize-origin-role'; +// Headers the routing CloudFront Function sets and that must reach the EO origin uncached. +export const EDGE_OPTIMIZE_CACHE_HEADERS = ['x-edgeoptimize-config', 'x-edgeoptimize-url']; + +const delay = (ms) => new Promise((resolve) => { + setTimeout(resolve, ms); +}); + /** * Assume the customer's cross-account connector role and return short-lived credentials. * @@ -142,3 +179,727 @@ export async function getDistributionConfig( return { origins, defaultCacheBehavior, cacheBehaviors }; } + +/** + * Locate a behavior on a parsed DistributionConfig by its path pattern. The default behavior is + * addressed with the pseudo-pattern `default` (or `Default (*)`, the projection used by the read + * endpoints). + * + * @param {object} config - a raw CloudFront DistributionConfig. + * @param {string} pathPattern - the behavior path pattern, or `default`/`Default (*)`. + * @returns {object} the raw behavior object (mutating it mutates the config). + */ +function getBehaviorFromConfig(config, pathPattern) { + if (pathPattern === 'default' || pathPattern === 'Default (*)') { + return config.DefaultCacheBehavior; + } + const behavior = (config.CacheBehaviors?.Items || []).find((b) => b.PathPattern === pathPattern); + if (!behavior) { + throw new Error(`Behavior not found: ${pathPattern}`); + } + return behavior; +} + +/** + * Add the Edge Optimize origin to a CloudFront distribution (idempotent). + * + * Mirrors the standalone wizard's create-origin: reads the distribution config, and — only if no + * Edge Optimize origin exists yet — appends a custom HTTPS origin pointing at the EO target domain + * with the EO request headers, then writes it back via UpdateDistribution (deploy propagates in the + * background; we do not block on it). + * + * @param {object} credentials - temporary credentials from {@link assumeConnectorRole}. + * @param {string} distributionId - the CloudFront distribution ID. + * @param {string} [originDomain] - EO origin domain (env-driven; defaults to the dev EO domain). + * @param {string} [region] - CloudFront control-plane region. + * @returns {Promise<{created: boolean, alreadyExisted: boolean, originId: string}>} + */ +export async function createEdgeOptimizeOrigin( + credentials, + distributionId, + originDomain = EDGE_OPTIMIZE_DEFAULT_ORIGIN_DOMAIN, + region = EDGE_OPTIMIZE_REGION, +) { + if (!hasText(distributionId)) { + throw new Error('distributionId is required'); + } + const client = new CloudFrontClient({ region, credentials }); + const result = await client.send(new GetDistributionConfigCommand({ Id: distributionId })); + const config = result.DistributionConfig; + const etag = result.ETag; + const origins = config.Origins?.Items || []; + + const alreadyExisted = origins.some( + (o) => o.Id === EDGE_OPTIMIZE_ORIGIN_ID || o.DomainName === originDomain, + ); + + if (alreadyExisted) { + return { created: false, alreadyExisted: true, originId: EDGE_OPTIMIZE_ORIGIN_ID }; + } + + origins.push({ + Id: EDGE_OPTIMIZE_ORIGIN_ID, + DomainName: originDomain, + OriginPath: '', + CustomHeaders: { Quantity: 0, Items: [] }, + CustomOriginConfig: { + HTTPPort: 80, + HTTPSPort: 443, + OriginProtocolPolicy: 'https-only', + OriginSslProtocols: { Quantity: 1, Items: ['TLSv1.2'] }, + OriginReadTimeout: 30, + OriginKeepaliveTimeout: 5, + }, + ConnectionAttempts: 3, + ConnectionTimeout: 10, + }); + config.Origins = { Quantity: origins.length, Items: origins }; + + await client.send(new UpdateDistributionCommand({ + Id: distributionId, + IfMatch: etag, + DistributionConfig: config, + })); + + return { created: true, alreadyExisted: false, originId: EDGE_OPTIMIZE_ORIGIN_ID }; +} + +/** + * Build the CloudFront Function (viewer-request) routing code. Ported verbatim from the standalone + * wizard's `buildFunctionCode` (server.mjs). It detects agentic bots on HTML pages and, for them, + * creates a request origin group that fails over from the Edge Optimize origin to the default + * origin. + * + * @param {string} defaultOriginId - the distribution's default-behavior target origin id. + * @param {string[]|null} [targetedPaths] - explicit paths to target, or null for "all HTML pages". + * @returns {string} the CloudFront Function source code. + */ +export function buildRoutingFunctionCode(defaultOriginId, targetedPaths = null) { + const targetedPathsValue = targetedPaths === null ? 'null' : JSON.stringify(targetedPaths); + + return `import cf from 'cloudfront'; + +function handler(event) { + var request = event.request; + var headers = request.headers; + + delete headers['x-edgeoptimize-api-key']; + delete headers['x-edgeoptimize-url']; + delete headers['x-edgeoptimize-config']; + + var AGENTIC_BOTS = ['AdobeEdgeOptimize-AI', 'ChatGPT-User', 'GPTBot', 'OAI-SearchBot', 'PerplexityBot', 'Perplexity-User', 'ClaudeBot', 'Claude-User', 'Claude-SearchBot']; + var TARGETED_PATHS = ${targetedPathsValue}; + + var userAgent = headers['user-agent'] ? headers['user-agent'].value.toLowerCase() : ''; + var isEdgeOptimizeRequest = headers['x-edgeoptimize-request']; + + var path = request.uri; + var pattern = /(?:\\/[^./]+|\\.html|\\/)$/; + var isHtmlPage = pattern.test(path); + + var isTargetedPath = TARGETED_PATHS === null + ? isHtmlPage + : isHtmlPage && TARGETED_PATHS.includes(path); + + var isAgenticBot = AGENTIC_BOTS.some(function(bot) { + return userAgent.includes(bot.toLowerCase()); + }); + + if (!isEdgeOptimizeRequest && isAgenticBot && isTargetedPath) { + request.headers['x-edgeoptimize-url'] = { value: request.uri }; + request.headers['x-edgeoptimize-config'] = { value: "LLMCLIENT=true" }; + + console.log("Adding origin group for userAgent: " + userAgent); + + cf.createRequestOriginGroup({ + "originIds": [ + { "originId": "EdgeOptimize_Origin" }, + { "originId": "${defaultOriginId}" } + ], + "failoverCriteria": { + "statusCodes": [400, 403, 404, 416, 500, 502, 503, 504] + } + }); + + console.log("Routing to Edge Optimize origin for userAgent: " + userAgent); + return request; + } + + console.log("Routing to Default origin for userAgent: " + userAgent); + return request; +}`; +} + +/** + * Create or update the `edgeoptimize-routing` CloudFront Function and publish it to LIVE + * (idempotent). Mirrors the standalone wizard's create-function step. + * + * @param {object} credentials - temporary credentials from {@link assumeConnectorRole}. + * @param {string} defaultOriginId - the default-behavior target origin id (baked into the code). + * @param {string[]|null} [targetedPaths] - explicit paths to target, or null for all HTML pages. + * @param {string} [region] - CloudFront control-plane region. + * @returns {Promise<{name: string, created: boolean, stage: string}>} + */ +export async function createEdgeOptimizeRoutingFunction( + credentials, + defaultOriginId, + targetedPaths = null, + region = EDGE_OPTIMIZE_REGION, +) { + if (!hasText(defaultOriginId)) { + throw new Error('defaultOriginId is required'); + } + const client = new CloudFrontClient({ region, credentials }); + const code = Buffer.from(buildRoutingFunctionCode(defaultOriginId, targetedPaths), 'utf-8'); + const functionConfig = { + Comment: 'EdgeOptimize agentic bot routing — managed by LLM Optimizer', + Runtime: 'cloudfront-js-2.0', + }; + + // Look up the DEVELOPMENT stage to get its ETag (needed to update an existing function). + let existingEtag = null; + try { + const desc = await client.send(new DescribeFunctionCommand({ + Name: EDGE_OPTIMIZE_FUNCTION_NAME, + Stage: 'DEVELOPMENT', + })); + existingEtag = desc.ETag; + } catch (err) { + if (err.name !== 'NoSuchFunctionExists') { + throw err; + } + } + + let etag; + if (existingEtag) { + const updated = await client.send(new UpdateFunctionCommand({ + Name: EDGE_OPTIMIZE_FUNCTION_NAME, + IfMatch: existingEtag, + FunctionConfig: functionConfig, + FunctionCode: code, + })); + etag = updated.ETag; + } else { + const created = await client.send(new CreateFunctionCommand({ + Name: EDGE_OPTIMIZE_FUNCTION_NAME, + FunctionConfig: functionConfig, + FunctionCode: code, + })); + etag = created.ETag; + } + + await client.send(new PublishFunctionCommand({ + Name: EDGE_OPTIMIZE_FUNCTION_NAME, + IfMatch: etag, + })); + + return { name: EDGE_OPTIMIZE_FUNCTION_NAME, created: !existingEtag, stage: 'LIVE' }; +} + +/** + * Ensure the Edge Optimize routing headers are forwarded by the target behavior's cache policy. + * + * Mirrors the common path of the standalone wizard's apply-cache (the "custom policy" scenario): + * reads the cache policy attached to the selected behavior and, if the EO headers are not already + * forwarded, adds them to the policy's whitelist via UpdateCachePolicy. Setting `setMinTTLZero` + * (default true) forces MinTTL to 0 so agentic responses are not over-cached. + * + * @param {object} credentials - temporary credentials from {@link assumeConnectorRole}. + * @param {string} distributionId - the CloudFront distribution ID. + * @param {string} pathPattern - the behavior to target (`default` for the default behavior). + * @param {object} [opts] + * @param {boolean} [opts.setMinTTLZero=true] - force the policy MinTTL to 0. + * @param {string} [opts.region] - CloudFront control-plane region. + * @returns {Promise<{policyId: string, updated: boolean, alreadyForwarded: boolean}>} + */ +export async function applyEdgeOptimizeCacheHeaders( + credentials, + distributionId, + pathPattern, + { setMinTTLZero = true, region = EDGE_OPTIMIZE_REGION } = {}, +) { + if (!hasText(distributionId)) { + throw new Error('distributionId is required'); + } + if (!hasText(pathPattern)) { + throw new Error('pathPattern is required'); + } + const client = new CloudFrontClient({ region, credentials }); + + const distResult = await client.send(new GetDistributionConfigCommand({ Id: distributionId })); + const behavior = getBehaviorFromConfig(distResult.DistributionConfig, pathPattern); + const policyId = behavior.CachePolicyId; + + // TODO: edge scenarios from the standalone wizard not yet ported here: + // - legacy behaviors using ForwardedValues (no CachePolicyId) + // - AWS-managed cache policies, which must be cloned into a custom `edgeoptimize-cache` policy + // (the connector role can create custom policies but cannot mutate managed ones). + // The common path — a custom cache policy already attached to the behavior — is implemented. + if (!policyId) { + throw new Error( + `Behavior '${pathPattern}' uses legacy ForwardedValues or a managed cache policy; ` + + 'attach a custom cache policy before applying Edge Optimize cache headers.', + ); + } + + const pcResult = await client.send(new GetCachePolicyConfigCommand({ Id: policyId })); + const pc = pcResult.CachePolicyConfig; + const pcEtag = pcResult.ETag; + + const params = pc.ParametersInCacheKeyAndForwardedToOrigin || {}; + const hc = params.HeadersConfig || { HeaderBehavior: 'none' }; + + let alreadyForwarded = false; + if (hc.HeaderBehavior === 'allViewer' || hc.HeaderBehavior === 'all') { + alreadyForwarded = true; + } else { + const items = hc.Headers?.Items || []; + const lower = items.map((x) => x.toLowerCase()); + alreadyForwarded = EDGE_OPTIMIZE_CACHE_HEADERS.every((h) => lower.includes(h)); + if (!alreadyForwarded) { + EDGE_OPTIMIZE_CACHE_HEADERS.forEach((h) => { + if (!lower.includes(h)) { + items.push(h); + } + }); + hc.HeaderBehavior = 'whitelist'; + hc.Headers = { Quantity: items.length, Items: items }; + params.HeadersConfig = hc; + pc.ParametersInCacheKeyAndForwardedToOrigin = params; + } + } + + const needsMinTtl = setMinTTLZero && pc.MinTTL !== 0; + if (alreadyForwarded && !needsMinTtl) { + return { policyId, updated: false, alreadyForwarded: true }; + } + + if (needsMinTtl) { + pc.MinTTL = 0; + } + + await client.send(new UpdateCachePolicyCommand({ + Id: policyId, + IfMatch: pcEtag, + CachePolicyConfig: pc, + })); + + return { policyId, updated: true, alreadyForwarded }; +} + +// The Lambda@Edge origin-request/response handler, ported verbatim from the standalone wizard's +// templates/origin-request-response.js. Kept as an inline JS module string (not a sibling-file +// read) so the helix-deploy bundle preserves it — see CLAUDE.md "Lambda Bundle Constraints". +export const EDGE_OPTIMIZE_LAMBDA_CODE = `function hasHeader(map, name) { + const h = map?.[name]; + return Array.isArray(h) && h.length > 0 && (h[0].value || '').trim() !== ''; +} + +function setHeader(map, name, value) { + if (map) { + map[name.toLowerCase()] = [{ key: name, value: String(value) }]; + } +} + +export const handler = async (event) => { + const request = event?.Records?.[0]?.cf?.request; + const response = event?.Records?.[0]?.cf?.response; + const eventType = event.Records[0].cf.config.eventType; + const reqHeaders = request.headers || {}; + + if (eventType === 'origin-request') { + const originDomain = request.origin?.custom?.domainName; + const isEdgeOptimizeConfig = hasHeader(reqHeaders, 'x-edgeoptimize-config'); + const isEdgeOptimizeRequest = hasHeader(reqHeaders, 'x-edgeoptimize-request'); + + if (isEdgeOptimizeConfig && !isEdgeOptimizeRequest) { + if (originDomain === 'dev.edgeoptimize.net') { + console.log("Calling Edge Optimize Origin for agentic requests"); + setHeader(request.headers, 'host', originDomain); + } else { + console.log("Calling Default Origin in case of failover for agentic requests"); + setHeader(request.headers, 'x-edgeoptimize-request', 'fo'); + } + } + + return request; + + } else if (eventType === 'origin-response') { + const resHeaders = response.headers || {}; + const isEdgeOptimizeConfig = hasHeader(reqHeaders, 'x-edgeoptimize-config'); + const isEdgeOptimizeRequestId = hasHeader(resHeaders, 'x-edgeoptimize-request-id'); + + if (isEdgeOptimizeConfig && !isEdgeOptimizeRequestId) { + setHeader(response.headers, 'x-edgeoptimize-fo', '1'); + setHeader(response.headers, 'cache-control', 'no-store'); + console.log('Failover Triggered for agentic requests'); + } + + return response; + } +}; +`; + +const LAMBDA_TRUST_POLICY = JSON.stringify({ + Version: '2012-10-17', + Statement: [{ + Effect: 'Allow', + Principal: { Service: ['lambda.amazonaws.com', 'edgelambda.amazonaws.com'] }, + Action: 'sts:AssumeRole', + }], +}); + +function buildCwLogsPolicy(accountId, functionName) { + return JSON.stringify({ + Version: '2012-10-17', + Statement: [ + { + Effect: 'Allow', + Action: 'logs:CreateLogGroup', + Resource: `arn:aws:logs:*:${accountId}:*`, + }, + { + Effect: 'Allow', + Action: ['logs:CreateLogStream', 'logs:PutLogEvents'], + Resource: [`arn:aws:logs:*:${accountId}:log-group:/aws/lambda/us-east-1.${functionName}:*`], + }, + ], + }); +} + +// ── Minimal zip builder (no external deps) — ported from the standalone wizard's buildZip. ── +// CRC32 + ZIP local/central directory headers are inherently bit-twiddling and densely packed; the +// helix lint rules against bitwise ops, multiple statements per line, and long lines do not fit +// binary-format code, so they are disabled for this block only. +/* eslint-disable no-bitwise, max-statements-per-line, max-len */ +const CRC32_TABLE = (() => { + const t = new Uint32Array(256); + for (let i = 0; i < 256; i += 1) { + let c = i; + for (let j = 0; j < 8; j += 1) { c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1); } + t[i] = c; + } + return t; +})(); + +function crc32(buf) { + let c = 0xFFFFFFFF; + for (let i = 0; i < buf.length; i += 1) { c = (c >>> 8) ^ CRC32_TABLE[(c ^ buf[i]) & 0xFF]; } + return (c ^ 0xFFFFFFFF) >>> 0; +} + +/** + * Build an in-memory zip containing a single file. Used to package the Lambda@Edge code without + * adding a zip dependency to the runtime bundle. + * + * @param {string} filename - the entry name inside the zip (e.g. `index.mjs`). + * @param {string|Buffer} content - the file content. + * @returns {Buffer} the zip archive bytes. + */ +export function buildLambdaZip(filename, content) { + const data = Buffer.isBuffer(content) ? content : Buffer.from(content, 'utf-8'); + const compressed = deflateRawSync(data, { level: 9 }); + const crcVal = crc32(data); + const fn = Buffer.from(filename, 'utf-8'); + const now = new Date(); + const dosDate = ((now.getFullYear() - 1980) << 9) | ((now.getMonth() + 1) << 5) | now.getDate(); + const dosTime = (now.getHours() << 11) | (now.getMinutes() << 5) | (now.getSeconds() >> 1); + + const lh = Buffer.alloc(30 + fn.length); + lh.writeUInt32LE(0x04034b50, 0); lh.writeUInt16LE(20, 4); lh.writeUInt16LE(0, 6); + lh.writeUInt16LE(8, 8); lh.writeUInt16LE(dosTime, 10); lh.writeUInt16LE(dosDate, 12); + lh.writeUInt32LE(crcVal, 14); lh.writeUInt32LE(compressed.length, 18); + lh.writeUInt32LE(data.length, 22); lh.writeUInt16LE(fn.length, 26); lh.writeUInt16LE(0, 28); + fn.copy(lh, 30); + + const cd = Buffer.alloc(46 + fn.length); + cd.writeUInt32LE(0x02014b50, 0); cd.writeUInt16LE(20, 4); cd.writeUInt16LE(20, 6); + cd.writeUInt16LE(0, 8); cd.writeUInt16LE(8, 10); cd.writeUInt16LE(dosTime, 12); + cd.writeUInt16LE(dosDate, 14); cd.writeUInt32LE(crcVal, 16); + cd.writeUInt32LE(compressed.length, 20); cd.writeUInt32LE(data.length, 24); + cd.writeUInt16LE(fn.length, 28); cd.writeUInt16LE(0, 30); cd.writeUInt16LE(0, 32); + cd.writeUInt16LE(0, 34); cd.writeUInt16LE(0, 36); cd.writeUInt32LE(0, 38); + cd.writeUInt32LE(0, 42); fn.copy(cd, 46); + + const eocd = Buffer.alloc(22); + eocd.writeUInt32LE(0x06054b50, 0); eocd.writeUInt16LE(0, 4); eocd.writeUInt16LE(0, 6); + eocd.writeUInt16LE(1, 8); eocd.writeUInt16LE(1, 10); + eocd.writeUInt32LE(cd.length, 12); eocd.writeUInt32LE(lh.length + compressed.length, 16); + eocd.writeUInt16LE(0, 20); + + return Buffer.concat([lh, compressed, cd, eocd]); +} +/* eslint-enable no-bitwise, max-statements-per-line, max-len */ + +async function waitForLambdaActive(lambda, functionName, maxWaitMs = 30000) { + const deadline = Date.now() + maxWaitMs; + /* eslint-disable no-await-in-loop */ + while (Date.now() < deadline) { + const cfg = await lambda.send( + new GetFunctionConfigurationCommand({ FunctionName: functionName }), + ); + if (cfg.State === 'Active') { + return; + } + if (cfg.State === 'Failed') { + throw new Error(`Lambda function entered Failed state: ${cfg.StateReason}`); + } + await delay(2000); + } + /* eslint-enable no-await-in-loop */ + throw new Error('Lambda function did not become Active within 30 s'); +} + +/** + * Create (or update) the `edgeoptimize-origin` Lambda@Edge function and publish a version + * (idempotent). Mirrors the standalone wizard's create-lambda step: ensure the exec role exists + * (trusting lambda + edgelambda) with a basic CloudWatch-logs inline policy, then create/update the + * function code and publish a numbered version. Newly-created IAM roles take a few seconds to + * propagate, so the create path retries CreateFunction with a bounded back-off + * (up to ~5×5s, within ~30s). + * + * @param {object} credentials - temporary credentials from {@link assumeConnectorRole}. + * @param {string} accountId - the 12-digit customer AWS account ID (for the logs-policy ARNs). + * @param {object} [opts] + * @param {string} [opts.region] - control-plane region (Lambda@Edge must be us-east-1). + * @param {number} [opts.roleWaitMs] - extra wait after creating a new role before first create. + * @param {number} [opts.retryDelayMs] - back-off between CreateFunction role-propagation retries. + * @returns {Promise<{functionArn: string, versionArn: string, version: string, + * roleArn: string, created: boolean}>} + */ +export async function createEdgeOptimizeLambda( + credentials, + accountId, + { region = EDGE_OPTIMIZE_REGION, roleWaitMs = 12000, retryDelayMs = 5000 } = {}, +) { + if (!/^[0-9]{12}$/.test(String(accountId))) { + throw new Error('accountId must be a 12-digit AWS account ID'); + } + const lambda = new LambdaClient({ region, credentials }); + const iam = new IAMClient({ region, credentials }); + + const zipBuffer = buildLambdaZip('index.mjs', EDGE_OPTIMIZE_LAMBDA_CODE); + + // ── 1. Ensure the exec role exists with the current trust policy. ── + let roleArn; + let roleIsNew = false; + try { + const existing = await iam.send( + new GetRoleCommand({ RoleName: EDGE_OPTIMIZE_LAMBDA_ROLE_NAME }), + ); + roleArn = existing.Role.Arn; + await iam.send(new UpdateAssumeRolePolicyCommand({ + RoleName: EDGE_OPTIMIZE_LAMBDA_ROLE_NAME, + PolicyDocument: LAMBDA_TRUST_POLICY, + })); + } catch (err) { + if (err.name !== 'NoSuchEntityException') { + throw err; + } + const created = await iam.send(new CreateRoleCommand({ + RoleName: EDGE_OPTIMIZE_LAMBDA_ROLE_NAME, + AssumeRolePolicyDocument: LAMBDA_TRUST_POLICY, + Description: 'Execution role for EdgeOptimize Lambda@Edge function', + })); + roleArn = created.Role.Arn; + roleIsNew = true; + } + + // ── 2. Attach the CloudWatch-logs inline policy. ── + await iam.send(new PutRolePolicyCommand({ + RoleName: EDGE_OPTIMIZE_LAMBDA_ROLE_NAME, + PolicyName: 'EdgeOptimizeLambdaLogging', + PolicyDocument: buildCwLogsPolicy(String(accountId), EDGE_OPTIMIZE_LAMBDA_FUNCTION_NAME), + })); + + // ── 3. Create or update the function code. ── + let functionArn; + let fnExists = false; + try { + await lambda.send( + new GetFunctionCommand({ FunctionName: EDGE_OPTIMIZE_LAMBDA_FUNCTION_NAME }), + ); + fnExists = true; + } catch (err) { + if (err.name !== 'ResourceNotFoundException') { + throw err; + } + } + + if (fnExists) { + const updated = await lambda.send(new UpdateFunctionCodeCommand({ + FunctionName: EDGE_OPTIMIZE_LAMBDA_FUNCTION_NAME, + ZipFile: zipBuffer, + })); + functionArn = updated.FunctionArn; + } else { + if (roleIsNew && roleWaitMs > 0) { + await delay(roleWaitMs); + } + let lastErr; + /* eslint-disable no-await-in-loop */ + for (let attempt = 0; attempt < 5; attempt += 1) { + try { + const created = await lambda.send(new LambdaCreateFunctionCommand({ + FunctionName: EDGE_OPTIMIZE_LAMBDA_FUNCTION_NAME, + Runtime: 'nodejs24.x', + Role: roleArn, + Handler: 'index.handler', + Code: { ZipFile: zipBuffer }, + Description: 'EdgeOptimize origin request/response handler (Lambda@Edge)', + Timeout: 5, + MemorySize: 128, + })); + functionArn = created.FunctionArn; + lastErr = null; + break; + } catch (createErr) { + lastErr = createErr; + const isRolePropagation = createErr.name === 'InvalidParameterValueException' + && (createErr.message || '').toLowerCase().includes('role'); + if (!isRolePropagation || attempt >= 4) { + throw createErr; + } + await delay(retryDelayMs); + } + } + /* eslint-enable no-await-in-loop */ + if (lastErr) { + throw lastErr; + } + } + + // ── 4. Wait for Active, then publish a version (Lambda@Edge requires a numbered version). ── + await waitForLambdaActive(lambda, EDGE_OPTIMIZE_LAMBDA_FUNCTION_NAME); + const published = await lambda.send(new PublishVersionCommand({ + FunctionName: EDGE_OPTIMIZE_LAMBDA_FUNCTION_NAME, + Description: 'Published by LLM Optimizer CloudFront wizard', + })); + + return { + functionArn, + versionArn: published.FunctionArn, // includes the :N version suffix + version: published.Version, + roleArn, + created: !fnExists, + }; +} + +/** + * Wire the routing CloudFront Function (viewer-request) and the Lambda@Edge function + * (origin-request + origin-response) onto a selected cache behavior. Mirrors the standalone + * wizard's apply-associations step. + * + * @param {object} credentials - temporary credentials from {@link assumeConnectorRole}. + * @param {string} distributionId - the CloudFront distribution ID. + * @param {string} pathPattern - the behavior to wire (`default` for the default behavior). + * @param {string} lambdaVersionArn - the published, versioned Lambda@Edge ARN. + * @param {string} [region] - CloudFront control-plane region. + * @returns {Promise<{cfFunctionArn: string, lambdaArn: string}>} + */ +export async function applyEdgeOptimizeAssociations( + credentials, + distributionId, + pathPattern, + lambdaVersionArn, + region = EDGE_OPTIMIZE_REGION, +) { + if (!hasText(distributionId)) { + throw new Error('distributionId is required'); + } + if (!hasText(pathPattern)) { + throw new Error('pathPattern is required'); + } + if (!hasText(lambdaVersionArn)) { + throw new Error('lambdaVersionArn is required'); + } + const client = new CloudFrontClient({ region, credentials }); + + const fnResult = await client.send(new DescribeFunctionCommand({ + Name: EDGE_OPTIMIZE_FUNCTION_NAME, + Stage: 'LIVE', + })); + const cfFunctionArn = fnResult.FunctionSummary?.FunctionMetadata?.FunctionARN; + if (!cfFunctionArn) { + throw new Error(`CloudFront function '${EDGE_OPTIMIZE_FUNCTION_NAME}' not found or not published to LIVE`); + } + + const distResult = await client.send(new GetDistributionConfigCommand({ Id: distributionId })); + const config = distResult.DistributionConfig; + const behavior = getBehaviorFromConfig(config, pathPattern); + + // Surface a conflicting viewer-request association rather than silently clobbering it. + const existingViewerFns = (behavior.FunctionAssociations?.Items || []) + .filter((a) => a.EventType === 'viewer-request' && a.FunctionARN !== cfFunctionArn); + if (existingViewerFns.length > 0) { + throw new Error( + `Behavior '${pathPattern}' already has a different viewer-request function associated ` + + `(${existingViewerFns[0].FunctionARN}). Remove it before applying Edge Optimize routing.`, + ); + } + + behavior.FunctionAssociations = { + Quantity: 1, + Items: [{ FunctionARN: cfFunctionArn, EventType: 'viewer-request' }], + }; + behavior.LambdaFunctionAssociations = { + Quantity: 2, + Items: [ + { LambdaFunctionARN: lambdaVersionArn, EventType: 'origin-request', IncludeBody: false }, + { LambdaFunctionARN: lambdaVersionArn, EventType: 'origin-response', IncludeBody: false }, + ], + }; + + await client.send(new UpdateDistributionCommand({ + Id: distributionId, + IfMatch: distResult.ETag, + DistributionConfig: config, + })); + + return { cfFunctionArn, lambdaArn: lambdaVersionArn }; +} + +async function fetchEdgeOptimizeHeaders(url, userAgent) { + const response = await fetch(url, { + redirect: 'manual', + headers: { 'user-agent': userAgent }, + }); + const headers = {}; + response.headers.forEach((value, key) => { + if (key.toLowerCase().startsWith('x-edgeoptimize')) { + headers[key.toLowerCase()] = value; + } + }); + // Drain the body so the connection can be reused/closed. + await response.arrayBuffer().catch(() => {}); + return { status: response.status, headers }; +} + +/** + * Verify Edge Optimize routing end-to-end by fetching the distribution domain as an agentic bot + * and as a human, then inspecting the `x-edgeoptimize-*` headers. Mirrors the standalone wizard's + * verify logic: success REQUIRES `x-edgeoptimize-request-id` on the bot response (served from the + * Edge Optimize prerender cache). `x-edgeoptimize-fo` means failover to origin — routing worked but + * the page is NOT optimised, which is NOT success. + * + * @param {string} url - the URL to probe (typically `https:///`). + * @returns {Promise<{passed: boolean, requestId: string|null, + * details: {bot: object, human: object}}>} + */ +export async function verifyEdgeOptimizeRouting(url) { + if (!hasText(url)) { + throw new Error('url is required'); + } + const [bot, human] = await Promise.all([ + fetchEdgeOptimizeHeaders(url, 'chatgpt-user'), + fetchEdgeOptimizeHeaders(url, 'Mozilla/5.0'), + ]); + + const requestId = bot.headers['x-edgeoptimize-request-id'] || null; + const passed = Boolean(requestId) + && !human.headers['x-edgeoptimize-request-id'] + && !human.headers['x-edgeoptimize-fo'] + && human.headers['x-edgeoptimize-proxy'] !== '1'; + + return { passed, requestId, details: { bot, human } }; +} diff --git a/test/controllers/llmo/llmo.test.js b/test/controllers/llmo/llmo.test.js index cfe9c47c63..8ca0228ce3 100644 --- a/test/controllers/llmo/llmo.test.js +++ b/test/controllers/llmo/llmo.test.js @@ -89,6 +89,12 @@ describe('LlmoController', () => { let assumeConnectorRoleStub; let listCloudFrontDistributionsStub; let getDistributionConfigStub; + let createEdgeOptimizeOriginStub; + let createEdgeOptimizeRoutingFunctionStub; + let applyEdgeOptimizeCacheHeadersStub; + let createEdgeOptimizeLambdaStub; + let applyEdgeOptimizeAssociationsStub; + let verifyEdgeOptimizeRoutingStub; let mockTokowakaClient; let readStrategyStub; let writeStrategyStub; @@ -200,6 +206,12 @@ describe('LlmoController', () => { assumeConnectorRoleStub = sinon.stub(); listCloudFrontDistributionsStub = sinon.stub(); getDistributionConfigStub = sinon.stub(); + createEdgeOptimizeOriginStub = sinon.stub(); + createEdgeOptimizeRoutingFunctionStub = sinon.stub(); + applyEdgeOptimizeCacheHeadersStub = sinon.stub(); + createEdgeOptimizeLambdaStub = sinon.stub(); + applyEdgeOptimizeAssociationsStub = sinon.stub(); + verifyEdgeOptimizeRoutingStub = sinon.stub(); // Initialize mock TokowakaClient mockTokowakaClient = { @@ -272,6 +284,14 @@ describe('LlmoController', () => { assumeConnectorRole: (...args) => assumeConnectorRoleStub(...args), listCloudFrontDistributions: (...args) => listCloudFrontDistributionsStub(...args), getDistributionConfig: (...args) => getDistributionConfigStub(...args), + createEdgeOptimizeOrigin: (...args) => createEdgeOptimizeOriginStub(...args), + createEdgeOptimizeRoutingFunction: (...args) => ( + createEdgeOptimizeRoutingFunctionStub(...args) + ), + applyEdgeOptimizeCacheHeaders: (...args) => applyEdgeOptimizeCacheHeadersStub(...args), + createEdgeOptimizeLambda: (...args) => createEdgeOptimizeLambdaStub(...args), + applyEdgeOptimizeAssociations: (...args) => applyEdgeOptimizeAssociationsStub(...args), + verifyEdgeOptimizeRouting: (...args) => verifyEdgeOptimizeRoutingStub(...args), }, '@adobe/spacecat-shared-ims-client': { ImsClient: function MockImsClient() { @@ -8160,6 +8180,574 @@ describe('LlmoController', () => { }); }); + describe('createEdgeOptimizeOrigin', () => { + let originContext; + + beforeEach(() => { + assumeConnectorRoleStub = sinon.stub().resolves({ + roleArn: 'arn:aws:iam::120569600543:role/AdobeLLMOptimizerCloudFrontConnectorRole', + accountId: '120569600543', + credentials: { accessKeyId: 'AKIA', secretAccessKey: 'secret', sessionToken: 'token' }, + }); + createEdgeOptimizeOriginStub = sinon.stub().resolves({ + created: true, alreadyExisted: false, originId: 'EdgeOptimize_Origin', + }); + originContext = { + ...mockContext, + params: { siteId: TEST_SITE_ID }, + data: { + accountId: '120569600543', + externalId: '7ff9518a-cf59-40b4-aa53-68a3cb2e24a5', + distributionId: 'E2EXAMPLE123', + }, + env: {}, + }; + }); + + it('creates the origin and returns the result', async () => { + const result = await controller.createEdgeOptimizeOrigin(originContext); + + expect(result.status).to.equal(200); + const body = await result.json(); + expect(body).to.deep.equal({ created: true, alreadyExisted: false, originId: 'EdgeOptimize_Origin' }); + expect(assumeConnectorRoleStub.calledOnce).to.equal(true); + expect(createEdgeOptimizeOriginStub.calledOnceWith(sinon.match.any, 'E2EXAMPLE123', 'dev.edgeoptimize.net')).to.equal(true); + }); + + it('passes the env-driven origin domain when set', async () => { + await controller.createEdgeOptimizeOrigin({ + ...originContext, + env: { EDGE_OPTIMIZE_ORIGIN_DOMAIN: 'live.edgeoptimize.net' }, + }); + + expect(createEdgeOptimizeOriginStub.calledOnceWith(sinon.match.any, 'E2EXAMPLE123', 'live.edgeoptimize.net')).to.equal(true); + }); + + it('is idempotent when the origin already exists', async () => { + createEdgeOptimizeOriginStub = sinon.stub().resolves({ + created: false, alreadyExisted: true, originId: 'EdgeOptimize_Origin', + }); + + const result = await controller.createEdgeOptimizeOrigin(originContext); + const body = await result.json(); + expect(body.alreadyExisted).to.equal(true); + }); + + it('returns 400 for an invalid account id', async () => { + const result = await controller.createEdgeOptimizeOrigin({ ...originContext, data: { ...originContext.data, accountId: '123' } }); + expect(result.status).to.equal(400); + }); + + it('returns 400 when the external id is missing', async () => { + const result = await controller.createEdgeOptimizeOrigin({ ...originContext, data: { accountId: '120569600543', distributionId: 'E2EXAMPLE123' } }); + expect(result.status).to.equal(400); + }); + + it('returns 400 when the distributionId is missing', async () => { + const result = await controller.createEdgeOptimizeOrigin({ ...originContext, data: { accountId: '120569600543', externalId: 'ext' } }); + expect(result.status).to.equal(400); + const body = await result.json(); + expect(body.message).to.include('distributionId'); + }); + + it('returns 400 when the AWS call fails', async () => { + createEdgeOptimizeOriginStub = sinon.stub().rejects(new Error('UpdateDistribution failed')); + const result = await controller.createEdgeOptimizeOrigin(originContext); + expect(result.status).to.equal(400); + const body = await result.json(); + expect(body.message).to.include('UpdateDistribution failed'); + }); + + it('returns 404 when the site is not found', async () => { + mockDataAccess.Site.findById.resolves(null); + const result = await controller.createEdgeOptimizeOrigin(originContext); + expect(result.status).to.equal(404); + }); + + it('returns 403 when the user lacks access to the site', async () => { + const deniedController = controllerWithAccessDenied(mockContext); + const result = await deniedController.createEdgeOptimizeOrigin(originContext); + expect(result.status).to.equal(403); + }); + + it('returns 403 when the user is not an LLMO administrator', async () => { + const LlmoControllerNoAdmin = await esmock('../../../src/controllers/llmo/llmo.js', { + '../../../src/support/access-control-util.js': createMockAccessControlUtil(true, true, false), + '@adobe/spacecat-shared-http-utils': mockHttpUtils, + '../../../src/support/cached-response.js': mockCachedResponse, + ...getCommonMocks(), + }); + const result = await LlmoControllerNoAdmin(mockContext) + .createEdgeOptimizeOrigin(originContext); + expect(result.status).to.equal(403); + }); + }); + + describe('createEdgeOptimizeRoutingFunction', () => { + let functionContext; + + beforeEach(() => { + assumeConnectorRoleStub = sinon.stub().resolves({ + roleArn: 'arn:aws:iam::120569600543:role/AdobeLLMOptimizerCloudFrontConnectorRole', + accountId: '120569600543', + credentials: { accessKeyId: 'AKIA', secretAccessKey: 'secret', sessionToken: 'token' }, + }); + getDistributionConfigStub = sinon.stub().resolves({ + origins: [], + defaultCacheBehavior: { pathPattern: 'Default (*)', targetOriginId: 'origin-aem' }, + cacheBehaviors: [], + }); + createEdgeOptimizeRoutingFunctionStub = sinon.stub().resolves({ + name: 'edgeoptimize-routing', created: true, stage: 'LIVE', + }); + functionContext = { + ...mockContext, + params: { siteId: TEST_SITE_ID }, + data: { + accountId: '120569600543', + externalId: '7ff9518a-cf59-40b4-aa53-68a3cb2e24a5', + distributionId: 'E2EXAMPLE123', + }, + env: {}, + }; + }); + + it('creates the routing function using the default origin id', async () => { + const result = await controller.createEdgeOptimizeRoutingFunction(functionContext); + + expect(result.status).to.equal(200); + const body = await result.json(); + expect(body).to.deep.equal({ name: 'edgeoptimize-routing', created: true, stage: 'LIVE' }); + expect(createEdgeOptimizeRoutingFunctionStub.calledOnceWith(sinon.match.any, 'origin-aem', null)).to.equal(true); + }); + + it('returns 400 when the default cache behavior has no target origin', async () => { + getDistributionConfigStub = sinon.stub().resolves({ + origins: [], defaultCacheBehavior: null, cacheBehaviors: [], + }); + const result = await controller.createEdgeOptimizeRoutingFunction(functionContext); + expect(result.status).to.equal(400); + const body = await result.json(); + expect(body.message).to.include('default cache behavior'); + }); + + it('returns 400 for an invalid account id', async () => { + const result = await controller.createEdgeOptimizeRoutingFunction({ ...functionContext, data: { ...functionContext.data, accountId: '123' } }); + expect(result.status).to.equal(400); + }); + + it('returns 400 when the external id is missing', async () => { + const result = await controller.createEdgeOptimizeRoutingFunction({ ...functionContext, data: { accountId: '120569600543', distributionId: 'E2EXAMPLE123' } }); + expect(result.status).to.equal(400); + }); + + it('returns 400 when the distributionId is missing', async () => { + const result = await controller.createEdgeOptimizeRoutingFunction({ ...functionContext, data: { accountId: '120569600543', externalId: 'ext' } }); + expect(result.status).to.equal(400); + }); + + it('returns 400 when the AWS call fails', async () => { + createEdgeOptimizeRoutingFunctionStub = sinon.stub().rejects(new Error('CreateFunction failed')); + const result = await controller.createEdgeOptimizeRoutingFunction(functionContext); + expect(result.status).to.equal(400); + const body = await result.json(); + expect(body.message).to.include('CreateFunction failed'); + }); + + it('returns 404 when the site is not found', async () => { + mockDataAccess.Site.findById.resolves(null); + const result = await controller.createEdgeOptimizeRoutingFunction(functionContext); + expect(result.status).to.equal(404); + }); + + it('returns 403 when the user lacks access to the site', async () => { + const deniedController = controllerWithAccessDenied(mockContext); + const result = await deniedController.createEdgeOptimizeRoutingFunction(functionContext); + expect(result.status).to.equal(403); + }); + + it('returns 403 when the user is not an LLMO administrator', async () => { + const LlmoControllerNoAdmin = await esmock('../../../src/controllers/llmo/llmo.js', { + '../../../src/support/access-control-util.js': createMockAccessControlUtil(true, true, false), + '@adobe/spacecat-shared-http-utils': mockHttpUtils, + '../../../src/support/cached-response.js': mockCachedResponse, + ...getCommonMocks(), + }); + const result = await LlmoControllerNoAdmin(mockContext) + .createEdgeOptimizeRoutingFunction(functionContext); + expect(result.status).to.equal(403); + }); + }); + + describe('applyEdgeOptimizeCache', () => { + let cacheContext; + + beforeEach(() => { + assumeConnectorRoleStub = sinon.stub().resolves({ + roleArn: 'arn:aws:iam::120569600543:role/AdobeLLMOptimizerCloudFrontConnectorRole', + accountId: '120569600543', + credentials: { accessKeyId: 'AKIA', secretAccessKey: 'secret', sessionToken: 'token' }, + }); + applyEdgeOptimizeCacheHeadersStub = sinon.stub().resolves({ + policyId: 'cp-1', updated: true, alreadyForwarded: false, + }); + cacheContext = { + ...mockContext, + params: { siteId: TEST_SITE_ID }, + data: { + accountId: '120569600543', + externalId: '7ff9518a-cf59-40b4-aa53-68a3cb2e24a5', + distributionId: 'E2EXAMPLE123', + pathPattern: '/api/*', + }, + env: {}, + }; + }); + + it('applies the cache headers to the selected behavior', async () => { + const result = await controller.applyEdgeOptimizeCache(cacheContext); + + expect(result.status).to.equal(200); + const body = await result.json(); + expect(body.policyId).to.equal('cp-1'); + expect(applyEdgeOptimizeCacheHeadersStub.calledOnceWith(sinon.match.any, 'E2EXAMPLE123', '/api/*')).to.equal(true); + }); + + it('defaults the behavior to "default" when pathPattern is omitted', async () => { + await controller.applyEdgeOptimizeCache({ + ...cacheContext, + data: { ...cacheContext.data, pathPattern: undefined }, + }); + expect(applyEdgeOptimizeCacheHeadersStub.calledOnceWith(sinon.match.any, 'E2EXAMPLE123', 'default')).to.equal(true); + }); + + it('returns 400 for an invalid account id', async () => { + const result = await controller.applyEdgeOptimizeCache({ ...cacheContext, data: { ...cacheContext.data, accountId: '123' } }); + expect(result.status).to.equal(400); + }); + + it('returns 400 when the external id is missing', async () => { + const result = await controller.applyEdgeOptimizeCache({ ...cacheContext, data: { accountId: '120569600543', distributionId: 'E2EXAMPLE123' } }); + expect(result.status).to.equal(400); + }); + + it('returns 400 when the distributionId is missing', async () => { + const result = await controller.applyEdgeOptimizeCache({ ...cacheContext, data: { accountId: '120569600543', externalId: 'ext' } }); + expect(result.status).to.equal(400); + }); + + it('returns 400 when the AWS call fails', async () => { + applyEdgeOptimizeCacheHeadersStub = sinon.stub().rejects(new Error('UpdateCachePolicy failed')); + const result = await controller.applyEdgeOptimizeCache(cacheContext); + expect(result.status).to.equal(400); + const body = await result.json(); + expect(body.message).to.include('UpdateCachePolicy failed'); + }); + + it('returns 404 when the site is not found', async () => { + mockDataAccess.Site.findById.resolves(null); + const result = await controller.applyEdgeOptimizeCache(cacheContext); + expect(result.status).to.equal(404); + }); + + it('returns 403 when the user lacks access to the site', async () => { + const deniedController = controllerWithAccessDenied(mockContext); + const result = await deniedController.applyEdgeOptimizeCache(cacheContext); + expect(result.status).to.equal(403); + }); + + it('returns 403 when the user is not an LLMO administrator', async () => { + const LlmoControllerNoAdmin = await esmock('../../../src/controllers/llmo/llmo.js', { + '../../../src/support/access-control-util.js': createMockAccessControlUtil(true, true, false), + '@adobe/spacecat-shared-http-utils': mockHttpUtils, + '../../../src/support/cached-response.js': mockCachedResponse, + ...getCommonMocks(), + }); + const result = await LlmoControllerNoAdmin(mockContext).applyEdgeOptimizeCache(cacheContext); + expect(result.status).to.equal(403); + }); + }); + + describe('createEdgeOptimizeLambda', () => { + let lambdaContext; + + beforeEach(() => { + assumeConnectorRoleStub = sinon.stub().resolves({ + roleArn: 'arn:aws:iam::120569600543:role/AdobeLLMOptimizerCloudFrontConnectorRole', + accountId: '120569600543', + credentials: { accessKeyId: 'AKIA', secretAccessKey: 'secret', sessionToken: 'token' }, + }); + createEdgeOptimizeLambdaStub = sinon.stub().resolves({ + functionArn: 'arn:fn', + versionArn: 'arn:fn:1', + version: '1', + roleArn: 'arn:role', + created: true, + }); + lambdaContext = { + ...mockContext, + params: { siteId: TEST_SITE_ID }, + data: { accountId: '120569600543', externalId: '7ff9518a-cf59-40b4-aa53-68a3cb2e24a5' }, + env: {}, + }; + }); + + it('creates the Lambda@Edge function and returns the versioned ARN', async () => { + const result = await controller.createEdgeOptimizeLambda(lambdaContext); + + expect(result.status).to.equal(200); + const body = await result.json(); + expect(body.versionArn).to.equal('arn:fn:1'); + expect(body.version).to.equal('1'); + expect(createEdgeOptimizeLambdaStub.calledOnceWith(sinon.match.any, '120569600543')).to.equal(true); + }); + + it('returns 400 for an invalid account id', async () => { + const result = await controller.createEdgeOptimizeLambda({ ...lambdaContext, data: { accountId: '123', externalId: 'ext' } }); + expect(result.status).to.equal(400); + }); + + it('returns 400 when the external id is missing', async () => { + const result = await controller.createEdgeOptimizeLambda({ ...lambdaContext, data: { accountId: '120569600543' } }); + expect(result.status).to.equal(400); + }); + + it('returns 400 when the AWS call fails', async () => { + createEdgeOptimizeLambdaStub = sinon.stub().rejects(new Error('CreateRole failed')); + const result = await controller.createEdgeOptimizeLambda(lambdaContext); + expect(result.status).to.equal(400); + const body = await result.json(); + expect(body.message).to.include('CreateRole failed'); + }); + + it('returns 404 when the site is not found', async () => { + mockDataAccess.Site.findById.resolves(null); + const result = await controller.createEdgeOptimizeLambda(lambdaContext); + expect(result.status).to.equal(404); + }); + + it('returns 403 when the user lacks access to the site', async () => { + const deniedController = controllerWithAccessDenied(mockContext); + const result = await deniedController.createEdgeOptimizeLambda(lambdaContext); + expect(result.status).to.equal(403); + }); + + it('returns 403 when the user is not an LLMO administrator', async () => { + const LlmoControllerNoAdmin = await esmock('../../../src/controllers/llmo/llmo.js', { + '../../../src/support/access-control-util.js': createMockAccessControlUtil(true, true, false), + '@adobe/spacecat-shared-http-utils': mockHttpUtils, + '../../../src/support/cached-response.js': mockCachedResponse, + ...getCommonMocks(), + }); + const result = await LlmoControllerNoAdmin(mockContext) + .createEdgeOptimizeLambda(lambdaContext); + expect(result.status).to.equal(403); + }); + }); + + describe('applyEdgeOptimizeAssociations', () => { + let associateContext; + + beforeEach(() => { + assumeConnectorRoleStub = sinon.stub().resolves({ + roleArn: 'arn:aws:iam::120569600543:role/AdobeLLMOptimizerCloudFrontConnectorRole', + accountId: '120569600543', + credentials: { accessKeyId: 'AKIA', secretAccessKey: 'secret', sessionToken: 'token' }, + }); + applyEdgeOptimizeAssociationsStub = sinon.stub().resolves({ + cfFunctionArn: 'arn:cf-fn', + lambdaArn: 'arn:aws:lambda:us-east-1:120569600543:function:edgeoptimize-origin:1', + }); + associateContext = { + ...mockContext, + params: { siteId: TEST_SITE_ID }, + data: { + accountId: '120569600543', + externalId: '7ff9518a-cf59-40b4-aa53-68a3cb2e24a5', + distributionId: 'E2EXAMPLE123', + pathPattern: '/api/*', + lambdaVersionArn: 'arn:aws:lambda:us-east-1:120569600543:function:edgeoptimize-origin:1', + }, + env: {}, + }; + }); + + it('associates the function and lambda onto the selected behavior', async () => { + const result = await controller.applyEdgeOptimizeAssociations(associateContext); + + expect(result.status).to.equal(200); + const body = await result.json(); + expect(body.cfFunctionArn).to.equal('arn:cf-fn'); + expect(applyEdgeOptimizeAssociationsStub.calledOnceWith( + sinon.match.any, + 'E2EXAMPLE123', + '/api/*', + 'arn:aws:lambda:us-east-1:120569600543:function:edgeoptimize-origin:1', + )).to.equal(true); + }); + + it('returns 400 when the lambdaVersionArn is missing', async () => { + const result = await controller.applyEdgeOptimizeAssociations({ + ...associateContext, + data: { ...associateContext.data, lambdaVersionArn: undefined }, + }); + expect(result.status).to.equal(400); + const body = await result.json(); + expect(body.message).to.include('lambdaVersionArn'); + }); + + it('returns 400 for an invalid account id', async () => { + const result = await controller.applyEdgeOptimizeAssociations({ ...associateContext, data: { ...associateContext.data, accountId: '123' } }); + expect(result.status).to.equal(400); + }); + + it('returns 400 when the external id is missing', async () => { + const result = await controller.applyEdgeOptimizeAssociations({ ...associateContext, data: { ...associateContext.data, externalId: '' } }); + expect(result.status).to.equal(400); + }); + + it('returns 400 when the distributionId is missing', async () => { + const result = await controller.applyEdgeOptimizeAssociations({ ...associateContext, data: { ...associateContext.data, distributionId: '' } }); + expect(result.status).to.equal(400); + }); + + it('returns 400 when the AWS call fails (conflict)', async () => { + applyEdgeOptimizeAssociationsStub = sinon.stub().rejects(new Error('already has a different viewer-request function')); + const result = await controller.applyEdgeOptimizeAssociations(associateContext); + expect(result.status).to.equal(400); + const body = await result.json(); + expect(body.message).to.include('viewer-request'); + }); + + it('returns 404 when the site is not found', async () => { + mockDataAccess.Site.findById.resolves(null); + const result = await controller.applyEdgeOptimizeAssociations(associateContext); + expect(result.status).to.equal(404); + }); + + it('returns 403 when the user lacks access to the site', async () => { + const deniedController = controllerWithAccessDenied(mockContext); + const result = await deniedController.applyEdgeOptimizeAssociations(associateContext); + expect(result.status).to.equal(403); + }); + + it('returns 403 when the user is not an LLMO administrator', async () => { + const LlmoControllerNoAdmin = await esmock('../../../src/controllers/llmo/llmo.js', { + '../../../src/support/access-control-util.js': createMockAccessControlUtil(true, true, false), + '@adobe/spacecat-shared-http-utils': mockHttpUtils, + '../../../src/support/cached-response.js': mockCachedResponse, + ...getCommonMocks(), + }); + const result = await LlmoControllerNoAdmin(mockContext) + .applyEdgeOptimizeAssociations(associateContext); + expect(result.status).to.equal(403); + }); + }); + + describe('verifyEdgeOptimizeRouting', () => { + let verifyContext; + + beforeEach(() => { + assumeConnectorRoleStub = sinon.stub().resolves({ + roleArn: 'arn:aws:iam::120569600543:role/AdobeLLMOptimizerCloudFrontConnectorRole', + accountId: '120569600543', + credentials: { accessKeyId: 'AKIA', secretAccessKey: 'secret', sessionToken: 'token' }, + }); + listCloudFrontDistributionsStub = sinon.stub().resolves([ + { + id: 'E2EXAMPLE123', domainName: 'd111111abcdef8.cloudfront.net', aliases: [], status: 'Deployed', enabled: true, comment: '', + }, + ]); + verifyEdgeOptimizeRoutingStub = sinon.stub().resolves({ + passed: true, + requestId: 'req-123', + details: { bot: { status: 200, headers: {} }, human: { status: 200, headers: {} } }, + }); + verifyContext = { + ...mockContext, + params: { siteId: TEST_SITE_ID }, + data: { + accountId: '120569600543', + externalId: '7ff9518a-cf59-40b4-aa53-68a3cb2e24a5', + distributionId: 'E2EXAMPLE123', + }, + env: {}, + }; + }); + + it('resolves the domain from the distribution and verifies routing', async () => { + const result = await controller.verifyEdgeOptimizeRouting(verifyContext); + + expect(result.status).to.equal(200); + const body = await result.json(); + expect(body.passed).to.equal(true); + expect(body.requestId).to.equal('req-123'); + expect(verifyEdgeOptimizeRoutingStub.calledOnceWith('https://d111111abcdef8.cloudfront.net/')).to.equal(true); + expect(listCloudFrontDistributionsStub.calledOnce).to.equal(true); + }); + + it('uses an explicit domain when provided (no distribution lookup)', async () => { + await controller.verifyEdgeOptimizeRouting({ ...verifyContext, data: { ...verifyContext.data, domain: 'www.example.com' } }); + expect(listCloudFrontDistributionsStub.called).to.equal(false); + expect(verifyEdgeOptimizeRoutingStub.calledOnceWith('https://www.example.com/')).to.equal(true); + }); + + it('returns 400 when the distribution domain cannot be resolved', async () => { + listCloudFrontDistributionsStub = sinon.stub().resolves([]); + const result = await controller.verifyEdgeOptimizeRouting(verifyContext); + expect(result.status).to.equal(400); + const body = await result.json(); + expect(body.message).to.include('domain'); + }); + + it('returns 400 for an invalid account id', async () => { + const result = await controller.verifyEdgeOptimizeRouting({ ...verifyContext, data: { ...verifyContext.data, accountId: '123' } }); + expect(result.status).to.equal(400); + }); + + it('returns 400 when the external id is missing', async () => { + const result = await controller.verifyEdgeOptimizeRouting({ ...verifyContext, data: { accountId: '120569600543', distributionId: 'E2EXAMPLE123' } }); + expect(result.status).to.equal(400); + }); + + it('returns 400 when the distributionId is missing', async () => { + const result = await controller.verifyEdgeOptimizeRouting({ ...verifyContext, data: { accountId: '120569600543', externalId: 'ext' } }); + expect(result.status).to.equal(400); + }); + + it('returns 400 when the verify call fails', async () => { + verifyEdgeOptimizeRoutingStub = sinon.stub().rejects(new Error('fetch failed')); + const result = await controller.verifyEdgeOptimizeRouting(verifyContext); + expect(result.status).to.equal(400); + const body = await result.json(); + expect(body.message).to.include('fetch failed'); + }); + + it('returns 404 when the site is not found', async () => { + mockDataAccess.Site.findById.resolves(null); + const result = await controller.verifyEdgeOptimizeRouting(verifyContext); + expect(result.status).to.equal(404); + }); + + it('returns 403 when the user lacks access to the site', async () => { + const deniedController = controllerWithAccessDenied(mockContext); + const result = await deniedController.verifyEdgeOptimizeRouting(verifyContext); + expect(result.status).to.equal(403); + }); + + it('returns 403 when the user is not an LLMO administrator', async () => { + const LlmoControllerNoAdmin = await esmock('../../../src/controllers/llmo/llmo.js', { + '../../../src/support/access-control-util.js': createMockAccessControlUtil(true, true, false), + '@adobe/spacecat-shared-http-utils': mockHttpUtils, + '../../../src/support/cached-response.js': mockCachedResponse, + ...getCommonMocks(), + }); + const result = await LlmoControllerNoAdmin(mockContext) + .verifyEdgeOptimizeRouting(verifyContext); + expect(result.status).to.equal(403); + }); + }); + describe('getStrategy', () => { const mockStrategyData = { opportunities: [ diff --git a/test/routes/index.test.js b/test/routes/index.test.js index 675e6fce7c..4817b17c9f 100755 --- a/test/routes/index.test.js +++ b/test/routes/index.test.js @@ -332,6 +332,12 @@ describe('getRouteHandlers', () => { checkEdgeOptimizePrerequisites: () => null, getEdgeOptimizeOrigins: () => null, getEdgeOptimizeBehaviors: () => null, + createEdgeOptimizeOrigin: () => null, + createEdgeOptimizeRoutingFunction: () => null, + applyEdgeOptimizeCache: () => null, + createEdgeOptimizeLambda: () => null, + applyEdgeOptimizeAssociations: () => null, + verifyEdgeOptimizeRouting: () => null, getEdgeConfig: () => null, createOrUpdateStageEdgeConfig: () => null, checkEdgeOptimizeStatus: () => null, @@ -1072,6 +1078,12 @@ describe('getRouteHandlers', () => { 'POST /sites/:siteId/llmo/edge-optimize/prerequisites', 'POST /sites/:siteId/llmo/edge-optimize/origins', 'POST /sites/:siteId/llmo/edge-optimize/behaviors', + 'POST /sites/:siteId/llmo/edge-optimize/create-origin', + 'POST /sites/:siteId/llmo/edge-optimize/create-function', + 'POST /sites/:siteId/llmo/edge-optimize/apply-cache', + 'POST /sites/:siteId/llmo/edge-optimize/create-lambda', + 'POST /sites/:siteId/llmo/edge-optimize/apply-associations', + 'POST /sites/:siteId/llmo/edge-optimize/verify', 'GET /sites/:siteId/llmo/edge-optimize-status', 'GET /sites/:siteId/llmo/probes/edge-optimize', 'GET /sites/:siteId/llmo/strategy', diff --git a/test/support/edge-optimize.test.js b/test/support/edge-optimize.test.js index 96d3508e22..2e94376b69 100644 --- a/test/support/edge-optimize.test.js +++ b/test/support/edge-optimize.test.js @@ -17,12 +17,30 @@ import esmock from 'esmock'; describe('edge-optimize support', () => { let stsSendStub; let cfSendStub; + let iamSendStub; + let lambdaSendStub; let edgeOptimize; beforeEach(async function setup() { this.timeout(30000); stsSendStub = sinon.stub(); cfSendStub = sinon.stub(); + iamSendStub = sinon.stub(); + lambdaSendStub = sinon.stub(); + // Each command in a mocked module is a constructor FUNCTION (not a class) — eslint forbids + // multiple class declarations in one file, so we capture the command name + input on `this`. + const cfCommand = (Name) => function CloudFrontCommand(input) { + this.input = input; + this.commandName = Name; + }; + const iamCommand = (Name) => function IamCommand(input) { + this.input = input; + this.commandName = Name; + }; + const lambdaCommand = (Name) => function LambdaCommand(input) { + this.input = input; + this.commandName = Name; + }; edgeOptimize = await esmock('../../src/support/edge-optimize.js', { '@aws-sdk/client-sts': { STSClient: function STSClient() { @@ -37,12 +55,36 @@ describe('edge-optimize support', () => { this.config = config; this.send = (cmd) => cfSendStub(cmd); }, - ListDistributionsCommand: function ListDistributionsCommand(input) { - this.input = input; + ListDistributionsCommand: cfCommand('ListDistributions'), + GetDistributionConfigCommand: cfCommand('GetDistributionConfig'), + GetCachePolicyConfigCommand: cfCommand('GetCachePolicyConfig'), + UpdateCachePolicyCommand: cfCommand('UpdateCachePolicy'), + CreateFunctionCommand: cfCommand('CreateFunction'), + UpdateFunctionCommand: cfCommand('UpdateFunction'), + DescribeFunctionCommand: cfCommand('DescribeFunction'), + PublishFunctionCommand: cfCommand('PublishFunction'), + UpdateDistributionCommand: cfCommand('UpdateDistribution'), + }, + '@aws-sdk/client-iam': { + IAMClient: function IAMClient(config) { + this.config = config; + this.send = (cmd) => iamSendStub(cmd); }, - GetDistributionConfigCommand: function GetDistributionConfigCommand(input) { - this.input = input; + CreateRoleCommand: iamCommand('CreateRole'), + GetRoleCommand: iamCommand('GetRole'), + PutRolePolicyCommand: iamCommand('PutRolePolicy'), + UpdateAssumeRolePolicyCommand: iamCommand('UpdateAssumeRolePolicy'), + }, + '@aws-sdk/client-lambda': { + LambdaClient: function LambdaClient(config) { + this.config = config; + this.send = (cmd) => lambdaSendStub(cmd); }, + CreateFunctionCommand: lambdaCommand('CreateFunction'), + UpdateFunctionCodeCommand: lambdaCommand('UpdateFunctionCode'), + GetFunctionCommand: lambdaCommand('GetFunction'), + GetFunctionConfigurationCommand: lambdaCommand('GetFunctionConfiguration'), + PublishVersionCommand: lambdaCommand('PublishVersion'), }, }); }); @@ -240,4 +282,460 @@ describe('edge-optimize support', () => { expect(cfSendStub.called).to.equal(false); }); }); + + describe('createEdgeOptimizeOrigin', () => { + it('adds the Edge Optimize origin when it does not exist', async () => { + cfSendStub.onFirstCall().resolves({ + DistributionConfig: { Origins: { Quantity: 1, Items: [{ Id: 'origin-aem', DomainName: 'origin.example.com' }] } }, + ETag: 'etag-1', + }); + cfSendStub.onSecondCall().resolves({}); + + const result = await edgeOptimize.createEdgeOptimizeOrigin({}, 'E2EXAMPLE', 'dev.edgeoptimize.net'); + + expect(result).to.deep.equal({ created: true, alreadyExisted: false, originId: 'EdgeOptimize_Origin' }); + expect(cfSendStub.secondCall.args[0].commandName).to.equal('UpdateDistribution'); + const update = cfSendStub.secondCall.args[0].input; + expect(update.IfMatch).to.equal('etag-1'); + const added = update.DistributionConfig.Origins.Items.find((o) => o.Id === 'EdgeOptimize_Origin'); + expect(added.DomainName).to.equal('dev.edgeoptimize.net'); + expect(added.CustomOriginConfig.OriginProtocolPolicy).to.equal('https-only'); + }); + + it('is idempotent when the origin already exists by id', async () => { + cfSendStub.resolves({ + DistributionConfig: { Origins: { Quantity: 1, Items: [{ Id: 'EdgeOptimize_Origin', DomainName: 'x' }] } }, + ETag: 'etag-1', + }); + + const result = await edgeOptimize.createEdgeOptimizeOrigin({}, 'E2EXAMPLE'); + + expect(result).to.deep.equal({ created: false, alreadyExisted: true, originId: 'EdgeOptimize_Origin' }); + expect(cfSendStub.calledOnce).to.equal(true); // never updated + }); + + it('is idempotent when an origin already uses the EO domain', async () => { + cfSendStub.resolves({ + DistributionConfig: { Origins: { Items: [{ Id: 'custom', DomainName: 'dev.edgeoptimize.net' }] } }, + ETag: 'etag-1', + }); + + const result = await edgeOptimize.createEdgeOptimizeOrigin({}, 'E2EXAMPLE', 'dev.edgeoptimize.net'); + + expect(result.alreadyExisted).to.equal(true); + expect(cfSendStub.calledOnce).to.equal(true); + }); + + it('throws when the distribution id is missing', async () => { + let error; + try { + await edgeOptimize.createEdgeOptimizeOrigin({}, ''); + } catch (e) { + error = e; + } + expect(error.message).to.include('distributionId'); + expect(cfSendStub.called).to.equal(false); + }); + }); + + describe('buildRoutingFunctionCode', () => { + it('embeds the default origin id and null targeted paths', () => { + const code = edgeOptimize.buildRoutingFunctionCode('origin-aem'); + expect(code).to.include('{ "originId": "origin-aem" }'); + expect(code).to.include('var TARGETED_PATHS = null;'); + expect(code).to.include("import cf from 'cloudfront';"); + }); + + it('embeds explicit targeted paths as JSON', () => { + const code = edgeOptimize.buildRoutingFunctionCode('origin-aem', ['/a', '/b']); + expect(code).to.include('var TARGETED_PATHS = ["/a","/b"];'); + }); + }); + + describe('createEdgeOptimizeRoutingFunction', () => { + it('creates and publishes a new function when none exists', async () => { + cfSendStub.onFirstCall().rejects(Object.assign(new Error('not found'), { name: 'NoSuchFunctionExists' })); + cfSendStub.onSecondCall().resolves({ ETag: 'fn-etag' }); // CreateFunction + cfSendStub.onThirdCall().resolves({}); // PublishFunction + + const result = await edgeOptimize.createEdgeOptimizeRoutingFunction({}, 'origin-aem'); + + expect(result).to.deep.equal({ name: 'edgeoptimize-routing', created: true, stage: 'LIVE' }); + expect(cfSendStub.secondCall.args[0].commandName).to.equal('CreateFunction'); + expect(cfSendStub.thirdCall.args[0].commandName).to.equal('PublishFunction'); + expect(cfSendStub.thirdCall.args[0].input.IfMatch).to.equal('fn-etag'); + }); + + it('updates and publishes when the function already exists', async () => { + cfSendStub.onFirstCall().resolves({ ETag: 'dev-etag' }); // DescribeFunction DEVELOPMENT + cfSendStub.onSecondCall().resolves({ ETag: 'updated-etag' }); // UpdateFunction + cfSendStub.onThirdCall().resolves({}); // PublishFunction + + const result = await edgeOptimize.createEdgeOptimizeRoutingFunction({}, 'origin-aem'); + + expect(result.created).to.equal(false); + expect(cfSendStub.secondCall.args[0].commandName).to.equal('UpdateFunction'); + expect(cfSendStub.thirdCall.args[0].input.IfMatch).to.equal('updated-etag'); + }); + + it('throws when defaultOriginId is missing', async () => { + let error; + try { + await edgeOptimize.createEdgeOptimizeRoutingFunction({}, ''); + } catch (e) { + error = e; + } + expect(error.message).to.include('defaultOriginId'); + expect(cfSendStub.called).to.equal(false); + }); + + it('rethrows unexpected describe errors', async () => { + cfSendStub.onFirstCall().rejects(new Error('boom')); + let error; + try { + await edgeOptimize.createEdgeOptimizeRoutingFunction({}, 'origin-aem'); + } catch (e) { + error = e; + } + expect(error.message).to.equal('boom'); + }); + }); + + describe('applyEdgeOptimizeCacheHeaders', () => { + it('adds the EO headers to the behavior cache policy whitelist', async () => { + cfSendStub.onFirstCall().resolves({ + DistributionConfig: { DefaultCacheBehavior: { CachePolicyId: 'cp-1' } }, + }); + cfSendStub.onSecondCall().resolves({ + CachePolicyConfig: { + Name: 'my-policy', + MinTTL: 60, + ParametersInCacheKeyAndForwardedToOrigin: { + HeadersConfig: { HeaderBehavior: 'whitelist', Headers: { Quantity: 1, Items: ['accept'] } }, + }, + }, + ETag: 'cp-etag', + }); + cfSendStub.onThirdCall().resolves({}); + + const result = await edgeOptimize.applyEdgeOptimizeCacheHeaders({}, 'E2EXAMPLE', 'default'); + + expect(result.policyId).to.equal('cp-1'); + expect(result.updated).to.equal(true); + expect(cfSendStub.thirdCall.args[0].commandName).to.equal('UpdateCachePolicy'); + const updated = cfSendStub.thirdCall.args[0].input.CachePolicyConfig; + expect(updated.MinTTL).to.equal(0); + const items = updated.ParametersInCacheKeyAndForwardedToOrigin.HeadersConfig.Headers.Items; + expect(items).to.include('x-edgeoptimize-config'); + expect(items).to.include('x-edgeoptimize-url'); + }); + + it('is a no-op when headers are already forwarded and MinTTL is 0', async () => { + cfSendStub.onFirstCall().resolves({ + DistributionConfig: { DefaultCacheBehavior: { CachePolicyId: 'cp-1' } }, + }); + cfSendStub.onSecondCall().resolves({ + CachePolicyConfig: { + Name: 'my-policy', + MinTTL: 0, + ParametersInCacheKeyAndForwardedToOrigin: { + HeadersConfig: { + HeaderBehavior: 'whitelist', + Headers: { Quantity: 2, Items: ['x-edgeoptimize-config', 'x-edgeoptimize-url'] }, + }, + }, + }, + ETag: 'cp-etag', + }); + + const result = await edgeOptimize.applyEdgeOptimizeCacheHeaders({}, 'E2EXAMPLE', 'default'); + + expect(result).to.deep.equal({ policyId: 'cp-1', updated: false, alreadyForwarded: true }); + expect(cfSendStub.calledTwice).to.equal(true); // never updated + }); + + it('treats an allViewer header policy as already forwarded', async () => { + cfSendStub.onFirstCall().resolves({ + DistributionConfig: { DefaultCacheBehavior: { CachePolicyId: 'cp-1' } }, + }); + cfSendStub.onSecondCall().resolves({ + CachePolicyConfig: { + Name: 'p', + MinTTL: 0, + ParametersInCacheKeyAndForwardedToOrigin: { HeadersConfig: { HeaderBehavior: 'allViewer' } }, + }, + ETag: 'cp-etag', + }); + + const result = await edgeOptimize.applyEdgeOptimizeCacheHeaders({}, 'E2EXAMPLE', 'default'); + expect(result.updated).to.equal(false); + expect(result.alreadyForwarded).to.equal(true); + }); + + it('throws when the behavior has no cache policy (legacy/managed)', async () => { + cfSendStub.onFirstCall().resolves({ + DistributionConfig: { DefaultCacheBehavior: { ForwardedValues: {} } }, + }); + let error; + try { + await edgeOptimize.applyEdgeOptimizeCacheHeaders({}, 'E2EXAMPLE', 'default'); + } catch (e) { + error = e; + } + expect(error.message).to.include('custom cache policy'); + }); + + it('targets a named (non-default) behavior', async () => { + cfSendStub.onFirstCall().resolves({ + DistributionConfig: { + DefaultCacheBehavior: { CachePolicyId: 'cp-default' }, + CacheBehaviors: { Items: [{ PathPattern: '/api/*', CachePolicyId: 'cp-api' }] }, + }, + }); + cfSendStub.onSecondCall().resolves({ + CachePolicyConfig: { + Name: 'api', + MinTTL: 0, + ParametersInCacheKeyAndForwardedToOrigin: { HeadersConfig: { HeaderBehavior: 'none' } }, + }, + ETag: 'cp-etag', + }); + cfSendStub.onThirdCall().resolves({}); + + const result = await edgeOptimize.applyEdgeOptimizeCacheHeaders({}, 'E2EXAMPLE', '/api/*'); + expect(result.policyId).to.equal('cp-api'); + expect(cfSendStub.secondCall.args[0].input.Id).to.equal('cp-api'); + }); + + it('throws when pathPattern is missing', async () => { + let error; + try { + await edgeOptimize.applyEdgeOptimizeCacheHeaders({}, 'E2EXAMPLE', ''); + } catch (e) { + error = e; + } + expect(error.message).to.include('pathPattern'); + }); + }); + + describe('buildLambdaZip', () => { + it('produces a zip buffer with the local-file-header signature', () => { + const zip = edgeOptimize.buildLambdaZip('index.mjs', 'console.log(1)'); + expect(Buffer.isBuffer(zip)).to.equal(true); + expect(zip.readUInt32LE(0)).to.equal(0x04034b50); + }); + }); + + describe('createEdgeOptimizeLambda', () => { + const creds = { accessKeyId: 'A', secretAccessKey: 'S', sessionToken: 'T' }; + + it('creates the role, function and publishes a version', async () => { + iamSendStub.onFirstCall().rejects(Object.assign(new Error('no role'), { name: 'NoSuchEntityException' })); + iamSendStub.onSecondCall().resolves({ Role: { Arn: 'arn:aws:iam::120569600543:role/edgeoptimize-origin-role' } }); // CreateRole + iamSendStub.onThirdCall().resolves({}); // PutRolePolicy + + // Lambda flow: GetFunction (not found) -> CreateFunction -> GetFunctionConfiguration(Active) + // -> PublishVersion + lambdaSendStub.onCall(0).rejects(Object.assign(new Error('nf'), { name: 'ResourceNotFoundException' })); + lambdaSendStub.onCall(1).resolves({ FunctionArn: 'arn:fn' }); + lambdaSendStub.onCall(2).resolves({ State: 'Active' }); + lambdaSendStub.onCall(3).resolves({ FunctionArn: 'arn:fn:1', Version: '1' }); + + const result = await edgeOptimize.createEdgeOptimizeLambda(creds, '120569600543', { roleWaitMs: 0 }); + + expect(result).to.include({ + functionArn: 'arn:fn', + versionArn: 'arn:fn:1', + version: '1', + created: true, + }); + expect(result.roleArn).to.include('edgeoptimize-origin-role'); + expect(iamSendStub.secondCall.args[0].commandName).to.equal('CreateRole'); + expect(lambdaSendStub.getCall(1).args[0].commandName).to.equal('CreateFunction'); + expect(lambdaSendStub.getCall(1).args[0].input.Role).to.include('edgeoptimize-origin-role'); + expect(lambdaSendStub.getCall(3).args[0].commandName).to.equal('PublishVersion'); + }); + + it('updates the function code when it already exists', async () => { + iamSendStub.onFirstCall().resolves({ Role: { Arn: 'arn:role' } }); // GetRole + iamSendStub.onSecondCall().resolves({}); // UpdateAssumeRolePolicy + iamSendStub.onThirdCall().resolves({}); // PutRolePolicy + + lambdaSendStub.onCall(0).resolves({}); // GetFunction (exists) + lambdaSendStub.onCall(1).resolves({ FunctionArn: 'arn:fn' }); // UpdateFunctionCode + lambdaSendStub.onCall(2).resolves({ State: 'Active' }); + lambdaSendStub.onCall(3).resolves({ FunctionArn: 'arn:fn:2', Version: '2' }); + + const result = await edgeOptimize.createEdgeOptimizeLambda(creds, '120569600543'); + + expect(result.created).to.equal(false); + expect(result.version).to.equal('2'); + expect(lambdaSendStub.getCall(1).args[0].commandName).to.equal('UpdateFunctionCode'); + }); + + it('retries CreateFunction on role-propagation errors then succeeds', async () => { + iamSendStub.onFirstCall().rejects(Object.assign(new Error('no role'), { name: 'NoSuchEntityException' })); + iamSendStub.onSecondCall().resolves({ Role: { Arn: 'arn:role' } }); + iamSendStub.onThirdCall().resolves({}); + + const roleErr = Object.assign( + new Error('The role defined for the function cannot be assumed by Lambda'), + { name: 'InvalidParameterValueException' }, + ); + lambdaSendStub.onCall(0).rejects(Object.assign(new Error('nf'), { name: 'ResourceNotFoundException' })); + lambdaSendStub.onCall(1).rejects(roleErr); // first CreateFunction attempt + lambdaSendStub.onCall(2).resolves({ FunctionArn: 'arn:fn' }); // retry succeeds + lambdaSendStub.onCall(3).resolves({ State: 'Active' }); + lambdaSendStub.onCall(4).resolves({ FunctionArn: 'arn:fn:1', Version: '1' }); + + const result = await edgeOptimize.createEdgeOptimizeLambda( + creds, + '120569600543', + { roleWaitMs: 0, retryDelayMs: 0 }, + ); + + expect(result.version).to.equal('1'); + expect(lambdaSendStub.getCall(1).args[0].commandName).to.equal('CreateFunction'); + expect(lambdaSendStub.getCall(2).args[0].commandName).to.equal('CreateFunction'); + }); + + it('throws for an invalid account id', async () => { + let error; + try { + await edgeOptimize.createEdgeOptimizeLambda(creds, '123'); + } catch (e) { + error = e; + } + expect(error.message).to.include('12-digit'); + expect(iamSendStub.called).to.equal(false); + }); + }); + + describe('applyEdgeOptimizeAssociations', () => { + const lambdaArn = 'arn:aws:lambda:us-east-1:120569600543:function:edgeoptimize-origin:1'; + + it('wires the CF function (viewer-request) and Lambda (origin req/res) onto the behavior', async () => { + cfSendStub.onFirstCall().resolves({ + FunctionSummary: { FunctionMetadata: { FunctionARN: 'arn:cf-fn' } }, + }); + cfSendStub.onSecondCall().resolves({ + DistributionConfig: { DefaultCacheBehavior: {} }, + ETag: 'dist-etag', + }); + cfSendStub.onThirdCall().resolves({}); + + const result = await edgeOptimize.applyEdgeOptimizeAssociations({}, 'E2EXAMPLE', 'default', lambdaArn); + + expect(result).to.deep.equal({ cfFunctionArn: 'arn:cf-fn', lambdaArn }); + const update = cfSendStub.thirdCall.args[0]; + expect(update.commandName).to.equal('UpdateDistribution'); + const behavior = update.input.DistributionConfig.DefaultCacheBehavior; + expect(behavior.FunctionAssociations.Items[0]).to.deep.equal({ FunctionARN: 'arn:cf-fn', EventType: 'viewer-request' }); + expect(behavior.LambdaFunctionAssociations.Quantity).to.equal(2); + expect(behavior.LambdaFunctionAssociations.Items.map((i) => i.EventType)).to.deep.equal(['origin-request', 'origin-response']); + }); + + it('throws when the CF function is not published to LIVE', async () => { + cfSendStub.onFirstCall().resolves({ FunctionSummary: {} }); + let error; + try { + await edgeOptimize.applyEdgeOptimizeAssociations({}, 'E2EXAMPLE', 'default', lambdaArn); + } catch (e) { + error = e; + } + expect(error.message).to.include('not found or not published'); + }); + + it('surfaces a conflicting viewer-request association', async () => { + cfSendStub.onFirstCall().resolves({ + FunctionSummary: { FunctionMetadata: { FunctionARN: 'arn:cf-fn' } }, + }); + cfSendStub.onSecondCall().resolves({ + DistributionConfig: { + DefaultCacheBehavior: { + FunctionAssociations: { Items: [{ EventType: 'viewer-request', FunctionARN: 'arn:other-fn' }] }, + }, + }, + ETag: 'dist-etag', + }); + let error; + try { + await edgeOptimize.applyEdgeOptimizeAssociations({}, 'E2EXAMPLE', 'default', lambdaArn); + } catch (e) { + error = e; + } + expect(error.message).to.include('already has a different viewer-request function'); + }); + + it('throws when lambdaVersionArn is missing', async () => { + let error; + try { + await edgeOptimize.applyEdgeOptimizeAssociations({}, 'E2EXAMPLE', 'default', ''); + } catch (e) { + error = e; + } + expect(error.message).to.include('lambdaVersionArn'); + expect(cfSendStub.called).to.equal(false); + }); + }); + + describe('verifyEdgeOptimizeRouting', () => { + let fetchStub; + + const makeResponse = (status, headerMap) => ({ + status, + headers: { forEach: (cb) => Object.entries(headerMap).forEach(([k, v]) => cb(v, k)) }, + arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)), + }); + + afterEach(() => { + if (fetchStub) { + fetchStub.restore(); + } + fetchStub = undefined; + }); + + it('passes when the bot response carries x-edgeoptimize-request-id and the human does not', async () => { + fetchStub = sinon.stub(global, 'fetch'); + fetchStub.onFirstCall().resolves(makeResponse(200, { 'x-edgeoptimize-request-id': 'req-123' })); + fetchStub.onSecondCall().resolves(makeResponse(200, {})); + + const result = await edgeOptimize.verifyEdgeOptimizeRouting('https://d.cloudfront.net/'); + + expect(result.passed).to.equal(true); + expect(result.requestId).to.equal('req-123'); + expect(result.details.bot.status).to.equal(200); + }); + + it('does NOT pass when only failover (x-edgeoptimize-fo) is present', async () => { + fetchStub = sinon.stub(global, 'fetch'); + fetchStub.onFirstCall().resolves(makeResponse(200, { 'x-edgeoptimize-fo': '1' })); + fetchStub.onSecondCall().resolves(makeResponse(200, {})); + + const result = await edgeOptimize.verifyEdgeOptimizeRouting('https://d.cloudfront.net/'); + + expect(result.passed).to.equal(false); + expect(result.requestId).to.equal(null); + }); + + it('does NOT pass when the human response is also optimized', async () => { + fetchStub = sinon.stub(global, 'fetch'); + fetchStub.onFirstCall().resolves(makeResponse(200, { 'x-edgeoptimize-request-id': 'req-123' })); + fetchStub.onSecondCall().resolves(makeResponse(200, { 'x-edgeoptimize-request-id': 'req-999' })); + + const result = await edgeOptimize.verifyEdgeOptimizeRouting('https://d.cloudfront.net/'); + + expect(result.passed).to.equal(false); + }); + + it('throws when url is missing', async () => { + let error; + try { + await edgeOptimize.verifyEdgeOptimizeRouting(''); + } catch (e) { + error = e; + } + expect(error.message).to.include('url'); + }); + }); }); From 59cb8770a12707caf5b6e6612b791fe0131597de Mon Sep 17 00:00:00 2001 From: Akash Bhardwaj Date: Sat, 20 Jun 2026 20:52:10 +0530 Subject: [PATCH 12/56] fix(llmo): clone AWS-managed cache policy instead of failing (P0) The Default(*) behavior commonly uses an AWS-managed cache policy, which cannot be updated (UpdateCachePolicy -> 'update is not allowed for this policy'). applyEdgeOptimizeCacheHeaders now ports the full standalone-wizard logic with all three scenarios: - legacy (ForwardedValues, no CachePolicyId): add EO headers there + MinTTL 0 - custom policy: UpdateCachePolicy to add EO headers (existing path) - managed policy: CLONE into a custom edgeoptimize-cache policy with the EO headers, then repoint the behavior to it (idempotent by name) Adds GetCachePolicy/ListCachePolicies/CreateCachePolicy. Support tests rewritten to dispatch by command name and cover all three scenarios. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/support/edge-optimize.js | 173 ++++++++++++++++++------- test/support/edge-optimize.test.js | 201 ++++++++++++++++++++--------- 2 files changed, 265 insertions(+), 109 deletions(-) diff --git a/src/support/edge-optimize.js b/src/support/edge-optimize.js index 3227fb80f1..d621b3cde4 100644 --- a/src/support/edge-optimize.js +++ b/src/support/edge-optimize.js @@ -17,6 +17,9 @@ import { ListDistributionsCommand, GetDistributionConfigCommand, GetCachePolicyConfigCommand, + GetCachePolicyCommand, + ListCachePoliciesCommand, + CreateCachePolicyCommand, UpdateCachePolicyCommand, CreateFunctionCommand, UpdateFunctionCommand, @@ -56,6 +59,8 @@ export const EDGE_OPTIMIZE_LAMBDA_FUNCTION_NAME = 'edgeoptimize-origin'; export const EDGE_OPTIMIZE_LAMBDA_ROLE_NAME = 'edgeoptimize-origin-role'; // Headers the routing CloudFront Function sets and that must reach the EO origin uncached. export const EDGE_OPTIMIZE_CACHE_HEADERS = ['x-edgeoptimize-config', 'x-edgeoptimize-url']; +// Name of the custom cache policy we create when cloning an AWS-managed policy. +export const EDGE_OPTIMIZE_CACHE_POLICY_NAME = 'edgeoptimize-cache'; const delay = (ms) => new Promise((resolve) => { setTimeout(resolve, ms); @@ -397,12 +402,16 @@ export async function createEdgeOptimizeRoutingFunction( } /** - * Ensure the Edge Optimize routing headers are forwarded by the target behavior's cache policy. + * Add the Edge Optimize routing headers to the cache key/forwarded set for the target behavior. * - * Mirrors the common path of the standalone wizard's apply-cache (the "custom policy" scenario): - * reads the cache policy attached to the selected behavior and, if the EO headers are not already - * forwarded, adds them to the policy's whitelist via UpdateCachePolicy. Setting `setMinTTLZero` - * (default true) forces MinTTL to 0 so agentic responses are not over-cached. + * Ported from the standalone wizard's detect-cache + apply-cache (server.mjs). Handles all three + * scenarios the wizard supports, because real distributions commonly use an AWS-managed policy: + * - `legacy` — behavior has no CachePolicyId (uses ForwardedValues): add EO headers there. + * - `custom` — behavior uses a customer-owned cache policy: UpdateCachePolicy to add EO headers. + * - `managed` — behavior uses an AWS-managed policy (cannot be updated → "update is not allowed + * for this policy"): CLONE it into a custom `edgeoptimize-cache` policy with EO headers and + * repoint the behavior to it. Idempotent by policy name. + * `setMinTTLZero` (default true) forces MinTTL to 0 so agentic responses are not over-cached. * * @param {object} credentials - temporary credentials from {@link assumeConnectorRole}. * @param {string} distributionId - the CloudFront distribution ID. @@ -410,7 +419,8 @@ export async function createEdgeOptimizeRoutingFunction( * @param {object} [opts] * @param {boolean} [opts.setMinTTLZero=true] - force the policy MinTTL to 0. * @param {string} [opts.region] - CloudFront control-plane region. - * @returns {Promise<{policyId: string, updated: boolean, alreadyForwarded: boolean}>} + * @returns {Promise<{scenario: string, policyId: string|null, updated: boolean, + * alreadyForwarded: boolean, reused?: boolean}>} */ export async function applyEdgeOptimizeCacheHeaders( credentials, @@ -427,64 +437,135 @@ export async function applyEdgeOptimizeCacheHeaders( const client = new CloudFrontClient({ region, credentials }); const distResult = await client.send(new GetDistributionConfigCommand({ Id: distributionId })); - const behavior = getBehaviorFromConfig(distResult.DistributionConfig, pathPattern); + const config = distResult.DistributionConfig; + const behavior = getBehaviorFromConfig(config, pathPattern); const policyId = behavior.CachePolicyId; - // TODO: edge scenarios from the standalone wizard not yet ported here: - // - legacy behaviors using ForwardedValues (no CachePolicyId) - // - AWS-managed cache policies, which must be cloned into a custom `edgeoptimize-cache` policy - // (the connector role can create custom policies but cannot mutate managed ones). - // The common path — a custom cache policy already attached to the behavior — is implemented. + // ── Scenario A: legacy (ForwardedValues, no CachePolicyId) ────────────── if (!policyId) { - throw new Error( - `Behavior '${pathPattern}' uses legacy ForwardedValues or a managed cache policy; ` - + 'attach a custom cache policy before applying Edge Optimize cache headers.', - ); - } - - const pcResult = await client.send(new GetCachePolicyConfigCommand({ Id: policyId })); - const pc = pcResult.CachePolicyConfig; - const pcEtag = pcResult.ETag; - - const params = pc.ParametersInCacheKeyAndForwardedToOrigin || {}; - const hc = params.HeadersConfig || { HeaderBehavior: 'none' }; - - let alreadyForwarded = false; - if (hc.HeaderBehavior === 'allViewer' || hc.HeaderBehavior === 'all') { - alreadyForwarded = true; - } else { - const items = hc.Headers?.Items || []; + const fv = behavior.ForwardedValues || {}; + const items = fv.Headers?.Items || []; const lower = items.map((x) => x.toLowerCase()); - alreadyForwarded = EDGE_OPTIMIZE_CACHE_HEADERS.every((h) => lower.includes(h)); - if (!alreadyForwarded) { + let changed = false; + if (!lower.includes('*')) { EDGE_OPTIMIZE_CACHE_HEADERS.forEach((h) => { if (!lower.includes(h)) { items.push(h); + changed = true; } }); - hc.HeaderBehavior = 'whitelist'; - hc.Headers = { Quantity: items.length, Items: items }; - params.HeadersConfig = hc; - pc.ParametersInCacheKeyAndForwardedToOrigin = params; + fv.Headers = { Quantity: items.length, Items: items }; + behavior.ForwardedValues = fv; + } + if (setMinTTLZero && behavior.MinTTL !== 0) { + behavior.MinTTL = 0; + changed = true; + } + if (!changed) { + return { + scenario: 'legacy', policyId: null, updated: false, alreadyForwarded: true, + }; } + await client.send(new UpdateDistributionCommand({ + Id: distributionId, IfMatch: distResult.ETag, DistributionConfig: config, + })); + return { + scenario: 'legacy', policyId: null, updated: true, alreadyForwarded: false, + }; } - const needsMinTtl = setMinTTLZero && pc.MinTTL !== 0; - if (alreadyForwarded && !needsMinTtl) { - return { policyId, updated: false, alreadyForwarded: true }; + // Determine whether the attached policy is AWS-managed (managed policies cannot be updated). + const managedList = await client.send(new ListCachePoliciesCommand({ Type: 'managed' })); + const managedIds = new Set( + (managedList.CachePolicyList?.Items || []).map((i) => i.CachePolicy.Id), + ); + const isManaged = managedIds.has(policyId); + + // Helper: add the EO headers to a HeadersConfig in place; returns true if anything changed. + const addEoHeaders = (params) => { + const hc = params.HeadersConfig || { HeaderBehavior: 'none' }; + if (hc.HeaderBehavior === 'allViewer' || hc.HeaderBehavior === 'all') { + return false; + } + const items = hc.Headers?.Items || []; + const lower = items.map((x) => x.toLowerCase()); + const missing = EDGE_OPTIMIZE_CACHE_HEADERS.filter((h) => !lower.includes(h)); + if (missing.length === 0) { + return false; + } + missing.forEach((h) => items.push(h)); + hc.HeaderBehavior = 'whitelist'; + hc.Headers = { Quantity: items.length, Items: items }; + // eslint-disable-next-line no-param-reassign + params.HeadersConfig = hc; + return true; + }; + + // ── Scenario B: custom policy → update it in place ────────────────────── + if (!isManaged) { + const pcResult = await client.send(new GetCachePolicyConfigCommand({ Id: policyId })); + const pc = pcResult.CachePolicyConfig; + const params = pc.ParametersInCacheKeyAndForwardedToOrigin || {}; + const headersChanged = addEoHeaders(params); + pc.ParametersInCacheKeyAndForwardedToOrigin = params; + const needsMinTtl = setMinTTLZero && pc.MinTTL !== 0; + if (!headersChanged && !needsMinTtl) { + return { + scenario: 'custom', policyId, updated: false, alreadyForwarded: true, + }; + } + if (needsMinTtl) { + pc.MinTTL = 0; + } + await client.send(new UpdateCachePolicyCommand({ + Id: policyId, IfMatch: pcResult.ETag, CachePolicyConfig: pc, + })); + return { + scenario: 'custom', policyId, updated: true, alreadyForwarded: false, + }; } - if (needsMinTtl) { - pc.MinTTL = 0; + // ── Scenario C: managed policy → clone into edgeoptimize-cache + repoint ── + const srcResult = await client.send(new GetCachePolicyCommand({ Id: policyId })); + const cloned = JSON.parse(JSON.stringify(srcResult.CachePolicy.CachePolicyConfig)); + const sourceName = cloned.Name; + cloned.Name = EDGE_OPTIMIZE_CACHE_POLICY_NAME; + cloned.Comment = `Cloned from ${sourceName} with Edge Optimize headers — managed by LLM Optimizer`; + if (setMinTTLZero) { + cloned.MinTTL = 0; + } + const clonedParams = cloned.ParametersInCacheKeyAndForwardedToOrigin || {}; + addEoHeaders(clonedParams); + cloned.ParametersInCacheKeyAndForwardedToOrigin = clonedParams; + + // Idempotent: reuse an existing edgeoptimize-cache custom policy if a prior run created it. + const customList = await client.send(new ListCachePoliciesCommand({ Type: 'custom' })); + const existing = (customList.CachePolicyList?.Items || []).find( + (i) => i.CachePolicy.CachePolicyConfig.Name === EDGE_OPTIMIZE_CACHE_POLICY_NAME, + ); + let newPolicyId; + let reused = false; + if (existing) { + newPolicyId = existing.CachePolicy.Id; + reused = true; + } else { + const created = await client.send(new CreateCachePolicyCommand({ CachePolicyConfig: cloned })); + newPolicyId = created.CachePolicy.Id; } - await client.send(new UpdateCachePolicyCommand({ - Id: policyId, - IfMatch: pcEtag, - CachePolicyConfig: pc, + // Re-read the distribution for a fresh ETag, repoint the behavior to the new custom policy. + const freshDist = await client.send(new GetDistributionConfigCommand({ Id: distributionId })); + const freshConfig = freshDist.DistributionConfig; + const freshBehavior = getBehaviorFromConfig(freshConfig, pathPattern); + freshBehavior.CachePolicyId = newPolicyId; + delete freshBehavior.ForwardedValues; // cannot coexist with CachePolicyId + await client.send(new UpdateDistributionCommand({ + Id: distributionId, IfMatch: freshDist.ETag, DistributionConfig: freshConfig, })); - return { policyId, updated: true, alreadyForwarded }; + return { + scenario: 'managed', policyId: newPolicyId, updated: true, alreadyForwarded: false, reused, + }; } // The Lambda@Edge origin-request/response handler, ported verbatim from the standalone wizard's diff --git a/test/support/edge-optimize.test.js b/test/support/edge-optimize.test.js index 2e94376b69..5e8776447b 100644 --- a/test/support/edge-optimize.test.js +++ b/test/support/edge-optimize.test.js @@ -58,6 +58,9 @@ describe('edge-optimize support', () => { ListDistributionsCommand: cfCommand('ListDistributions'), GetDistributionConfigCommand: cfCommand('GetDistributionConfig'), GetCachePolicyConfigCommand: cfCommand('GetCachePolicyConfig'), + GetCachePolicyCommand: cfCommand('GetCachePolicy'), + ListCachePoliciesCommand: cfCommand('ListCachePolicies'), + CreateCachePolicyCommand: cfCommand('CreateCachePolicy'), UpdateCachePolicyCommand: cfCommand('UpdateCachePolicy'), CreateFunctionCommand: cfCommand('CreateFunction'), UpdateFunctionCommand: cfCommand('UpdateFunction'), @@ -402,109 +405,181 @@ describe('edge-optimize support', () => { }); describe('applyEdgeOptimizeCacheHeaders', () => { - it('adds the EO headers to the behavior cache policy whitelist', async () => { - cfSendStub.onFirstCall().resolves({ - DistributionConfig: { DefaultCacheBehavior: { CachePolicyId: 'cp-1' } }, + // Dispatch cfSendStub by command name so tests are robust to call order. + const wireCloudFront = (responders) => { + cfSendStub.callsFake((cmd) => { + const fn = responders[cmd.commandName]; + if (!fn) { + throw new Error(`unexpected command in test: ${cmd.commandName}`); + } + return Promise.resolve(typeof fn === 'function' ? fn(cmd) : fn); }); - cfSendStub.onSecondCall().resolves({ - CachePolicyConfig: { - Name: 'my-policy', - MinTTL: 60, - ParametersInCacheKeyAndForwardedToOrigin: { - HeadersConfig: { HeaderBehavior: 'whitelist', Headers: { Quantity: 1, Items: ['accept'] } }, + }; + + const lastCommand = (name) => cfSendStub.getCalls() + .filter((c) => c.args[0].commandName === name).pop()?.args[0]; + + it('updates a CUSTOM policy to add the EO headers + MinTTL 0', async () => { + wireCloudFront({ + GetDistributionConfig: { DistributionConfig: { DefaultCacheBehavior: { CachePolicyId: 'cp-1' } } }, + ListCachePolicies: { CachePolicyList: { Items: [{ CachePolicy: { Id: 'managed-x' } }] } }, + GetCachePolicyConfig: { + CachePolicyConfig: { + Name: 'my-policy', + MinTTL: 60, + ParametersInCacheKeyAndForwardedToOrigin: { + HeadersConfig: { HeaderBehavior: 'whitelist', Headers: { Quantity: 1, Items: ['accept'] } }, + }, }, + ETag: 'cp-etag', }, - ETag: 'cp-etag', + UpdateCachePolicy: {}, }); - cfSendStub.onThirdCall().resolves({}); const result = await edgeOptimize.applyEdgeOptimizeCacheHeaders({}, 'E2EXAMPLE', 'default'); + expect(result.scenario).to.equal('custom'); expect(result.policyId).to.equal('cp-1'); expect(result.updated).to.equal(true); - expect(cfSendStub.thirdCall.args[0].commandName).to.equal('UpdateCachePolicy'); - const updated = cfSendStub.thirdCall.args[0].input.CachePolicyConfig; + const updated = lastCommand('UpdateCachePolicy').input.CachePolicyConfig; expect(updated.MinTTL).to.equal(0); const items = updated.ParametersInCacheKeyAndForwardedToOrigin.HeadersConfig.Headers.Items; expect(items).to.include('x-edgeoptimize-config'); expect(items).to.include('x-edgeoptimize-url'); }); - it('is a no-op when headers are already forwarded and MinTTL is 0', async () => { - cfSendStub.onFirstCall().resolves({ - DistributionConfig: { DefaultCacheBehavior: { CachePolicyId: 'cp-1' } }, - }); - cfSendStub.onSecondCall().resolves({ - CachePolicyConfig: { - Name: 'my-policy', - MinTTL: 0, - ParametersInCacheKeyAndForwardedToOrigin: { - HeadersConfig: { - HeaderBehavior: 'whitelist', - Headers: { Quantity: 2, Items: ['x-edgeoptimize-config', 'x-edgeoptimize-url'] }, + it('is a no-op when a custom policy already forwards the headers and MinTTL is 0', async () => { + wireCloudFront({ + GetDistributionConfig: { DistributionConfig: { DefaultCacheBehavior: { CachePolicyId: 'cp-1' } } }, + ListCachePolicies: { CachePolicyList: { Items: [] } }, + GetCachePolicyConfig: { + CachePolicyConfig: { + Name: 'my-policy', + MinTTL: 0, + ParametersInCacheKeyAndForwardedToOrigin: { + HeadersConfig: { + HeaderBehavior: 'whitelist', + Headers: { Quantity: 2, Items: ['x-edgeoptimize-config', 'x-edgeoptimize-url'] }, + }, }, }, + ETag: 'cp-etag', }, - ETag: 'cp-etag', }); const result = await edgeOptimize.applyEdgeOptimizeCacheHeaders({}, 'E2EXAMPLE', 'default'); - expect(result).to.deep.equal({ policyId: 'cp-1', updated: false, alreadyForwarded: true }); - expect(cfSendStub.calledTwice).to.equal(true); // never updated + expect(result).to.deep.equal({ + scenario: 'custom', policyId: 'cp-1', updated: false, alreadyForwarded: true, + }); + expect(lastCommand('UpdateCachePolicy')).to.equal(undefined); // never updated }); - it('treats an allViewer header policy as already forwarded', async () => { - cfSendStub.onFirstCall().resolves({ - DistributionConfig: { DefaultCacheBehavior: { CachePolicyId: 'cp-1' } }, - }); - cfSendStub.onSecondCall().resolves({ - CachePolicyConfig: { - Name: 'p', - MinTTL: 0, - ParametersInCacheKeyAndForwardedToOrigin: { HeadersConfig: { HeaderBehavior: 'allViewer' } }, + it('CLONES an AWS-managed policy into edgeoptimize-cache and repoints the behavior', async () => { + wireCloudFront({ + GetDistributionConfig: { + DistributionConfig: { DefaultCacheBehavior: { CachePolicyId: 'managed-1', ForwardedValues: { x: 1 } } }, + ETag: 'dist-etag', + }, + ListCachePolicies: (cmd) => (cmd.input.Type === 'managed' + ? { CachePolicyList: { Items: [{ CachePolicy: { Id: 'managed-1' } }] } } + : { CachePolicyList: { Items: [] } }), // no existing custom edgeoptimize-cache + GetCachePolicy: { + CachePolicy: { + CachePolicyConfig: { + Name: 'Managed-CachingOptimized', + MinTTL: 1, + ParametersInCacheKeyAndForwardedToOrigin: { HeadersConfig: { HeaderBehavior: 'none' } }, + }, + }, }, - ETag: 'cp-etag', + CreateCachePolicy: { CachePolicy: { Id: 'new-eo-policy' } }, + UpdateDistribution: {}, }); const result = await edgeOptimize.applyEdgeOptimizeCacheHeaders({}, 'E2EXAMPLE', 'default'); - expect(result.updated).to.equal(false); - expect(result.alreadyForwarded).to.equal(true); + + expect(result.scenario).to.equal('managed'); + expect(result.policyId).to.equal('new-eo-policy'); + expect(result.reused).to.equal(false); + const created = lastCommand('CreateCachePolicy').input.CachePolicyConfig; + expect(created.Name).to.equal('edgeoptimize-cache'); + expect(created.MinTTL).to.equal(0); + const items = created.ParametersInCacheKeyAndForwardedToOrigin.HeadersConfig.Headers.Items; + expect(items).to.include('x-edgeoptimize-config'); + // behavior repointed to the new policy + ForwardedValues removed + const cfg = lastCommand('UpdateDistribution').input.DistributionConfig; + expect(cfg.DefaultCacheBehavior.CachePolicyId).to.equal('new-eo-policy'); + expect(cfg.DefaultCacheBehavior.ForwardedValues).to.equal(undefined); }); - it('throws when the behavior has no cache policy (legacy/managed)', async () => { - cfSendStub.onFirstCall().resolves({ - DistributionConfig: { DefaultCacheBehavior: { ForwardedValues: {} } }, + it('reuses an existing edgeoptimize-cache custom policy (idempotent managed path)', async () => { + wireCloudFront({ + GetDistributionConfig: { + DistributionConfig: { DefaultCacheBehavior: { CachePolicyId: 'managed-1' } }, + ETag: 'dist-etag', + }, + ListCachePolicies: (cmd) => (cmd.input.Type === 'managed' + ? { CachePolicyList: { Items: [{ CachePolicy: { Id: 'managed-1' } }] } } + : { CachePolicyList: { Items: [{ CachePolicy: { Id: 'existing-eo', CachePolicyConfig: { Name: 'edgeoptimize-cache' } } }] } }), + GetCachePolicy: { + CachePolicy: { CachePolicyConfig: { Name: 'Managed-X', ParametersInCacheKeyAndForwardedToOrigin: {} } }, + }, + UpdateDistribution: {}, }); - let error; - try { - await edgeOptimize.applyEdgeOptimizeCacheHeaders({}, 'E2EXAMPLE', 'default'); - } catch (e) { - error = e; - } - expect(error.message).to.include('custom cache policy'); + + const result = await edgeOptimize.applyEdgeOptimizeCacheHeaders({}, 'E2EXAMPLE', 'default'); + + expect(result.scenario).to.equal('managed'); + expect(result.policyId).to.equal('existing-eo'); + expect(result.reused).to.equal(true); + expect(lastCommand('CreateCachePolicy')).to.equal(undefined); // reused, not created }); - it('targets a named (non-default) behavior', async () => { - cfSendStub.onFirstCall().resolves({ - DistributionConfig: { - DefaultCacheBehavior: { CachePolicyId: 'cp-default' }, - CacheBehaviors: { Items: [{ PathPattern: '/api/*', CachePolicyId: 'cp-api' }] }, + it('handles a LEGACY behavior (ForwardedValues, no CachePolicyId)', async () => { + wireCloudFront({ + GetDistributionConfig: { + DistributionConfig: { + DefaultCacheBehavior: { ForwardedValues: { Headers: { Quantity: 1, Items: ['accept'] } }, MinTTL: 60 }, + }, + ETag: 'dist-etag', }, + UpdateDistribution: {}, }); - cfSendStub.onSecondCall().resolves({ - CachePolicyConfig: { - Name: 'api', - MinTTL: 0, - ParametersInCacheKeyAndForwardedToOrigin: { HeadersConfig: { HeaderBehavior: 'none' } }, + + const result = await edgeOptimize.applyEdgeOptimizeCacheHeaders({}, 'E2EXAMPLE', 'default'); + + expect(result.scenario).to.equal('legacy'); + expect(result.updated).to.equal(true); + const cfg = lastCommand('UpdateDistribution').input.DistributionConfig; + const items = cfg.DefaultCacheBehavior.ForwardedValues.Headers.Items; + expect(items).to.include('x-edgeoptimize-config'); + expect(cfg.DefaultCacheBehavior.MinTTL).to.equal(0); + }); + + it('targets a named (non-default) custom-policy behavior', async () => { + wireCloudFront({ + GetDistributionConfig: { + DistributionConfig: { + DefaultCacheBehavior: { CachePolicyId: 'cp-default' }, + CacheBehaviors: { Items: [{ PathPattern: '/api/*', CachePolicyId: 'cp-api' }] }, + }, }, - ETag: 'cp-etag', + ListCachePolicies: { CachePolicyList: { Items: [] } }, + GetCachePolicyConfig: { + CachePolicyConfig: { + Name: 'api', + MinTTL: 0, + ParametersInCacheKeyAndForwardedToOrigin: { HeadersConfig: { HeaderBehavior: 'none' } }, + }, + ETag: 'cp-etag', + }, + UpdateCachePolicy: {}, }); - cfSendStub.onThirdCall().resolves({}); const result = await edgeOptimize.applyEdgeOptimizeCacheHeaders({}, 'E2EXAMPLE', '/api/*'); expect(result.policyId).to.equal('cp-api'); - expect(cfSendStub.secondCall.args[0].input.Id).to.equal('cp-api'); + expect(lastCommand('GetCachePolicyConfig').input.Id).to.equal('cp-api'); }); it('throws when pathPattern is missing', async () => { From 9cb075f587204f22261837d5d5f1c070a4396de6 Mon Sep 17 00:00:00 2001 From: Akash Bhardwaj Date: Sun, 21 Jun 2026 14:13:39 +0530 Subject: [PATCH 13/56] fix(llmo): make Lambda@Edge step idempotent + idle-aware; add status endpoint Fixes the Lambda step's three failure modes (timeout, 'update in progress', no existence check): - waitForLambdaIdle now gates on State Active AND LastUpdateStatus != InProgress (was State only), so we never hit ResourceConflictException ('update is in progress') on a retry after a slow/timed-out first call. - createEdgeOptimizeLambda is fully idempotent: if a published numbered version already matches the current code, reuse it (no update/publish); otherwise update + publish. So a retry after a CDN first-byte timeout returns immediately instead of conflicting. - New read-only POST /sites/:siteId/llmo/edge-optimize/lambda-status (getEdgeOptimizeLambdaStatus) so the wizard can detect on entry and poll whether the function already exists with a published version. Support tests rewritten to dispatch by command name; status tests added. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/openapi/api.yaml | 2 + docs/openapi/llmo-api.yaml | 63 +++++++++ src/controllers/llmo/llmo.js | 34 +++++ src/routes/index.js | 1 + src/routes/required-capabilities.js | 1 + src/support/edge-optimize.js | 102 +++++++++++++-- test/controllers/llmo/llmo.test.js | 71 ++++++++++ test/routes/index.test.js | 2 + test/support/edge-optimize.test.js | 196 ++++++++++++++++++++-------- 9 files changed, 408 insertions(+), 64 deletions(-) diff --git a/docs/openapi/api.yaml b/docs/openapi/api.yaml index 32f1868d43..003d22c4be 100644 --- a/docs/openapi/api.yaml +++ b/docs/openapi/api.yaml @@ -643,6 +643,8 @@ paths: $ref: './llmo-api.yaml#/site-llmo-edge-optimize-apply-cache' /sites/{siteId}/llmo/edge-optimize/create-lambda: $ref: './llmo-api.yaml#/site-llmo-edge-optimize-create-lambda' + /sites/{siteId}/llmo/edge-optimize/lambda-status: + $ref: './llmo-api.yaml#/site-llmo-edge-optimize-lambda-status' /sites/{siteId}/llmo/edge-optimize/apply-associations: $ref: './llmo-api.yaml#/site-llmo-edge-optimize-apply-associations' /sites/{siteId}/llmo/edge-optimize/verify: diff --git a/docs/openapi/llmo-api.yaml b/docs/openapi/llmo-api.yaml index 00ab3d109c..ed95da1641 100644 --- a/docs/openapi/llmo-api.yaml +++ b/docs/openapi/llmo-api.yaml @@ -2949,6 +2949,69 @@ site-llmo-edge-optimize-create-lambda: security: - api_key: [ ] +site-llmo-edge-optimize-lambda-status: + post: + tags: + - llmo + summary: Read the Edge Optimize Lambda@Edge function status + description: | + Used by the CloudFront "Deploy routing" wizard to check on entry (and poll after a slow or + timed-out create) whether the `edgeoptimize-origin` Lambda@Edge function already exists and + has a published numbered version. Read-only — assumes the connector role and inspects the + function configuration + versions. + operationId: getEdgeOptimizeLambdaStatus + parameters: + - $ref: './parameters.yaml#/siteId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/edge-optimize-connector-request' + responses: + '200': + description: The Lambda@Edge function status. + content: + application/json: + schema: + type: object + required: + - exists + - versionArn + properties: + exists: + type: boolean + state: + type: string + lastUpdateStatus: + type: string + functionArn: + type: string + versionArn: + type: string + description: Latest published numbered version ARN, or null if not yet published. + version: + type: string + example: + exists: true + state: Active + lastUpdateStatus: Successful + functionArn: arn:aws:lambda:us-east-1:682033462621:function:edgeoptimize-origin + versionArn: arn:aws:lambda:us-east-1:682033462621:function:edgeoptimize-origin:1 + version: '1' + '400': + $ref: './responses.yaml#/400' + '401': + $ref: './responses.yaml#/401' + '403': + $ref: './responses.yaml#/403' + '404': + $ref: './responses.yaml#/404' + '500': + $ref: './responses.yaml#/500' + security: + - api_key: [ ] + site-llmo-edge-optimize-apply-associations: post: tags: diff --git a/src/controllers/llmo/llmo.js b/src/controllers/llmo/llmo.js index 39d2ecb30e..d3a9d1e003 100644 --- a/src/controllers/llmo/llmo.js +++ b/src/controllers/llmo/llmo.js @@ -41,6 +41,7 @@ import { createEdgeOptimizeRoutingFunction, applyEdgeOptimizeCacheHeaders, createEdgeOptimizeLambda, + getEdgeOptimizeLambdaStatus, applyEdgeOptimizeAssociations, verifyEdgeOptimizeRouting, } from '../../support/edge-optimize.js'; @@ -2555,6 +2556,38 @@ function LlmoController(ctx) { } }; + // Read-only status for the Lambda@Edge function so the wizard can detect on entry (and poll + // after a slow/timed-out create) whether it already exists with a published version. + const getEdgeOptimizeLambdaStatusHandler = async (context) => { + const { log, dataAccess, env } = context; + const { siteId } = context.params; + const { Site } = dataAccess; + const accountId = String(context.data?.accountId || '').replace(/\D/g, ''); + const externalId = String(context.data?.externalId || '').trim(); + const roleName = env?.EDGE_OPTIMIZE_ROLE_NAME || undefined; + + if (accountId.length !== 12) { + return badRequest('accountId must be a 12-digit AWS account ID'); + } + if (!hasText(externalId)) { + return badRequest('externalId is required'); + } + + try { + const { error } = await gateEdgeOptimizeWizard(siteId, Site, 'read the edge optimize Lambda@Edge status'); + if (error) { + return error; + } + + const { credentials } = await assumeConnectorRole({ accountId, externalId, roleName }); + const status = await getEdgeOptimizeLambdaStatus(credentials); + return ok(status); + } catch (error) { + log.error(`Failed to read Lambda@Edge status for site ${siteId}:`, error); + return badRequest(cleanupHeaderValue(error.message)); + } + }; + // Associate the routing CloudFront Function (viewer-request) and Lambda@Edge (origin-request/ // response, versioned ARN) onto the user-selected behavior (mutation). Used by "Associate". const applyEdgeOptimizeAssociationsHandler = async (context) => { @@ -2662,6 +2695,7 @@ function LlmoController(ctx) { createEdgeOptimizeRoutingFunction: createEdgeOptimizeRoutingFunctionHandler, applyEdgeOptimizeCache: applyEdgeOptimizeCacheHandler, createEdgeOptimizeLambda: createEdgeOptimizeLambdaHandler, + getEdgeOptimizeLambdaStatus: getEdgeOptimizeLambdaStatusHandler, applyEdgeOptimizeAssociations: applyEdgeOptimizeAssociationsHandler, verifyEdgeOptimizeRouting: verifyEdgeOptimizeRoutingHandler, getLlmoSheetData, diff --git a/src/routes/index.js b/src/routes/index.js index bb314348f0..40b4b73818 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -490,6 +490,7 @@ export default function getRouteHandlers( 'POST /sites/:siteId/llmo/edge-optimize/create-function': llmoController.createEdgeOptimizeRoutingFunction, 'POST /sites/:siteId/llmo/edge-optimize/apply-cache': llmoController.applyEdgeOptimizeCache, 'POST /sites/:siteId/llmo/edge-optimize/create-lambda': llmoController.createEdgeOptimizeLambda, + 'POST /sites/:siteId/llmo/edge-optimize/lambda-status': llmoController.getEdgeOptimizeLambdaStatus, 'POST /sites/:siteId/llmo/edge-optimize/apply-associations': llmoController.applyEdgeOptimizeAssociations, 'POST /sites/:siteId/llmo/edge-optimize/verify': llmoController.verifyEdgeOptimizeRouting, 'GET /sites/:siteId/llmo/strategy': llmoController.getStrategy, diff --git a/src/routes/required-capabilities.js b/src/routes/required-capabilities.js index c5527c9dad..fb7100d20e 100644 --- a/src/routes/required-capabilities.js +++ b/src/routes/required-capabilities.js @@ -137,6 +137,7 @@ export const INTERNAL_ROUTES = [ 'POST /sites/:siteId/llmo/edge-optimize/create-function', 'POST /sites/:siteId/llmo/edge-optimize/apply-cache', 'POST /sites/:siteId/llmo/edge-optimize/create-lambda', + 'POST /sites/:siteId/llmo/edge-optimize/lambda-status', 'POST /sites/:siteId/llmo/edge-optimize/apply-associations', 'POST /sites/:siteId/llmo/edge-optimize/verify', 'PUT /sites/:siteId/llmo/opportunities-reviewed', diff --git a/src/support/edge-optimize.js b/src/support/edge-optimize.js index d621b3cde4..6120aaab4b 100644 --- a/src/support/edge-optimize.js +++ b/src/support/edge-optimize.js @@ -38,8 +38,8 @@ import { LambdaClient, CreateFunctionCommand as LambdaCreateFunctionCommand, UpdateFunctionCodeCommand, - GetFunctionCommand, GetFunctionConfigurationCommand, + ListVersionsByFunctionCommand, PublishVersionCommand, } from '@aws-sdk/client-lambda'; import { hasText } from '@adobe/spacecat-shared-utils'; @@ -712,23 +712,40 @@ export function buildLambdaZip(filename, content) { } /* eslint-enable no-bitwise, max-statements-per-line, max-len */ -async function waitForLambdaActive(lambda, functionName, maxWaitMs = 30000) { +// Wait until the function is fully idle: State Active AND no update in progress. Lambda rejects +// UpdateFunctionCode / PublishVersion with ResourceConflictException ("update is in progress") +// while LastUpdateStatus is InProgress, so we must gate on both signals (not just State). +async function waitForLambdaIdle(lambda, functionName, maxWaitMs = 25000) { const deadline = Date.now() + maxWaitMs; /* eslint-disable no-await-in-loop */ while (Date.now() < deadline) { const cfg = await lambda.send( new GetFunctionConfigurationCommand({ FunctionName: functionName }), ); - if (cfg.State === 'Active') { - return; - } if (cfg.State === 'Failed') { throw new Error(`Lambda function entered Failed state: ${cfg.StateReason}`); } + if (cfg.State === 'Active' && cfg.LastUpdateStatus !== 'InProgress') { + return cfg; + } await delay(2000); } /* eslint-enable no-await-in-loop */ - throw new Error('Lambda function did not become Active within 30 s'); + throw new Error('Lambda function did not become idle in time'); +} + +// Latest published numbered version (skips $LATEST). Returns { versionArn, version, codeSha256 } +// or null when no numbered version has been published yet. +async function getLatestLambdaVersion(lambda, functionName) { + const resp = await lambda.send( + new ListVersionsByFunctionCommand({ FunctionName: functionName }), + ); + const numbered = (resp.Versions || []).filter((v) => v.Version && v.Version !== '$LATEST'); + if (numbered.length === 0) { + return null; + } + const latest = numbered.sort((a, b) => Number(b.Version) - Number(a.Version))[0]; + return { versionArn: latest.FunctionArn, version: latest.Version, codeSha256: latest.CodeSha256 }; } /** @@ -793,14 +810,17 @@ export async function createEdgeOptimizeLambda( PolicyDocument: buildCwLogsPolicy(String(accountId), EDGE_OPTIMIZE_LAMBDA_FUNCTION_NAME), })); - // ── 3. Create or update the function code. ── + // ── 3. Ensure the function exists with the current code (idempotent + idle-aware). ── let functionArn; let fnExists = false; + let currentCodeSha; try { - await lambda.send( - new GetFunctionCommand({ FunctionName: EDGE_OPTIMIZE_LAMBDA_FUNCTION_NAME }), + const cfg = await lambda.send( + new GetFunctionConfigurationCommand({ FunctionName: EDGE_OPTIMIZE_LAMBDA_FUNCTION_NAME }), ); fnExists = true; + functionArn = cfg.FunctionArn; + currentCodeSha = cfg.CodeSha256; } catch (err) { if (err.name !== 'ResourceNotFoundException') { throw err; @@ -808,11 +828,33 @@ export async function createEdgeOptimizeLambda( } if (fnExists) { - const updated = await lambda.send(new UpdateFunctionCodeCommand({ + // The function may still be finalizing a prior (possibly timed-out) call — wait for idle + // before touching it so we don't hit "update is in progress" (ResourceConflictException). + const idleCfg = await waitForLambdaIdle(lambda, EDGE_OPTIMIZE_LAMBDA_FUNCTION_NAME); + currentCodeSha = idleCfg.CodeSha256; + + // If a numbered version already exists for the CURRENT code, reuse it — fully idempotent, + // avoids version churn and lets a retry after a CDN timeout return immediately. + const existingVersion = await getLatestLambdaVersion( + lambda, + EDGE_OPTIMIZE_LAMBDA_FUNCTION_NAME, + ); + if (existingVersion && existingVersion.codeSha256 === currentCodeSha) { + return { + functionArn, + versionArn: existingVersion.versionArn, + version: existingVersion.version, + roleArn, + created: false, + alreadyExisted: true, + }; + } + + // Code differs (or no version yet) — update the code, wait for idle, then publish. + await lambda.send(new UpdateFunctionCodeCommand({ FunctionName: EDGE_OPTIMIZE_LAMBDA_FUNCTION_NAME, ZipFile: zipBuffer, })); - functionArn = updated.FunctionArn; } else { if (roleIsNew && roleWaitMs > 0) { await delay(roleWaitMs); @@ -850,8 +892,8 @@ export async function createEdgeOptimizeLambda( } } - // ── 4. Wait for Active, then publish a version (Lambda@Edge requires a numbered version). ── - await waitForLambdaActive(lambda, EDGE_OPTIMIZE_LAMBDA_FUNCTION_NAME); + // ── 4. Wait for idle, then publish a version (Lambda@Edge requires a numbered version). ── + await waitForLambdaIdle(lambda, EDGE_OPTIMIZE_LAMBDA_FUNCTION_NAME); const published = await lambda.send(new PublishVersionCommand({ FunctionName: EDGE_OPTIMIZE_LAMBDA_FUNCTION_NAME, Description: 'Published by LLM Optimizer CloudFront wizard', @@ -863,6 +905,40 @@ export async function createEdgeOptimizeLambda( version: published.Version, roleArn, created: !fnExists, + alreadyExisted: false, + }; +} + +/** + * Read-only status of the Edge Optimize Lambda@Edge function so the wizard can check on entry + * (and poll after a slow/timed-out create) whether it already exists and has a published version. + * + * @param {object} credentials - temporary credentials from {@link assumeConnectorRole}. + * @param {string} [region] - control-plane region. + * @returns {Promise<{exists: boolean, state?: string, lastUpdateStatus?: string, + * functionArn?: string, versionArn: string|null, version?: string}>} + */ +export async function getEdgeOptimizeLambdaStatus(credentials, region = EDGE_OPTIMIZE_REGION) { + const lambda = new LambdaClient({ region, credentials }); + let cfg; + try { + cfg = await lambda.send( + new GetFunctionConfigurationCommand({ FunctionName: EDGE_OPTIMIZE_LAMBDA_FUNCTION_NAME }), + ); + } catch (err) { + if (err.name === 'ResourceNotFoundException') { + return { exists: false, versionArn: null }; + } + throw err; + } + const latest = await getLatestLambdaVersion(lambda, EDGE_OPTIMIZE_LAMBDA_FUNCTION_NAME); + return { + exists: true, + state: cfg.State, + lastUpdateStatus: cfg.LastUpdateStatus, + functionArn: cfg.FunctionArn, + versionArn: latest?.versionArn || null, + version: latest?.version, }; } diff --git a/test/controllers/llmo/llmo.test.js b/test/controllers/llmo/llmo.test.js index 8ca0228ce3..cab086718f 100644 --- a/test/controllers/llmo/llmo.test.js +++ b/test/controllers/llmo/llmo.test.js @@ -93,6 +93,7 @@ describe('LlmoController', () => { let createEdgeOptimizeRoutingFunctionStub; let applyEdgeOptimizeCacheHeadersStub; let createEdgeOptimizeLambdaStub; + let getEdgeOptimizeLambdaStatusStub; let applyEdgeOptimizeAssociationsStub; let verifyEdgeOptimizeRoutingStub; let mockTokowakaClient; @@ -210,6 +211,7 @@ describe('LlmoController', () => { createEdgeOptimizeRoutingFunctionStub = sinon.stub(); applyEdgeOptimizeCacheHeadersStub = sinon.stub(); createEdgeOptimizeLambdaStub = sinon.stub(); + getEdgeOptimizeLambdaStatusStub = sinon.stub(); applyEdgeOptimizeAssociationsStub = sinon.stub(); verifyEdgeOptimizeRoutingStub = sinon.stub(); @@ -290,6 +292,7 @@ describe('LlmoController', () => { ), applyEdgeOptimizeCacheHeaders: (...args) => applyEdgeOptimizeCacheHeadersStub(...args), createEdgeOptimizeLambda: (...args) => createEdgeOptimizeLambdaStub(...args), + getEdgeOptimizeLambdaStatus: (...args) => getEdgeOptimizeLambdaStatusStub(...args), applyEdgeOptimizeAssociations: (...args) => applyEdgeOptimizeAssociationsStub(...args), verifyEdgeOptimizeRouting: (...args) => verifyEdgeOptimizeRoutingStub(...args), }, @@ -8545,6 +8548,74 @@ describe('LlmoController', () => { }); }); + describe('getEdgeOptimizeLambdaStatus', () => { + let statusContext; + + beforeEach(() => { + assumeConnectorRoleStub = sinon.stub().resolves({ + roleArn: 'arn:aws:iam::120569600543:role/AdobeLLMOptimizerCloudFrontConnectorRole', + accountId: '120569600543', + credentials: { accessKeyId: 'AKIA', secretAccessKey: 'secret', sessionToken: 'token' }, + }); + getEdgeOptimizeLambdaStatusStub = sinon.stub().resolves({ + exists: true, state: 'Active', lastUpdateStatus: 'Successful', versionArn: 'arn:fn:2', version: '2', + }); + statusContext = { + ...mockContext, + params: { siteId: TEST_SITE_ID }, + data: { accountId: '120569600543', externalId: '7ff9518a-cf59-40b4-aa53-68a3cb2e24a5' }, + env: {}, + }; + }); + + it('returns the Lambda@Edge status with the versioned ARN', async () => { + const result = await controller.getEdgeOptimizeLambdaStatus(statusContext); + + expect(result.status).to.equal(200); + const body = await result.json(); + expect(body.exists).to.equal(true); + expect(body.versionArn).to.equal('arn:fn:2'); + expect(getEdgeOptimizeLambdaStatusStub.calledOnce).to.equal(true); + }); + + it('returns exists:false when the function is absent', async () => { + getEdgeOptimizeLambdaStatusStub = sinon.stub().resolves({ exists: false, versionArn: null }); + const result = await controller.getEdgeOptimizeLambdaStatus(statusContext); + expect(result.status).to.equal(200); + const body = await result.json(); + expect(body.exists).to.equal(false); + expect(body.versionArn).to.equal(null); + }); + + it('returns 400 for an invalid account id', async () => { + const result = await controller.getEdgeOptimizeLambdaStatus({ ...statusContext, data: { accountId: '123', externalId: 'ext' } }); + expect(result.status).to.equal(400); + }); + + it('returns 400 when the external id is missing', async () => { + const result = await controller.getEdgeOptimizeLambdaStatus({ ...statusContext, data: { accountId: '120569600543' } }); + expect(result.status).to.equal(400); + }); + + it('returns 400 when the AWS call fails', async () => { + getEdgeOptimizeLambdaStatusStub = sinon.stub().rejects(new Error('ListVersions failed')); + const result = await controller.getEdgeOptimizeLambdaStatus(statusContext); + expect(result.status).to.equal(400); + }); + + it('returns 404 when the site is not found', async () => { + mockDataAccess.Site.findById.resolves(null); + const result = await controller.getEdgeOptimizeLambdaStatus(statusContext); + expect(result.status).to.equal(404); + }); + + it('returns 403 when the user lacks access to the site', async () => { + const deniedController = controllerWithAccessDenied(mockContext); + const result = await deniedController.getEdgeOptimizeLambdaStatus(statusContext); + expect(result.status).to.equal(403); + }); + }); + describe('applyEdgeOptimizeAssociations', () => { let associateContext; diff --git a/test/routes/index.test.js b/test/routes/index.test.js index 77692c7571..5f7389a2f7 100755 --- a/test/routes/index.test.js +++ b/test/routes/index.test.js @@ -336,6 +336,7 @@ describe('getRouteHandlers', () => { createEdgeOptimizeRoutingFunction: () => null, applyEdgeOptimizeCache: () => null, createEdgeOptimizeLambda: () => null, + getEdgeOptimizeLambdaStatus: () => null, applyEdgeOptimizeAssociations: () => null, verifyEdgeOptimizeRouting: () => null, getEdgeConfig: () => null, @@ -1094,6 +1095,7 @@ describe('getRouteHandlers', () => { 'POST /sites/:siteId/llmo/edge-optimize/create-function', 'POST /sites/:siteId/llmo/edge-optimize/apply-cache', 'POST /sites/:siteId/llmo/edge-optimize/create-lambda', + 'POST /sites/:siteId/llmo/edge-optimize/lambda-status', 'POST /sites/:siteId/llmo/edge-optimize/apply-associations', 'POST /sites/:siteId/llmo/edge-optimize/verify', 'GET /sites/:siteId/llmo/edge-optimize-status', diff --git a/test/support/edge-optimize.test.js b/test/support/edge-optimize.test.js index 5e8776447b..a975f6ef66 100644 --- a/test/support/edge-optimize.test.js +++ b/test/support/edge-optimize.test.js @@ -85,8 +85,8 @@ describe('edge-optimize support', () => { }, CreateFunctionCommand: lambdaCommand('CreateFunction'), UpdateFunctionCodeCommand: lambdaCommand('UpdateFunctionCode'), - GetFunctionCommand: lambdaCommand('GetFunction'), GetFunctionConfigurationCommand: lambdaCommand('GetFunctionConfiguration'), + ListVersionsByFunctionCommand: lambdaCommand('ListVersionsByFunction'), PublishVersionCommand: lambdaCommand('PublishVersion'), }, }); @@ -604,74 +604,117 @@ describe('edge-optimize support', () => { describe('createEdgeOptimizeLambda', () => { const creds = { accessKeyId: 'A', secretAccessKey: 'S', sessionToken: 'T' }; - it('creates the role, function and publishes a version', async () => { - iamSendStub.onFirstCall().rejects(Object.assign(new Error('no role'), { name: 'NoSuchEntityException' })); - iamSendStub.onSecondCall().resolves({ Role: { Arn: 'arn:aws:iam::120569600543:role/edgeoptimize-origin-role' } }); // CreateRole - iamSendStub.onThirdCall().resolves({}); // PutRolePolicy + // IAM + Lambda stubs dispatch by command name (robust to call order/poll counts). + const wireIam = (responders) => { + iamSendStub.callsFake((cmd) => { + const r = responders[cmd.commandName]; + return Promise.resolve(typeof r === 'function' ? r(cmd) : (r || {})); + }); + }; + const wireLambda = (responders) => { + lambdaSendStub.callsFake((cmd) => { + const r = responders[cmd.commandName]; + if (r === undefined) { + throw new Error(`unexpected lambda command: ${cmd.commandName}`); + } + return Promise.resolve(typeof r === 'function' ? r(cmd) : r); + }); + }; + const lastLambda = (name) => lambdaSendStub.getCalls() + .filter((c) => c.args[0].commandName === name).pop()?.args[0]; + const notFound = () => Promise.reject(Object.assign(new Error('nf'), { name: 'ResourceNotFoundException' })); - // Lambda flow: GetFunction (not found) -> CreateFunction -> GetFunctionConfiguration(Active) - // -> PublishVersion - lambdaSendStub.onCall(0).rejects(Object.assign(new Error('nf'), { name: 'ResourceNotFoundException' })); - lambdaSendStub.onCall(1).resolves({ FunctionArn: 'arn:fn' }); - lambdaSendStub.onCall(2).resolves({ State: 'Active' }); - lambdaSendStub.onCall(3).resolves({ FunctionArn: 'arn:fn:1', Version: '1' }); + it('creates the role, function and publishes a version', async () => { + wireIam({ + GetRole: () => Promise.reject(Object.assign(new Error('no role'), { name: 'NoSuchEntityException' })), + CreateRole: { Role: { Arn: 'arn:aws:iam::120569600543:role/edgeoptimize-origin-role' } }, + PutRolePolicy: {}, + }); + let fnCreated = false; + wireLambda({ + GetFunctionConfiguration: () => (fnCreated + ? { + FunctionArn: 'arn:fn', State: 'Active', LastUpdateStatus: 'Successful', CodeSha256: 'sha-new', + } + : notFound()), + CreateFunction: () => { + fnCreated = true; + return { FunctionArn: 'arn:fn' }; + }, + ListVersionsByFunction: { Versions: [{ Version: '$LATEST' }] }, + PublishVersion: { FunctionArn: 'arn:fn:1', Version: '1' }, + }); const result = await edgeOptimize.createEdgeOptimizeLambda(creds, '120569600543', { roleWaitMs: 0 }); expect(result).to.include({ - functionArn: 'arn:fn', - versionArn: 'arn:fn:1', - version: '1', - created: true, + functionArn: 'arn:fn', versionArn: 'arn:fn:1', version: '1', created: true, }); expect(result.roleArn).to.include('edgeoptimize-origin-role'); - expect(iamSendStub.secondCall.args[0].commandName).to.equal('CreateRole'); - expect(lambdaSendStub.getCall(1).args[0].commandName).to.equal('CreateFunction'); - expect(lambdaSendStub.getCall(1).args[0].input.Role).to.include('edgeoptimize-origin-role'); - expect(lambdaSendStub.getCall(3).args[0].commandName).to.equal('PublishVersion'); + expect(lastLambda('CreateFunction').input.Role).to.include('edgeoptimize-origin-role'); + expect(lastLambda('PublishVersion')).to.not.equal(undefined); }); - it('updates the function code when it already exists', async () => { - iamSendStub.onFirstCall().resolves({ Role: { Arn: 'arn:role' } }); // GetRole - iamSendStub.onSecondCall().resolves({}); // UpdateAssumeRolePolicy - iamSendStub.onThirdCall().resolves({}); // PutRolePolicy - - lambdaSendStub.onCall(0).resolves({}); // GetFunction (exists) - lambdaSendStub.onCall(1).resolves({ FunctionArn: 'arn:fn' }); // UpdateFunctionCode - lambdaSendStub.onCall(2).resolves({ State: 'Active' }); - lambdaSendStub.onCall(3).resolves({ FunctionArn: 'arn:fn:2', Version: '2' }); + it('is idempotent: reuses the existing version when code is unchanged (no update/publish)', async () => { + wireIam({ GetRole: { Role: { Arn: 'arn:role' } }, UpdateAssumeRolePolicy: {}, PutRolePolicy: {} }); + wireLambda({ + GetFunctionConfiguration: { + FunctionArn: 'arn:fn', State: 'Active', LastUpdateStatus: 'Successful', CodeSha256: 'sha-1', + }, + ListVersionsByFunction: { Versions: [{ Version: '$LATEST' }, { Version: '3', FunctionArn: 'arn:fn:3', CodeSha256: 'sha-1' }] }, + }); const result = await edgeOptimize.createEdgeOptimizeLambda(creds, '120569600543'); expect(result.created).to.equal(false); - expect(result.version).to.equal('2'); - expect(lambdaSendStub.getCall(1).args[0].commandName).to.equal('UpdateFunctionCode'); + expect(result.alreadyExisted).to.equal(true); + expect(result.versionArn).to.equal('arn:fn:3'); + expect(result.version).to.equal('3'); + expect(lastLambda('UpdateFunctionCode')).to.equal(undefined); // not re-updated + expect(lastLambda('PublishVersion')).to.equal(undefined); // not re-published }); - it('retries CreateFunction on role-propagation errors then succeeds', async () => { - iamSendStub.onFirstCall().rejects(Object.assign(new Error('no role'), { name: 'NoSuchEntityException' })); - iamSendStub.onSecondCall().resolves({ Role: { Arn: 'arn:role' } }); - iamSendStub.onThirdCall().resolves({}); + it('updates the code + publishes when the existing version is stale', async () => { + wireIam({ GetRole: { Role: { Arn: 'arn:role' } }, UpdateAssumeRolePolicy: {}, PutRolePolicy: {} }); + wireLambda({ + GetFunctionConfiguration: { + FunctionArn: 'arn:fn', State: 'Active', LastUpdateStatus: 'Successful', CodeSha256: 'sha-current', + }, + ListVersionsByFunction: { Versions: [{ Version: '1', FunctionArn: 'arn:fn:1', CodeSha256: 'sha-OLD' }] }, + UpdateFunctionCode: { FunctionArn: 'arn:fn' }, + PublishVersion: { FunctionArn: 'arn:fn:2', Version: '2' }, + }); - const roleErr = Object.assign( - new Error('The role defined for the function cannot be assumed by Lambda'), - { name: 'InvalidParameterValueException' }, - ); - lambdaSendStub.onCall(0).rejects(Object.assign(new Error('nf'), { name: 'ResourceNotFoundException' })); - lambdaSendStub.onCall(1).rejects(roleErr); // first CreateFunction attempt - lambdaSendStub.onCall(2).resolves({ FunctionArn: 'arn:fn' }); // retry succeeds - lambdaSendStub.onCall(3).resolves({ State: 'Active' }); - lambdaSendStub.onCall(4).resolves({ FunctionArn: 'arn:fn:1', Version: '1' }); + const result = await edgeOptimize.createEdgeOptimizeLambda(creds, '120569600543'); - const result = await edgeOptimize.createEdgeOptimizeLambda( - creds, - '120569600543', - { roleWaitMs: 0, retryDelayMs: 0 }, - ); + expect(result.created).to.equal(false); + expect(result.version).to.equal('2'); + expect(lastLambda('UpdateFunctionCode')).to.not.equal(undefined); + }); + + it('waits for an in-progress update to finish before publishing (no conflict)', async () => { + wireIam({ GetRole: { Role: { Arn: 'arn:role' } }, UpdateAssumeRolePolicy: {}, PutRolePolicy: {} }); + let polls = 0; + wireLambda({ + // existence check, then waitForLambdaIdle polls: InProgress first, then idle + GetFunctionConfiguration: () => { + polls += 1; + if (polls <= 1) { + return { + FunctionArn: 'arn:fn', State: 'Active', LastUpdateStatus: 'InProgress', CodeSha256: 'sha-1', + }; + } + return { + FunctionArn: 'arn:fn', State: 'Active', LastUpdateStatus: 'Successful', CodeSha256: 'sha-1', + }; + }, + ListVersionsByFunction: { Versions: [{ Version: '5', FunctionArn: 'arn:fn:5', CodeSha256: 'sha-1' }] }, + }); - expect(result.version).to.equal('1'); - expect(lambdaSendStub.getCall(1).args[0].commandName).to.equal('CreateFunction'); - expect(lambdaSendStub.getCall(2).args[0].commandName).to.equal('CreateFunction'); + const result = await edgeOptimize.createEdgeOptimizeLambda(creds, '120569600543'); + + expect(result.versionArn).to.equal('arn:fn:5'); + expect(polls).to.be.greaterThan(1); // it polled past the InProgress state }); it('throws for an invalid account id', async () => { @@ -686,6 +729,57 @@ describe('edge-optimize support', () => { }); }); + describe('getEdgeOptimizeLambdaStatus', () => { + it('reports exists:false when the function is absent', async () => { + lambdaSendStub.callsFake((cmd) => { + if (cmd.commandName === 'GetFunctionConfiguration') { + return Promise.reject(Object.assign(new Error('nf'), { name: 'ResourceNotFoundException' })); + } + throw new Error(`unexpected: ${cmd.commandName}`); + }); + + const result = await edgeOptimize.getEdgeOptimizeLambdaStatus({}); + + expect(result).to.deep.equal({ exists: false, versionArn: null }); + }); + + it('reports the latest published version when the function exists', async () => { + lambdaSendStub.callsFake((cmd) => { + if (cmd.commandName === 'GetFunctionConfiguration') { + return Promise.resolve({ FunctionArn: 'arn:fn', State: 'Active', LastUpdateStatus: 'Successful' }); + } + if (cmd.commandName === 'ListVersionsByFunction') { + return Promise.resolve({ Versions: [{ Version: '$LATEST' }, { Version: '2', FunctionArn: 'arn:fn:2' }] }); + } + throw new Error(`unexpected: ${cmd.commandName}`); + }); + + const result = await edgeOptimize.getEdgeOptimizeLambdaStatus({}); + + expect(result.exists).to.equal(true); + expect(result.state).to.equal('Active'); + expect(result.versionArn).to.equal('arn:fn:2'); + expect(result.version).to.equal('2'); + }); + + it('reports versionArn null when only $LATEST exists (not yet published)', async () => { + lambdaSendStub.callsFake((cmd) => { + if (cmd.commandName === 'GetFunctionConfiguration') { + return Promise.resolve({ FunctionArn: 'arn:fn', State: 'Pending', LastUpdateStatus: 'InProgress' }); + } + if (cmd.commandName === 'ListVersionsByFunction') { + return Promise.resolve({ Versions: [{ Version: '$LATEST' }] }); + } + throw new Error(`unexpected: ${cmd.commandName}`); + }); + + const result = await edgeOptimize.getEdgeOptimizeLambdaStatus({}); + + expect(result.exists).to.equal(true); + expect(result.versionArn).to.equal(null); + }); + }); + describe('applyEdgeOptimizeAssociations', () => { const lambdaArn = 'arn:aws:lambda:us-east-1:120569600543:function:edgeoptimize-origin:1'; From e9a32b879314f7f693c1126117f493eb253a898b Mon Sep 17 00:00:00 2001 From: Akash Bhardwaj Date: Sun, 21 Jun 2026 18:13:17 +0530 Subject: [PATCH 14/56] fix(llmo): non-blocking Lambda@Edge create + role status (async-friendly) create-lambda no longer blocks on a fresh function becoming Active (which exceeded the CDN first-byte timeout -> 503). It now ensures the role + kicks off the function create and returns { status: 'provisioning' | 'ready' } immediately; the UI polls until a published version exists. Also: - buildLambdaZip uses a fixed timestamp so CodeSha256 is deterministic (no version churn). - lambda-status now reports roleExists + a ready flag (role is created synchronously by the create ack) so the wizard can show role + function state and check on entry. - Removed the in-request waitForLambdaIdle/UpdateFunctionCode blocking path. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/support/edge-optimize.js | 144 ++++++++++++++--------------- test/support/edge-optimize.test.js | 107 +++++++++++---------- 2 files changed, 125 insertions(+), 126 deletions(-) diff --git a/src/support/edge-optimize.js b/src/support/edge-optimize.js index 6120aaab4b..412010a494 100644 --- a/src/support/edge-optimize.js +++ b/src/support/edge-optimize.js @@ -37,7 +37,6 @@ import { import { LambdaClient, CreateFunctionCommand as LambdaCreateFunctionCommand, - UpdateFunctionCodeCommand, GetFunctionConfigurationCommand, ListVersionsByFunctionCommand, PublishVersionCommand, @@ -682,9 +681,11 @@ export function buildLambdaZip(filename, content) { const compressed = deflateRawSync(data, { level: 9 }); const crcVal = crc32(data); const fn = Buffer.from(filename, 'utf-8'); - const now = new Date(); - const dosDate = ((now.getFullYear() - 1980) << 9) | ((now.getMonth() + 1) << 5) | now.getDate(); - const dosTime = (now.getHours() << 11) | (now.getMinutes() << 5) | (now.getSeconds() >> 1); + // Fixed DOS date/time (1980-01-01 00:00:00) so the zip — and thus the Lambda CodeSha256 — is + // deterministic for identical source. A timestamp here would change the hash on every call, + // causing needless code updates and version churn. + const dosDate = (0 << 9) | (1 << 5) | 1; + const dosTime = 0; const lh = Buffer.alloc(30 + fn.length); lh.writeUInt32LE(0x04034b50, 0); lh.writeUInt16LE(20, 4); lh.writeUInt16LE(0, 6); @@ -712,28 +713,6 @@ export function buildLambdaZip(filename, content) { } /* eslint-enable no-bitwise, max-statements-per-line, max-len */ -// Wait until the function is fully idle: State Active AND no update in progress. Lambda rejects -// UpdateFunctionCode / PublishVersion with ResourceConflictException ("update is in progress") -// while LastUpdateStatus is InProgress, so we must gate on both signals (not just State). -async function waitForLambdaIdle(lambda, functionName, maxWaitMs = 25000) { - const deadline = Date.now() + maxWaitMs; - /* eslint-disable no-await-in-loop */ - while (Date.now() < deadline) { - const cfg = await lambda.send( - new GetFunctionConfigurationCommand({ FunctionName: functionName }), - ); - if (cfg.State === 'Failed') { - throw new Error(`Lambda function entered Failed state: ${cfg.StateReason}`); - } - if (cfg.State === 'Active' && cfg.LastUpdateStatus !== 'InProgress') { - return cfg; - } - await delay(2000); - } - /* eslint-enable no-await-in-loop */ - throw new Error('Lambda function did not become idle in time'); -} - // Latest published numbered version (skips $LATEST). Returns { versionArn, version, codeSha256 } // or null when no numbered version has been published yet. async function getLatestLambdaVersion(lambda, functionName) { @@ -810,58 +789,30 @@ export async function createEdgeOptimizeLambda( PolicyDocument: buildCwLogsPolicy(String(accountId), EDGE_OPTIMIZE_LAMBDA_FUNCTION_NAME), })); - // ── 3. Ensure the function exists with the current code (idempotent + idle-aware). ── - let functionArn; - let fnExists = false; - let currentCodeSha; + // ── 3. Advance the function state machine WITHOUT blocking on provisioning. ── + // This runs behind a CDN/gateway with a ~60s first-byte timeout, so we must never wait for a + // fresh function to become Active (30–60s) inside the request. Each call does at most one fast + // step and returns `status: 'provisioning' | 'ready'`; the UI polls until ready. + let cfg = null; try { - const cfg = await lambda.send( + cfg = await lambda.send( new GetFunctionConfigurationCommand({ FunctionName: EDGE_OPTIMIZE_LAMBDA_FUNCTION_NAME }), ); - fnExists = true; - functionArn = cfg.FunctionArn; - currentCodeSha = cfg.CodeSha256; } catch (err) { if (err.name !== 'ResourceNotFoundException') { throw err; } } - if (fnExists) { - // The function may still be finalizing a prior (possibly timed-out) call — wait for idle - // before touching it so we don't hit "update is in progress" (ResourceConflictException). - const idleCfg = await waitForLambdaIdle(lambda, EDGE_OPTIMIZE_LAMBDA_FUNCTION_NAME); - currentCodeSha = idleCfg.CodeSha256; - - // If a numbered version already exists for the CURRENT code, reuse it — fully idempotent, - // avoids version churn and lets a retry after a CDN timeout return immediately. - const existingVersion = await getLatestLambdaVersion( - lambda, - EDGE_OPTIMIZE_LAMBDA_FUNCTION_NAME, - ); - if (existingVersion && existingVersion.codeSha256 === currentCodeSha) { - return { - functionArn, - versionArn: existingVersion.versionArn, - version: existingVersion.version, - roleArn, - created: false, - alreadyExisted: true, - }; - } - - // Code differs (or no version yet) — update the code, wait for idle, then publish. - await lambda.send(new UpdateFunctionCodeCommand({ - FunctionName: EDGE_OPTIMIZE_LAMBDA_FUNCTION_NAME, - ZipFile: zipBuffer, - })); - } else { + // Function does not exist yet → create it (returns fast in Pending) and report provisioning. + if (!cfg) { if (roleIsNew && roleWaitMs > 0) { await delay(roleWaitMs); } let lastErr; + let createdArn; /* eslint-disable no-await-in-loop */ - for (let attempt = 0; attempt < 5; attempt += 1) { + for (let attempt = 0; attempt < 3; attempt += 1) { try { const created = await lambda.send(new LambdaCreateFunctionCommand({ FunctionName: EDGE_OPTIMIZE_LAMBDA_FUNCTION_NAME, @@ -873,14 +824,21 @@ export async function createEdgeOptimizeLambda( Timeout: 5, MemorySize: 128, })); - functionArn = created.FunctionArn; + createdArn = created.FunctionArn; lastErr = null; break; } catch (createErr) { lastErr = createErr; + // A just-created role may not have propagated yet — short bounded retry, then give up + // (the next poll will succeed once it propagates) so we never block long. const isRolePropagation = createErr.name === 'InvalidParameterValueException' && (createErr.message || '').toLowerCase().includes('role'); - if (!isRolePropagation || attempt >= 4) { + if (createErr.name === 'ResourceConflictException') { + // Created concurrently by a prior (timed-out) call — treat as provisioning. + lastErr = null; + break; + } + if (!isRolePropagation || attempt >= 2) { throw createErr; } await delay(retryDelayMs); @@ -890,21 +848,44 @@ export async function createEdgeOptimizeLambda( if (lastErr) { throw lastErr; } + return { + status: 'provisioning', functionArn: createdArn, roleArn, created: true, versionArn: null, + }; + } + + // Still finalizing a create/update → report provisioning, don't touch it (avoids conflicts). + if (cfg.State === 'Pending' || cfg.LastUpdateStatus === 'InProgress') { + return { + status: 'provisioning', functionArn: cfg.FunctionArn, roleArn, created: false, versionArn: null, + }; + } + + // Active and idle. If a numbered version already exists, reuse it (idempotent). + const existingVersion = await getLatestLambdaVersion(lambda, EDGE_OPTIMIZE_LAMBDA_FUNCTION_NAME); + if (existingVersion) { + return { + status: 'ready', + functionArn: cfg.FunctionArn, + versionArn: existingVersion.versionArn, + version: existingVersion.version, + roleArn, + created: false, + alreadyExisted: true, + }; } - // ── 4. Wait for idle, then publish a version (Lambda@Edge requires a numbered version). ── - await waitForLambdaIdle(lambda, EDGE_OPTIMIZE_LAMBDA_FUNCTION_NAME); + // Active, idle, no version yet → publish one (fast on an idle function). const published = await lambda.send(new PublishVersionCommand({ FunctionName: EDGE_OPTIMIZE_LAMBDA_FUNCTION_NAME, Description: 'Published by LLM Optimizer CloudFront wizard', })); - return { - functionArn, + status: 'ready', + functionArn: cfg.FunctionArn, versionArn: published.FunctionArn, // includes the :N version suffix version: published.Version, roleArn, - created: !fnExists, + created: false, alreadyExisted: false, }; } @@ -920,6 +901,20 @@ export async function createEdgeOptimizeLambda( */ export async function getEdgeOptimizeLambdaStatus(credentials, region = EDGE_OPTIMIZE_REGION) { const lambda = new LambdaClient({ region, credentials }); + const iam = new IAMClient({ region, credentials }); + + // Execution role status (created synchronously by create-lambda's ack call). + let roleExists = false; + try { + await iam.send(new GetRoleCommand({ RoleName: EDGE_OPTIMIZE_LAMBDA_ROLE_NAME })); + roleExists = true; + } catch (err) { + if (err.name !== 'NoSuchEntityException') { + throw err; + } + } + + // Function status. let cfg; try { cfg = await lambda.send( @@ -927,18 +922,23 @@ export async function getEdgeOptimizeLambdaStatus(credentials, region = EDGE_OPT ); } catch (err) { if (err.name === 'ResourceNotFoundException') { - return { exists: false, versionArn: null }; + return { + roleExists, exists: false, versionArn: null, ready: false, + }; } throw err; } const latest = await getLatestLambdaVersion(lambda, EDGE_OPTIMIZE_LAMBDA_FUNCTION_NAME); + const ready = cfg.State === 'Active' && cfg.LastUpdateStatus !== 'InProgress' && !!latest; return { + roleExists, exists: true, state: cfg.State, lastUpdateStatus: cfg.LastUpdateStatus, functionArn: cfg.FunctionArn, versionArn: latest?.versionArn || null, version: latest?.version, + ready, }; } diff --git a/test/support/edge-optimize.test.js b/test/support/edge-optimize.test.js index a975f6ef66..134774b08d 100644 --- a/test/support/edge-optimize.test.js +++ b/test/support/edge-optimize.test.js @@ -624,97 +624,87 @@ describe('edge-optimize support', () => { .filter((c) => c.args[0].commandName === name).pop()?.args[0]; const notFound = () => Promise.reject(Object.assign(new Error('nf'), { name: 'ResourceNotFoundException' })); - it('creates the role, function and publishes a version', async () => { + it('creates the role + function (non-blocking) and returns provisioning', async () => { wireIam({ GetRole: () => Promise.reject(Object.assign(new Error('no role'), { name: 'NoSuchEntityException' })), CreateRole: { Role: { Arn: 'arn:aws:iam::120569600543:role/edgeoptimize-origin-role' } }, PutRolePolicy: {}, }); - let fnCreated = false; wireLambda({ - GetFunctionConfiguration: () => (fnCreated - ? { - FunctionArn: 'arn:fn', State: 'Active', LastUpdateStatus: 'Successful', CodeSha256: 'sha-new', - } - : notFound()), - CreateFunction: () => { - fnCreated = true; - return { FunctionArn: 'arn:fn' }; - }, - ListVersionsByFunction: { Versions: [{ Version: '$LATEST' }] }, - PublishVersion: { FunctionArn: 'arn:fn:1', Version: '1' }, + GetFunctionConfiguration: () => notFound(), + CreateFunction: { FunctionArn: 'arn:fn' }, }); const result = await edgeOptimize.createEdgeOptimizeLambda(creds, '120569600543', { roleWaitMs: 0 }); - expect(result).to.include({ - functionArn: 'arn:fn', versionArn: 'arn:fn:1', version: '1', created: true, - }); + // Does NOT block on the new function becoming Active — returns provisioning immediately. + expect(result.status).to.equal('provisioning'); + expect(result.created).to.equal(true); + expect(result.versionArn).to.equal(null); expect(result.roleArn).to.include('edgeoptimize-origin-role'); expect(lastLambda('CreateFunction').input.Role).to.include('edgeoptimize-origin-role'); - expect(lastLambda('PublishVersion')).to.not.equal(undefined); + expect(lastLambda('PublishVersion')).to.equal(undefined); // never publishes while Pending }); - it('is idempotent: reuses the existing version when code is unchanged (no update/publish)', async () => { + it('returns provisioning (no mutation) while the function is still finalizing', async () => { wireIam({ GetRole: { Role: { Arn: 'arn:role' } }, UpdateAssumeRolePolicy: {}, PutRolePolicy: {} }); wireLambda({ GetFunctionConfiguration: { - FunctionArn: 'arn:fn', State: 'Active', LastUpdateStatus: 'Successful', CodeSha256: 'sha-1', + FunctionArn: 'arn:fn', State: 'Active', LastUpdateStatus: 'InProgress', }, - ListVersionsByFunction: { Versions: [{ Version: '$LATEST' }, { Version: '3', FunctionArn: 'arn:fn:3', CodeSha256: 'sha-1' }] }, }); const result = await edgeOptimize.createEdgeOptimizeLambda(creds, '120569600543'); - expect(result.created).to.equal(false); - expect(result.alreadyExisted).to.equal(true); - expect(result.versionArn).to.equal('arn:fn:3'); - expect(result.version).to.equal('3'); - expect(lastLambda('UpdateFunctionCode')).to.equal(undefined); // not re-updated - expect(lastLambda('PublishVersion')).to.equal(undefined); // not re-published + expect(result.status).to.equal('provisioning'); + expect(result.versionArn).to.equal(null); + expect(lastLambda('PublishVersion')).to.equal(undefined); // never touched while InProgress }); - it('updates the code + publishes when the existing version is stale', async () => { + it('is idempotent: reuses the existing version when the function is idle', async () => { wireIam({ GetRole: { Role: { Arn: 'arn:role' } }, UpdateAssumeRolePolicy: {}, PutRolePolicy: {} }); wireLambda({ GetFunctionConfiguration: { - FunctionArn: 'arn:fn', State: 'Active', LastUpdateStatus: 'Successful', CodeSha256: 'sha-current', + FunctionArn: 'arn:fn', State: 'Active', LastUpdateStatus: 'Successful', }, - ListVersionsByFunction: { Versions: [{ Version: '1', FunctionArn: 'arn:fn:1', CodeSha256: 'sha-OLD' }] }, - UpdateFunctionCode: { FunctionArn: 'arn:fn' }, - PublishVersion: { FunctionArn: 'arn:fn:2', Version: '2' }, + ListVersionsByFunction: { Versions: [{ Version: '$LATEST' }, { Version: '3', FunctionArn: 'arn:fn:3' }] }, }); const result = await edgeOptimize.createEdgeOptimizeLambda(creds, '120569600543'); - expect(result.created).to.equal(false); - expect(result.version).to.equal('2'); - expect(lastLambda('UpdateFunctionCode')).to.not.equal(undefined); + expect(result.status).to.equal('ready'); + expect(result.alreadyExisted).to.equal(true); + expect(result.versionArn).to.equal('arn:fn:3'); + expect(lastLambda('PublishVersion')).to.equal(undefined); // reused, not re-published }); - it('waits for an in-progress update to finish before publishing (no conflict)', async () => { + it('publishes a version when the function is idle but unpublished', async () => { wireIam({ GetRole: { Role: { Arn: 'arn:role' } }, UpdateAssumeRolePolicy: {}, PutRolePolicy: {} }); - let polls = 0; wireLambda({ - // existence check, then waitForLambdaIdle polls: InProgress first, then idle - GetFunctionConfiguration: () => { - polls += 1; - if (polls <= 1) { - return { - FunctionArn: 'arn:fn', State: 'Active', LastUpdateStatus: 'InProgress', CodeSha256: 'sha-1', - }; - } - return { - FunctionArn: 'arn:fn', State: 'Active', LastUpdateStatus: 'Successful', CodeSha256: 'sha-1', - }; + GetFunctionConfiguration: { + FunctionArn: 'arn:fn', State: 'Active', LastUpdateStatus: 'Successful', }, - ListVersionsByFunction: { Versions: [{ Version: '5', FunctionArn: 'arn:fn:5', CodeSha256: 'sha-1' }] }, + ListVersionsByFunction: { Versions: [{ Version: '$LATEST' }] }, + PublishVersion: { FunctionArn: 'arn:fn:1', Version: '1' }, }); const result = await edgeOptimize.createEdgeOptimizeLambda(creds, '120569600543'); - expect(result.versionArn).to.equal('arn:fn:5'); - expect(polls).to.be.greaterThan(1); // it polled past the InProgress state + expect(result.status).to.equal('ready'); + expect(result.versionArn).to.equal('arn:fn:1'); + expect(lastLambda('PublishVersion')).to.not.equal(undefined); + }); + + it('treats a concurrent-create conflict as provisioning', async () => { + wireIam({ GetRole: { Role: { Arn: 'arn:role' } }, UpdateAssumeRolePolicy: {}, PutRolePolicy: {} }); + wireLambda({ + GetFunctionConfiguration: () => notFound(), + CreateFunction: () => Promise.reject(Object.assign(new Error('exists'), { name: 'ResourceConflictException' })), + }); + + const result = await edgeOptimize.createEdgeOptimizeLambda(creds, '120569600543', { roleWaitMs: 0 }); + + expect(result.status).to.equal('provisioning'); }); it('throws for an invalid account id', async () => { @@ -730,7 +720,8 @@ describe('edge-optimize support', () => { }); describe('getEdgeOptimizeLambdaStatus', () => { - it('reports exists:false when the function is absent', async () => { + it('reports roleExists:false + exists:false when nothing is provisioned', async () => { + iamSendStub.callsFake(() => Promise.reject(Object.assign(new Error('no role'), { name: 'NoSuchEntityException' }))); lambdaSendStub.callsFake((cmd) => { if (cmd.commandName === 'GetFunctionConfiguration') { return Promise.reject(Object.assign(new Error('nf'), { name: 'ResourceNotFoundException' })); @@ -740,10 +731,13 @@ describe('edge-optimize support', () => { const result = await edgeOptimize.getEdgeOptimizeLambdaStatus({}); - expect(result).to.deep.equal({ exists: false, versionArn: null }); + expect(result).to.deep.equal({ + roleExists: false, exists: false, versionArn: null, ready: false, + }); }); - it('reports the latest published version when the function exists', async () => { + it('reports the role + published version and ready:true when fully provisioned', async () => { + iamSendStub.callsFake(() => Promise.resolve({ Role: { Arn: 'arn:role' } })); lambdaSendStub.callsFake((cmd) => { if (cmd.commandName === 'GetFunctionConfiguration') { return Promise.resolve({ FunctionArn: 'arn:fn', State: 'Active', LastUpdateStatus: 'Successful' }); @@ -756,13 +750,16 @@ describe('edge-optimize support', () => { const result = await edgeOptimize.getEdgeOptimizeLambdaStatus({}); + expect(result.roleExists).to.equal(true); expect(result.exists).to.equal(true); expect(result.state).to.equal('Active'); expect(result.versionArn).to.equal('arn:fn:2'); expect(result.version).to.equal('2'); + expect(result.ready).to.equal(true); }); - it('reports versionArn null when only $LATEST exists (not yet published)', async () => { + it('reports ready:false (role created, still provisioning) when not yet published', async () => { + iamSendStub.callsFake(() => Promise.resolve({ Role: { Arn: 'arn:role' } })); lambdaSendStub.callsFake((cmd) => { if (cmd.commandName === 'GetFunctionConfiguration') { return Promise.resolve({ FunctionArn: 'arn:fn', State: 'Pending', LastUpdateStatus: 'InProgress' }); @@ -775,8 +772,10 @@ describe('edge-optimize support', () => { const result = await edgeOptimize.getEdgeOptimizeLambdaStatus({}); + expect(result.roleExists).to.equal(true); expect(result.exists).to.equal(true); expect(result.versionArn).to.equal(null); + expect(result.ready).to.equal(false); }); }); From be2feb734ebc62c36cbf4a9fd85e85d598ad9f0c Mon Sep 17 00:00:00 2001 From: Akash Bhardwaj Date: Sun, 21 Jun 2026 19:09:33 +0530 Subject: [PATCH 15/56] test(llmo): esmock edge-optimize once per file (fix CI heap OOM) edge-optimize.test.js re-ran esmock() in beforeEach, re-instantiating the mocked AWS SDK module graph on every test. As this file grew this session it accumulated enough memory to push the 12.5k-test suite past the 4GB V8 heap limit (worker OOM -> '1 failing: Worker terminated' + lost-worker coverage dropping below the 90% gate). Move esmock to a single before() hook and reset only the send stubs per test. Suite run time for this file drops from ~4min to ~7s and the leak is gone. Co-Authored-By: Claude Opus 4.8 (1M context) --- test/support/edge-optimize.test.js | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/test/support/edge-optimize.test.js b/test/support/edge-optimize.test.js index 134774b08d..f947be1b5b 100644 --- a/test/support/edge-optimize.test.js +++ b/test/support/edge-optimize.test.js @@ -21,12 +21,12 @@ describe('edge-optimize support', () => { let lambdaSendStub; let edgeOptimize; - beforeEach(async function setup() { + // esmock ONCE for the whole file (not per-test) — esmock re-instantiates the mocked module + // graph on every call and accumulates memory, which contributes to the suite's heap pressure. + // The mocked clients call the `*SendStub` closures, which read the `let` bindings reassigned + // fresh in beforeEach, so a single esmock works for all tests. + before(async function setupEsmock() { this.timeout(30000); - stsSendStub = sinon.stub(); - cfSendStub = sinon.stub(); - iamSendStub = sinon.stub(); - lambdaSendStub = sinon.stub(); // Each command in a mocked module is a constructor FUNCTION (not a class) — eslint forbids // multiple class declarations in one file, so we capture the command name + input on `this`. const cfCommand = (Name) => function CloudFrontCommand(input) { @@ -92,6 +92,14 @@ describe('edge-optimize support', () => { }); }); + beforeEach(() => { + // Fresh stubs per test; the esmocked clients read these `let` bindings at call time. + stsSendStub = sinon.stub(); + cfSendStub = sinon.stub(); + iamSendStub = sinon.stub(); + lambdaSendStub = sinon.stub(); + }); + afterEach(() => { sinon.restore(); }); From 5dc58a0f8126652c2b66322df7be10fbfa7a8fb9 Mon Sep 17 00:00:00 2001 From: Akash Bhardwaj Date: Mon, 22 Jun 2026 15:00:35 +0530 Subject: [PATCH 16/56] fix(llmo): set EO origin custom headers so Verify can pass The create-origin step created the EdgeOptimize_Origin without its custom headers, so the routing function's request could not authenticate to Edge Optimize or resolve the customer host - Verify never returned an x-edgeoptimize-request-id. - createEdgeOptimizeOrigin now sets x-edgeoptimize-api-key (site EO API key), x-forwarded-host (customer host), and optional x-edgeoptimize-fetcher-key, mirroring the standalone wizard + CloudFormation installer. - Self-heals: an origin created header-less by the earlier version is patched in place on re-run (returns updated: true). - Handler derives both server-side - api key from the tokowaka metaconfig (apiKeys[0]), forwarded host from calculateForwardedHost(site.baseURL) - so no new UI input; gateEdgeOptimizeWizard now returns the site to avoid a second fetch. - Verify: documented the prod TODO (probe the customer's real domain, not the *.cloudfront.net domain) - behavior unchanged for dev testing. Co-Authored-By: Claude Opus 4.8 --- src/controllers/llmo/llmo.js | 39 ++++++++++++-- src/support/edge-optimize.js | 81 ++++++++++++++++++++++++---- test/controllers/llmo/llmo.test.js | 39 ++++++++++++-- test/support/edge-optimize.test.js | 87 +++++++++++++++++++++++++++++- 4 files changed, 225 insertions(+), 21 deletions(-) diff --git a/src/controllers/llmo/llmo.js b/src/controllers/llmo/llmo.js index d3a9d1e003..95b3d81ce8 100644 --- a/src/controllers/llmo/llmo.js +++ b/src/controllers/llmo/llmo.js @@ -2186,7 +2186,7 @@ function LlmoController(ctx) { if (!accessControlUtil.isLLMOAdministrator()) { return { error: forbidden(`Only LLMO administrators can ${action}`) }; } - return {}; + return { site }; }; // Verify the customer's cross-account connector role is assumable. Used by the wizard's @@ -2417,14 +2417,37 @@ function LlmoController(ctx) { } try { - const { error } = await gateEdgeOptimizeWizard(siteId, Site, 'create the edge optimize origin'); + const { error, site } = await gateEdgeOptimizeWizard(siteId, Site, 'create the edge optimize origin'); if (error) { return error; } + // The EO origin needs custom headers so the routing function's request authenticates to Edge + // Optimize (x-edgeoptimize-api-key) and resolves the customer host (x-forwarded-host). Both + // are derived server-side from the site — no UI input. Without them Verify never goes green. + const baseURL = site.getBaseURL(); + const tokowakaClient = TokowakaClient.createFrom(context); + const metaconfig = await tokowakaClient.fetchMetaconfig(baseURL); + const apiKey = metaconfig?.apiKeys?.[0]; + if (!hasText(apiKey)) { + return badRequest('Site has no Edge Optimize API key — enable Edge Optimize for this site first'); + } + const forwardedHost = calculateForwardedHost(baseURL, log); + const { credentials } = await assumeConnectorRole({ accountId, externalId, roleName }); - const result = await createEdgeOptimizeOrigin(credentials, distributionId, originDomain); - log.info(`[edge-optimize-origin] ${result.created ? 'Created' : 'Origin already existed for'} site ${siteId}, distribution ${distributionId}`); + const result = await createEdgeOptimizeOrigin( + credentials, + distributionId, + originDomain, + { apiKey, forwardedHost }, + ); + let action = 'Origin already existed for'; + if (result.created) { + action = 'Created origin for'; + } else if (result.updated) { + action = 'Patched origin headers for'; + } + log.info(`[edge-optimize-origin] ${action} site ${siteId}, distribution ${distributionId}`); return ok(result); } catch (error) { log.error(`Failed to create CloudFront Edge Optimize origin for site ${siteId}:`, error); @@ -2663,6 +2686,14 @@ function LlmoController(ctx) { } // Determine the URL to probe: prefer an explicit domain, else resolve from the distribution. + // + // TODO(prod): probe the customer's REAL onboarded domain, not the *.cloudfront.net domain. + // In prod, bot traffic hits the customer's own domain (their CNAME / distribution alias), so + // that is the true end-to-end test. We have it server-side via the site + // (calculateForwardedHost(site.getBaseURL())) and/or the distribution Aliases. We default to + // the distribution cloudfront.net DomainName today only because the dev test distribution has + // no custom domain. Switch the default to the site/alias domain before prod (keep the + // explicit `domain` override + cloudfront.net fallback for distributions with no alias). let domain = String(context.data?.domain || '').trim(); if (!hasText(domain)) { const { credentials } = await assumeConnectorRole({ accountId, externalId, roleName }); diff --git a/src/support/edge-optimize.js b/src/support/edge-optimize.js index 412010a494..aa4ceace3f 100644 --- a/src/support/edge-optimize.js +++ b/src/support/edge-optimize.js @@ -205,47 +205,104 @@ function getBehaviorFromConfig(config, pathPattern) { } /** - * Add the Edge Optimize origin to a CloudFront distribution (idempotent). + * Build the custom-header items the EO origin must carry. Mirrors the standalone wizard's + * apiCreateOrigin (server.mjs) + the CloudFormation installer: `x-edgeoptimize-api-key` + * authenticates the prerender request to Edge Optimize, `x-forwarded-host` tells EO which site's + * content to serve, and the optional `x-edgeoptimize-fetcher-key` is for WAF-allowlisted customers. + * Without these the origin returns no `x-edgeoptimize-request-id` and Verify never goes green. * - * Mirrors the standalone wizard's create-origin: reads the distribution config, and — only if no - * Edge Optimize origin exists yet — appends a custom HTTPS origin pointing at the EO target domain - * with the EO request headers, then writes it back via UpdateDistribution (deploy propagates in the - * background; we do not block on it). + * @param {object} headers + * @param {string} [headers.apiKey] - the site's Edge Optimize API key. + * @param {string} [headers.forwardedHost] - the customer's canonical site host. + * @param {string} [headers.fetcherKey] - optional fetcher key (WAF allowlist). + * @returns {Array<{HeaderName: string, HeaderValue: string}>} + */ +function buildEdgeOptimizeOriginHeaders({ apiKey, forwardedHost, fetcherKey } = {}) { + const items = []; + if (hasText(apiKey)) { + items.push({ HeaderName: 'x-edgeoptimize-api-key', HeaderValue: apiKey }); + } + if (hasText(forwardedHost)) { + items.push({ HeaderName: 'x-forwarded-host', HeaderValue: forwardedHost }); + } + if (hasText(fetcherKey)) { + items.push({ HeaderName: 'x-edgeoptimize-fetcher-key', HeaderValue: fetcherKey }); + } + return items; +} + +/** + * Add the Edge Optimize origin to a CloudFront distribution (idempotent + self-healing). + * + * Reads the distribution config and, if no Edge Optimize origin exists yet, appends a custom HTTPS + * origin pointing at the EO target domain with the EO request headers. If the origin already exists + * but its custom headers do not match the desired set (e.g. it was created header-less by an + * earlier version), the headers are patched in place. Writes are applied via UpdateDistribution + * (deploy propagates in the background; we do not block on it). * * @param {object} credentials - temporary credentials from {@link assumeConnectorRole}. * @param {string} distributionId - the CloudFront distribution ID. * @param {string} [originDomain] - EO origin domain (env-driven; defaults to the dev EO domain). + * @param {object} [headers] - EO origin headers ({ apiKey, forwardedHost, fetcherKey }). * @param {string} [region] - CloudFront control-plane region. - * @returns {Promise<{created: boolean, alreadyExisted: boolean, originId: string}>} + * @returns {Promise<{created, alreadyExisted, updated, originId}>} origin mutation outcome. */ export async function createEdgeOptimizeOrigin( credentials, distributionId, originDomain = EDGE_OPTIMIZE_DEFAULT_ORIGIN_DOMAIN, + headers = {}, region = EDGE_OPTIMIZE_REGION, ) { if (!hasText(distributionId)) { throw new Error('distributionId is required'); } + const desiredHeaderItems = buildEdgeOptimizeOriginHeaders(headers); + const client = new CloudFrontClient({ region, credentials }); const result = await client.send(new GetDistributionConfigCommand({ Id: distributionId })); const config = result.DistributionConfig; const etag = result.ETag; const origins = config.Origins?.Items || []; - const alreadyExisted = origins.some( + const existing = origins.find( (o) => o.Id === EDGE_OPTIMIZE_ORIGIN_ID || o.DomainName === originDomain, ); - if (alreadyExisted) { - return { created: false, alreadyExisted: true, originId: EDGE_OPTIMIZE_ORIGIN_ID }; + if (existing) { + // Idempotent — but self-heal an origin created without the EO headers (earlier bug): patch its + // CustomHeaders to the desired set when they differ. Never wipe headers if none were supplied. + const toMap = (arr) => (arr || []).reduce((acc, h) => { + acc[h.HeaderName.toLowerCase()] = h.HeaderValue; + return acc; + }, {}); + const current = toMap(existing.CustomHeaders?.Items); + const desired = toMap(desiredHeaderItems); + const headersMatch = Object.keys(desired).length === Object.keys(current).length + && Object.entries(desired).every(([k, v]) => current[k] === v); + + if (desiredHeaderItems.length === 0 || headersMatch) { + return { + created: false, alreadyExisted: true, updated: false, originId: EDGE_OPTIMIZE_ORIGIN_ID, + }; + } + + existing.CustomHeaders = { Quantity: desiredHeaderItems.length, Items: desiredHeaderItems }; + await client.send(new UpdateDistributionCommand({ + Id: distributionId, + IfMatch: etag, + DistributionConfig: config, + })); + return { + created: false, alreadyExisted: true, updated: true, originId: EDGE_OPTIMIZE_ORIGIN_ID, + }; } origins.push({ Id: EDGE_OPTIMIZE_ORIGIN_ID, DomainName: originDomain, OriginPath: '', - CustomHeaders: { Quantity: 0, Items: [] }, + CustomHeaders: { Quantity: desiredHeaderItems.length, Items: desiredHeaderItems }, CustomOriginConfig: { HTTPPort: 80, HTTPSPort: 443, @@ -265,7 +322,9 @@ export async function createEdgeOptimizeOrigin( DistributionConfig: config, })); - return { created: true, alreadyExisted: false, originId: EDGE_OPTIMIZE_ORIGIN_ID }; + return { + created: true, alreadyExisted: false, updated: false, originId: EDGE_OPTIMIZE_ORIGIN_ID, + }; } /** diff --git a/test/controllers/llmo/llmo.test.js b/test/controllers/llmo/llmo.test.js index cab086718f..99575094ef 100644 --- a/test/controllers/llmo/llmo.test.js +++ b/test/controllers/llmo/llmo.test.js @@ -8193,8 +8193,9 @@ describe('LlmoController', () => { credentials: { accessKeyId: 'AKIA', secretAccessKey: 'secret', sessionToken: 'token' }, }); createEdgeOptimizeOriginStub = sinon.stub().resolves({ - created: true, alreadyExisted: false, originId: 'EdgeOptimize_Origin', + created: true, alreadyExisted: false, updated: false, originId: 'EdgeOptimize_Origin', }); + mockTokowakaClient.fetchMetaconfig.resolves({ apiKeys: ['eo-key-123'] }); originContext = { ...mockContext, params: { siteId: TEST_SITE_ID }, @@ -8212,9 +8213,16 @@ describe('LlmoController', () => { expect(result.status).to.equal(200); const body = await result.json(); - expect(body).to.deep.equal({ created: true, alreadyExisted: false, originId: 'EdgeOptimize_Origin' }); + expect(body).to.deep.equal({ + created: true, alreadyExisted: false, updated: false, originId: 'EdgeOptimize_Origin', + }); expect(assumeConnectorRoleStub.calledOnce).to.equal(true); - expect(createEdgeOptimizeOriginStub.calledOnceWith(sinon.match.any, 'E2EXAMPLE123', 'dev.edgeoptimize.net')).to.equal(true); + expect(createEdgeOptimizeOriginStub.calledOnceWith( + sinon.match.any, + 'E2EXAMPLE123', + 'dev.edgeoptimize.net', + sinon.match({ apiKey: 'eo-key-123', forwardedHost: 'www.example.com' }), + )).to.equal(true); }); it('passes the env-driven origin domain when set', async () => { @@ -8226,9 +8234,20 @@ describe('LlmoController', () => { expect(createEdgeOptimizeOriginStub.calledOnceWith(sinon.match.any, 'E2EXAMPLE123', 'live.edgeoptimize.net')).to.equal(true); }); + it('returns 400 when the site has no Edge Optimize API key', async () => { + mockTokowakaClient.fetchMetaconfig.resolves({ apiKeys: [] }); + + const result = await controller.createEdgeOptimizeOrigin(originContext); + + expect(result.status).to.equal(400); + const body = await result.json(); + expect(body.message).to.include('API key'); + expect(createEdgeOptimizeOriginStub.called).to.equal(false); + }); + it('is idempotent when the origin already exists', async () => { createEdgeOptimizeOriginStub = sinon.stub().resolves({ - created: false, alreadyExisted: true, originId: 'EdgeOptimize_Origin', + created: false, alreadyExisted: true, updated: false, originId: 'EdgeOptimize_Origin', }); const result = await controller.createEdgeOptimizeOrigin(originContext); @@ -8236,6 +8255,18 @@ describe('LlmoController', () => { expect(body.alreadyExisted).to.equal(true); }); + it('reports a header patch on an existing header-less origin', async () => { + createEdgeOptimizeOriginStub = sinon.stub().resolves({ + created: false, alreadyExisted: true, updated: true, originId: 'EdgeOptimize_Origin', + }); + + const result = await controller.createEdgeOptimizeOrigin(originContext); + + expect(result.status).to.equal(200); + const body = await result.json(); + expect(body.updated).to.equal(true); + }); + it('returns 400 for an invalid account id', async () => { const result = await controller.createEdgeOptimizeOrigin({ ...originContext, data: { ...originContext.data, accountId: '123' } }); expect(result.status).to.equal(400); diff --git a/test/support/edge-optimize.test.js b/test/support/edge-optimize.test.js index f947be1b5b..f4be403820 100644 --- a/test/support/edge-optimize.test.js +++ b/test/support/edge-optimize.test.js @@ -304,7 +304,9 @@ describe('edge-optimize support', () => { const result = await edgeOptimize.createEdgeOptimizeOrigin({}, 'E2EXAMPLE', 'dev.edgeoptimize.net'); - expect(result).to.deep.equal({ created: true, alreadyExisted: false, originId: 'EdgeOptimize_Origin' }); + expect(result).to.deep.equal({ + created: true, alreadyExisted: false, updated: false, originId: 'EdgeOptimize_Origin', + }); expect(cfSendStub.secondCall.args[0].commandName).to.equal('UpdateDistribution'); const update = cfSendStub.secondCall.args[0].input; expect(update.IfMatch).to.equal('etag-1'); @@ -313,6 +315,31 @@ describe('edge-optimize support', () => { expect(added.CustomOriginConfig.OriginProtocolPolicy).to.equal('https-only'); }); + it('sets the EO custom headers on the new origin', async () => { + cfSendStub.onFirstCall().resolves({ + DistributionConfig: { Origins: { Quantity: 1, Items: [{ Id: 'origin-aem', DomainName: 'origin.example.com' }] } }, + ETag: 'etag-1', + }); + cfSendStub.onSecondCall().resolves({}); + + await edgeOptimize.createEdgeOptimizeOrigin({}, 'E2EXAMPLE', 'dev.edgeoptimize.net', { + apiKey: 'eo-key-123', forwardedHost: 'www.example.com', fetcherKey: 'fk-9', + }); + + const update = cfSendStub.secondCall.args[0].input; + const added = update.DistributionConfig.Origins.Items.find((o) => o.Id === 'EdgeOptimize_Origin'); + expect(added.CustomHeaders.Quantity).to.equal(3); + const headerMap = added.CustomHeaders.Items.reduce((acc, h) => { + acc[h.HeaderName] = h.HeaderValue; + return acc; + }, {}); + expect(headerMap).to.deep.equal({ + 'x-edgeoptimize-api-key': 'eo-key-123', + 'x-forwarded-host': 'www.example.com', + 'x-edgeoptimize-fetcher-key': 'fk-9', + }); + }); + it('is idempotent when the origin already exists by id', async () => { cfSendStub.resolves({ DistributionConfig: { Origins: { Quantity: 1, Items: [{ Id: 'EdgeOptimize_Origin', DomainName: 'x' }] } }, @@ -321,7 +348,9 @@ describe('edge-optimize support', () => { const result = await edgeOptimize.createEdgeOptimizeOrigin({}, 'E2EXAMPLE'); - expect(result).to.deep.equal({ created: false, alreadyExisted: true, originId: 'EdgeOptimize_Origin' }); + expect(result).to.deep.equal({ + created: false, alreadyExisted: true, updated: false, originId: 'EdgeOptimize_Origin', + }); expect(cfSendStub.calledOnce).to.equal(true); // never updated }); @@ -337,6 +366,60 @@ describe('edge-optimize support', () => { expect(cfSendStub.calledOnce).to.equal(true); }); + it('patches the headers when the origin exists without them (self-heal)', async () => { + cfSendStub.onFirstCall().resolves({ + DistributionConfig: { + Origins: { + Quantity: 1, + Items: [{ Id: 'EdgeOptimize_Origin', DomainName: 'dev.edgeoptimize.net', CustomHeaders: { Quantity: 0, Items: [] } }], + }, + }, + ETag: 'etag-1', + }); + cfSendStub.onSecondCall().resolves({}); + + const result = await edgeOptimize.createEdgeOptimizeOrigin({}, 'E2EXAMPLE', 'dev.edgeoptimize.net', { + apiKey: 'eo-key-123', forwardedHost: 'www.example.com', + }); + + expect(result).to.deep.equal({ + created: false, alreadyExisted: true, updated: true, originId: 'EdgeOptimize_Origin', + }); + expect(cfSendStub.secondCall.args[0].commandName).to.equal('UpdateDistribution'); + const patched = cfSendStub.secondCall.args[0].input + .DistributionConfig.Origins.Items.find((o) => o.Id === 'EdgeOptimize_Origin'); + expect(patched.CustomHeaders.Quantity).to.equal(2); + }); + + it('does not patch when the existing headers already match', async () => { + cfSendStub.resolves({ + DistributionConfig: { + Origins: { + Quantity: 1, + Items: [{ + Id: 'EdgeOptimize_Origin', + DomainName: 'dev.edgeoptimize.net', + CustomHeaders: { + Quantity: 2, + Items: [ + { HeaderName: 'x-edgeoptimize-api-key', HeaderValue: 'eo-key-123' }, + { HeaderName: 'x-forwarded-host', HeaderValue: 'www.example.com' }, + ], + }, + }], + }, + }, + ETag: 'etag-1', + }); + + const result = await edgeOptimize.createEdgeOptimizeOrigin({}, 'E2EXAMPLE', 'dev.edgeoptimize.net', { + apiKey: 'eo-key-123', forwardedHost: 'www.example.com', + }); + + expect(result.updated).to.equal(false); + expect(cfSendStub.calledOnce).to.equal(true); // no UpdateDistribution + }); + it('throws when the distribution id is missing', async () => { let error; try { From 6bebfaf31fbe321d979d45620baa62b8548f233f Mon Sep 17 00:00:00 2001 From: Akash Bhardwaj Date: Mon, 22 Jun 2026 15:29:41 +0530 Subject: [PATCH 17/56] fix(llmo): scope EO connector-role trust default to the dev exec role Tighten the dev-only default for EDGE_OPTIMIZE_TRUSTED_PRINCIPAL_ARN from the whole dev account (arn:aws:iam::682033462621:root) to the exact assuming identity - the spacecat-api-service Lambda execution role (arn:aws:iam::682033462621:role/spacecat-role-lambda-generic) - shrinking the blast radius of the connector-role trust. No AWS-side change needed; the assuming identity is already that role. Prod must still set this via env to the prod execution role ARN (no in-code default) - tracked in the punch list. Co-Authored-By: Claude Opus 4.8 --- src/controllers/llmo/llmo.js | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/controllers/llmo/llmo.js b/src/controllers/llmo/llmo.js index 95b3d81ce8..fbdae44efb 100644 --- a/src/controllers/llmo/llmo.js +++ b/src/controllers/llmo/llmo.js @@ -2130,11 +2130,12 @@ function LlmoController(ctx) { const presignTtlSeconds = Number(env.EDGE_OPTIMIZE_PRESIGN_TTL || 900); const externalId = crypto.randomUUID(); const roleArn = `arn:aws:iam::${accountId}:role/${roleName}`; - // TEMPORARY (testing only): default the trust to the dev signer account so the - // cross-account test works (dev signs, stage is the customer where the role is made). - // TODO: REMOVE before merge/prod — set EDGE_OPTIMIZE_TRUSTED_PRINCIPAL_ARN via env. + // TEMPORARY (testing only): default the trust to the dev signer's Lambda execution role + // (the exact identity that calls AssumeRole), not the whole account — smaller blast radius. + // TODO: REMOVE before prod — set EDGE_OPTIMIZE_TRUSTED_PRINCIPAL_ARN to the PROD + // spacecat-api-service Lambda execution role ARN via env (no in-code default). const trustedPrincipalArn = env.EDGE_OPTIMIZE_TRUSTED_PRINCIPAL_ARN - || 'arn:aws:iam::682033462621:root'; + || 'arn:aws:iam::682033462621:role/spacecat-role-lambda-generic'; // Presign the (private) template so the customer's CloudFormation can read it // cross-account via the signature — no public bucket, no customer S3 access. From 0f7b2fdea3a8aaccf364abaa0cb307e0b51e38ed Mon Sep 17 00:00:00 2001 From: Akash Bhardwaj Date: Mon, 22 Jun 2026 16:24:48 +0530 Subject: [PATCH 18/56] feat(llmo): add idempotent step-on-poll Edge Optimize CloudFront deploy orchestrator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add POST /sites/:siteId/llmo/edge-optimize/deploy: a single endpoint the wizard FE calls once then polls (~30s). Each call advances origin → function → cache → lambda → associate → verify as far as it safely can (well under the ~60s gateway timeout) and returns per-step status. Safe to call repeatedly: - function step gated on DescribeFunction(Stage:LIVE) so it never re-publishes - associate step gated on the target behavior already referencing our CF function + Lambda so it never re-issues UpdateDistribution - origin + cache reuse the existing idempotent support fns - lambda kicks off / polls without re-creating; sequence pauses while it provisions and resumes on the next poll - verify is best-effort: in_progress (never error) until CloudFront propagates - a step that throws is marked error on its own row; the response is still 200 so the FE shows the failure and a re-poll retries idempotently Orchestration lives in runEdgeOptimizeDeployStep (support/edge-optimize.js, unit-testable without HTTP); the deployEdgeOptimize handler does validation + gate + metaconfig/forwarded-host + a single assumeConnectorRole, then delegates. Wires the route into routes/index.js + required-capabilities INTERNAL_ROUTES, and the OpenAPI path + request schema. Adds support-fn and handler tests. Co-Authored-By: Claude Opus 4.8 --- docs/openapi/api.yaml | 2 + docs/openapi/llmo-api.yaml | 142 ++++++++++++ src/controllers/llmo/llmo.js | 77 ++++++ src/routes/index.js | 1 + src/routes/required-capabilities.js | 1 + src/support/edge-optimize.js | 218 +++++++++++++++++ test/controllers/llmo/llmo.test.js | 145 ++++++++++++ test/routes/index.test.js | 2 + test/support/edge-optimize.test.js | 348 ++++++++++++++++++++++++++++ 9 files changed, 936 insertions(+) diff --git a/docs/openapi/api.yaml b/docs/openapi/api.yaml index 003d22c4be..ca3d8ffd72 100644 --- a/docs/openapi/api.yaml +++ b/docs/openapi/api.yaml @@ -649,6 +649,8 @@ paths: $ref: './llmo-api.yaml#/site-llmo-edge-optimize-apply-associations' /sites/{siteId}/llmo/edge-optimize/verify: $ref: './llmo-api.yaml#/site-llmo-edge-optimize-verify' + /sites/{siteId}/llmo/edge-optimize/deploy: + $ref: './llmo-api.yaml#/site-llmo-edge-optimize-deploy' /sites/{siteId}/llmo/edge-optimize-status: $ref: './llmo-api.yaml#/llmo-edge-optimize-status' /sites/{siteId}/llmo/probes/edge-optimize: diff --git a/docs/openapi/llmo-api.yaml b/docs/openapi/llmo-api.yaml index ed95da1641..4405fc96d6 100644 --- a/docs/openapi/llmo-api.yaml +++ b/docs/openapi/llmo-api.yaml @@ -3124,6 +3124,115 @@ site-llmo-edge-optimize-verify: security: - api_key: [ ] +site-llmo-edge-optimize-deploy: + post: + tags: + - llmo + summary: Orchestrate the full Edge Optimize CloudFront deploy (idempotent, step-on-poll) + description: | + Single endpoint that drives the CloudFront "Deploy routing" wizard end-to-end. The frontend + calls it once and then polls it every ~30s; each call advances the deploy sequence (origin → + routing function → cache policy → Lambda@Edge → association → verify) as far as it safely can + while staying well under the gateway's ~60s first-byte timeout, and returns the per-step + status. It is safe to call repeatedly: completed steps are gated and never re-mutated (no + CloudFront re-deploy churn, no routing-function re-publish). When the Lambda@Edge is still + provisioning the sequence pauses (associate/verify stay pending) and the next poll re-checks. + The caller passes the customer's selected distribution, failover origin, and behavior + explicitly; the Edge Optimize API key and forwarded host are derived server-side from the site. + Always returns HTTP 200 unless validation or the access gate fails; a step that errors is + surfaced on its own row with `status: error` rather than failing the whole response. + operationId: deployEdgeOptimize + parameters: + - $ref: './parameters.yaml#/siteId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/edge-optimize-deploy-request' + responses: + '200': + description: The current per-step deploy status. + content: + application/json: + schema: + type: object + required: + - routingDeployed + - verified + - steps + properties: + routingDeployed: + type: boolean + description: True once the association step has completed. + verified: + type: boolean + description: True once end-to-end routing verification passes. + steps: + type: array + items: + type: object + required: + - key + - label + - status + properties: + key: + type: string + enum: + - origin + - function + - cache + - lambda + - associate + - verify + label: + type: string + status: + type: string + enum: + - pending + - in_progress + - done + - error + detail: + type: string + example: + routingDeployed: false + verified: false + steps: + - key: origin + label: Edge Optimize origin + status: done + - key: function + label: Routing function + status: done + - key: cache + label: Cache policy + status: done + - key: lambda + label: Lambda@Edge + status: in_progress + detail: Lambda@Edge is still provisioning + - key: associate + label: Association + status: pending + - key: verify + label: Verify routing + status: pending + '400': + $ref: './responses.yaml#/400' + '401': + $ref: './responses.yaml#/401' + '403': + $ref: './responses.yaml#/403' + '404': + $ref: './responses.yaml#/404' + '500': + $ref: './responses.yaml#/500' + security: + - api_key: [ ] + edge-optimize-connector-request: type: object required: @@ -3218,6 +3327,39 @@ edge-optimize-associate-request: description: The published, versioned Lambda@Edge ARN from the create-lambda step. example: arn:aws:lambda:us-east-1:682033462621:function:edgeoptimize-origin:1 +edge-optimize-deploy-request: + type: object + required: + - accountId + - externalId + - distributionId + - originId + - behavior + properties: + accountId: + type: string + description: The 12-digit AWS account ID hosting the customer's CloudFront distribution. + pattern: '^[0-9]{12}$' + example: '682033462621' + externalId: + type: string + description: Per-session external ID baked into the connector role's trust policy. + example: '7ff9518a-cf59-40b4-aa53-68a3cb2e24a5' + distributionId: + type: string + description: The CloudFront distribution ID to configure. + example: E2EXAMPLE123 + originId: + type: string + description: >- + The customer's existing failover origin id (the default behavior's target origin), + baked into the routing function's failover origin group. + example: origin-aem + behavior: + type: string + description: The cache behavior path pattern to target; use `default` for the default behavior. + example: default + llmo-probe-edge-optimize: get: tags: diff --git a/src/controllers/llmo/llmo.js b/src/controllers/llmo/llmo.js index fbdae44efb..2f8db4fce5 100644 --- a/src/controllers/llmo/llmo.js +++ b/src/controllers/llmo/llmo.js @@ -44,6 +44,7 @@ import { getEdgeOptimizeLambdaStatus, applyEdgeOptimizeAssociations, verifyEdgeOptimizeRouting, + runEdgeOptimizeDeployStep, } from '../../support/edge-optimize.js'; import { UnauthorizedProductError } from '../../support/errors.js'; import { cachedOk } from '../../support/cached-response.js'; @@ -2716,6 +2717,81 @@ function LlmoController(ctx) { } }; + // Idempotent, step-on-poll orchestrator for the CloudFront "Deploy routing" wizard. The FE calls + // this once then polls it (~30s); each call advances origin → function → cache → lambda → + // associate → verify as far as it safely can (well under the gateway's ~60s timeout) and returns + // per-step status. Safe to call repeatedly — gated steps never re-mutate completed work. The FE + // passes the customer's selected distribution, failover origin, and behavior explicitly; the EO + // API key + forwarded host are derived server-side from the site (no UI input). + const deployEdgeOptimizeHandler = async (context) => { + const { log, dataAccess, env } = context; + const { siteId } = context.params; + const { Site } = dataAccess; + const accountId = String(context.data?.accountId || '').replace(/\D/g, ''); + const externalId = String(context.data?.externalId || '').trim(); + const distributionId = String(context.data?.distributionId || '').trim(); + const originId = String(context.data?.originId || '').trim(); + const behavior = String(context.data?.behavior || '').trim(); + const roleName = env?.EDGE_OPTIMIZE_ROLE_NAME || undefined; + const originDomain = env?.EDGE_OPTIMIZE_ORIGIN_DOMAIN || 'dev.edgeoptimize.net'; + + if (accountId.length !== 12) { + return badRequest('accountId must be a 12-digit AWS account ID'); + } + if (!hasText(externalId)) { + return badRequest('externalId is required'); + } + if (!hasText(distributionId)) { + return badRequest('distributionId is required'); + } + if (!hasText(originId)) { + return badRequest('originId is required'); + } + if (!hasText(behavior)) { + return badRequest('behavior is required'); + } + + try { + const { error, site } = await gateEdgeOptimizeWizard(siteId, Site, 'deploy edge optimize routing'); + if (error) { + return error; + } + + // The EO origin needs custom headers so the routing function's request authenticates to Edge + // Optimize (x-edgeoptimize-api-key) and resolves the customer host (x-forwarded-host). Both + // are derived server-side from the site — no UI input. Without them Verify never goes green. + const baseURL = site.getBaseURL(); + const tokowakaClient = TokowakaClient.createFrom(context); + const metaconfig = await tokowakaClient.fetchMetaconfig(baseURL); + const apiKey = metaconfig?.apiKeys?.[0]; + if (!hasText(apiKey)) { + return badRequest('Site has no Edge Optimize API key — enable Edge Optimize for this site first'); + } + const forwardedHost = calculateForwardedHost(baseURL, log); + + // Assume the connector role ONCE; all steps run with the same short-lived credentials. + const { credentials, accountId: resolvedAccountId } = await assumeConnectorRole({ + accountId, externalId, roleName, + }); + + const result = await runEdgeOptimizeDeployStep(credentials, { + distributionId, + originId, + behavior, + originDomain, + originHeaders: { apiKey, forwardedHost }, + accountId: resolvedAccountId, + }); + + log.info(`[edge-optimize-deploy] site ${siteId}: routingDeployed=${result.routingDeployed},` + + ` verified=${result.verified}, steps=${result.steps.map((s) => `${s.key}:${s.status}`).join(',')}`); + return ok(result); + } catch (error) { + log.error(`[edge-optimize-deploy] Failed for site ${siteId}:`, error); + return badRequest(cleanupHeaderValue(error.message)); + } + }; + return { getEdgeOptimizeBootstrapUrl, connectEdgeOptimize, @@ -2730,6 +2806,7 @@ function LlmoController(ctx) { getEdgeOptimizeLambdaStatus: getEdgeOptimizeLambdaStatusHandler, applyEdgeOptimizeAssociations: applyEdgeOptimizeAssociationsHandler, verifyEdgeOptimizeRouting: verifyEdgeOptimizeRoutingHandler, + deployEdgeOptimize: deployEdgeOptimizeHandler, getLlmoSheetData, queryLlmoSheetData, getLlmoGlobalSheetData, diff --git a/src/routes/index.js b/src/routes/index.js index 40b4b73818..a9cae2b1bf 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -493,6 +493,7 @@ export default function getRouteHandlers( 'POST /sites/:siteId/llmo/edge-optimize/lambda-status': llmoController.getEdgeOptimizeLambdaStatus, 'POST /sites/:siteId/llmo/edge-optimize/apply-associations': llmoController.applyEdgeOptimizeAssociations, 'POST /sites/:siteId/llmo/edge-optimize/verify': llmoController.verifyEdgeOptimizeRouting, + 'POST /sites/:siteId/llmo/edge-optimize/deploy': llmoController.deployEdgeOptimize, 'GET /sites/:siteId/llmo/strategy': llmoController.getStrategy, 'PUT /sites/:siteId/llmo/strategy': llmoController.saveStrategy, 'GET /sites/:siteId/llmo/edge-optimize-status': llmoController.checkEdgeOptimizeStatus, diff --git a/src/routes/required-capabilities.js b/src/routes/required-capabilities.js index b3d1f4d512..a57400d32d 100644 --- a/src/routes/required-capabilities.js +++ b/src/routes/required-capabilities.js @@ -140,6 +140,7 @@ export const INTERNAL_ROUTES = [ 'POST /sites/:siteId/llmo/edge-optimize/lambda-status', 'POST /sites/:siteId/llmo/edge-optimize/apply-associations', 'POST /sites/:siteId/llmo/edge-optimize/verify', + 'POST /sites/:siteId/llmo/edge-optimize/deploy', 'PUT /sites/:siteId/llmo/opportunities-reviewed', // PLG onboarding - IMS token auth, self-service flow, not S2S diff --git a/src/support/edge-optimize.js b/src/support/edge-optimize.js index aa4ceace3f..257689bbf3 100644 --- a/src/support/edge-optimize.js +++ b/src/support/edge-optimize.js @@ -42,6 +42,7 @@ import { PublishVersionCommand, } from '@aws-sdk/client-lambda'; import { hasText } from '@adobe/spacecat-shared-utils'; +import { cleanupHeaderValue } from '@adobe/helix-shared-utils'; // CloudFront is a global service; its control plane lives in us-east-1. export const EDGE_OPTIMIZE_REGION = 'us-east-1'; @@ -1119,3 +1120,220 @@ export async function verifyEdgeOptimizeRouting(url) { return { passed, requestId, details: { bot, human } }; } + +// The ordered deploy steps + their human labels, in the sequence the orchestrator advances them. +// Exported so the controller/tests can assert the contract without re-declaring it. +export const EDGE_OPTIMIZE_DEPLOY_STEPS = [ + { key: 'origin', label: 'Edge Optimize origin' }, + { key: 'function', label: 'Routing function' }, + { key: 'cache', label: 'Cache policy' }, + { key: 'lambda', label: 'Lambda@Edge' }, + { key: 'associate', label: 'Association' }, + { key: 'verify', label: 'Verify routing' }, +]; + +/** + * True when the `edgeoptimize-routing` CloudFront Function is already published to LIVE. + * Used to gate the function step so we never re-publish (which causes deploy churn). + * + * @param {CloudFrontClient} client - a CloudFront client built with the connector credentials. + * @returns {Promise} + */ +async function isRoutingFunctionLive(client) { + try { + const desc = await client.send(new DescribeFunctionCommand({ + Name: EDGE_OPTIMIZE_FUNCTION_NAME, + Stage: 'LIVE', + })); + return Boolean(desc?.FunctionSummary?.FunctionMetadata?.FunctionARN); + } catch (err) { + if (err.name === 'NoSuchFunctionExists') { + return false; + } + throw err; + } +} + +/** + * True when the target behavior already has BOTH the Edge Optimize routing CloudFront Function + * (viewer-request) AND the Edge Optimize Lambda@Edge (origin-request) associated. Used to gate the + * associate step so we never re-issue UpdateDistribution (needless re-deploy) once wired. + * + * @param {CloudFrontClient} client - a CloudFront client built with the connector credentials. + * @param {string} distributionId - the CloudFront distribution ID. + * @param {string} pathPattern - the behavior to inspect (`default` for the default behavior). + * @returns {Promise} + */ +async function isBehaviorAlreadyAssociated(client, distributionId, pathPattern) { + const result = await client.send(new GetDistributionConfigCommand({ Id: distributionId })); + const config = result.DistributionConfig || {}; + let behavior; + if (pathPattern === 'default' || pathPattern === 'Default (*)') { + behavior = config.DefaultCacheBehavior; + } else { + behavior = (config.CacheBehaviors?.Items || []).find((b) => b.PathPattern === pathPattern); + } + if (!behavior) { + return false; + } + const hasCfFunction = (behavior.FunctionAssociations?.Items || []).some( + (a) => a.EventType === 'viewer-request' && /edgeoptimize-routing/i.test(a.FunctionARN || ''), + ); + const hasLambda = (behavior.LambdaFunctionAssociations?.Items || []).some( + (a) => a.EventType === 'origin-request' && /edgeoptimize-origin/i.test(a.LambdaFunctionARN || ''), + ); + return hasCfFunction && hasLambda; +} + +/** + * Run one poll of the idempotent Edge Optimize "Deploy routing" orchestrator. + * + * Advances the deploy sequence (origin → function → cache → lambda → associate → verify) as far as + * it safely can in a single call, staying well under the CDN/gateway ~60s first-byte timeout. Each + * step is gated so a re-poll never re-mutates already-completed work (no CloudFront re-deploy + * churn, no CF-function re-publish). Designed to be called once and then polled every ~30s by the + * wizard UI: each call returns the per-step status and the FE keeps polling until verify is green. + * + * Stops advancing (returning earlier steps' real status and later steps `pending`) when the + * Lambda@Edge is still provisioning — the next poll re-checks. A step that throws is marked + * `error` on its own row (with later steps `pending`); the caller still returns HTTP 200 so the FE + * shows the failure on that row and a re-poll retries idempotently. + * + * @param {object} credentials - temporary credentials from {@link assumeConnectorRole}. + * @param {object} params + * @param {string} params.distributionId - the CloudFront distribution ID. + * @param {string} params.originId - the default-behavior target origin id (failover origin). + * @param {string} params.behavior - the cache behavior to target (`default` for the default). + * @param {string} [params.originDomain] - the Edge Optimize origin domain. + * @param {object} [params.originHeaders] - EO origin headers ({ apiKey, forwardedHost }). + * @param {string} params.accountId - the 12-digit customer AWS account ID. + * @param {string} [region] - CloudFront control-plane region. + * @returns {Promise<{routingDeployed: boolean, verified: boolean, steps: Array}>} + */ +export async function runEdgeOptimizeDeployStep( + credentials, + { + distributionId, originId, behavior, originDomain, originHeaders, accountId, + }, + region = EDGE_OPTIMIZE_REGION, +) { + // Start every step pending; each handler flips its own row to done/in_progress/error. + const steps = EDGE_OPTIMIZE_DEPLOY_STEPS.map((s) => ({ ...s, status: 'pending' })); + const byKey = (key) => steps.find((s) => s.key === key); + const client = new CloudFrontClient({ region, credentials }); + + let routingDeployed = false; + let verified = false; + let lambdaVersionArn = null; + + // ── 1. origin — already idempotent (no UpdateDistribution when headers match). ── + try { + await createEdgeOptimizeOrigin( + credentials, + distributionId, + originDomain, + originHeaders, + region, + ); + byKey('origin').status = 'done'; + } catch (err) { + byKey('origin').status = 'error'; + byKey('origin').detail = cleanupHeaderValue(err.message); + return { routingDeployed, verified, steps }; + } + + // ── 2. function — GATE: skip the create+publish when already LIVE (avoids re-publish churn). ── + try { + if (await isRoutingFunctionLive(client)) { + byKey('function').status = 'done'; + } else { + await createEdgeOptimizeRoutingFunction(credentials, originId, null, region); + byKey('function').status = 'done'; + } + } catch (err) { + byKey('function').status = 'error'; + byKey('function').detail = cleanupHeaderValue(err.message); + return { routingDeployed, verified, steps }; + } + + // ── 3. cache — idempotent (skips UpdateDistribution/UpdateCachePolicy when already applied). ── + try { + await applyEdgeOptimizeCacheHeaders(credentials, distributionId, behavior, { region }); + byKey('cache').status = 'done'; + } catch (err) { + byKey('cache').status = 'error'; + byKey('cache').detail = cleanupHeaderValue(err.message); + return { routingDeployed, verified, steps }; + } + + // ── 4. lambda — kick off / poll WITHOUT re-creating; stop here while still provisioning. ── + try { + const ls = await getEdgeOptimizeLambdaStatus(credentials, region); + if (ls.ready) { + lambdaVersionArn = ls.versionArn; + byKey('lambda').status = 'done'; + } else if (ls.exists) { + // Provisioning in progress — do NOT re-create. Hold the sequence and re-check next poll. + byKey('lambda').status = 'in_progress'; + byKey('lambda').detail = 'Lambda@Edge is still provisioning'; + return { routingDeployed, verified, steps }; + } else { + // Not created yet — kick off the create (returns fast) and report in_progress. + await createEdgeOptimizeLambda(credentials, accountId, { region }); + byKey('lambda').status = 'in_progress'; + byKey('lambda').detail = 'Lambda@Edge create started'; + return { routingDeployed, verified, steps }; + } + } catch (err) { + byKey('lambda').status = 'error'; + byKey('lambda').detail = cleanupHeaderValue(err.message); + return { routingDeployed, verified, steps }; + } + + // ── 5. associate — GATE: skip UpdateDistribution when the behavior is already wired. ── + try { + if (await isBehaviorAlreadyAssociated(client, distributionId, behavior)) { + byKey('associate').status = 'done'; + } else { + await applyEdgeOptimizeAssociations( + credentials, + distributionId, + behavior, + lambdaVersionArn, + region, + ); + byKey('associate').status = 'done'; + } + routingDeployed = true; + } catch (err) { + byKey('associate').status = 'error'; + byKey('associate').detail = cleanupHeaderValue(err.message); + return { routingDeployed, verified, steps }; + } + + // ── 6. verify — BEST-EFFORT: in_progress (not error) until CloudFront propagation lets pass. ── + try { + const distributions = await listCloudFrontDistributions(credentials, region); + const match = distributions.find((d) => d.id === distributionId); + const domain = match?.domainName; + if (!hasText(domain)) { + byKey('verify').status = 'in_progress'; + byKey('verify').detail = 'waiting for distribution domain'; + return { routingDeployed, verified, steps }; + } + const result = await verifyEdgeOptimizeRouting(`https://${domain}/`); + if (result.passed) { + verified = true; + byKey('verify').status = 'done'; + } else { + byKey('verify').status = 'in_progress'; + byKey('verify').detail = 'waiting for propagation'; + } + } catch (err) { + // Never fail the whole deploy because verify could not run yet — surface as in_progress. + byKey('verify').status = 'in_progress'; + byKey('verify').detail = cleanupHeaderValue(err.message); + } + + return { routingDeployed, verified, steps }; +} diff --git a/test/controllers/llmo/llmo.test.js b/test/controllers/llmo/llmo.test.js index 99575094ef..6cf30a924c 100644 --- a/test/controllers/llmo/llmo.test.js +++ b/test/controllers/llmo/llmo.test.js @@ -96,6 +96,7 @@ describe('LlmoController', () => { let getEdgeOptimizeLambdaStatusStub; let applyEdgeOptimizeAssociationsStub; let verifyEdgeOptimizeRoutingStub; + let runEdgeOptimizeDeployStepStub; let mockTokowakaClient; let readStrategyStub; let writeStrategyStub; @@ -214,6 +215,7 @@ describe('LlmoController', () => { getEdgeOptimizeLambdaStatusStub = sinon.stub(); applyEdgeOptimizeAssociationsStub = sinon.stub(); verifyEdgeOptimizeRoutingStub = sinon.stub(); + runEdgeOptimizeDeployStepStub = sinon.stub(); // Initialize mock TokowakaClient mockTokowakaClient = { @@ -295,6 +297,7 @@ describe('LlmoController', () => { getEdgeOptimizeLambdaStatus: (...args) => getEdgeOptimizeLambdaStatusStub(...args), applyEdgeOptimizeAssociations: (...args) => applyEdgeOptimizeAssociationsStub(...args), verifyEdgeOptimizeRouting: (...args) => verifyEdgeOptimizeRoutingStub(...args), + runEdgeOptimizeDeployStep: (...args) => runEdgeOptimizeDeployStepStub(...args), }, '@adobe/spacecat-shared-ims-client': { ImsClient: function MockImsClient() { @@ -8850,6 +8853,148 @@ describe('LlmoController', () => { }); }); + describe('deployEdgeOptimize', () => { + let deployContext; + + const sampleSteps = [ + { key: 'origin', label: 'Edge Optimize origin', status: 'done' }, + { key: 'function', label: 'Routing function', status: 'done' }, + { key: 'cache', label: 'Cache policy', status: 'done' }, + { key: 'lambda', label: 'Lambda@Edge', status: 'in_progress' }, + { key: 'associate', label: 'Association', status: 'pending' }, + { key: 'verify', label: 'Verify routing', status: 'pending' }, + ]; + + beforeEach(() => { + assumeConnectorRoleStub = sinon.stub().resolves({ + roleArn: 'arn:aws:iam::120569600543:role/AdobeLLMOptimizerCloudFrontConnectorRole', + accountId: '120569600543', + credentials: { accessKeyId: 'AKIA', secretAccessKey: 'secret', sessionToken: 'token' }, + }); + runEdgeOptimizeDeployStepStub = sinon.stub().resolves({ + routingDeployed: false, + verified: false, + steps: sampleSteps, + }); + mockTokowakaClient.fetchMetaconfig.resolves({ apiKeys: ['eo-key-123'] }); + deployContext = { + ...mockContext, + params: { siteId: TEST_SITE_ID }, + data: { + accountId: '120569600543', + externalId: '7ff9518a-cf59-40b4-aa53-68a3cb2e24a5', + distributionId: 'E2EXAMPLE123', + originId: 'origin-aem', + behavior: 'default', + }, + env: {}, + }; + }); + + it('runs the orchestrator and returns the per-step status', async () => { + const result = await controller.deployEdgeOptimize(deployContext); + + expect(result.status).to.equal(200); + const body = await result.json(); + expect(body.routingDeployed).to.equal(false); + expect(body.verified).to.equal(false); + expect(body.steps).to.deep.equal(sampleSteps); + // assumeConnectorRole is called exactly once for the whole sequence. + expect(assumeConnectorRoleStub.calledOnce).to.equal(true); + expect(runEdgeOptimizeDeployStepStub.calledOnce).to.equal(true); + const [, params] = runEdgeOptimizeDeployStepStub.firstCall.args; + expect(params).to.include({ + distributionId: 'E2EXAMPLE123', + originId: 'origin-aem', + behavior: 'default', + originDomain: 'dev.edgeoptimize.net', + accountId: '120569600543', + }); + expect(params.originHeaders).to.deep.equal({ apiKey: 'eo-key-123', forwardedHost: 'www.example.com' }); + }); + + it('passes the env-driven origin domain when set', async () => { + await controller.deployEdgeOptimize({ + ...deployContext, + env: { EDGE_OPTIMIZE_ORIGIN_DOMAIN: 'live.edgeoptimize.net' }, + }); + const [, params] = runEdgeOptimizeDeployStepStub.firstCall.args; + expect(params.originDomain).to.equal('live.edgeoptimize.net'); + }); + + it('returns 400 when the site has no Edge Optimize API key', async () => { + mockTokowakaClient.fetchMetaconfig.resolves({ apiKeys: [] }); + const result = await controller.deployEdgeOptimize(deployContext); + expect(result.status).to.equal(400); + const body = await result.json(); + expect(body.message).to.include('API key'); + expect(runEdgeOptimizeDeployStepStub.called).to.equal(false); + }); + + it('returns 400 for an invalid account id', async () => { + const result = await controller.deployEdgeOptimize({ ...deployContext, data: { ...deployContext.data, accountId: '123' } }); + expect(result.status).to.equal(400); + }); + + it('returns 400 when the external id is missing', async () => { + const result = await controller.deployEdgeOptimize({ ...deployContext, data: { ...deployContext.data, externalId: '' } }); + expect(result.status).to.equal(400); + }); + + it('returns 400 when the distributionId is missing', async () => { + const result = await controller.deployEdgeOptimize({ ...deployContext, data: { ...deployContext.data, distributionId: '' } }); + expect(result.status).to.equal(400); + const body = await result.json(); + expect(body.message).to.include('distributionId'); + }); + + it('returns 400 when the originId is missing', async () => { + const result = await controller.deployEdgeOptimize({ ...deployContext, data: { ...deployContext.data, originId: '' } }); + expect(result.status).to.equal(400); + const body = await result.json(); + expect(body.message).to.include('originId'); + }); + + it('returns 400 when the behavior is missing', async () => { + const result = await controller.deployEdgeOptimize({ ...deployContext, data: { ...deployContext.data, behavior: '' } }); + expect(result.status).to.equal(400); + const body = await result.json(); + expect(body.message).to.include('behavior'); + }); + + it('returns 400 when the orchestrator throws', async () => { + runEdgeOptimizeDeployStepStub = sinon.stub().rejects(new Error('assume failed')); + const result = await controller.deployEdgeOptimize(deployContext); + expect(result.status).to.equal(400); + const body = await result.json(); + expect(body.message).to.include('assume failed'); + }); + + it('returns 404 when the site is not found', async () => { + mockDataAccess.Site.findById.resolves(null); + const result = await controller.deployEdgeOptimize(deployContext); + expect(result.status).to.equal(404); + }); + + it('returns 403 when the user lacks access to the site', async () => { + const deniedController = controllerWithAccessDenied(mockContext); + const result = await deniedController.deployEdgeOptimize(deployContext); + expect(result.status).to.equal(403); + }); + + it('returns 403 when the user is not an LLMO administrator', async () => { + const LlmoControllerNoAdmin = await esmock('../../../src/controllers/llmo/llmo.js', { + '../../../src/support/access-control-util.js': createMockAccessControlUtil(true, true, false), + '@adobe/spacecat-shared-http-utils': mockHttpUtils, + '../../../src/support/cached-response.js': mockCachedResponse, + ...getCommonMocks(), + }); + const result = await LlmoControllerNoAdmin(mockContext) + .deployEdgeOptimize(deployContext); + expect(result.status).to.equal(403); + }); + }); + describe('getStrategy', () => { const mockStrategyData = { opportunities: [ diff --git a/test/routes/index.test.js b/test/routes/index.test.js index 5f7389a2f7..571ef2268a 100755 --- a/test/routes/index.test.js +++ b/test/routes/index.test.js @@ -339,6 +339,7 @@ describe('getRouteHandlers', () => { getEdgeOptimizeLambdaStatus: () => null, applyEdgeOptimizeAssociations: () => null, verifyEdgeOptimizeRouting: () => null, + deployEdgeOptimize: () => null, getEdgeConfig: () => null, createOrUpdateStageEdgeConfig: () => null, checkEdgeOptimizeStatus: () => null, @@ -1098,6 +1099,7 @@ describe('getRouteHandlers', () => { 'POST /sites/:siteId/llmo/edge-optimize/lambda-status', 'POST /sites/:siteId/llmo/edge-optimize/apply-associations', 'POST /sites/:siteId/llmo/edge-optimize/verify', + 'POST /sites/:siteId/llmo/edge-optimize/deploy', 'GET /sites/:siteId/llmo/edge-optimize-status', 'GET /sites/:siteId/llmo/probes/edge-optimize', 'GET /sites/:siteId/llmo/strategy', diff --git a/test/support/edge-optimize.test.js b/test/support/edge-optimize.test.js index f4be403820..d2ac211161 100644 --- a/test/support/edge-optimize.test.js +++ b/test/support/edge-optimize.test.js @@ -997,4 +997,352 @@ describe('edge-optimize support', () => { expect(error.message).to.include('url'); }); }); + + describe('runEdgeOptimizeDeployStep', () => { + let fetchStub; + const deployParams = { + distributionId: 'E2EXAMPLE123', + originId: 'origin-aem', + behavior: 'default', + originDomain: 'dev.edgeoptimize.net', + originHeaders: { apiKey: 'eo-key', forwardedHost: 'www.example.com' }, + accountId: '120569600543', + }; + + // Dispatch each client's send() by command name; per-test overrides via the `r` map. + const wire = (cf = {}, lambda = {}, iam = {}) => { + cfSendStub.callsFake((cmd) => { + const fn = cf[cmd.commandName]; + if (fn === undefined) { + throw new Error(`unexpected cf command: ${cmd.commandName}`); + } + return Promise.resolve(typeof fn === 'function' ? fn(cmd) : fn); + }); + lambdaSendStub.callsFake((cmd) => { + const fn = lambda[cmd.commandName]; + if (fn === undefined) { + throw new Error(`unexpected lambda command: ${cmd.commandName}`); + } + return Promise.resolve(typeof fn === 'function' ? fn(cmd) : fn); + }); + iamSendStub.callsFake((cmd) => { + const fn = iam[cmd.commandName]; + if (fn === undefined) { + throw new Error(`unexpected iam command: ${cmd.commandName}`); + } + return Promise.resolve(typeof fn === 'function' ? fn(cmd) : fn); + }); + }; + + const statusOf = (steps, key) => steps.find((s) => s.key === key).status; + const cfCalls = (name) => cfSendStub.getCalls().filter((c) => c.args[0].commandName === name); + + // Returns a responder that throws an AWS-style named error (so the SDK error path triggers). + const throwNamed = (name, message) => () => { + const e = new Error(message); + e.name = name; + throw e; + }; + + const makeFetchResponse = (status, headerMap) => ({ + status, + headers: { forEach: (cb) => Object.entries(headerMap).forEach(([k, v]) => cb(v, k)) }, + arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)), + }); + + afterEach(() => { + if (fetchStub) { + fetchStub.restore(); + } + fetchStub = undefined; + }); + + it('first call advances origin+function+cache and returns lambda in_progress (others pending)', async () => { + wire( + { + // origin: existing with matching headers → idempotent no-op (no UpdateDistribution). + GetDistributionConfig: { + DistributionConfig: { + Origins: { + Items: [{ + Id: 'EdgeOptimize_Origin', + DomainName: 'dev.edgeoptimize.net', + CustomHeaders: { + Items: [ + { HeaderName: 'x-edgeoptimize-api-key', HeaderValue: 'eo-key' }, + { HeaderName: 'x-forwarded-host', HeaderValue: 'www.example.com' }, + ], + }, + }], + }, + DefaultCacheBehavior: { CachePolicyId: 'cp-1' }, + }, + ETag: 'etag', + }, + // function gate: already published to LIVE → skip create+publish. + DescribeFunction: { FunctionSummary: { FunctionMetadata: { FunctionARN: 'arn:cf-fn' } } }, + // cache: custom policy already forwards EO headers + MinTTL 0 → no-op. + ListCachePolicies: { CachePolicyList: { Items: [] } }, + GetCachePolicyConfig: { + CachePolicyConfig: { + Name: 'p', + MinTTL: 0, + ParametersInCacheKeyAndForwardedToOrigin: { + HeadersConfig: { + HeaderBehavior: 'whitelist', + Headers: { Quantity: 2, Items: ['x-edgeoptimize-config', 'x-edgeoptimize-url'] }, + }, + }, + }, + ETag: 'cp-etag', + }, + }, + { + // lambda: does not exist yet → kick off create → in_progress. + GetFunctionConfiguration: throwNamed('ResourceNotFoundException', 'nope'), + ListVersionsByFunction: { Versions: [] }, + CreateFunction: { FunctionArn: 'arn:lambda', Version: '$LATEST' }, + }, + { + // Role already exists → no role-propagation wait (the slow create path is avoided). + GetRole: { Role: { Arn: 'arn:role' } }, + UpdateAssumeRolePolicy: {}, + PutRolePolicy: {}, + }, + ); + + const out = await edgeOptimize.runEdgeOptimizeDeployStep({}, deployParams); + + expect(statusOf(out.steps, 'origin')).to.equal('done'); + expect(statusOf(out.steps, 'function')).to.equal('done'); + expect(statusOf(out.steps, 'cache')).to.equal('done'); + expect(statusOf(out.steps, 'lambda')).to.equal('in_progress'); + expect(statusOf(out.steps, 'associate')).to.equal('pending'); + expect(statusOf(out.steps, 'verify')).to.equal('pending'); + expect(out.routingDeployed).to.equal(false); + expect(out.verified).to.equal(false); + // function already LIVE → never created/published. + expect(cfCalls('CreateFunction')).to.have.length(0); + expect(cfCalls('PublishFunction')).to.have.length(0); + }); + + it('with lambda ready proceeds to associate then verify (in_progress until propagation)', async () => { + const lambdaVersionArn = 'arn:aws:lambda:us-east-1:120569600543:function:edgeoptimize-origin:3'; + wire( + { + GetDistributionConfig: () => ({ + // origin exists (idempotent), default behavior NOT yet associated (associate must run). + DistributionConfig: { + Origins: { + Items: [{ + Id: 'EdgeOptimize_Origin', + DomainName: 'dev.edgeoptimize.net', + CustomHeaders: { + Items: [ + { HeaderName: 'x-edgeoptimize-api-key', HeaderValue: 'eo-key' }, + { HeaderName: 'x-forwarded-host', HeaderValue: 'www.example.com' }, + ], + }, + }], + }, + DefaultCacheBehavior: { CachePolicyId: 'cp-1' }, + }, + ETag: 'etag', + }), + DescribeFunction: { FunctionSummary: { FunctionMetadata: { FunctionARN: 'arn:cf-fn' } } }, + ListCachePolicies: { CachePolicyList: { Items: [] } }, + GetCachePolicyConfig: { + CachePolicyConfig: { + Name: 'p', + MinTTL: 0, + ParametersInCacheKeyAndForwardedToOrigin: { + HeadersConfig: { + HeaderBehavior: 'whitelist', + Headers: { Quantity: 2, Items: ['x-edgeoptimize-config', 'x-edgeoptimize-url'] }, + }, + }, + }, + ETag: 'cp-etag', + }, + UpdateDistribution: {}, + ListDistributions: { DistributionList: { Items: [{ Id: 'E2EXAMPLE123', DomainName: 'd123.cloudfront.net' }] } }, + }, + { + GetFunctionConfiguration: { State: 'Active', LastUpdateStatus: 'Successful', FunctionArn: 'arn:lambda' }, + ListVersionsByFunction: { Versions: [{ Version: '3', FunctionArn: lambdaVersionArn, CodeSha256: 'sha' }] }, + }, + { + GetRole: { Role: { Arn: 'arn:role' } }, + }, + ); + // verify probe: bot lacks request-id → not passed yet (propagation). + fetchStub = sinon.stub(global, 'fetch'); + fetchStub.onFirstCall().resolves(makeFetchResponse(200, {})); + fetchStub.onSecondCall().resolves(makeFetchResponse(200, {})); + + const out = await edgeOptimize.runEdgeOptimizeDeployStep({}, deployParams); + + expect(statusOf(out.steps, 'lambda')).to.equal('done'); + expect(statusOf(out.steps, 'associate')).to.equal('done'); + expect(statusOf(out.steps, 'verify')).to.equal('in_progress'); + expect(out.routingDeployed).to.equal(true); + expect(out.verified).to.equal(false); + // associate ran exactly one UpdateDistribution (behavior was not associated). + expect(cfCalls('UpdateDistribution')).to.have.length(1); + }); + + it('verify passes → verified true and verify done', async () => { + const lambdaVersionArn = 'arn:aws:lambda:us-east-1:120569600543:function:edgeoptimize-origin:3'; + wire( + { + GetDistributionConfig: () => ({ + DistributionConfig: { + Origins: { + Items: [{ + Id: 'EdgeOptimize_Origin', + DomainName: 'dev.edgeoptimize.net', + CustomHeaders: { + Items: [ + { HeaderName: 'x-edgeoptimize-api-key', HeaderValue: 'eo-key' }, + { HeaderName: 'x-forwarded-host', HeaderValue: 'www.example.com' }, + ], + }, + }], + }, + // already associated → associate gate skips UpdateDistribution. + DefaultCacheBehavior: { + CachePolicyId: 'cp-1', + FunctionAssociations: { Items: [{ EventType: 'viewer-request', FunctionARN: 'arn:fn/edgeoptimize-routing' }] }, + LambdaFunctionAssociations: { Items: [{ EventType: 'origin-request', LambdaFunctionARN: 'arn:edgeoptimize-origin:3' }] }, + }, + }, + ETag: 'etag', + }), + DescribeFunction: { FunctionSummary: { FunctionMetadata: { FunctionARN: 'arn:cf-fn' } } }, + ListCachePolicies: { CachePolicyList: { Items: [] } }, + GetCachePolicyConfig: { + CachePolicyConfig: { + Name: 'p', + MinTTL: 0, + ParametersInCacheKeyAndForwardedToOrigin: { + HeadersConfig: { + HeaderBehavior: 'whitelist', + Headers: { Quantity: 2, Items: ['x-edgeoptimize-config', 'x-edgeoptimize-url'] }, + }, + }, + }, + ETag: 'cp-etag', + }, + ListDistributions: { DistributionList: { Items: [{ Id: 'E2EXAMPLE123', DomainName: 'd123.cloudfront.net' }] } }, + }, + { + GetFunctionConfiguration: { State: 'Active', LastUpdateStatus: 'Successful', FunctionArn: 'arn:lambda' }, + ListVersionsByFunction: { Versions: [{ Version: '3', FunctionArn: lambdaVersionArn, CodeSha256: 'sha' }] }, + }, + { GetRole: { Role: { Arn: 'arn:role' } } }, + ); + fetchStub = sinon.stub(global, 'fetch'); + fetchStub.onFirstCall().resolves(makeFetchResponse(200, { 'x-edgeoptimize-request-id': 'req-1' })); + fetchStub.onSecondCall().resolves(makeFetchResponse(200, {})); + + const out = await edgeOptimize.runEdgeOptimizeDeployStep({}, deployParams); + + expect(statusOf(out.steps, 'associate')).to.equal('done'); + expect(statusOf(out.steps, 'verify')).to.equal('done'); + expect(out.routingDeployed).to.equal(true); + expect(out.verified).to.equal(true); + // idempotent gate: behavior already associated → no UpdateDistribution at all. + expect(cfCalls('UpdateDistribution')).to.have.length(0); + }); + + it('marks the step error (earlier done, later pending) and does not throw when a step fails', async () => { + wire( + { + GetDistributionConfig: { + DistributionConfig: { + Origins: { + Items: [{ + Id: 'EdgeOptimize_Origin', + DomainName: 'dev.edgeoptimize.net', + CustomHeaders: { + Items: [ + { HeaderName: 'x-edgeoptimize-api-key', HeaderValue: 'eo-key' }, + { HeaderName: 'x-forwarded-host', HeaderValue: 'www.example.com' }, + ], + }, + }], + }, + DefaultCacheBehavior: { CachePolicyId: 'cp-1' }, + }, + ETag: 'etag', + }, + // function gate DescribeFunction throws a non-NoSuchFunction error → step error. + DescribeFunction: () => { throw new Error('AccessDenied on DescribeFunction'); }, + }, + ); + + const out = await edgeOptimize.runEdgeOptimizeDeployStep({}, deployParams); + + expect(statusOf(out.steps, 'origin')).to.equal('done'); + expect(statusOf(out.steps, 'function')).to.equal('error'); + expect(out.steps.find((s) => s.key === 'function').detail).to.include('AccessDenied'); + // later steps remain pending. + expect(statusOf(out.steps, 'cache')).to.equal('pending'); + expect(statusOf(out.steps, 'lambda')).to.equal('pending'); + expect(out.routingDeployed).to.equal(false); + }); + + it('holds the sequence when lambda exists but is not yet ready (no re-create)', async () => { + wire( + { + GetDistributionConfig: { + DistributionConfig: { + Origins: { + Items: [{ + Id: 'EdgeOptimize_Origin', + DomainName: 'dev.edgeoptimize.net', + CustomHeaders: { + Items: [ + { HeaderName: 'x-edgeoptimize-api-key', HeaderValue: 'eo-key' }, + { HeaderName: 'x-forwarded-host', HeaderValue: 'www.example.com' }, + ], + }, + }], + }, + DefaultCacheBehavior: { CachePolicyId: 'cp-1' }, + }, + ETag: 'etag', + }, + DescribeFunction: { FunctionSummary: { FunctionMetadata: { FunctionARN: 'arn:cf-fn' } } }, + ListCachePolicies: { CachePolicyList: { Items: [] } }, + GetCachePolicyConfig: { + CachePolicyConfig: { + Name: 'p', + MinTTL: 0, + ParametersInCacheKeyAndForwardedToOrigin: { + HeadersConfig: { + HeaderBehavior: 'whitelist', + Headers: { Quantity: 2, Items: ['x-edgeoptimize-config', 'x-edgeoptimize-url'] }, + }, + }, + }, + ETag: 'cp-etag', + }, + }, + { + // exists but still finalizing → not ready, must NOT re-create. + GetFunctionConfiguration: { State: 'Pending', LastUpdateStatus: 'InProgress', FunctionArn: 'arn:lambda' }, + ListVersionsByFunction: { Versions: [] }, + }, + { GetRole: { Role: { Arn: 'arn:role' } } }, + ); + + const out = await edgeOptimize.runEdgeOptimizeDeployStep({}, deployParams); + + expect(statusOf(out.steps, 'lambda')).to.equal('in_progress'); + expect(statusOf(out.steps, 'associate')).to.equal('pending'); + // never called CreateFunction on the Lambda client (no re-create of an in-flight function). + expect(lambdaSendStub.getCalls().filter((c) => c.args[0].commandName === 'CreateFunction')).to.have.length(0); + }); + }); }); From d76198a4df0ebcfcc38c3b53dd15069ce41df6f6 Mon Sep 17 00:00:00 2001 From: Akash Bhardwaj Date: Mon, 22 Jun 2026 18:10:44 +0530 Subject: [PATCH 19/56] test(llmo): raise edge-optimize esmock before-hook timeout to 120s The one-time setupEsmock before() hook in edge-optimize.test.js exceeded the 30s hook timeout under the full CI suite (12k+ tests + nyc coverage + heap pressure), failing the build even though it completes in ~1s locally. The orchestrator's added code + tests (and a main merge) pushed cumulative suite memory over the edge. Give the one-time hook generous headroom so suite growth can't flake the whole build. Co-Authored-By: Claude Opus 4.8 --- test/support/edge-optimize.test.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/test/support/edge-optimize.test.js b/test/support/edge-optimize.test.js index d2ac211161..218d0e1e30 100644 --- a/test/support/edge-optimize.test.js +++ b/test/support/edge-optimize.test.js @@ -26,7 +26,11 @@ describe('edge-optimize support', () => { // The mocked clients call the `*SendStub` closures, which read the `let` bindings reassigned // fresh in beforeEach, so a single esmock works for all tests. before(async function setupEsmock() { - this.timeout(30000); + // One-time esmock of the AWS SDK module graph. This is memory-heavy, so under the full CI + // suite (12k+ tests + nyc coverage + heap pressure) it can take well over the default/30s + // even though it runs in ~1s locally. Give the hook generous headroom so it can't flake the + // whole build on suite growth (it still completes in seconds in practice). + this.timeout(120000); // Each command in a mocked module is a constructor FUNCTION (not a class) — eslint forbids // multiple class declarations in one file, so we capture the command name + input on `this`. const cfCommand = (Name) => function CloudFrontCommand(input) { From 834564059b082a631faabd1a626a6617b9a03ddc Mon Sep 17 00:00:00 2001 From: Akash Bhardwaj Date: Mon, 22 Jun 2026 21:33:33 +0530 Subject: [PATCH 20/56] fix(llmo): publish Lambda@Edge version in deploy orchestrator The orchestrator only called createEdgeOptimizeLambda when the function did NOT exist; once it existed (Pending -> Active) it merely status-checked. But createEdgeOptimizeLambda is the only thing that PUBLISHES the numbered version (Active + no version -> PublishVersion), and readiness requires a published version -> the Deploy step hung at 'Lambda@Edge is still provisioning' forever. Fix: call createEdgeOptimizeLambda on EVERY not-ready poll (it is idempotent + non-blocking: creates when missing, no-ops while Pending, publishes once Active). Added a unit test for the publish-on-Active path; updated the 'holds while Pending' test (createEdgeOptimizeLambda now runs but still does not CreateFunction/PublishVersion while Pending). Co-Authored-By: Claude Opus 4.8 --- src/support/edge-optimize.js | 29 +++++++----- test/support/edge-optimize.test.js | 75 ++++++++++++++++++++++++++++-- 2 files changed, 90 insertions(+), 14 deletions(-) diff --git a/src/support/edge-optimize.js b/src/support/edge-optimize.js index 257689bbf3..8537127454 100644 --- a/src/support/edge-optimize.js +++ b/src/support/edge-optimize.js @@ -1266,23 +1266,30 @@ export async function runEdgeOptimizeDeployStep( return { routingDeployed, verified, steps }; } - // ── 4. lambda — kick off / poll WITHOUT re-creating; stop here while still provisioning. ── + // ── 4. lambda — drive the create/publish state machine each poll (idempotent + non-blocking). ── + // createEdgeOptimizeLambda creates the function when missing, no-ops while it is Pending, and — + // crucially — PUBLISHES a numbered version once the function is Active (which is what flips it to + // ready). We must call it on EVERY not-ready poll, not only when the function is missing: the + // version is published on a later poll (after the function reaches Active), so if we merely + // status-check while it "exists", the version never gets published and the step hangs at + // "provisioning" forever. try { const ls = await getEdgeOptimizeLambdaStatus(credentials, region); if (ls.ready) { lambdaVersionArn = ls.versionArn; byKey('lambda').status = 'done'; - } else if (ls.exists) { - // Provisioning in progress — do NOT re-create. Hold the sequence and re-check next poll. - byKey('lambda').status = 'in_progress'; - byKey('lambda').detail = 'Lambda@Edge is still provisioning'; - return { routingDeployed, verified, steps }; } else { - // Not created yet — kick off the create (returns fast) and report in_progress. - await createEdgeOptimizeLambda(credentials, accountId, { region }); - byKey('lambda').status = 'in_progress'; - byKey('lambda').detail = 'Lambda@Edge create started'; - return { routingDeployed, verified, steps }; + const created = await createEdgeOptimizeLambda(credentials, accountId, { region }); + if (created.status === 'ready') { + lambdaVersionArn = created.versionArn; + byKey('lambda').status = 'done'; + } else { + byKey('lambda').status = 'in_progress'; + byKey('lambda').detail = ls.exists + ? 'Lambda@Edge is still provisioning' + : 'Lambda@Edge create started'; + return { routingDeployed, verified, steps }; + } } } catch (err) { byKey('lambda').status = 'error'; diff --git a/test/support/edge-optimize.test.js b/test/support/edge-optimize.test.js index 218d0e1e30..6717c92f34 100644 --- a/test/support/edge-optimize.test.js +++ b/test/support/edge-optimize.test.js @@ -1334,19 +1334,88 @@ describe('edge-optimize support', () => { }, }, { - // exists but still finalizing → not ready, must NOT re-create. + // exists but still finalizing (Pending) → createEdgeOptimizeLambda is called to drive the + // state machine, but it must NOT CreateFunction or PublishVersion while still Pending. GetFunctionConfiguration: { State: 'Pending', LastUpdateStatus: 'InProgress', FunctionArn: 'arn:lambda' }, ListVersionsByFunction: { Versions: [] }, }, - { GetRole: { Role: { Arn: 'arn:role' } } }, + { GetRole: { Role: { Arn: 'arn:role' } }, UpdateAssumeRolePolicy: {}, PutRolePolicy: {} }, ); const out = await edgeOptimize.runEdgeOptimizeDeployStep({}, deployParams); expect(statusOf(out.steps, 'lambda')).to.equal('in_progress'); expect(statusOf(out.steps, 'associate')).to.equal('pending'); - // never called CreateFunction on the Lambda client (no re-create of an in-flight function). + // Pending → neither CreateFunction nor PublishVersion (no re-create, no premature publish). expect(lambdaSendStub.getCalls().filter((c) => c.args[0].commandName === 'CreateFunction')).to.have.length(0); + expect(lambdaSendStub.getCalls().filter((c) => c.args[0].commandName === 'PublishVersion')).to.have.length(0); + }); + + it('publishes the version once the Lambda is Active, then proceeds to associate + verify', async () => { + const lambdaVersionArn = 'arn:aws:lambda:us-east-1:120569600543:function:edgeoptimize-origin:1'; + wire( + { + GetDistributionConfig: () => ({ + DistributionConfig: { + Origins: { + Items: [{ + Id: 'EdgeOptimize_Origin', + DomainName: 'dev.edgeoptimize.net', + CustomHeaders: { + Items: [ + { HeaderName: 'x-edgeoptimize-api-key', HeaderValue: 'eo-key' }, + { HeaderName: 'x-forwarded-host', HeaderValue: 'www.example.com' }, + ], + }, + }], + }, + // already associated → associate gate skips; the focus is the lambda publish path. + DefaultCacheBehavior: { + CachePolicyId: 'cp-1', + FunctionAssociations: { Items: [{ EventType: 'viewer-request', FunctionARN: 'arn:fn/edgeoptimize-routing' }] }, + LambdaFunctionAssociations: { Items: [{ EventType: 'origin-request', LambdaFunctionARN: 'arn:edgeoptimize-origin:1' }] }, + }, + }, + ETag: 'etag', + }), + DescribeFunction: { FunctionSummary: { FunctionMetadata: { FunctionARN: 'arn:cf-fn' } } }, + ListCachePolicies: { CachePolicyList: { Items: [] } }, + GetCachePolicyConfig: { + CachePolicyConfig: { + Name: 'p', + MinTTL: 0, + ParametersInCacheKeyAndForwardedToOrigin: { + HeadersConfig: { + HeaderBehavior: 'whitelist', + Headers: { Quantity: 2, Items: ['x-edgeoptimize-config', 'x-edgeoptimize-url'] }, + }, + }, + }, + ETag: 'cp-etag', + }, + ListDistributions: { DistributionList: { Items: [{ Id: 'E2EXAMPLE123', DomainName: 'd123.cloudfront.net' }] } }, + }, + { + // Active + idle, NO published version yet → createEdgeOptimizeLambda must publish one. + GetFunctionConfiguration: { State: 'Active', LastUpdateStatus: 'Successful', FunctionArn: 'arn:lambda' }, + ListVersionsByFunction: { Versions: [] }, + PublishVersion: { Version: '1', FunctionArn: lambdaVersionArn }, + }, + { GetRole: { Role: { Arn: 'arn:role' } }, UpdateAssumeRolePolicy: {}, PutRolePolicy: {} }, + ); + fetchStub = sinon.stub(global, 'fetch'); + fetchStub.onFirstCall().resolves(makeFetchResponse(200, { 'x-edgeoptimize-request-id': 'req-1' })); + fetchStub.onSecondCall().resolves(makeFetchResponse(200, {})); + + const out = await edgeOptimize.runEdgeOptimizeDeployStep({}, deployParams); + + // the fix: Active-without-version gets published → lambda flips to done (not stuck). + expect(statusOf(out.steps, 'lambda')).to.equal('done'); + expect(lambdaSendStub.getCalls().filter((c) => c.args[0].commandName === 'PublishVersion')).to.have.length(1); + expect(statusOf(out.steps, 'associate')).to.equal('done'); + expect(statusOf(out.steps, 'verify')).to.equal('done'); + expect(out.routingDeployed).to.equal(true); + expect(out.verified).to.equal(true); }); }); }); From e09b37e7f5b1c6b0ee8907b8946ab7b17c491850 Mon Sep 17 00:00:00 2001 From: Akash Bhardwaj Date: Mon, 22 Jun 2026 22:01:52 +0530 Subject: [PATCH 21/56] feat(llmo): add customer-managed Edge Optimize installer-url endpoint Add GET /sites/:siteId/llmo/edge-optimize/installer-url ("Option B"): a read-only handler that presigns the CloudFront installer template and builds a one-click CloudFormation quick-create ("Launch Stack") URL the customer runs entirely in their own AWS account (us-east-1). No cross-account access: no AssumeRole, no SDK mutations. The handler only reads the site's Edge Optimize API key (trimmed), presigns the template, and builds the URL. Prefills SiteHost + EdgeOptimizeApiKey (+ sensible defaults); DistributionId and DefaultOriginId are left unset for the customer to fill in. - handler getEdgeOptimizeInstallerUrl in src/controllers/llmo/llmo.js - route in src/routes/index.js + INTERNAL_ROUTES in required-capabilities.js - OpenAPI path in docs/openapi (api.yaml + llmo-api.yaml) - unit tests mirroring getEdgeOptimizeBootstrapUrl (200/400/404/403) Co-Authored-By: Claude Opus 4.8 --- docs/openapi/api.yaml | 2 + docs/openapi/llmo-api.yaml | 58 ++++++++++++++++++ src/controllers/llmo/llmo.js | 80 +++++++++++++++++++++++++ src/routes/index.js | 1 + src/routes/required-capabilities.js | 1 + test/controllers/llmo/llmo.test.js | 91 +++++++++++++++++++++++++++++ 6 files changed, 233 insertions(+) diff --git a/docs/openapi/api.yaml b/docs/openapi/api.yaml index a2ce395f35..d80a6bae92 100644 --- a/docs/openapi/api.yaml +++ b/docs/openapi/api.yaml @@ -653,6 +653,8 @@ paths: $ref: './llmo-api.yaml#/site-llmo-edge-optimize-verify' /sites/{siteId}/llmo/edge-optimize/deploy: $ref: './llmo-api.yaml#/site-llmo-edge-optimize-deploy' + /sites/{siteId}/llmo/edge-optimize/installer-url: + $ref: './llmo-api.yaml#/site-llmo-edge-optimize-installer-url' /sites/{siteId}/llmo/edge-optimize-status: $ref: './llmo-api.yaml#/llmo-edge-optimize-status' /sites/{siteId}/llmo/probes/edge-optimize: diff --git a/docs/openapi/llmo-api.yaml b/docs/openapi/llmo-api.yaml index 4405fc96d6..b45adcc53b 100644 --- a/docs/openapi/llmo-api.yaml +++ b/docs/openapi/llmo-api.yaml @@ -2409,6 +2409,64 @@ site-llmo-edge-optimize-bootstrap-url: security: - api_key: [ ] +site-llmo-edge-optimize-installer-url: + get: + tags: + - llmo + summary: Generate a one-click CloudFormation "Launch Stack" URL for a customer-managed Edge Optimize install + description: | + "Option B" onboarding. Returns a one-click AWS CloudFormation quick-create + ("Launch Stack") URL (with a server-side presigned template URL) that the + customer runs entirely in their own AWS account (us-east-1, a Lambda@Edge + requirement). Unlike the assume-role wizard, this endpoint makes no + cross-account calls — no AssumeRole, no SDK mutations. It only reads the + site's Edge Optimize API key, presigns the installer template, and builds + the URL, so Adobe gets no access to the customer's account. + + The URL prefills only `SiteHost` and `EdgeOptimizeApiKey` (plus sensible + defaults). `DistributionId` and `DefaultOriginId` are intentionally left + unset for the customer to fill in the CloudFormation form, because they are + account-specific and cannot be discovered without cross-account access. + operationId: getEdgeOptimizeInstallerUrl + parameters: + - $ref: './parameters.yaml#/siteId' + responses: + '200': + description: Installer URL generated successfully + content: + application/json: + schema: + type: object + required: + - quickCreateUrl + - siteHost + - presignTtlSeconds + properties: + quickCreateUrl: + type: string + description: One-click CloudFormation quick-create URL (presigned template) + siteHost: + type: string + description: The customer host forwarded to Edge Optimize (x-forwarded-host) + presignTtlSeconds: + type: integer + example: + quickCreateUrl: 'https://us-east-1.console.aws.amazon.com/cloudformation/home?region=us-east-1#/stacks/quickcreate?templateURL=...&stackName=edgeoptimize¶m_SiteHost=www.example.com¶m_EdgeOptimizeApiKey=...' + siteHost: www.example.com + presignTtlSeconds: 3600 + '400': + $ref: './responses.yaml#/400' + '401': + $ref: './responses.yaml#/401' + '403': + $ref: './responses.yaml#/403' + '404': + $ref: './responses.yaml#/404' + '500': + $ref: './responses.yaml#/500' + security: + - api_key: [ ] + site-llmo-edge-optimize-connect: post: tags: diff --git a/src/controllers/llmo/llmo.js b/src/controllers/llmo/llmo.js index 2f8db4fce5..a3f5fd273e 100644 --- a/src/controllers/llmo/llmo.js +++ b/src/controllers/llmo/llmo.js @@ -2191,6 +2191,85 @@ function LlmoController(ctx) { return { site }; }; + /** + * GET /sites/{siteId}/llmo/edge-optimize/installer-url + * "Option B" — builds a one-click CloudFormation quick-create ("Launch Stack") URL for a + * fully customer-managed Edge Optimize install. Unlike the assume-role wizard, this endpoint + * makes NO cross-account calls (no AssumeRole, no SDK mutations): it only reads the site's + * Edge Optimize config + presigns the installer template + builds a URL. Everything runs in + * the customer's own AWS account when they launch the stack — Adobe gets no access. + * + * Prefills only SiteHost + EdgeOptimizeApiKey (plus sensible defaults). DistributionId and + * DefaultOriginId are intentionally left UNSET — they are account-specific and the customer + * fills them in the CloudFormation form (we have no cross-account access to discover them). + * @param {object} context - Request context + * @returns {Promise} CloudFormation quick-create URL + siteHost + presign TTL + */ + const getEdgeOptimizeInstallerUrl = async (context) => { + const { + log, dataAccess, env, s3, + } = context; + const { siteId } = context.params; + const { Site } = dataAccess; + + try { + const { error, site } = await gateEdgeOptimizeWizard(siteId, Site, 'generate the edge optimize installer link'); + if (error) { + return error; + } + + const baseURL = site.getBaseURL(); + const metaconfig = await TokowakaClient.createFrom(context).fetchMetaconfig(baseURL); + const rawApiKey = metaconfig?.apiKeys?.[0]; + if (!hasText(rawApiKey)) { + return badRequest('Site has no Edge Optimize API key — enable Edge Optimize first'); + } + // TRIM the apiKey — a stray newline/space breaks the EO header at the edge. + const apiKey = String(rawApiKey).trim(); + const siteHost = String(calculateForwardedHost(baseURL, log) || '').trim(); + + if (!s3?.s3Client) { + return badRequest('Edge optimize template hosting is not configured for this environment'); + } + + // Presign the (private) installer template so the customer's CloudFormation can read it + // cross-account via the signature — no public bucket, no customer S3 access. + const bucket = env.EDGE_OPTIMIZE_TEMPLATE_BUCKET || 'llmo-edgeoptimize-cf-template'; + const key = env.EDGE_OPTIMIZE_INSTALLER_KEY || 'edgeoptimize-cloudfront-installer.yaml'; + const region = 'us-east-1'; // Lambda@Edge requirement + // Longer TTL than the role link — this is a one-shot launch the customer opens directly. + const presignTtlSeconds = Number(env.EDGE_OPTIMIZE_PRESIGN_TTL || 3600); + + const templateUrl = await s3.getSignedUrl( + s3.s3Client, + new s3.GetObjectCommand({ Bucket: bucket, Key: key }), + { expiresIn: presignTtlSeconds }, + ); + + // Prefill only SiteHost + EdgeOptimizeApiKey (+ sensible defaults). Leave DistributionId + // and DefaultOriginId UNSET — the customer fills those in the CloudFormation form. + const params = { + SiteHost: siteHost, + EdgeOptimizeApiKey: apiKey, + TargetBehaviorPathPattern: 'default', + TargetedPathsJson: 'null', + RestoreDistributionOnDelete: 'true', + }; + const qs = new URLSearchParams(); + qs.set('templateURL', templateUrl); + qs.set('stackName', 'edgeoptimize'); + Object.entries(params).forEach(([k, v]) => qs.set(`param_${k}`, v)); + const quickCreateUrl = `https://${region}.console.aws.amazon.com/cloudformation/home?region=${region}#/stacks/quickcreate?${qs.toString()}`; + + log.info(`[edge-optimize-installer-url] Generated installer URL for site ${siteId}`); + + return ok({ quickCreateUrl, siteHost, presignTtlSeconds }); + } catch (error) { + log.error(`Failed to generate edge optimize installer URL for site ${siteId}:`, error); + return badRequest(cleanupHeaderValue(error.message)); + } + }; + // Verify the customer's cross-account connector role is assumable. Used by the wizard's // "Allow access" step, which polls this after the customer creates the role via CloudFormation. const connectEdgeOptimize = async (context) => { @@ -2794,6 +2873,7 @@ function LlmoController(ctx) { return { getEdgeOptimizeBootstrapUrl, + getEdgeOptimizeInstallerUrl, connectEdgeOptimize, getEdgeOptimizeDistributions, checkEdgeOptimizePrerequisites, diff --git a/src/routes/index.js b/src/routes/index.js index fb42b2eb9e..dc290ec356 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -497,6 +497,7 @@ export default function getRouteHandlers( 'POST /sites/:siteId/llmo/edge-optimize/apply-associations': llmoController.applyEdgeOptimizeAssociations, 'POST /sites/:siteId/llmo/edge-optimize/verify': llmoController.verifyEdgeOptimizeRouting, 'POST /sites/:siteId/llmo/edge-optimize/deploy': llmoController.deployEdgeOptimize, + 'GET /sites/:siteId/llmo/edge-optimize/installer-url': llmoController.getEdgeOptimizeInstallerUrl, 'GET /sites/:siteId/llmo/strategy': llmoController.getStrategy, 'PUT /sites/:siteId/llmo/strategy': llmoController.saveStrategy, 'GET /sites/:siteId/llmo/edge-optimize-status': llmoController.checkEdgeOptimizeStatus, diff --git a/src/routes/required-capabilities.js b/src/routes/required-capabilities.js index 2436784aee..bc82186168 100644 --- a/src/routes/required-capabilities.js +++ b/src/routes/required-capabilities.js @@ -144,6 +144,7 @@ export const INTERNAL_ROUTES = [ 'POST /sites/:siteId/llmo/edge-optimize/apply-associations', 'POST /sites/:siteId/llmo/edge-optimize/verify', 'POST /sites/:siteId/llmo/edge-optimize/deploy', + 'GET /sites/:siteId/llmo/edge-optimize/installer-url', 'PUT /sites/:siteId/llmo/opportunities-reviewed', // PLG onboarding - IMS token auth, self-service flow, not S2S diff --git a/test/controllers/llmo/llmo.test.js b/test/controllers/llmo/llmo.test.js index 6cf30a924c..810412618c 100644 --- a/test/controllers/llmo/llmo.test.js +++ b/test/controllers/llmo/llmo.test.js @@ -7659,6 +7659,97 @@ describe('LlmoController', () => { }); }); + describe('getEdgeOptimizeInstallerUrl', () => { + let installerContext; + let getSignedUrlStub; + + beforeEach(() => { + getSignedUrlStub = sinon.stub().resolves('https://llmo-edgeoptimize-cf-template.s3.us-east-1.amazonaws.com/edgeoptimize-cloudfront-installer.yaml?X-Amz-Signature=abc'); + // The handler trims the api key — seed a value with surrounding whitespace + // to assert trimming. + mockTokowakaClient.fetchMetaconfig.resolves({ apiKeys: [' eo-secret-key \n'] }); + installerContext = { + ...mockContext, + params: { siteId: TEST_SITE_ID }, + env: {}, + s3: { + s3Client: {}, + getSignedUrl: getSignedUrlStub, + GetObjectCommand: function MockGetObjectCommand(params) { + Object.assign(this, params); + }, + }, + }; + }); + + it('returns a quick-create URL prefilling SiteHost + EdgeOptimizeApiKey (but not DistributionId)', async () => { + const result = await controller.getEdgeOptimizeInstallerUrl(installerContext); + + expect(result.status).to.equal(200); + const body = await result.json(); + expect(body.quickCreateUrl).to.include('stacks/quickcreate'); + expect(body.quickCreateUrl).to.include('templateURL='); + // siteHost is www.example.com (calculateForwardedHost on https://www.example.com) + expect(body.quickCreateUrl).to.include('param_SiteHost=www.example.com'); + // api key is trimmed (no leading/trailing whitespace or newline survives) + expect(body.quickCreateUrl).to.include('param_EdgeOptimizeApiKey=eo-secret-key'); + // DistributionId + DefaultOriginId are intentionally left unset (customer fills them) + expect(body.quickCreateUrl).to.not.include('param_DistributionId'); + expect(body.quickCreateUrl).to.not.include('param_DefaultOriginId'); + expect(body.siteHost).to.equal('www.example.com'); + expect(body.presignTtlSeconds).to.equal(3600); + expect(getSignedUrlStub.calledOnce).to.equal(true); + }); + + it('returns 400 when the site has no Edge Optimize API key', async () => { + mockTokowakaClient.fetchMetaconfig.resolves({ apiKeys: [] }); + + const result = await controller.getEdgeOptimizeInstallerUrl(installerContext); + + expect(result.status).to.equal(400); + const body = await result.json(); + expect(body.message).to.include('no Edge Optimize API key'); + }); + + it('returns 400 when template hosting is not configured (no S3 client)', async () => { + const result = await controller.getEdgeOptimizeInstallerUrl({ ...installerContext, s3: {} }); + + expect(result.status).to.equal(400); + const body = await result.json(); + expect(body.message).to.include('not configured'); + }); + + it('returns 404 when the site is not found', async () => { + mockDataAccess.Site.findById.resolves(null); + + const result = await controller.getEdgeOptimizeInstallerUrl(installerContext); + + expect(result.status).to.equal(404); + }); + + it('returns 403 when the user lacks access to the site', async () => { + const deniedController = controllerWithAccessDenied(mockContext); + + const result = await deniedController.getEdgeOptimizeInstallerUrl(installerContext); + + expect(result.status).to.equal(403); + }); + + it('returns 403 when the user is not an LLMO administrator', async () => { + const LlmoControllerNoAdmin = await esmock('../../../src/controllers/llmo/llmo.js', { + '../../../src/support/access-control-util.js': createMockAccessControlUtil(true, true, false), + '@adobe/spacecat-shared-http-utils': mockHttpUtils, + '../../../src/support/cached-response.js': mockCachedResponse, + ...getCommonMocks(), + }); + + const result = await LlmoControllerNoAdmin(mockContext) + .getEdgeOptimizeInstallerUrl(installerContext); + + expect(result.status).to.equal(403); + }); + }); + describe('connectEdgeOptimize', () => { let connectContext; From f459382c9a023f9af65b0f56861625d882b18b52 Mon Sep 17 00:00:00 2001 From: Akash Bhardwaj Date: Tue, 23 Jun 2026 08:07:59 +0530 Subject: [PATCH 22/56] test(llmo): register installer-url route in route-membership test Co-Authored-By: Claude Opus 4.8 --- test/routes/index.test.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/routes/index.test.js b/test/routes/index.test.js index ba0c0d3218..e812456313 100755 --- a/test/routes/index.test.js +++ b/test/routes/index.test.js @@ -340,6 +340,7 @@ describe('getRouteHandlers', () => { applyEdgeOptimizeAssociations: () => null, verifyEdgeOptimizeRouting: () => null, deployEdgeOptimize: () => null, + getEdgeOptimizeInstallerUrl: () => null, getEdgeConfig: () => null, createOrUpdateStageEdgeConfig: () => null, checkEdgeOptimizeStatus: () => null, @@ -1109,6 +1110,7 @@ describe('getRouteHandlers', () => { 'POST /sites/:siteId/llmo/edge-optimize/apply-associations', 'POST /sites/:siteId/llmo/edge-optimize/verify', 'POST /sites/:siteId/llmo/edge-optimize/deploy', + 'GET /sites/:siteId/llmo/edge-optimize/installer-url', 'GET /sites/:siteId/llmo/edge-optimize-status', 'GET /sites/:siteId/llmo/probes/edge-optimize', 'GET /sites/:siteId/llmo/strategy', From 030e1ceb10afec9554cc83e0d81317bd37319f45 Mon Sep 17 00:00:00 2001 From: Akash Bhardwaj Date: Wed, 24 Jun 2026 11:35:22 +0530 Subject: [PATCH 23/56] fix(llmo): point EO origin to live + verify by customer domain - default EO origin domain dev.edgeoptimize.net -> live.edgeoptimize.net (constant, Lambda@Edge hardcode, both controller handlers) to match the canonical llmo-code-samples reference - Verify now probes the customer's own domain (calculateForwardedHost / x-forwarded-host) instead of the *.cloudfront.net distribution domain; explicit override + cloudfront.net fallback retained - update controller + support tests Co-Authored-By: Claude Opus 4.8 --- src/controllers/llmo/llmo.js | 28 +++++++++++++++------------- src/support/edge-optimize.js | 18 ++++++++++++------ test/controllers/llmo/llmo.test.js | 21 +++++++++++++++------ 3 files changed, 42 insertions(+), 25 deletions(-) diff --git a/src/controllers/llmo/llmo.js b/src/controllers/llmo/llmo.js index a3f5fd273e..b412bb4026 100644 --- a/src/controllers/llmo/llmo.js +++ b/src/controllers/llmo/llmo.js @@ -2485,7 +2485,7 @@ function LlmoController(ctx) { const externalId = String(context.data?.externalId || '').trim(); const distributionId = String(context.data?.distributionId || '').trim(); const roleName = env?.EDGE_OPTIMIZE_ROLE_NAME || undefined; - const originDomain = env?.EDGE_OPTIMIZE_ORIGIN_DOMAIN || 'dev.edgeoptimize.net'; + const originDomain = env?.EDGE_OPTIMIZE_ORIGIN_DOMAIN || 'live.edgeoptimize.net'; if (accountId.length !== 12) { return badRequest('accountId must be a 12-digit AWS account ID'); @@ -2761,21 +2761,23 @@ function LlmoController(ctx) { } try { - const { error } = await gateEdgeOptimizeWizard(siteId, Site, 'verify edge optimize routing'); + const { error, site } = await gateEdgeOptimizeWizard(siteId, Site, 'verify edge optimize routing'); if (error) { return error; } - // Determine the URL to probe: prefer an explicit domain, else resolve from the distribution. - // - // TODO(prod): probe the customer's REAL onboarded domain, not the *.cloudfront.net domain. - // In prod, bot traffic hits the customer's own domain (their CNAME / distribution alias), so - // that is the true end-to-end test. We have it server-side via the site - // (calculateForwardedHost(site.getBaseURL())) and/or the distribution Aliases. We default to - // the distribution cloudfront.net DomainName today only because the dev test distribution has - // no custom domain. Switch the default to the site/alias domain before prod (keep the - // explicit `domain` override + cloudfront.net fallback for distributions with no alias). + // Probe the customer's REAL onboarded domain (the site's own host) — that is where bot + // traffic actually lands, so it is the true end-to-end test of the routing. An explicit + // `domain` override still wins; the distribution's *.cloudfront.net DomainName is only a + // last-resort fallback for distributions with no resolvable site host. let domain = String(context.data?.domain || '').trim(); + if (!hasText(domain)) { + try { + domain = String(calculateForwardedHost(site.getBaseURL(), log) || '').trim(); + } catch (e) { + log.warn(`[edge-optimize-verify] could not derive host from site baseURL: ${e.message}`); + } + } if (!hasText(domain)) { const { credentials } = await assumeConnectorRole({ accountId, externalId, roleName }); const distributions = await listCloudFrontDistributions(credentials); @@ -2783,7 +2785,7 @@ function LlmoController(ctx) { domain = match?.domainName || ''; } if (!hasText(domain)) { - return badRequest('Could not determine the distribution domain to verify'); + return badRequest('Could not determine the domain to verify'); } const url = /^https?:\/\//.test(domain) ? domain : `https://${domain}/`; @@ -2812,7 +2814,7 @@ function LlmoController(ctx) { const originId = String(context.data?.originId || '').trim(); const behavior = String(context.data?.behavior || '').trim(); const roleName = env?.EDGE_OPTIMIZE_ROLE_NAME || undefined; - const originDomain = env?.EDGE_OPTIMIZE_ORIGIN_DOMAIN || 'dev.edgeoptimize.net'; + const originDomain = env?.EDGE_OPTIMIZE_ORIGIN_DOMAIN || 'live.edgeoptimize.net'; if (accountId.length !== 12) { return badRequest('accountId must be a 12-digit AWS account ID'); diff --git a/src/support/edge-optimize.js b/src/support/edge-optimize.js index 8537127454..4f45bba5fa 100644 --- a/src/support/edge-optimize.js +++ b/src/support/edge-optimize.js @@ -53,7 +53,7 @@ const SESSION_DURATION_SECONDS = 900; // The connector role only permits writes to these exact resource names — keep them in sync // with the standalone connect-aws-wizard (server.mjs) and the customer-bootstrap-role policy. export const EDGE_OPTIMIZE_ORIGIN_ID = 'EdgeOptimize_Origin'; -export const EDGE_OPTIMIZE_DEFAULT_ORIGIN_DOMAIN = 'dev.edgeoptimize.net'; +export const EDGE_OPTIMIZE_DEFAULT_ORIGIN_DOMAIN = 'live.edgeoptimize.net'; export const EDGE_OPTIMIZE_FUNCTION_NAME = 'edgeoptimize-routing'; export const EDGE_OPTIMIZE_LAMBDA_FUNCTION_NAME = 'edgeoptimize-origin'; export const EDGE_OPTIMIZE_LAMBDA_ROLE_NAME = 'edgeoptimize-origin-role'; @@ -653,7 +653,7 @@ export const handler = async (event) => { const isEdgeOptimizeRequest = hasHeader(reqHeaders, 'x-edgeoptimize-request'); if (isEdgeOptimizeConfig && !isEdgeOptimizeRequest) { - if (originDomain === 'dev.edgeoptimize.net') { + if (originDomain === 'live.edgeoptimize.net') { console.log("Calling Edge Optimize Origin for agentic requests"); setHeader(request.headers, 'host', originDomain); } else { @@ -1320,12 +1320,18 @@ export async function runEdgeOptimizeDeployStep( // ── 6. verify — BEST-EFFORT: in_progress (not error) until CloudFront propagation lets pass. ── try { - const distributions = await listCloudFrontDistributions(credentials, region); - const match = distributions.find((d) => d.id === distributionId); - const domain = match?.domainName; + // Probe the customer's own onboarded host (from x-forwarded-host) — that is where bot traffic + // actually lands, so it is the true end-to-end test. Fall back to the distribution's + // *.cloudfront.net domain only when no site host is known. + let domain = String(originHeaders?.forwardedHost || '').trim(); + if (!hasText(domain)) { + const distributions = await listCloudFrontDistributions(credentials, region); + const match = distributions.find((d) => d.id === distributionId); + domain = match?.domainName || ''; + } if (!hasText(domain)) { byKey('verify').status = 'in_progress'; - byKey('verify').detail = 'waiting for distribution domain'; + byKey('verify').detail = 'waiting for domain'; return { routingDeployed, verified, steps }; } const result = await verifyEdgeOptimizeRouting(`https://${domain}/`); diff --git a/test/controllers/llmo/llmo.test.js b/test/controllers/llmo/llmo.test.js index 810412618c..c0d9c8e9c3 100644 --- a/test/controllers/llmo/llmo.test.js +++ b/test/controllers/llmo/llmo.test.js @@ -8314,7 +8314,7 @@ describe('LlmoController', () => { expect(createEdgeOptimizeOriginStub.calledOnceWith( sinon.match.any, 'E2EXAMPLE123', - 'dev.edgeoptimize.net', + 'live.edgeoptimize.net', sinon.match({ apiKey: 'eo-key-123', forwardedHost: 'www.example.com' }), )).to.equal(true); }); @@ -8871,15 +8871,15 @@ describe('LlmoController', () => { }; }); - it('resolves the domain from the distribution and verifies routing', async () => { + it('resolves the domain from the site and verifies routing (no distribution lookup)', async () => { const result = await controller.verifyEdgeOptimizeRouting(verifyContext); expect(result.status).to.equal(200); const body = await result.json(); expect(body.passed).to.equal(true); expect(body.requestId).to.equal('req-123'); - expect(verifyEdgeOptimizeRoutingStub.calledOnceWith('https://d111111abcdef8.cloudfront.net/')).to.equal(true); - expect(listCloudFrontDistributionsStub.calledOnce).to.equal(true); + expect(verifyEdgeOptimizeRoutingStub.calledOnceWith('https://www.example.com/')).to.equal(true); + expect(listCloudFrontDistributionsStub.called).to.equal(false); }); it('uses an explicit domain when provided (no distribution lookup)', async () => { @@ -8888,7 +8888,16 @@ describe('LlmoController', () => { expect(verifyEdgeOptimizeRoutingStub.calledOnceWith('https://www.example.com/')).to.equal(true); }); - it('returns 400 when the distribution domain cannot be resolved', async () => { + it('falls back to the distribution domain when the site host is unavailable', async () => { + mockSite.getBaseURL.returns(''); + const result = await controller.verifyEdgeOptimizeRouting(verifyContext); + expect(result.status).to.equal(200); + expect(verifyEdgeOptimizeRoutingStub.calledOnceWith('https://d111111abcdef8.cloudfront.net/')).to.equal(true); + expect(listCloudFrontDistributionsStub.calledOnce).to.equal(true); + }); + + it('returns 400 when no domain can be resolved (no site host, no distribution)', async () => { + mockSite.getBaseURL.returns(''); listCloudFrontDistributionsStub = sinon.stub().resolves([]); const result = await controller.verifyEdgeOptimizeRouting(verifyContext); expect(result.status).to.equal(400); @@ -8998,7 +9007,7 @@ describe('LlmoController', () => { distributionId: 'E2EXAMPLE123', originId: 'origin-aem', behavior: 'default', - originDomain: 'dev.edgeoptimize.net', + originDomain: 'live.edgeoptimize.net', accountId: '120569600543', }); expect(params.originHeaders).to.deep.equal({ apiKey: 'eo-key-123', forwardedHost: 'www.example.com' }); From ed638a1c677d446e470c2a322186c8c784362ee3 Mon Sep 17 00:00:00 2001 From: Akash Bhardwaj Date: Wed, 24 Jun 2026 16:29:04 +0530 Subject: [PATCH 24/56] feat(llmo): per-distribution EO resource names + short-TTL guard (Option A) - cache: clone managed policies into -adobe- (per-distribution, no account-level collision); custom policies still updated in place - cache: skip forcing MinTTL to 0 when the current MinTTL is already <= 5s (matches the BYOCDN doc), across legacy/custom/managed scenarios - resources: suffix the CloudFront function, Lambda@Edge function, and its IAM exec role with -adobe- (threaded distributionId through the deploy orchestrator + standalone handlers); EO origin id unchanged - all names stay within the connector role's edgeoptimize-*/Resource:* grants, so no customer re-onboarding is required Co-Authored-By: Claude Opus 4.8 --- src/controllers/llmo/llmo.js | 19 ++++- src/support/edge-optimize.js | 122 +++++++++++++++++++++-------- test/controllers/llmo/llmo.test.js | 6 +- test/support/edge-optimize.test.js | 60 ++++++++++---- 4 files changed, 154 insertions(+), 53 deletions(-) diff --git a/src/controllers/llmo/llmo.js b/src/controllers/llmo/llmo.js index e9bf75babe..838a4864c8 100644 --- a/src/controllers/llmo/llmo.js +++ b/src/controllers/llmo/llmo.js @@ -2726,6 +2726,7 @@ function LlmoController(ctx) { const result = await createEdgeOptimizeRoutingFunction( credentials, defaultOriginId, + distributionId, targetedPaths, ); log.info(`[edge-optimize-function] ${result.created ? 'Created' : 'Updated'} routing function for site ${siteId}`); @@ -2791,6 +2792,11 @@ function LlmoController(ctx) { return badRequest('externalId is required'); } + const distributionId = String(context.data?.distributionId || '').trim(); + if (!hasText(distributionId)) { + return badRequest('distributionId is required'); + } + try { const { error } = await gateEdgeOptimizeWizard(siteId, Site, 'create the edge optimize Lambda@Edge function'); if (error) { @@ -2800,7 +2806,11 @@ function LlmoController(ctx) { const { credentials, accountId: resolvedAccountId } = await assumeConnectorRole({ accountId, externalId, roleName, }); - const result = await createEdgeOptimizeLambda(credentials, resolvedAccountId); + const result = await createEdgeOptimizeLambda( + credentials, + resolvedAccountId, + { distributionId }, + ); log.info(`[edge-optimize-lambda] ${result.created ? 'Created' : 'Updated'} Lambda@Edge for site ${siteId}, published version ${result.version}`); return ok(result); } catch (error) { @@ -2826,6 +2836,11 @@ function LlmoController(ctx) { return badRequest('externalId is required'); } + const distributionId = String(context.data?.distributionId || '').trim(); + if (!hasText(distributionId)) { + return badRequest('distributionId is required'); + } + try { const { error } = await gateEdgeOptimizeWizard(siteId, Site, 'read the edge optimize Lambda@Edge status'); if (error) { @@ -2833,7 +2848,7 @@ function LlmoController(ctx) { } const { credentials } = await assumeConnectorRole({ accountId, externalId, roleName }); - const status = await getEdgeOptimizeLambdaStatus(credentials); + const status = await getEdgeOptimizeLambdaStatus(credentials, distributionId); return ok(status); } catch (error) { log.error(`Failed to read Lambda@Edge status for site ${siteId}:`, error); diff --git a/src/support/edge-optimize.js b/src/support/edge-optimize.js index 4f45bba5fa..daf206ee00 100644 --- a/src/support/edge-optimize.js +++ b/src/support/edge-optimize.js @@ -57,11 +57,41 @@ export const EDGE_OPTIMIZE_DEFAULT_ORIGIN_DOMAIN = 'live.edgeoptimize.net'; export const EDGE_OPTIMIZE_FUNCTION_NAME = 'edgeoptimize-routing'; export const EDGE_OPTIMIZE_LAMBDA_FUNCTION_NAME = 'edgeoptimize-origin'; export const EDGE_OPTIMIZE_LAMBDA_ROLE_NAME = 'edgeoptimize-origin-role'; + +// Per-distribution resource names — the `-adobe-` suffix keeps the account-level +// CloudFront function, Lambda@Edge function, and its IAM execution role unique per distribution +// (so one AWS account fronting multiple distributions never collides). All stay within the +// connector role's `edgeoptimize-*` (Lambda/role) and `Resource: '*'` (CloudFront) grants, so no +// customer re-onboarding is needed. The EO origin id is intentionally NOT suffixed — it is scoped +// inside the distribution config and cannot collide. +export const eoRoutingFunctionName = (distributionId) => `${EDGE_OPTIMIZE_FUNCTION_NAME}-adobe-${distributionId}`; +export const eoLambdaFunctionName = (distributionId) => `${EDGE_OPTIMIZE_LAMBDA_FUNCTION_NAME}-adobe-${distributionId}`; +export const eoLambdaRoleName = (distributionId) => `${EDGE_OPTIMIZE_LAMBDA_ROLE_NAME}-adobe-${distributionId}`; // Headers the routing CloudFront Function sets and that must reach the EO origin uncached. export const EDGE_OPTIMIZE_CACHE_HEADERS = ['x-edgeoptimize-config', 'x-edgeoptimize-url']; // Name of the custom cache policy we create when cloning an AWS-managed policy. export const EDGE_OPTIMIZE_CACHE_POLICY_NAME = 'edgeoptimize-cache'; +// Per the BYOCDN doc, force the cache policy MinTTL to 0 so agentic responses are not +// over-cached — UNLESS the current MinTTL is already short (<= this many seconds), in which +// case we leave it exactly as the customer configured it. +export const EDGE_OPTIMIZE_MIN_TTL_KEEP_THRESHOLD = 5; + +/** + * Build the per-distribution name for a cache policy cloned from a managed (AWS) policy. + * Strips the AWS `Managed-` prefix (a custom policy must not carry it) and appends an + * `-adobe-` suffix so each distribution gets its own clone (no account-level + * collision when one account fronts multiple distributions). Capped at the 128-char AWS limit. + * + * @param {string} sourceName - the source (managed) policy name, e.g. `Managed-CachingOptimized`. + * @param {string} distributionId - the CloudFront distribution id. + * @returns {string} e.g. `CachingOptimized-adobe-E2VLBZCBR857CC`. + */ +export function buildEoClonedCachePolicyName(sourceName, distributionId) { + const base = String(sourceName || 'cache').replace(/^Managed-/i, ''); + return `${base}-adobe-${distributionId}`.slice(0, 128); +} + const delay = (ms) => new Promise((resolve) => { setTimeout(resolve, ms); }); @@ -407,12 +437,17 @@ function handler(event) { export async function createEdgeOptimizeRoutingFunction( credentials, defaultOriginId, + distributionId, targetedPaths = null, region = EDGE_OPTIMIZE_REGION, ) { if (!hasText(defaultOriginId)) { throw new Error('defaultOriginId is required'); } + if (!hasText(distributionId)) { + throw new Error('distributionId is required'); + } + const functionName = eoRoutingFunctionName(distributionId); const client = new CloudFrontClient({ region, credentials }); const code = Buffer.from(buildRoutingFunctionCode(defaultOriginId, targetedPaths), 'utf-8'); const functionConfig = { @@ -424,7 +459,7 @@ export async function createEdgeOptimizeRoutingFunction( let existingEtag = null; try { const desc = await client.send(new DescribeFunctionCommand({ - Name: EDGE_OPTIMIZE_FUNCTION_NAME, + Name: functionName, Stage: 'DEVELOPMENT', })); existingEtag = desc.ETag; @@ -437,7 +472,7 @@ export async function createEdgeOptimizeRoutingFunction( let etag; if (existingEtag) { const updated = await client.send(new UpdateFunctionCommand({ - Name: EDGE_OPTIMIZE_FUNCTION_NAME, + Name: functionName, IfMatch: existingEtag, FunctionConfig: functionConfig, FunctionCode: code, @@ -445,7 +480,7 @@ export async function createEdgeOptimizeRoutingFunction( etag = updated.ETag; } else { const created = await client.send(new CreateFunctionCommand({ - Name: EDGE_OPTIMIZE_FUNCTION_NAME, + Name: functionName, FunctionConfig: functionConfig, FunctionCode: code, })); @@ -453,11 +488,11 @@ export async function createEdgeOptimizeRoutingFunction( } await client.send(new PublishFunctionCommand({ - Name: EDGE_OPTIMIZE_FUNCTION_NAME, + Name: functionName, IfMatch: etag, })); - return { name: EDGE_OPTIMIZE_FUNCTION_NAME, created: !existingEtag, stage: 'LIVE' }; + return { name: functionName, created: !existingEtag, stage: 'LIVE' }; } /** @@ -516,7 +551,7 @@ export async function applyEdgeOptimizeCacheHeaders( fv.Headers = { Quantity: items.length, Items: items }; behavior.ForwardedValues = fv; } - if (setMinTTLZero && behavior.MinTTL !== 0) { + if (setMinTTLZero && Number(behavior.MinTTL ?? 0) > EDGE_OPTIMIZE_MIN_TTL_KEEP_THRESHOLD) { behavior.MinTTL = 0; changed = true; } @@ -567,7 +602,8 @@ export async function applyEdgeOptimizeCacheHeaders( const params = pc.ParametersInCacheKeyAndForwardedToOrigin || {}; const headersChanged = addEoHeaders(params); pc.ParametersInCacheKeyAndForwardedToOrigin = params; - const needsMinTtl = setMinTTLZero && pc.MinTTL !== 0; + const needsMinTtl = setMinTTLZero + && Number(pc.MinTTL ?? 0) > EDGE_OPTIMIZE_MIN_TTL_KEEP_THRESHOLD; if (!headersChanged && !needsMinTtl) { return { scenario: 'custom', policyId, updated: false, alreadyForwarded: true, @@ -588,9 +624,10 @@ export async function applyEdgeOptimizeCacheHeaders( const srcResult = await client.send(new GetCachePolicyCommand({ Id: policyId })); const cloned = JSON.parse(JSON.stringify(srcResult.CachePolicy.CachePolicyConfig)); const sourceName = cloned.Name; - cloned.Name = EDGE_OPTIMIZE_CACHE_POLICY_NAME; + const clonedName = buildEoClonedCachePolicyName(sourceName, distributionId); + cloned.Name = clonedName; cloned.Comment = `Cloned from ${sourceName} with Edge Optimize headers — managed by LLM Optimizer`; - if (setMinTTLZero) { + if (setMinTTLZero && Number(cloned.MinTTL ?? 0) > EDGE_OPTIMIZE_MIN_TTL_KEEP_THRESHOLD) { cloned.MinTTL = 0; } const clonedParams = cloned.ParametersInCacheKeyAndForwardedToOrigin || {}; @@ -600,7 +637,7 @@ export async function applyEdgeOptimizeCacheHeaders( // Idempotent: reuse an existing edgeoptimize-cache custom policy if a prior run created it. const customList = await client.send(new ListCachePoliciesCommand({ Type: 'custom' })); const existing = (customList.CachePolicyList?.Items || []).find( - (i) => i.CachePolicy.CachePolicyConfig.Name === EDGE_OPTIMIZE_CACHE_POLICY_NAME, + (i) => i.CachePolicy.CachePolicyConfig.Name === clonedName, ); let newPolicyId; let reused = false; @@ -807,11 +844,18 @@ async function getLatestLambdaVersion(lambda, functionName) { export async function createEdgeOptimizeLambda( credentials, accountId, - { region = EDGE_OPTIMIZE_REGION, roleWaitMs = 12000, retryDelayMs = 5000 } = {}, + { + region = EDGE_OPTIMIZE_REGION, distributionId, roleWaitMs = 12000, retryDelayMs = 5000, + } = {}, ) { if (!/^[0-9]{12}$/.test(String(accountId))) { throw new Error('accountId must be a 12-digit AWS account ID'); } + if (!hasText(distributionId)) { + throw new Error('distributionId is required'); + } + const lambdaName = eoLambdaFunctionName(distributionId); + const roleName = eoLambdaRoleName(distributionId); const lambda = new LambdaClient({ region, credentials }); const iam = new IAMClient({ region, credentials }); @@ -822,11 +866,11 @@ export async function createEdgeOptimizeLambda( let roleIsNew = false; try { const existing = await iam.send( - new GetRoleCommand({ RoleName: EDGE_OPTIMIZE_LAMBDA_ROLE_NAME }), + new GetRoleCommand({ RoleName: roleName }), ); roleArn = existing.Role.Arn; await iam.send(new UpdateAssumeRolePolicyCommand({ - RoleName: EDGE_OPTIMIZE_LAMBDA_ROLE_NAME, + RoleName: roleName, PolicyDocument: LAMBDA_TRUST_POLICY, })); } catch (err) { @@ -834,7 +878,7 @@ export async function createEdgeOptimizeLambda( throw err; } const created = await iam.send(new CreateRoleCommand({ - RoleName: EDGE_OPTIMIZE_LAMBDA_ROLE_NAME, + RoleName: roleName, AssumeRolePolicyDocument: LAMBDA_TRUST_POLICY, Description: 'Execution role for EdgeOptimize Lambda@Edge function', })); @@ -844,9 +888,9 @@ export async function createEdgeOptimizeLambda( // ── 2. Attach the CloudWatch-logs inline policy. ── await iam.send(new PutRolePolicyCommand({ - RoleName: EDGE_OPTIMIZE_LAMBDA_ROLE_NAME, + RoleName: roleName, PolicyName: 'EdgeOptimizeLambdaLogging', - PolicyDocument: buildCwLogsPolicy(String(accountId), EDGE_OPTIMIZE_LAMBDA_FUNCTION_NAME), + PolicyDocument: buildCwLogsPolicy(String(accountId), lambdaName), })); // ── 3. Advance the function state machine WITHOUT blocking on provisioning. ── @@ -856,7 +900,7 @@ export async function createEdgeOptimizeLambda( let cfg = null; try { cfg = await lambda.send( - new GetFunctionConfigurationCommand({ FunctionName: EDGE_OPTIMIZE_LAMBDA_FUNCTION_NAME }), + new GetFunctionConfigurationCommand({ FunctionName: lambdaName }), ); } catch (err) { if (err.name !== 'ResourceNotFoundException') { @@ -875,7 +919,7 @@ export async function createEdgeOptimizeLambda( for (let attempt = 0; attempt < 3; attempt += 1) { try { const created = await lambda.send(new LambdaCreateFunctionCommand({ - FunctionName: EDGE_OPTIMIZE_LAMBDA_FUNCTION_NAME, + FunctionName: lambdaName, Runtime: 'nodejs24.x', Role: roleArn, Handler: 'index.handler', @@ -921,7 +965,7 @@ export async function createEdgeOptimizeLambda( } // Active and idle. If a numbered version already exists, reuse it (idempotent). - const existingVersion = await getLatestLambdaVersion(lambda, EDGE_OPTIMIZE_LAMBDA_FUNCTION_NAME); + const existingVersion = await getLatestLambdaVersion(lambda, lambdaName); if (existingVersion) { return { status: 'ready', @@ -936,7 +980,7 @@ export async function createEdgeOptimizeLambda( // Active, idle, no version yet → publish one (fast on an idle function). const published = await lambda.send(new PublishVersionCommand({ - FunctionName: EDGE_OPTIMIZE_LAMBDA_FUNCTION_NAME, + FunctionName: lambdaName, Description: 'Published by LLM Optimizer CloudFront wizard', })); return { @@ -959,14 +1003,23 @@ export async function createEdgeOptimizeLambda( * @returns {Promise<{exists: boolean, state?: string, lastUpdateStatus?: string, * functionArn?: string, versionArn: string|null, version?: string}>} */ -export async function getEdgeOptimizeLambdaStatus(credentials, region = EDGE_OPTIMIZE_REGION) { +export async function getEdgeOptimizeLambdaStatus( + credentials, + distributionId, + region = EDGE_OPTIMIZE_REGION, +) { + if (!hasText(distributionId)) { + throw new Error('distributionId is required'); + } + const lambdaName = eoLambdaFunctionName(distributionId); + const roleName = eoLambdaRoleName(distributionId); const lambda = new LambdaClient({ region, credentials }); const iam = new IAMClient({ region, credentials }); // Execution role status (created synchronously by create-lambda's ack call). let roleExists = false; try { - await iam.send(new GetRoleCommand({ RoleName: EDGE_OPTIMIZE_LAMBDA_ROLE_NAME })); + await iam.send(new GetRoleCommand({ RoleName: roleName })); roleExists = true; } catch (err) { if (err.name !== 'NoSuchEntityException') { @@ -978,7 +1031,7 @@ export async function getEdgeOptimizeLambdaStatus(credentials, region = EDGE_OPT let cfg; try { cfg = await lambda.send( - new GetFunctionConfigurationCommand({ FunctionName: EDGE_OPTIMIZE_LAMBDA_FUNCTION_NAME }), + new GetFunctionConfigurationCommand({ FunctionName: lambdaName }), ); } catch (err) { if (err.name === 'ResourceNotFoundException') { @@ -988,7 +1041,7 @@ export async function getEdgeOptimizeLambdaStatus(credentials, region = EDGE_OPT } throw err; } - const latest = await getLatestLambdaVersion(lambda, EDGE_OPTIMIZE_LAMBDA_FUNCTION_NAME); + const latest = await getLatestLambdaVersion(lambda, lambdaName); const ready = cfg.State === 'Active' && cfg.LastUpdateStatus !== 'InProgress' && !!latest; return { roleExists, @@ -1031,14 +1084,15 @@ export async function applyEdgeOptimizeAssociations( throw new Error('lambdaVersionArn is required'); } const client = new CloudFrontClient({ region, credentials }); + const functionName = eoRoutingFunctionName(distributionId); const fnResult = await client.send(new DescribeFunctionCommand({ - Name: EDGE_OPTIMIZE_FUNCTION_NAME, + Name: functionName, Stage: 'LIVE', })); const cfFunctionArn = fnResult.FunctionSummary?.FunctionMetadata?.FunctionARN; if (!cfFunctionArn) { - throw new Error(`CloudFront function '${EDGE_OPTIMIZE_FUNCTION_NAME}' not found or not published to LIVE`); + throw new Error(`CloudFront function '${functionName}' not found or not published to LIVE`); } const distResult = await client.send(new GetDistributionConfigCommand({ Id: distributionId })); @@ -1139,10 +1193,10 @@ export const EDGE_OPTIMIZE_DEPLOY_STEPS = [ * @param {CloudFrontClient} client - a CloudFront client built with the connector credentials. * @returns {Promise} */ -async function isRoutingFunctionLive(client) { +async function isRoutingFunctionLive(client, distributionId) { try { const desc = await client.send(new DescribeFunctionCommand({ - Name: EDGE_OPTIMIZE_FUNCTION_NAME, + Name: eoRoutingFunctionName(distributionId), Stage: 'LIVE', })); return Boolean(desc?.FunctionSummary?.FunctionMetadata?.FunctionARN); @@ -1244,10 +1298,10 @@ export async function runEdgeOptimizeDeployStep( // ── 2. function — GATE: skip the create+publish when already LIVE (avoids re-publish churn). ── try { - if (await isRoutingFunctionLive(client)) { + if (await isRoutingFunctionLive(client, distributionId)) { byKey('function').status = 'done'; } else { - await createEdgeOptimizeRoutingFunction(credentials, originId, null, region); + await createEdgeOptimizeRoutingFunction(credentials, originId, distributionId, null, region); byKey('function').status = 'done'; } } catch (err) { @@ -1274,12 +1328,16 @@ export async function runEdgeOptimizeDeployStep( // status-check while it "exists", the version never gets published and the step hangs at // "provisioning" forever. try { - const ls = await getEdgeOptimizeLambdaStatus(credentials, region); + const ls = await getEdgeOptimizeLambdaStatus(credentials, distributionId, region); if (ls.ready) { lambdaVersionArn = ls.versionArn; byKey('lambda').status = 'done'; } else { - const created = await createEdgeOptimizeLambda(credentials, accountId, { region }); + const created = await createEdgeOptimizeLambda( + credentials, + accountId, + { region, distributionId }, + ); if (created.status === 'ready') { lambdaVersionArn = created.versionArn; byKey('lambda').status = 'done'; diff --git a/test/controllers/llmo/llmo.test.js b/test/controllers/llmo/llmo.test.js index c0d9c8e9c3..f16c493a08 100644 --- a/test/controllers/llmo/llmo.test.js +++ b/test/controllers/llmo/llmo.test.js @@ -8446,7 +8446,7 @@ describe('LlmoController', () => { expect(result.status).to.equal(200); const body = await result.json(); expect(body).to.deep.equal({ name: 'edgeoptimize-routing', created: true, stage: 'LIVE' }); - expect(createEdgeOptimizeRoutingFunctionStub.calledOnceWith(sinon.match.any, 'origin-aem', null)).to.equal(true); + expect(createEdgeOptimizeRoutingFunctionStub.calledOnceWith(sinon.match.any, 'origin-aem', 'E2EXAMPLE123', null)).to.equal(true); }); it('returns 400 when the default cache behavior has no target origin', async () => { @@ -8615,7 +8615,7 @@ describe('LlmoController', () => { lambdaContext = { ...mockContext, params: { siteId: TEST_SITE_ID }, - data: { accountId: '120569600543', externalId: '7ff9518a-cf59-40b4-aa53-68a3cb2e24a5' }, + data: { accountId: '120569600543', externalId: '7ff9518a-cf59-40b4-aa53-68a3cb2e24a5', distributionId: 'E2EXAMPLE123' }, env: {}, }; }); @@ -8688,7 +8688,7 @@ describe('LlmoController', () => { statusContext = { ...mockContext, params: { siteId: TEST_SITE_ID }, - data: { accountId: '120569600543', externalId: '7ff9518a-cf59-40b4-aa53-68a3cb2e24a5' }, + data: { accountId: '120569600543', externalId: '7ff9518a-cf59-40b4-aa53-68a3cb2e24a5', distributionId: 'E2EXAMPLE123' }, env: {}, }; }); diff --git a/test/support/edge-optimize.test.js b/test/support/edge-optimize.test.js index 6717c92f34..7814c430f3 100644 --- a/test/support/edge-optimize.test.js +++ b/test/support/edge-optimize.test.js @@ -456,9 +456,9 @@ describe('edge-optimize support', () => { cfSendStub.onSecondCall().resolves({ ETag: 'fn-etag' }); // CreateFunction cfSendStub.onThirdCall().resolves({}); // PublishFunction - const result = await edgeOptimize.createEdgeOptimizeRoutingFunction({}, 'origin-aem'); + const result = await edgeOptimize.createEdgeOptimizeRoutingFunction({}, 'origin-aem', 'E2EXAMPLE'); - expect(result).to.deep.equal({ name: 'edgeoptimize-routing', created: true, stage: 'LIVE' }); + expect(result).to.deep.equal({ name: 'edgeoptimize-routing-adobe-E2EXAMPLE', created: true, stage: 'LIVE' }); expect(cfSendStub.secondCall.args[0].commandName).to.equal('CreateFunction'); expect(cfSendStub.thirdCall.args[0].commandName).to.equal('PublishFunction'); expect(cfSendStub.thirdCall.args[0].input.IfMatch).to.equal('fn-etag'); @@ -469,7 +469,7 @@ describe('edge-optimize support', () => { cfSendStub.onSecondCall().resolves({ ETag: 'updated-etag' }); // UpdateFunction cfSendStub.onThirdCall().resolves({}); // PublishFunction - const result = await edgeOptimize.createEdgeOptimizeRoutingFunction({}, 'origin-aem'); + const result = await edgeOptimize.createEdgeOptimizeRoutingFunction({}, 'origin-aem', 'E2EXAMPLE'); expect(result.created).to.equal(false); expect(cfSendStub.secondCall.args[0].commandName).to.equal('UpdateFunction'); @@ -491,7 +491,7 @@ describe('edge-optimize support', () => { cfSendStub.onFirstCall().rejects(new Error('boom')); let error; try { - await edgeOptimize.createEdgeOptimizeRoutingFunction({}, 'origin-aem'); + await edgeOptimize.createEdgeOptimizeRoutingFunction({}, 'origin-aem', 'E2EXAMPLE'); } catch (e) { error = e; } @@ -570,7 +570,7 @@ describe('edge-optimize support', () => { expect(lastCommand('UpdateCachePolicy')).to.equal(undefined); // never updated }); - it('CLONES an AWS-managed policy into edgeoptimize-cache and repoints the behavior', async () => { + it('CLONES an AWS-managed policy into a per-distribution custom policy and repoints the behavior', async () => { wireCloudFront({ GetDistributionConfig: { DistributionConfig: { DefaultCacheBehavior: { CachePolicyId: 'managed-1', ForwardedValues: { x: 1 } } }, @@ -583,7 +583,7 @@ describe('edge-optimize support', () => { CachePolicy: { CachePolicyConfig: { Name: 'Managed-CachingOptimized', - MinTTL: 1, + MinTTL: 86400, ParametersInCacheKeyAndForwardedToOrigin: { HeadersConfig: { HeaderBehavior: 'none' } }, }, }, @@ -598,7 +598,7 @@ describe('edge-optimize support', () => { expect(result.policyId).to.equal('new-eo-policy'); expect(result.reused).to.equal(false); const created = lastCommand('CreateCachePolicy').input.CachePolicyConfig; - expect(created.Name).to.equal('edgeoptimize-cache'); + expect(created.Name).to.equal('CachingOptimized-adobe-E2EXAMPLE'); expect(created.MinTTL).to.equal(0); const items = created.ParametersInCacheKeyAndForwardedToOrigin.HeadersConfig.Headers.Items; expect(items).to.include('x-edgeoptimize-config'); @@ -608,6 +608,34 @@ describe('edge-optimize support', () => { expect(cfg.DefaultCacheBehavior.ForwardedValues).to.equal(undefined); }); + it('keeps a short MinTTL (<=5s) when cloning a managed policy instead of forcing it to 0', async () => { + wireCloudFront({ + GetDistributionConfig: { + DistributionConfig: { DefaultCacheBehavior: { CachePolicyId: 'managed-1' } }, + ETag: 'dist-etag', + }, + ListCachePolicies: (cmd) => (cmd.input.Type === 'managed' + ? { CachePolicyList: { Items: [{ CachePolicy: { Id: 'managed-1' } }] } } + : { CachePolicyList: { Items: [] } }), + GetCachePolicy: { + CachePolicy: { + CachePolicyConfig: { + Name: 'Managed-CachingOptimized', + MinTTL: 3, + ParametersInCacheKeyAndForwardedToOrigin: { HeadersConfig: { HeaderBehavior: 'none' } }, + }, + }, + }, + CreateCachePolicy: { CachePolicy: { Id: 'new-eo-policy' } }, + UpdateDistribution: {}, + }); + + await edgeOptimize.applyEdgeOptimizeCacheHeaders({}, 'E2EXAMPLE', 'default'); + + const created = lastCommand('CreateCachePolicy').input.CachePolicyConfig; + expect(created.MinTTL).to.equal(3); // <= 5s kept, not zeroed + }); + it('reuses an existing edgeoptimize-cache custom policy (idempotent managed path)', async () => { wireCloudFront({ GetDistributionConfig: { @@ -616,7 +644,7 @@ describe('edge-optimize support', () => { }, ListCachePolicies: (cmd) => (cmd.input.Type === 'managed' ? { CachePolicyList: { Items: [{ CachePolicy: { Id: 'managed-1' } }] } } - : { CachePolicyList: { Items: [{ CachePolicy: { Id: 'existing-eo', CachePolicyConfig: { Name: 'edgeoptimize-cache' } } }] } }), + : { CachePolicyList: { Items: [{ CachePolicy: { Id: 'existing-eo', CachePolicyConfig: { Name: 'X-adobe-E2EXAMPLE' } } }] } }), GetCachePolicy: { CachePolicy: { CachePolicyConfig: { Name: 'Managed-X', ParametersInCacheKeyAndForwardedToOrigin: {} } }, }, @@ -730,7 +758,7 @@ describe('edge-optimize support', () => { CreateFunction: { FunctionArn: 'arn:fn' }, }); - const result = await edgeOptimize.createEdgeOptimizeLambda(creds, '120569600543', { roleWaitMs: 0 }); + const result = await edgeOptimize.createEdgeOptimizeLambda(creds, '120569600543', { roleWaitMs: 0, distributionId: 'E2EXAMPLE' }); // Does NOT block on the new function becoming Active — returns provisioning immediately. expect(result.status).to.equal('provisioning'); @@ -749,7 +777,7 @@ describe('edge-optimize support', () => { }, }); - const result = await edgeOptimize.createEdgeOptimizeLambda(creds, '120569600543'); + const result = await edgeOptimize.createEdgeOptimizeLambda(creds, '120569600543', { distributionId: 'E2EXAMPLE' }); expect(result.status).to.equal('provisioning'); expect(result.versionArn).to.equal(null); @@ -765,7 +793,7 @@ describe('edge-optimize support', () => { ListVersionsByFunction: { Versions: [{ Version: '$LATEST' }, { Version: '3', FunctionArn: 'arn:fn:3' }] }, }); - const result = await edgeOptimize.createEdgeOptimizeLambda(creds, '120569600543'); + const result = await edgeOptimize.createEdgeOptimizeLambda(creds, '120569600543', { distributionId: 'E2EXAMPLE' }); expect(result.status).to.equal('ready'); expect(result.alreadyExisted).to.equal(true); @@ -783,7 +811,7 @@ describe('edge-optimize support', () => { PublishVersion: { FunctionArn: 'arn:fn:1', Version: '1' }, }); - const result = await edgeOptimize.createEdgeOptimizeLambda(creds, '120569600543'); + const result = await edgeOptimize.createEdgeOptimizeLambda(creds, '120569600543', { distributionId: 'E2EXAMPLE' }); expect(result.status).to.equal('ready'); expect(result.versionArn).to.equal('arn:fn:1'); @@ -797,7 +825,7 @@ describe('edge-optimize support', () => { CreateFunction: () => Promise.reject(Object.assign(new Error('exists'), { name: 'ResourceConflictException' })), }); - const result = await edgeOptimize.createEdgeOptimizeLambda(creds, '120569600543', { roleWaitMs: 0 }); + const result = await edgeOptimize.createEdgeOptimizeLambda(creds, '120569600543', { roleWaitMs: 0, distributionId: 'E2EXAMPLE' }); expect(result.status).to.equal('provisioning'); }); @@ -824,7 +852,7 @@ describe('edge-optimize support', () => { throw new Error(`unexpected: ${cmd.commandName}`); }); - const result = await edgeOptimize.getEdgeOptimizeLambdaStatus({}); + const result = await edgeOptimize.getEdgeOptimizeLambdaStatus({}, 'E2EXAMPLE'); expect(result).to.deep.equal({ roleExists: false, exists: false, versionArn: null, ready: false, @@ -843,7 +871,7 @@ describe('edge-optimize support', () => { throw new Error(`unexpected: ${cmd.commandName}`); }); - const result = await edgeOptimize.getEdgeOptimizeLambdaStatus({}); + const result = await edgeOptimize.getEdgeOptimizeLambdaStatus({}, 'E2EXAMPLE'); expect(result.roleExists).to.equal(true); expect(result.exists).to.equal(true); @@ -865,7 +893,7 @@ describe('edge-optimize support', () => { throw new Error(`unexpected: ${cmd.commandName}`); }); - const result = await edgeOptimize.getEdgeOptimizeLambdaStatus({}); + const result = await edgeOptimize.getEdgeOptimizeLambdaStatus({}, 'E2EXAMPLE'); expect(result.roleExists).to.equal(true); expect(result.exists).to.equal(true); From 2d6eaacc75f1c443168fce38d771c20a1767f08c Mon Sep 17 00:00:00 2001 From: Akash Bhardwaj Date: Wed, 24 Jun 2026 16:44:22 +0530 Subject: [PATCH 25/56] feat(edge-optimize): add read-only deploy plan + permissions manifest endpoints Phase 2 of CloudFront "Optimize at Edge" Option-A onboarding: - planEdgeOptimizeDeploy (support): read-only preview mirroring the deploy orchestrator's inspection (origin/function/cache/lambda/associate) with no writes; returns per-step action (create|exists|update|blocked) + canProceed/blocker. Hard-blocks when the behavior is already associated. - planEdgeOptimizeHandler + POST /sites/:siteId/llmo/edge-optimize/plan: powers the FE "Review & Deploy" screen. - getEdgeOptimizePermissionsHandler + GET /sites/:siteId/llmo/edge-optimize/permissions: returns the curated permissions manifest (from the template S3 bucket) + Adobe principal ARN; powers "View Permissions". - cloudfront-edgeoptimize-stack/permissions-manifest.json: curated, faithful to customer-bootstrap-role.yaml Sids/Actions. - Routes added to required-capabilities INTERNAL_ROUTES + route-membership tests; unit + controller tests for all new paths. Co-Authored-By: Claude Opus 4.8 --- .../permissions-manifest.json | 44 +++ src/controllers/llmo/llmo.js | 127 +++++++++ src/routes/index.js | 2 + src/routes/required-capabilities.js | 2 + src/support/edge-optimize.js | 196 +++++++++++++ test/controllers/llmo/llmo.test.js | 261 ++++++++++++++++++ test/routes/index.test.js | 4 + test/support/edge-optimize.test.js | 238 ++++++++++++++++ 8 files changed, 874 insertions(+) create mode 100644 cloudfront-edgeoptimize-stack/permissions-manifest.json diff --git a/cloudfront-edgeoptimize-stack/permissions-manifest.json b/cloudfront-edgeoptimize-stack/permissions-manifest.json new file mode 100644 index 0000000000..071e386c38 --- /dev/null +++ b/cloudfront-edgeoptimize-stack/permissions-manifest.json @@ -0,0 +1,44 @@ +{ + "appName": "Adobe LLM Optimizer Deployer", + "groups": [ + { + "name": "CloudFront", + "items": [ + "Read & update distributions", + "Create/update cache policies", + "Create/publish & associate functions" + ] + }, + { + "name": "Lambda@Edge", + "items": [ + "Create & manage edgeoptimize-* functions and versions" + ] + }, + { + "name": "IAM", + "items": [ + "Create the edgeoptimize-* execution role", + "Pass that role only to Lambda/edge" + ] + }, + { + "name": "S3", + "items": [ + "Create & write the llmo-edgeoptimize-* state and rollback buckets" + ] + }, + { + "name": "CloudFormation", + "items": [ + "Create/update the adobe-edgeoptimize-* installer stacks" + ] + }, + { + "name": "Logs", + "items": [ + "Write CloudWatch logs for the installer" + ] + } + ] +} diff --git a/src/controllers/llmo/llmo.js b/src/controllers/llmo/llmo.js index 838a4864c8..07855d2095 100644 --- a/src/controllers/llmo/llmo.js +++ b/src/controllers/llmo/llmo.js @@ -46,6 +46,7 @@ import { applyEdgeOptimizeAssociations, verifyEdgeOptimizeRouting, runEdgeOptimizeDeployStep, + planEdgeOptimizeDeploy, } from '../../support/edge-optimize.js'; import { UnauthorizedProductError } from '../../support/errors.js'; import { cachedOk } from '../../support/cached-response.js'; @@ -3037,6 +3038,130 @@ function LlmoController(ctx) { } }; + // Read-only "preview" for the wizard's "Review & Deploy" screen. Mirrors the deploy handler (same + // validation + gate + role assumption + server-derived EO origin headers), but calls the + // NON-mutating planEdgeOptimizeDeploy and returns the per-step plan + canProceed/blocker so the + // FE can show exactly what will happen before the customer commits. + const planEdgeOptimizeHandler = async (context) => { + const { log, dataAccess, env } = context; + const { siteId } = context.params; + const { Site } = dataAccess; + const accountId = String(context.data?.accountId || '').replace(/\D/g, ''); + const externalId = String(context.data?.externalId || '').trim(); + const distributionId = String(context.data?.distributionId || '').trim(); + const originId = String(context.data?.originId || '').trim(); + const behavior = String(context.data?.behavior || '').trim(); + const roleName = env?.EDGE_OPTIMIZE_ROLE_NAME || undefined; + const originDomain = env?.EDGE_OPTIMIZE_ORIGIN_DOMAIN || 'live.edgeoptimize.net'; + + if (accountId.length !== 12) { + return badRequest('accountId must be a 12-digit AWS account ID'); + } + if (!hasText(externalId)) { + return badRequest('externalId is required'); + } + if (!hasText(distributionId)) { + return badRequest('distributionId is required'); + } + if (!hasText(originId)) { + return badRequest('originId is required'); + } + if (!hasText(behavior)) { + return badRequest('behavior is required'); + } + + try { + const { error, site } = await gateEdgeOptimizeWizard(siteId, Site, 'preview edge optimize routing'); + if (error) { + return error; + } + + // Derive the EO origin headers server-side (same as deploy) so the origin step of the plan + // reflects whether the existing origin already carries the right headers. + const baseURL = site.getBaseURL(); + const tokowakaClient = TokowakaClient.createFrom(context); + const metaconfig = await tokowakaClient.fetchMetaconfig(baseURL); + const apiKey = metaconfig?.apiKeys?.[0]; + if (!hasText(apiKey)) { + return badRequest('Site has no Edge Optimize API key — enable Edge Optimize for this site first'); + } + const forwardedHost = calculateForwardedHost(baseURL, log); + + const { credentials, accountId: resolvedAccountId } = await assumeConnectorRole({ + accountId, externalId, roleName, + }); + + const result = await planEdgeOptimizeDeploy(credentials, { + distributionId, + originId, + behavior, + originDomain, + originHeaders: { apiKey, forwardedHost }, + accountId: resolvedAccountId, + }); + + log.info(`[edge-optimize-plan] site ${siteId}: canProceed=${result.canProceed},` + + ` steps=${result.steps.map((s) => `${s.key}:${s.action}`).join(',')}`); + return ok(result); + } catch (error) { + log.error(`[edge-optimize-plan] Failed for site ${siteId}:`, error); + return badRequest(cleanupHeaderValue(error.message)); + } + }; + + /** + * GET /sites/{siteId}/llmo/edge-optimize/permissions + * Powers the wizard's "View Permissions" panel. Returns a curated, human-friendly manifest of the + * AWS permissions the connector role grants (read from a static JSON object in the template S3 + * bucket) plus the Adobe principal ARN that will assume the role. Read-only — gated on site + * access + LLMO admin (like getEdgeOptimizeBootstrapUrl). No cross-account calls. + * @param {object} context - Request context + * @returns {Promise} { adobeAccount, manifest } or a 400 on a config/read failure. + */ + const getEdgeOptimizePermissionsHandler = async (context) => { + const { + log, dataAccess, env, s3, + } = context; + const { siteId } = context.params; + const { Site } = dataAccess; + + try { + const { error } = await gateEdgeOptimizeWizard(siteId, Site, 'view edge optimize permissions'); + if (error) { + return error; + } + + const bucket = env.EDGE_OPTIMIZE_TEMPLATE_BUCKET || 'llmo-edgeoptimize-cf-template'; + if (!hasText(bucket) || !s3?.s3Client || !s3?.GetObjectCommand) { + return badRequest('Edge optimize template hosting is not configured for this environment'); + } + + // TEMPORARY (testing only): default the Adobe principal to the dev signer's Lambda execution + // role. TODO: REMOVE before prod — set EDGE_OPTIMIZE_TRUSTED_PRINCIPAL_ARN via env. + const adobeAccount = env.EDGE_OPTIMIZE_TRUSTED_PRINCIPAL_ARN + || 'arn:aws:iam::682033462621:role/spacecat-role-lambda-generic'; + + let manifest; + try { + const response = await s3.s3Client.send(new s3.GetObjectCommand({ + Bucket: bucket, + Key: 'permissions-manifest.json', + })); + const body = await response.Body.transformToString(); + manifest = JSON.parse(body); + } catch (s3Error) { + log.error(`[edge-optimize-permissions] Failed to read permissions manifest for site ${siteId}: ${s3Error.message}`); + return badRequest('Edge optimize permissions manifest is not available'); + } + + log.info(`[edge-optimize-permissions] Returned permissions manifest for site ${siteId}`); + return ok({ adobeAccount, manifest }); + } catch (error) { + log.error(`Failed to read edge optimize permissions for site ${siteId}:`, error); + return badRequest(cleanupHeaderValue(error.message)); + } + }; + return { getEdgeOptimizeBootstrapUrl, getEdgeOptimizeInstallerUrl, @@ -3053,6 +3178,8 @@ function LlmoController(ctx) { applyEdgeOptimizeAssociations: applyEdgeOptimizeAssociationsHandler, verifyEdgeOptimizeRouting: verifyEdgeOptimizeRoutingHandler, deployEdgeOptimize: deployEdgeOptimizeHandler, + planEdgeOptimize: planEdgeOptimizeHandler, + getEdgeOptimizePermissions: getEdgeOptimizePermissionsHandler, getLlmoSheetData, queryLlmoSheetData, getLlmoGlobalSheetData, diff --git a/src/routes/index.js b/src/routes/index.js index 6d20eebe14..5ac38de43b 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -498,6 +498,8 @@ export default function getRouteHandlers( 'POST /sites/:siteId/llmo/edge-optimize/apply-associations': llmoController.applyEdgeOptimizeAssociations, 'POST /sites/:siteId/llmo/edge-optimize/verify': llmoController.verifyEdgeOptimizeRouting, 'POST /sites/:siteId/llmo/edge-optimize/deploy': llmoController.deployEdgeOptimize, + 'POST /sites/:siteId/llmo/edge-optimize/plan': llmoController.planEdgeOptimize, + 'GET /sites/:siteId/llmo/edge-optimize/permissions': llmoController.getEdgeOptimizePermissions, 'GET /sites/:siteId/llmo/edge-optimize/installer-url': llmoController.getEdgeOptimizeInstallerUrl, 'GET /sites/:siteId/llmo/strategy': llmoController.getStrategy, 'PUT /sites/:siteId/llmo/strategy': llmoController.saveStrategy, diff --git a/src/routes/required-capabilities.js b/src/routes/required-capabilities.js index 20cd22a6c4..6a248196d1 100644 --- a/src/routes/required-capabilities.js +++ b/src/routes/required-capabilities.js @@ -144,6 +144,8 @@ export const INTERNAL_ROUTES = [ 'POST /sites/:siteId/llmo/edge-optimize/apply-associations', 'POST /sites/:siteId/llmo/edge-optimize/verify', 'POST /sites/:siteId/llmo/edge-optimize/deploy', + 'POST /sites/:siteId/llmo/edge-optimize/plan', + 'GET /sites/:siteId/llmo/edge-optimize/permissions', 'GET /sites/:siteId/llmo/edge-optimize/installer-url', 'PUT /sites/:siteId/llmo/opportunities-reviewed', diff --git a/src/support/edge-optimize.js b/src/support/edge-optimize.js index daf206ee00..4b267b9bc8 100644 --- a/src/support/edge-optimize.js +++ b/src/support/edge-optimize.js @@ -1408,3 +1408,199 @@ export async function runEdgeOptimizeDeployStep( return { routingDeployed, verified, steps }; } + +/** + * Read-only "preview" of what {@link runEdgeOptimizeDeployStep} would do, without mutating + * anything. Powers the wizard's "Review & Deploy" screen: it inspects the distribution config, + * the attached cache policy, the routing CloudFront Function, and the Lambda@Edge function, and + * returns a per-step plan (create | exists | update | blocked) plus an overall canProceed/blocker. + * + * Only reads are issued (GetDistributionConfig, ListCachePolicies, DescribeFunction, + * GetFunctionConfiguration/ListVersions via the existing gates). It is intentionally defensive: + * a missing resource is "create", and a read that genuinely errors is surfaced in that step's + * detail while still allowing the plan to proceed — the ONLY hard blocker is a behavior that is + * already associated with EO routes (that is the one case the automation refuses to touch). + * + * @param {object} credentials - temporary credentials from {@link assumeConnectorRole}. + * @param {object} params + * @param {string} params.distributionId - the CloudFront distribution ID. + * @param {string} params.originId - the default-behavior target origin id (failover origin). + * @param {string} params.behavior - the cache behavior to target (`default` for the default). + * @param {string} [params.originDomain] - the Edge Optimize origin domain. + * @param {object} [params.originHeaders] - EO origin headers ({ apiKey, forwardedHost }). + * @param {string} [params.accountId] - the 12-digit customer AWS account ID. + * @param {string} [region] - CloudFront control-plane region. + * @returns {Promise<{canProceed: boolean, blocker: string|null, + * steps: Array<{key: string, label: string, action: string, detail: string}>}>} + */ +export async function planEdgeOptimizeDeploy( + credentials, + { + distributionId, behavior, originDomain = EDGE_OPTIMIZE_DEFAULT_ORIGIN_DOMAIN, originHeaders, + }, + region = EDGE_OPTIMIZE_REGION, +) { + if (!hasText(distributionId)) { + throw new Error('distributionId is required'); + } + if (!hasText(behavior)) { + throw new Error('behavior is required'); + } + const client = new CloudFrontClient({ region, credentials }); + + // Plan rows mirror EDGE_OPTIMIZE_DEPLOY_STEPS (sans `verify`, which is a post-deploy probe). + const labelOf = (key) => EDGE_OPTIMIZE_DEPLOY_STEPS.find((s) => s.key === key)?.label || key; + const steps = ['origin', 'function', 'cache', 'lambda', 'associate'].map((key) => ({ + key, label: labelOf(key), action: 'create', detail: '', + })); + const byKey = (key) => steps.find((s) => s.key === key); + + let canProceed = true; + let blocker = null; + + // Read the distribution config ONCE for the origin + cache inspections. + let config = null; + try { + const distResult = await client.send(new GetDistributionConfigCommand({ Id: distributionId })); + config = distResult.DistributionConfig || null; + } catch (err) { + // A read failure here doesn't block — surface it on the origin/cache rows and keep going. + byKey('origin').detail = `could not read distribution config: ${err.message}`; + byKey('cache').detail = `could not read distribution config: ${err.message}`; + } + + // ── origin ────────────────────────────────────────────────────────────── + // 'exists' when the EO origin is already present WITH the required custom headers; otherwise + // 'create' (a header-less existing origin still needs the headers patched → treated as create). + const desiredHeaderItems = buildEdgeOptimizeOriginHeaders(originHeaders || {}); + if (config) { + const origins = config.Origins?.Items || []; + const existing = origins.find( + (o) => o.Id === EDGE_OPTIMIZE_ORIGIN_ID || o.DomainName === originDomain, + ); + if (existing) { + const toMap = (arr) => (arr || []).reduce((acc, h) => { + acc[h.HeaderName.toLowerCase()] = h.HeaderValue; + return acc; + }, {}); + const current = toMap(existing.CustomHeaders?.Items); + const desired = toMap(desiredHeaderItems); + const headersMatch = desiredHeaderItems.length === 0 + || (Object.keys(desired).length === Object.keys(current).length + && Object.entries(desired).every(([k, v]) => current[k] === v)); + if (headersMatch) { + byKey('origin').action = 'exists'; + byKey('origin').detail = `Edge Optimize origin already present (${existing.DomainName})`; + } else { + byKey('origin').detail = `patch Edge Optimize origin headers (${existing.DomainName})`; + } + } else { + byKey('origin').detail = `add Edge Optimize origin (${originDomain})`; + } + } else if (!byKey('origin').detail) { + byKey('origin').detail = `add Edge Optimize origin (${originDomain})`; + } + + // ── function ──────────────────────────────────────────────────────────── + // 'exists' when the routing CloudFront Function is already published to LIVE. + try { + if (await isRoutingFunctionLive(client, distributionId)) { + byKey('function').action = 'exists'; + byKey('function').detail = `routing function ${eoRoutingFunctionName(distributionId)} already published to LIVE`; + } else { + byKey('function').detail = `create routing function ${eoRoutingFunctionName(distributionId)}`; + } + } catch (err) { + byKey('function').detail = `could not read routing function status: ${err.message}`; + } + + // ── cache ─────────────────────────────────────────────────────────────── + // Detect the scenario the deploy would hit (legacy / custom / managed) and describe it without + // mutating. Mirrors applyEdgeOptimizeCacheHeaders' detection logic. + try { + if (!config) { + throw new Error('distribution config unavailable'); + } + const targetBehavior = getBehaviorFromConfig(config, behavior); + const policyId = targetBehavior.CachePolicyId; + const minTtlNote = ' (MinTTL forced to 0 unless already <= 5s)'; + if (!policyId) { + // Legacy: ForwardedValues. 'exists' when EO headers are already forwarded. + const fv = targetBehavior.ForwardedValues || {}; + const lower = (fv.Headers?.Items || []).map((x) => x.toLowerCase()); + const allForwarded = lower.includes('*') + || EDGE_OPTIMIZE_CACHE_HEADERS.every((h) => lower.includes(h)); + if (allForwarded) { + byKey('cache').action = 'exists'; + byKey('cache').detail = 'legacy behaviour already forwards the Edge Optimize headers'; + } else { + byKey('cache').action = 'update'; + byKey('cache').detail = `legacy behaviour: add Edge Optimize headers to ForwardedValues${minTtlNote}`; + } + } else { + const managedList = await client.send(new ListCachePoliciesCommand({ Type: 'managed' })); + const managedIds = new Set( + (managedList.CachePolicyList?.Items || []).map((i) => i.CachePolicy.Id), + ); + const isManaged = managedIds.has(policyId); + if (!isManaged) { + byKey('cache').action = 'update'; + byKey('cache').detail = `update existing custom cache policy in place to add the Edge Optimize headers${minTtlNote}`; + } else { + // Managed → must clone. 'exists' when the per-dist clone already exists (idempotent). + const srcResult = await client.send(new GetCachePolicyCommand({ Id: policyId })); + const sourceName = srcResult.CachePolicy?.CachePolicyConfig?.Name || 'cache'; + const clonedName = buildEoClonedCachePolicyName(sourceName, distributionId); + const customList = await client.send(new ListCachePoliciesCommand({ Type: 'custom' })); + const cloneExists = (customList.CachePolicyList?.Items || []).some( + (i) => i.CachePolicy.CachePolicyConfig.Name === clonedName, + ); + if (cloneExists) { + byKey('cache').action = 'exists'; + byKey('cache').detail = `managed policy already cloned into ${clonedName}`; + } else { + byKey('cache').action = 'create'; + byKey('cache').detail = `managed policy '${sourceName}' cannot be edited: clone into ${clonedName}${minTtlNote}`; + } + } + } + } catch (err) { + // Don't block the plan on a cache read failure — surface it on the row. + byKey('cache').action = 'update'; + byKey('cache').detail = `could not determine cache scenario: ${err.message}`; + } + + // ── lambda ────────────────────────────────────────────────────────────── + // 'exists' when the Lambda@Edge function exists (ready or still provisioning); else 'create'. + try { + const ls = await getEdgeOptimizeLambdaStatus(credentials, distributionId, region); + if (ls.exists) { + byKey('lambda').action = 'exists'; + byKey('lambda').detail = ls.ready + ? `Lambda@Edge ${eoLambdaFunctionName(distributionId)} already published` + : `Lambda@Edge ${eoLambdaFunctionName(distributionId)} exists (still provisioning)`; + } else { + byKey('lambda').detail = `create Lambda@Edge ${eoLambdaFunctionName(distributionId)}`; + } + } catch (err) { + byKey('lambda').detail = `could not read Lambda@Edge status: ${err.message}`; + } + + // ── associate ─────────────────────────────────────────────────────────── + // HARD BLOCK: if the behavior is already associated with EO routes, the automation refuses to + // proceed (it would clobber the customer's existing wiring). This is the only blocker. + try { + if (await isBehaviorAlreadyAssociated(client, distributionId, behavior)) { + byKey('associate').action = 'blocked'; + byKey('associate').detail = 'this behaviour is already associated with Edge Optimize routes'; + canProceed = false; + blocker = "This behaviour is already associated with routes, please recheck — can't proceed with this automation."; + } else { + byKey('associate').detail = 'will associate routing function + Lambda@Edge to the behavior'; + } + } catch (err) { + byKey('associate').detail = `could not read behavior associations: ${err.message}`; + } + + return { canProceed, blocker, steps }; +} diff --git a/test/controllers/llmo/llmo.test.js b/test/controllers/llmo/llmo.test.js index f16c493a08..749a890b3d 100644 --- a/test/controllers/llmo/llmo.test.js +++ b/test/controllers/llmo/llmo.test.js @@ -97,6 +97,7 @@ describe('LlmoController', () => { let applyEdgeOptimizeAssociationsStub; let verifyEdgeOptimizeRoutingStub; let runEdgeOptimizeDeployStepStub; + let planEdgeOptimizeDeployStub; let mockTokowakaClient; let readStrategyStub; let writeStrategyStub; @@ -216,6 +217,7 @@ describe('LlmoController', () => { applyEdgeOptimizeAssociationsStub = sinon.stub(); verifyEdgeOptimizeRoutingStub = sinon.stub(); runEdgeOptimizeDeployStepStub = sinon.stub(); + planEdgeOptimizeDeployStub = sinon.stub(); // Initialize mock TokowakaClient mockTokowakaClient = { @@ -298,6 +300,7 @@ describe('LlmoController', () => { applyEdgeOptimizeAssociations: (...args) => applyEdgeOptimizeAssociationsStub(...args), verifyEdgeOptimizeRouting: (...args) => verifyEdgeOptimizeRoutingStub(...args), runEdgeOptimizeDeployStep: (...args) => runEdgeOptimizeDeployStepStub(...args), + planEdgeOptimizeDeploy: (...args) => planEdgeOptimizeDeployStub(...args), }, '@adobe/spacecat-shared-ims-client': { ImsClient: function MockImsClient() { @@ -9095,6 +9098,264 @@ describe('LlmoController', () => { }); }); + describe('planEdgeOptimize', () => { + let planContext; + + const samplePlan = { + canProceed: true, + blocker: null, + steps: [ + { + key: 'origin', label: 'Edge Optimize origin', action: 'create', detail: 'add origin', + }, + { + key: 'function', label: 'Routing function', action: 'create', detail: 'create fn', + }, + { + key: 'cache', label: 'Cache policy', action: 'update', detail: 'legacy', + }, + { + key: 'lambda', label: 'Lambda@Edge', action: 'create', detail: 'create lambda', + }, + { + key: 'associate', label: 'Association', action: 'create', detail: 'will associate', + }, + ], + }; + + beforeEach(() => { + assumeConnectorRoleStub = sinon.stub().resolves({ + roleArn: 'arn:aws:iam::120569600543:role/AdobeLLMOptimizerCloudFrontConnectorRole', + accountId: '120569600543', + credentials: { accessKeyId: 'AKIA', secretAccessKey: 'secret', sessionToken: 'token' }, + }); + planEdgeOptimizeDeployStub = sinon.stub().resolves(samplePlan); + mockTokowakaClient.fetchMetaconfig.resolves({ apiKeys: ['eo-key-123'] }); + planContext = { + ...mockContext, + params: { siteId: TEST_SITE_ID }, + data: { + accountId: '120569600543', + externalId: '7ff9518a-cf59-40b4-aa53-68a3cb2e24a5', + distributionId: 'E2EXAMPLE123', + originId: 'origin-aem', + behavior: 'default', + }, + env: {}, + }; + }); + + it('runs the planner and returns the per-step plan', async () => { + const result = await controller.planEdgeOptimize(planContext); + + expect(result.status).to.equal(200); + const body = await result.json(); + expect(body).to.deep.equal(samplePlan); + expect(assumeConnectorRoleStub.calledOnce).to.equal(true); + expect(planEdgeOptimizeDeployStub.calledOnce).to.equal(true); + const [, params] = planEdgeOptimizeDeployStub.firstCall.args; + expect(params).to.include({ + distributionId: 'E2EXAMPLE123', + originId: 'origin-aem', + behavior: 'default', + originDomain: 'live.edgeoptimize.net', + accountId: '120569600543', + }); + expect(params.originHeaders).to.deep.equal({ apiKey: 'eo-key-123', forwardedHost: 'www.example.com' }); + }); + + it('returns canProceed:false + blocker when the behavior is already associated', async () => { + planEdgeOptimizeDeployStub = sinon.stub().resolves({ + canProceed: false, + blocker: "This behaviour is already associated with routes, please recheck — can't proceed with this automation.", + steps: samplePlan.steps, + }); + + const result = await controller.planEdgeOptimize(planContext); + expect(result.status).to.equal(200); + const body = await result.json(); + expect(body.canProceed).to.equal(false); + expect(body.blocker).to.include("can't proceed with this automation"); + }); + + it('returns 400 for an invalid account id', async () => { + const result = await controller.planEdgeOptimize({ ...planContext, data: { ...planContext.data, accountId: '123' } }); + expect(result.status).to.equal(400); + expect(planEdgeOptimizeDeployStub.called).to.equal(false); + }); + + it('returns 400 when the external id is missing', async () => { + const result = await controller.planEdgeOptimize({ ...planContext, data: { ...planContext.data, externalId: '' } }); + expect(result.status).to.equal(400); + }); + + it('returns 400 when the distributionId is missing', async () => { + const result = await controller.planEdgeOptimize({ ...planContext, data: { ...planContext.data, distributionId: '' } }); + expect(result.status).to.equal(400); + const body = await result.json(); + expect(body.message).to.include('distributionId'); + }); + + it('returns 400 when the originId is missing', async () => { + const result = await controller.planEdgeOptimize({ ...planContext, data: { ...planContext.data, originId: '' } }); + expect(result.status).to.equal(400); + const body = await result.json(); + expect(body.message).to.include('originId'); + }); + + it('returns 400 when the behavior is missing', async () => { + const result = await controller.planEdgeOptimize({ ...planContext, data: { ...planContext.data, behavior: '' } }); + expect(result.status).to.equal(400); + const body = await result.json(); + expect(body.message).to.include('behavior'); + }); + + it('returns 400 when the site has no Edge Optimize API key', async () => { + mockTokowakaClient.fetchMetaconfig.resolves({ apiKeys: [] }); + const result = await controller.planEdgeOptimize(planContext); + expect(result.status).to.equal(400); + const body = await result.json(); + expect(body.message).to.include('API key'); + expect(planEdgeOptimizeDeployStub.called).to.equal(false); + }); + + it('returns 400 when the planner throws', async () => { + planEdgeOptimizeDeployStub = sinon.stub().rejects(new Error('plan failed')); + const result = await controller.planEdgeOptimize(planContext); + expect(result.status).to.equal(400); + const body = await result.json(); + expect(body.message).to.include('plan failed'); + }); + + it('returns 404 when the site is not found', async () => { + mockDataAccess.Site.findById.resolves(null); + const result = await controller.planEdgeOptimize(planContext); + expect(result.status).to.equal(404); + }); + + it('returns 403 when the user lacks access to the site', async () => { + const deniedController = controllerWithAccessDenied(mockContext); + const result = await deniedController.planEdgeOptimize(planContext); + expect(result.status).to.equal(403); + }); + + it('returns 403 when the user is not an LLMO administrator', async () => { + const LlmoControllerNoAdmin = await esmock('../../../src/controllers/llmo/llmo.js', { + '../../../src/support/access-control-util.js': createMockAccessControlUtil(true, true, false), + '@adobe/spacecat-shared-http-utils': mockHttpUtils, + '../../../src/support/cached-response.js': mockCachedResponse, + ...getCommonMocks(), + }); + const result = await LlmoControllerNoAdmin(mockContext) + .planEdgeOptimize(planContext); + expect(result.status).to.equal(403); + }); + }); + + describe('getEdgeOptimizePermissions', () => { + let permissionsContext; + let s3SendStub; + + const sampleManifest = { + appName: 'Adobe LLM Optimizer Deployer', + groups: [ + { name: 'CloudFront', items: ['Read & update distributions'] }, + ], + }; + + beforeEach(() => { + s3SendStub = sinon.stub().resolves({ + Body: { transformToString: async () => JSON.stringify(sampleManifest) }, + }); + permissionsContext = { + ...mockContext, + params: { siteId: TEST_SITE_ID }, + env: {}, + s3: { + s3Client: { send: s3SendStub }, + GetObjectCommand: function MockGetObjectCommand(params) { + Object.assign(this, params); + }, + }, + }; + }); + + it('returns the manifest + adobeAccount', async () => { + const result = await controller.getEdgeOptimizePermissions(permissionsContext); + + expect(result.status).to.equal(200); + const body = await result.json(); + expect(body.manifest).to.deep.equal(sampleManifest); + expect(body.adobeAccount).to.equal('arn:aws:iam::682033462621:role/spacecat-role-lambda-generic'); + expect(s3SendStub.calledOnce).to.equal(true); + const [cmd] = s3SendStub.firstCall.args; + expect(cmd.Key).to.equal('permissions-manifest.json'); + expect(cmd.Bucket).to.equal('llmo-edgeoptimize-cf-template'); + }); + + it('uses env-configured bucket + trusted principal when set', async () => { + const result = await controller.getEdgeOptimizePermissions({ + ...permissionsContext, + env: { + EDGE_OPTIMIZE_TEMPLATE_BUCKET: 'custom-bucket', + EDGE_OPTIMIZE_TRUSTED_PRINCIPAL_ARN: 'arn:aws:iam::111111111111:role/prod-signer', + }, + }); + expect(result.status).to.equal(200); + const body = await result.json(); + expect(body.adobeAccount).to.equal('arn:aws:iam::111111111111:role/prod-signer'); + const [cmd] = s3SendStub.firstCall.args; + expect(cmd.Bucket).to.equal('custom-bucket'); + }); + + it('returns 400 when template hosting is not configured (no S3 client)', async () => { + const result = await controller.getEdgeOptimizePermissions({ ...permissionsContext, s3: {} }); + expect(result.status).to.equal(400); + const body = await result.json(); + expect(body.message).to.include('not configured'); + }); + + it('returns 400 when the manifest read fails', async () => { + s3SendStub.rejects(new Error('NoSuchKey')); + const result = await controller.getEdgeOptimizePermissions(permissionsContext); + expect(result.status).to.equal(400); + const body = await result.json(); + expect(body.message).to.include('not available'); + }); + + it('returns 400 when the manifest is not valid JSON', async () => { + s3SendStub.resolves({ Body: { transformToString: async () => 'not-json{' } }); + const result = await controller.getEdgeOptimizePermissions(permissionsContext); + expect(result.status).to.equal(400); + const body = await result.json(); + expect(body.message).to.include('not available'); + }); + + it('returns 404 when the site is not found', async () => { + mockDataAccess.Site.findById.resolves(null); + const result = await controller.getEdgeOptimizePermissions(permissionsContext); + expect(result.status).to.equal(404); + }); + + it('returns 403 when the user lacks access to the site', async () => { + const deniedController = controllerWithAccessDenied(mockContext); + const result = await deniedController.getEdgeOptimizePermissions(permissionsContext); + expect(result.status).to.equal(403); + }); + + it('returns 403 when the user is not an LLMO administrator', async () => { + const LlmoControllerNoAdmin = await esmock('../../../src/controllers/llmo/llmo.js', { + '../../../src/support/access-control-util.js': createMockAccessControlUtil(true, true, false), + '@adobe/spacecat-shared-http-utils': mockHttpUtils, + '../../../src/support/cached-response.js': mockCachedResponse, + ...getCommonMocks(), + }); + const result = await LlmoControllerNoAdmin(mockContext) + .getEdgeOptimizePermissions(permissionsContext); + expect(result.status).to.equal(403); + }); + }); + describe('getStrategy', () => { const mockStrategyData = { opportunities: [ diff --git a/test/routes/index.test.js b/test/routes/index.test.js index 7d060ccc5a..df1c37fe60 100755 --- a/test/routes/index.test.js +++ b/test/routes/index.test.js @@ -341,6 +341,8 @@ describe('getRouteHandlers', () => { applyEdgeOptimizeAssociations: () => null, verifyEdgeOptimizeRouting: () => null, deployEdgeOptimize: () => null, + planEdgeOptimize: () => null, + getEdgeOptimizePermissions: () => null, getEdgeOptimizeInstallerUrl: () => null, getEdgeConfig: () => null, createOrUpdateStageEdgeConfig: () => null, @@ -1112,6 +1114,8 @@ describe('getRouteHandlers', () => { 'POST /sites/:siteId/llmo/edge-optimize/apply-associations', 'POST /sites/:siteId/llmo/edge-optimize/verify', 'POST /sites/:siteId/llmo/edge-optimize/deploy', + 'POST /sites/:siteId/llmo/edge-optimize/plan', + 'GET /sites/:siteId/llmo/edge-optimize/permissions', 'GET /sites/:siteId/llmo/edge-optimize/installer-url', 'GET /sites/:siteId/llmo/edge-optimize-status', 'GET /sites/:siteId/llmo/probes/edge-optimize', diff --git a/test/support/edge-optimize.test.js b/test/support/edge-optimize.test.js index 7814c430f3..fa8f53dbb5 100644 --- a/test/support/edge-optimize.test.js +++ b/test/support/edge-optimize.test.js @@ -1446,4 +1446,242 @@ describe('edge-optimize support', () => { expect(out.verified).to.equal(true); }); }); + + describe('planEdgeOptimizeDeploy', () => { + const planParams = { + distributionId: 'E2EXAMPLE123', + originId: 'origin-aem', + behavior: 'default', + originDomain: 'live.edgeoptimize.net', + originHeaders: { apiKey: 'eo-key', forwardedHost: 'www.example.com' }, + accountId: '120569600543', + }; + + // Dispatch each client's send() by command name; per-test overrides via the maps. + const wire = (cf = {}, lambda = {}, iam = {}) => { + cfSendStub.callsFake((cmd) => { + const fn = cf[cmd.commandName]; + if (fn === undefined) { + throw new Error(`unexpected cf command: ${cmd.commandName}`); + } + return Promise.resolve(typeof fn === 'function' ? fn(cmd) : fn); + }); + lambdaSendStub.callsFake((cmd) => { + const fn = lambda[cmd.commandName]; + if (fn === undefined) { + throw new Error(`unexpected lambda command: ${cmd.commandName}`); + } + return Promise.resolve(typeof fn === 'function' ? fn(cmd) : fn); + }); + iamSendStub.callsFake((cmd) => { + const fn = iam[cmd.commandName]; + if (fn === undefined) { + throw new Error(`unexpected iam command: ${cmd.commandName}`); + } + return Promise.resolve(typeof fn === 'function' ? fn(cmd) : fn); + }); + }; + + const throwNamed = (name, message) => () => { + const e = new Error(message); + e.name = name; + throw e; + }; + + const stepOf = (steps, key) => steps.find((s) => s.key === key); + + it('plans an all-create deploy (nothing exists yet, legacy cache)', async () => { + wire( + { + GetDistributionConfig: { + DistributionConfig: { + Origins: { Items: [] }, + DefaultCacheBehavior: { + ForwardedValues: { Headers: { Quantity: 0, Items: [] } }, + MinTTL: 60, + }, + }, + }, + // function gate: not published to LIVE. + DescribeFunction: throwNamed('NoSuchFunctionExists', 'no fn'), + }, + { + // lambda: does not exist. + GetFunctionConfiguration: throwNamed('ResourceNotFoundException', 'nope'), + }, + { + GetRole: throwNamed('NoSuchEntityException', 'no role'), + }, + ); + + const result = await edgeOptimize.planEdgeOptimizeDeploy({}, planParams); + + expect(result.canProceed).to.equal(true); + expect(result.blocker).to.equal(null); + expect(result.steps.map((s) => s.key)).to.deep.equal(['origin', 'function', 'cache', 'lambda', 'associate']); + expect(stepOf(result.steps, 'origin').action).to.equal('create'); + expect(stepOf(result.steps, 'function').action).to.equal('create'); + expect(stepOf(result.steps, 'cache').action).to.equal('update'); + expect(stepOf(result.steps, 'cache').detail).to.include('legacy'); + expect(stepOf(result.steps, 'lambda').action).to.equal('create'); + expect(stepOf(result.steps, 'associate').action).to.equal('create'); + // no `verify` row in the plan + expect(result.steps.some((s) => s.key === 'verify')).to.equal(false); + }); + + it('blocks when the behavior is already associated (canProceed:false + exact blocker)', async () => { + const associatedBehavior = { + ForwardedValues: { Headers: { Items: [] } }, + FunctionAssociations: { + Items: [{ EventType: 'viewer-request', FunctionARN: 'arn:aws:cloudfront::1:function/edgeoptimize-routing-adobe-E2EXAMPLE123' }], + }, + LambdaFunctionAssociations: { + Items: [{ EventType: 'origin-request', LambdaFunctionARN: 'arn:aws:lambda:us-east-1:1:function:edgeoptimize-origin-adobe-E2EXAMPLE123:1' }], + }, + }; + wire( + { + GetDistributionConfig: { + DistributionConfig: { + Origins: { Items: [] }, + DefaultCacheBehavior: associatedBehavior, + }, + }, + DescribeFunction: throwNamed('NoSuchFunctionExists', 'no fn'), + }, + { GetFunctionConfiguration: throwNamed('ResourceNotFoundException', 'nope') }, + { GetRole: throwNamed('NoSuchEntityException', 'no role') }, + ); + + const result = await edgeOptimize.planEdgeOptimizeDeploy({}, planParams); + + expect(result.canProceed).to.equal(false); + expect(result.blocker).to.equal( + "This behaviour is already associated with routes, please recheck — can't proceed with this automation.", + ); + expect(stepOf(result.steps, 'associate').action).to.equal('blocked'); + }); + + it('describes a managed-policy clone in the cache step', async () => { + wire( + { + GetDistributionConfig: { + DistributionConfig: { + Origins: { Items: [] }, + DefaultCacheBehavior: { CachePolicyId: 'managed-1' }, + }, + }, + DescribeFunction: throwNamed('NoSuchFunctionExists', 'no fn'), + ListCachePolicies: (cmd) => (cmd.input.Type === 'managed' + ? { CachePolicyList: { Items: [{ CachePolicy: { Id: 'managed-1' } }] } } + : { CachePolicyList: { Items: [] } }), // no existing clone + GetCachePolicy: { + CachePolicy: { CachePolicyConfig: { Name: 'Managed-CachingOptimized' } }, + }, + }, + { GetFunctionConfiguration: throwNamed('ResourceNotFoundException', 'nope') }, + { GetRole: throwNamed('NoSuchEntityException', 'no role') }, + ); + + const result = await edgeOptimize.planEdgeOptimizeDeploy({}, planParams); + + expect(stepOf(result.steps, 'cache').action).to.equal('create'); + expect(stepOf(result.steps, 'cache').detail).to.include('CachingOptimized-adobe-E2EXAMPLE123'); + expect(result.canProceed).to.equal(true); + }); + + it('marks the managed cache step "exists" when the per-dist clone already exists', async () => { + wire( + { + GetDistributionConfig: { + DistributionConfig: { + Origins: { Items: [] }, + DefaultCacheBehavior: { CachePolicyId: 'managed-1' }, + }, + }, + DescribeFunction: throwNamed('NoSuchFunctionExists', 'no fn'), + ListCachePolicies: (cmd) => (cmd.input.Type === 'managed' + ? { CachePolicyList: { Items: [{ CachePolicy: { Id: 'managed-1' } }] } } + : { CachePolicyList: { Items: [{ CachePolicy: { Id: 'eo-clone', CachePolicyConfig: { Name: 'CachingOptimized-adobe-E2EXAMPLE123' } } }] } }), + GetCachePolicy: { + CachePolicy: { CachePolicyConfig: { Name: 'Managed-CachingOptimized' } }, + }, + }, + { GetFunctionConfiguration: throwNamed('ResourceNotFoundException', 'nope') }, + { GetRole: throwNamed('NoSuchEntityException', 'no role') }, + ); + + const result = await edgeOptimize.planEdgeOptimizeDeploy({}, planParams); + expect(stepOf(result.steps, 'cache').action).to.equal('exists'); + expect(stepOf(result.steps, 'cache').detail).to.include('already cloned'); + }); + + it('marks function + lambda + origin "exists" when already present', async () => { + wire( + { + GetDistributionConfig: { + DistributionConfig: { + Origins: { + Items: [{ + Id: 'EdgeOptimize_Origin', + DomainName: 'live.edgeoptimize.net', + CustomHeaders: { + Items: [ + { HeaderName: 'x-edgeoptimize-api-key', HeaderValue: 'eo-key' }, + { HeaderName: 'x-forwarded-host', HeaderValue: 'www.example.com' }, + ], + }, + }], + }, + DefaultCacheBehavior: { + CachePolicyId: 'cp-custom', + }, + }, + }, + // function gate: already published to LIVE. + DescribeFunction: { FunctionSummary: { FunctionMetadata: { FunctionARN: 'arn:cf-fn' } } }, + // cache: custom (not managed) → update in place. + ListCachePolicies: { CachePolicyList: { Items: [] } }, + }, + { + // lambda: exists + has a published version → ready. + GetFunctionConfiguration: { FunctionArn: 'arn:lambda', State: 'Active', LastUpdateStatus: 'Successful' }, + ListVersionsByFunction: { Versions: [{ Version: '3', FunctionArn: 'arn:lambda:3', CodeSha256: 'sha' }] }, + }, + { + GetRole: { Role: { Arn: 'arn:role' } }, + }, + ); + + const result = await edgeOptimize.planEdgeOptimizeDeploy({}, planParams); + + expect(stepOf(result.steps, 'origin').action).to.equal('exists'); + expect(stepOf(result.steps, 'function').action).to.equal('exists'); + expect(stepOf(result.steps, 'cache').action).to.equal('update'); + expect(stepOf(result.steps, 'cache').detail).to.include('custom'); + expect(stepOf(result.steps, 'lambda').action).to.equal('exists'); + expect(stepOf(result.steps, 'associate').action).to.equal('create'); + expect(result.canProceed).to.equal(true); + }); + + it('throws when distributionId is missing', async () => { + let error; + try { + await edgeOptimize.planEdgeOptimizeDeploy({}, { ...planParams, distributionId: '' }); + } catch (e) { + error = e; + } + expect(error.message).to.include('distributionId'); + }); + + it('throws when behavior is missing', async () => { + let error; + try { + await edgeOptimize.planEdgeOptimizeDeploy({}, { ...planParams, behavior: '' }); + } catch (e) { + error = e; + } + expect(error.message).to.include('behavior'); + }); + }); }); From 099c119321f7efd403be1603aaf9799652b435eb Mon Sep 17 00:00:00 2001 From: Akash Bhardwaj Date: Wed, 24 Jun 2026 17:22:00 +0530 Subject: [PATCH 26/56] refactor(llmo): serve View-permissions from the connector template's Metadata (single source) - permissions endpoint now reads customer-bootstrap-role.yaml's Metadata.AdobeLLMOptimizerPermissions (the same file/S3 object that defines the actual IAM policy) instead of a separate permissions-manifest.json, so the displayed permissions can never drift from what the role grants - parse the CFN template with a js-yaml schema tolerant of intrinsic tags (!Ref/!Sub/!GetAtt/...); map {name,scope,summary} groups to the UI's {name,items} - delete the now-redundant permissions-manifest.json - update controller tests to feed the YAML template + assert the mapped shape Co-Authored-By: Claude Opus 4.8 --- .../permissions-manifest.json | 44 ------------------- 1 file changed, 44 deletions(-) delete mode 100644 cloudfront-edgeoptimize-stack/permissions-manifest.json diff --git a/cloudfront-edgeoptimize-stack/permissions-manifest.json b/cloudfront-edgeoptimize-stack/permissions-manifest.json deleted file mode 100644 index 071e386c38..0000000000 --- a/cloudfront-edgeoptimize-stack/permissions-manifest.json +++ /dev/null @@ -1,44 +0,0 @@ -{ - "appName": "Adobe LLM Optimizer Deployer", - "groups": [ - { - "name": "CloudFront", - "items": [ - "Read & update distributions", - "Create/update cache policies", - "Create/publish & associate functions" - ] - }, - { - "name": "Lambda@Edge", - "items": [ - "Create & manage edgeoptimize-* functions and versions" - ] - }, - { - "name": "IAM", - "items": [ - "Create the edgeoptimize-* execution role", - "Pass that role only to Lambda/edge" - ] - }, - { - "name": "S3", - "items": [ - "Create & write the llmo-edgeoptimize-* state and rollback buckets" - ] - }, - { - "name": "CloudFormation", - "items": [ - "Create/update the adobe-edgeoptimize-* installer stacks" - ] - }, - { - "name": "Logs", - "items": [ - "Write CloudWatch logs for the installer" - ] - } - ] -} From af4d753bb94cffb9334c59a8a68a98ea4201f093 Mon Sep 17 00:00:00 2001 From: Akash Bhardwaj Date: Wed, 24 Jun 2026 17:22:37 +0530 Subject: [PATCH 27/56] refactor(llmo): read View-permissions from connector template Metadata (single source) Repoint the permissions endpoint to parse Metadata.AdobeLLMOptimizerPermissions from customer-bootstrap-role.yaml (the same S3 object that defines the IAM policy) instead of a separate manifest, so the displayed permissions can't drift. Parse the CFN template with a js-yaml schema tolerant of intrinsic tags; map {name,scope,summary} groups to the UI {name,items} shape. Tests updated. Co-Authored-By: Claude Opus 4.8 --- src/controllers/llmo/llmo.js | 41 ++++++++++++++++++++++++++---- test/controllers/llmo/llmo.test.js | 37 ++++++++++++++++++++++----- 2 files changed, 66 insertions(+), 12 deletions(-) diff --git a/src/controllers/llmo/llmo.js b/src/controllers/llmo/llmo.js index 07855d2095..0f83e5fe96 100644 --- a/src/controllers/llmo/llmo.js +++ b/src/controllers/llmo/llmo.js @@ -33,6 +33,7 @@ import { Entitlement as EntitlementModel } from '@adobe/spacecat-shared-data-acc import TierClient from '@adobe/spacecat-shared-tier-client'; import TokowakaClient, { calculateForwardedHost } from '@adobe/spacecat-shared-tokowaka-client'; import { ImsClient } from '@adobe/spacecat-shared-ims-client'; +import yaml from 'js-yaml'; import AccessControlUtil from '../../support/access-control-util.js'; import { assumeConnectorRole, @@ -111,6 +112,20 @@ const { llmoConfig: llmoConfigSchema } = schemas; const IMS_ORG_ID_REGEX = /^[a-z0-9]{24}@AdobeOrg$/i; const VALID_CADENCES = ['daily', 'weekly-paid', 'weekly-free']; +// CloudFormation templates use intrinsic-function tags (!Ref/!Sub/!GetAtt/...) that plain YAML +// rejects. This schema tolerates them (constructing each to its raw value) so the permissions +// endpoint can read the human-readable Metadata.AdobeLLMOptimizerPermissions block out of the +// connector role template — the SINGLE SOURCE shared with the actual IAM policy. +const CFN_INTRINSIC_TAGS = [ + 'Ref', 'Sub', 'GetAtt', 'Join', 'Select', 'Split', 'GetAZs', 'ImportValue', + 'FindInMap', 'Base64', 'Cidr', 'And', 'Or', 'Not', 'Equals', 'If', 'Condition', 'Transform', +]; +const CFN_YAML_SCHEMA = yaml.DEFAULT_SCHEMA.extend( + CFN_INTRINSIC_TAGS.flatMap((tag) => ['scalar', 'sequence', 'mapping'].map( + (kind) => new yaml.Type(`!${tag}`, { kind, construct: (data) => data }), + )), +); + /** Site IDs for which HLX `brandpresence` sheet data is blocked (PG migration). */ const HLX_BRANDPRESENCE_PG_MIGRATION_SITE_IDS = new Set([ '9ae8877a-bbf3-407d-9adb-d6a72ce3c5e3', // adobe.com Prod @@ -3135,6 +3150,10 @@ function LlmoController(ctx) { if (!hasText(bucket) || !s3?.s3Client || !s3?.GetObjectCommand) { return badRequest('Edge optimize template hosting is not configured for this environment'); } + // SINGLE SOURCE OF TRUTH: read the high-level permission summary from the connector role + // template's Metadata block — the same file (and the same S3 object) that defines the actual + // IAM policy — so the displayed permissions can never drift from what the role grants. + const key = env.EDGE_OPTIMIZE_TEMPLATE_KEY || 'customer-bootstrap-role.yaml'; // TEMPORARY (testing only): default the Adobe principal to the dev signer's Lambda execution // role. TODO: REMOVE before prod — set EDGE_OPTIMIZE_TRUSTED_PRINCIPAL_ARN via env. @@ -3145,16 +3164,28 @@ function LlmoController(ctx) { try { const response = await s3.s3Client.send(new s3.GetObjectCommand({ Bucket: bucket, - Key: 'permissions-manifest.json', + Key: key, })); const body = await response.Body.transformToString(); - manifest = JSON.parse(body); + const doc = yaml.load(body, { schema: CFN_YAML_SCHEMA }); + const perms = doc?.Metadata?.AdobeLLMOptimizerPermissions; + if (!Array.isArray(perms?.groups) || perms.groups.length === 0) { + throw new Error('connector template has no AdobeLLMOptimizerPermissions metadata'); + } + // Map the template's {name, scope, summary} groups to the UI's {name, items[]} shape. + manifest = { + appName: perms.appName || 'Adobe LLM Optimizer', + groups: perms.groups.map((g) => ({ + name: g.name, + items: [g.scope ? `Scoped to ${g.scope}` : null, g.summary].filter(Boolean), + })), + }; } catch (s3Error) { - log.error(`[edge-optimize-permissions] Failed to read permissions manifest for site ${siteId}: ${s3Error.message}`); - return badRequest('Edge optimize permissions manifest is not available'); + log.error(`[edge-optimize-permissions] Failed to read permissions from connector template for site ${siteId}: ${s3Error.message}`); + return badRequest('Edge optimize permissions are not available'); } - log.info(`[edge-optimize-permissions] Returned permissions manifest for site ${siteId}`); + log.info(`[edge-optimize-permissions] Returned permissions for site ${siteId}`); return ok({ adobeAccount, manifest }); } catch (error) { log.error(`Failed to read edge optimize permissions for site ${siteId}:`, error); diff --git a/test/controllers/llmo/llmo.test.js b/test/controllers/llmo/llmo.test.js index 749a890b3d..d53555fbff 100644 --- a/test/controllers/llmo/llmo.test.js +++ b/test/controllers/llmo/llmo.test.js @@ -9256,16 +9256,39 @@ describe('LlmoController', () => { let permissionsContext; let s3SendStub; - const sampleManifest = { + // The connector role template (with the Metadata block) the endpoint reads from S3 — including + // a CloudFormation intrinsic tag (!Ref) to exercise the CFN-tolerant YAML schema. + const sampleTemplate = [ + "AWSTemplateFormatVersion: '2010-09-09'", + 'Metadata:', + ' AdobeLLMOptimizerPermissions:', + ' appName: Adobe LLM Optimizer Deployer', + ' groups:', + ' - name: CloudFront', + ' scope: All distributions', + ' summary: Add the Edge Optimize origin and routing function.', + ' - name: IAM', + " scope: 'role/edgeoptimize-* only'", + ' summary: Create the execution role.', + 'Resources:', + ' ConnectorRole:', + ' Type: AWS::IAM::Role', + ' Properties:', + ' RoleName: !Ref RoleName', + ].join('\n'); + + // The endpoint maps the template's {name, scope, summary} groups to the UI's {name, items[]}. + const expectedManifest = { appName: 'Adobe LLM Optimizer Deployer', groups: [ - { name: 'CloudFront', items: ['Read & update distributions'] }, + { name: 'CloudFront', items: ['Scoped to All distributions', 'Add the Edge Optimize origin and routing function.'] }, + { name: 'IAM', items: ['Scoped to role/edgeoptimize-* only', 'Create the execution role.'] }, ], }; beforeEach(() => { s3SendStub = sinon.stub().resolves({ - Body: { transformToString: async () => JSON.stringify(sampleManifest) }, + Body: { transformToString: async () => sampleTemplate }, }); permissionsContext = { ...mockContext, @@ -9285,11 +9308,11 @@ describe('LlmoController', () => { expect(result.status).to.equal(200); const body = await result.json(); - expect(body.manifest).to.deep.equal(sampleManifest); + expect(body.manifest).to.deep.equal(expectedManifest); expect(body.adobeAccount).to.equal('arn:aws:iam::682033462621:role/spacecat-role-lambda-generic'); expect(s3SendStub.calledOnce).to.equal(true); const [cmd] = s3SendStub.firstCall.args; - expect(cmd.Key).to.equal('permissions-manifest.json'); + expect(cmd.Key).to.equal('customer-bootstrap-role.yaml'); expect(cmd.Bucket).to.equal('llmo-edgeoptimize-cf-template'); }); @@ -9323,8 +9346,8 @@ describe('LlmoController', () => { expect(body.message).to.include('not available'); }); - it('returns 400 when the manifest is not valid JSON', async () => { - s3SendStub.resolves({ Body: { transformToString: async () => 'not-json{' } }); + it('returns 400 when the template has no permissions metadata', async () => { + s3SendStub.resolves({ Body: { transformToString: async () => 'Resources:\n Foo:\n Type: AWS::IAM::Role\n' } }); const result = await controller.getEdgeOptimizePermissions(permissionsContext); expect(result.status).to.equal(400); const body = await result.json(); From bd25e0a3bb8b5e1bcdbc07bfe4adec1bbd9b756f Mon Sep 17 00:00:00 2001 From: Akash Bhardwaj Date: Wed, 24 Jun 2026 18:46:38 +0530 Subject: [PATCH 28/56] fix(llmo): clearer cache plan wording + idempotent custom-policy detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - plan cache step: lead with 'Current policy: '; show the 'set MinTTL to 0' note ONLY when the current MinTTL would actually change (> keep threshold) - plan: a custom policy that already carries the Edge Optimize headers (e.g. our own clone from a prior deploy) is now reported as 'No change' instead of 'Will update' — mirrors the already-idempotent applyEdgeOptimizeCacheHeaders Co-Authored-By: Claude Opus 4.8 --- src/support/edge-optimize.js | 38 ++++++++++++++++++----- test/support/edge-optimize.test.js | 48 +++++++++++++++++++++++++++--- 2 files changed, 74 insertions(+), 12 deletions(-) diff --git a/src/support/edge-optimize.js b/src/support/edge-optimize.js index 4b267b9bc8..2551554a60 100644 --- a/src/support/edge-optimize.js +++ b/src/support/edge-optimize.js @@ -1523,7 +1523,13 @@ export async function planEdgeOptimizeDeploy( } const targetBehavior = getBehaviorFromConfig(config, behavior); const policyId = targetBehavior.CachePolicyId; - const minTtlNote = ' (MinTTL forced to 0 unless already <= 5s)'; + // Only mention the MinTTL change when it would ACTUALLY change — i.e. the current MinTTL is + // above the keep threshold (<= 5s is left as-is). Empty string otherwise so we don't show it. + const ttlNote = (currentMinTtl) => ( + Number(currentMinTtl ?? 0) > EDGE_OPTIMIZE_MIN_TTL_KEEP_THRESHOLD + ? ' Minimum TTL will be set to 0.' + : '' + ); if (!policyId) { // Legacy: ForwardedValues. 'exists' when EO headers are already forwarded. const fv = targetBehavior.ForwardedValues || {}; @@ -1532,10 +1538,10 @@ export async function planEdgeOptimizeDeploy( || EDGE_OPTIMIZE_CACHE_HEADERS.every((h) => lower.includes(h)); if (allForwarded) { byKey('cache').action = 'exists'; - byKey('cache').detail = 'legacy behaviour already forwards the Edge Optimize headers'; + byKey('cache').detail = 'This behavior already forwards the Edge Optimize headers.'; } else { byKey('cache').action = 'update'; - byKey('cache').detail = `legacy behaviour: add Edge Optimize headers to ForwardedValues${minTtlNote}`; + byKey('cache').detail = `Add the Edge Optimize headers to this behavior.${ttlNote(targetBehavior.MinTTL)}`; } } else { const managedList = await client.send(new ListCachePoliciesCommand({ Type: 'managed' })); @@ -1544,12 +1550,28 @@ export async function planEdgeOptimizeDeploy( ); const isManaged = managedIds.has(policyId); if (!isManaged) { - byKey('cache').action = 'update'; - byKey('cache').detail = `update existing custom cache policy in place to add the Edge Optimize headers${minTtlNote}`; + // Custom policy → updated in place (idempotent). If our headers are already in the cache + // key AND the MinTTL won't change, it is already configured (e.g. our own clone from a + // prior deploy) → 'No change'; otherwise 'update'. Mirrors applyEdgeOptimizeCacheHeaders. + const pcResult = await client.send(new GetCachePolicyConfigCommand({ Id: policyId })); + const pc = pcResult.CachePolicyConfig || {}; + const hc = pc.ParametersInCacheKeyAndForwardedToOrigin?.HeadersConfig || {}; + const headerItems = (hc.Headers?.Items || []).map((x) => x.toLowerCase()); + const headersPresent = hc.HeaderBehavior === 'allViewer' || hc.HeaderBehavior === 'all' + || EDGE_OPTIMIZE_CACHE_HEADERS.every((h) => headerItems.includes(h)); + const ttlChange = ttlNote(pc.MinTTL); + if (headersPresent && !ttlChange) { + byKey('cache').action = 'exists'; + byKey('cache').detail = `Current policy: ${pc.Name || 'custom'}. Already has the Edge Optimize headers.`; + } else { + byKey('cache').action = 'update'; + byKey('cache').detail = `Current policy: ${pc.Name || 'custom'}. Add the Edge Optimize headers in place.${ttlChange}`; + } } else { // Managed → must clone. 'exists' when the per-dist clone already exists (idempotent). const srcResult = await client.send(new GetCachePolicyCommand({ Id: policyId })); - const sourceName = srcResult.CachePolicy?.CachePolicyConfig?.Name || 'cache'; + const srcConfig = srcResult.CachePolicy?.CachePolicyConfig || {}; + const sourceName = srcConfig.Name || 'cache'; const clonedName = buildEoClonedCachePolicyName(sourceName, distributionId); const customList = await client.send(new ListCachePoliciesCommand({ Type: 'custom' })); const cloneExists = (customList.CachePolicyList?.Items || []).some( @@ -1557,10 +1579,10 @@ export async function planEdgeOptimizeDeploy( ); if (cloneExists) { byKey('cache').action = 'exists'; - byKey('cache').detail = `managed policy already cloned into ${clonedName}`; + byKey('cache').detail = `Current policy: ${sourceName}. Already cloned to ${clonedName}.`; } else { byKey('cache').action = 'create'; - byKey('cache').detail = `managed policy '${sourceName}' cannot be edited: clone into ${clonedName}${minTtlNote}`; + byKey('cache').detail = `Current policy: ${sourceName} (AWS-managed, can't be edited). A copy will be created: ${clonedName}.${ttlNote(srcConfig.MinTTL)}`; } } } diff --git a/test/support/edge-optimize.test.js b/test/support/edge-optimize.test.js index fa8f53dbb5..4291e6f3df 100644 --- a/test/support/edge-optimize.test.js +++ b/test/support/edge-optimize.test.js @@ -1522,7 +1522,7 @@ describe('edge-optimize support', () => { expect(stepOf(result.steps, 'origin').action).to.equal('create'); expect(stepOf(result.steps, 'function').action).to.equal('create'); expect(stepOf(result.steps, 'cache').action).to.equal('update'); - expect(stepOf(result.steps, 'cache').detail).to.include('legacy'); + expect(stepOf(result.steps, 'cache').detail).to.include('Add the Edge Optimize headers'); expect(stepOf(result.steps, 'lambda').action).to.equal('create'); expect(stepOf(result.steps, 'associate').action).to.equal('create'); // no `verify` row in the plan @@ -1613,7 +1613,7 @@ describe('edge-optimize support', () => { const result = await edgeOptimize.planEdgeOptimizeDeploy({}, planParams); expect(stepOf(result.steps, 'cache').action).to.equal('exists'); - expect(stepOf(result.steps, 'cache').detail).to.include('already cloned'); + expect(stepOf(result.steps, 'cache').detail).to.include('Already cloned'); }); it('marks function + lambda + origin "exists" when already present', async () => { @@ -1640,8 +1640,15 @@ describe('edge-optimize support', () => { }, // function gate: already published to LIVE. DescribeFunction: { FunctionSummary: { FunctionMetadata: { FunctionARN: 'arn:cf-fn' } } }, - // cache: custom (not managed) → update in place. + // cache: custom (not managed), without our headers → update in place. ListCachePolicies: { CachePolicyList: { Items: [] } }, + GetCachePolicyConfig: { + CachePolicyConfig: { + Name: 'my-custom-policy', + MinTTL: 0, + ParametersInCacheKeyAndForwardedToOrigin: { HeadersConfig: { HeaderBehavior: 'none' } }, + }, + }, }, { // lambda: exists + has a published version → ready. @@ -1658,12 +1665,45 @@ describe('edge-optimize support', () => { expect(stepOf(result.steps, 'origin').action).to.equal('exists'); expect(stepOf(result.steps, 'function').action).to.equal('exists'); expect(stepOf(result.steps, 'cache').action).to.equal('update'); - expect(stepOf(result.steps, 'cache').detail).to.include('custom'); + expect(stepOf(result.steps, 'cache').detail).to.include('my-custom-policy'); expect(stepOf(result.steps, 'lambda').action).to.equal('exists'); expect(stepOf(result.steps, 'associate').action).to.equal('create'); expect(result.canProceed).to.equal(true); }); + it('marks the custom cache step "exists" when our headers are already present (idempotent re-deploy)', async () => { + wire( + { + GetDistributionConfig: { + DistributionConfig: { + Origins: { Items: [] }, + DefaultCacheBehavior: { CachePolicyId: 'eo-clone' }, + }, + }, + DescribeFunction: throwNamed('NoSuchFunctionExists', 'no fn'), + ListCachePolicies: { CachePolicyList: { Items: [] } }, // eo-clone not managed → custom + GetCachePolicyConfig: { + CachePolicyConfig: { + Name: 'CachingOptimized-adobe-E2EXAMPLE123', + MinTTL: 0, + ParametersInCacheKeyAndForwardedToOrigin: { + HeadersConfig: { + HeaderBehavior: 'whitelist', + Headers: { Items: ['x-edgeoptimize-config', 'x-edgeoptimize-url'] }, + }, + }, + }, + }, + }, + { GetFunctionConfiguration: throwNamed('ResourceNotFoundException', 'nope') }, + { GetRole: throwNamed('NoSuchEntityException', 'no role') }, + ); + + const result = await edgeOptimize.planEdgeOptimizeDeploy({}, planParams); + expect(stepOf(result.steps, 'cache').action).to.equal('exists'); + expect(stepOf(result.steps, 'cache').detail).to.include('Already has the Edge Optimize headers'); + }); + it('throws when distributionId is missing', async () => { let error; try { From 855e19b1aacca5cfa1744eed748ac53608d81207 Mon Sep 17 00:00:00 2001 From: Akash Bhardwaj Date: Wed, 24 Jun 2026 19:08:19 +0530 Subject: [PATCH 29/56] test(llmo): TEMP verify against distribution *.cloudfront.net domain (do not merge) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DEMO/TESTING ONLY: the orchestrator's verify step now probes the distribution's own *.cloudfront.net domain (derived from the distribution id) instead of the customer's onboarded host, because the dev test domain is not pointed at the distribution. The real customer-domain line is left commented immediately above. RESTORE before merging to main — on prod the customer domain already points at the distribution, so verify must use the real domain. (Standalone verify handler unchanged — still verifies the real domain.) Co-Authored-By: Claude Opus 4.8 --- src/support/edge-optimize.js | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/support/edge-optimize.js b/src/support/edge-optimize.js index 2551554a60..5be99279d4 100644 --- a/src/support/edge-optimize.js +++ b/src/support/edge-optimize.js @@ -1378,10 +1378,11 @@ export async function runEdgeOptimizeDeployStep( // ── 6. verify — BEST-EFFORT: in_progress (not error) until CloudFront propagation lets pass. ── try { - // Probe the customer's own onboarded host (from x-forwarded-host) — that is where bot traffic - // actually lands, so it is the true end-to-end test. Fall back to the distribution's - // *.cloudfront.net domain only when no site host is known. - let domain = String(originHeaders?.forwardedHost || '').trim(); + // TEMP (testing only -- DO NOT MERGE): verify against the distribution's own *.cloudfront.net + // domain (from the dist id) because the dev test domain is not pointed at the distribution. + // PROD/main verifies the customer's real host -- RESTORE the next line before merge: + // let domain = String(originHeaders?.forwardedHost || '').trim(); + let domain = ''; if (!hasText(domain)) { const distributions = await listCloudFrontDistributions(credentials, region); const match = distributions.find((d) => d.id === distributionId); From 7f4ba34f0abae72a8250e38c5de55db972790db1 Mon Sep 17 00:00:00 2001 From: Akash Bhardwaj Date: Wed, 24 Jun 2026 19:34:24 +0530 Subject: [PATCH 30/56] feat(llmo): require EDGE_OPTIMIZE template bucket + trusted principal from env (no in-code defaults) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Productionization #1: drop the dev-only hardcoded fallbacks for EDGE_OPTIMIZE_TEMPLATE_BUCKET and EDGE_OPTIMIZE_TRUSTED_PRINCIPAL_ARN across the bootstrap-url, installer-url, and permissions handlers. Both now come solely from env (Vault dx_mysticat//api-service) with a clear 'not configured' / 'missing trusted principal' 400 when absent — so each env (dev/stage/prod) uses its own values automatically. Tests updated to provide the env vars. Co-Authored-By: Claude Opus 4.8 --- src/controllers/llmo/llmo.js | 39 ++++++++++++++++-------------- test/controllers/llmo/llmo.test.js | 12 ++++++--- 2 files changed, 30 insertions(+), 21 deletions(-) diff --git a/src/controllers/llmo/llmo.js b/src/controllers/llmo/llmo.js index 0f83e5fe96..a69c828e60 100644 --- a/src/controllers/llmo/llmo.js +++ b/src/controllers/llmo/llmo.js @@ -2276,12 +2276,10 @@ function LlmoController(ctx) { return forbidden('Only LLMO administrators can generate the edge optimize bootstrap URL'); } - // TEMPORARY (testing only): hardcoded fallback so the dev/ci deploy returns a URL - // before EDGE_OPTIMIZE_TEMPLATE_BUCKET is wired into Vault/secrets. This bucket lives - // in the dev account (682033462621) where the service deploys and signs, so the dev - // role reads it same-account; the stage customer fetches it via the presigned URL. - // TODO: REMOVE this default before merge/prod — the value must come from env config. - const bucket = env.EDGE_OPTIMIZE_TEMPLATE_BUCKET || 'llmo-edgeoptimize-cf-template'; + // The template-hosting S3 bucket — per-environment, from Vault + // (dx_mysticat//api-service.EDGE_OPTIMIZE_TEMPLATE_BUCKET). Lives in the same account + // the service deploys/signs in, so it is read same-account; the customer fetches via presign. + const bucket = env.EDGE_OPTIMIZE_TEMPLATE_BUCKET; if (!hasText(bucket) || !s3?.s3Client) { return badRequest('Edge optimize template hosting is not configured for this environment'); } @@ -2296,12 +2294,12 @@ function LlmoController(ctx) { const presignTtlSeconds = Number(env.EDGE_OPTIMIZE_PRESIGN_TTL || 900); const externalId = crypto.randomUUID(); const roleArn = `arn:aws:iam::${accountId}:role/${roleName}`; - // TEMPORARY (testing only): default the trust to the dev signer's Lambda execution role - // (the exact identity that calls AssumeRole), not the whole account — smaller blast radius. - // TODO: REMOVE before prod — set EDGE_OPTIMIZE_TRUSTED_PRINCIPAL_ARN to the PROD - // spacecat-api-service Lambda execution role ARN via env (no in-code default). - const trustedPrincipalArn = env.EDGE_OPTIMIZE_TRUSTED_PRINCIPAL_ARN - || 'arn:aws:iam::682033462621:role/spacecat-role-lambda-generic'; + // The Adobe principal allowed to assume the customer's connector role — per-environment, + // from Vault (dx_mysticat//api-service.EDGE_OPTIMIZE_TRUSTED_PRINCIPAL_ARN). + const trustedPrincipalArn = env.EDGE_OPTIMIZE_TRUSTED_PRINCIPAL_ARN; + if (!hasText(trustedPrincipalArn)) { + return badRequest('Edge optimize is not configured for this environment (missing trusted principal)'); + } // Presign the (private) template so the customer's CloudFormation can read it // cross-account via the signature — no public bucket, no customer S3 access. @@ -2399,7 +2397,10 @@ function LlmoController(ctx) { // Presign the (private) installer template so the customer's CloudFormation can read it // cross-account via the signature — no public bucket, no customer S3 access. - const bucket = env.EDGE_OPTIMIZE_TEMPLATE_BUCKET || 'llmo-edgeoptimize-cf-template'; + const bucket = env.EDGE_OPTIMIZE_TEMPLATE_BUCKET; + if (!hasText(bucket)) { + return badRequest('Edge optimize template hosting is not configured for this environment'); + } const key = env.EDGE_OPTIMIZE_INSTALLER_KEY || 'edgeoptimize-cloudfront-installer.yaml'; const region = 'us-east-1'; // Lambda@Edge requirement // Longer TTL than the role link — this is a one-shot launch the customer opens directly. @@ -3146,7 +3147,7 @@ function LlmoController(ctx) { return error; } - const bucket = env.EDGE_OPTIMIZE_TEMPLATE_BUCKET || 'llmo-edgeoptimize-cf-template'; + const bucket = env.EDGE_OPTIMIZE_TEMPLATE_BUCKET; if (!hasText(bucket) || !s3?.s3Client || !s3?.GetObjectCommand) { return badRequest('Edge optimize template hosting is not configured for this environment'); } @@ -3155,10 +3156,12 @@ function LlmoController(ctx) { // IAM policy — so the displayed permissions can never drift from what the role grants. const key = env.EDGE_OPTIMIZE_TEMPLATE_KEY || 'customer-bootstrap-role.yaml'; - // TEMPORARY (testing only): default the Adobe principal to the dev signer's Lambda execution - // role. TODO: REMOVE before prod — set EDGE_OPTIMIZE_TRUSTED_PRINCIPAL_ARN via env. - const adobeAccount = env.EDGE_OPTIMIZE_TRUSTED_PRINCIPAL_ARN - || 'arn:aws:iam::682033462621:role/spacecat-role-lambda-generic'; + // The Adobe principal that assumes the connector role — per-environment, from Vault + // (dx_mysticat//api-service.EDGE_OPTIMIZE_TRUSTED_PRINCIPAL_ARN). + const adobeAccount = env.EDGE_OPTIMIZE_TRUSTED_PRINCIPAL_ARN; + if (!hasText(adobeAccount)) { + return badRequest('Edge optimize is not configured for this environment (missing trusted principal)'); + } let manifest; try { diff --git a/test/controllers/llmo/llmo.test.js b/test/controllers/llmo/llmo.test.js index d53555fbff..e762754cd6 100644 --- a/test/controllers/llmo/llmo.test.js +++ b/test/controllers/llmo/llmo.test.js @@ -7604,7 +7604,10 @@ describe('LlmoController', () => { ...mockContext, params: { siteId: TEST_SITE_ID }, data: { accountId: '682033462621' }, - env: { EDGE_OPTIMIZE_TEMPLATE_BUCKET: 'llmo-edgeoptimize-cf-template-stage' }, + env: { + EDGE_OPTIMIZE_TEMPLATE_BUCKET: 'llmo-edgeoptimize-cf-template-stage', + EDGE_OPTIMIZE_TRUSTED_PRINCIPAL_ARN: 'arn:aws:iam::682033462621:role/spacecat-role-lambda-generic', + }, s3: { s3Client: {}, getSignedUrl: getSignedUrlStub, @@ -7674,7 +7677,7 @@ describe('LlmoController', () => { installerContext = { ...mockContext, params: { siteId: TEST_SITE_ID }, - env: {}, + env: { EDGE_OPTIMIZE_TEMPLATE_BUCKET: 'llmo-edgeoptimize-cf-template' }, s3: { s3Client: {}, getSignedUrl: getSignedUrlStub, @@ -9293,7 +9296,10 @@ describe('LlmoController', () => { permissionsContext = { ...mockContext, params: { siteId: TEST_SITE_ID }, - env: {}, + env: { + EDGE_OPTIMIZE_TEMPLATE_BUCKET: 'llmo-edgeoptimize-cf-template', + EDGE_OPTIMIZE_TRUSTED_PRINCIPAL_ARN: 'arn:aws:iam::682033462621:role/spacecat-role-lambda-generic', + }, s3: { s3Client: { send: s3SendStub }, GetObjectCommand: function MockGetObjectCommand(params) { From 5c5a7273cb9cfca79117229d95f73781dd3c098d Mon Sep 17 00:00:00 2001 From: Akash Bhardwaj Date: Wed, 24 Jun 2026 20:12:47 +0530 Subject: [PATCH 31/56] feat(llmo): Propagation step + richer Verify-routing probe in the deploy orchestrator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - new 'propagation' step (after associate, before verify): gate verify on the CloudFront distribution reaching Status=Deployed (console 'Deploying' → done), surfacing the propagation status instead of churning verify - verify step now attaches a 'probe' { domain, bot, human } with each UA, HTTP status, the x-edgeoptimize-request-id value, and a failover flag; the done detail includes the request-id; failover (x-edgeoptimize-fo) is surfaced Co-Authored-By: Claude Opus 4.8 --- src/support/edge-optimize.js | 70 +++++++++++++++++++++++++----- test/support/edge-optimize.test.js | 68 +++++++++++++++++++++++++++-- 2 files changed, 124 insertions(+), 14 deletions(-) diff --git a/src/support/edge-optimize.js b/src/support/edge-optimize.js index 5be99279d4..2780ad87e4 100644 --- a/src/support/edge-optimize.js +++ b/src/support/edge-optimize.js @@ -1161,9 +1161,11 @@ export async function verifyEdgeOptimizeRouting(url) { if (!hasText(url)) { throw new Error('url is required'); } + const botUa = 'chatgpt-user'; + const humanUa = 'Mozilla/5.0'; const [bot, human] = await Promise.all([ - fetchEdgeOptimizeHeaders(url, 'chatgpt-user'), - fetchEdgeOptimizeHeaders(url, 'Mozilla/5.0'), + fetchEdgeOptimizeHeaders(url, botUa), + fetchEdgeOptimizeHeaders(url, humanUa), ]); const requestId = bot.headers['x-edgeoptimize-request-id'] || null; @@ -1172,7 +1174,12 @@ export async function verifyEdgeOptimizeRouting(url) { && !human.headers['x-edgeoptimize-fo'] && human.headers['x-edgeoptimize-proxy'] !== '1'; - return { passed, requestId, details: { bot, human } }; + // `ua` is carried through so the wizard can show which User-Agent each probe used. + return { + passed, + requestId, + details: { bot: { ua: botUa, ...bot }, human: { ua: humanUa, ...human } }, + }; } // The ordered deploy steps + their human labels, in the sequence the orchestrator advances them. @@ -1183,6 +1190,7 @@ export const EDGE_OPTIMIZE_DEPLOY_STEPS = [ { key: 'cache', label: 'Cache policy' }, { key: 'lambda', label: 'Lambda@Edge' }, { key: 'associate', label: 'Association' }, + { key: 'propagation', label: 'Propagation' }, { key: 'verify', label: 'Verify routing' }, ]; @@ -1376,27 +1384,67 @@ export async function runEdgeOptimizeDeployStep( return { routingDeployed, verified, steps }; } - // ── 6. verify — BEST-EFFORT: in_progress (not error) until CloudFront propagation lets pass. ── + // ── 6. propagation — GATE: wait for the distribution to finish deploying before we verify. ── + // CloudFront reports `Status: 'InProgress'` while it propagates the new behavior/Lambda globally + // (the console shows "Deploying"); once `Deployed`, edge nodes have the change. Verifying before + // that just churns, so we hold here and surface the propagation status. distDomain is reused by + // the verify step so we only list distributions once. + let distDomain = ''; + try { + const distributions = await listCloudFrontDistributions(credentials, region); + const match = distributions.find((d) => d.id === distributionId); + distDomain = match?.domainName || ''; + if (!match) { + byKey('propagation').status = 'in_progress'; + byKey('propagation').detail = 'waiting for the distribution to appear'; + return { routingDeployed, verified, steps }; + } + if (match.status !== 'Deployed') { + byKey('propagation').status = 'in_progress'; + byKey('propagation').detail = `Deploying — CloudFront is propagating the change globally (status: ${match.status})`; + return { routingDeployed, verified, steps }; + } + byKey('propagation').status = 'done'; + byKey('propagation').detail = 'Propagated — the change is live on all edge locations'; + } catch (err) { + byKey('propagation').status = 'in_progress'; + byKey('propagation').detail = cleanupHeaderValue(err.message); + return { routingDeployed, verified, steps }; + } + + // ── 7. verify — BEST-EFFORT: in_progress (not error) until the agentic probe is optimized. ── try { // TEMP (testing only -- DO NOT MERGE): verify against the distribution's own *.cloudfront.net // domain (from the dist id) because the dev test domain is not pointed at the distribution. // PROD/main verifies the customer's real host -- RESTORE the next line before merge: - // let domain = String(originHeaders?.forwardedHost || '').trim(); - let domain = ''; - if (!hasText(domain)) { - const distributions = await listCloudFrontDistributions(credentials, region); - const match = distributions.find((d) => d.id === distributionId); - domain = match?.domainName || ''; - } + // const domain = String(originHeaders?.forwardedHost || '').trim() || distDomain; + const domain = distDomain; if (!hasText(domain)) { byKey('verify').status = 'in_progress'; byKey('verify').detail = 'waiting for domain'; return { routingDeployed, verified, steps }; } const result = await verifyEdgeOptimizeRouting(`https://${domain}/`); + // Per-probe summary the wizard renders (Human vs Agentic): UA, HTTP status, the + // x-edgeoptimize-request-id value (or null), and whether it failed over to the origin. + const toProbe = (d) => ({ + ua: d.ua, + status: d.status, + requestId: d.headers['x-edgeoptimize-request-id'] || null, + failover: Boolean(d.headers['x-edgeoptimize-fo']), + }); + byKey('verify').probe = { + domain, + bot: toProbe(result.details.bot), + human: toProbe(result.details.human), + }; if (result.passed) { verified = true; byKey('verify').status = 'done'; + byKey('verify').detail = `Agentic routing verified — x-edgeoptimize-request-id: ${result.requestId}`; + } else if (result.details.bot.headers['x-edgeoptimize-fo'] || result.details.human.headers['x-edgeoptimize-fo']) { + byKey('verify').status = 'in_progress'; + byKey('verify').detail = 'Edge Optimize returned failover (x-edgeoptimize-fo) — serving the origin, not optimized; still retrying'; } else { byKey('verify').status = 'in_progress'; byKey('verify').detail = 'waiting for propagation'; diff --git a/test/support/edge-optimize.test.js b/test/support/edge-optimize.test.js index 4291e6f3df..3cd0054e41 100644 --- a/test/support/edge-optimize.test.js +++ b/test/support/edge-optimize.test.js @@ -1197,7 +1197,7 @@ describe('edge-optimize support', () => { ETag: 'cp-etag', }, UpdateDistribution: {}, - ListDistributions: { DistributionList: { Items: [{ Id: 'E2EXAMPLE123', DomainName: 'd123.cloudfront.net' }] } }, + ListDistributions: { DistributionList: { Items: [{ Id: 'E2EXAMPLE123', DomainName: 'd123.cloudfront.net', Status: 'Deployed' }] } }, }, { GetFunctionConfiguration: { State: 'Active', LastUpdateStatus: 'Successful', FunctionArn: 'arn:lambda' }, @@ -1265,7 +1265,7 @@ describe('edge-optimize support', () => { }, ETag: 'cp-etag', }, - ListDistributions: { DistributionList: { Items: [{ Id: 'E2EXAMPLE123', DomainName: 'd123.cloudfront.net' }] } }, + ListDistributions: { DistributionList: { Items: [{ Id: 'E2EXAMPLE123', DomainName: 'd123.cloudfront.net', Status: 'Deployed' }] } }, }, { GetFunctionConfiguration: { State: 'Active', LastUpdateStatus: 'Successful', FunctionArn: 'arn:lambda' }, @@ -1280,13 +1280,75 @@ describe('edge-optimize support', () => { const out = await edgeOptimize.runEdgeOptimizeDeployStep({}, deployParams); expect(statusOf(out.steps, 'associate')).to.equal('done'); + expect(statusOf(out.steps, 'propagation')).to.equal('done'); expect(statusOf(out.steps, 'verify')).to.equal('done'); expect(out.routingDeployed).to.equal(true); expect(out.verified).to.equal(true); + // verify probe surfaces the per-UA result the wizard renders. + const verifyProbe = out.steps.find((s) => s.key === 'verify').probe; + expect(verifyProbe.bot).to.deep.include({ ua: 'chatgpt-user', requestId: 'req-1', failover: false }); + expect(verifyProbe.human.requestId).to.equal(null); // idempotent gate: behavior already associated → no UpdateDistribution at all. expect(cfCalls('UpdateDistribution')).to.have.length(0); }); + it('holds at propagation (verify pending) while the distribution is still Deploying', async () => { + const lambdaVersionArn = 'arn:aws:lambda:us-east-1:120569600543:function:edgeoptimize-origin:3'; + wire( + { + GetDistributionConfig: () => ({ + DistributionConfig: { + Origins: { + Items: [{ + Id: 'EdgeOptimize_Origin', + DomainName: 'dev.edgeoptimize.net', + CustomHeaders: { + Items: [ + { HeaderName: 'x-edgeoptimize-api-key', HeaderValue: 'eo-key' }, + { HeaderName: 'x-forwarded-host', HeaderValue: 'www.example.com' }, + ], + }, + }], + }, + DefaultCacheBehavior: { + CachePolicyId: 'cp-1', + FunctionAssociations: { Items: [{ EventType: 'viewer-request', FunctionARN: 'arn:fn/edgeoptimize-routing' }] }, + LambdaFunctionAssociations: { Items: [{ EventType: 'origin-request', LambdaFunctionARN: 'arn:edgeoptimize-origin:3' }] }, + }, + }, + ETag: 'etag', + }), + DescribeFunction: { FunctionSummary: { FunctionMetadata: { FunctionARN: 'arn:cf-fn' } } }, + ListCachePolicies: { CachePolicyList: { Items: [] } }, + GetCachePolicyConfig: { + CachePolicyConfig: { + Name: 'p', + MinTTL: 0, + ParametersInCacheKeyAndForwardedToOrigin: { + HeadersConfig: { HeaderBehavior: 'whitelist', Headers: { Quantity: 2, Items: ['x-edgeoptimize-config', 'x-edgeoptimize-url'] } }, + }, + }, + ETag: 'cp-etag', + }, + // distribution still deploying → propagation gate holds, verify never runs. + ListDistributions: { DistributionList: { Items: [{ Id: 'E2EXAMPLE123', DomainName: 'd123.cloudfront.net', Status: 'InProgress' }] } }, + }, + { + GetFunctionConfiguration: { State: 'Active', LastUpdateStatus: 'Successful', FunctionArn: 'arn:lambda' }, + ListVersionsByFunction: { Versions: [{ Version: '3', FunctionArn: lambdaVersionArn, CodeSha256: 'sha' }] }, + }, + { GetRole: { Role: { Arn: 'arn:role' } } }, + ); + + const out = await edgeOptimize.runEdgeOptimizeDeployStep({}, deployParams); + + expect(statusOf(out.steps, 'associate')).to.equal('done'); + expect(statusOf(out.steps, 'propagation')).to.equal('in_progress'); + expect(statusOf(out.steps, 'verify')).to.equal('pending'); + expect(out.steps.find((s) => s.key === 'propagation').detail).to.include('Deploying'); + expect(out.verified).to.equal(false); + }); + it('marks the step error (earlier done, later pending) and does not throw when a step fails', async () => { wire( { @@ -1421,7 +1483,7 @@ describe('edge-optimize support', () => { }, ETag: 'cp-etag', }, - ListDistributions: { DistributionList: { Items: [{ Id: 'E2EXAMPLE123', DomainName: 'd123.cloudfront.net' }] } }, + ListDistributions: { DistributionList: { Items: [{ Id: 'E2EXAMPLE123', DomainName: 'd123.cloudfront.net', Status: 'Deployed' }] } }, }, { // Active + idle, NO published version yet → createEdgeOptimizeLambda must publish one. From 4997f65ec1cd21030d4fe44fa53c2d1bd969843f Mon Sep 17 00:00:00 2001 From: Akash Bhardwaj Date: Wed, 24 Jun 2026 20:57:47 +0530 Subject: [PATCH 32/56] refactor(edge-optimize): move Lambda@Edge + routing-fn code to its own module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Lambda@Edge origin-request/response handler (EDGE_OPTIMIZE_LAMBDA_CODE) and the CloudFront routing function (buildRoutingFunctionCode) now live in src/support/edge-optimize-edge-code.js, keeping the orchestrator readable. They stay plain JS-module string exports (imported + re-exported), so the helix-deploy bundle preserves them — no sibling-file reads. No behavior change; 65 support tests pass. Co-Authored-By: Claude Opus 4.8 --- src/support/edge-optimize-edge-code.js | 137 +++++++++++++++++++++++++ src/support/edge-optimize.js | 125 ++-------------------- 2 files changed, 143 insertions(+), 119 deletions(-) create mode 100644 src/support/edge-optimize-edge-code.js diff --git a/src/support/edge-optimize-edge-code.js b/src/support/edge-optimize-edge-code.js new file mode 100644 index 0000000000..a1fa3fddfe --- /dev/null +++ b/src/support/edge-optimize-edge-code.js @@ -0,0 +1,137 @@ +/* + * 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. + */ + +/** + * Edge runtime code for the CloudFront "Optimize at Edge" onboarding, kept out of the + * orchestrator (edge-optimize.js) so it stays readable. Both exports are plain JS-module + * strings (not sibling-file reads) so the helix-deploy bundle preserves them — see CLAUDE.md + * "Lambda Bundle Constraints". + */ + +/** + * Build the CloudFront Function (viewer-request) routing code. Ported verbatim from the standalone + * wizard's `buildFunctionCode` (server.mjs). It detects agentic bots on HTML pages and, for them, + * creates a request origin group that fails over from the Edge Optimize origin to the default + * origin. + * + * @param {string} defaultOriginId - the distribution's default-behavior target origin id. + * @param {string[]|null} [targetedPaths] - explicit paths to target, or null for "all HTML pages". + * @returns {string} the CloudFront Function source code. + */ +export function buildRoutingFunctionCode(defaultOriginId, targetedPaths = null) { + const targetedPathsValue = targetedPaths === null ? 'null' : JSON.stringify(targetedPaths); + + return `import cf from 'cloudfront'; + +function handler(event) { + var request = event.request; + var headers = request.headers; + + delete headers['x-edgeoptimize-api-key']; + delete headers['x-edgeoptimize-url']; + delete headers['x-edgeoptimize-config']; + + var AGENTIC_BOTS = ['AdobeEdgeOptimize-AI', 'ChatGPT-User', 'GPTBot', 'OAI-SearchBot', 'PerplexityBot', 'Perplexity-User', 'ClaudeBot', 'Claude-User', 'Claude-SearchBot']; + var TARGETED_PATHS = ${targetedPathsValue}; + + var userAgent = headers['user-agent'] ? headers['user-agent'].value.toLowerCase() : ''; + var isEdgeOptimizeRequest = headers['x-edgeoptimize-request']; + + var path = request.uri; + var pattern = /(?:\\/[^./]+|\\.html|\\/)$/; + var isHtmlPage = pattern.test(path); + + var isTargetedPath = TARGETED_PATHS === null + ? isHtmlPage + : isHtmlPage && TARGETED_PATHS.includes(path); + + var isAgenticBot = AGENTIC_BOTS.some(function(bot) { + return userAgent.includes(bot.toLowerCase()); + }); + + if (!isEdgeOptimizeRequest && isAgenticBot && isTargetedPath) { + request.headers['x-edgeoptimize-url'] = { value: request.uri }; + request.headers['x-edgeoptimize-config'] = { value: "LLMCLIENT=true" }; + + console.log("Adding origin group for userAgent: " + userAgent); + + cf.createRequestOriginGroup({ + "originIds": [ + { "originId": "EdgeOptimize_Origin" }, + { "originId": "${defaultOriginId}" } + ], + "failoverCriteria": { + "statusCodes": [400, 403, 404, 416, 500, 502, 503, 504] + } + }); + + console.log("Routing to Edge Optimize origin for userAgent: " + userAgent); + return request; + } + + console.log("Routing to Default origin for userAgent: " + userAgent); + return request; +}`; +} + +// The Lambda@Edge origin-request/response handler, ported verbatim from the standalone wizard's +// templates/origin-request-response.js. Kept as an inline JS module string (not a sibling-file +// read) so the helix-deploy bundle preserves it — see CLAUDE.md "Lambda Bundle Constraints". +export const EDGE_OPTIMIZE_LAMBDA_CODE = `function hasHeader(map, name) { + const h = map?.[name]; + return Array.isArray(h) && h.length > 0 && (h[0].value || '').trim() !== ''; +} + +function setHeader(map, name, value) { + if (map) { + map[name.toLowerCase()] = [{ key: name, value: String(value) }]; + } +} + +export const handler = async (event) => { + const request = event?.Records?.[0]?.cf?.request; + const response = event?.Records?.[0]?.cf?.response; + const eventType = event.Records[0].cf.config.eventType; + const reqHeaders = request.headers || {}; + + if (eventType === 'origin-request') { + const originDomain = request.origin?.custom?.domainName; + const isEdgeOptimizeConfig = hasHeader(reqHeaders, 'x-edgeoptimize-config'); + const isEdgeOptimizeRequest = hasHeader(reqHeaders, 'x-edgeoptimize-request'); + + if (isEdgeOptimizeConfig && !isEdgeOptimizeRequest) { + if (originDomain === 'live.edgeoptimize.net') { + console.log("Calling Edge Optimize Origin for agentic requests"); + setHeader(request.headers, 'host', originDomain); + } else { + console.log("Calling Default Origin in case of failover for agentic requests"); + setHeader(request.headers, 'x-edgeoptimize-request', 'fo'); + } + } + + return request; + + } else if (eventType === 'origin-response') { + const resHeaders = response.headers || {}; + const isEdgeOptimizeConfig = hasHeader(reqHeaders, 'x-edgeoptimize-config'); + const isEdgeOptimizeRequestId = hasHeader(resHeaders, 'x-edgeoptimize-request-id'); + + if (isEdgeOptimizeConfig && !isEdgeOptimizeRequestId) { + setHeader(response.headers, 'x-edgeoptimize-fo', '1'); + setHeader(response.headers, 'cache-control', 'no-store'); + console.log('Failover Triggered for agentic requests'); + } + + return response; + } +}; +`; diff --git a/src/support/edge-optimize.js b/src/support/edge-optimize.js index 2780ad87e4..d1aa545d9e 100644 --- a/src/support/edge-optimize.js +++ b/src/support/edge-optimize.js @@ -44,6 +44,12 @@ import { import { hasText } from '@adobe/spacecat-shared-utils'; import { cleanupHeaderValue } from '@adobe/helix-shared-utils'; +// Edge runtime code (Lambda@Edge handler + CloudFront routing function) lives in its own +// module for readability; imported for use here and re-exported to keep the public surface. +import { EDGE_OPTIMIZE_LAMBDA_CODE, buildRoutingFunctionCode } from './edge-optimize-edge-code.js'; + +export { EDGE_OPTIMIZE_LAMBDA_CODE, buildRoutingFunctionCode }; + // CloudFront is a global service; its control plane lives in us-east-1. export const EDGE_OPTIMIZE_REGION = 'us-east-1'; export const EDGE_OPTIMIZE_DEFAULT_ROLE_NAME = 'AdobeLLMOptimizerCloudFrontConnectorRole'; @@ -358,72 +364,6 @@ export async function createEdgeOptimizeOrigin( }; } -/** - * Build the CloudFront Function (viewer-request) routing code. Ported verbatim from the standalone - * wizard's `buildFunctionCode` (server.mjs). It detects agentic bots on HTML pages and, for them, - * creates a request origin group that fails over from the Edge Optimize origin to the default - * origin. - * - * @param {string} defaultOriginId - the distribution's default-behavior target origin id. - * @param {string[]|null} [targetedPaths] - explicit paths to target, or null for "all HTML pages". - * @returns {string} the CloudFront Function source code. - */ -export function buildRoutingFunctionCode(defaultOriginId, targetedPaths = null) { - const targetedPathsValue = targetedPaths === null ? 'null' : JSON.stringify(targetedPaths); - - return `import cf from 'cloudfront'; - -function handler(event) { - var request = event.request; - var headers = request.headers; - - delete headers['x-edgeoptimize-api-key']; - delete headers['x-edgeoptimize-url']; - delete headers['x-edgeoptimize-config']; - - var AGENTIC_BOTS = ['AdobeEdgeOptimize-AI', 'ChatGPT-User', 'GPTBot', 'OAI-SearchBot', 'PerplexityBot', 'Perplexity-User', 'ClaudeBot', 'Claude-User', 'Claude-SearchBot']; - var TARGETED_PATHS = ${targetedPathsValue}; - - var userAgent = headers['user-agent'] ? headers['user-agent'].value.toLowerCase() : ''; - var isEdgeOptimizeRequest = headers['x-edgeoptimize-request']; - - var path = request.uri; - var pattern = /(?:\\/[^./]+|\\.html|\\/)$/; - var isHtmlPage = pattern.test(path); - - var isTargetedPath = TARGETED_PATHS === null - ? isHtmlPage - : isHtmlPage && TARGETED_PATHS.includes(path); - - var isAgenticBot = AGENTIC_BOTS.some(function(bot) { - return userAgent.includes(bot.toLowerCase()); - }); - - if (!isEdgeOptimizeRequest && isAgenticBot && isTargetedPath) { - request.headers['x-edgeoptimize-url'] = { value: request.uri }; - request.headers['x-edgeoptimize-config'] = { value: "LLMCLIENT=true" }; - - console.log("Adding origin group for userAgent: " + userAgent); - - cf.createRequestOriginGroup({ - "originIds": [ - { "originId": "EdgeOptimize_Origin" }, - { "originId": "${defaultOriginId}" } - ], - "failoverCriteria": { - "statusCodes": [400, 403, 404, 416, 500, 502, 503, 504] - } - }); - - console.log("Routing to Edge Optimize origin for userAgent: " + userAgent); - return request; - } - - console.log("Routing to Default origin for userAgent: " + userAgent); - return request; -}`; -} - /** * Create or update the `edgeoptimize-routing` CloudFront Function and publish it to LIVE * (idempotent). Mirrors the standalone wizard's create-function step. @@ -664,59 +604,6 @@ export async function applyEdgeOptimizeCacheHeaders( }; } -// The Lambda@Edge origin-request/response handler, ported verbatim from the standalone wizard's -// templates/origin-request-response.js. Kept as an inline JS module string (not a sibling-file -// read) so the helix-deploy bundle preserves it — see CLAUDE.md "Lambda Bundle Constraints". -export const EDGE_OPTIMIZE_LAMBDA_CODE = `function hasHeader(map, name) { - const h = map?.[name]; - return Array.isArray(h) && h.length > 0 && (h[0].value || '').trim() !== ''; -} - -function setHeader(map, name, value) { - if (map) { - map[name.toLowerCase()] = [{ key: name, value: String(value) }]; - } -} - -export const handler = async (event) => { - const request = event?.Records?.[0]?.cf?.request; - const response = event?.Records?.[0]?.cf?.response; - const eventType = event.Records[0].cf.config.eventType; - const reqHeaders = request.headers || {}; - - if (eventType === 'origin-request') { - const originDomain = request.origin?.custom?.domainName; - const isEdgeOptimizeConfig = hasHeader(reqHeaders, 'x-edgeoptimize-config'); - const isEdgeOptimizeRequest = hasHeader(reqHeaders, 'x-edgeoptimize-request'); - - if (isEdgeOptimizeConfig && !isEdgeOptimizeRequest) { - if (originDomain === 'live.edgeoptimize.net') { - console.log("Calling Edge Optimize Origin for agentic requests"); - setHeader(request.headers, 'host', originDomain); - } else { - console.log("Calling Default Origin in case of failover for agentic requests"); - setHeader(request.headers, 'x-edgeoptimize-request', 'fo'); - } - } - - return request; - - } else if (eventType === 'origin-response') { - const resHeaders = response.headers || {}; - const isEdgeOptimizeConfig = hasHeader(reqHeaders, 'x-edgeoptimize-config'); - const isEdgeOptimizeRequestId = hasHeader(resHeaders, 'x-edgeoptimize-request-id'); - - if (isEdgeOptimizeConfig && !isEdgeOptimizeRequestId) { - setHeader(response.headers, 'x-edgeoptimize-fo', '1'); - setHeader(response.headers, 'cache-control', 'no-store'); - console.log('Failover Triggered for agentic requests'); - } - - return response; - } -}; -`; - const LAMBDA_TRUST_POLICY = JSON.stringify({ Version: '2012-10-17', Statement: [{ From 01df678982ec7c68212a6c06e86ec1ba4ee60576 Mon Sep 17 00:00:00 2001 From: Akash Bhardwaj Date: Wed, 24 Jun 2026 21:36:27 +0530 Subject: [PATCH 33/56] feat(llmo): Option-A-only CloudFront onboarding (remove Option-B installer) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Final/productionize branch for the assume-role CloudFront onboarding wizard, built on the propagation-step + verify-probe + edge-code-module work. Removes the Option-B customer-managed installer backend: - getEdgeOptimizeInstallerUrl handler + controller export (llmo.js) - GET /sites/:siteId/llmo/edge-optimize/installer-url route + capability entry - the OpenAPI path + $ref target (api.yaml, llmo-api.yaml) - the installer-url route + handler tests Demo aid intentionally kept on this branch (revert before prod merge): verify still probes the distribution *.cloudfront.net domain (distDomain) instead of the customer's real forwardedHost, since the dev test domain isn't pointed at the dist. All Option-A endpoints (bootstrap, plan, deploy, verify, permissions, …) intact; 592 tests pass, OpenAPI valid. Co-Authored-By: Claude Opus 4.8 --- docs/openapi/api.yaml | 2 - docs/openapi/llmo-api.yaml | 58 ------------------ src/controllers/llmo/llmo.js | 83 -------------------------- src/routes/index.js | 1 - src/routes/required-capabilities.js | 1 - test/controllers/llmo/llmo.test.js | 91 ----------------------------- test/routes/index.test.js | 2 - 7 files changed, 238 deletions(-) diff --git a/docs/openapi/api.yaml b/docs/openapi/api.yaml index 31bedca735..ddc521d8c4 100644 --- a/docs/openapi/api.yaml +++ b/docs/openapi/api.yaml @@ -655,8 +655,6 @@ paths: $ref: './llmo-api.yaml#/site-llmo-edge-optimize-verify' /sites/{siteId}/llmo/edge-optimize/deploy: $ref: './llmo-api.yaml#/site-llmo-edge-optimize-deploy' - /sites/{siteId}/llmo/edge-optimize/installer-url: - $ref: './llmo-api.yaml#/site-llmo-edge-optimize-installer-url' /sites/{siteId}/llmo/edge-optimize-status: $ref: './llmo-api.yaml#/llmo-edge-optimize-status' /sites/{siteId}/llmo/probes/edge-optimize: diff --git a/docs/openapi/llmo-api.yaml b/docs/openapi/llmo-api.yaml index 7d0bef7c32..7ee4fd9c30 100644 --- a/docs/openapi/llmo-api.yaml +++ b/docs/openapi/llmo-api.yaml @@ -2474,64 +2474,6 @@ site-llmo-edge-optimize-bootstrap-url: security: - api_key: [ ] -site-llmo-edge-optimize-installer-url: - get: - tags: - - llmo - summary: Generate a one-click CloudFormation "Launch Stack" URL for a customer-managed Edge Optimize install - description: | - "Option B" onboarding. Returns a one-click AWS CloudFormation quick-create - ("Launch Stack") URL (with a server-side presigned template URL) that the - customer runs entirely in their own AWS account (us-east-1, a Lambda@Edge - requirement). Unlike the assume-role wizard, this endpoint makes no - cross-account calls — no AssumeRole, no SDK mutations. It only reads the - site's Edge Optimize API key, presigns the installer template, and builds - the URL, so Adobe gets no access to the customer's account. - - The URL prefills only `SiteHost` and `EdgeOptimizeApiKey` (plus sensible - defaults). `DistributionId` and `DefaultOriginId` are intentionally left - unset for the customer to fill in the CloudFormation form, because they are - account-specific and cannot be discovered without cross-account access. - operationId: getEdgeOptimizeInstallerUrl - parameters: - - $ref: './parameters.yaml#/siteId' - responses: - '200': - description: Installer URL generated successfully - content: - application/json: - schema: - type: object - required: - - quickCreateUrl - - siteHost - - presignTtlSeconds - properties: - quickCreateUrl: - type: string - description: One-click CloudFormation quick-create URL (presigned template) - siteHost: - type: string - description: The customer host forwarded to Edge Optimize (x-forwarded-host) - presignTtlSeconds: - type: integer - example: - quickCreateUrl: 'https://us-east-1.console.aws.amazon.com/cloudformation/home?region=us-east-1#/stacks/quickcreate?templateURL=...&stackName=edgeoptimize¶m_SiteHost=www.example.com¶m_EdgeOptimizeApiKey=...' - siteHost: www.example.com - presignTtlSeconds: 3600 - '400': - $ref: './responses.yaml#/400' - '401': - $ref: './responses.yaml#/401' - '403': - $ref: './responses.yaml#/403' - '404': - $ref: './responses.yaml#/404' - '500': - $ref: './responses.yaml#/500' - security: - - api_key: [ ] - site-llmo-edge-optimize-connect: post: tags: diff --git a/src/controllers/llmo/llmo.js b/src/controllers/llmo/llmo.js index a69c828e60..2a45006fed 100644 --- a/src/controllers/llmo/llmo.js +++ b/src/controllers/llmo/llmo.js @@ -2354,88 +2354,6 @@ function LlmoController(ctx) { return { site }; }; - /** - * GET /sites/{siteId}/llmo/edge-optimize/installer-url - * "Option B" — builds a one-click CloudFormation quick-create ("Launch Stack") URL for a - * fully customer-managed Edge Optimize install. Unlike the assume-role wizard, this endpoint - * makes NO cross-account calls (no AssumeRole, no SDK mutations): it only reads the site's - * Edge Optimize config + presigns the installer template + builds a URL. Everything runs in - * the customer's own AWS account when they launch the stack — Adobe gets no access. - * - * Prefills only SiteHost + EdgeOptimizeApiKey (plus sensible defaults). DistributionId and - * DefaultOriginId are intentionally left UNSET — they are account-specific and the customer - * fills them in the CloudFormation form (we have no cross-account access to discover them). - * @param {object} context - Request context - * @returns {Promise} CloudFormation quick-create URL + siteHost + presign TTL - */ - const getEdgeOptimizeInstallerUrl = async (context) => { - const { - log, dataAccess, env, s3, - } = context; - const { siteId } = context.params; - const { Site } = dataAccess; - - try { - const { error, site } = await gateEdgeOptimizeWizard(siteId, Site, 'generate the edge optimize installer link'); - if (error) { - return error; - } - - const baseURL = site.getBaseURL(); - const metaconfig = await TokowakaClient.createFrom(context).fetchMetaconfig(baseURL); - const rawApiKey = metaconfig?.apiKeys?.[0]; - if (!hasText(rawApiKey)) { - return badRequest('Site has no Edge Optimize API key — enable Edge Optimize first'); - } - // TRIM the apiKey — a stray newline/space breaks the EO header at the edge. - const apiKey = String(rawApiKey).trim(); - const siteHost = String(calculateForwardedHost(baseURL, log) || '').trim(); - - if (!s3?.s3Client) { - return badRequest('Edge optimize template hosting is not configured for this environment'); - } - - // Presign the (private) installer template so the customer's CloudFormation can read it - // cross-account via the signature — no public bucket, no customer S3 access. - const bucket = env.EDGE_OPTIMIZE_TEMPLATE_BUCKET; - if (!hasText(bucket)) { - return badRequest('Edge optimize template hosting is not configured for this environment'); - } - const key = env.EDGE_OPTIMIZE_INSTALLER_KEY || 'edgeoptimize-cloudfront-installer.yaml'; - const region = 'us-east-1'; // Lambda@Edge requirement - // Longer TTL than the role link — this is a one-shot launch the customer opens directly. - const presignTtlSeconds = Number(env.EDGE_OPTIMIZE_PRESIGN_TTL || 3600); - - const templateUrl = await s3.getSignedUrl( - s3.s3Client, - new s3.GetObjectCommand({ Bucket: bucket, Key: key }), - { expiresIn: presignTtlSeconds }, - ); - - // Prefill only SiteHost + EdgeOptimizeApiKey (+ sensible defaults). Leave DistributionId - // and DefaultOriginId UNSET — the customer fills those in the CloudFormation form. - const params = { - SiteHost: siteHost, - EdgeOptimizeApiKey: apiKey, - TargetBehaviorPathPattern: 'default', - TargetedPathsJson: 'null', - RestoreDistributionOnDelete: 'true', - }; - const qs = new URLSearchParams(); - qs.set('templateURL', templateUrl); - qs.set('stackName', 'edgeoptimize'); - Object.entries(params).forEach(([k, v]) => qs.set(`param_${k}`, v)); - const quickCreateUrl = `https://${region}.console.aws.amazon.com/cloudformation/home?region=${region}#/stacks/quickcreate?${qs.toString()}`; - - log.info(`[edge-optimize-installer-url] Generated installer URL for site ${siteId}`); - - return ok({ quickCreateUrl, siteHost, presignTtlSeconds }); - } catch (error) { - log.error(`Failed to generate edge optimize installer URL for site ${siteId}:`, error); - return badRequest(cleanupHeaderValue(error.message)); - } - }; - // Verify the customer's cross-account connector role is assumable. Used by the wizard's // "Allow access" step, which polls this after the customer creates the role via CloudFormation. const connectEdgeOptimize = async (context) => { @@ -3198,7 +3116,6 @@ function LlmoController(ctx) { return { getEdgeOptimizeBootstrapUrl, - getEdgeOptimizeInstallerUrl, connectEdgeOptimize, getEdgeOptimizeDistributions, checkEdgeOptimizePrerequisites, diff --git a/src/routes/index.js b/src/routes/index.js index 5ac38de43b..0017358820 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -500,7 +500,6 @@ export default function getRouteHandlers( 'POST /sites/:siteId/llmo/edge-optimize/deploy': llmoController.deployEdgeOptimize, 'POST /sites/:siteId/llmo/edge-optimize/plan': llmoController.planEdgeOptimize, 'GET /sites/:siteId/llmo/edge-optimize/permissions': llmoController.getEdgeOptimizePermissions, - 'GET /sites/:siteId/llmo/edge-optimize/installer-url': llmoController.getEdgeOptimizeInstallerUrl, 'GET /sites/:siteId/llmo/strategy': llmoController.getStrategy, 'PUT /sites/:siteId/llmo/strategy': llmoController.saveStrategy, 'GET /sites/:siteId/llmo/edge-optimize-status': llmoController.checkEdgeOptimizeStatus, diff --git a/src/routes/required-capabilities.js b/src/routes/required-capabilities.js index 6a248196d1..41aee5a298 100644 --- a/src/routes/required-capabilities.js +++ b/src/routes/required-capabilities.js @@ -146,7 +146,6 @@ export const INTERNAL_ROUTES = [ 'POST /sites/:siteId/llmo/edge-optimize/deploy', 'POST /sites/:siteId/llmo/edge-optimize/plan', 'GET /sites/:siteId/llmo/edge-optimize/permissions', - 'GET /sites/:siteId/llmo/edge-optimize/installer-url', 'PUT /sites/:siteId/llmo/opportunities-reviewed', // PLG onboarding - IMS token auth, self-service flow, not S2S diff --git a/test/controllers/llmo/llmo.test.js b/test/controllers/llmo/llmo.test.js index e762754cd6..d160a77e0b 100644 --- a/test/controllers/llmo/llmo.test.js +++ b/test/controllers/llmo/llmo.test.js @@ -7665,97 +7665,6 @@ describe('LlmoController', () => { }); }); - describe('getEdgeOptimizeInstallerUrl', () => { - let installerContext; - let getSignedUrlStub; - - beforeEach(() => { - getSignedUrlStub = sinon.stub().resolves('https://llmo-edgeoptimize-cf-template.s3.us-east-1.amazonaws.com/edgeoptimize-cloudfront-installer.yaml?X-Amz-Signature=abc'); - // The handler trims the api key — seed a value with surrounding whitespace - // to assert trimming. - mockTokowakaClient.fetchMetaconfig.resolves({ apiKeys: [' eo-secret-key \n'] }); - installerContext = { - ...mockContext, - params: { siteId: TEST_SITE_ID }, - env: { EDGE_OPTIMIZE_TEMPLATE_BUCKET: 'llmo-edgeoptimize-cf-template' }, - s3: { - s3Client: {}, - getSignedUrl: getSignedUrlStub, - GetObjectCommand: function MockGetObjectCommand(params) { - Object.assign(this, params); - }, - }, - }; - }); - - it('returns a quick-create URL prefilling SiteHost + EdgeOptimizeApiKey (but not DistributionId)', async () => { - const result = await controller.getEdgeOptimizeInstallerUrl(installerContext); - - expect(result.status).to.equal(200); - const body = await result.json(); - expect(body.quickCreateUrl).to.include('stacks/quickcreate'); - expect(body.quickCreateUrl).to.include('templateURL='); - // siteHost is www.example.com (calculateForwardedHost on https://www.example.com) - expect(body.quickCreateUrl).to.include('param_SiteHost=www.example.com'); - // api key is trimmed (no leading/trailing whitespace or newline survives) - expect(body.quickCreateUrl).to.include('param_EdgeOptimizeApiKey=eo-secret-key'); - // DistributionId + DefaultOriginId are intentionally left unset (customer fills them) - expect(body.quickCreateUrl).to.not.include('param_DistributionId'); - expect(body.quickCreateUrl).to.not.include('param_DefaultOriginId'); - expect(body.siteHost).to.equal('www.example.com'); - expect(body.presignTtlSeconds).to.equal(3600); - expect(getSignedUrlStub.calledOnce).to.equal(true); - }); - - it('returns 400 when the site has no Edge Optimize API key', async () => { - mockTokowakaClient.fetchMetaconfig.resolves({ apiKeys: [] }); - - const result = await controller.getEdgeOptimizeInstallerUrl(installerContext); - - expect(result.status).to.equal(400); - const body = await result.json(); - expect(body.message).to.include('no Edge Optimize API key'); - }); - - it('returns 400 when template hosting is not configured (no S3 client)', async () => { - const result = await controller.getEdgeOptimizeInstallerUrl({ ...installerContext, s3: {} }); - - expect(result.status).to.equal(400); - const body = await result.json(); - expect(body.message).to.include('not configured'); - }); - - it('returns 404 when the site is not found', async () => { - mockDataAccess.Site.findById.resolves(null); - - const result = await controller.getEdgeOptimizeInstallerUrl(installerContext); - - expect(result.status).to.equal(404); - }); - - it('returns 403 when the user lacks access to the site', async () => { - const deniedController = controllerWithAccessDenied(mockContext); - - const result = await deniedController.getEdgeOptimizeInstallerUrl(installerContext); - - expect(result.status).to.equal(403); - }); - - it('returns 403 when the user is not an LLMO administrator', async () => { - const LlmoControllerNoAdmin = await esmock('../../../src/controllers/llmo/llmo.js', { - '../../../src/support/access-control-util.js': createMockAccessControlUtil(true, true, false), - '@adobe/spacecat-shared-http-utils': mockHttpUtils, - '../../../src/support/cached-response.js': mockCachedResponse, - ...getCommonMocks(), - }); - - const result = await LlmoControllerNoAdmin(mockContext) - .getEdgeOptimizeInstallerUrl(installerContext); - - expect(result.status).to.equal(403); - }); - }); - describe('connectEdgeOptimize', () => { let connectContext; diff --git a/test/routes/index.test.js b/test/routes/index.test.js index df1c37fe60..73655370a0 100755 --- a/test/routes/index.test.js +++ b/test/routes/index.test.js @@ -343,7 +343,6 @@ describe('getRouteHandlers', () => { deployEdgeOptimize: () => null, planEdgeOptimize: () => null, getEdgeOptimizePermissions: () => null, - getEdgeOptimizeInstallerUrl: () => null, getEdgeConfig: () => null, createOrUpdateStageEdgeConfig: () => null, checkEdgeOptimizeStatus: () => null, @@ -1116,7 +1115,6 @@ describe('getRouteHandlers', () => { 'POST /sites/:siteId/llmo/edge-optimize/deploy', 'POST /sites/:siteId/llmo/edge-optimize/plan', 'GET /sites/:siteId/llmo/edge-optimize/permissions', - 'GET /sites/:siteId/llmo/edge-optimize/installer-url', 'GET /sites/:siteId/llmo/edge-optimize-status', 'GET /sites/:siteId/llmo/probes/edge-optimize', 'GET /sites/:siteId/llmo/strategy', From d316189c6c60c538fceb99195974c4e109608d60 Mon Sep 17 00:00:00 2001 From: Akash Bhardwaj Date: Wed, 24 Jun 2026 22:31:54 +0530 Subject: [PATCH 34/56] fix(edge-optimize): plan shows cache copy as 'update' when created but not associated MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the per-distribution cache-policy copy (…-adobe-) already exists from a prior run but the behavior is still on the AWS-managed policy, the plan now marks the cache step 'update' (not 'exists') and spells out both names: the current managed policy and the existing copy the behavior will be switched to. Previously it read 'Already cloned …', which looked done even though the behavior was not yet associated with the copy. Deploy path was already correct (reuses copy + repoints). Co-Authored-By: Claude Opus 4.8 --- src/support/edge-optimize.js | 10 ++++++++-- test/support/edge-optimize.test.js | 10 +++++++--- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/src/support/edge-optimize.js b/src/support/edge-optimize.js index d1aa545d9e..cd7fa0f66b 100644 --- a/src/support/edge-optimize.js +++ b/src/support/edge-optimize.js @@ -1514,8 +1514,14 @@ export async function planEdgeOptimizeDeploy( (i) => i.CachePolicy.CachePolicyConfig.Name === clonedName, ); if (cloneExists) { - byKey('cache').action = 'exists'; - byKey('cache').detail = `Current policy: ${sourceName}. Already cloned to ${clonedName}.`; + // The copy exists from a prior run, but the behavior is still on the AWS-managed policy + // (if it were already on the copy we'd be in the custom branch above) — created but not + // associated. The deploy will switch the behavior to the copy, so this is an 'update', + // not a no-op. Surface both names + that it isn't associated yet. + byKey('cache').action = 'update'; + byKey('cache').detail = `Current policy: ${sourceName} (AWS-managed). A copy ` + + `"${clonedName}" already exists but is not associated with this behavior ` + + `yet — the behavior will be switched to it.${ttlNote(srcConfig.MinTTL)}`; } else { byKey('cache').action = 'create'; byKey('cache').detail = `Current policy: ${sourceName} (AWS-managed, can't be edited). A copy will be created: ${clonedName}.${ttlNote(srcConfig.MinTTL)}`; diff --git a/test/support/edge-optimize.test.js b/test/support/edge-optimize.test.js index 3cd0054e41..a734e65512 100644 --- a/test/support/edge-optimize.test.js +++ b/test/support/edge-optimize.test.js @@ -1652,7 +1652,7 @@ describe('edge-optimize support', () => { expect(result.canProceed).to.equal(true); }); - it('marks the managed cache step "exists" when the per-dist clone already exists', async () => { + it('marks the managed cache step "update" when the clone exists but the behavior is not associated with it', async () => { wire( { GetDistributionConfig: { @@ -1674,8 +1674,12 @@ describe('edge-optimize support', () => { ); const result = await edgeOptimize.planEdgeOptimizeDeploy({}, planParams); - expect(stepOf(result.steps, 'cache').action).to.equal('exists'); - expect(stepOf(result.steps, 'cache').detail).to.include('Already cloned'); + // The clone exists but the behavior is still on the managed policy → the deploy will switch + // the behavior to the existing copy, so this is an 'update' with a clear created-but-not- + // associated message that names both the current policy and the copy. + expect(stepOf(result.steps, 'cache').action).to.equal('update'); + expect(stepOf(result.steps, 'cache').detail).to.include('not associated'); + expect(stepOf(result.steps, 'cache').detail).to.include('CachingOptimized-adobe-E2EXAMPLE123'); }); it('marks function + lambda + origin "exists" when already present', async () => { From 20148627173e8b3ec5c3a25cfb3513d80f8aecb2 Mon Sep 17 00:00:00 2001 From: Akash Bhardwaj Date: Wed, 24 Jun 2026 23:43:32 +0530 Subject: [PATCH 35/56] fix(edge-optimize): make Lambda@Edge EO origin domain env-driven (dev/prod split) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Lambda@Edge handler hardcoded the EO origin domain (live.edgeoptimize.net) in its failover check, so pointing the EO origin at a non-prod domain would make every agentic request fail over. EDGE_OPTIMIZE_LAMBDA_CODE is now buildEdgeOptimizeLambdaCode (originDomain), and createEdgeOptimizeLambda bakes in the same originDomain used for the EO origin's DomainName (env EDGE_OPTIMIZE_ORIGIN_DOMAIN; defaults to live, so this is a no-op until that var is set per env — e.g. dev.edgeoptimize.net on dev). Co-Authored-By: Claude Opus 4.8 --- src/support/edge-optimize-edge-code.js | 22 +++++++++++++++++----- src/support/edge-optimize.js | 15 ++++++++++----- test/support/edge-optimize.test.js | 11 +++++++++++ 3 files changed, 38 insertions(+), 10 deletions(-) diff --git a/src/support/edge-optimize-edge-code.js b/src/support/edge-optimize-edge-code.js index a1fa3fddfe..9c90fe3db1 100644 --- a/src/support/edge-optimize-edge-code.js +++ b/src/support/edge-optimize-edge-code.js @@ -83,10 +83,21 @@ function handler(event) { }`; } -// The Lambda@Edge origin-request/response handler, ported verbatim from the standalone wizard's -// templates/origin-request-response.js. Kept as an inline JS module string (not a sibling-file -// read) so the helix-deploy bundle preserves it — see CLAUDE.md "Lambda Bundle Constraints". -export const EDGE_OPTIMIZE_LAMBDA_CODE = `function hasHeader(map, name) { +/** + * Build the Lambda@Edge origin-request/response handler. Ported from the standalone wizard's + * templates/origin-request-response.js. Returned as an inline JS module string (not a sibling-file + * read) so the helix-deploy bundle preserves it — see CLAUDE.md "Lambda Bundle Constraints". + * + * The Edge Optimize origin domain is injected so the same handler works per environment + * (e.g. dev.edgeoptimize.net on dev, live.edgeoptimize.net on prod): the origin-request branch + * routes to the EO origin only when CloudFront's current origin matches this domain — otherwise it + * marks the request as a failover. It MUST be the same value used as the EO origin's DomainName. + * + * @param {string} eoOriginDomain - the Edge Optimize origin domain baked into the routing check. + * @returns {string} the Lambda@Edge function source code. + */ +export function buildEdgeOptimizeLambdaCode(eoOriginDomain) { + return `function hasHeader(map, name) { const h = map?.[name]; return Array.isArray(h) && h.length > 0 && (h[0].value || '').trim() !== ''; } @@ -109,7 +120,7 @@ export const handler = async (event) => { const isEdgeOptimizeRequest = hasHeader(reqHeaders, 'x-edgeoptimize-request'); if (isEdgeOptimizeConfig && !isEdgeOptimizeRequest) { - if (originDomain === 'live.edgeoptimize.net') { + if (originDomain === '${eoOriginDomain}') { console.log("Calling Edge Optimize Origin for agentic requests"); setHeader(request.headers, 'host', originDomain); } else { @@ -135,3 +146,4 @@ export const handler = async (event) => { } }; `; +} diff --git a/src/support/edge-optimize.js b/src/support/edge-optimize.js index cd7fa0f66b..d486629e20 100644 --- a/src/support/edge-optimize.js +++ b/src/support/edge-optimize.js @@ -46,9 +46,9 @@ import { cleanupHeaderValue } from '@adobe/helix-shared-utils'; // Edge runtime code (Lambda@Edge handler + CloudFront routing function) lives in its own // module for readability; imported for use here and re-exported to keep the public surface. -import { EDGE_OPTIMIZE_LAMBDA_CODE, buildRoutingFunctionCode } from './edge-optimize-edge-code.js'; +import { buildEdgeOptimizeLambdaCode, buildRoutingFunctionCode } from './edge-optimize-edge-code.js'; -export { EDGE_OPTIMIZE_LAMBDA_CODE, buildRoutingFunctionCode }; +export { buildEdgeOptimizeLambdaCode, buildRoutingFunctionCode }; // CloudFront is a global service; its control plane lives in us-east-1. export const EDGE_OPTIMIZE_REGION = 'us-east-1'; @@ -732,7 +732,11 @@ export async function createEdgeOptimizeLambda( credentials, accountId, { - region = EDGE_OPTIMIZE_REGION, distributionId, roleWaitMs = 12000, retryDelayMs = 5000, + region = EDGE_OPTIMIZE_REGION, + distributionId, + originDomain = EDGE_OPTIMIZE_DEFAULT_ORIGIN_DOMAIN, + roleWaitMs = 12000, + retryDelayMs = 5000, } = {}, ) { if (!/^[0-9]{12}$/.test(String(accountId))) { @@ -746,7 +750,8 @@ export async function createEdgeOptimizeLambda( const lambda = new LambdaClient({ region, credentials }); const iam = new IAMClient({ region, credentials }); - const zipBuffer = buildLambdaZip('index.mjs', EDGE_OPTIMIZE_LAMBDA_CODE); + // Bake the EO origin domain into the handler so it matches the EO origin's DomainName per env. + const zipBuffer = buildLambdaZip('index.mjs', buildEdgeOptimizeLambdaCode(originDomain)); // ── 1. Ensure the exec role exists with the current trust policy. ── let roleArn; @@ -1231,7 +1236,7 @@ export async function runEdgeOptimizeDeployStep( const created = await createEdgeOptimizeLambda( credentials, accountId, - { region, distributionId }, + { region, distributionId, originDomain }, ); if (created.status === 'ready') { lambdaVersionArn = created.versionArn; diff --git a/test/support/edge-optimize.test.js b/test/support/edge-optimize.test.js index a734e65512..11ce605cdd 100644 --- a/test/support/edge-optimize.test.js +++ b/test/support/edge-optimize.test.js @@ -450,6 +450,17 @@ describe('edge-optimize support', () => { }); }); + describe('buildEdgeOptimizeLambdaCode', () => { + it('bakes the EO origin domain into the routing check (per environment)', () => { + const dev = edgeOptimize.buildEdgeOptimizeLambdaCode('dev.edgeoptimize.net'); + expect(dev).to.include("originDomain === 'dev.edgeoptimize.net'"); + expect(dev).to.not.include("originDomain === 'live.edgeoptimize.net'"); + + const prod = edgeOptimize.buildEdgeOptimizeLambdaCode('live.edgeoptimize.net'); + expect(prod).to.include("originDomain === 'live.edgeoptimize.net'"); + }); + }); + describe('createEdgeOptimizeRoutingFunction', () => { it('creates and publishes a new function when none exists', async () => { cfSendStub.onFirstCall().rejects(Object.assign(new Error('not found'), { name: 'NoSuchFunctionExists' })); From a226232d6b82cafc11c4deeb0fd427bd309ccc04 Mon Sep 17 00:00:00 2001 From: Akash Bhardwaj Date: Thu, 25 Jun 2026 08:54:51 +0530 Subject: [PATCH 36/56] fix(edge-optimize): preserve customer edge associations + surface role status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Association (data safety): applyEdgeOptimizeAssociations no longer replaces the behavior's FunctionAssociations / LambdaFunctionAssociations wholesale — it MERGES, preserving every association on event types EO does not own (e.g. a viewer-response function or lambda) and (re)setting only EO's own slots (viewer-request function + origin-request/origin-response lambda). A non-EO association already on a slot EO needs is now REFUSED with a clear message instead of silently clobbered; the plan surfaces the same conflict so Review blocks before deploy. Role visibility: the plan's Lambda@Edge step now reports the execution role — it inspects an existing role (trust = lambda + edgelambda + the logs inline policy) and says whether it is correctly configured and will be reused, will be created, or will be corrected (the deploy already conforms it). Previously Review said nothing about the role even when one existed from a prior partial run. 68 support tests pass. Co-Authored-By: Claude Opus 4.8 --- src/support/edge-optimize.js | 193 +++++++++++++++++++++++++---- test/support/edge-optimize.test.js | 78 +++++++++++- 2 files changed, 243 insertions(+), 28 deletions(-) diff --git a/src/support/edge-optimize.js b/src/support/edge-optimize.js index d486629e20..02c03acbd3 100644 --- a/src/support/edge-optimize.js +++ b/src/support/edge-optimize.js @@ -31,6 +31,7 @@ import { IAMClient, CreateRoleCommand, GetRoleCommand, + GetRolePolicyCommand, PutRolePolicyCommand, UpdateAssumeRolePolicyCommand, } from '@aws-sdk/client-iam'; @@ -947,6 +948,110 @@ export async function getEdgeOptimizeLambdaStatus( }; } +/** + * Read-only inspection of the Lambda@Edge execution role so the Review screen can say whether an + * existing role (e.g. left by a prior partial run) is the right one. The deploy ALWAYS conforms the + * role to the required trust (lambda + edgelambda) and CloudWatch-logs policy, so this only drives + * the wording — it never changes behavior. + * + * @param {IAMClient} iam - an IAM client built with the connector credentials. + * @param {string} roleName - the EO Lambda@Edge execution role name. + * @returns {Promise<{exists: boolean, trustOk?: boolean, logsPolicyOk?: boolean}>} + */ +async function inspectEdgeOptimizeLambdaRole(iam, roleName) { + let role; + try { + const res = await iam.send(new GetRoleCommand({ RoleName: roleName })); + role = res.Role; + } catch (err) { + if (err.name === 'NoSuchEntityException') { + return { exists: false }; + } + throw err; + } + + // Trust must allow both lambda.amazonaws.com and edgelambda.amazonaws.com (Lambda@Edge). + let trustOk = false; + const rawTrust = role.AssumeRolePolicyDocument || ''; + if (rawTrust) { + let doc = null; + try { + doc = JSON.parse(decodeURIComponent(rawTrust)); + } catch { + doc = null; + } + const services = ((doc && doc.Statement) || []).flatMap((st) => { + const svc = st.Principal && st.Principal.Service; + return Array.isArray(svc) ? svc : [svc]; + }).filter(Boolean); + trustOk = services.includes('lambda.amazonaws.com') + && services.includes('edgelambda.amazonaws.com'); + } + + // The CloudWatch-logs inline policy the deploy attaches. + let logsPolicyOk = false; + try { + await iam.send(new GetRolePolicyCommand({ + RoleName: roleName, + PolicyName: 'EdgeOptimizeLambdaLogging', + })); + logsPolicyOk = true; + } catch (err) { + if (err.name !== 'NoSuchEntityException') { + throw err; + } + } + + return { exists: true, trustOk, logsPolicyOk }; +} + +// Edge Optimize owns exactly these association slots on a behavior; every other association is the +// customer's and must be preserved. A non-EO association ON one of these slots is a conflict we +// refuse (rather than overwrite), so customer edge logic is never silently removed. +const EDGE_OPTIMIZE_LAMBDA_EVENTS = ['origin-request', 'origin-response']; +const isEdgeOptimizeFunctionArn = (arn) => /edgeoptimize-routing/i.test(arn || ''); +const isEdgeOptimizeLambdaArn = (arn) => /edgeoptimize-origin/i.test(arn || ''); + +/** + * Inspect a behavior's existing associations and return a conflict message when a NON-Edge-Optimize + * association occupies a slot EO needs (a different viewer-request function, a viewer-request + * Lambda@Edge that CloudFront forbids alongside a function, or an origin-request/origin-response + * Lambda@Edge). Returns null when EO can be wired in while preserving everything else. EO's own + * prior associations (matched by name) are never flagged, so re-deploys stay idempotent. + * + * @param {object} behavior - the cache behavior config. + * @param {string} pathPattern - the behavior label (for the message). + * @returns {string|null} + */ +function findEdgeOptimizeAssociationConflict(behavior, pathPattern) { + const fns = behavior?.FunctionAssociations?.Items || []; + const lambdas = behavior?.LambdaFunctionAssociations?.Items || []; + + const viewerFn = fns.find( + (a) => a.EventType === 'viewer-request' && !isEdgeOptimizeFunctionArn(a.FunctionARN), + ); + if (viewerFn) { + return `Behavior '${pathPattern}' already has a different viewer-request function associated ` + + `(${viewerFn.FunctionARN}). Remove it before applying Edge Optimize routing.`; + } + const viewerLambda = lambdas.find((a) => a.EventType === 'viewer-request'); + if (viewerLambda) { + return `Behavior '${pathPattern}' already has a viewer-request Lambda@Edge ` + + `(${viewerLambda.LambdaFunctionARN}) which conflicts with the Edge Optimize routing ` + + 'function. Remove it before applying Edge Optimize routing.'; + } + const originLambda = lambdas.find( + (a) => EDGE_OPTIMIZE_LAMBDA_EVENTS.includes(a.EventType) + && !isEdgeOptimizeLambdaArn(a.LambdaFunctionARN), + ); + if (originLambda) { + return `Behavior '${pathPattern}' already has a different ${originLambda.EventType} ` + + `Lambda@Edge associated (${originLambda.LambdaFunctionARN}). Remove it before applying ` + + 'Edge Optimize routing.'; + } + return null; +} + /** * Wire the routing CloudFront Function (viewer-request) and the Lambda@Edge function * (origin-request + origin-response) onto a selected cache behavior. Mirrors the standalone @@ -991,27 +1096,31 @@ export async function applyEdgeOptimizeAssociations( const config = distResult.DistributionConfig; const behavior = getBehaviorFromConfig(config, pathPattern); - // Surface a conflicting viewer-request association rather than silently clobbering it. - const existingViewerFns = (behavior.FunctionAssociations?.Items || []) - .filter((a) => a.EventType === 'viewer-request' && a.FunctionARN !== cfFunctionArn); - if (existingViewerFns.length > 0) { - throw new Error( - `Behavior '${pathPattern}' already has a different viewer-request function associated ` - + `(${existingViewerFns[0].FunctionARN}). Remove it before applying Edge Optimize routing.`, - ); + // Refuse (rather than silently clobber) if the customer already owns a slot EO needs. + const conflict = findEdgeOptimizeAssociationConflict(behavior, pathPattern); + if (conflict) { + throw new Error(conflict); } - behavior.FunctionAssociations = { - Quantity: 1, - Items: [{ FunctionARN: cfFunctionArn, EventType: 'viewer-request' }], - }; - behavior.LambdaFunctionAssociations = { - Quantity: 2, - Items: [ - { LambdaFunctionARN: lambdaVersionArn, EventType: 'origin-request', IncludeBody: false }, - { LambdaFunctionARN: lambdaVersionArn, EventType: 'origin-response', IncludeBody: false }, - ], - }; + // Merge, don't replace: preserve every association on event types EO does NOT own (e.g. a + // viewer-response function, a viewer-response lambda) and (re)set ONLY EO's own slots — + // viewer-request (function) + origin-request/origin-response (lambda). Wholesale replacement here + // would drop the customer's edge logic. + const existingFns = behavior.FunctionAssociations?.Items || []; + const existingLambdas = behavior.LambdaFunctionAssociations?.Items || []; + const mergedFns = [ + ...existingFns.filter((a) => a.EventType !== 'viewer-request'), + { FunctionARN: cfFunctionArn, EventType: 'viewer-request' }, + ]; + const mergedLambdas = [ + ...existingLambdas.filter( + (a) => a.EventType !== 'viewer-request' && !EDGE_OPTIMIZE_LAMBDA_EVENTS.includes(a.EventType), + ), + { LambdaFunctionARN: lambdaVersionArn, EventType: 'origin-request', IncludeBody: false }, + { LambdaFunctionARN: lambdaVersionArn, EventType: 'origin-response', IncludeBody: false }, + ]; + behavior.FunctionAssociations = { Quantity: mergedFns.length, Items: mergedFns }; + behavior.LambdaFunctionAssociations = { Quantity: mergedLambdas.length, Items: mergedLambdas }; await client.send(new UpdateDistributionCommand({ Id: distributionId, @@ -1541,31 +1650,61 @@ export async function planEdgeOptimizeDeploy( // ── lambda ────────────────────────────────────────────────────────────── // 'exists' when the Lambda@Edge function exists (ready or still provisioning); else 'create'. + // Also surface the execution role: a role with our name may already exist from a prior partial + // run. We say whether it is correctly configured — the deploy ALWAYS conforms it to the required + // trust (lambda + edgelambda) + logs policy, so a mismatch is auto-corrected, not a blocker. try { - const ls = await getEdgeOptimizeLambdaStatus(credentials, distributionId, region); + const iam = new IAMClient({ region, credentials }); + const roleName = eoLambdaRoleName(distributionId); + const [ls, role] = await Promise.all([ + getEdgeOptimizeLambdaStatus(credentials, distributionId, region), + inspectEdgeOptimizeLambdaRole(iam, roleName), + ]); + let roleNote; + if (!role.exists) { + roleNote = ` Execution role ${roleName} will be created.`; + } else if (role.trustOk && role.logsPolicyOk) { + roleNote = ` Execution role ${roleName} already exists and is correctly configured ` + + '(trust + logs) — it will be reused.'; + } else { + roleNote = ` Execution role ${roleName} already exists but is not correctly configured ` + + '— the deploy will correct its trust + logs policy.'; + } if (ls.exists) { byKey('lambda').action = 'exists'; - byKey('lambda').detail = ls.ready - ? `Lambda@Edge ${eoLambdaFunctionName(distributionId)} already published` - : `Lambda@Edge ${eoLambdaFunctionName(distributionId)} exists (still provisioning)`; + byKey('lambda').detail = (ls.ready + ? `Lambda@Edge ${eoLambdaFunctionName(distributionId)} already published.` + : `Lambda@Edge ${eoLambdaFunctionName(distributionId)} exists (still provisioning).`) + + roleNote; } else { - byKey('lambda').detail = `create Lambda@Edge ${eoLambdaFunctionName(distributionId)}`; + byKey('lambda').detail = `create Lambda@Edge ${eoLambdaFunctionName(distributionId)}.${roleNote}`; } } catch (err) { byKey('lambda').detail = `could not read Lambda@Edge status: ${err.message}`; } // ── associate ─────────────────────────────────────────────────────────── - // HARD BLOCK: if the behavior is already associated with EO routes, the automation refuses to - // proceed (it would clobber the customer's existing wiring). This is the only blocker. + // HARD BLOCK in two cases: (1) the behavior is already EO-associated (nothing to do), or (2) the + // customer already owns a slot EO needs (a different viewer-request function, a viewer-request + // lambda, or an origin-request/response lambda) — we refuse rather than remove their edge logic. + // Otherwise EO is merged in, preserving every other association on the behavior. try { + const assocBehavior = config ? getBehaviorFromConfig(config, behavior) : null; + const assocConflict = assocBehavior + ? findEdgeOptimizeAssociationConflict(assocBehavior, behavior) : null; if (await isBehaviorAlreadyAssociated(client, distributionId, behavior)) { byKey('associate').action = 'blocked'; byKey('associate').detail = 'this behaviour is already associated with Edge Optimize routes'; canProceed = false; blocker = "This behaviour is already associated with routes, please recheck — can't proceed with this automation."; + } else if (assocConflict) { + byKey('associate').action = 'blocked'; + byKey('associate').detail = assocConflict; + canProceed = false; + blocker = assocConflict; } else { - byKey('associate').detail = 'will associate routing function + Lambda@Edge to the behavior'; + byKey('associate').detail = 'will add the routing function + Lambda@Edge, ' + + 'preserving your other associations on this behavior'; } } catch (err) { byKey('associate').detail = `could not read behavior associations: ${err.message}`; diff --git a/test/support/edge-optimize.test.js b/test/support/edge-optimize.test.js index 11ce605cdd..33d1ae8a2f 100644 --- a/test/support/edge-optimize.test.js +++ b/test/support/edge-optimize.test.js @@ -79,6 +79,7 @@ describe('edge-optimize support', () => { }, CreateRoleCommand: iamCommand('CreateRole'), GetRoleCommand: iamCommand('GetRole'), + GetRolePolicyCommand: iamCommand('GetRolePolicy'), PutRolePolicyCommand: iamCommand('PutRolePolicy'), UpdateAssumeRolePolicyCommand: iamCommand('UpdateAssumeRolePolicy'), }, @@ -969,6 +970,65 @@ describe('edge-optimize support', () => { expect(error.message).to.include('already has a different viewer-request function'); }); + it('preserves the customer\'s other-slot associations (merge, not wholesale replace)', async () => { + cfSendStub.onFirstCall().resolves({ + FunctionSummary: { FunctionMetadata: { FunctionARN: 'arn:cf-fn' } }, + }); + cfSendStub.onSecondCall().resolves({ + DistributionConfig: { + DefaultCacheBehavior: { + FunctionAssociations: { + Quantity: 1, + Items: [{ EventType: 'viewer-response', FunctionARN: 'arn:cust-fn' }], + }, + LambdaFunctionAssociations: { + Quantity: 1, + Items: [{ EventType: 'viewer-response', LambdaFunctionARN: 'arn:cust-lambda', IncludeBody: false }], + }, + }, + }, + ETag: 'dist-etag', + }); + cfSendStub.onThirdCall().resolves({}); + + await edgeOptimize.applyEdgeOptimizeAssociations({}, 'E2EXAMPLE', 'default', lambdaArn); + const behavior = cfSendStub.thirdCall.args[0].input.DistributionConfig.DefaultCacheBehavior; + // Customer's viewer-response function is preserved; EO's viewer-request function is added. + expect(behavior.FunctionAssociations.Items) + .to.deep.include({ EventType: 'viewer-response', FunctionARN: 'arn:cust-fn' }); + expect(behavior.FunctionAssociations.Items) + .to.deep.include({ FunctionARN: 'arn:cf-fn', EventType: 'viewer-request' }); + // Customer's viewer-response lambda is preserved; EO's origin-request/response are added. + const lambdaEvents = behavior.LambdaFunctionAssociations.Items.map((i) => i.EventType); + expect(lambdaEvents).to.include.members(['viewer-response', 'origin-request', 'origin-response']); + expect(behavior.LambdaFunctionAssociations.Items + .find((i) => i.EventType === 'viewer-response').LambdaFunctionARN).to.equal('arn:cust-lambda'); + }); + + it('refuses to overwrite a customer origin-request Lambda@Edge', async () => { + cfSendStub.onFirstCall().resolves({ + FunctionSummary: { FunctionMetadata: { FunctionARN: 'arn:cf-fn' } }, + }); + cfSendStub.onSecondCall().resolves({ + DistributionConfig: { + DefaultCacheBehavior: { + LambdaFunctionAssociations: { + Items: [{ EventType: 'origin-request', LambdaFunctionARN: 'arn:cust-origin-lambda' }], + }, + }, + }, + ETag: 'dist-etag', + }); + let error; + try { + await edgeOptimize.applyEdgeOptimizeAssociations({}, 'E2EXAMPLE', 'default', lambdaArn); + } catch (e) { + error = e; + } + expect(error.message).to.include('different origin-request'); + expect(cfSendStub.thirdCall).to.equal(null); // never issued an UpdateDistribution + }); + it('throws when lambdaVersionArn is missing', async () => { let error; try { @@ -1733,7 +1793,20 @@ describe('edge-optimize support', () => { ListVersionsByFunction: { Versions: [{ Version: '3', FunctionArn: 'arn:lambda:3', CodeSha256: 'sha' }] }, }, { - GetRole: { Role: { Arn: 'arn:role' } }, + GetRole: { + Role: { + Arn: 'arn:role', + AssumeRolePolicyDocument: encodeURIComponent(JSON.stringify({ + Version: '2012-10-17', + Statement: [{ + Effect: 'Allow', + Principal: { Service: ['lambda.amazonaws.com', 'edgelambda.amazonaws.com'] }, + Action: 'sts:AssumeRole', + }], + })), + }, + }, + GetRolePolicy: { PolicyName: 'EdgeOptimizeLambdaLogging', PolicyDocument: '{}' }, }, ); @@ -1744,6 +1817,9 @@ describe('edge-optimize support', () => { expect(stepOf(result.steps, 'cache').action).to.equal('update'); expect(stepOf(result.steps, 'cache').detail).to.include('my-custom-policy'); expect(stepOf(result.steps, 'lambda').action).to.equal('exists'); + // Role visibility: an existing, correctly-configured execution role is surfaced + reused. + expect(stepOf(result.steps, 'lambda').detail).to.include('Execution role'); + expect(stepOf(result.steps, 'lambda').detail).to.include('correctly configured'); expect(stepOf(result.steps, 'associate').action).to.equal('create'); expect(result.canProceed).to.equal(true); }); From 83dc3e6a705b8f892389c87f3c171eb484b6cd68 Mon Sep 17 00:00:00 2001 From: Akash Bhardwaj Date: Thu, 25 Jun 2026 09:06:00 +0530 Subject: [PATCH 37/56] docs(edge-optimize): clarify cache-clone match is exact -adobe- MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No behavior change. The plan + deploy already match the existing clone by its FULL derived name (Managed- stripped, then -adobe-), not by suffix — so if the customer re-points the behavior to a different source policy, we create a clone matching the CURRENT source rather than reusing one built from a different base. Documents the decision to prevent a regression to suffix matching. Co-Authored-By: Claude Opus 4.8 --- src/support/edge-optimize.js | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/support/edge-optimize.js b/src/support/edge-optimize.js index 02c03acbd3..9e4c15611d 100644 --- a/src/support/edge-optimize.js +++ b/src/support/edge-optimize.js @@ -575,7 +575,10 @@ export async function applyEdgeOptimizeCacheHeaders( addEoHeaders(clonedParams); cloned.ParametersInCacheKeyAndForwardedToOrigin = clonedParams; - // Idempotent: reuse an existing edgeoptimize-cache custom policy if a prior run created it. + // Idempotent: reuse an existing clone only when it matches the FULL derived name (exact) + // -adobe-. If the customer re-pointed the behavior to a + // different source since a prior run, the derived name differs, so we create a clone matching the + // CURRENT source instead of reusing a clone built from a different base. const customList = await client.send(new ListCachePoliciesCommand({ Type: 'custom' })); const existing = (customList.CachePolicyList?.Items || []).find( (i) => i.CachePolicy.CachePolicyConfig.Name === clonedName, @@ -1623,6 +1626,9 @@ export async function planEdgeOptimizeDeploy( const srcConfig = srcResult.CachePolicy?.CachePolicyConfig || {}; const sourceName = srcConfig.Name || 'cache'; const clonedName = buildEoClonedCachePolicyName(sourceName, distributionId); + // Match the FULL derived name (exact): -adobe- — not + // just the suffix. If the customer re-pointed the behavior to a different source, a clone + // with a different prefix is NOT a match, so the deploy creates one for the current source. const customList = await client.send(new ListCachePoliciesCommand({ Type: 'custom' })); const cloneExists = (customList.CachePolicyList?.Items || []).some( (i) => i.CachePolicy.CachePolicyConfig.Name === clonedName, From 610a545278c34f9b65766ddafd3878a12f5b9054 Mon Sep 17 00:00:00 2001 From: Akash Bhardwaj Date: Thu, 25 Jun 2026 09:34:23 +0530 Subject: [PATCH 38/56] fix(edge-optimize): heal missing/mis-configured Lambda@Edge role even when function is ready MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The deploy step gated the lambda step on `ls.ready` only, so a published function with a missing OR mis-configured execution role was marked done without recreating/ healing the role — contradicting the plan's 'role will be created/corrected' message. getEdgeOptimizeLambdaStatus now also reports roleOk (exists + trust(lambda+edgelambda) + EdgeOptimizeLambdaLogging policy) by reusing inspectEdgeOptimizeLambdaRole, and the gate is now `ls.ready && ls.roleOk`. When the role is missing/mis-configured it falls through to createEdgeOptimizeLambda, which (re)creates the role + heals its trust/logs policy; the function already exists so it reuses the published version and returns ready. The plan derives its role note from roleExists/roleOk (no extra IAM read). Tests: getStatus roleOk; deploy-step gate rows — ready+role-missing → recreated, ready+role-misconfigured → healed, ready+role-ok → no role writes (no churn). 71 pass. Co-Authored-By: Claude Opus 4.8 --- src/support/edge-optimize.js | 148 ++++++++++++++------------- test/support/edge-optimize.test.js | 159 ++++++++++++++++++++++++++--- 2 files changed, 218 insertions(+), 89 deletions(-) diff --git a/src/support/edge-optimize.js b/src/support/edge-optimize.js index 9e4c15611d..8a6171273c 100644 --- a/src/support/edge-optimize.js +++ b/src/support/edge-optimize.js @@ -891,71 +891,9 @@ export async function createEdgeOptimizeLambda( } /** - * Read-only status of the Edge Optimize Lambda@Edge function so the wizard can check on entry - * (and poll after a slow/timed-out create) whether it already exists and has a published version. - * - * @param {object} credentials - temporary credentials from {@link assumeConnectorRole}. - * @param {string} [region] - control-plane region. - * @returns {Promise<{exists: boolean, state?: string, lastUpdateStatus?: string, - * functionArn?: string, versionArn: string|null, version?: string}>} - */ -export async function getEdgeOptimizeLambdaStatus( - credentials, - distributionId, - region = EDGE_OPTIMIZE_REGION, -) { - if (!hasText(distributionId)) { - throw new Error('distributionId is required'); - } - const lambdaName = eoLambdaFunctionName(distributionId); - const roleName = eoLambdaRoleName(distributionId); - const lambda = new LambdaClient({ region, credentials }); - const iam = new IAMClient({ region, credentials }); - - // Execution role status (created synchronously by create-lambda's ack call). - let roleExists = false; - try { - await iam.send(new GetRoleCommand({ RoleName: roleName })); - roleExists = true; - } catch (err) { - if (err.name !== 'NoSuchEntityException') { - throw err; - } - } - - // Function status. - let cfg; - try { - cfg = await lambda.send( - new GetFunctionConfigurationCommand({ FunctionName: lambdaName }), - ); - } catch (err) { - if (err.name === 'ResourceNotFoundException') { - return { - roleExists, exists: false, versionArn: null, ready: false, - }; - } - throw err; - } - const latest = await getLatestLambdaVersion(lambda, lambdaName); - const ready = cfg.State === 'Active' && cfg.LastUpdateStatus !== 'InProgress' && !!latest; - return { - roleExists, - exists: true, - state: cfg.State, - lastUpdateStatus: cfg.LastUpdateStatus, - functionArn: cfg.FunctionArn, - versionArn: latest?.versionArn || null, - version: latest?.version, - ready, - }; -} - -/** - * Read-only inspection of the Lambda@Edge execution role so the Review screen can say whether an - * existing role (e.g. left by a prior partial run) is the right one. The deploy ALWAYS conforms the - * role to the required trust (lambda + edgelambda) and CloudWatch-logs policy, so this only drives - * the wording — it never changes behavior. + * Read-only inspection of the Lambda@Edge execution role: whether it exists and is correctly + * configured. Checks the trust policy (must allow both lambda + edgelambda) and the CloudWatch-logs + * inline policy the deploy attaches. Drives both the Review wording and the deploy's heal decision. * * @param {IAMClient} iam - an IAM client built with the connector credentials. * @param {string} roleName - the EO Lambda@Edge execution role name. @@ -1008,6 +946,68 @@ async function inspectEdgeOptimizeLambdaRole(iam, roleName) { return { exists: true, trustOk, logsPolicyOk }; } +/** + * Read-only status of the Edge Optimize Lambda@Edge function AND its execution role, so the wizard + * can check on entry (and poll after a slow/timed-out create) whether the function exists and has a + * published version, and whether the role is present (`roleExists`) and correctly configured + * (`roleOk` = exists + trust + logs policy). `roleOk` lets the deploy heal a missing or + * mis-configured role even when the function is already published. + * + * @param {object} credentials - temporary credentials from {@link assumeConnectorRole}. + * @param {string} [region] - control-plane region. + * @returns {Promise<{exists: boolean, roleExists: boolean, roleOk: boolean, state?: string, + * lastUpdateStatus?: string, functionArn?: string, versionArn: string|null, version?: string, + * ready: boolean}>} + */ +export async function getEdgeOptimizeLambdaStatus( + credentials, + distributionId, + region = EDGE_OPTIMIZE_REGION, +) { + if (!hasText(distributionId)) { + throw new Error('distributionId is required'); + } + const lambdaName = eoLambdaFunctionName(distributionId); + const roleName = eoLambdaRoleName(distributionId); + const lambda = new LambdaClient({ region, credentials }); + const iam = new IAMClient({ region, credentials }); + + // Execution role status: present AND correctly configured (trust + logs policy). roleOk gates the + // deploy's "done" decision so a missing OR mis-configured role is healed even when the function + // is already published. + const role = await inspectEdgeOptimizeLambdaRole(iam, roleName); + const roleExists = Boolean(role.exists); + const roleOk = Boolean(role.exists && role.trustOk && role.logsPolicyOk); + + // Function status. + let cfg; + try { + cfg = await lambda.send( + new GetFunctionConfigurationCommand({ FunctionName: lambdaName }), + ); + } catch (err) { + if (err.name === 'ResourceNotFoundException') { + return { + roleExists, roleOk, exists: false, versionArn: null, ready: false, + }; + } + throw err; + } + const latest = await getLatestLambdaVersion(lambda, lambdaName); + const ready = cfg.State === 'Active' && cfg.LastUpdateStatus !== 'InProgress' && !!latest; + return { + roleExists, + roleOk, + exists: true, + state: cfg.State, + lastUpdateStatus: cfg.LastUpdateStatus, + functionArn: cfg.FunctionArn, + versionArn: latest?.versionArn || null, + version: latest?.version, + ready, + }; +} + // Edge Optimize owns exactly these association slots on a behavior; every other association is the // customer's and must be preserved. A non-EO association ON one of these slots is a conflict we // refuse (rather than overwrite), so customer edge logic is never silently removed. @@ -1341,7 +1341,11 @@ export async function runEdgeOptimizeDeployStep( // "provisioning" forever. try { const ls = await getEdgeOptimizeLambdaStatus(credentials, distributionId, region); - if (ls.ready) { + // Done only when the function is ready AND the role is present + correctly configured. If the + // role is missing or mis-configured (even with a published function), fall through to + // createEdgeOptimizeLambda — it (re)creates the role + heals its trust/logs policy; the + // function already exists so it just reuses the published version and returns ready. + if (ls.ready && ls.roleOk) { lambdaVersionArn = ls.versionArn; byKey('lambda').status = 'done'; } else { @@ -1660,16 +1664,14 @@ export async function planEdgeOptimizeDeploy( // run. We say whether it is correctly configured — the deploy ALWAYS conforms it to the required // trust (lambda + edgelambda) + logs policy, so a mismatch is auto-corrected, not a blocker. try { - const iam = new IAMClient({ region, credentials }); const roleName = eoLambdaRoleName(distributionId); - const [ls, role] = await Promise.all([ - getEdgeOptimizeLambdaStatus(credentials, distributionId, region), - inspectEdgeOptimizeLambdaRole(iam, roleName), - ]); + // getEdgeOptimizeLambdaStatus already inspects the role (roleExists + roleOk = trust + logs), + // so we derive the role note from it — no separate IAM read needed. + const ls = await getEdgeOptimizeLambdaStatus(credentials, distributionId, region); let roleNote; - if (!role.exists) { + if (!ls.roleExists) { roleNote = ` Execution role ${roleName} will be created.`; - } else if (role.trustOk && role.logsPolicyOk) { + } else if (ls.roleOk) { roleNote = ` Execution role ${roleName} already exists and is correctly configured ` + '(trust + logs) — it will be reused.'; } else { diff --git a/test/support/edge-optimize.test.js b/test/support/edge-optimize.test.js index 33d1ae8a2f..1698e3cfa8 100644 --- a/test/support/edge-optimize.test.js +++ b/test/support/edge-optimize.test.js @@ -867,12 +867,28 @@ describe('edge-optimize support', () => { const result = await edgeOptimize.getEdgeOptimizeLambdaStatus({}, 'E2EXAMPLE'); expect(result).to.deep.equal({ - roleExists: false, exists: false, versionArn: null, ready: false, + roleExists: false, roleOk: false, exists: false, versionArn: null, ready: false, }); }); - it('reports the role + published version and ready:true when fully provisioned', async () => { - iamSendStub.callsFake(() => Promise.resolve({ Role: { Arn: 'arn:role' } })); + it('reports the role (roleOk) + published version and ready:true when fully provisioned', async () => { + const trust = encodeURIComponent(JSON.stringify({ + Version: '2012-10-17', + Statement: [{ + Effect: 'Allow', + Principal: { Service: ['lambda.amazonaws.com', 'edgelambda.amazonaws.com'] }, + Action: 'sts:AssumeRole', + }], + })); + iamSendStub.callsFake((cmd) => { + if (cmd.commandName === 'GetRole') { + return Promise.resolve({ Role: { Arn: 'arn:role', AssumeRolePolicyDocument: trust } }); + } + if (cmd.commandName === 'GetRolePolicy') { + return Promise.resolve({ PolicyName: 'EdgeOptimizeLambdaLogging', PolicyDocument: '{}' }); + } + throw new Error(`unexpected iam: ${cmd.commandName}`); + }); lambdaSendStub.callsFake((cmd) => { if (cmd.commandName === 'GetFunctionConfiguration') { return Promise.resolve({ FunctionArn: 'arn:fn', State: 'Active', LastUpdateStatus: 'Successful' }); @@ -886,6 +902,7 @@ describe('edge-optimize support', () => { const result = await edgeOptimize.getEdgeOptimizeLambdaStatus({}, 'E2EXAMPLE'); expect(result.roleExists).to.equal(true); + expect(result.roleOk).to.equal(true); expect(result.exists).to.equal(true); expect(result.state).to.equal('Active'); expect(result.versionArn).to.equal('arn:fn:2'); @@ -1147,6 +1164,70 @@ describe('edge-optimize support', () => { throw e; }; + const iamCalls = (name) => iamSendStub.getCalls().filter((c) => c.args[0].commandName === name); + + // Encoded trust doc allowing both Lambda@Edge principals — what inspectRole treats as valid. + const validTrust = encodeURIComponent(JSON.stringify({ + Version: '2012-10-17', + Statement: [{ + Effect: 'Allow', + Principal: { Service: ['lambda.amazonaws.com', 'edgelambda.amazonaws.com'] }, + Action: 'sts:AssumeRole', + }], + })); + // IAM mock for an existing, correctly-configured role (roleExists + roleOk = true). + const okRoleIam = (extra = {}) => ({ + GetRole: { Role: { Arn: 'arn:role', AssumeRolePolicyDocument: validTrust } }, + GetRolePolicy: { PolicyName: 'EdgeOptimizeLambdaLogging', PolicyDocument: '{}' }, + UpdateAssumeRolePolicy: {}, + PutRolePolicy: {}, + ...extra, + }); + + // CF + Lambda wiring for "function already published, behavior not yet associated, propagation + // still in progress" — the role-heal gate tests reuse this and only vary the IAM/role mock. + const readyDeployCf = () => ({ + GetDistributionConfig: () => ({ + DistributionConfig: { + Origins: { + Items: [{ + Id: 'EdgeOptimize_Origin', + DomainName: 'dev.edgeoptimize.net', + CustomHeaders: { + Items: [ + { HeaderName: 'x-edgeoptimize-api-key', HeaderValue: 'eo-key' }, + { HeaderName: 'x-forwarded-host', HeaderValue: 'www.example.com' }, + ], + }, + }], + }, + DefaultCacheBehavior: { CachePolicyId: 'cp-1' }, + }, + ETag: 'etag', + }), + DescribeFunction: { FunctionSummary: { FunctionMetadata: { FunctionARN: 'arn:cf-fn' } } }, + ListCachePolicies: { CachePolicyList: { Items: [] } }, + GetCachePolicyConfig: { + CachePolicyConfig: { + Name: 'p', + MinTTL: 0, + ParametersInCacheKeyAndForwardedToOrigin: { + HeadersConfig: { + HeaderBehavior: 'whitelist', + Headers: { Quantity: 2, Items: ['x-edgeoptimize-config', 'x-edgeoptimize-url'] }, + }, + }, + }, + ETag: 'cp-etag', + }, + UpdateDistribution: {}, + ListDistributions: { DistributionList: { Items: [{ Id: 'E2EXAMPLE123', DomainName: 'd123.cloudfront.net', Status: 'InProgress' }] } }, + }); + const readyLambda = () => ({ + GetFunctionConfiguration: { State: 'Active', LastUpdateStatus: 'Successful', FunctionArn: 'arn:lambda' }, + ListVersionsByFunction: { Versions: [{ Version: '3', FunctionArn: 'arn:lambda:3', CodeSha256: 'sha' }] }, + }); + const makeFetchResponse = (status, headerMap) => ({ status, headers: { forEach: (cb) => Object.entries(headerMap).forEach(([k, v]) => cb(v, k)) }, @@ -1206,12 +1287,8 @@ describe('edge-optimize support', () => { ListVersionsByFunction: { Versions: [] }, CreateFunction: { FunctionArn: 'arn:lambda', Version: '$LATEST' }, }, - { - // Role already exists → no role-propagation wait (the slow create path is avoided). - GetRole: { Role: { Arn: 'arn:role' } }, - UpdateAssumeRolePolicy: {}, - PutRolePolicy: {}, - }, + // Role already exists + correctly configured → no role-propagation wait. + okRoleIam(), ); const out = await edgeOptimize.runEdgeOptimizeDeployStep({}, deployParams); @@ -1274,9 +1351,7 @@ describe('edge-optimize support', () => { GetFunctionConfiguration: { State: 'Active', LastUpdateStatus: 'Successful', FunctionArn: 'arn:lambda' }, ListVersionsByFunction: { Versions: [{ Version: '3', FunctionArn: lambdaVersionArn, CodeSha256: 'sha' }] }, }, - { - GetRole: { Role: { Arn: 'arn:role' } }, - }, + okRoleIam(), ); // verify probe: bot lacks request-id → not passed yet (propagation). fetchStub = sinon.stub(global, 'fetch'); @@ -1342,7 +1417,7 @@ describe('edge-optimize support', () => { GetFunctionConfiguration: { State: 'Active', LastUpdateStatus: 'Successful', FunctionArn: 'arn:lambda' }, ListVersionsByFunction: { Versions: [{ Version: '3', FunctionArn: lambdaVersionArn, CodeSha256: 'sha' }] }, }, - { GetRole: { Role: { Arn: 'arn:role' } } }, + okRoleIam(), ); fetchStub = sinon.stub(global, 'fetch'); fetchStub.onFirstCall().resolves(makeFetchResponse(200, { 'x-edgeoptimize-request-id': 'req-1' })); @@ -1408,7 +1483,7 @@ describe('edge-optimize support', () => { GetFunctionConfiguration: { State: 'Active', LastUpdateStatus: 'Successful', FunctionArn: 'arn:lambda' }, ListVersionsByFunction: { Versions: [{ Version: '3', FunctionArn: lambdaVersionArn, CodeSha256: 'sha' }] }, }, - { GetRole: { Role: { Arn: 'arn:role' } } }, + okRoleIam(), ); const out = await edgeOptimize.runEdgeOptimizeDeployStep({}, deployParams); @@ -1500,7 +1575,7 @@ describe('edge-optimize support', () => { GetFunctionConfiguration: { State: 'Pending', LastUpdateStatus: 'InProgress', FunctionArn: 'arn:lambda' }, ListVersionsByFunction: { Versions: [] }, }, - { GetRole: { Role: { Arn: 'arn:role' } }, UpdateAssumeRolePolicy: {}, PutRolePolicy: {} }, + okRoleIam(), ); const out = await edgeOptimize.runEdgeOptimizeDeployStep({}, deployParams); @@ -1562,7 +1637,7 @@ describe('edge-optimize support', () => { ListVersionsByFunction: { Versions: [] }, PublishVersion: { Version: '1', FunctionArn: lambdaVersionArn }, }, - { GetRole: { Role: { Arn: 'arn:role' } }, UpdateAssumeRolePolicy: {}, PutRolePolicy: {} }, + okRoleIam(), ); fetchStub = sinon.stub(global, 'fetch'); fetchStub.onFirstCall().resolves(makeFetchResponse(200, { 'x-edgeoptimize-request-id': 'req-1' })); @@ -1578,6 +1653,58 @@ describe('edge-optimize support', () => { expect(out.routingDeployed).to.equal(true); expect(out.verified).to.equal(true); }); + + // Role-heal gate: the lambda step is "done" only when the function is ready AND the role is + // present + correctly configured. The next three cover each role state on a ready function. + it('lambda ready + role MISSING → recreates the role, then completes the lambda step', async () => { + wire(readyDeployCf(), readyLambda(), { + GetRole: throwNamed('NoSuchEntityException', 'no role'), + CreateRole: { Role: { Arn: 'arn:role' } }, + PutRolePolicy: {}, + }); + + const out = await edgeOptimize.runEdgeOptimizeDeployStep({}, deployParams); + + expect(statusOf(out.steps, 'lambda')).to.equal('done'); + expect(iamCalls('CreateRole')).to.have.length(1); // role recreated despite a ready function + expect(iamCalls('PutRolePolicy')).to.have.length(1); // logs policy re-attached + expect(out.routingDeployed).to.equal(true); + expect(statusOf(out.steps, 'propagation')).to.equal('in_progress'); + }); + + it('lambda ready + role MIS-CONFIGURED → heals trust + logs, then completes', async () => { + const badTrust = encodeURIComponent(JSON.stringify({ + Version: '2012-10-17', + // missing edgelambda.amazonaws.com → roleOk is false + Statement: [{ Effect: 'Allow', Principal: { Service: 'lambda.amazonaws.com' }, Action: 'sts:AssumeRole' }], + })); + wire(readyDeployCf(), readyLambda(), { + GetRole: { Role: { Arn: 'arn:role', AssumeRolePolicyDocument: badTrust } }, + GetRolePolicy: { PolicyName: 'EdgeOptimizeLambdaLogging', PolicyDocument: '{}' }, + UpdateAssumeRolePolicy: {}, + PutRolePolicy: {}, + }); + + const out = await edgeOptimize.runEdgeOptimizeDeployStep({}, deployParams); + + expect(statusOf(out.steps, 'lambda')).to.equal('done'); + expect(iamCalls('UpdateAssumeRolePolicy')).to.have.length(1); // trust corrected + expect(iamCalls('PutRolePolicy')).to.have.length(1); // logs policy re-attached + expect(iamCalls('CreateRole')).to.have.length(0); // role exists → not recreated + expect(out.routingDeployed).to.equal(true); + }); + + it('lambda ready + role OK → completes WITHOUT touching the role (no churn)', async () => { + wire(readyDeployCf(), readyLambda(), okRoleIam()); + + const out = await edgeOptimize.runEdgeOptimizeDeployStep({}, deployParams); + + expect(statusOf(out.steps, 'lambda')).to.equal('done'); + expect(iamCalls('CreateRole')).to.have.length(0); + expect(iamCalls('UpdateAssumeRolePolicy')).to.have.length(0); + expect(iamCalls('PutRolePolicy')).to.have.length(0); // gate passed → createLambda skipped + expect(out.routingDeployed).to.equal(true); + }); }); describe('planEdgeOptimizeDeploy', () => { From 44a9a6f8090b6dd84adb98f17300fb642f223412 Mon Sep 17 00:00:00 2001 From: Akash Bhardwaj Date: Thu, 25 Jun 2026 10:43:19 +0530 Subject: [PATCH 39/56] feat(edge-optimize): stage/prod environment support in plan + deploy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New `environment: 'production' | 'stage'` request field (default production) on the plan + deploy endpoints. resolveEoTarget(site, environment) resolves the EO target: production = the site's own baseURL/apiKey/host (today); stage = the single stagingDomains[0] → that stage site (same org) → its metaconfig apiKey + calculateForwardedHost. The resolved apiKey + forwardedHost flow into originHeaders, so verify (which reads originHeaders.forwardedHost on restore) is env-aware for free. plan response now returns targetDomain. OpenAPI updated (deploy env field + documented plan path/schema). 608 tests pass; stage paths + 400s covered. Co-Authored-By: Claude Opus 4.8 --- docs/openapi/api.yaml | 2 + docs/openapi/llmo-api.yaml | 140 +++++++++++++++++++++++++ src/controllers/llmo/llmo.js | 118 ++++++++++++++++----- test/controllers/llmo/llmo.test.js | 159 ++++++++++++++++++++++++++++- 4 files changed, 392 insertions(+), 27 deletions(-) diff --git a/docs/openapi/api.yaml b/docs/openapi/api.yaml index ddc521d8c4..e52b1443b3 100644 --- a/docs/openapi/api.yaml +++ b/docs/openapi/api.yaml @@ -655,6 +655,8 @@ paths: $ref: './llmo-api.yaml#/site-llmo-edge-optimize-verify' /sites/{siteId}/llmo/edge-optimize/deploy: $ref: './llmo-api.yaml#/site-llmo-edge-optimize-deploy' + /sites/{siteId}/llmo/edge-optimize/plan: + $ref: './llmo-api.yaml#/site-llmo-edge-optimize-plan' /sites/{siteId}/llmo/edge-optimize-status: $ref: './llmo-api.yaml#/llmo-edge-optimize-status' /sites/{siteId}/llmo/probes/edge-optimize: diff --git a/docs/openapi/llmo-api.yaml b/docs/openapi/llmo-api.yaml index 7ee4fd9c30..4f7e1e4be9 100644 --- a/docs/openapi/llmo-api.yaml +++ b/docs/openapi/llmo-api.yaml @@ -3298,6 +3298,91 @@ site-llmo-edge-optimize-deploy: security: - api_key: [ ] +site-llmo-edge-optimize-plan: + post: + tags: + - llmo + summary: Preview (non-mutating) the Edge Optimize CloudFront deploy plan + description: | + Read-only "Review & Deploy" preview for the CloudFront wizard. Mirrors the deploy endpoint + (same validation, access gate, role assumption, and server-derived Edge Optimize origin + headers) but runs the NON-mutating planner and returns the per-step plan plus + `canProceed`/`blocker` so the frontend can show exactly what will happen before the customer + commits. The caller passes the selected distribution, failover origin, and behavior; the Edge + Optimize API key and forwarded host are derived server-side from the site (env-aware via the + optional `environment` flag). The response also includes `targetDomain` — the resolved host + the backend will route to for the chosen environment. + operationId: planEdgeOptimize + parameters: + - $ref: './parameters.yaml#/siteId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/edge-optimize-plan-request' + responses: + '200': + description: The per-step plan plus whether the deploy can proceed. + content: + application/json: + schema: + type: object + required: + - canProceed + - steps + properties: + canProceed: + type: boolean + description: True when the deploy can proceed (no blocking condition detected). + blocker: + type: + - string + - 'null' + description: Human-readable reason the deploy cannot proceed, or null. + targetDomain: + type: string + description: >- + The resolved host the backend will route to for the chosen environment + (the forwarded host). Informational — the frontend also knows it locally. + example: www.example.com + steps: + type: array + items: + type: object + required: + - key + - label + - action + properties: + key: + type: string + enum: + - origin + - function + - cache + - lambda + - associate + - verify + label: + type: string + action: + type: string + detail: + type: string + '400': + $ref: './responses.yaml#/400' + '401': + $ref: './responses.yaml#/401' + '403': + $ref: './responses.yaml#/403' + '404': + $ref: './responses.yaml#/404' + '500': + $ref: './responses.yaml#/500' + security: + - api_key: [ ] + edge-optimize-connector-request: type: object required: @@ -3424,6 +3509,61 @@ edge-optimize-deploy-request: type: string description: The cache behavior path pattern to target; use `default` for the default behavior. example: default + environment: + type: string + description: >- + Which environment to route. Optional; defaults to `production` (backward-compatible). When + `stage`, the backend resolves the site's single configured stage domain and uses the stage + site's Edge Optimize API key + forwarded host instead of production's. + enum: + - production + - stage + default: production + example: production + +edge-optimize-plan-request: + type: object + required: + - accountId + - externalId + - distributionId + - originId + - behavior + properties: + accountId: + type: string + description: The 12-digit AWS account ID hosting the customer's CloudFront distribution. + pattern: '^[0-9]{12}$' + example: '682033462621' + externalId: + type: string + description: Per-session external ID baked into the connector role's trust policy. + example: '7ff9518a-cf59-40b4-aa53-68a3cb2e24a5' + distributionId: + type: string + description: The CloudFront distribution ID to preview. + example: E2EXAMPLE123 + originId: + type: string + description: >- + The customer's existing failover origin id (the default behavior's target origin), + baked into the routing function's failover origin group. + example: origin-aem + behavior: + type: string + description: The cache behavior path pattern to target; use `default` for the default behavior. + example: default + environment: + type: string + description: >- + Which environment to route. Optional; defaults to `production` (backward-compatible). When + `stage`, the backend resolves the site's single configured stage domain and uses the stage + site's Edge Optimize API key + forwarded host instead of production's. + enum: + - production + - stage + default: production + example: production llmo-probe-edge-optimize: get: diff --git a/src/controllers/llmo/llmo.js b/src/controllers/llmo/llmo.js index 2a45006fed..f0f52aa9da 100644 --- a/src/controllers/llmo/llmo.js +++ b/src/controllers/llmo/llmo.js @@ -2558,6 +2558,64 @@ function LlmoController(ctx) { } }; + // Single source of environment-awareness for the CloudFront wizard. The FE sends only an optional + // `environment` flag ('production' | 'stage'); the BE does ALL resolution here and returns the EO + // origin headers (apiKey + forwardedHost) plus the resolved baseURL the wizard will route to. + // + // - 'production' / absent → today's behavior: prod site baseURL → first metaconfig apiKey + + // calculateForwardedHost(prod baseURL). + // - 'stage' → resolve the single stage domain persisted on the prod site's edgeOptimizeConfig + // (stagingDomains[0]); compose its baseURL; look up the (already-onboarded) stage site; use + // that stage site's first metaconfig apiKey + calculateForwardedHost(stage baseURL). + // + // Returns `{ target: { baseURL, apiKey, forwardedHost }, error }`. On any resolution failure + // `error` is a badRequest Response the caller returns directly; otherwise `error` is undefined. + const resolveEoTarget = async (context, site, environment, log) => { + const { Site } = context.dataAccess; + const tokowakaClient = TokowakaClient.createFrom(context); + + if (environment === 'stage') { + const edgeConfig = site.getConfig().getEdgeOptimizeConfig() || {}; + const stagingDomains = Array.isArray(edgeConfig.stagingDomains) + ? edgeConfig.stagingDomains + : []; + const stageDomain = String(stagingDomains[0]?.domain || '').trim(); + if (!hasText(stageDomain)) { + return { error: badRequest('No stage domain configured for this site') }; + } + + const stageBaseURL = composeBaseURL(stageDomain); + const stageSite = await Site.findByBaseURL(stageBaseURL); + if (!stageSite) { + return { error: badRequest('Stage site not found — add the stage domain first') }; + } + + const metaconfig = await tokowakaClient.fetchMetaconfig(stageBaseURL); + const apiKey = metaconfig?.apiKeys?.[0]; + if (!hasText(apiKey)) { + return { + error: badRequest('Stage site has no Edge Optimize API key' + + ' — enable Edge Optimize for the stage domain first'), + }; + } + const forwardedHost = calculateForwardedHost(stageBaseURL, log); + return { target: { baseURL: stageBaseURL, apiKey, forwardedHost } }; + } + + // production / absent + const baseURL = site.getBaseURL(); + const metaconfig = await tokowakaClient.fetchMetaconfig(baseURL); + const apiKey = metaconfig?.apiKeys?.[0]; + if (!hasText(apiKey)) { + return { + error: badRequest('Site has no Edge Optimize API key' + + ' — enable Edge Optimize for this site first'), + }; + } + const forwardedHost = calculateForwardedHost(baseURL, log); + return { target: { baseURL, apiKey, forwardedHost } }; + }; + // Add the Edge Optimize origin to the selected distribution (mutation). Idempotent: returns // { created: false, alreadyExisted: true } when the origin is already present. Used by the // wizard's "Create Edge Optimize origin" step. @@ -2568,6 +2626,7 @@ function LlmoController(ctx) { const accountId = String(context.data?.accountId || '').replace(/\D/g, ''); const externalId = String(context.data?.externalId || '').trim(); const distributionId = String(context.data?.distributionId || '').trim(); + const environment = String(context.data?.environment || 'production').trim(); const roleName = env?.EDGE_OPTIMIZE_ROLE_NAME || undefined; const originDomain = env?.EDGE_OPTIMIZE_ORIGIN_DOMAIN || 'live.edgeoptimize.net'; @@ -2580,6 +2639,9 @@ function LlmoController(ctx) { if (!hasText(distributionId)) { return badRequest('distributionId is required'); } + if (environment !== 'production' && environment !== 'stage') { + return badRequest("environment must be 'production' or 'stage'"); + } try { const { error, site } = await gateEdgeOptimizeWizard(siteId, Site, 'create the edge optimize origin'); @@ -2589,15 +2651,13 @@ function LlmoController(ctx) { // The EO origin needs custom headers so the routing function's request authenticates to Edge // Optimize (x-edgeoptimize-api-key) and resolves the customer host (x-forwarded-host). Both - // are derived server-side from the site — no UI input. Without them Verify never goes green. - const baseURL = site.getBaseURL(); - const tokowakaClient = TokowakaClient.createFrom(context); - const metaconfig = await tokowakaClient.fetchMetaconfig(baseURL); - const apiKey = metaconfig?.apiKeys?.[0]; - if (!hasText(apiKey)) { - return badRequest('Site has no Edge Optimize API key — enable Edge Optimize for this site first'); + // are derived server-side from the site (env-aware via resolveEoTarget) — no UI input beyond + // the optional `environment` flag. Without them Verify never goes green. + const { target, error: targetError } = await resolveEoTarget(context, site, environment, log); + if (targetError) { + return targetError; } - const forwardedHost = calculateForwardedHost(baseURL, log); + const { apiKey, forwardedHost } = target; const { credentials } = await assumeConnectorRole({ accountId, externalId, roleName }); const result = await createEdgeOptimizeOrigin( @@ -2912,6 +2972,7 @@ function LlmoController(ctx) { const distributionId = String(context.data?.distributionId || '').trim(); const originId = String(context.data?.originId || '').trim(); const behavior = String(context.data?.behavior || '').trim(); + const environment = String(context.data?.environment || 'production').trim(); const roleName = env?.EDGE_OPTIMIZE_ROLE_NAME || undefined; const originDomain = env?.EDGE_OPTIMIZE_ORIGIN_DOMAIN || 'live.edgeoptimize.net'; @@ -2930,6 +2991,9 @@ function LlmoController(ctx) { if (!hasText(behavior)) { return badRequest('behavior is required'); } + if (environment !== 'production' && environment !== 'stage') { + return badRequest("environment must be 'production' or 'stage'"); + } try { const { error, site } = await gateEdgeOptimizeWizard(siteId, Site, 'deploy edge optimize routing'); @@ -2939,15 +3003,13 @@ function LlmoController(ctx) { // The EO origin needs custom headers so the routing function's request authenticates to Edge // Optimize (x-edgeoptimize-api-key) and resolves the customer host (x-forwarded-host). Both - // are derived server-side from the site — no UI input. Without them Verify never goes green. - const baseURL = site.getBaseURL(); - const tokowakaClient = TokowakaClient.createFrom(context); - const metaconfig = await tokowakaClient.fetchMetaconfig(baseURL); - const apiKey = metaconfig?.apiKeys?.[0]; - if (!hasText(apiKey)) { - return badRequest('Site has no Edge Optimize API key — enable Edge Optimize for this site first'); + // are derived server-side from the site (env-aware via resolveEoTarget) — no UI input beyond + // the optional `environment` flag. Without them Verify never goes green. + const { target, error: targetError } = await resolveEoTarget(context, site, environment, log); + if (targetError) { + return targetError; } - const forwardedHost = calculateForwardedHost(baseURL, log); + const { apiKey, forwardedHost } = target; // Assume the connector role ONCE; all steps run with the same short-lived credentials. const { credentials, accountId: resolvedAccountId } = await assumeConnectorRole({ @@ -2985,6 +3047,7 @@ function LlmoController(ctx) { const distributionId = String(context.data?.distributionId || '').trim(); const originId = String(context.data?.originId || '').trim(); const behavior = String(context.data?.behavior || '').trim(); + const environment = String(context.data?.environment || 'production').trim(); const roleName = env?.EDGE_OPTIMIZE_ROLE_NAME || undefined; const originDomain = env?.EDGE_OPTIMIZE_ORIGIN_DOMAIN || 'live.edgeoptimize.net'; @@ -3003,6 +3066,9 @@ function LlmoController(ctx) { if (!hasText(behavior)) { return badRequest('behavior is required'); } + if (environment !== 'production' && environment !== 'stage') { + return badRequest("environment must be 'production' or 'stage'"); + } try { const { error, site } = await gateEdgeOptimizeWizard(siteId, Site, 'preview edge optimize routing'); @@ -3010,16 +3076,14 @@ function LlmoController(ctx) { return error; } - // Derive the EO origin headers server-side (same as deploy) so the origin step of the plan - // reflects whether the existing origin already carries the right headers. - const baseURL = site.getBaseURL(); - const tokowakaClient = TokowakaClient.createFrom(context); - const metaconfig = await tokowakaClient.fetchMetaconfig(baseURL); - const apiKey = metaconfig?.apiKeys?.[0]; - if (!hasText(apiKey)) { - return badRequest('Site has no Edge Optimize API key — enable Edge Optimize for this site first'); + // Derive the EO origin headers server-side (same as deploy, env-aware via resolveEoTarget) so + // the origin step of the plan reflects whether the existing origin already carries the right + // headers for the chosen environment. + const { target, error: targetError } = await resolveEoTarget(context, site, environment, log); + if (targetError) { + return targetError; } - const forwardedHost = calculateForwardedHost(baseURL, log); + const { apiKey, forwardedHost } = target; const { credentials, accountId: resolvedAccountId } = await assumeConnectorRole({ accountId, externalId, roleName, @@ -3036,7 +3100,9 @@ function LlmoController(ctx) { log.info(`[edge-optimize-plan] site ${siteId}: canProceed=${result.canProceed},` + ` steps=${result.steps.map((s) => `${s.key}:${s.action}`).join(',')}`); - return ok(result); + // targetDomain lets the FE display exactly the host the BE will route to for this env. + // Loosely coupled: the FE also knows it locally, so this is purely informational. + return ok({ ...result, targetDomain: forwardedHost }); } catch (error) { log.error(`[edge-optimize-plan] Failed for site ${siteId}:`, error); return badRequest(cleanupHeaderValue(error.message)); diff --git a/test/controllers/llmo/llmo.test.js b/test/controllers/llmo/llmo.test.js index d160a77e0b..a86eea58bc 100644 --- a/test/controllers/llmo/llmo.test.js +++ b/test/controllers/llmo/llmo.test.js @@ -9008,6 +9008,93 @@ describe('LlmoController', () => { .deployEdgeOptimize(deployContext); expect(result.status).to.equal(403); }); + + it('defaults to production resolution when environment is omitted', async () => { + const result = await controller.deployEdgeOptimize(deployContext); + expect(result.status).to.equal(200); + const [, params] = runEdgeOptimizeDeployStepStub.firstCall.args; + // production path uses the prod baseURL host (www.example.com) + prod metaconfig key. + expect(params.originHeaders).to.deep.equal({ apiKey: 'eo-key-123', forwardedHost: 'www.example.com' }); + }); + + it("uses the stage apiKey + forwardedHost when environment is 'stage'", async () => { + mockConfig.getEdgeOptimizeConfig = sinon.stub().returns({ + stagingDomains: [{ domain: 'staging.example.com', id: 'stage-site-id' }], + }); + mockDataAccess.Site.findByBaseURL = sinon.stub() + .withArgs('https://staging.example.com') + .resolves({ getId: () => 'stage-site-id' }); + // prod metaconfig is the default stub; the stage one wins for the stage baseURL. + mockTokowakaClient.fetchMetaconfig + .withArgs('https://staging.example.com') + .resolves({ apiKeys: ['stage-key-999'] }); + + const result = await controller.deployEdgeOptimize({ + ...deployContext, + data: { ...deployContext.data, environment: 'stage' }, + }); + + expect(result.status).to.equal(200); + const [, params] = runEdgeOptimizeDeployStepStub.firstCall.args; + expect(params.originHeaders).to.deep.equal({ + apiKey: 'stage-key-999', + forwardedHost: 'staging.example.com', + }); + }); + + it('returns 400 for an unknown environment', async () => { + const result = await controller.deployEdgeOptimize({ + ...deployContext, + data: { ...deployContext.data, environment: 'qa' }, + }); + expect(result.status).to.equal(400); + expect(runEdgeOptimizeDeployStepStub.called).to.equal(false); + }); + + it('returns 400 for stage when no stage domain is configured', async () => { + mockConfig.getEdgeOptimizeConfig = sinon.stub().returns({}); + const result = await controller.deployEdgeOptimize({ + ...deployContext, + data: { ...deployContext.data, environment: 'stage' }, + }); + expect(result.status).to.equal(400); + const body = await result.json(); + expect(body.message).to.include('No stage domain'); + expect(runEdgeOptimizeDeployStepStub.called).to.equal(false); + }); + + it('returns 400 for stage when the stage site is not found', async () => { + mockConfig.getEdgeOptimizeConfig = sinon.stub().returns({ + stagingDomains: [{ domain: 'staging.example.com', id: 'stage-site-id' }], + }); + mockDataAccess.Site.findByBaseURL = sinon.stub().resolves(null); + const result = await controller.deployEdgeOptimize({ + ...deployContext, + data: { ...deployContext.data, environment: 'stage' }, + }); + expect(result.status).to.equal(400); + const body = await result.json(); + expect(body.message).to.include('Stage site not found'); + expect(runEdgeOptimizeDeployStepStub.called).to.equal(false); + }); + + it('returns 400 for stage when the stage site has no Edge Optimize API key', async () => { + mockConfig.getEdgeOptimizeConfig = sinon.stub().returns({ + stagingDomains: [{ domain: 'staging.example.com', id: 'stage-site-id' }], + }); + mockDataAccess.Site.findByBaseURL = sinon.stub().resolves({ getId: () => 'stage-site-id' }); + mockTokowakaClient.fetchMetaconfig + .withArgs('https://staging.example.com') + .resolves({ apiKeys: [] }); + const result = await controller.deployEdgeOptimize({ + ...deployContext, + data: { ...deployContext.data, environment: 'stage' }, + }); + expect(result.status).to.equal(400); + const body = await result.json(); + expect(body.message).to.include('Stage site has no Edge Optimize API key'); + expect(runEdgeOptimizeDeployStepStub.called).to.equal(false); + }); }); describe('planEdgeOptimize', () => { @@ -9062,7 +9149,8 @@ describe('LlmoController', () => { expect(result.status).to.equal(200); const body = await result.json(); - expect(body).to.deep.equal(samplePlan); + // plan response now carries an extra targetDomain (the resolved host) alongside the plan. + expect(body).to.deep.equal({ ...samplePlan, targetDomain: 'www.example.com' }); expect(assumeConnectorRoleStub.calledOnce).to.equal(true); expect(planEdgeOptimizeDeployStub.calledOnce).to.equal(true); const [, params] = planEdgeOptimizeDeployStub.firstCall.args; @@ -9162,6 +9250,75 @@ describe('LlmoController', () => { .planEdgeOptimize(planContext); expect(result.status).to.equal(403); }); + + it('returns targetDomain (prod host) and defaults to production when env is omitted', async () => { + const result = await controller.planEdgeOptimize(planContext); + expect(result.status).to.equal(200); + const body = await result.json(); + expect(body.targetDomain).to.equal('www.example.com'); + const [, params] = planEdgeOptimizeDeployStub.firstCall.args; + expect(params.originHeaders).to.deep.equal({ apiKey: 'eo-key-123', forwardedHost: 'www.example.com' }); + }); + + it('uses the stage apiKey + forwardedHost and returns the stage targetDomain', async () => { + mockConfig.getEdgeOptimizeConfig = sinon.stub().returns({ + stagingDomains: [{ domain: 'staging.example.com', id: 'stage-site-id' }], + }); + mockDataAccess.Site.findByBaseURL = sinon.stub().resolves({ getId: () => 'stage-site-id' }); + mockTokowakaClient.fetchMetaconfig + .withArgs('https://staging.example.com') + .resolves({ apiKeys: ['stage-key-999'] }); + + const result = await controller.planEdgeOptimize({ + ...planContext, + data: { ...planContext.data, environment: 'stage' }, + }); + + expect(result.status).to.equal(200); + const body = await result.json(); + expect(body.targetDomain).to.equal('staging.example.com'); + const [, params] = planEdgeOptimizeDeployStub.firstCall.args; + expect(params.originHeaders).to.deep.equal({ + apiKey: 'stage-key-999', + forwardedHost: 'staging.example.com', + }); + }); + + it('returns 400 for an unknown environment', async () => { + const result = await controller.planEdgeOptimize({ + ...planContext, + data: { ...planContext.data, environment: 'qa' }, + }); + expect(result.status).to.equal(400); + expect(planEdgeOptimizeDeployStub.called).to.equal(false); + }); + + it('returns 400 for stage when no stage domain is configured', async () => { + mockConfig.getEdgeOptimizeConfig = sinon.stub().returns({}); + const result = await controller.planEdgeOptimize({ + ...planContext, + data: { ...planContext.data, environment: 'stage' }, + }); + expect(result.status).to.equal(400); + const body = await result.json(); + expect(body.message).to.include('No stage domain'); + expect(planEdgeOptimizeDeployStub.called).to.equal(false); + }); + + it('returns 400 for stage when the stage site is not found', async () => { + mockConfig.getEdgeOptimizeConfig = sinon.stub().returns({ + stagingDomains: [{ domain: 'staging.example.com', id: 'stage-site-id' }], + }); + mockDataAccess.Site.findByBaseURL = sinon.stub().resolves(null); + const result = await controller.planEdgeOptimize({ + ...planContext, + data: { ...planContext.data, environment: 'stage' }, + }); + expect(result.status).to.equal(400); + const body = await result.json(); + expect(body.message).to.include('Stage site not found'); + expect(planEdgeOptimizeDeployStub.called).to.equal(false); + }); }); describe('getEdgeOptimizePermissions', () => { From 57ae0757f5992a478b7d8d1e5f1f7b15f40e9d90 Mon Sep 17 00:00:00 2001 From: Akash Bhardwaj Date: Thu, 25 Jun 2026 14:30:32 +0530 Subject: [PATCH 40/56] feat(edge-optimize): source CloudFront control-plane from tokowaka-client Replace the local CloudFront "Optimize at Edge" control-plane (src/support/edge-optimize.js + edge-optimize-edge-code.js) with imports from @adobe/spacecat-shared-tokowaka-client, where it now lives (spacecat-shared PR #1722). The llmo controller imports the 12 control-plane functions + calculateForwardedHost from the client; the local files and their unit test are removed (coverage moved to the client at 100%). The dependency temporarily points at an unpublished test build (gist tarball) so CI can resolve the migrated client before #1722 merges and publishes. DO NOT MERGE until #1722 lands and this is bumped to the published version. Co-Authored-By: Claude Opus 4.8 --- package-lock.json | 116 +- package.json | 2 +- src/controllers/llmo/llmo.js | 12 +- src/support/edge-optimize-edge-code.js | 149 -- src/support/edge-optimize.js | 1722 -------------------- test/controllers/llmo/llmo.test.js | 50 +- test/support/edge-optimize.test.js | 2007 ------------------------ 7 files changed, 152 insertions(+), 3906 deletions(-) delete mode 100644 src/support/edge-optimize-edge-code.js delete mode 100644 src/support/edge-optimize.js delete mode 100644 test/support/edge-optimize.test.js diff --git a/package-lock.json b/package-lock.json index 426dde5bb0..d1dec0984d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,7 +19,7 @@ "@adobe/mysticat-shared-seo-client": "1.3.1", "@adobe/spacecat-helix-content-sdk": "1.4.33", "@adobe/spacecat-shared-athena-client": "1.9.12", - "@adobe/spacecat-shared-brand-client": "^1.4.0", + "@adobe/spacecat-shared-brand-client": "1.4.0", "@adobe/spacecat-shared-content-client": "1.8.24", "@adobe/spacecat-shared-data-access": "3.79.0", "@adobe/spacecat-shared-drs-client": "1.12.1", @@ -31,7 +31,7 @@ "@adobe/spacecat-shared-scrape-client": "2.6.3", "@adobe/spacecat-shared-slack-client": "1.6.7", "@adobe/spacecat-shared-tier-client": "1.5.1", - "@adobe/spacecat-shared-tokowaka-client": "1.19.0", + "@adobe/spacecat-shared-tokowaka-client": "https://gist.githubusercontent.com/ABHA61/85486e1ccf4882e6b09800f629daed1f/raw/b1e637aa82161192c1172d46ad2fb2e14c3492c4/adobe-spacecat-shared-tokowaka-client-1.19.1.tgz", "@adobe/spacecat-shared-utils": "1.119.2", "@adobe/spacecat-shared-vault-secrets": "1.3.5", "@aws-sdk/client-cloudfront": "3.1045.0", @@ -8785,14 +8785,17 @@ } }, "node_modules/@adobe/spacecat-shared-tokowaka-client": { - "version": "1.19.0", - "resolved": "https://registry.npmjs.org/@adobe/spacecat-shared-tokowaka-client/-/spacecat-shared-tokowaka-client-1.19.0.tgz", - "integrity": "sha512-OyF/y5QtocL5Y+cpaf0cZVRsXeU/9xtSI6fodt1HWUGXZKDP4suRhfNJiTjc6ej5sV9Oxqgzev1rTpTZK38n7A==", + "version": "1.19.1", + "resolved": "https://gist.githubusercontent.com/ABHA61/85486e1ccf4882e6b09800f629daed1f/raw/b1e637aa82161192c1172d46ad2fb2e14c3492c4/adobe-spacecat-shared-tokowaka-client-1.19.1.tgz", + "integrity": "sha512-GohB24TGlroGPE1Vxj7rIz74Zop9T3BupOYmMK8xAhost6Yzbz0sKPE5xcyEY+rw/zVO8aDjwbOYQfZerdRynQ==", "license": "Apache-2.0", "dependencies": { "@adobe/spacecat-shared-utils": "1.81.1", "@aws-sdk/client-cloudfront": "3.1057.0", + "@aws-sdk/client-iam": "3.1057.0", + "@aws-sdk/client-lambda": "3.1057.0", "@aws-sdk/client-s3": "3.1057.0", + "@aws-sdk/client-sts": "3.1057.0", "hast-util-from-html": "2.0.3", "mdast-util-from-markdown": "2.0.3", "mdast-util-to-hast": "13.2.1", @@ -9143,6 +9146,74 @@ "node": ">=20.0.0" } }, + "node_modules/@adobe/spacecat-shared-tokowaka-client/node_modules/@aws-sdk/client-iam": { + "version": "3.1057.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-iam/-/client-iam-3.1057.0.tgz", + "integrity": "sha512-P69o/7oMiVdk50j2+nCb+Vd+SyGydD3dItfqpaa//5U1ho5DEsrS6FqK0g7TfSz9a87nrDDRh80BHs1w+B/G7Q==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.974.15", + "@aws-sdk/credential-provider-node": "^3.972.47", + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.5", + "@smithy/fetch-http-handler": "^5.4.5", + "@smithy/node-http-handler": "^4.7.5", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@adobe/spacecat-shared-tokowaka-client/node_modules/@aws-sdk/client-iam/node_modules/@aws-sdk/types": { + "version": "3.973.13", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.13.tgz", + "integrity": "sha512-pEHZqRkAlHfnfAU9tK+WpKv/gBNjGJrHMgA3A0iYRGyswBS2t0pfez+lWlwktb3Bqa0ovh7w/QJTFwp3fDxLNg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@adobe/spacecat-shared-tokowaka-client/node_modules/@aws-sdk/client-lambda": { + "version": "3.1057.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-lambda/-/client-lambda-3.1057.0.tgz", + "integrity": "sha512-JoaE3QNvPqOSHJtcAFfza7BRoGUNerIKlSVhsKCBsa1i54Vto1YWUS7itkUELK2rpvS8kNZMPliPxDdi/oV5dw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.974.15", + "@aws-sdk/credential-provider-node": "^3.972.47", + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.5", + "@smithy/fetch-http-handler": "^5.4.5", + "@smithy/node-http-handler": "^4.7.5", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@adobe/spacecat-shared-tokowaka-client/node_modules/@aws-sdk/client-lambda/node_modules/@aws-sdk/types": { + "version": "3.973.13", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.13.tgz", + "integrity": "sha512-pEHZqRkAlHfnfAU9tK+WpKv/gBNjGJrHMgA3A0iYRGyswBS2t0pfez+lWlwktb3Bqa0ovh7w/QJTFwp3fDxLNg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@adobe/spacecat-shared-tokowaka-client/node_modules/@aws-sdk/client-s3": { "version": "3.1057.0", "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1057.0.tgz", @@ -9326,6 +9397,41 @@ } } }, + "node_modules/@adobe/spacecat-shared-tokowaka-client/node_modules/@aws-sdk/client-sts": { + "version": "3.1057.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sts/-/client-sts-3.1057.0.tgz", + "integrity": "sha512-67Qi3j1Np/y8QAiTQn3SlYIDg6j3gUbwbjYqPzL0S0pDTYoNtnHjABrvarg21txotlQn9ZkbgBawEL3+f3A/Qg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.974.15", + "@aws-sdk/credential-provider-node": "^3.972.47", + "@aws-sdk/signature-v4-multi-region": "^3.996.30", + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.5", + "@smithy/fetch-http-handler": "^5.4.5", + "@smithy/node-http-handler": "^4.7.5", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@adobe/spacecat-shared-tokowaka-client/node_modules/@aws-sdk/client-sts/node_modules/@aws-sdk/types": { + "version": "3.973.13", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.13.tgz", + "integrity": "sha512-pEHZqRkAlHfnfAU9tK+WpKv/gBNjGJrHMgA3A0iYRGyswBS2t0pfez+lWlwktb3Bqa0ovh7w/QJTFwp3fDxLNg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@adobe/spacecat-shared-tokowaka-client/node_modules/@aws-sdk/credential-provider-env": { "version": "3.940.0", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.940.0.tgz", diff --git a/package.json b/package.json index 775f478e81..59da5744e6 100644 --- a/package.json +++ b/package.json @@ -96,7 +96,7 @@ "@adobe/spacecat-shared-scrape-client": "2.6.3", "@adobe/spacecat-shared-slack-client": "1.6.7", "@adobe/spacecat-shared-tier-client": "1.5.1", - "@adobe/spacecat-shared-tokowaka-client": "1.19.0", + "@adobe/spacecat-shared-tokowaka-client": "https://gist.githubusercontent.com/ABHA61/85486e1ccf4882e6b09800f629daed1f/raw/b1e637aa82161192c1172d46ad2fb2e14c3492c4/adobe-spacecat-shared-tokowaka-client-1.19.1.tgz", "@adobe/spacecat-shared-utils": "1.119.2", "@adobe/spacecat-shared-vault-secrets": "1.3.5", "@aws-sdk/client-cloudfront": "3.1045.0", diff --git a/src/controllers/llmo/llmo.js b/src/controllers/llmo/llmo.js index f0f52aa9da..ee8d3c742f 100644 --- a/src/controllers/llmo/llmo.js +++ b/src/controllers/llmo/llmo.js @@ -31,11 +31,8 @@ import crypto from 'crypto'; import { getDomain, parse as parseDomain } from 'tldts'; import { Entitlement as EntitlementModel } from '@adobe/spacecat-shared-data-access'; import TierClient from '@adobe/spacecat-shared-tier-client'; -import TokowakaClient, { calculateForwardedHost } from '@adobe/spacecat-shared-tokowaka-client'; -import { ImsClient } from '@adobe/spacecat-shared-ims-client'; -import yaml from 'js-yaml'; -import AccessControlUtil from '../../support/access-control-util.js'; -import { +import TokowakaClient, { + calculateForwardedHost, assumeConnectorRole, listCloudFrontDistributions, getDistributionConfig, @@ -48,7 +45,10 @@ import { verifyEdgeOptimizeRouting, runEdgeOptimizeDeployStep, planEdgeOptimizeDeploy, -} from '../../support/edge-optimize.js'; +} from '@adobe/spacecat-shared-tokowaka-client'; +import { ImsClient } from '@adobe/spacecat-shared-ims-client'; +import yaml from 'js-yaml'; +import AccessControlUtil from '../../support/access-control-util.js'; import { UnauthorizedProductError } from '../../support/errors.js'; import { cachedOk } from '../../support/cached-response.js'; import { diff --git a/src/support/edge-optimize-edge-code.js b/src/support/edge-optimize-edge-code.js deleted file mode 100644 index 9c90fe3db1..0000000000 --- a/src/support/edge-optimize-edge-code.js +++ /dev/null @@ -1,149 +0,0 @@ -/* - * 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. - */ - -/** - * Edge runtime code for the CloudFront "Optimize at Edge" onboarding, kept out of the - * orchestrator (edge-optimize.js) so it stays readable. Both exports are plain JS-module - * strings (not sibling-file reads) so the helix-deploy bundle preserves them — see CLAUDE.md - * "Lambda Bundle Constraints". - */ - -/** - * Build the CloudFront Function (viewer-request) routing code. Ported verbatim from the standalone - * wizard's `buildFunctionCode` (server.mjs). It detects agentic bots on HTML pages and, for them, - * creates a request origin group that fails over from the Edge Optimize origin to the default - * origin. - * - * @param {string} defaultOriginId - the distribution's default-behavior target origin id. - * @param {string[]|null} [targetedPaths] - explicit paths to target, or null for "all HTML pages". - * @returns {string} the CloudFront Function source code. - */ -export function buildRoutingFunctionCode(defaultOriginId, targetedPaths = null) { - const targetedPathsValue = targetedPaths === null ? 'null' : JSON.stringify(targetedPaths); - - return `import cf from 'cloudfront'; - -function handler(event) { - var request = event.request; - var headers = request.headers; - - delete headers['x-edgeoptimize-api-key']; - delete headers['x-edgeoptimize-url']; - delete headers['x-edgeoptimize-config']; - - var AGENTIC_BOTS = ['AdobeEdgeOptimize-AI', 'ChatGPT-User', 'GPTBot', 'OAI-SearchBot', 'PerplexityBot', 'Perplexity-User', 'ClaudeBot', 'Claude-User', 'Claude-SearchBot']; - var TARGETED_PATHS = ${targetedPathsValue}; - - var userAgent = headers['user-agent'] ? headers['user-agent'].value.toLowerCase() : ''; - var isEdgeOptimizeRequest = headers['x-edgeoptimize-request']; - - var path = request.uri; - var pattern = /(?:\\/[^./]+|\\.html|\\/)$/; - var isHtmlPage = pattern.test(path); - - var isTargetedPath = TARGETED_PATHS === null - ? isHtmlPage - : isHtmlPage && TARGETED_PATHS.includes(path); - - var isAgenticBot = AGENTIC_BOTS.some(function(bot) { - return userAgent.includes(bot.toLowerCase()); - }); - - if (!isEdgeOptimizeRequest && isAgenticBot && isTargetedPath) { - request.headers['x-edgeoptimize-url'] = { value: request.uri }; - request.headers['x-edgeoptimize-config'] = { value: "LLMCLIENT=true" }; - - console.log("Adding origin group for userAgent: " + userAgent); - - cf.createRequestOriginGroup({ - "originIds": [ - { "originId": "EdgeOptimize_Origin" }, - { "originId": "${defaultOriginId}" } - ], - "failoverCriteria": { - "statusCodes": [400, 403, 404, 416, 500, 502, 503, 504] - } - }); - - console.log("Routing to Edge Optimize origin for userAgent: " + userAgent); - return request; - } - - console.log("Routing to Default origin for userAgent: " + userAgent); - return request; -}`; -} - -/** - * Build the Lambda@Edge origin-request/response handler. Ported from the standalone wizard's - * templates/origin-request-response.js. Returned as an inline JS module string (not a sibling-file - * read) so the helix-deploy bundle preserves it — see CLAUDE.md "Lambda Bundle Constraints". - * - * The Edge Optimize origin domain is injected so the same handler works per environment - * (e.g. dev.edgeoptimize.net on dev, live.edgeoptimize.net on prod): the origin-request branch - * routes to the EO origin only when CloudFront's current origin matches this domain — otherwise it - * marks the request as a failover. It MUST be the same value used as the EO origin's DomainName. - * - * @param {string} eoOriginDomain - the Edge Optimize origin domain baked into the routing check. - * @returns {string} the Lambda@Edge function source code. - */ -export function buildEdgeOptimizeLambdaCode(eoOriginDomain) { - return `function hasHeader(map, name) { - const h = map?.[name]; - return Array.isArray(h) && h.length > 0 && (h[0].value || '').trim() !== ''; -} - -function setHeader(map, name, value) { - if (map) { - map[name.toLowerCase()] = [{ key: name, value: String(value) }]; - } -} - -export const handler = async (event) => { - const request = event?.Records?.[0]?.cf?.request; - const response = event?.Records?.[0]?.cf?.response; - const eventType = event.Records[0].cf.config.eventType; - const reqHeaders = request.headers || {}; - - if (eventType === 'origin-request') { - const originDomain = request.origin?.custom?.domainName; - const isEdgeOptimizeConfig = hasHeader(reqHeaders, 'x-edgeoptimize-config'); - const isEdgeOptimizeRequest = hasHeader(reqHeaders, 'x-edgeoptimize-request'); - - if (isEdgeOptimizeConfig && !isEdgeOptimizeRequest) { - if (originDomain === '${eoOriginDomain}') { - console.log("Calling Edge Optimize Origin for agentic requests"); - setHeader(request.headers, 'host', originDomain); - } else { - console.log("Calling Default Origin in case of failover for agentic requests"); - setHeader(request.headers, 'x-edgeoptimize-request', 'fo'); - } - } - - return request; - - } else if (eventType === 'origin-response') { - const resHeaders = response.headers || {}; - const isEdgeOptimizeConfig = hasHeader(reqHeaders, 'x-edgeoptimize-config'); - const isEdgeOptimizeRequestId = hasHeader(resHeaders, 'x-edgeoptimize-request-id'); - - if (isEdgeOptimizeConfig && !isEdgeOptimizeRequestId) { - setHeader(response.headers, 'x-edgeoptimize-fo', '1'); - setHeader(response.headers, 'cache-control', 'no-store'); - console.log('Failover Triggered for agentic requests'); - } - - return response; - } -}; -`; -} diff --git a/src/support/edge-optimize.js b/src/support/edge-optimize.js deleted file mode 100644 index 8a6171273c..0000000000 --- a/src/support/edge-optimize.js +++ /dev/null @@ -1,1722 +0,0 @@ -/* - * 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 { deflateRawSync } from 'node:zlib'; -import { STSClient, AssumeRoleCommand } from '@aws-sdk/client-sts'; -import { - CloudFrontClient, - ListDistributionsCommand, - GetDistributionConfigCommand, - GetCachePolicyConfigCommand, - GetCachePolicyCommand, - ListCachePoliciesCommand, - CreateCachePolicyCommand, - UpdateCachePolicyCommand, - CreateFunctionCommand, - UpdateFunctionCommand, - DescribeFunctionCommand, - PublishFunctionCommand, - UpdateDistributionCommand, -} from '@aws-sdk/client-cloudfront'; -import { - IAMClient, - CreateRoleCommand, - GetRoleCommand, - GetRolePolicyCommand, - PutRolePolicyCommand, - UpdateAssumeRolePolicyCommand, -} from '@aws-sdk/client-iam'; -import { - LambdaClient, - CreateFunctionCommand as LambdaCreateFunctionCommand, - GetFunctionConfigurationCommand, - ListVersionsByFunctionCommand, - PublishVersionCommand, -} from '@aws-sdk/client-lambda'; -import { hasText } from '@adobe/spacecat-shared-utils'; -import { cleanupHeaderValue } from '@adobe/helix-shared-utils'; - -// Edge runtime code (Lambda@Edge handler + CloudFront routing function) lives in its own -// module for readability; imported for use here and re-exported to keep the public surface. -import { buildEdgeOptimizeLambdaCode, buildRoutingFunctionCode } from './edge-optimize-edge-code.js'; - -export { buildEdgeOptimizeLambdaCode, buildRoutingFunctionCode }; - -// CloudFront is a global service; its control plane lives in us-east-1. -export const EDGE_OPTIMIZE_REGION = 'us-east-1'; -export const EDGE_OPTIMIZE_DEFAULT_ROLE_NAME = 'AdobeLLMOptimizerCloudFrontConnectorRole'; -const SESSION_NAME = 'llmo-edge-optimize'; -const SESSION_DURATION_SECONDS = 900; - -// The connector role only permits writes to these exact resource names — keep them in sync -// with the standalone connect-aws-wizard (server.mjs) and the customer-bootstrap-role policy. -export const EDGE_OPTIMIZE_ORIGIN_ID = 'EdgeOptimize_Origin'; -export const EDGE_OPTIMIZE_DEFAULT_ORIGIN_DOMAIN = 'live.edgeoptimize.net'; -export const EDGE_OPTIMIZE_FUNCTION_NAME = 'edgeoptimize-routing'; -export const EDGE_OPTIMIZE_LAMBDA_FUNCTION_NAME = 'edgeoptimize-origin'; -export const EDGE_OPTIMIZE_LAMBDA_ROLE_NAME = 'edgeoptimize-origin-role'; - -// Per-distribution resource names — the `-adobe-` suffix keeps the account-level -// CloudFront function, Lambda@Edge function, and its IAM execution role unique per distribution -// (so one AWS account fronting multiple distributions never collides). All stay within the -// connector role's `edgeoptimize-*` (Lambda/role) and `Resource: '*'` (CloudFront) grants, so no -// customer re-onboarding is needed. The EO origin id is intentionally NOT suffixed — it is scoped -// inside the distribution config and cannot collide. -export const eoRoutingFunctionName = (distributionId) => `${EDGE_OPTIMIZE_FUNCTION_NAME}-adobe-${distributionId}`; -export const eoLambdaFunctionName = (distributionId) => `${EDGE_OPTIMIZE_LAMBDA_FUNCTION_NAME}-adobe-${distributionId}`; -export const eoLambdaRoleName = (distributionId) => `${EDGE_OPTIMIZE_LAMBDA_ROLE_NAME}-adobe-${distributionId}`; -// Headers the routing CloudFront Function sets and that must reach the EO origin uncached. -export const EDGE_OPTIMIZE_CACHE_HEADERS = ['x-edgeoptimize-config', 'x-edgeoptimize-url']; -// Name of the custom cache policy we create when cloning an AWS-managed policy. -export const EDGE_OPTIMIZE_CACHE_POLICY_NAME = 'edgeoptimize-cache'; - -// Per the BYOCDN doc, force the cache policy MinTTL to 0 so agentic responses are not -// over-cached — UNLESS the current MinTTL is already short (<= this many seconds), in which -// case we leave it exactly as the customer configured it. -export const EDGE_OPTIMIZE_MIN_TTL_KEEP_THRESHOLD = 5; - -/** - * Build the per-distribution name for a cache policy cloned from a managed (AWS) policy. - * Strips the AWS `Managed-` prefix (a custom policy must not carry it) and appends an - * `-adobe-` suffix so each distribution gets its own clone (no account-level - * collision when one account fronts multiple distributions). Capped at the 128-char AWS limit. - * - * @param {string} sourceName - the source (managed) policy name, e.g. `Managed-CachingOptimized`. - * @param {string} distributionId - the CloudFront distribution id. - * @returns {string} e.g. `CachingOptimized-adobe-E2VLBZCBR857CC`. - */ -export function buildEoClonedCachePolicyName(sourceName, distributionId) { - const base = String(sourceName || 'cache').replace(/^Managed-/i, ''); - return `${base}-adobe-${distributionId}`.slice(0, 128); -} - -const delay = (ms) => new Promise((resolve) => { - setTimeout(resolve, ms); -}); - -/** - * Assume the customer's cross-account connector role and return short-lived credentials. - * - * The api-service Lambda execution role (the default credential chain) assumes the role the - * customer created via the CloudFormation bootstrap, scoped by the per-session external ID. - * Credentials are short-lived — callers should use them immediately for a single operation - * and never persist them in the browser. - * - * @param {object} params - * @param {string} params.accountId - 12-digit customer AWS account ID. - * @param {string} params.externalId - external ID baked into the connector role trust policy. - * @param {string} [params.roleName] - connector role name (defaults to the standard name). - * @param {string} [params.region] - STS region. - * @returns {Promise<{roleArn: string, accountId: string, credentials: object}>} - */ -export async function assumeConnectorRole({ - accountId, - externalId, - roleName = EDGE_OPTIMIZE_DEFAULT_ROLE_NAME, - region = EDGE_OPTIMIZE_REGION, -}) { - if (!/^[0-9]{12}$/.test(String(accountId))) { - throw new Error('accountId must be a 12-digit AWS account ID'); - } - if (!hasText(externalId)) { - throw new Error('externalId is required'); - } - - const roleArn = `arn:aws:iam::${accountId}:role/${roleName}`; - const sts = new STSClient({ region }); - const response = await sts.send(new AssumeRoleCommand({ - RoleArn: roleArn, - RoleSessionName: SESSION_NAME, - ExternalId: externalId, - DurationSeconds: SESSION_DURATION_SECONDS, - })); - - const creds = response?.Credentials; - if (!creds?.AccessKeyId || !creds?.SecretAccessKey || !creds?.SessionToken) { - throw new Error('Failed to assume connector role: no credentials returned'); - } - - return { - roleArn, - accountId: String(accountId), - credentials: { - accessKeyId: creds.AccessKeyId, - secretAccessKey: creds.SecretAccessKey, - sessionToken: creds.SessionToken, - expiration: creds.Expiration, - }, - }; -} - -/** - * List the CloudFront distributions in the customer account using assumed-role credentials. - * - * @param {object} credentials - temporary credentials from {@link assumeConnectorRole}. - * @param {string} [region] - CloudFront control-plane region. - * @returns {Promise>} distributions projected to the fields the wizard needs. - */ -export async function listCloudFrontDistributions(credentials, region = EDGE_OPTIMIZE_REGION) { - const client = new CloudFrontClient({ region, credentials }); - const response = await client.send(new ListDistributionsCommand({})); - const items = response?.DistributionList?.Items || []; - return items.map((dist) => ({ - id: dist.Id, - domainName: dist.DomainName, - aliases: dist.Aliases?.Items || [], - status: dist.Status, - enabled: dist.Enabled === true, - comment: dist.Comment || '', - })); -} - -/** - * Fetch a single CloudFront distribution's configuration using assumed-role credentials. - * - * Returns the parsed origins, default cache behavior, and ordered cache behaviors projected to - * the fields the wizard needs to inspect routing. Read-only — uses GetDistributionConfig. - * - * @param {object} credentials - temporary credentials from {@link assumeConnectorRole}. - * @param {string} distributionId - the CloudFront distribution ID. - * @param {string} [region] - CloudFront control-plane region. - * @returns {Promise<{origins: Array, defaultCacheBehavior: object|null, - * cacheBehaviors: Array}>} - */ -export async function getDistributionConfig( - credentials, - distributionId, - region = EDGE_OPTIMIZE_REGION, -) { - if (!hasText(distributionId)) { - throw new Error('distributionId is required'); - } - const client = new CloudFrontClient({ region, credentials }); - const response = await client.send(new GetDistributionConfigCommand({ Id: distributionId })); - const config = response?.DistributionConfig || {}; - - const origins = (config.Origins?.Items || []).map((origin) => ({ - id: origin.Id, - domainName: origin.DomainName, - originPath: origin.OriginPath || '', - })); - - const mapBehavior = (behavior) => ({ - pathPattern: behavior.PathPattern, - targetOriginId: behavior.TargetOriginId, - }); - - const defaultCacheBehavior = config.DefaultCacheBehavior - ? mapBehavior({ ...config.DefaultCacheBehavior, PathPattern: 'Default (*)' }) - : null; - - const cacheBehaviors = (config.CacheBehaviors?.Items || []).map(mapBehavior); - - return { origins, defaultCacheBehavior, cacheBehaviors }; -} - -/** - * Locate a behavior on a parsed DistributionConfig by its path pattern. The default behavior is - * addressed with the pseudo-pattern `default` (or `Default (*)`, the projection used by the read - * endpoints). - * - * @param {object} config - a raw CloudFront DistributionConfig. - * @param {string} pathPattern - the behavior path pattern, or `default`/`Default (*)`. - * @returns {object} the raw behavior object (mutating it mutates the config). - */ -function getBehaviorFromConfig(config, pathPattern) { - if (pathPattern === 'default' || pathPattern === 'Default (*)') { - return config.DefaultCacheBehavior; - } - const behavior = (config.CacheBehaviors?.Items || []).find((b) => b.PathPattern === pathPattern); - if (!behavior) { - throw new Error(`Behavior not found: ${pathPattern}`); - } - return behavior; -} - -/** - * Build the custom-header items the EO origin must carry. Mirrors the standalone wizard's - * apiCreateOrigin (server.mjs) + the CloudFormation installer: `x-edgeoptimize-api-key` - * authenticates the prerender request to Edge Optimize, `x-forwarded-host` tells EO which site's - * content to serve, and the optional `x-edgeoptimize-fetcher-key` is for WAF-allowlisted customers. - * Without these the origin returns no `x-edgeoptimize-request-id` and Verify never goes green. - * - * @param {object} headers - * @param {string} [headers.apiKey] - the site's Edge Optimize API key. - * @param {string} [headers.forwardedHost] - the customer's canonical site host. - * @param {string} [headers.fetcherKey] - optional fetcher key (WAF allowlist). - * @returns {Array<{HeaderName: string, HeaderValue: string}>} - */ -function buildEdgeOptimizeOriginHeaders({ apiKey, forwardedHost, fetcherKey } = {}) { - const items = []; - if (hasText(apiKey)) { - items.push({ HeaderName: 'x-edgeoptimize-api-key', HeaderValue: apiKey }); - } - if (hasText(forwardedHost)) { - items.push({ HeaderName: 'x-forwarded-host', HeaderValue: forwardedHost }); - } - if (hasText(fetcherKey)) { - items.push({ HeaderName: 'x-edgeoptimize-fetcher-key', HeaderValue: fetcherKey }); - } - return items; -} - -/** - * Add the Edge Optimize origin to a CloudFront distribution (idempotent + self-healing). - * - * Reads the distribution config and, if no Edge Optimize origin exists yet, appends a custom HTTPS - * origin pointing at the EO target domain with the EO request headers. If the origin already exists - * but its custom headers do not match the desired set (e.g. it was created header-less by an - * earlier version), the headers are patched in place. Writes are applied via UpdateDistribution - * (deploy propagates in the background; we do not block on it). - * - * @param {object} credentials - temporary credentials from {@link assumeConnectorRole}. - * @param {string} distributionId - the CloudFront distribution ID. - * @param {string} [originDomain] - EO origin domain (env-driven; defaults to the dev EO domain). - * @param {object} [headers] - EO origin headers ({ apiKey, forwardedHost, fetcherKey }). - * @param {string} [region] - CloudFront control-plane region. - * @returns {Promise<{created, alreadyExisted, updated, originId}>} origin mutation outcome. - */ -export async function createEdgeOptimizeOrigin( - credentials, - distributionId, - originDomain = EDGE_OPTIMIZE_DEFAULT_ORIGIN_DOMAIN, - headers = {}, - region = EDGE_OPTIMIZE_REGION, -) { - if (!hasText(distributionId)) { - throw new Error('distributionId is required'); - } - const desiredHeaderItems = buildEdgeOptimizeOriginHeaders(headers); - - const client = new CloudFrontClient({ region, credentials }); - const result = await client.send(new GetDistributionConfigCommand({ Id: distributionId })); - const config = result.DistributionConfig; - const etag = result.ETag; - const origins = config.Origins?.Items || []; - - const existing = origins.find( - (o) => o.Id === EDGE_OPTIMIZE_ORIGIN_ID || o.DomainName === originDomain, - ); - - if (existing) { - // Idempotent — but self-heal an origin created without the EO headers (earlier bug): patch its - // CustomHeaders to the desired set when they differ. Never wipe headers if none were supplied. - const toMap = (arr) => (arr || []).reduce((acc, h) => { - acc[h.HeaderName.toLowerCase()] = h.HeaderValue; - return acc; - }, {}); - const current = toMap(existing.CustomHeaders?.Items); - const desired = toMap(desiredHeaderItems); - const headersMatch = Object.keys(desired).length === Object.keys(current).length - && Object.entries(desired).every(([k, v]) => current[k] === v); - - if (desiredHeaderItems.length === 0 || headersMatch) { - return { - created: false, alreadyExisted: true, updated: false, originId: EDGE_OPTIMIZE_ORIGIN_ID, - }; - } - - existing.CustomHeaders = { Quantity: desiredHeaderItems.length, Items: desiredHeaderItems }; - await client.send(new UpdateDistributionCommand({ - Id: distributionId, - IfMatch: etag, - DistributionConfig: config, - })); - return { - created: false, alreadyExisted: true, updated: true, originId: EDGE_OPTIMIZE_ORIGIN_ID, - }; - } - - origins.push({ - Id: EDGE_OPTIMIZE_ORIGIN_ID, - DomainName: originDomain, - OriginPath: '', - CustomHeaders: { Quantity: desiredHeaderItems.length, Items: desiredHeaderItems }, - CustomOriginConfig: { - HTTPPort: 80, - HTTPSPort: 443, - OriginProtocolPolicy: 'https-only', - OriginSslProtocols: { Quantity: 1, Items: ['TLSv1.2'] }, - OriginReadTimeout: 30, - OriginKeepaliveTimeout: 5, - }, - ConnectionAttempts: 3, - ConnectionTimeout: 10, - }); - config.Origins = { Quantity: origins.length, Items: origins }; - - await client.send(new UpdateDistributionCommand({ - Id: distributionId, - IfMatch: etag, - DistributionConfig: config, - })); - - return { - created: true, alreadyExisted: false, updated: false, originId: EDGE_OPTIMIZE_ORIGIN_ID, - }; -} - -/** - * Create or update the `edgeoptimize-routing` CloudFront Function and publish it to LIVE - * (idempotent). Mirrors the standalone wizard's create-function step. - * - * @param {object} credentials - temporary credentials from {@link assumeConnectorRole}. - * @param {string} defaultOriginId - the default-behavior target origin id (baked into the code). - * @param {string[]|null} [targetedPaths] - explicit paths to target, or null for all HTML pages. - * @param {string} [region] - CloudFront control-plane region. - * @returns {Promise<{name: string, created: boolean, stage: string}>} - */ -export async function createEdgeOptimizeRoutingFunction( - credentials, - defaultOriginId, - distributionId, - targetedPaths = null, - region = EDGE_OPTIMIZE_REGION, -) { - if (!hasText(defaultOriginId)) { - throw new Error('defaultOriginId is required'); - } - if (!hasText(distributionId)) { - throw new Error('distributionId is required'); - } - const functionName = eoRoutingFunctionName(distributionId); - const client = new CloudFrontClient({ region, credentials }); - const code = Buffer.from(buildRoutingFunctionCode(defaultOriginId, targetedPaths), 'utf-8'); - const functionConfig = { - Comment: 'EdgeOptimize agentic bot routing — managed by LLM Optimizer', - Runtime: 'cloudfront-js-2.0', - }; - - // Look up the DEVELOPMENT stage to get its ETag (needed to update an existing function). - let existingEtag = null; - try { - const desc = await client.send(new DescribeFunctionCommand({ - Name: functionName, - Stage: 'DEVELOPMENT', - })); - existingEtag = desc.ETag; - } catch (err) { - if (err.name !== 'NoSuchFunctionExists') { - throw err; - } - } - - let etag; - if (existingEtag) { - const updated = await client.send(new UpdateFunctionCommand({ - Name: functionName, - IfMatch: existingEtag, - FunctionConfig: functionConfig, - FunctionCode: code, - })); - etag = updated.ETag; - } else { - const created = await client.send(new CreateFunctionCommand({ - Name: functionName, - FunctionConfig: functionConfig, - FunctionCode: code, - })); - etag = created.ETag; - } - - await client.send(new PublishFunctionCommand({ - Name: functionName, - IfMatch: etag, - })); - - return { name: functionName, created: !existingEtag, stage: 'LIVE' }; -} - -/** - * Add the Edge Optimize routing headers to the cache key/forwarded set for the target behavior. - * - * Ported from the standalone wizard's detect-cache + apply-cache (server.mjs). Handles all three - * scenarios the wizard supports, because real distributions commonly use an AWS-managed policy: - * - `legacy` — behavior has no CachePolicyId (uses ForwardedValues): add EO headers there. - * - `custom` — behavior uses a customer-owned cache policy: UpdateCachePolicy to add EO headers. - * - `managed` — behavior uses an AWS-managed policy (cannot be updated → "update is not allowed - * for this policy"): CLONE it into a custom `edgeoptimize-cache` policy with EO headers and - * repoint the behavior to it. Idempotent by policy name. - * `setMinTTLZero` (default true) forces MinTTL to 0 so agentic responses are not over-cached. - * - * @param {object} credentials - temporary credentials from {@link assumeConnectorRole}. - * @param {string} distributionId - the CloudFront distribution ID. - * @param {string} pathPattern - the behavior to target (`default` for the default behavior). - * @param {object} [opts] - * @param {boolean} [opts.setMinTTLZero=true] - force the policy MinTTL to 0. - * @param {string} [opts.region] - CloudFront control-plane region. - * @returns {Promise<{scenario: string, policyId: string|null, updated: boolean, - * alreadyForwarded: boolean, reused?: boolean}>} - */ -export async function applyEdgeOptimizeCacheHeaders( - credentials, - distributionId, - pathPattern, - { setMinTTLZero = true, region = EDGE_OPTIMIZE_REGION } = {}, -) { - if (!hasText(distributionId)) { - throw new Error('distributionId is required'); - } - if (!hasText(pathPattern)) { - throw new Error('pathPattern is required'); - } - const client = new CloudFrontClient({ region, credentials }); - - const distResult = await client.send(new GetDistributionConfigCommand({ Id: distributionId })); - const config = distResult.DistributionConfig; - const behavior = getBehaviorFromConfig(config, pathPattern); - const policyId = behavior.CachePolicyId; - - // ── Scenario A: legacy (ForwardedValues, no CachePolicyId) ────────────── - if (!policyId) { - const fv = behavior.ForwardedValues || {}; - const items = fv.Headers?.Items || []; - const lower = items.map((x) => x.toLowerCase()); - let changed = false; - if (!lower.includes('*')) { - EDGE_OPTIMIZE_CACHE_HEADERS.forEach((h) => { - if (!lower.includes(h)) { - items.push(h); - changed = true; - } - }); - fv.Headers = { Quantity: items.length, Items: items }; - behavior.ForwardedValues = fv; - } - if (setMinTTLZero && Number(behavior.MinTTL ?? 0) > EDGE_OPTIMIZE_MIN_TTL_KEEP_THRESHOLD) { - behavior.MinTTL = 0; - changed = true; - } - if (!changed) { - return { - scenario: 'legacy', policyId: null, updated: false, alreadyForwarded: true, - }; - } - await client.send(new UpdateDistributionCommand({ - Id: distributionId, IfMatch: distResult.ETag, DistributionConfig: config, - })); - return { - scenario: 'legacy', policyId: null, updated: true, alreadyForwarded: false, - }; - } - - // Determine whether the attached policy is AWS-managed (managed policies cannot be updated). - const managedList = await client.send(new ListCachePoliciesCommand({ Type: 'managed' })); - const managedIds = new Set( - (managedList.CachePolicyList?.Items || []).map((i) => i.CachePolicy.Id), - ); - const isManaged = managedIds.has(policyId); - - // Helper: add the EO headers to a HeadersConfig in place; returns true if anything changed. - const addEoHeaders = (params) => { - const hc = params.HeadersConfig || { HeaderBehavior: 'none' }; - if (hc.HeaderBehavior === 'allViewer' || hc.HeaderBehavior === 'all') { - return false; - } - const items = hc.Headers?.Items || []; - const lower = items.map((x) => x.toLowerCase()); - const missing = EDGE_OPTIMIZE_CACHE_HEADERS.filter((h) => !lower.includes(h)); - if (missing.length === 0) { - return false; - } - missing.forEach((h) => items.push(h)); - hc.HeaderBehavior = 'whitelist'; - hc.Headers = { Quantity: items.length, Items: items }; - // eslint-disable-next-line no-param-reassign - params.HeadersConfig = hc; - return true; - }; - - // ── Scenario B: custom policy → update it in place ────────────────────── - if (!isManaged) { - const pcResult = await client.send(new GetCachePolicyConfigCommand({ Id: policyId })); - const pc = pcResult.CachePolicyConfig; - const params = pc.ParametersInCacheKeyAndForwardedToOrigin || {}; - const headersChanged = addEoHeaders(params); - pc.ParametersInCacheKeyAndForwardedToOrigin = params; - const needsMinTtl = setMinTTLZero - && Number(pc.MinTTL ?? 0) > EDGE_OPTIMIZE_MIN_TTL_KEEP_THRESHOLD; - if (!headersChanged && !needsMinTtl) { - return { - scenario: 'custom', policyId, updated: false, alreadyForwarded: true, - }; - } - if (needsMinTtl) { - pc.MinTTL = 0; - } - await client.send(new UpdateCachePolicyCommand({ - Id: policyId, IfMatch: pcResult.ETag, CachePolicyConfig: pc, - })); - return { - scenario: 'custom', policyId, updated: true, alreadyForwarded: false, - }; - } - - // ── Scenario C: managed policy → clone into edgeoptimize-cache + repoint ── - const srcResult = await client.send(new GetCachePolicyCommand({ Id: policyId })); - const cloned = JSON.parse(JSON.stringify(srcResult.CachePolicy.CachePolicyConfig)); - const sourceName = cloned.Name; - const clonedName = buildEoClonedCachePolicyName(sourceName, distributionId); - cloned.Name = clonedName; - cloned.Comment = `Cloned from ${sourceName} with Edge Optimize headers — managed by LLM Optimizer`; - if (setMinTTLZero && Number(cloned.MinTTL ?? 0) > EDGE_OPTIMIZE_MIN_TTL_KEEP_THRESHOLD) { - cloned.MinTTL = 0; - } - const clonedParams = cloned.ParametersInCacheKeyAndForwardedToOrigin || {}; - addEoHeaders(clonedParams); - cloned.ParametersInCacheKeyAndForwardedToOrigin = clonedParams; - - // Idempotent: reuse an existing clone only when it matches the FULL derived name (exact) - // -adobe-. If the customer re-pointed the behavior to a - // different source since a prior run, the derived name differs, so we create a clone matching the - // CURRENT source instead of reusing a clone built from a different base. - const customList = await client.send(new ListCachePoliciesCommand({ Type: 'custom' })); - const existing = (customList.CachePolicyList?.Items || []).find( - (i) => i.CachePolicy.CachePolicyConfig.Name === clonedName, - ); - let newPolicyId; - let reused = false; - if (existing) { - newPolicyId = existing.CachePolicy.Id; - reused = true; - } else { - const created = await client.send(new CreateCachePolicyCommand({ CachePolicyConfig: cloned })); - newPolicyId = created.CachePolicy.Id; - } - - // Re-read the distribution for a fresh ETag, repoint the behavior to the new custom policy. - const freshDist = await client.send(new GetDistributionConfigCommand({ Id: distributionId })); - const freshConfig = freshDist.DistributionConfig; - const freshBehavior = getBehaviorFromConfig(freshConfig, pathPattern); - freshBehavior.CachePolicyId = newPolicyId; - delete freshBehavior.ForwardedValues; // cannot coexist with CachePolicyId - await client.send(new UpdateDistributionCommand({ - Id: distributionId, IfMatch: freshDist.ETag, DistributionConfig: freshConfig, - })); - - return { - scenario: 'managed', policyId: newPolicyId, updated: true, alreadyForwarded: false, reused, - }; -} - -const LAMBDA_TRUST_POLICY = JSON.stringify({ - Version: '2012-10-17', - Statement: [{ - Effect: 'Allow', - Principal: { Service: ['lambda.amazonaws.com', 'edgelambda.amazonaws.com'] }, - Action: 'sts:AssumeRole', - }], -}); - -function buildCwLogsPolicy(accountId, functionName) { - return JSON.stringify({ - Version: '2012-10-17', - Statement: [ - { - Effect: 'Allow', - Action: 'logs:CreateLogGroup', - Resource: `arn:aws:logs:*:${accountId}:*`, - }, - { - Effect: 'Allow', - Action: ['logs:CreateLogStream', 'logs:PutLogEvents'], - Resource: [`arn:aws:logs:*:${accountId}:log-group:/aws/lambda/us-east-1.${functionName}:*`], - }, - ], - }); -} - -// ── Minimal zip builder (no external deps) — ported from the standalone wizard's buildZip. ── -// CRC32 + ZIP local/central directory headers are inherently bit-twiddling and densely packed; the -// helix lint rules against bitwise ops, multiple statements per line, and long lines do not fit -// binary-format code, so they are disabled for this block only. -/* eslint-disable no-bitwise, max-statements-per-line, max-len */ -const CRC32_TABLE = (() => { - const t = new Uint32Array(256); - for (let i = 0; i < 256; i += 1) { - let c = i; - for (let j = 0; j < 8; j += 1) { c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1); } - t[i] = c; - } - return t; -})(); - -function crc32(buf) { - let c = 0xFFFFFFFF; - for (let i = 0; i < buf.length; i += 1) { c = (c >>> 8) ^ CRC32_TABLE[(c ^ buf[i]) & 0xFF]; } - return (c ^ 0xFFFFFFFF) >>> 0; -} - -/** - * Build an in-memory zip containing a single file. Used to package the Lambda@Edge code without - * adding a zip dependency to the runtime bundle. - * - * @param {string} filename - the entry name inside the zip (e.g. `index.mjs`). - * @param {string|Buffer} content - the file content. - * @returns {Buffer} the zip archive bytes. - */ -export function buildLambdaZip(filename, content) { - const data = Buffer.isBuffer(content) ? content : Buffer.from(content, 'utf-8'); - const compressed = deflateRawSync(data, { level: 9 }); - const crcVal = crc32(data); - const fn = Buffer.from(filename, 'utf-8'); - // Fixed DOS date/time (1980-01-01 00:00:00) so the zip — and thus the Lambda CodeSha256 — is - // deterministic for identical source. A timestamp here would change the hash on every call, - // causing needless code updates and version churn. - const dosDate = (0 << 9) | (1 << 5) | 1; - const dosTime = 0; - - const lh = Buffer.alloc(30 + fn.length); - lh.writeUInt32LE(0x04034b50, 0); lh.writeUInt16LE(20, 4); lh.writeUInt16LE(0, 6); - lh.writeUInt16LE(8, 8); lh.writeUInt16LE(dosTime, 10); lh.writeUInt16LE(dosDate, 12); - lh.writeUInt32LE(crcVal, 14); lh.writeUInt32LE(compressed.length, 18); - lh.writeUInt32LE(data.length, 22); lh.writeUInt16LE(fn.length, 26); lh.writeUInt16LE(0, 28); - fn.copy(lh, 30); - - const cd = Buffer.alloc(46 + fn.length); - cd.writeUInt32LE(0x02014b50, 0); cd.writeUInt16LE(20, 4); cd.writeUInt16LE(20, 6); - cd.writeUInt16LE(0, 8); cd.writeUInt16LE(8, 10); cd.writeUInt16LE(dosTime, 12); - cd.writeUInt16LE(dosDate, 14); cd.writeUInt32LE(crcVal, 16); - cd.writeUInt32LE(compressed.length, 20); cd.writeUInt32LE(data.length, 24); - cd.writeUInt16LE(fn.length, 28); cd.writeUInt16LE(0, 30); cd.writeUInt16LE(0, 32); - cd.writeUInt16LE(0, 34); cd.writeUInt16LE(0, 36); cd.writeUInt32LE(0, 38); - cd.writeUInt32LE(0, 42); fn.copy(cd, 46); - - const eocd = Buffer.alloc(22); - eocd.writeUInt32LE(0x06054b50, 0); eocd.writeUInt16LE(0, 4); eocd.writeUInt16LE(0, 6); - eocd.writeUInt16LE(1, 8); eocd.writeUInt16LE(1, 10); - eocd.writeUInt32LE(cd.length, 12); eocd.writeUInt32LE(lh.length + compressed.length, 16); - eocd.writeUInt16LE(0, 20); - - return Buffer.concat([lh, compressed, cd, eocd]); -} -/* eslint-enable no-bitwise, max-statements-per-line, max-len */ - -// Latest published numbered version (skips $LATEST). Returns { versionArn, version, codeSha256 } -// or null when no numbered version has been published yet. -async function getLatestLambdaVersion(lambda, functionName) { - const resp = await lambda.send( - new ListVersionsByFunctionCommand({ FunctionName: functionName }), - ); - const numbered = (resp.Versions || []).filter((v) => v.Version && v.Version !== '$LATEST'); - if (numbered.length === 0) { - return null; - } - const latest = numbered.sort((a, b) => Number(b.Version) - Number(a.Version))[0]; - return { versionArn: latest.FunctionArn, version: latest.Version, codeSha256: latest.CodeSha256 }; -} - -/** - * Create (or update) the `edgeoptimize-origin` Lambda@Edge function and publish a version - * (idempotent). Mirrors the standalone wizard's create-lambda step: ensure the exec role exists - * (trusting lambda + edgelambda) with a basic CloudWatch-logs inline policy, then create/update the - * function code and publish a numbered version. Newly-created IAM roles take a few seconds to - * propagate, so the create path retries CreateFunction with a bounded back-off - * (up to ~5×5s, within ~30s). - * - * @param {object} credentials - temporary credentials from {@link assumeConnectorRole}. - * @param {string} accountId - the 12-digit customer AWS account ID (for the logs-policy ARNs). - * @param {object} [opts] - * @param {string} [opts.region] - control-plane region (Lambda@Edge must be us-east-1). - * @param {number} [opts.roleWaitMs] - extra wait after creating a new role before first create. - * @param {number} [opts.retryDelayMs] - back-off between CreateFunction role-propagation retries. - * @returns {Promise<{functionArn: string, versionArn: string, version: string, - * roleArn: string, created: boolean}>} - */ -export async function createEdgeOptimizeLambda( - credentials, - accountId, - { - region = EDGE_OPTIMIZE_REGION, - distributionId, - originDomain = EDGE_OPTIMIZE_DEFAULT_ORIGIN_DOMAIN, - roleWaitMs = 12000, - retryDelayMs = 5000, - } = {}, -) { - if (!/^[0-9]{12}$/.test(String(accountId))) { - throw new Error('accountId must be a 12-digit AWS account ID'); - } - if (!hasText(distributionId)) { - throw new Error('distributionId is required'); - } - const lambdaName = eoLambdaFunctionName(distributionId); - const roleName = eoLambdaRoleName(distributionId); - const lambda = new LambdaClient({ region, credentials }); - const iam = new IAMClient({ region, credentials }); - - // Bake the EO origin domain into the handler so it matches the EO origin's DomainName per env. - const zipBuffer = buildLambdaZip('index.mjs', buildEdgeOptimizeLambdaCode(originDomain)); - - // ── 1. Ensure the exec role exists with the current trust policy. ── - let roleArn; - let roleIsNew = false; - try { - const existing = await iam.send( - new GetRoleCommand({ RoleName: roleName }), - ); - roleArn = existing.Role.Arn; - await iam.send(new UpdateAssumeRolePolicyCommand({ - RoleName: roleName, - PolicyDocument: LAMBDA_TRUST_POLICY, - })); - } catch (err) { - if (err.name !== 'NoSuchEntityException') { - throw err; - } - const created = await iam.send(new CreateRoleCommand({ - RoleName: roleName, - AssumeRolePolicyDocument: LAMBDA_TRUST_POLICY, - Description: 'Execution role for EdgeOptimize Lambda@Edge function', - })); - roleArn = created.Role.Arn; - roleIsNew = true; - } - - // ── 2. Attach the CloudWatch-logs inline policy. ── - await iam.send(new PutRolePolicyCommand({ - RoleName: roleName, - PolicyName: 'EdgeOptimizeLambdaLogging', - PolicyDocument: buildCwLogsPolicy(String(accountId), lambdaName), - })); - - // ── 3. Advance the function state machine WITHOUT blocking on provisioning. ── - // This runs behind a CDN/gateway with a ~60s first-byte timeout, so we must never wait for a - // fresh function to become Active (30–60s) inside the request. Each call does at most one fast - // step and returns `status: 'provisioning' | 'ready'`; the UI polls until ready. - let cfg = null; - try { - cfg = await lambda.send( - new GetFunctionConfigurationCommand({ FunctionName: lambdaName }), - ); - } catch (err) { - if (err.name !== 'ResourceNotFoundException') { - throw err; - } - } - - // Function does not exist yet → create it (returns fast in Pending) and report provisioning. - if (!cfg) { - if (roleIsNew && roleWaitMs > 0) { - await delay(roleWaitMs); - } - let lastErr; - let createdArn; - /* eslint-disable no-await-in-loop */ - for (let attempt = 0; attempt < 3; attempt += 1) { - try { - const created = await lambda.send(new LambdaCreateFunctionCommand({ - FunctionName: lambdaName, - Runtime: 'nodejs24.x', - Role: roleArn, - Handler: 'index.handler', - Code: { ZipFile: zipBuffer }, - Description: 'EdgeOptimize origin request/response handler (Lambda@Edge)', - Timeout: 5, - MemorySize: 128, - })); - createdArn = created.FunctionArn; - lastErr = null; - break; - } catch (createErr) { - lastErr = createErr; - // A just-created role may not have propagated yet — short bounded retry, then give up - // (the next poll will succeed once it propagates) so we never block long. - const isRolePropagation = createErr.name === 'InvalidParameterValueException' - && (createErr.message || '').toLowerCase().includes('role'); - if (createErr.name === 'ResourceConflictException') { - // Created concurrently by a prior (timed-out) call — treat as provisioning. - lastErr = null; - break; - } - if (!isRolePropagation || attempt >= 2) { - throw createErr; - } - await delay(retryDelayMs); - } - } - /* eslint-enable no-await-in-loop */ - if (lastErr) { - throw lastErr; - } - return { - status: 'provisioning', functionArn: createdArn, roleArn, created: true, versionArn: null, - }; - } - - // Still finalizing a create/update → report provisioning, don't touch it (avoids conflicts). - if (cfg.State === 'Pending' || cfg.LastUpdateStatus === 'InProgress') { - return { - status: 'provisioning', functionArn: cfg.FunctionArn, roleArn, created: false, versionArn: null, - }; - } - - // Active and idle. If a numbered version already exists, reuse it (idempotent). - const existingVersion = await getLatestLambdaVersion(lambda, lambdaName); - if (existingVersion) { - return { - status: 'ready', - functionArn: cfg.FunctionArn, - versionArn: existingVersion.versionArn, - version: existingVersion.version, - roleArn, - created: false, - alreadyExisted: true, - }; - } - - // Active, idle, no version yet → publish one (fast on an idle function). - const published = await lambda.send(new PublishVersionCommand({ - FunctionName: lambdaName, - Description: 'Published by LLM Optimizer CloudFront wizard', - })); - return { - status: 'ready', - functionArn: cfg.FunctionArn, - versionArn: published.FunctionArn, // includes the :N version suffix - version: published.Version, - roleArn, - created: false, - alreadyExisted: false, - }; -} - -/** - * Read-only inspection of the Lambda@Edge execution role: whether it exists and is correctly - * configured. Checks the trust policy (must allow both lambda + edgelambda) and the CloudWatch-logs - * inline policy the deploy attaches. Drives both the Review wording and the deploy's heal decision. - * - * @param {IAMClient} iam - an IAM client built with the connector credentials. - * @param {string} roleName - the EO Lambda@Edge execution role name. - * @returns {Promise<{exists: boolean, trustOk?: boolean, logsPolicyOk?: boolean}>} - */ -async function inspectEdgeOptimizeLambdaRole(iam, roleName) { - let role; - try { - const res = await iam.send(new GetRoleCommand({ RoleName: roleName })); - role = res.Role; - } catch (err) { - if (err.name === 'NoSuchEntityException') { - return { exists: false }; - } - throw err; - } - - // Trust must allow both lambda.amazonaws.com and edgelambda.amazonaws.com (Lambda@Edge). - let trustOk = false; - const rawTrust = role.AssumeRolePolicyDocument || ''; - if (rawTrust) { - let doc = null; - try { - doc = JSON.parse(decodeURIComponent(rawTrust)); - } catch { - doc = null; - } - const services = ((doc && doc.Statement) || []).flatMap((st) => { - const svc = st.Principal && st.Principal.Service; - return Array.isArray(svc) ? svc : [svc]; - }).filter(Boolean); - trustOk = services.includes('lambda.amazonaws.com') - && services.includes('edgelambda.amazonaws.com'); - } - - // The CloudWatch-logs inline policy the deploy attaches. - let logsPolicyOk = false; - try { - await iam.send(new GetRolePolicyCommand({ - RoleName: roleName, - PolicyName: 'EdgeOptimizeLambdaLogging', - })); - logsPolicyOk = true; - } catch (err) { - if (err.name !== 'NoSuchEntityException') { - throw err; - } - } - - return { exists: true, trustOk, logsPolicyOk }; -} - -/** - * Read-only status of the Edge Optimize Lambda@Edge function AND its execution role, so the wizard - * can check on entry (and poll after a slow/timed-out create) whether the function exists and has a - * published version, and whether the role is present (`roleExists`) and correctly configured - * (`roleOk` = exists + trust + logs policy). `roleOk` lets the deploy heal a missing or - * mis-configured role even when the function is already published. - * - * @param {object} credentials - temporary credentials from {@link assumeConnectorRole}. - * @param {string} [region] - control-plane region. - * @returns {Promise<{exists: boolean, roleExists: boolean, roleOk: boolean, state?: string, - * lastUpdateStatus?: string, functionArn?: string, versionArn: string|null, version?: string, - * ready: boolean}>} - */ -export async function getEdgeOptimizeLambdaStatus( - credentials, - distributionId, - region = EDGE_OPTIMIZE_REGION, -) { - if (!hasText(distributionId)) { - throw new Error('distributionId is required'); - } - const lambdaName = eoLambdaFunctionName(distributionId); - const roleName = eoLambdaRoleName(distributionId); - const lambda = new LambdaClient({ region, credentials }); - const iam = new IAMClient({ region, credentials }); - - // Execution role status: present AND correctly configured (trust + logs policy). roleOk gates the - // deploy's "done" decision so a missing OR mis-configured role is healed even when the function - // is already published. - const role = await inspectEdgeOptimizeLambdaRole(iam, roleName); - const roleExists = Boolean(role.exists); - const roleOk = Boolean(role.exists && role.trustOk && role.logsPolicyOk); - - // Function status. - let cfg; - try { - cfg = await lambda.send( - new GetFunctionConfigurationCommand({ FunctionName: lambdaName }), - ); - } catch (err) { - if (err.name === 'ResourceNotFoundException') { - return { - roleExists, roleOk, exists: false, versionArn: null, ready: false, - }; - } - throw err; - } - const latest = await getLatestLambdaVersion(lambda, lambdaName); - const ready = cfg.State === 'Active' && cfg.LastUpdateStatus !== 'InProgress' && !!latest; - return { - roleExists, - roleOk, - exists: true, - state: cfg.State, - lastUpdateStatus: cfg.LastUpdateStatus, - functionArn: cfg.FunctionArn, - versionArn: latest?.versionArn || null, - version: latest?.version, - ready, - }; -} - -// Edge Optimize owns exactly these association slots on a behavior; every other association is the -// customer's and must be preserved. A non-EO association ON one of these slots is a conflict we -// refuse (rather than overwrite), so customer edge logic is never silently removed. -const EDGE_OPTIMIZE_LAMBDA_EVENTS = ['origin-request', 'origin-response']; -const isEdgeOptimizeFunctionArn = (arn) => /edgeoptimize-routing/i.test(arn || ''); -const isEdgeOptimizeLambdaArn = (arn) => /edgeoptimize-origin/i.test(arn || ''); - -/** - * Inspect a behavior's existing associations and return a conflict message when a NON-Edge-Optimize - * association occupies a slot EO needs (a different viewer-request function, a viewer-request - * Lambda@Edge that CloudFront forbids alongside a function, or an origin-request/origin-response - * Lambda@Edge). Returns null when EO can be wired in while preserving everything else. EO's own - * prior associations (matched by name) are never flagged, so re-deploys stay idempotent. - * - * @param {object} behavior - the cache behavior config. - * @param {string} pathPattern - the behavior label (for the message). - * @returns {string|null} - */ -function findEdgeOptimizeAssociationConflict(behavior, pathPattern) { - const fns = behavior?.FunctionAssociations?.Items || []; - const lambdas = behavior?.LambdaFunctionAssociations?.Items || []; - - const viewerFn = fns.find( - (a) => a.EventType === 'viewer-request' && !isEdgeOptimizeFunctionArn(a.FunctionARN), - ); - if (viewerFn) { - return `Behavior '${pathPattern}' already has a different viewer-request function associated ` - + `(${viewerFn.FunctionARN}). Remove it before applying Edge Optimize routing.`; - } - const viewerLambda = lambdas.find((a) => a.EventType === 'viewer-request'); - if (viewerLambda) { - return `Behavior '${pathPattern}' already has a viewer-request Lambda@Edge ` - + `(${viewerLambda.LambdaFunctionARN}) which conflicts with the Edge Optimize routing ` - + 'function. Remove it before applying Edge Optimize routing.'; - } - const originLambda = lambdas.find( - (a) => EDGE_OPTIMIZE_LAMBDA_EVENTS.includes(a.EventType) - && !isEdgeOptimizeLambdaArn(a.LambdaFunctionARN), - ); - if (originLambda) { - return `Behavior '${pathPattern}' already has a different ${originLambda.EventType} ` - + `Lambda@Edge associated (${originLambda.LambdaFunctionARN}). Remove it before applying ` - + 'Edge Optimize routing.'; - } - return null; -} - -/** - * Wire the routing CloudFront Function (viewer-request) and the Lambda@Edge function - * (origin-request + origin-response) onto a selected cache behavior. Mirrors the standalone - * wizard's apply-associations step. - * - * @param {object} credentials - temporary credentials from {@link assumeConnectorRole}. - * @param {string} distributionId - the CloudFront distribution ID. - * @param {string} pathPattern - the behavior to wire (`default` for the default behavior). - * @param {string} lambdaVersionArn - the published, versioned Lambda@Edge ARN. - * @param {string} [region] - CloudFront control-plane region. - * @returns {Promise<{cfFunctionArn: string, lambdaArn: string}>} - */ -export async function applyEdgeOptimizeAssociations( - credentials, - distributionId, - pathPattern, - lambdaVersionArn, - region = EDGE_OPTIMIZE_REGION, -) { - if (!hasText(distributionId)) { - throw new Error('distributionId is required'); - } - if (!hasText(pathPattern)) { - throw new Error('pathPattern is required'); - } - if (!hasText(lambdaVersionArn)) { - throw new Error('lambdaVersionArn is required'); - } - const client = new CloudFrontClient({ region, credentials }); - const functionName = eoRoutingFunctionName(distributionId); - - const fnResult = await client.send(new DescribeFunctionCommand({ - Name: functionName, - Stage: 'LIVE', - })); - const cfFunctionArn = fnResult.FunctionSummary?.FunctionMetadata?.FunctionARN; - if (!cfFunctionArn) { - throw new Error(`CloudFront function '${functionName}' not found or not published to LIVE`); - } - - const distResult = await client.send(new GetDistributionConfigCommand({ Id: distributionId })); - const config = distResult.DistributionConfig; - const behavior = getBehaviorFromConfig(config, pathPattern); - - // Refuse (rather than silently clobber) if the customer already owns a slot EO needs. - const conflict = findEdgeOptimizeAssociationConflict(behavior, pathPattern); - if (conflict) { - throw new Error(conflict); - } - - // Merge, don't replace: preserve every association on event types EO does NOT own (e.g. a - // viewer-response function, a viewer-response lambda) and (re)set ONLY EO's own slots — - // viewer-request (function) + origin-request/origin-response (lambda). Wholesale replacement here - // would drop the customer's edge logic. - const existingFns = behavior.FunctionAssociations?.Items || []; - const existingLambdas = behavior.LambdaFunctionAssociations?.Items || []; - const mergedFns = [ - ...existingFns.filter((a) => a.EventType !== 'viewer-request'), - { FunctionARN: cfFunctionArn, EventType: 'viewer-request' }, - ]; - const mergedLambdas = [ - ...existingLambdas.filter( - (a) => a.EventType !== 'viewer-request' && !EDGE_OPTIMIZE_LAMBDA_EVENTS.includes(a.EventType), - ), - { LambdaFunctionARN: lambdaVersionArn, EventType: 'origin-request', IncludeBody: false }, - { LambdaFunctionARN: lambdaVersionArn, EventType: 'origin-response', IncludeBody: false }, - ]; - behavior.FunctionAssociations = { Quantity: mergedFns.length, Items: mergedFns }; - behavior.LambdaFunctionAssociations = { Quantity: mergedLambdas.length, Items: mergedLambdas }; - - await client.send(new UpdateDistributionCommand({ - Id: distributionId, - IfMatch: distResult.ETag, - DistributionConfig: config, - })); - - return { cfFunctionArn, lambdaArn: lambdaVersionArn }; -} - -async function fetchEdgeOptimizeHeaders(url, userAgent) { - const response = await fetch(url, { - redirect: 'manual', - headers: { 'user-agent': userAgent }, - }); - const headers = {}; - response.headers.forEach((value, key) => { - if (key.toLowerCase().startsWith('x-edgeoptimize')) { - headers[key.toLowerCase()] = value; - } - }); - // Drain the body so the connection can be reused/closed. - await response.arrayBuffer().catch(() => {}); - return { status: response.status, headers }; -} - -/** - * Verify Edge Optimize routing end-to-end by fetching the distribution domain as an agentic bot - * and as a human, then inspecting the `x-edgeoptimize-*` headers. Mirrors the standalone wizard's - * verify logic: success REQUIRES `x-edgeoptimize-request-id` on the bot response (served from the - * Edge Optimize prerender cache). `x-edgeoptimize-fo` means failover to origin — routing worked but - * the page is NOT optimised, which is NOT success. - * - * @param {string} url - the URL to probe (typically `https:///`). - * @returns {Promise<{passed: boolean, requestId: string|null, - * details: {bot: object, human: object}}>} - */ -export async function verifyEdgeOptimizeRouting(url) { - if (!hasText(url)) { - throw new Error('url is required'); - } - const botUa = 'chatgpt-user'; - const humanUa = 'Mozilla/5.0'; - const [bot, human] = await Promise.all([ - fetchEdgeOptimizeHeaders(url, botUa), - fetchEdgeOptimizeHeaders(url, humanUa), - ]); - - const requestId = bot.headers['x-edgeoptimize-request-id'] || null; - const passed = Boolean(requestId) - && !human.headers['x-edgeoptimize-request-id'] - && !human.headers['x-edgeoptimize-fo'] - && human.headers['x-edgeoptimize-proxy'] !== '1'; - - // `ua` is carried through so the wizard can show which User-Agent each probe used. - return { - passed, - requestId, - details: { bot: { ua: botUa, ...bot }, human: { ua: humanUa, ...human } }, - }; -} - -// The ordered deploy steps + their human labels, in the sequence the orchestrator advances them. -// Exported so the controller/tests can assert the contract without re-declaring it. -export const EDGE_OPTIMIZE_DEPLOY_STEPS = [ - { key: 'origin', label: 'Edge Optimize origin' }, - { key: 'function', label: 'Routing function' }, - { key: 'cache', label: 'Cache policy' }, - { key: 'lambda', label: 'Lambda@Edge' }, - { key: 'associate', label: 'Association' }, - { key: 'propagation', label: 'Propagation' }, - { key: 'verify', label: 'Verify routing' }, -]; - -/** - * True when the `edgeoptimize-routing` CloudFront Function is already published to LIVE. - * Used to gate the function step so we never re-publish (which causes deploy churn). - * - * @param {CloudFrontClient} client - a CloudFront client built with the connector credentials. - * @returns {Promise} - */ -async function isRoutingFunctionLive(client, distributionId) { - try { - const desc = await client.send(new DescribeFunctionCommand({ - Name: eoRoutingFunctionName(distributionId), - Stage: 'LIVE', - })); - return Boolean(desc?.FunctionSummary?.FunctionMetadata?.FunctionARN); - } catch (err) { - if (err.name === 'NoSuchFunctionExists') { - return false; - } - throw err; - } -} - -/** - * True when the target behavior already has BOTH the Edge Optimize routing CloudFront Function - * (viewer-request) AND the Edge Optimize Lambda@Edge (origin-request) associated. Used to gate the - * associate step so we never re-issue UpdateDistribution (needless re-deploy) once wired. - * - * @param {CloudFrontClient} client - a CloudFront client built with the connector credentials. - * @param {string} distributionId - the CloudFront distribution ID. - * @param {string} pathPattern - the behavior to inspect (`default` for the default behavior). - * @returns {Promise} - */ -async function isBehaviorAlreadyAssociated(client, distributionId, pathPattern) { - const result = await client.send(new GetDistributionConfigCommand({ Id: distributionId })); - const config = result.DistributionConfig || {}; - let behavior; - if (pathPattern === 'default' || pathPattern === 'Default (*)') { - behavior = config.DefaultCacheBehavior; - } else { - behavior = (config.CacheBehaviors?.Items || []).find((b) => b.PathPattern === pathPattern); - } - if (!behavior) { - return false; - } - const hasCfFunction = (behavior.FunctionAssociations?.Items || []).some( - (a) => a.EventType === 'viewer-request' && /edgeoptimize-routing/i.test(a.FunctionARN || ''), - ); - const hasLambda = (behavior.LambdaFunctionAssociations?.Items || []).some( - (a) => a.EventType === 'origin-request' && /edgeoptimize-origin/i.test(a.LambdaFunctionARN || ''), - ); - return hasCfFunction && hasLambda; -} - -/** - * Run one poll of the idempotent Edge Optimize "Deploy routing" orchestrator. - * - * Advances the deploy sequence (origin → function → cache → lambda → associate → verify) as far as - * it safely can in a single call, staying well under the CDN/gateway ~60s first-byte timeout. Each - * step is gated so a re-poll never re-mutates already-completed work (no CloudFront re-deploy - * churn, no CF-function re-publish). Designed to be called once and then polled every ~30s by the - * wizard UI: each call returns the per-step status and the FE keeps polling until verify is green. - * - * Stops advancing (returning earlier steps' real status and later steps `pending`) when the - * Lambda@Edge is still provisioning — the next poll re-checks. A step that throws is marked - * `error` on its own row (with later steps `pending`); the caller still returns HTTP 200 so the FE - * shows the failure on that row and a re-poll retries idempotently. - * - * @param {object} credentials - temporary credentials from {@link assumeConnectorRole}. - * @param {object} params - * @param {string} params.distributionId - the CloudFront distribution ID. - * @param {string} params.originId - the default-behavior target origin id (failover origin). - * @param {string} params.behavior - the cache behavior to target (`default` for the default). - * @param {string} [params.originDomain] - the Edge Optimize origin domain. - * @param {object} [params.originHeaders] - EO origin headers ({ apiKey, forwardedHost }). - * @param {string} params.accountId - the 12-digit customer AWS account ID. - * @param {string} [region] - CloudFront control-plane region. - * @returns {Promise<{routingDeployed: boolean, verified: boolean, steps: Array}>} - */ -export async function runEdgeOptimizeDeployStep( - credentials, - { - distributionId, originId, behavior, originDomain, originHeaders, accountId, - }, - region = EDGE_OPTIMIZE_REGION, -) { - // Start every step pending; each handler flips its own row to done/in_progress/error. - const steps = EDGE_OPTIMIZE_DEPLOY_STEPS.map((s) => ({ ...s, status: 'pending' })); - const byKey = (key) => steps.find((s) => s.key === key); - const client = new CloudFrontClient({ region, credentials }); - - let routingDeployed = false; - let verified = false; - let lambdaVersionArn = null; - - // ── 1. origin — already idempotent (no UpdateDistribution when headers match). ── - try { - await createEdgeOptimizeOrigin( - credentials, - distributionId, - originDomain, - originHeaders, - region, - ); - byKey('origin').status = 'done'; - } catch (err) { - byKey('origin').status = 'error'; - byKey('origin').detail = cleanupHeaderValue(err.message); - return { routingDeployed, verified, steps }; - } - - // ── 2. function — GATE: skip the create+publish when already LIVE (avoids re-publish churn). ── - try { - if (await isRoutingFunctionLive(client, distributionId)) { - byKey('function').status = 'done'; - } else { - await createEdgeOptimizeRoutingFunction(credentials, originId, distributionId, null, region); - byKey('function').status = 'done'; - } - } catch (err) { - byKey('function').status = 'error'; - byKey('function').detail = cleanupHeaderValue(err.message); - return { routingDeployed, verified, steps }; - } - - // ── 3. cache — idempotent (skips UpdateDistribution/UpdateCachePolicy when already applied). ── - try { - await applyEdgeOptimizeCacheHeaders(credentials, distributionId, behavior, { region }); - byKey('cache').status = 'done'; - } catch (err) { - byKey('cache').status = 'error'; - byKey('cache').detail = cleanupHeaderValue(err.message); - return { routingDeployed, verified, steps }; - } - - // ── 4. lambda — drive the create/publish state machine each poll (idempotent + non-blocking). ── - // createEdgeOptimizeLambda creates the function when missing, no-ops while it is Pending, and — - // crucially — PUBLISHES a numbered version once the function is Active (which is what flips it to - // ready). We must call it on EVERY not-ready poll, not only when the function is missing: the - // version is published on a later poll (after the function reaches Active), so if we merely - // status-check while it "exists", the version never gets published and the step hangs at - // "provisioning" forever. - try { - const ls = await getEdgeOptimizeLambdaStatus(credentials, distributionId, region); - // Done only when the function is ready AND the role is present + correctly configured. If the - // role is missing or mis-configured (even with a published function), fall through to - // createEdgeOptimizeLambda — it (re)creates the role + heals its trust/logs policy; the - // function already exists so it just reuses the published version and returns ready. - if (ls.ready && ls.roleOk) { - lambdaVersionArn = ls.versionArn; - byKey('lambda').status = 'done'; - } else { - const created = await createEdgeOptimizeLambda( - credentials, - accountId, - { region, distributionId, originDomain }, - ); - if (created.status === 'ready') { - lambdaVersionArn = created.versionArn; - byKey('lambda').status = 'done'; - } else { - byKey('lambda').status = 'in_progress'; - byKey('lambda').detail = ls.exists - ? 'Lambda@Edge is still provisioning' - : 'Lambda@Edge create started'; - return { routingDeployed, verified, steps }; - } - } - } catch (err) { - byKey('lambda').status = 'error'; - byKey('lambda').detail = cleanupHeaderValue(err.message); - return { routingDeployed, verified, steps }; - } - - // ── 5. associate — GATE: skip UpdateDistribution when the behavior is already wired. ── - try { - if (await isBehaviorAlreadyAssociated(client, distributionId, behavior)) { - byKey('associate').status = 'done'; - } else { - await applyEdgeOptimizeAssociations( - credentials, - distributionId, - behavior, - lambdaVersionArn, - region, - ); - byKey('associate').status = 'done'; - } - routingDeployed = true; - } catch (err) { - byKey('associate').status = 'error'; - byKey('associate').detail = cleanupHeaderValue(err.message); - return { routingDeployed, verified, steps }; - } - - // ── 6. propagation — GATE: wait for the distribution to finish deploying before we verify. ── - // CloudFront reports `Status: 'InProgress'` while it propagates the new behavior/Lambda globally - // (the console shows "Deploying"); once `Deployed`, edge nodes have the change. Verifying before - // that just churns, so we hold here and surface the propagation status. distDomain is reused by - // the verify step so we only list distributions once. - let distDomain = ''; - try { - const distributions = await listCloudFrontDistributions(credentials, region); - const match = distributions.find((d) => d.id === distributionId); - distDomain = match?.domainName || ''; - if (!match) { - byKey('propagation').status = 'in_progress'; - byKey('propagation').detail = 'waiting for the distribution to appear'; - return { routingDeployed, verified, steps }; - } - if (match.status !== 'Deployed') { - byKey('propagation').status = 'in_progress'; - byKey('propagation').detail = `Deploying — CloudFront is propagating the change globally (status: ${match.status})`; - return { routingDeployed, verified, steps }; - } - byKey('propagation').status = 'done'; - byKey('propagation').detail = 'Propagated — the change is live on all edge locations'; - } catch (err) { - byKey('propagation').status = 'in_progress'; - byKey('propagation').detail = cleanupHeaderValue(err.message); - return { routingDeployed, verified, steps }; - } - - // ── 7. verify — BEST-EFFORT: in_progress (not error) until the agentic probe is optimized. ── - try { - // TEMP (testing only -- DO NOT MERGE): verify against the distribution's own *.cloudfront.net - // domain (from the dist id) because the dev test domain is not pointed at the distribution. - // PROD/main verifies the customer's real host -- RESTORE the next line before merge: - // const domain = String(originHeaders?.forwardedHost || '').trim() || distDomain; - const domain = distDomain; - if (!hasText(domain)) { - byKey('verify').status = 'in_progress'; - byKey('verify').detail = 'waiting for domain'; - return { routingDeployed, verified, steps }; - } - const result = await verifyEdgeOptimizeRouting(`https://${domain}/`); - // Per-probe summary the wizard renders (Human vs Agentic): UA, HTTP status, the - // x-edgeoptimize-request-id value (or null), and whether it failed over to the origin. - const toProbe = (d) => ({ - ua: d.ua, - status: d.status, - requestId: d.headers['x-edgeoptimize-request-id'] || null, - failover: Boolean(d.headers['x-edgeoptimize-fo']), - }); - byKey('verify').probe = { - domain, - bot: toProbe(result.details.bot), - human: toProbe(result.details.human), - }; - if (result.passed) { - verified = true; - byKey('verify').status = 'done'; - byKey('verify').detail = `Agentic routing verified — x-edgeoptimize-request-id: ${result.requestId}`; - } else if (result.details.bot.headers['x-edgeoptimize-fo'] || result.details.human.headers['x-edgeoptimize-fo']) { - byKey('verify').status = 'in_progress'; - byKey('verify').detail = 'Edge Optimize returned failover (x-edgeoptimize-fo) — serving the origin, not optimized; still retrying'; - } else { - byKey('verify').status = 'in_progress'; - byKey('verify').detail = 'waiting for propagation'; - } - } catch (err) { - // Never fail the whole deploy because verify could not run yet — surface as in_progress. - byKey('verify').status = 'in_progress'; - byKey('verify').detail = cleanupHeaderValue(err.message); - } - - return { routingDeployed, verified, steps }; -} - -/** - * Read-only "preview" of what {@link runEdgeOptimizeDeployStep} would do, without mutating - * anything. Powers the wizard's "Review & Deploy" screen: it inspects the distribution config, - * the attached cache policy, the routing CloudFront Function, and the Lambda@Edge function, and - * returns a per-step plan (create | exists | update | blocked) plus an overall canProceed/blocker. - * - * Only reads are issued (GetDistributionConfig, ListCachePolicies, DescribeFunction, - * GetFunctionConfiguration/ListVersions via the existing gates). It is intentionally defensive: - * a missing resource is "create", and a read that genuinely errors is surfaced in that step's - * detail while still allowing the plan to proceed — the ONLY hard blocker is a behavior that is - * already associated with EO routes (that is the one case the automation refuses to touch). - * - * @param {object} credentials - temporary credentials from {@link assumeConnectorRole}. - * @param {object} params - * @param {string} params.distributionId - the CloudFront distribution ID. - * @param {string} params.originId - the default-behavior target origin id (failover origin). - * @param {string} params.behavior - the cache behavior to target (`default` for the default). - * @param {string} [params.originDomain] - the Edge Optimize origin domain. - * @param {object} [params.originHeaders] - EO origin headers ({ apiKey, forwardedHost }). - * @param {string} [params.accountId] - the 12-digit customer AWS account ID. - * @param {string} [region] - CloudFront control-plane region. - * @returns {Promise<{canProceed: boolean, blocker: string|null, - * steps: Array<{key: string, label: string, action: string, detail: string}>}>} - */ -export async function planEdgeOptimizeDeploy( - credentials, - { - distributionId, behavior, originDomain = EDGE_OPTIMIZE_DEFAULT_ORIGIN_DOMAIN, originHeaders, - }, - region = EDGE_OPTIMIZE_REGION, -) { - if (!hasText(distributionId)) { - throw new Error('distributionId is required'); - } - if (!hasText(behavior)) { - throw new Error('behavior is required'); - } - const client = new CloudFrontClient({ region, credentials }); - - // Plan rows mirror EDGE_OPTIMIZE_DEPLOY_STEPS (sans `verify`, which is a post-deploy probe). - const labelOf = (key) => EDGE_OPTIMIZE_DEPLOY_STEPS.find((s) => s.key === key)?.label || key; - const steps = ['origin', 'function', 'cache', 'lambda', 'associate'].map((key) => ({ - key, label: labelOf(key), action: 'create', detail: '', - })); - const byKey = (key) => steps.find((s) => s.key === key); - - let canProceed = true; - let blocker = null; - - // Read the distribution config ONCE for the origin + cache inspections. - let config = null; - try { - const distResult = await client.send(new GetDistributionConfigCommand({ Id: distributionId })); - config = distResult.DistributionConfig || null; - } catch (err) { - // A read failure here doesn't block — surface it on the origin/cache rows and keep going. - byKey('origin').detail = `could not read distribution config: ${err.message}`; - byKey('cache').detail = `could not read distribution config: ${err.message}`; - } - - // ── origin ────────────────────────────────────────────────────────────── - // 'exists' when the EO origin is already present WITH the required custom headers; otherwise - // 'create' (a header-less existing origin still needs the headers patched → treated as create). - const desiredHeaderItems = buildEdgeOptimizeOriginHeaders(originHeaders || {}); - if (config) { - const origins = config.Origins?.Items || []; - const existing = origins.find( - (o) => o.Id === EDGE_OPTIMIZE_ORIGIN_ID || o.DomainName === originDomain, - ); - if (existing) { - const toMap = (arr) => (arr || []).reduce((acc, h) => { - acc[h.HeaderName.toLowerCase()] = h.HeaderValue; - return acc; - }, {}); - const current = toMap(existing.CustomHeaders?.Items); - const desired = toMap(desiredHeaderItems); - const headersMatch = desiredHeaderItems.length === 0 - || (Object.keys(desired).length === Object.keys(current).length - && Object.entries(desired).every(([k, v]) => current[k] === v)); - if (headersMatch) { - byKey('origin').action = 'exists'; - byKey('origin').detail = `Edge Optimize origin already present (${existing.DomainName})`; - } else { - byKey('origin').detail = `patch Edge Optimize origin headers (${existing.DomainName})`; - } - } else { - byKey('origin').detail = `add Edge Optimize origin (${originDomain})`; - } - } else if (!byKey('origin').detail) { - byKey('origin').detail = `add Edge Optimize origin (${originDomain})`; - } - - // ── function ──────────────────────────────────────────────────────────── - // 'exists' when the routing CloudFront Function is already published to LIVE. - try { - if (await isRoutingFunctionLive(client, distributionId)) { - byKey('function').action = 'exists'; - byKey('function').detail = `routing function ${eoRoutingFunctionName(distributionId)} already published to LIVE`; - } else { - byKey('function').detail = `create routing function ${eoRoutingFunctionName(distributionId)}`; - } - } catch (err) { - byKey('function').detail = `could not read routing function status: ${err.message}`; - } - - // ── cache ─────────────────────────────────────────────────────────────── - // Detect the scenario the deploy would hit (legacy / custom / managed) and describe it without - // mutating. Mirrors applyEdgeOptimizeCacheHeaders' detection logic. - try { - if (!config) { - throw new Error('distribution config unavailable'); - } - const targetBehavior = getBehaviorFromConfig(config, behavior); - const policyId = targetBehavior.CachePolicyId; - // Only mention the MinTTL change when it would ACTUALLY change — i.e. the current MinTTL is - // above the keep threshold (<= 5s is left as-is). Empty string otherwise so we don't show it. - const ttlNote = (currentMinTtl) => ( - Number(currentMinTtl ?? 0) > EDGE_OPTIMIZE_MIN_TTL_KEEP_THRESHOLD - ? ' Minimum TTL will be set to 0.' - : '' - ); - if (!policyId) { - // Legacy: ForwardedValues. 'exists' when EO headers are already forwarded. - const fv = targetBehavior.ForwardedValues || {}; - const lower = (fv.Headers?.Items || []).map((x) => x.toLowerCase()); - const allForwarded = lower.includes('*') - || EDGE_OPTIMIZE_CACHE_HEADERS.every((h) => lower.includes(h)); - if (allForwarded) { - byKey('cache').action = 'exists'; - byKey('cache').detail = 'This behavior already forwards the Edge Optimize headers.'; - } else { - byKey('cache').action = 'update'; - byKey('cache').detail = `Add the Edge Optimize headers to this behavior.${ttlNote(targetBehavior.MinTTL)}`; - } - } else { - const managedList = await client.send(new ListCachePoliciesCommand({ Type: 'managed' })); - const managedIds = new Set( - (managedList.CachePolicyList?.Items || []).map((i) => i.CachePolicy.Id), - ); - const isManaged = managedIds.has(policyId); - if (!isManaged) { - // Custom policy → updated in place (idempotent). If our headers are already in the cache - // key AND the MinTTL won't change, it is already configured (e.g. our own clone from a - // prior deploy) → 'No change'; otherwise 'update'. Mirrors applyEdgeOptimizeCacheHeaders. - const pcResult = await client.send(new GetCachePolicyConfigCommand({ Id: policyId })); - const pc = pcResult.CachePolicyConfig || {}; - const hc = pc.ParametersInCacheKeyAndForwardedToOrigin?.HeadersConfig || {}; - const headerItems = (hc.Headers?.Items || []).map((x) => x.toLowerCase()); - const headersPresent = hc.HeaderBehavior === 'allViewer' || hc.HeaderBehavior === 'all' - || EDGE_OPTIMIZE_CACHE_HEADERS.every((h) => headerItems.includes(h)); - const ttlChange = ttlNote(pc.MinTTL); - if (headersPresent && !ttlChange) { - byKey('cache').action = 'exists'; - byKey('cache').detail = `Current policy: ${pc.Name || 'custom'}. Already has the Edge Optimize headers.`; - } else { - byKey('cache').action = 'update'; - byKey('cache').detail = `Current policy: ${pc.Name || 'custom'}. Add the Edge Optimize headers in place.${ttlChange}`; - } - } else { - // Managed → must clone. 'exists' when the per-dist clone already exists (idempotent). - const srcResult = await client.send(new GetCachePolicyCommand({ Id: policyId })); - const srcConfig = srcResult.CachePolicy?.CachePolicyConfig || {}; - const sourceName = srcConfig.Name || 'cache'; - const clonedName = buildEoClonedCachePolicyName(sourceName, distributionId); - // Match the FULL derived name (exact): -adobe- — not - // just the suffix. If the customer re-pointed the behavior to a different source, a clone - // with a different prefix is NOT a match, so the deploy creates one for the current source. - const customList = await client.send(new ListCachePoliciesCommand({ Type: 'custom' })); - const cloneExists = (customList.CachePolicyList?.Items || []).some( - (i) => i.CachePolicy.CachePolicyConfig.Name === clonedName, - ); - if (cloneExists) { - // The copy exists from a prior run, but the behavior is still on the AWS-managed policy - // (if it were already on the copy we'd be in the custom branch above) — created but not - // associated. The deploy will switch the behavior to the copy, so this is an 'update', - // not a no-op. Surface both names + that it isn't associated yet. - byKey('cache').action = 'update'; - byKey('cache').detail = `Current policy: ${sourceName} (AWS-managed). A copy ` - + `"${clonedName}" already exists but is not associated with this behavior ` - + `yet — the behavior will be switched to it.${ttlNote(srcConfig.MinTTL)}`; - } else { - byKey('cache').action = 'create'; - byKey('cache').detail = `Current policy: ${sourceName} (AWS-managed, can't be edited). A copy will be created: ${clonedName}.${ttlNote(srcConfig.MinTTL)}`; - } - } - } - } catch (err) { - // Don't block the plan on a cache read failure — surface it on the row. - byKey('cache').action = 'update'; - byKey('cache').detail = `could not determine cache scenario: ${err.message}`; - } - - // ── lambda ────────────────────────────────────────────────────────────── - // 'exists' when the Lambda@Edge function exists (ready or still provisioning); else 'create'. - // Also surface the execution role: a role with our name may already exist from a prior partial - // run. We say whether it is correctly configured — the deploy ALWAYS conforms it to the required - // trust (lambda + edgelambda) + logs policy, so a mismatch is auto-corrected, not a blocker. - try { - const roleName = eoLambdaRoleName(distributionId); - // getEdgeOptimizeLambdaStatus already inspects the role (roleExists + roleOk = trust + logs), - // so we derive the role note from it — no separate IAM read needed. - const ls = await getEdgeOptimizeLambdaStatus(credentials, distributionId, region); - let roleNote; - if (!ls.roleExists) { - roleNote = ` Execution role ${roleName} will be created.`; - } else if (ls.roleOk) { - roleNote = ` Execution role ${roleName} already exists and is correctly configured ` - + '(trust + logs) — it will be reused.'; - } else { - roleNote = ` Execution role ${roleName} already exists but is not correctly configured ` - + '— the deploy will correct its trust + logs policy.'; - } - if (ls.exists) { - byKey('lambda').action = 'exists'; - byKey('lambda').detail = (ls.ready - ? `Lambda@Edge ${eoLambdaFunctionName(distributionId)} already published.` - : `Lambda@Edge ${eoLambdaFunctionName(distributionId)} exists (still provisioning).`) - + roleNote; - } else { - byKey('lambda').detail = `create Lambda@Edge ${eoLambdaFunctionName(distributionId)}.${roleNote}`; - } - } catch (err) { - byKey('lambda').detail = `could not read Lambda@Edge status: ${err.message}`; - } - - // ── associate ─────────────────────────────────────────────────────────── - // HARD BLOCK in two cases: (1) the behavior is already EO-associated (nothing to do), or (2) the - // customer already owns a slot EO needs (a different viewer-request function, a viewer-request - // lambda, or an origin-request/response lambda) — we refuse rather than remove their edge logic. - // Otherwise EO is merged in, preserving every other association on the behavior. - try { - const assocBehavior = config ? getBehaviorFromConfig(config, behavior) : null; - const assocConflict = assocBehavior - ? findEdgeOptimizeAssociationConflict(assocBehavior, behavior) : null; - if (await isBehaviorAlreadyAssociated(client, distributionId, behavior)) { - byKey('associate').action = 'blocked'; - byKey('associate').detail = 'this behaviour is already associated with Edge Optimize routes'; - canProceed = false; - blocker = "This behaviour is already associated with routes, please recheck — can't proceed with this automation."; - } else if (assocConflict) { - byKey('associate').action = 'blocked'; - byKey('associate').detail = assocConflict; - canProceed = false; - blocker = assocConflict; - } else { - byKey('associate').detail = 'will add the routing function + Lambda@Edge, ' - + 'preserving your other associations on this behavior'; - } - } catch (err) { - byKey('associate').detail = `could not read behavior associations: ${err.message}`; - } - - return { canProceed, blocker, steps }; -} diff --git a/test/controllers/llmo/llmo.test.js b/test/controllers/llmo/llmo.test.js index a86eea58bc..5d114b5358 100644 --- a/test/controllers/llmo/llmo.test.js +++ b/test/controllers/llmo/llmo.test.js @@ -199,9 +199,29 @@ describe('LlmoController', () => { } }, getEffectiveBaseURL: mockTokowakaGetEffectiveBaseURL, + // eslint-disable-next-line no-use-before-define + ...getEdgeOptimizeStubs(), }, }); + // Edge-optimize control-plane functions are now imported from + // '@adobe/spacecat-shared-tokowaka-client' (moved out of the local controller support copy). + // Spread these into the tokowaka-client mock for any esmock block that needs them. + const getEdgeOptimizeStubs = () => ({ + assumeConnectorRole: (...args) => assumeConnectorRoleStub(...args), + listCloudFrontDistributions: (...args) => listCloudFrontDistributionsStub(...args), + getDistributionConfig: (...args) => getDistributionConfigStub(...args), + createEdgeOptimizeOrigin: (...args) => createEdgeOptimizeOriginStub(...args), + createEdgeOptimizeRoutingFunction: (...args) => createEdgeOptimizeRoutingFunctionStub(...args), + applyEdgeOptimizeCacheHeaders: (...args) => applyEdgeOptimizeCacheHeadersStub(...args), + createEdgeOptimizeLambda: (...args) => createEdgeOptimizeLambdaStub(...args), + getEdgeOptimizeLambdaStatus: (...args) => getEdgeOptimizeLambdaStatusStub(...args), + applyEdgeOptimizeAssociations: (...args) => applyEdgeOptimizeAssociationsStub(...args), + verifyEdgeOptimizeRouting: (...args) => verifyEdgeOptimizeRoutingStub(...args), + runEdgeOptimizeDeployStep: (...args) => runEdgeOptimizeDeployStepStub(...args), + planEdgeOptimizeDeploy: (...args) => planEdgeOptimizeDeployStub(...args), + }); + before(async function () { this.timeout(120000); triggerBrandProfileAgentStub = sinon.stub().resolves('exec-123'); @@ -286,22 +306,6 @@ describe('LlmoController', () => { getImsTokenFromPromiseToken: (...args) => getImsTokenFromPromiseTokenStub(...args), authorizeEdgeCdnRouting: (...args) => authorizeEdgeCdnRoutingStub(...args), }, - '../../../src/support/edge-optimize.js': { - assumeConnectorRole: (...args) => assumeConnectorRoleStub(...args), - listCloudFrontDistributions: (...args) => listCloudFrontDistributionsStub(...args), - getDistributionConfig: (...args) => getDistributionConfigStub(...args), - createEdgeOptimizeOrigin: (...args) => createEdgeOptimizeOriginStub(...args), - createEdgeOptimizeRoutingFunction: (...args) => ( - createEdgeOptimizeRoutingFunctionStub(...args) - ), - applyEdgeOptimizeCacheHeaders: (...args) => applyEdgeOptimizeCacheHeadersStub(...args), - createEdgeOptimizeLambda: (...args) => createEdgeOptimizeLambdaStub(...args), - getEdgeOptimizeLambdaStatus: (...args) => getEdgeOptimizeLambdaStatusStub(...args), - applyEdgeOptimizeAssociations: (...args) => applyEdgeOptimizeAssociationsStub(...args), - verifyEdgeOptimizeRouting: (...args) => verifyEdgeOptimizeRoutingStub(...args), - runEdgeOptimizeDeployStep: (...args) => runEdgeOptimizeDeployStepStub(...args), - planEdgeOptimizeDeploy: (...args) => planEdgeOptimizeDeployStub(...args), - }, '@adobe/spacecat-shared-ims-client': { ImsClient: function MockImsClient() { this.getServicePrincipalAccessToken = (...args) => ( @@ -354,6 +358,7 @@ describe('LlmoController', () => { } }, getEffectiveBaseURL: mockTokowakaGetEffectiveBaseURL, + ...getEdgeOptimizeStubs(), }, '../../../src/utils/slack/base.js': { postSlackMessage: (...args) => postSlackMessageStub(...args), @@ -428,6 +433,7 @@ describe('LlmoController', () => { } }, getEffectiveBaseURL: mockTokowakaGetEffectiveBaseURL, + ...getEdgeOptimizeStubs(), }, '../../../src/utils/slack/base.js': { postSlackMessage: (...args) => postSlackMessageStub(...args), @@ -472,6 +478,7 @@ describe('LlmoController', () => { } }, getEffectiveBaseURL: mockTokowakaGetEffectiveBaseURL, + ...getEdgeOptimizeStubs(), }, '../../../src/utils/slack/base.js': { postSlackMessage: (...args) => postSlackMessageStub(...args), @@ -513,6 +520,7 @@ describe('LlmoController', () => { } }, getEffectiveBaseURL: mockTokowakaGetEffectiveBaseURL, + ...getEdgeOptimizeStubs(), }, '../../../src/utils/slack/base.js': { postSlackMessage: (...args) => postSlackMessageStub(...args), @@ -5763,6 +5771,7 @@ describe('LlmoController', () => { } }, getEffectiveBaseURL: mockTokowakaGetEffectiveBaseURL, + ...getEdgeOptimizeStubs(), }, '../../../src/controllers/llmo/llmo-onboarding.js': { validateSiteNotOnboarded: sinon.stub().resolves({ isValid: true }), @@ -5872,6 +5881,7 @@ describe('LlmoController', () => { } }, getEffectiveBaseURL: mockTokowakaGetEffectiveBaseURL, + ...getEdgeOptimizeStubs(), }, '../../../src/controllers/llmo/llmo-onboarding.js': { validateSiteNotOnboarded: sinon.stub().resolves({ isValid: true }), @@ -5967,6 +5977,7 @@ describe('LlmoController', () => { default: { createFrom: () => mockTokowakaClient }, calculateForwardedHost: (url) => new URL(url).hostname, getEffectiveBaseURL: mockTokowakaGetEffectiveBaseURL, + ...getEdgeOptimizeStubs(), }, '../../../src/controllers/llmo/llmo-onboarding.js': { validateSiteNotOnboarded: sinon.stub().resolves({}), @@ -6038,6 +6049,7 @@ describe('LlmoController', () => { default: { createFrom: () => mockTokowakaClient }, calculateForwardedHost: (url) => new URL(url).hostname, getEffectiveBaseURL: mockTokowakaGetEffectiveBaseURL, + ...getEdgeOptimizeStubs(), }, '../../../src/controllers/llmo/llmo-onboarding.js': { validateSiteNotOnboarded: sinon.stub().resolves({}), @@ -6106,6 +6118,7 @@ describe('LlmoController', () => { default: { createFrom: () => mockTokowakaClient }, calculateForwardedHost: (url) => new URL(url).hostname, getEffectiveBaseURL: mockTokowakaGetEffectiveBaseURL, + ...getEdgeOptimizeStubs(), }, '../../../src/controllers/llmo/llmo-onboarding.js': { validateSiteNotOnboarded: sinon.stub().resolves({}), @@ -6866,6 +6879,7 @@ describe('LlmoController', () => { throw new Error(`Error calculating forwarded host from URL ${url}: ${e.message}`); } }, + ...getEdgeOptimizeStubs(), }, '../../../src/controllers/llmo/llmo-onboarding.js': { validateSiteNotOnboarded: sinon.stub().resolves({ isValid: true }), @@ -6972,6 +6986,7 @@ describe('LlmoController', () => { throw new Error(`Error calculating forwarded host from URL ${url}: ${e.message}`); } }, + ...getEdgeOptimizeStubs(), }, '../../../src/controllers/llmo/llmo-onboarding.js': { validateSiteNotOnboarded: sinon.stub().resolves({ isValid: true }), @@ -7073,6 +7088,7 @@ describe('LlmoController', () => { throw new Error(`Error calculating forwarded host from URL ${url}: ${e.message}`); } }, + ...getEdgeOptimizeStubs(), }, '../../../src/controllers/llmo/llmo-onboarding.js': { validateSiteNotOnboarded: sinon.stub().resolves({ isValid: true }), @@ -10095,6 +10111,7 @@ describe('LlmoController', () => { default: { createFrom: () => mockTokowakaClient }, calculateForwardedHost: () => 'www.example.com', getEffectiveBaseURL: mockTokowakaGetEffectiveBaseURL, + ...getEdgeOptimizeStubs(), }, '../../../src/utils/slack/base.js': { postSlackMessage: sinon.stub(), @@ -10151,6 +10168,7 @@ describe('LlmoController', () => { default: { createFrom: () => mockTokowakaClient }, calculateForwardedHost: () => 'www.example.com', getEffectiveBaseURL: mockTokowakaGetEffectiveBaseURL, + ...getEdgeOptimizeStubs(), }, '../../../src/utils/slack/base.js': { postSlackMessage: sinon.stub(), diff --git a/test/support/edge-optimize.test.js b/test/support/edge-optimize.test.js deleted file mode 100644 index 1698e3cfa8..0000000000 --- a/test/support/edge-optimize.test.js +++ /dev/null @@ -1,2007 +0,0 @@ -/* - * 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 { expect } from 'chai'; -import sinon from 'sinon'; -import esmock from 'esmock'; - -describe('edge-optimize support', () => { - let stsSendStub; - let cfSendStub; - let iamSendStub; - let lambdaSendStub; - let edgeOptimize; - - // esmock ONCE for the whole file (not per-test) — esmock re-instantiates the mocked module - // graph on every call and accumulates memory, which contributes to the suite's heap pressure. - // The mocked clients call the `*SendStub` closures, which read the `let` bindings reassigned - // fresh in beforeEach, so a single esmock works for all tests. - before(async function setupEsmock() { - // One-time esmock of the AWS SDK module graph. This is memory-heavy, so under the full CI - // suite (12k+ tests + nyc coverage + heap pressure) it can take well over the default/30s - // even though it runs in ~1s locally. Give the hook generous headroom so it can't flake the - // whole build on suite growth (it still completes in seconds in practice). - this.timeout(120000); - // Each command in a mocked module is a constructor FUNCTION (not a class) — eslint forbids - // multiple class declarations in one file, so we capture the command name + input on `this`. - const cfCommand = (Name) => function CloudFrontCommand(input) { - this.input = input; - this.commandName = Name; - }; - const iamCommand = (Name) => function IamCommand(input) { - this.input = input; - this.commandName = Name; - }; - const lambdaCommand = (Name) => function LambdaCommand(input) { - this.input = input; - this.commandName = Name; - }; - edgeOptimize = await esmock('../../src/support/edge-optimize.js', { - '@aws-sdk/client-sts': { - STSClient: function STSClient() { - this.send = (cmd) => stsSendStub(cmd); - }, - AssumeRoleCommand: function AssumeRoleCommand(input) { - this.input = input; - }, - }, - '@aws-sdk/client-cloudfront': { - CloudFrontClient: function CloudFrontClient(config) { - this.config = config; - this.send = (cmd) => cfSendStub(cmd); - }, - ListDistributionsCommand: cfCommand('ListDistributions'), - GetDistributionConfigCommand: cfCommand('GetDistributionConfig'), - GetCachePolicyConfigCommand: cfCommand('GetCachePolicyConfig'), - GetCachePolicyCommand: cfCommand('GetCachePolicy'), - ListCachePoliciesCommand: cfCommand('ListCachePolicies'), - CreateCachePolicyCommand: cfCommand('CreateCachePolicy'), - UpdateCachePolicyCommand: cfCommand('UpdateCachePolicy'), - CreateFunctionCommand: cfCommand('CreateFunction'), - UpdateFunctionCommand: cfCommand('UpdateFunction'), - DescribeFunctionCommand: cfCommand('DescribeFunction'), - PublishFunctionCommand: cfCommand('PublishFunction'), - UpdateDistributionCommand: cfCommand('UpdateDistribution'), - }, - '@aws-sdk/client-iam': { - IAMClient: function IAMClient(config) { - this.config = config; - this.send = (cmd) => iamSendStub(cmd); - }, - CreateRoleCommand: iamCommand('CreateRole'), - GetRoleCommand: iamCommand('GetRole'), - GetRolePolicyCommand: iamCommand('GetRolePolicy'), - PutRolePolicyCommand: iamCommand('PutRolePolicy'), - UpdateAssumeRolePolicyCommand: iamCommand('UpdateAssumeRolePolicy'), - }, - '@aws-sdk/client-lambda': { - LambdaClient: function LambdaClient(config) { - this.config = config; - this.send = (cmd) => lambdaSendStub(cmd); - }, - CreateFunctionCommand: lambdaCommand('CreateFunction'), - UpdateFunctionCodeCommand: lambdaCommand('UpdateFunctionCode'), - GetFunctionConfigurationCommand: lambdaCommand('GetFunctionConfiguration'), - ListVersionsByFunctionCommand: lambdaCommand('ListVersionsByFunction'), - PublishVersionCommand: lambdaCommand('PublishVersion'), - }, - }); - }); - - beforeEach(() => { - // Fresh stubs per test; the esmocked clients read these `let` bindings at call time. - stsSendStub = sinon.stub(); - cfSendStub = sinon.stub(); - iamSendStub = sinon.stub(); - lambdaSendStub = sinon.stub(); - }); - - afterEach(() => { - sinon.restore(); - }); - - describe('assumeConnectorRole', () => { - it('assumes the role and returns mapped credentials', async () => { - stsSendStub.resolves({ - Credentials: { - AccessKeyId: 'AKIA', - SecretAccessKey: 'secret', - SessionToken: 'token', - Expiration: new Date('2030-01-01T00:00:00Z'), - }, - }); - - const result = await edgeOptimize.assumeConnectorRole({ - accountId: '120569600543', - externalId: 'ext-123', - }); - - expect(result.roleArn).to.equal('arn:aws:iam::120569600543:role/AdobeLLMOptimizerCloudFrontConnectorRole'); - expect(result.accountId).to.equal('120569600543'); - expect(result.credentials.accessKeyId).to.equal('AKIA'); - expect(result.credentials.secretAccessKey).to.equal('secret'); - expect(result.credentials.sessionToken).to.equal('token'); - expect(stsSendStub.calledOnce).to.equal(true); - }); - - it('uses a custom role name when provided', async () => { - stsSendStub.resolves({ - Credentials: { AccessKeyId: 'A', SecretAccessKey: 'S', SessionToken: 'T' }, - }); - - const result = await edgeOptimize.assumeConnectorRole({ - accountId: '120569600543', - externalId: 'ext', - roleName: 'CustomRole', - }); - - expect(result.roleArn).to.equal('arn:aws:iam::120569600543:role/CustomRole'); - }); - - it('throws for an invalid account id', async () => { - let error; - try { - await edgeOptimize.assumeConnectorRole({ accountId: '123', externalId: 'ext' }); - } catch (e) { - error = e; - } - expect(error).to.be.an('error'); - expect(error.message).to.include('12-digit'); - expect(stsSendStub.called).to.equal(false); - }); - - it('throws when the external id is missing', async () => { - let error; - try { - await edgeOptimize.assumeConnectorRole({ accountId: '120569600543', externalId: '' }); - } catch (e) { - error = e; - } - expect(error).to.be.an('error'); - expect(error.message).to.include('externalId'); - }); - - it('throws when STS returns no credentials', async () => { - stsSendStub.resolves({}); - let error; - try { - await edgeOptimize.assumeConnectorRole({ accountId: '120569600543', externalId: 'ext' }); - } catch (e) { - error = e; - } - expect(error).to.be.an('error'); - expect(error.message).to.include('no credentials'); - }); - }); - - describe('listCloudFrontDistributions', () => { - it('maps the distribution list to the wizard projection', async () => { - cfSendStub.resolves({ - DistributionList: { - Items: [ - { - Id: 'E123', - DomainName: 'd.cloudfront.net', - Aliases: { Items: ['www.example.com'] }, - Status: 'Deployed', - Enabled: true, - Comment: 'prod', - }, - ], - }, - }); - - const result = await edgeOptimize.listCloudFrontDistributions({ - accessKeyId: 'A', secretAccessKey: 'S', sessionToken: 'T', - }); - - expect(result).to.have.length(1); - expect(result[0]).to.deep.equal({ - id: 'E123', - domainName: 'd.cloudfront.net', - aliases: ['www.example.com'], - status: 'Deployed', - enabled: true, - comment: 'prod', - }); - }); - - it('returns an empty array when there are no distributions', async () => { - cfSendStub.resolves({ DistributionList: {} }); - - const result = await edgeOptimize.listCloudFrontDistributions({}); - - expect(result).to.deep.equal([]); - }); - - it('defaults aliases and comment when absent and reflects disabled state', async () => { - cfSendStub.resolves({ - DistributionList: { - Items: [{ - Id: 'E2', DomainName: 'd2.cloudfront.net', Status: 'InProgress', Enabled: false, - }], - }, - }); - - const result = await edgeOptimize.listCloudFrontDistributions({}); - - expect(result[0].aliases).to.deep.equal([]); - expect(result[0].comment).to.equal(''); - expect(result[0].enabled).to.equal(false); - }); - }); - - describe('getDistributionConfig', () => { - it('maps origins, default cache behavior, and ordered cache behaviors', async () => { - cfSendStub.resolves({ - DistributionConfig: { - Origins: { - Items: [ - { Id: 'origin-aem', DomainName: 'origin.example.com', OriginPath: '/content' }, - { Id: 'EdgeOptimizeOrigin', DomainName: 'live.edgeoptimize.net' }, - ], - }, - DefaultCacheBehavior: { TargetOriginId: 'origin-aem' }, - CacheBehaviors: { - Items: [ - { PathPattern: '/api/*', TargetOriginId: 'origin-aem' }, - ], - }, - }, - }); - - const result = await edgeOptimize.getDistributionConfig({}, 'E2EXAMPLE'); - - expect(cfSendStub.calledOnce).to.equal(true); - expect(cfSendStub.firstCall.args[0].input).to.deep.equal({ Id: 'E2EXAMPLE' }); - expect(result.origins).to.deep.equal([ - { id: 'origin-aem', domainName: 'origin.example.com', originPath: '/content' }, - { id: 'EdgeOptimizeOrigin', domainName: 'live.edgeoptimize.net', originPath: '' }, - ]); - expect(result.defaultCacheBehavior).to.deep.equal({ - pathPattern: 'Default (*)', - targetOriginId: 'origin-aem', - }); - expect(result.cacheBehaviors).to.deep.equal([ - { pathPattern: '/api/*', targetOriginId: 'origin-aem' }, - ]); - }); - - it('defaults to empty collections when the config is sparse', async () => { - cfSendStub.resolves({ DistributionConfig: {} }); - - const result = await edgeOptimize.getDistributionConfig({}, 'E2EXAMPLE'); - - expect(result.origins).to.deep.equal([]); - expect(result.defaultCacheBehavior).to.equal(null); - expect(result.cacheBehaviors).to.deep.equal([]); - }); - - it('throws when the distribution id is missing', async () => { - let error; - try { - await edgeOptimize.getDistributionConfig({}, ''); - } catch (e) { - error = e; - } - expect(error).to.be.an('error'); - expect(error.message).to.include('distributionId'); - expect(cfSendStub.called).to.equal(false); - }); - }); - - describe('createEdgeOptimizeOrigin', () => { - it('adds the Edge Optimize origin when it does not exist', async () => { - cfSendStub.onFirstCall().resolves({ - DistributionConfig: { Origins: { Quantity: 1, Items: [{ Id: 'origin-aem', DomainName: 'origin.example.com' }] } }, - ETag: 'etag-1', - }); - cfSendStub.onSecondCall().resolves({}); - - const result = await edgeOptimize.createEdgeOptimizeOrigin({}, 'E2EXAMPLE', 'dev.edgeoptimize.net'); - - expect(result).to.deep.equal({ - created: true, alreadyExisted: false, updated: false, originId: 'EdgeOptimize_Origin', - }); - expect(cfSendStub.secondCall.args[0].commandName).to.equal('UpdateDistribution'); - const update = cfSendStub.secondCall.args[0].input; - expect(update.IfMatch).to.equal('etag-1'); - const added = update.DistributionConfig.Origins.Items.find((o) => o.Id === 'EdgeOptimize_Origin'); - expect(added.DomainName).to.equal('dev.edgeoptimize.net'); - expect(added.CustomOriginConfig.OriginProtocolPolicy).to.equal('https-only'); - }); - - it('sets the EO custom headers on the new origin', async () => { - cfSendStub.onFirstCall().resolves({ - DistributionConfig: { Origins: { Quantity: 1, Items: [{ Id: 'origin-aem', DomainName: 'origin.example.com' }] } }, - ETag: 'etag-1', - }); - cfSendStub.onSecondCall().resolves({}); - - await edgeOptimize.createEdgeOptimizeOrigin({}, 'E2EXAMPLE', 'dev.edgeoptimize.net', { - apiKey: 'eo-key-123', forwardedHost: 'www.example.com', fetcherKey: 'fk-9', - }); - - const update = cfSendStub.secondCall.args[0].input; - const added = update.DistributionConfig.Origins.Items.find((o) => o.Id === 'EdgeOptimize_Origin'); - expect(added.CustomHeaders.Quantity).to.equal(3); - const headerMap = added.CustomHeaders.Items.reduce((acc, h) => { - acc[h.HeaderName] = h.HeaderValue; - return acc; - }, {}); - expect(headerMap).to.deep.equal({ - 'x-edgeoptimize-api-key': 'eo-key-123', - 'x-forwarded-host': 'www.example.com', - 'x-edgeoptimize-fetcher-key': 'fk-9', - }); - }); - - it('is idempotent when the origin already exists by id', async () => { - cfSendStub.resolves({ - DistributionConfig: { Origins: { Quantity: 1, Items: [{ Id: 'EdgeOptimize_Origin', DomainName: 'x' }] } }, - ETag: 'etag-1', - }); - - const result = await edgeOptimize.createEdgeOptimizeOrigin({}, 'E2EXAMPLE'); - - expect(result).to.deep.equal({ - created: false, alreadyExisted: true, updated: false, originId: 'EdgeOptimize_Origin', - }); - expect(cfSendStub.calledOnce).to.equal(true); // never updated - }); - - it('is idempotent when an origin already uses the EO domain', async () => { - cfSendStub.resolves({ - DistributionConfig: { Origins: { Items: [{ Id: 'custom', DomainName: 'dev.edgeoptimize.net' }] } }, - ETag: 'etag-1', - }); - - const result = await edgeOptimize.createEdgeOptimizeOrigin({}, 'E2EXAMPLE', 'dev.edgeoptimize.net'); - - expect(result.alreadyExisted).to.equal(true); - expect(cfSendStub.calledOnce).to.equal(true); - }); - - it('patches the headers when the origin exists without them (self-heal)', async () => { - cfSendStub.onFirstCall().resolves({ - DistributionConfig: { - Origins: { - Quantity: 1, - Items: [{ Id: 'EdgeOptimize_Origin', DomainName: 'dev.edgeoptimize.net', CustomHeaders: { Quantity: 0, Items: [] } }], - }, - }, - ETag: 'etag-1', - }); - cfSendStub.onSecondCall().resolves({}); - - const result = await edgeOptimize.createEdgeOptimizeOrigin({}, 'E2EXAMPLE', 'dev.edgeoptimize.net', { - apiKey: 'eo-key-123', forwardedHost: 'www.example.com', - }); - - expect(result).to.deep.equal({ - created: false, alreadyExisted: true, updated: true, originId: 'EdgeOptimize_Origin', - }); - expect(cfSendStub.secondCall.args[0].commandName).to.equal('UpdateDistribution'); - const patched = cfSendStub.secondCall.args[0].input - .DistributionConfig.Origins.Items.find((o) => o.Id === 'EdgeOptimize_Origin'); - expect(patched.CustomHeaders.Quantity).to.equal(2); - }); - - it('does not patch when the existing headers already match', async () => { - cfSendStub.resolves({ - DistributionConfig: { - Origins: { - Quantity: 1, - Items: [{ - Id: 'EdgeOptimize_Origin', - DomainName: 'dev.edgeoptimize.net', - CustomHeaders: { - Quantity: 2, - Items: [ - { HeaderName: 'x-edgeoptimize-api-key', HeaderValue: 'eo-key-123' }, - { HeaderName: 'x-forwarded-host', HeaderValue: 'www.example.com' }, - ], - }, - }], - }, - }, - ETag: 'etag-1', - }); - - const result = await edgeOptimize.createEdgeOptimizeOrigin({}, 'E2EXAMPLE', 'dev.edgeoptimize.net', { - apiKey: 'eo-key-123', forwardedHost: 'www.example.com', - }); - - expect(result.updated).to.equal(false); - expect(cfSendStub.calledOnce).to.equal(true); // no UpdateDistribution - }); - - it('throws when the distribution id is missing', async () => { - let error; - try { - await edgeOptimize.createEdgeOptimizeOrigin({}, ''); - } catch (e) { - error = e; - } - expect(error.message).to.include('distributionId'); - expect(cfSendStub.called).to.equal(false); - }); - }); - - describe('buildRoutingFunctionCode', () => { - it('embeds the default origin id and null targeted paths', () => { - const code = edgeOptimize.buildRoutingFunctionCode('origin-aem'); - expect(code).to.include('{ "originId": "origin-aem" }'); - expect(code).to.include('var TARGETED_PATHS = null;'); - expect(code).to.include("import cf from 'cloudfront';"); - }); - - it('embeds explicit targeted paths as JSON', () => { - const code = edgeOptimize.buildRoutingFunctionCode('origin-aem', ['/a', '/b']); - expect(code).to.include('var TARGETED_PATHS = ["/a","/b"];'); - }); - }); - - describe('buildEdgeOptimizeLambdaCode', () => { - it('bakes the EO origin domain into the routing check (per environment)', () => { - const dev = edgeOptimize.buildEdgeOptimizeLambdaCode('dev.edgeoptimize.net'); - expect(dev).to.include("originDomain === 'dev.edgeoptimize.net'"); - expect(dev).to.not.include("originDomain === 'live.edgeoptimize.net'"); - - const prod = edgeOptimize.buildEdgeOptimizeLambdaCode('live.edgeoptimize.net'); - expect(prod).to.include("originDomain === 'live.edgeoptimize.net'"); - }); - }); - - describe('createEdgeOptimizeRoutingFunction', () => { - it('creates and publishes a new function when none exists', async () => { - cfSendStub.onFirstCall().rejects(Object.assign(new Error('not found'), { name: 'NoSuchFunctionExists' })); - cfSendStub.onSecondCall().resolves({ ETag: 'fn-etag' }); // CreateFunction - cfSendStub.onThirdCall().resolves({}); // PublishFunction - - const result = await edgeOptimize.createEdgeOptimizeRoutingFunction({}, 'origin-aem', 'E2EXAMPLE'); - - expect(result).to.deep.equal({ name: 'edgeoptimize-routing-adobe-E2EXAMPLE', created: true, stage: 'LIVE' }); - expect(cfSendStub.secondCall.args[0].commandName).to.equal('CreateFunction'); - expect(cfSendStub.thirdCall.args[0].commandName).to.equal('PublishFunction'); - expect(cfSendStub.thirdCall.args[0].input.IfMatch).to.equal('fn-etag'); - }); - - it('updates and publishes when the function already exists', async () => { - cfSendStub.onFirstCall().resolves({ ETag: 'dev-etag' }); // DescribeFunction DEVELOPMENT - cfSendStub.onSecondCall().resolves({ ETag: 'updated-etag' }); // UpdateFunction - cfSendStub.onThirdCall().resolves({}); // PublishFunction - - const result = await edgeOptimize.createEdgeOptimizeRoutingFunction({}, 'origin-aem', 'E2EXAMPLE'); - - expect(result.created).to.equal(false); - expect(cfSendStub.secondCall.args[0].commandName).to.equal('UpdateFunction'); - expect(cfSendStub.thirdCall.args[0].input.IfMatch).to.equal('updated-etag'); - }); - - it('throws when defaultOriginId is missing', async () => { - let error; - try { - await edgeOptimize.createEdgeOptimizeRoutingFunction({}, ''); - } catch (e) { - error = e; - } - expect(error.message).to.include('defaultOriginId'); - expect(cfSendStub.called).to.equal(false); - }); - - it('rethrows unexpected describe errors', async () => { - cfSendStub.onFirstCall().rejects(new Error('boom')); - let error; - try { - await edgeOptimize.createEdgeOptimizeRoutingFunction({}, 'origin-aem', 'E2EXAMPLE'); - } catch (e) { - error = e; - } - expect(error.message).to.equal('boom'); - }); - }); - - describe('applyEdgeOptimizeCacheHeaders', () => { - // Dispatch cfSendStub by command name so tests are robust to call order. - const wireCloudFront = (responders) => { - cfSendStub.callsFake((cmd) => { - const fn = responders[cmd.commandName]; - if (!fn) { - throw new Error(`unexpected command in test: ${cmd.commandName}`); - } - return Promise.resolve(typeof fn === 'function' ? fn(cmd) : fn); - }); - }; - - const lastCommand = (name) => cfSendStub.getCalls() - .filter((c) => c.args[0].commandName === name).pop()?.args[0]; - - it('updates a CUSTOM policy to add the EO headers + MinTTL 0', async () => { - wireCloudFront({ - GetDistributionConfig: { DistributionConfig: { DefaultCacheBehavior: { CachePolicyId: 'cp-1' } } }, - ListCachePolicies: { CachePolicyList: { Items: [{ CachePolicy: { Id: 'managed-x' } }] } }, - GetCachePolicyConfig: { - CachePolicyConfig: { - Name: 'my-policy', - MinTTL: 60, - ParametersInCacheKeyAndForwardedToOrigin: { - HeadersConfig: { HeaderBehavior: 'whitelist', Headers: { Quantity: 1, Items: ['accept'] } }, - }, - }, - ETag: 'cp-etag', - }, - UpdateCachePolicy: {}, - }); - - const result = await edgeOptimize.applyEdgeOptimizeCacheHeaders({}, 'E2EXAMPLE', 'default'); - - expect(result.scenario).to.equal('custom'); - expect(result.policyId).to.equal('cp-1'); - expect(result.updated).to.equal(true); - const updated = lastCommand('UpdateCachePolicy').input.CachePolicyConfig; - expect(updated.MinTTL).to.equal(0); - const items = updated.ParametersInCacheKeyAndForwardedToOrigin.HeadersConfig.Headers.Items; - expect(items).to.include('x-edgeoptimize-config'); - expect(items).to.include('x-edgeoptimize-url'); - }); - - it('is a no-op when a custom policy already forwards the headers and MinTTL is 0', async () => { - wireCloudFront({ - GetDistributionConfig: { DistributionConfig: { DefaultCacheBehavior: { CachePolicyId: 'cp-1' } } }, - ListCachePolicies: { CachePolicyList: { Items: [] } }, - GetCachePolicyConfig: { - CachePolicyConfig: { - Name: 'my-policy', - MinTTL: 0, - ParametersInCacheKeyAndForwardedToOrigin: { - HeadersConfig: { - HeaderBehavior: 'whitelist', - Headers: { Quantity: 2, Items: ['x-edgeoptimize-config', 'x-edgeoptimize-url'] }, - }, - }, - }, - ETag: 'cp-etag', - }, - }); - - const result = await edgeOptimize.applyEdgeOptimizeCacheHeaders({}, 'E2EXAMPLE', 'default'); - - expect(result).to.deep.equal({ - scenario: 'custom', policyId: 'cp-1', updated: false, alreadyForwarded: true, - }); - expect(lastCommand('UpdateCachePolicy')).to.equal(undefined); // never updated - }); - - it('CLONES an AWS-managed policy into a per-distribution custom policy and repoints the behavior', async () => { - wireCloudFront({ - GetDistributionConfig: { - DistributionConfig: { DefaultCacheBehavior: { CachePolicyId: 'managed-1', ForwardedValues: { x: 1 } } }, - ETag: 'dist-etag', - }, - ListCachePolicies: (cmd) => (cmd.input.Type === 'managed' - ? { CachePolicyList: { Items: [{ CachePolicy: { Id: 'managed-1' } }] } } - : { CachePolicyList: { Items: [] } }), // no existing custom edgeoptimize-cache - GetCachePolicy: { - CachePolicy: { - CachePolicyConfig: { - Name: 'Managed-CachingOptimized', - MinTTL: 86400, - ParametersInCacheKeyAndForwardedToOrigin: { HeadersConfig: { HeaderBehavior: 'none' } }, - }, - }, - }, - CreateCachePolicy: { CachePolicy: { Id: 'new-eo-policy' } }, - UpdateDistribution: {}, - }); - - const result = await edgeOptimize.applyEdgeOptimizeCacheHeaders({}, 'E2EXAMPLE', 'default'); - - expect(result.scenario).to.equal('managed'); - expect(result.policyId).to.equal('new-eo-policy'); - expect(result.reused).to.equal(false); - const created = lastCommand('CreateCachePolicy').input.CachePolicyConfig; - expect(created.Name).to.equal('CachingOptimized-adobe-E2EXAMPLE'); - expect(created.MinTTL).to.equal(0); - const items = created.ParametersInCacheKeyAndForwardedToOrigin.HeadersConfig.Headers.Items; - expect(items).to.include('x-edgeoptimize-config'); - // behavior repointed to the new policy + ForwardedValues removed - const cfg = lastCommand('UpdateDistribution').input.DistributionConfig; - expect(cfg.DefaultCacheBehavior.CachePolicyId).to.equal('new-eo-policy'); - expect(cfg.DefaultCacheBehavior.ForwardedValues).to.equal(undefined); - }); - - it('keeps a short MinTTL (<=5s) when cloning a managed policy instead of forcing it to 0', async () => { - wireCloudFront({ - GetDistributionConfig: { - DistributionConfig: { DefaultCacheBehavior: { CachePolicyId: 'managed-1' } }, - ETag: 'dist-etag', - }, - ListCachePolicies: (cmd) => (cmd.input.Type === 'managed' - ? { CachePolicyList: { Items: [{ CachePolicy: { Id: 'managed-1' } }] } } - : { CachePolicyList: { Items: [] } }), - GetCachePolicy: { - CachePolicy: { - CachePolicyConfig: { - Name: 'Managed-CachingOptimized', - MinTTL: 3, - ParametersInCacheKeyAndForwardedToOrigin: { HeadersConfig: { HeaderBehavior: 'none' } }, - }, - }, - }, - CreateCachePolicy: { CachePolicy: { Id: 'new-eo-policy' } }, - UpdateDistribution: {}, - }); - - await edgeOptimize.applyEdgeOptimizeCacheHeaders({}, 'E2EXAMPLE', 'default'); - - const created = lastCommand('CreateCachePolicy').input.CachePolicyConfig; - expect(created.MinTTL).to.equal(3); // <= 5s kept, not zeroed - }); - - it('reuses an existing edgeoptimize-cache custom policy (idempotent managed path)', async () => { - wireCloudFront({ - GetDistributionConfig: { - DistributionConfig: { DefaultCacheBehavior: { CachePolicyId: 'managed-1' } }, - ETag: 'dist-etag', - }, - ListCachePolicies: (cmd) => (cmd.input.Type === 'managed' - ? { CachePolicyList: { Items: [{ CachePolicy: { Id: 'managed-1' } }] } } - : { CachePolicyList: { Items: [{ CachePolicy: { Id: 'existing-eo', CachePolicyConfig: { Name: 'X-adobe-E2EXAMPLE' } } }] } }), - GetCachePolicy: { - CachePolicy: { CachePolicyConfig: { Name: 'Managed-X', ParametersInCacheKeyAndForwardedToOrigin: {} } }, - }, - UpdateDistribution: {}, - }); - - const result = await edgeOptimize.applyEdgeOptimizeCacheHeaders({}, 'E2EXAMPLE', 'default'); - - expect(result.scenario).to.equal('managed'); - expect(result.policyId).to.equal('existing-eo'); - expect(result.reused).to.equal(true); - expect(lastCommand('CreateCachePolicy')).to.equal(undefined); // reused, not created - }); - - it('handles a LEGACY behavior (ForwardedValues, no CachePolicyId)', async () => { - wireCloudFront({ - GetDistributionConfig: { - DistributionConfig: { - DefaultCacheBehavior: { ForwardedValues: { Headers: { Quantity: 1, Items: ['accept'] } }, MinTTL: 60 }, - }, - ETag: 'dist-etag', - }, - UpdateDistribution: {}, - }); - - const result = await edgeOptimize.applyEdgeOptimizeCacheHeaders({}, 'E2EXAMPLE', 'default'); - - expect(result.scenario).to.equal('legacy'); - expect(result.updated).to.equal(true); - const cfg = lastCommand('UpdateDistribution').input.DistributionConfig; - const items = cfg.DefaultCacheBehavior.ForwardedValues.Headers.Items; - expect(items).to.include('x-edgeoptimize-config'); - expect(cfg.DefaultCacheBehavior.MinTTL).to.equal(0); - }); - - it('targets a named (non-default) custom-policy behavior', async () => { - wireCloudFront({ - GetDistributionConfig: { - DistributionConfig: { - DefaultCacheBehavior: { CachePolicyId: 'cp-default' }, - CacheBehaviors: { Items: [{ PathPattern: '/api/*', CachePolicyId: 'cp-api' }] }, - }, - }, - ListCachePolicies: { CachePolicyList: { Items: [] } }, - GetCachePolicyConfig: { - CachePolicyConfig: { - Name: 'api', - MinTTL: 0, - ParametersInCacheKeyAndForwardedToOrigin: { HeadersConfig: { HeaderBehavior: 'none' } }, - }, - ETag: 'cp-etag', - }, - UpdateCachePolicy: {}, - }); - - const result = await edgeOptimize.applyEdgeOptimizeCacheHeaders({}, 'E2EXAMPLE', '/api/*'); - expect(result.policyId).to.equal('cp-api'); - expect(lastCommand('GetCachePolicyConfig').input.Id).to.equal('cp-api'); - }); - - it('throws when pathPattern is missing', async () => { - let error; - try { - await edgeOptimize.applyEdgeOptimizeCacheHeaders({}, 'E2EXAMPLE', ''); - } catch (e) { - error = e; - } - expect(error.message).to.include('pathPattern'); - }); - }); - - describe('buildLambdaZip', () => { - it('produces a zip buffer with the local-file-header signature', () => { - const zip = edgeOptimize.buildLambdaZip('index.mjs', 'console.log(1)'); - expect(Buffer.isBuffer(zip)).to.equal(true); - expect(zip.readUInt32LE(0)).to.equal(0x04034b50); - }); - }); - - describe('createEdgeOptimizeLambda', () => { - const creds = { accessKeyId: 'A', secretAccessKey: 'S', sessionToken: 'T' }; - - // IAM + Lambda stubs dispatch by command name (robust to call order/poll counts). - const wireIam = (responders) => { - iamSendStub.callsFake((cmd) => { - const r = responders[cmd.commandName]; - return Promise.resolve(typeof r === 'function' ? r(cmd) : (r || {})); - }); - }; - const wireLambda = (responders) => { - lambdaSendStub.callsFake((cmd) => { - const r = responders[cmd.commandName]; - if (r === undefined) { - throw new Error(`unexpected lambda command: ${cmd.commandName}`); - } - return Promise.resolve(typeof r === 'function' ? r(cmd) : r); - }); - }; - const lastLambda = (name) => lambdaSendStub.getCalls() - .filter((c) => c.args[0].commandName === name).pop()?.args[0]; - const notFound = () => Promise.reject(Object.assign(new Error('nf'), { name: 'ResourceNotFoundException' })); - - it('creates the role + function (non-blocking) and returns provisioning', async () => { - wireIam({ - GetRole: () => Promise.reject(Object.assign(new Error('no role'), { name: 'NoSuchEntityException' })), - CreateRole: { Role: { Arn: 'arn:aws:iam::120569600543:role/edgeoptimize-origin-role' } }, - PutRolePolicy: {}, - }); - wireLambda({ - GetFunctionConfiguration: () => notFound(), - CreateFunction: { FunctionArn: 'arn:fn' }, - }); - - const result = await edgeOptimize.createEdgeOptimizeLambda(creds, '120569600543', { roleWaitMs: 0, distributionId: 'E2EXAMPLE' }); - - // Does NOT block on the new function becoming Active — returns provisioning immediately. - expect(result.status).to.equal('provisioning'); - expect(result.created).to.equal(true); - expect(result.versionArn).to.equal(null); - expect(result.roleArn).to.include('edgeoptimize-origin-role'); - expect(lastLambda('CreateFunction').input.Role).to.include('edgeoptimize-origin-role'); - expect(lastLambda('PublishVersion')).to.equal(undefined); // never publishes while Pending - }); - - it('returns provisioning (no mutation) while the function is still finalizing', async () => { - wireIam({ GetRole: { Role: { Arn: 'arn:role' } }, UpdateAssumeRolePolicy: {}, PutRolePolicy: {} }); - wireLambda({ - GetFunctionConfiguration: { - FunctionArn: 'arn:fn', State: 'Active', LastUpdateStatus: 'InProgress', - }, - }); - - const result = await edgeOptimize.createEdgeOptimizeLambda(creds, '120569600543', { distributionId: 'E2EXAMPLE' }); - - expect(result.status).to.equal('provisioning'); - expect(result.versionArn).to.equal(null); - expect(lastLambda('PublishVersion')).to.equal(undefined); // never touched while InProgress - }); - - it('is idempotent: reuses the existing version when the function is idle', async () => { - wireIam({ GetRole: { Role: { Arn: 'arn:role' } }, UpdateAssumeRolePolicy: {}, PutRolePolicy: {} }); - wireLambda({ - GetFunctionConfiguration: { - FunctionArn: 'arn:fn', State: 'Active', LastUpdateStatus: 'Successful', - }, - ListVersionsByFunction: { Versions: [{ Version: '$LATEST' }, { Version: '3', FunctionArn: 'arn:fn:3' }] }, - }); - - const result = await edgeOptimize.createEdgeOptimizeLambda(creds, '120569600543', { distributionId: 'E2EXAMPLE' }); - - expect(result.status).to.equal('ready'); - expect(result.alreadyExisted).to.equal(true); - expect(result.versionArn).to.equal('arn:fn:3'); - expect(lastLambda('PublishVersion')).to.equal(undefined); // reused, not re-published - }); - - it('publishes a version when the function is idle but unpublished', async () => { - wireIam({ GetRole: { Role: { Arn: 'arn:role' } }, UpdateAssumeRolePolicy: {}, PutRolePolicy: {} }); - wireLambda({ - GetFunctionConfiguration: { - FunctionArn: 'arn:fn', State: 'Active', LastUpdateStatus: 'Successful', - }, - ListVersionsByFunction: { Versions: [{ Version: '$LATEST' }] }, - PublishVersion: { FunctionArn: 'arn:fn:1', Version: '1' }, - }); - - const result = await edgeOptimize.createEdgeOptimizeLambda(creds, '120569600543', { distributionId: 'E2EXAMPLE' }); - - expect(result.status).to.equal('ready'); - expect(result.versionArn).to.equal('arn:fn:1'); - expect(lastLambda('PublishVersion')).to.not.equal(undefined); - }); - - it('treats a concurrent-create conflict as provisioning', async () => { - wireIam({ GetRole: { Role: { Arn: 'arn:role' } }, UpdateAssumeRolePolicy: {}, PutRolePolicy: {} }); - wireLambda({ - GetFunctionConfiguration: () => notFound(), - CreateFunction: () => Promise.reject(Object.assign(new Error('exists'), { name: 'ResourceConflictException' })), - }); - - const result = await edgeOptimize.createEdgeOptimizeLambda(creds, '120569600543', { roleWaitMs: 0, distributionId: 'E2EXAMPLE' }); - - expect(result.status).to.equal('provisioning'); - }); - - it('throws for an invalid account id', async () => { - let error; - try { - await edgeOptimize.createEdgeOptimizeLambda(creds, '123'); - } catch (e) { - error = e; - } - expect(error.message).to.include('12-digit'); - expect(iamSendStub.called).to.equal(false); - }); - }); - - describe('getEdgeOptimizeLambdaStatus', () => { - it('reports roleExists:false + exists:false when nothing is provisioned', async () => { - iamSendStub.callsFake(() => Promise.reject(Object.assign(new Error('no role'), { name: 'NoSuchEntityException' }))); - lambdaSendStub.callsFake((cmd) => { - if (cmd.commandName === 'GetFunctionConfiguration') { - return Promise.reject(Object.assign(new Error('nf'), { name: 'ResourceNotFoundException' })); - } - throw new Error(`unexpected: ${cmd.commandName}`); - }); - - const result = await edgeOptimize.getEdgeOptimizeLambdaStatus({}, 'E2EXAMPLE'); - - expect(result).to.deep.equal({ - roleExists: false, roleOk: false, exists: false, versionArn: null, ready: false, - }); - }); - - it('reports the role (roleOk) + published version and ready:true when fully provisioned', async () => { - const trust = encodeURIComponent(JSON.stringify({ - Version: '2012-10-17', - Statement: [{ - Effect: 'Allow', - Principal: { Service: ['lambda.amazonaws.com', 'edgelambda.amazonaws.com'] }, - Action: 'sts:AssumeRole', - }], - })); - iamSendStub.callsFake((cmd) => { - if (cmd.commandName === 'GetRole') { - return Promise.resolve({ Role: { Arn: 'arn:role', AssumeRolePolicyDocument: trust } }); - } - if (cmd.commandName === 'GetRolePolicy') { - return Promise.resolve({ PolicyName: 'EdgeOptimizeLambdaLogging', PolicyDocument: '{}' }); - } - throw new Error(`unexpected iam: ${cmd.commandName}`); - }); - lambdaSendStub.callsFake((cmd) => { - if (cmd.commandName === 'GetFunctionConfiguration') { - return Promise.resolve({ FunctionArn: 'arn:fn', State: 'Active', LastUpdateStatus: 'Successful' }); - } - if (cmd.commandName === 'ListVersionsByFunction') { - return Promise.resolve({ Versions: [{ Version: '$LATEST' }, { Version: '2', FunctionArn: 'arn:fn:2' }] }); - } - throw new Error(`unexpected: ${cmd.commandName}`); - }); - - const result = await edgeOptimize.getEdgeOptimizeLambdaStatus({}, 'E2EXAMPLE'); - - expect(result.roleExists).to.equal(true); - expect(result.roleOk).to.equal(true); - expect(result.exists).to.equal(true); - expect(result.state).to.equal('Active'); - expect(result.versionArn).to.equal('arn:fn:2'); - expect(result.version).to.equal('2'); - expect(result.ready).to.equal(true); - }); - - it('reports ready:false (role created, still provisioning) when not yet published', async () => { - iamSendStub.callsFake(() => Promise.resolve({ Role: { Arn: 'arn:role' } })); - lambdaSendStub.callsFake((cmd) => { - if (cmd.commandName === 'GetFunctionConfiguration') { - return Promise.resolve({ FunctionArn: 'arn:fn', State: 'Pending', LastUpdateStatus: 'InProgress' }); - } - if (cmd.commandName === 'ListVersionsByFunction') { - return Promise.resolve({ Versions: [{ Version: '$LATEST' }] }); - } - throw new Error(`unexpected: ${cmd.commandName}`); - }); - - const result = await edgeOptimize.getEdgeOptimizeLambdaStatus({}, 'E2EXAMPLE'); - - expect(result.roleExists).to.equal(true); - expect(result.exists).to.equal(true); - expect(result.versionArn).to.equal(null); - expect(result.ready).to.equal(false); - }); - }); - - describe('applyEdgeOptimizeAssociations', () => { - const lambdaArn = 'arn:aws:lambda:us-east-1:120569600543:function:edgeoptimize-origin:1'; - - it('wires the CF function (viewer-request) and Lambda (origin req/res) onto the behavior', async () => { - cfSendStub.onFirstCall().resolves({ - FunctionSummary: { FunctionMetadata: { FunctionARN: 'arn:cf-fn' } }, - }); - cfSendStub.onSecondCall().resolves({ - DistributionConfig: { DefaultCacheBehavior: {} }, - ETag: 'dist-etag', - }); - cfSendStub.onThirdCall().resolves({}); - - const result = await edgeOptimize.applyEdgeOptimizeAssociations({}, 'E2EXAMPLE', 'default', lambdaArn); - - expect(result).to.deep.equal({ cfFunctionArn: 'arn:cf-fn', lambdaArn }); - const update = cfSendStub.thirdCall.args[0]; - expect(update.commandName).to.equal('UpdateDistribution'); - const behavior = update.input.DistributionConfig.DefaultCacheBehavior; - expect(behavior.FunctionAssociations.Items[0]).to.deep.equal({ FunctionARN: 'arn:cf-fn', EventType: 'viewer-request' }); - expect(behavior.LambdaFunctionAssociations.Quantity).to.equal(2); - expect(behavior.LambdaFunctionAssociations.Items.map((i) => i.EventType)).to.deep.equal(['origin-request', 'origin-response']); - }); - - it('throws when the CF function is not published to LIVE', async () => { - cfSendStub.onFirstCall().resolves({ FunctionSummary: {} }); - let error; - try { - await edgeOptimize.applyEdgeOptimizeAssociations({}, 'E2EXAMPLE', 'default', lambdaArn); - } catch (e) { - error = e; - } - expect(error.message).to.include('not found or not published'); - }); - - it('surfaces a conflicting viewer-request association', async () => { - cfSendStub.onFirstCall().resolves({ - FunctionSummary: { FunctionMetadata: { FunctionARN: 'arn:cf-fn' } }, - }); - cfSendStub.onSecondCall().resolves({ - DistributionConfig: { - DefaultCacheBehavior: { - FunctionAssociations: { Items: [{ EventType: 'viewer-request', FunctionARN: 'arn:other-fn' }] }, - }, - }, - ETag: 'dist-etag', - }); - let error; - try { - await edgeOptimize.applyEdgeOptimizeAssociations({}, 'E2EXAMPLE', 'default', lambdaArn); - } catch (e) { - error = e; - } - expect(error.message).to.include('already has a different viewer-request function'); - }); - - it('preserves the customer\'s other-slot associations (merge, not wholesale replace)', async () => { - cfSendStub.onFirstCall().resolves({ - FunctionSummary: { FunctionMetadata: { FunctionARN: 'arn:cf-fn' } }, - }); - cfSendStub.onSecondCall().resolves({ - DistributionConfig: { - DefaultCacheBehavior: { - FunctionAssociations: { - Quantity: 1, - Items: [{ EventType: 'viewer-response', FunctionARN: 'arn:cust-fn' }], - }, - LambdaFunctionAssociations: { - Quantity: 1, - Items: [{ EventType: 'viewer-response', LambdaFunctionARN: 'arn:cust-lambda', IncludeBody: false }], - }, - }, - }, - ETag: 'dist-etag', - }); - cfSendStub.onThirdCall().resolves({}); - - await edgeOptimize.applyEdgeOptimizeAssociations({}, 'E2EXAMPLE', 'default', lambdaArn); - const behavior = cfSendStub.thirdCall.args[0].input.DistributionConfig.DefaultCacheBehavior; - // Customer's viewer-response function is preserved; EO's viewer-request function is added. - expect(behavior.FunctionAssociations.Items) - .to.deep.include({ EventType: 'viewer-response', FunctionARN: 'arn:cust-fn' }); - expect(behavior.FunctionAssociations.Items) - .to.deep.include({ FunctionARN: 'arn:cf-fn', EventType: 'viewer-request' }); - // Customer's viewer-response lambda is preserved; EO's origin-request/response are added. - const lambdaEvents = behavior.LambdaFunctionAssociations.Items.map((i) => i.EventType); - expect(lambdaEvents).to.include.members(['viewer-response', 'origin-request', 'origin-response']); - expect(behavior.LambdaFunctionAssociations.Items - .find((i) => i.EventType === 'viewer-response').LambdaFunctionARN).to.equal('arn:cust-lambda'); - }); - - it('refuses to overwrite a customer origin-request Lambda@Edge', async () => { - cfSendStub.onFirstCall().resolves({ - FunctionSummary: { FunctionMetadata: { FunctionARN: 'arn:cf-fn' } }, - }); - cfSendStub.onSecondCall().resolves({ - DistributionConfig: { - DefaultCacheBehavior: { - LambdaFunctionAssociations: { - Items: [{ EventType: 'origin-request', LambdaFunctionARN: 'arn:cust-origin-lambda' }], - }, - }, - }, - ETag: 'dist-etag', - }); - let error; - try { - await edgeOptimize.applyEdgeOptimizeAssociations({}, 'E2EXAMPLE', 'default', lambdaArn); - } catch (e) { - error = e; - } - expect(error.message).to.include('different origin-request'); - expect(cfSendStub.thirdCall).to.equal(null); // never issued an UpdateDistribution - }); - - it('throws when lambdaVersionArn is missing', async () => { - let error; - try { - await edgeOptimize.applyEdgeOptimizeAssociations({}, 'E2EXAMPLE', 'default', ''); - } catch (e) { - error = e; - } - expect(error.message).to.include('lambdaVersionArn'); - expect(cfSendStub.called).to.equal(false); - }); - }); - - describe('verifyEdgeOptimizeRouting', () => { - let fetchStub; - - const makeResponse = (status, headerMap) => ({ - status, - headers: { forEach: (cb) => Object.entries(headerMap).forEach(([k, v]) => cb(v, k)) }, - arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)), - }); - - afterEach(() => { - if (fetchStub) { - fetchStub.restore(); - } - fetchStub = undefined; - }); - - it('passes when the bot response carries x-edgeoptimize-request-id and the human does not', async () => { - fetchStub = sinon.stub(global, 'fetch'); - fetchStub.onFirstCall().resolves(makeResponse(200, { 'x-edgeoptimize-request-id': 'req-123' })); - fetchStub.onSecondCall().resolves(makeResponse(200, {})); - - const result = await edgeOptimize.verifyEdgeOptimizeRouting('https://d.cloudfront.net/'); - - expect(result.passed).to.equal(true); - expect(result.requestId).to.equal('req-123'); - expect(result.details.bot.status).to.equal(200); - }); - - it('does NOT pass when only failover (x-edgeoptimize-fo) is present', async () => { - fetchStub = sinon.stub(global, 'fetch'); - fetchStub.onFirstCall().resolves(makeResponse(200, { 'x-edgeoptimize-fo': '1' })); - fetchStub.onSecondCall().resolves(makeResponse(200, {})); - - const result = await edgeOptimize.verifyEdgeOptimizeRouting('https://d.cloudfront.net/'); - - expect(result.passed).to.equal(false); - expect(result.requestId).to.equal(null); - }); - - it('does NOT pass when the human response is also optimized', async () => { - fetchStub = sinon.stub(global, 'fetch'); - fetchStub.onFirstCall().resolves(makeResponse(200, { 'x-edgeoptimize-request-id': 'req-123' })); - fetchStub.onSecondCall().resolves(makeResponse(200, { 'x-edgeoptimize-request-id': 'req-999' })); - - const result = await edgeOptimize.verifyEdgeOptimizeRouting('https://d.cloudfront.net/'); - - expect(result.passed).to.equal(false); - }); - - it('throws when url is missing', async () => { - let error; - try { - await edgeOptimize.verifyEdgeOptimizeRouting(''); - } catch (e) { - error = e; - } - expect(error.message).to.include('url'); - }); - }); - - describe('runEdgeOptimizeDeployStep', () => { - let fetchStub; - const deployParams = { - distributionId: 'E2EXAMPLE123', - originId: 'origin-aem', - behavior: 'default', - originDomain: 'dev.edgeoptimize.net', - originHeaders: { apiKey: 'eo-key', forwardedHost: 'www.example.com' }, - accountId: '120569600543', - }; - - // Dispatch each client's send() by command name; per-test overrides via the `r` map. - const wire = (cf = {}, lambda = {}, iam = {}) => { - cfSendStub.callsFake((cmd) => { - const fn = cf[cmd.commandName]; - if (fn === undefined) { - throw new Error(`unexpected cf command: ${cmd.commandName}`); - } - return Promise.resolve(typeof fn === 'function' ? fn(cmd) : fn); - }); - lambdaSendStub.callsFake((cmd) => { - const fn = lambda[cmd.commandName]; - if (fn === undefined) { - throw new Error(`unexpected lambda command: ${cmd.commandName}`); - } - return Promise.resolve(typeof fn === 'function' ? fn(cmd) : fn); - }); - iamSendStub.callsFake((cmd) => { - const fn = iam[cmd.commandName]; - if (fn === undefined) { - throw new Error(`unexpected iam command: ${cmd.commandName}`); - } - return Promise.resolve(typeof fn === 'function' ? fn(cmd) : fn); - }); - }; - - const statusOf = (steps, key) => steps.find((s) => s.key === key).status; - const cfCalls = (name) => cfSendStub.getCalls().filter((c) => c.args[0].commandName === name); - - // Returns a responder that throws an AWS-style named error (so the SDK error path triggers). - const throwNamed = (name, message) => () => { - const e = new Error(message); - e.name = name; - throw e; - }; - - const iamCalls = (name) => iamSendStub.getCalls().filter((c) => c.args[0].commandName === name); - - // Encoded trust doc allowing both Lambda@Edge principals — what inspectRole treats as valid. - const validTrust = encodeURIComponent(JSON.stringify({ - Version: '2012-10-17', - Statement: [{ - Effect: 'Allow', - Principal: { Service: ['lambda.amazonaws.com', 'edgelambda.amazonaws.com'] }, - Action: 'sts:AssumeRole', - }], - })); - // IAM mock for an existing, correctly-configured role (roleExists + roleOk = true). - const okRoleIam = (extra = {}) => ({ - GetRole: { Role: { Arn: 'arn:role', AssumeRolePolicyDocument: validTrust } }, - GetRolePolicy: { PolicyName: 'EdgeOptimizeLambdaLogging', PolicyDocument: '{}' }, - UpdateAssumeRolePolicy: {}, - PutRolePolicy: {}, - ...extra, - }); - - // CF + Lambda wiring for "function already published, behavior not yet associated, propagation - // still in progress" — the role-heal gate tests reuse this and only vary the IAM/role mock. - const readyDeployCf = () => ({ - GetDistributionConfig: () => ({ - DistributionConfig: { - Origins: { - Items: [{ - Id: 'EdgeOptimize_Origin', - DomainName: 'dev.edgeoptimize.net', - CustomHeaders: { - Items: [ - { HeaderName: 'x-edgeoptimize-api-key', HeaderValue: 'eo-key' }, - { HeaderName: 'x-forwarded-host', HeaderValue: 'www.example.com' }, - ], - }, - }], - }, - DefaultCacheBehavior: { CachePolicyId: 'cp-1' }, - }, - ETag: 'etag', - }), - DescribeFunction: { FunctionSummary: { FunctionMetadata: { FunctionARN: 'arn:cf-fn' } } }, - ListCachePolicies: { CachePolicyList: { Items: [] } }, - GetCachePolicyConfig: { - CachePolicyConfig: { - Name: 'p', - MinTTL: 0, - ParametersInCacheKeyAndForwardedToOrigin: { - HeadersConfig: { - HeaderBehavior: 'whitelist', - Headers: { Quantity: 2, Items: ['x-edgeoptimize-config', 'x-edgeoptimize-url'] }, - }, - }, - }, - ETag: 'cp-etag', - }, - UpdateDistribution: {}, - ListDistributions: { DistributionList: { Items: [{ Id: 'E2EXAMPLE123', DomainName: 'd123.cloudfront.net', Status: 'InProgress' }] } }, - }); - const readyLambda = () => ({ - GetFunctionConfiguration: { State: 'Active', LastUpdateStatus: 'Successful', FunctionArn: 'arn:lambda' }, - ListVersionsByFunction: { Versions: [{ Version: '3', FunctionArn: 'arn:lambda:3', CodeSha256: 'sha' }] }, - }); - - const makeFetchResponse = (status, headerMap) => ({ - status, - headers: { forEach: (cb) => Object.entries(headerMap).forEach(([k, v]) => cb(v, k)) }, - arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)), - }); - - afterEach(() => { - if (fetchStub) { - fetchStub.restore(); - } - fetchStub = undefined; - }); - - it('first call advances origin+function+cache and returns lambda in_progress (others pending)', async () => { - wire( - { - // origin: existing with matching headers → idempotent no-op (no UpdateDistribution). - GetDistributionConfig: { - DistributionConfig: { - Origins: { - Items: [{ - Id: 'EdgeOptimize_Origin', - DomainName: 'dev.edgeoptimize.net', - CustomHeaders: { - Items: [ - { HeaderName: 'x-edgeoptimize-api-key', HeaderValue: 'eo-key' }, - { HeaderName: 'x-forwarded-host', HeaderValue: 'www.example.com' }, - ], - }, - }], - }, - DefaultCacheBehavior: { CachePolicyId: 'cp-1' }, - }, - ETag: 'etag', - }, - // function gate: already published to LIVE → skip create+publish. - DescribeFunction: { FunctionSummary: { FunctionMetadata: { FunctionARN: 'arn:cf-fn' } } }, - // cache: custom policy already forwards EO headers + MinTTL 0 → no-op. - ListCachePolicies: { CachePolicyList: { Items: [] } }, - GetCachePolicyConfig: { - CachePolicyConfig: { - Name: 'p', - MinTTL: 0, - ParametersInCacheKeyAndForwardedToOrigin: { - HeadersConfig: { - HeaderBehavior: 'whitelist', - Headers: { Quantity: 2, Items: ['x-edgeoptimize-config', 'x-edgeoptimize-url'] }, - }, - }, - }, - ETag: 'cp-etag', - }, - }, - { - // lambda: does not exist yet → kick off create → in_progress. - GetFunctionConfiguration: throwNamed('ResourceNotFoundException', 'nope'), - ListVersionsByFunction: { Versions: [] }, - CreateFunction: { FunctionArn: 'arn:lambda', Version: '$LATEST' }, - }, - // Role already exists + correctly configured → no role-propagation wait. - okRoleIam(), - ); - - const out = await edgeOptimize.runEdgeOptimizeDeployStep({}, deployParams); - - expect(statusOf(out.steps, 'origin')).to.equal('done'); - expect(statusOf(out.steps, 'function')).to.equal('done'); - expect(statusOf(out.steps, 'cache')).to.equal('done'); - expect(statusOf(out.steps, 'lambda')).to.equal('in_progress'); - expect(statusOf(out.steps, 'associate')).to.equal('pending'); - expect(statusOf(out.steps, 'verify')).to.equal('pending'); - expect(out.routingDeployed).to.equal(false); - expect(out.verified).to.equal(false); - // function already LIVE → never created/published. - expect(cfCalls('CreateFunction')).to.have.length(0); - expect(cfCalls('PublishFunction')).to.have.length(0); - }); - - it('with lambda ready proceeds to associate then verify (in_progress until propagation)', async () => { - const lambdaVersionArn = 'arn:aws:lambda:us-east-1:120569600543:function:edgeoptimize-origin:3'; - wire( - { - GetDistributionConfig: () => ({ - // origin exists (idempotent), default behavior NOT yet associated (associate must run). - DistributionConfig: { - Origins: { - Items: [{ - Id: 'EdgeOptimize_Origin', - DomainName: 'dev.edgeoptimize.net', - CustomHeaders: { - Items: [ - { HeaderName: 'x-edgeoptimize-api-key', HeaderValue: 'eo-key' }, - { HeaderName: 'x-forwarded-host', HeaderValue: 'www.example.com' }, - ], - }, - }], - }, - DefaultCacheBehavior: { CachePolicyId: 'cp-1' }, - }, - ETag: 'etag', - }), - DescribeFunction: { FunctionSummary: { FunctionMetadata: { FunctionARN: 'arn:cf-fn' } } }, - ListCachePolicies: { CachePolicyList: { Items: [] } }, - GetCachePolicyConfig: { - CachePolicyConfig: { - Name: 'p', - MinTTL: 0, - ParametersInCacheKeyAndForwardedToOrigin: { - HeadersConfig: { - HeaderBehavior: 'whitelist', - Headers: { Quantity: 2, Items: ['x-edgeoptimize-config', 'x-edgeoptimize-url'] }, - }, - }, - }, - ETag: 'cp-etag', - }, - UpdateDistribution: {}, - ListDistributions: { DistributionList: { Items: [{ Id: 'E2EXAMPLE123', DomainName: 'd123.cloudfront.net', Status: 'Deployed' }] } }, - }, - { - GetFunctionConfiguration: { State: 'Active', LastUpdateStatus: 'Successful', FunctionArn: 'arn:lambda' }, - ListVersionsByFunction: { Versions: [{ Version: '3', FunctionArn: lambdaVersionArn, CodeSha256: 'sha' }] }, - }, - okRoleIam(), - ); - // verify probe: bot lacks request-id → not passed yet (propagation). - fetchStub = sinon.stub(global, 'fetch'); - fetchStub.onFirstCall().resolves(makeFetchResponse(200, {})); - fetchStub.onSecondCall().resolves(makeFetchResponse(200, {})); - - const out = await edgeOptimize.runEdgeOptimizeDeployStep({}, deployParams); - - expect(statusOf(out.steps, 'lambda')).to.equal('done'); - expect(statusOf(out.steps, 'associate')).to.equal('done'); - expect(statusOf(out.steps, 'verify')).to.equal('in_progress'); - expect(out.routingDeployed).to.equal(true); - expect(out.verified).to.equal(false); - // associate ran exactly one UpdateDistribution (behavior was not associated). - expect(cfCalls('UpdateDistribution')).to.have.length(1); - }); - - it('verify passes → verified true and verify done', async () => { - const lambdaVersionArn = 'arn:aws:lambda:us-east-1:120569600543:function:edgeoptimize-origin:3'; - wire( - { - GetDistributionConfig: () => ({ - DistributionConfig: { - Origins: { - Items: [{ - Id: 'EdgeOptimize_Origin', - DomainName: 'dev.edgeoptimize.net', - CustomHeaders: { - Items: [ - { HeaderName: 'x-edgeoptimize-api-key', HeaderValue: 'eo-key' }, - { HeaderName: 'x-forwarded-host', HeaderValue: 'www.example.com' }, - ], - }, - }], - }, - // already associated → associate gate skips UpdateDistribution. - DefaultCacheBehavior: { - CachePolicyId: 'cp-1', - FunctionAssociations: { Items: [{ EventType: 'viewer-request', FunctionARN: 'arn:fn/edgeoptimize-routing' }] }, - LambdaFunctionAssociations: { Items: [{ EventType: 'origin-request', LambdaFunctionARN: 'arn:edgeoptimize-origin:3' }] }, - }, - }, - ETag: 'etag', - }), - DescribeFunction: { FunctionSummary: { FunctionMetadata: { FunctionARN: 'arn:cf-fn' } } }, - ListCachePolicies: { CachePolicyList: { Items: [] } }, - GetCachePolicyConfig: { - CachePolicyConfig: { - Name: 'p', - MinTTL: 0, - ParametersInCacheKeyAndForwardedToOrigin: { - HeadersConfig: { - HeaderBehavior: 'whitelist', - Headers: { Quantity: 2, Items: ['x-edgeoptimize-config', 'x-edgeoptimize-url'] }, - }, - }, - }, - ETag: 'cp-etag', - }, - ListDistributions: { DistributionList: { Items: [{ Id: 'E2EXAMPLE123', DomainName: 'd123.cloudfront.net', Status: 'Deployed' }] } }, - }, - { - GetFunctionConfiguration: { State: 'Active', LastUpdateStatus: 'Successful', FunctionArn: 'arn:lambda' }, - ListVersionsByFunction: { Versions: [{ Version: '3', FunctionArn: lambdaVersionArn, CodeSha256: 'sha' }] }, - }, - okRoleIam(), - ); - fetchStub = sinon.stub(global, 'fetch'); - fetchStub.onFirstCall().resolves(makeFetchResponse(200, { 'x-edgeoptimize-request-id': 'req-1' })); - fetchStub.onSecondCall().resolves(makeFetchResponse(200, {})); - - const out = await edgeOptimize.runEdgeOptimizeDeployStep({}, deployParams); - - expect(statusOf(out.steps, 'associate')).to.equal('done'); - expect(statusOf(out.steps, 'propagation')).to.equal('done'); - expect(statusOf(out.steps, 'verify')).to.equal('done'); - expect(out.routingDeployed).to.equal(true); - expect(out.verified).to.equal(true); - // verify probe surfaces the per-UA result the wizard renders. - const verifyProbe = out.steps.find((s) => s.key === 'verify').probe; - expect(verifyProbe.bot).to.deep.include({ ua: 'chatgpt-user', requestId: 'req-1', failover: false }); - expect(verifyProbe.human.requestId).to.equal(null); - // idempotent gate: behavior already associated → no UpdateDistribution at all. - expect(cfCalls('UpdateDistribution')).to.have.length(0); - }); - - it('holds at propagation (verify pending) while the distribution is still Deploying', async () => { - const lambdaVersionArn = 'arn:aws:lambda:us-east-1:120569600543:function:edgeoptimize-origin:3'; - wire( - { - GetDistributionConfig: () => ({ - DistributionConfig: { - Origins: { - Items: [{ - Id: 'EdgeOptimize_Origin', - DomainName: 'dev.edgeoptimize.net', - CustomHeaders: { - Items: [ - { HeaderName: 'x-edgeoptimize-api-key', HeaderValue: 'eo-key' }, - { HeaderName: 'x-forwarded-host', HeaderValue: 'www.example.com' }, - ], - }, - }], - }, - DefaultCacheBehavior: { - CachePolicyId: 'cp-1', - FunctionAssociations: { Items: [{ EventType: 'viewer-request', FunctionARN: 'arn:fn/edgeoptimize-routing' }] }, - LambdaFunctionAssociations: { Items: [{ EventType: 'origin-request', LambdaFunctionARN: 'arn:edgeoptimize-origin:3' }] }, - }, - }, - ETag: 'etag', - }), - DescribeFunction: { FunctionSummary: { FunctionMetadata: { FunctionARN: 'arn:cf-fn' } } }, - ListCachePolicies: { CachePolicyList: { Items: [] } }, - GetCachePolicyConfig: { - CachePolicyConfig: { - Name: 'p', - MinTTL: 0, - ParametersInCacheKeyAndForwardedToOrigin: { - HeadersConfig: { HeaderBehavior: 'whitelist', Headers: { Quantity: 2, Items: ['x-edgeoptimize-config', 'x-edgeoptimize-url'] } }, - }, - }, - ETag: 'cp-etag', - }, - // distribution still deploying → propagation gate holds, verify never runs. - ListDistributions: { DistributionList: { Items: [{ Id: 'E2EXAMPLE123', DomainName: 'd123.cloudfront.net', Status: 'InProgress' }] } }, - }, - { - GetFunctionConfiguration: { State: 'Active', LastUpdateStatus: 'Successful', FunctionArn: 'arn:lambda' }, - ListVersionsByFunction: { Versions: [{ Version: '3', FunctionArn: lambdaVersionArn, CodeSha256: 'sha' }] }, - }, - okRoleIam(), - ); - - const out = await edgeOptimize.runEdgeOptimizeDeployStep({}, deployParams); - - expect(statusOf(out.steps, 'associate')).to.equal('done'); - expect(statusOf(out.steps, 'propagation')).to.equal('in_progress'); - expect(statusOf(out.steps, 'verify')).to.equal('pending'); - expect(out.steps.find((s) => s.key === 'propagation').detail).to.include('Deploying'); - expect(out.verified).to.equal(false); - }); - - it('marks the step error (earlier done, later pending) and does not throw when a step fails', async () => { - wire( - { - GetDistributionConfig: { - DistributionConfig: { - Origins: { - Items: [{ - Id: 'EdgeOptimize_Origin', - DomainName: 'dev.edgeoptimize.net', - CustomHeaders: { - Items: [ - { HeaderName: 'x-edgeoptimize-api-key', HeaderValue: 'eo-key' }, - { HeaderName: 'x-forwarded-host', HeaderValue: 'www.example.com' }, - ], - }, - }], - }, - DefaultCacheBehavior: { CachePolicyId: 'cp-1' }, - }, - ETag: 'etag', - }, - // function gate DescribeFunction throws a non-NoSuchFunction error → step error. - DescribeFunction: () => { throw new Error('AccessDenied on DescribeFunction'); }, - }, - ); - - const out = await edgeOptimize.runEdgeOptimizeDeployStep({}, deployParams); - - expect(statusOf(out.steps, 'origin')).to.equal('done'); - expect(statusOf(out.steps, 'function')).to.equal('error'); - expect(out.steps.find((s) => s.key === 'function').detail).to.include('AccessDenied'); - // later steps remain pending. - expect(statusOf(out.steps, 'cache')).to.equal('pending'); - expect(statusOf(out.steps, 'lambda')).to.equal('pending'); - expect(out.routingDeployed).to.equal(false); - }); - - it('holds the sequence when lambda exists but is not yet ready (no re-create)', async () => { - wire( - { - GetDistributionConfig: { - DistributionConfig: { - Origins: { - Items: [{ - Id: 'EdgeOptimize_Origin', - DomainName: 'dev.edgeoptimize.net', - CustomHeaders: { - Items: [ - { HeaderName: 'x-edgeoptimize-api-key', HeaderValue: 'eo-key' }, - { HeaderName: 'x-forwarded-host', HeaderValue: 'www.example.com' }, - ], - }, - }], - }, - DefaultCacheBehavior: { CachePolicyId: 'cp-1' }, - }, - ETag: 'etag', - }, - DescribeFunction: { FunctionSummary: { FunctionMetadata: { FunctionARN: 'arn:cf-fn' } } }, - ListCachePolicies: { CachePolicyList: { Items: [] } }, - GetCachePolicyConfig: { - CachePolicyConfig: { - Name: 'p', - MinTTL: 0, - ParametersInCacheKeyAndForwardedToOrigin: { - HeadersConfig: { - HeaderBehavior: 'whitelist', - Headers: { Quantity: 2, Items: ['x-edgeoptimize-config', 'x-edgeoptimize-url'] }, - }, - }, - }, - ETag: 'cp-etag', - }, - }, - { - // exists but still finalizing (Pending) → createEdgeOptimizeLambda is called to drive the - // state machine, but it must NOT CreateFunction or PublishVersion while still Pending. - GetFunctionConfiguration: { State: 'Pending', LastUpdateStatus: 'InProgress', FunctionArn: 'arn:lambda' }, - ListVersionsByFunction: { Versions: [] }, - }, - okRoleIam(), - ); - - const out = await edgeOptimize.runEdgeOptimizeDeployStep({}, deployParams); - - expect(statusOf(out.steps, 'lambda')).to.equal('in_progress'); - expect(statusOf(out.steps, 'associate')).to.equal('pending'); - // Pending → neither CreateFunction nor PublishVersion (no re-create, no premature publish). - expect(lambdaSendStub.getCalls().filter((c) => c.args[0].commandName === 'CreateFunction')).to.have.length(0); - expect(lambdaSendStub.getCalls().filter((c) => c.args[0].commandName === 'PublishVersion')).to.have.length(0); - }); - - it('publishes the version once the Lambda is Active, then proceeds to associate + verify', async () => { - const lambdaVersionArn = 'arn:aws:lambda:us-east-1:120569600543:function:edgeoptimize-origin:1'; - wire( - { - GetDistributionConfig: () => ({ - DistributionConfig: { - Origins: { - Items: [{ - Id: 'EdgeOptimize_Origin', - DomainName: 'dev.edgeoptimize.net', - CustomHeaders: { - Items: [ - { HeaderName: 'x-edgeoptimize-api-key', HeaderValue: 'eo-key' }, - { HeaderName: 'x-forwarded-host', HeaderValue: 'www.example.com' }, - ], - }, - }], - }, - // already associated → associate gate skips; the focus is the lambda publish path. - DefaultCacheBehavior: { - CachePolicyId: 'cp-1', - FunctionAssociations: { Items: [{ EventType: 'viewer-request', FunctionARN: 'arn:fn/edgeoptimize-routing' }] }, - LambdaFunctionAssociations: { Items: [{ EventType: 'origin-request', LambdaFunctionARN: 'arn:edgeoptimize-origin:1' }] }, - }, - }, - ETag: 'etag', - }), - DescribeFunction: { FunctionSummary: { FunctionMetadata: { FunctionARN: 'arn:cf-fn' } } }, - ListCachePolicies: { CachePolicyList: { Items: [] } }, - GetCachePolicyConfig: { - CachePolicyConfig: { - Name: 'p', - MinTTL: 0, - ParametersInCacheKeyAndForwardedToOrigin: { - HeadersConfig: { - HeaderBehavior: 'whitelist', - Headers: { Quantity: 2, Items: ['x-edgeoptimize-config', 'x-edgeoptimize-url'] }, - }, - }, - }, - ETag: 'cp-etag', - }, - ListDistributions: { DistributionList: { Items: [{ Id: 'E2EXAMPLE123', DomainName: 'd123.cloudfront.net', Status: 'Deployed' }] } }, - }, - { - // Active + idle, NO published version yet → createEdgeOptimizeLambda must publish one. - GetFunctionConfiguration: { State: 'Active', LastUpdateStatus: 'Successful', FunctionArn: 'arn:lambda' }, - ListVersionsByFunction: { Versions: [] }, - PublishVersion: { Version: '1', FunctionArn: lambdaVersionArn }, - }, - okRoleIam(), - ); - fetchStub = sinon.stub(global, 'fetch'); - fetchStub.onFirstCall().resolves(makeFetchResponse(200, { 'x-edgeoptimize-request-id': 'req-1' })); - fetchStub.onSecondCall().resolves(makeFetchResponse(200, {})); - - const out = await edgeOptimize.runEdgeOptimizeDeployStep({}, deployParams); - - // the fix: Active-without-version gets published → lambda flips to done (not stuck). - expect(statusOf(out.steps, 'lambda')).to.equal('done'); - expect(lambdaSendStub.getCalls().filter((c) => c.args[0].commandName === 'PublishVersion')).to.have.length(1); - expect(statusOf(out.steps, 'associate')).to.equal('done'); - expect(statusOf(out.steps, 'verify')).to.equal('done'); - expect(out.routingDeployed).to.equal(true); - expect(out.verified).to.equal(true); - }); - - // Role-heal gate: the lambda step is "done" only when the function is ready AND the role is - // present + correctly configured. The next three cover each role state on a ready function. - it('lambda ready + role MISSING → recreates the role, then completes the lambda step', async () => { - wire(readyDeployCf(), readyLambda(), { - GetRole: throwNamed('NoSuchEntityException', 'no role'), - CreateRole: { Role: { Arn: 'arn:role' } }, - PutRolePolicy: {}, - }); - - const out = await edgeOptimize.runEdgeOptimizeDeployStep({}, deployParams); - - expect(statusOf(out.steps, 'lambda')).to.equal('done'); - expect(iamCalls('CreateRole')).to.have.length(1); // role recreated despite a ready function - expect(iamCalls('PutRolePolicy')).to.have.length(1); // logs policy re-attached - expect(out.routingDeployed).to.equal(true); - expect(statusOf(out.steps, 'propagation')).to.equal('in_progress'); - }); - - it('lambda ready + role MIS-CONFIGURED → heals trust + logs, then completes', async () => { - const badTrust = encodeURIComponent(JSON.stringify({ - Version: '2012-10-17', - // missing edgelambda.amazonaws.com → roleOk is false - Statement: [{ Effect: 'Allow', Principal: { Service: 'lambda.amazonaws.com' }, Action: 'sts:AssumeRole' }], - })); - wire(readyDeployCf(), readyLambda(), { - GetRole: { Role: { Arn: 'arn:role', AssumeRolePolicyDocument: badTrust } }, - GetRolePolicy: { PolicyName: 'EdgeOptimizeLambdaLogging', PolicyDocument: '{}' }, - UpdateAssumeRolePolicy: {}, - PutRolePolicy: {}, - }); - - const out = await edgeOptimize.runEdgeOptimizeDeployStep({}, deployParams); - - expect(statusOf(out.steps, 'lambda')).to.equal('done'); - expect(iamCalls('UpdateAssumeRolePolicy')).to.have.length(1); // trust corrected - expect(iamCalls('PutRolePolicy')).to.have.length(1); // logs policy re-attached - expect(iamCalls('CreateRole')).to.have.length(0); // role exists → not recreated - expect(out.routingDeployed).to.equal(true); - }); - - it('lambda ready + role OK → completes WITHOUT touching the role (no churn)', async () => { - wire(readyDeployCf(), readyLambda(), okRoleIam()); - - const out = await edgeOptimize.runEdgeOptimizeDeployStep({}, deployParams); - - expect(statusOf(out.steps, 'lambda')).to.equal('done'); - expect(iamCalls('CreateRole')).to.have.length(0); - expect(iamCalls('UpdateAssumeRolePolicy')).to.have.length(0); - expect(iamCalls('PutRolePolicy')).to.have.length(0); // gate passed → createLambda skipped - expect(out.routingDeployed).to.equal(true); - }); - }); - - describe('planEdgeOptimizeDeploy', () => { - const planParams = { - distributionId: 'E2EXAMPLE123', - originId: 'origin-aem', - behavior: 'default', - originDomain: 'live.edgeoptimize.net', - originHeaders: { apiKey: 'eo-key', forwardedHost: 'www.example.com' }, - accountId: '120569600543', - }; - - // Dispatch each client's send() by command name; per-test overrides via the maps. - const wire = (cf = {}, lambda = {}, iam = {}) => { - cfSendStub.callsFake((cmd) => { - const fn = cf[cmd.commandName]; - if (fn === undefined) { - throw new Error(`unexpected cf command: ${cmd.commandName}`); - } - return Promise.resolve(typeof fn === 'function' ? fn(cmd) : fn); - }); - lambdaSendStub.callsFake((cmd) => { - const fn = lambda[cmd.commandName]; - if (fn === undefined) { - throw new Error(`unexpected lambda command: ${cmd.commandName}`); - } - return Promise.resolve(typeof fn === 'function' ? fn(cmd) : fn); - }); - iamSendStub.callsFake((cmd) => { - const fn = iam[cmd.commandName]; - if (fn === undefined) { - throw new Error(`unexpected iam command: ${cmd.commandName}`); - } - return Promise.resolve(typeof fn === 'function' ? fn(cmd) : fn); - }); - }; - - const throwNamed = (name, message) => () => { - const e = new Error(message); - e.name = name; - throw e; - }; - - const stepOf = (steps, key) => steps.find((s) => s.key === key); - - it('plans an all-create deploy (nothing exists yet, legacy cache)', async () => { - wire( - { - GetDistributionConfig: { - DistributionConfig: { - Origins: { Items: [] }, - DefaultCacheBehavior: { - ForwardedValues: { Headers: { Quantity: 0, Items: [] } }, - MinTTL: 60, - }, - }, - }, - // function gate: not published to LIVE. - DescribeFunction: throwNamed('NoSuchFunctionExists', 'no fn'), - }, - { - // lambda: does not exist. - GetFunctionConfiguration: throwNamed('ResourceNotFoundException', 'nope'), - }, - { - GetRole: throwNamed('NoSuchEntityException', 'no role'), - }, - ); - - const result = await edgeOptimize.planEdgeOptimizeDeploy({}, planParams); - - expect(result.canProceed).to.equal(true); - expect(result.blocker).to.equal(null); - expect(result.steps.map((s) => s.key)).to.deep.equal(['origin', 'function', 'cache', 'lambda', 'associate']); - expect(stepOf(result.steps, 'origin').action).to.equal('create'); - expect(stepOf(result.steps, 'function').action).to.equal('create'); - expect(stepOf(result.steps, 'cache').action).to.equal('update'); - expect(stepOf(result.steps, 'cache').detail).to.include('Add the Edge Optimize headers'); - expect(stepOf(result.steps, 'lambda').action).to.equal('create'); - expect(stepOf(result.steps, 'associate').action).to.equal('create'); - // no `verify` row in the plan - expect(result.steps.some((s) => s.key === 'verify')).to.equal(false); - }); - - it('blocks when the behavior is already associated (canProceed:false + exact blocker)', async () => { - const associatedBehavior = { - ForwardedValues: { Headers: { Items: [] } }, - FunctionAssociations: { - Items: [{ EventType: 'viewer-request', FunctionARN: 'arn:aws:cloudfront::1:function/edgeoptimize-routing-adobe-E2EXAMPLE123' }], - }, - LambdaFunctionAssociations: { - Items: [{ EventType: 'origin-request', LambdaFunctionARN: 'arn:aws:lambda:us-east-1:1:function:edgeoptimize-origin-adobe-E2EXAMPLE123:1' }], - }, - }; - wire( - { - GetDistributionConfig: { - DistributionConfig: { - Origins: { Items: [] }, - DefaultCacheBehavior: associatedBehavior, - }, - }, - DescribeFunction: throwNamed('NoSuchFunctionExists', 'no fn'), - }, - { GetFunctionConfiguration: throwNamed('ResourceNotFoundException', 'nope') }, - { GetRole: throwNamed('NoSuchEntityException', 'no role') }, - ); - - const result = await edgeOptimize.planEdgeOptimizeDeploy({}, planParams); - - expect(result.canProceed).to.equal(false); - expect(result.blocker).to.equal( - "This behaviour is already associated with routes, please recheck — can't proceed with this automation.", - ); - expect(stepOf(result.steps, 'associate').action).to.equal('blocked'); - }); - - it('describes a managed-policy clone in the cache step', async () => { - wire( - { - GetDistributionConfig: { - DistributionConfig: { - Origins: { Items: [] }, - DefaultCacheBehavior: { CachePolicyId: 'managed-1' }, - }, - }, - DescribeFunction: throwNamed('NoSuchFunctionExists', 'no fn'), - ListCachePolicies: (cmd) => (cmd.input.Type === 'managed' - ? { CachePolicyList: { Items: [{ CachePolicy: { Id: 'managed-1' } }] } } - : { CachePolicyList: { Items: [] } }), // no existing clone - GetCachePolicy: { - CachePolicy: { CachePolicyConfig: { Name: 'Managed-CachingOptimized' } }, - }, - }, - { GetFunctionConfiguration: throwNamed('ResourceNotFoundException', 'nope') }, - { GetRole: throwNamed('NoSuchEntityException', 'no role') }, - ); - - const result = await edgeOptimize.planEdgeOptimizeDeploy({}, planParams); - - expect(stepOf(result.steps, 'cache').action).to.equal('create'); - expect(stepOf(result.steps, 'cache').detail).to.include('CachingOptimized-adobe-E2EXAMPLE123'); - expect(result.canProceed).to.equal(true); - }); - - it('marks the managed cache step "update" when the clone exists but the behavior is not associated with it', async () => { - wire( - { - GetDistributionConfig: { - DistributionConfig: { - Origins: { Items: [] }, - DefaultCacheBehavior: { CachePolicyId: 'managed-1' }, - }, - }, - DescribeFunction: throwNamed('NoSuchFunctionExists', 'no fn'), - ListCachePolicies: (cmd) => (cmd.input.Type === 'managed' - ? { CachePolicyList: { Items: [{ CachePolicy: { Id: 'managed-1' } }] } } - : { CachePolicyList: { Items: [{ CachePolicy: { Id: 'eo-clone', CachePolicyConfig: { Name: 'CachingOptimized-adobe-E2EXAMPLE123' } } }] } }), - GetCachePolicy: { - CachePolicy: { CachePolicyConfig: { Name: 'Managed-CachingOptimized' } }, - }, - }, - { GetFunctionConfiguration: throwNamed('ResourceNotFoundException', 'nope') }, - { GetRole: throwNamed('NoSuchEntityException', 'no role') }, - ); - - const result = await edgeOptimize.planEdgeOptimizeDeploy({}, planParams); - // The clone exists but the behavior is still on the managed policy → the deploy will switch - // the behavior to the existing copy, so this is an 'update' with a clear created-but-not- - // associated message that names both the current policy and the copy. - expect(stepOf(result.steps, 'cache').action).to.equal('update'); - expect(stepOf(result.steps, 'cache').detail).to.include('not associated'); - expect(stepOf(result.steps, 'cache').detail).to.include('CachingOptimized-adobe-E2EXAMPLE123'); - }); - - it('marks function + lambda + origin "exists" when already present', async () => { - wire( - { - GetDistributionConfig: { - DistributionConfig: { - Origins: { - Items: [{ - Id: 'EdgeOptimize_Origin', - DomainName: 'live.edgeoptimize.net', - CustomHeaders: { - Items: [ - { HeaderName: 'x-edgeoptimize-api-key', HeaderValue: 'eo-key' }, - { HeaderName: 'x-forwarded-host', HeaderValue: 'www.example.com' }, - ], - }, - }], - }, - DefaultCacheBehavior: { - CachePolicyId: 'cp-custom', - }, - }, - }, - // function gate: already published to LIVE. - DescribeFunction: { FunctionSummary: { FunctionMetadata: { FunctionARN: 'arn:cf-fn' } } }, - // cache: custom (not managed), without our headers → update in place. - ListCachePolicies: { CachePolicyList: { Items: [] } }, - GetCachePolicyConfig: { - CachePolicyConfig: { - Name: 'my-custom-policy', - MinTTL: 0, - ParametersInCacheKeyAndForwardedToOrigin: { HeadersConfig: { HeaderBehavior: 'none' } }, - }, - }, - }, - { - // lambda: exists + has a published version → ready. - GetFunctionConfiguration: { FunctionArn: 'arn:lambda', State: 'Active', LastUpdateStatus: 'Successful' }, - ListVersionsByFunction: { Versions: [{ Version: '3', FunctionArn: 'arn:lambda:3', CodeSha256: 'sha' }] }, - }, - { - GetRole: { - Role: { - Arn: 'arn:role', - AssumeRolePolicyDocument: encodeURIComponent(JSON.stringify({ - Version: '2012-10-17', - Statement: [{ - Effect: 'Allow', - Principal: { Service: ['lambda.amazonaws.com', 'edgelambda.amazonaws.com'] }, - Action: 'sts:AssumeRole', - }], - })), - }, - }, - GetRolePolicy: { PolicyName: 'EdgeOptimizeLambdaLogging', PolicyDocument: '{}' }, - }, - ); - - const result = await edgeOptimize.planEdgeOptimizeDeploy({}, planParams); - - expect(stepOf(result.steps, 'origin').action).to.equal('exists'); - expect(stepOf(result.steps, 'function').action).to.equal('exists'); - expect(stepOf(result.steps, 'cache').action).to.equal('update'); - expect(stepOf(result.steps, 'cache').detail).to.include('my-custom-policy'); - expect(stepOf(result.steps, 'lambda').action).to.equal('exists'); - // Role visibility: an existing, correctly-configured execution role is surfaced + reused. - expect(stepOf(result.steps, 'lambda').detail).to.include('Execution role'); - expect(stepOf(result.steps, 'lambda').detail).to.include('correctly configured'); - expect(stepOf(result.steps, 'associate').action).to.equal('create'); - expect(result.canProceed).to.equal(true); - }); - - it('marks the custom cache step "exists" when our headers are already present (idempotent re-deploy)', async () => { - wire( - { - GetDistributionConfig: { - DistributionConfig: { - Origins: { Items: [] }, - DefaultCacheBehavior: { CachePolicyId: 'eo-clone' }, - }, - }, - DescribeFunction: throwNamed('NoSuchFunctionExists', 'no fn'), - ListCachePolicies: { CachePolicyList: { Items: [] } }, // eo-clone not managed → custom - GetCachePolicyConfig: { - CachePolicyConfig: { - Name: 'CachingOptimized-adobe-E2EXAMPLE123', - MinTTL: 0, - ParametersInCacheKeyAndForwardedToOrigin: { - HeadersConfig: { - HeaderBehavior: 'whitelist', - Headers: { Items: ['x-edgeoptimize-config', 'x-edgeoptimize-url'] }, - }, - }, - }, - }, - }, - { GetFunctionConfiguration: throwNamed('ResourceNotFoundException', 'nope') }, - { GetRole: throwNamed('NoSuchEntityException', 'no role') }, - ); - - const result = await edgeOptimize.planEdgeOptimizeDeploy({}, planParams); - expect(stepOf(result.steps, 'cache').action).to.equal('exists'); - expect(stepOf(result.steps, 'cache').detail).to.include('Already has the Edge Optimize headers'); - }); - - it('throws when distributionId is missing', async () => { - let error; - try { - await edgeOptimize.planEdgeOptimizeDeploy({}, { ...planParams, distributionId: '' }); - } catch (e) { - error = e; - } - expect(error.message).to.include('distributionId'); - }); - - it('throws when behavior is missing', async () => { - let error; - try { - await edgeOptimize.planEdgeOptimizeDeploy({}, { ...planParams, behavior: '' }); - } catch (e) { - error = e; - } - expect(error.message).to.include('behavior'); - }); - }); -}); From 718aebb4c7d4b7178506c6d5936e47a1ac24c31f Mon Sep 17 00:00:00 2001 From: Akash Bhardwaj Date: Thu, 25 Jun 2026 17:36:07 +0530 Subject: [PATCH 41/56] fix(edge-optimize): bump tokowaka-client tarball to include first-poll 503 fix Re-points the @adobe/spacecat-shared-tokowaka-client gist tarball + lockfile integrity to the rebuilt package that returns provisioning immediately after the Lambda@Edge exec role is created, avoiding the first deploy poll's 503 first-byte timeout (spacecat-shared #1722). Co-authored-by: Cursor --- package-lock.json | 6 +++--- package.json | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index 3bfbbdbb4e..be844fee99 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,7 +32,7 @@ "@adobe/spacecat-shared-scrape-client": "2.6.3", "@adobe/spacecat-shared-slack-client": "1.6.7", "@adobe/spacecat-shared-tier-client": "1.5.1", - "@adobe/spacecat-shared-tokowaka-client": "https://gist.githubusercontent.com/ABHA61/85486e1ccf4882e6b09800f629daed1f/raw/b1e637aa82161192c1172d46ad2fb2e14c3492c4/adobe-spacecat-shared-tokowaka-client-1.19.1.tgz", + "@adobe/spacecat-shared-tokowaka-client": "https://gist.githubusercontent.com/ABHA61/85486e1ccf4882e6b09800f629daed1f/raw/91aeea2c3815c51503394f8487f426da801337e0/adobe-spacecat-shared-tokowaka-client-1.19.1.tgz", "@adobe/spacecat-shared-user-manager-client": "1.1.0", "@adobe/spacecat-shared-utils": "1.119.2", "@adobe/spacecat-shared-vault-secrets": "1.3.5", @@ -8801,8 +8801,8 @@ }, "node_modules/@adobe/spacecat-shared-tokowaka-client": { "version": "1.19.1", - "resolved": "https://gist.githubusercontent.com/ABHA61/85486e1ccf4882e6b09800f629daed1f/raw/b1e637aa82161192c1172d46ad2fb2e14c3492c4/adobe-spacecat-shared-tokowaka-client-1.19.1.tgz", - "integrity": "sha512-GohB24TGlroGPE1Vxj7rIz74Zop9T3BupOYmMK8xAhost6Yzbz0sKPE5xcyEY+rw/zVO8aDjwbOYQfZerdRynQ==", + "resolved": "https://gist.githubusercontent.com/ABHA61/85486e1ccf4882e6b09800f629daed1f/raw/91aeea2c3815c51503394f8487f426da801337e0/adobe-spacecat-shared-tokowaka-client-1.19.1.tgz", + "integrity": "sha512-Ez7riox3dHgDSDrsb4Ll4h2284IZZnVa/J6ZWNDSa5RnZfkU9yHikSZPsiXk6oL9Y87OqSQWvc92CfapuFiWIw==", "license": "Apache-2.0", "dependencies": { "@adobe/spacecat-shared-utils": "1.81.1", diff --git a/package.json b/package.json index 9d7aefdbdc..23fa82de47 100644 --- a/package.json +++ b/package.json @@ -97,7 +97,7 @@ "@adobe/spacecat-shared-scrape-client": "2.6.3", "@adobe/spacecat-shared-slack-client": "1.6.7", "@adobe/spacecat-shared-tier-client": "1.5.1", - "@adobe/spacecat-shared-tokowaka-client": "https://gist.githubusercontent.com/ABHA61/85486e1ccf4882e6b09800f629daed1f/raw/b1e637aa82161192c1172d46ad2fb2e14c3492c4/adobe-spacecat-shared-tokowaka-client-1.19.1.tgz", + "@adobe/spacecat-shared-tokowaka-client": "https://gist.githubusercontent.com/ABHA61/85486e1ccf4882e6b09800f629daed1f/raw/91aeea2c3815c51503394f8487f426da801337e0/adobe-spacecat-shared-tokowaka-client-1.19.1.tgz", "@adobe/spacecat-shared-user-manager-client": "1.1.0", "@adobe/spacecat-shared-utils": "1.119.2", "@adobe/spacecat-shared-vault-secrets": "1.3.5", From 9e048f5d8a1f0c0b6e710aec5aa7435863633a26 Mon Sep 17 00:00:00 2001 From: Akash Bhardwaj Date: Thu, 25 Jun 2026 17:56:58 +0530 Subject: [PATCH 42/56] fix(edge-optimize): register CloudFront wizard routes in FACS capability map The new facs-capabilities invariant (from main) requires every route in src/routes/index.js to be owned by a PRODUCTS_ROUTES sub-map or INTERNAL_ROUTES. The 16 edge-optimize wizard routes were missing, breaking `ci/build` (routeFacsCapabilities: union-equality). They are admin-only (gateEdgeOptimizeWizard requires LLMO admin) and already live in required-capabilities.js INTERNAL_ROUTES, so add them to facs-capabilities.js INTERNAL_ROUTES to match. Co-authored-by: Cursor --- src/routes/facs-capabilities.js | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/routes/facs-capabilities.js b/src/routes/facs-capabilities.js index 2463c77a48..962bc6dd1c 100644 --- a/src/routes/facs-capabilities.js +++ b/src/routes/facs-capabilities.js @@ -139,6 +139,25 @@ const routeFacsCapabilities = { 'GET /config/:service/redirects.txt', // LLMO onboarding — internal/manual provisioning flow, not a customer FACS surface. 'POST /v2/orgs/:spaceCatId/llmo/onboard-site', + // LLMO CloudFront "Optimize at Edge" onboarding wizard — admin-only + // (gateEdgeOptimizeWizard requires LLMO admin); cross-account control-plane, not a + // customer FACS surface. + 'POST /sites/:siteId/llmo/edge-optimize-bootstrap-url', + 'POST /sites/:siteId/llmo/edge-optimize/connect', + 'POST /sites/:siteId/llmo/edge-optimize/distributions', + 'POST /sites/:siteId/llmo/edge-optimize/prerequisites', + 'POST /sites/:siteId/llmo/edge-optimize/origins', + 'POST /sites/:siteId/llmo/edge-optimize/behaviors', + 'POST /sites/:siteId/llmo/edge-optimize/create-origin', + 'POST /sites/:siteId/llmo/edge-optimize/create-function', + 'POST /sites/:siteId/llmo/edge-optimize/apply-cache', + 'POST /sites/:siteId/llmo/edge-optimize/create-lambda', + 'POST /sites/:siteId/llmo/edge-optimize/lambda-status', + 'POST /sites/:siteId/llmo/edge-optimize/apply-associations', + 'POST /sites/:siteId/llmo/edge-optimize/verify', + 'POST /sites/:siteId/llmo/edge-optimize/deploy', + 'POST /sites/:siteId/llmo/edge-optimize/plan', + 'GET /sites/:siteId/llmo/edge-optimize/permissions', // Admin-only writes 'POST /sites', // hasAdminAccess 'DELETE /sites/:siteId', // restricted (always 403) From 0c0e9f973f1fd6661249afb4958753d4e2eeacd5 Mon Sep 17 00:00:00 2001 From: Akash Bhardwaj Date: Thu, 25 Jun 2026 18:19:03 +0530 Subject: [PATCH 43/56] test(edge-optimize): cover migrated llmo edge-optimize handler guard/catch branches Adds the missing unit tests for the 18 patch-uncovered lines in src/controllers/llmo/llmo.js (codecov/patch on #2682): bootstrap-url admin-denied / missing-trusted-principal / presign-failure, create-origin invalid-environment, create-lambda + lambda-status missing-distributionId, and permissions missing-trusted-principal / unexpected-error. No source change; addresses patch coverage without relaxing the codecov gate. Co-authored-by: Cursor --- test/controllers/llmo/llmo.test.js | 83 ++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) diff --git a/test/controllers/llmo/llmo.test.js b/test/controllers/llmo/llmo.test.js index 5d114b5358..a4747008bd 100644 --- a/test/controllers/llmo/llmo.test.js +++ b/test/controllers/llmo/llmo.test.js @@ -7679,6 +7679,39 @@ describe('LlmoController', () => { expect(result.status).to.equal(403); }); + + it('returns 403 when the user is not an LLMO administrator', async () => { + const LlmoControllerNoAdmin = await esmock('../../../src/controllers/llmo/llmo.js', { + '../../../src/support/access-control-util.js': createMockAccessControlUtil(true, true, false), + '@adobe/spacecat-shared-http-utils': mockHttpUtils, + '../../../src/support/cached-response.js': mockCachedResponse, + ...getCommonMocks(), + }); + const result = await LlmoControllerNoAdmin(mockContext) + .getEdgeOptimizeBootstrapUrl(bootstrapContext); + expect(result.status).to.equal(403); + }); + + it('returns 400 when the trusted principal is not configured', async () => { + const result = await controller.getEdgeOptimizeBootstrapUrl({ + ...bootstrapContext, + env: { EDGE_OPTIMIZE_TEMPLATE_BUCKET: 'llmo-edgeoptimize-cf-template-stage' }, + }); + + expect(result.status).to.equal(400); + const body = await result.json(); + expect(body.message).to.include('missing trusted principal'); + }); + + it('returns 400 when presigning the template fails', async () => { + getSignedUrlStub.rejects(new Error('presign boom')); + + const result = await controller.getEdgeOptimizeBootstrapUrl(bootstrapContext); + + expect(result.status).to.equal(400); + const body = await result.json(); + expect(body.message).to.include('presign boom'); + }); }); describe('connectEdgeOptimize', () => { @@ -8270,6 +8303,18 @@ describe('LlmoController', () => { expect(createEdgeOptimizeOriginStub.called).to.equal(false); }); + it("returns 400 when environment is neither 'production' nor 'stage'", async () => { + const result = await controller.createEdgeOptimizeOrigin({ + ...originContext, + data: { ...originContext.data, environment: 'staging' }, + }); + + expect(result.status).to.equal(400); + const body = await result.json(); + expect(body.message).to.include("'production' or 'stage'"); + expect(createEdgeOptimizeOriginStub.called).to.equal(false); + }); + it('is idempotent when the origin already exists', async () => { createEdgeOptimizeOriginStub = sinon.stub().resolves({ created: false, alreadyExisted: true, updated: false, originId: 'EdgeOptimize_Origin', @@ -8571,6 +8616,16 @@ describe('LlmoController', () => { expect(result.status).to.equal(400); }); + it('returns 400 when the distribution id is missing', async () => { + const result = await controller.createEdgeOptimizeLambda({ + ...lambdaContext, + data: { accountId: '120569600543', externalId: '7ff9518a-cf59-40b4-aa53-68a3cb2e24a5' }, + }); + expect(result.status).to.equal(400); + const body = await result.json(); + expect(body.message).to.include('distributionId is required'); + }); + it('returns 400 when the AWS call fails', async () => { createEdgeOptimizeLambdaStub = sinon.stub().rejects(new Error('CreateRole failed')); const result = await controller.createEdgeOptimizeLambda(lambdaContext); @@ -8653,6 +8708,16 @@ describe('LlmoController', () => { expect(result.status).to.equal(400); }); + it('returns 400 when the distribution id is missing', async () => { + const result = await controller.getEdgeOptimizeLambdaStatus({ + ...statusContext, + data: { accountId: '120569600543', externalId: '7ff9518a-cf59-40b4-aa53-68a3cb2e24a5' }, + }); + expect(result.status).to.equal(400); + const body = await result.json(); + expect(body.message).to.include('distributionId is required'); + }); + it('returns 400 when the AWS call fails', async () => { getEdgeOptimizeLambdaStatusStub = sinon.stub().rejects(new Error('ListVersions failed')); const result = await controller.getEdgeOptimizeLambdaStatus(statusContext); @@ -9426,6 +9491,24 @@ describe('LlmoController', () => { expect(body.message).to.include('not configured'); }); + it('returns 400 when the trusted principal is not configured', async () => { + const result = await controller.getEdgeOptimizePermissions({ + ...permissionsContext, + env: { EDGE_OPTIMIZE_TEMPLATE_BUCKET: 'llmo-edgeoptimize-cf-template' }, + }); + expect(result.status).to.equal(400); + const body = await result.json(); + expect(body.message).to.include('missing trusted principal'); + }); + + it('returns 400 when an unexpected error is thrown', async () => { + mockDataAccess.Site.findById.rejects(new Error('db down')); + const result = await controller.getEdgeOptimizePermissions(permissionsContext); + expect(result.status).to.equal(400); + const body = await result.json(); + expect(body.message).to.include('db down'); + }); + it('returns 400 when the manifest read fails', async () => { s3SendStub.rejects(new Error('NoSuchKey')); const result = await controller.getEdgeOptimizePermissions(permissionsContext); From 2e6eb4ac70d6e8e8e7b8bfdc5efb11ca02d8863e Mon Sep 17 00:00:00 2001 From: Akash Bhardwaj Date: Fri, 26 Jun 2026 11:42:25 +0530 Subject: [PATCH 44/56] refactor(llmo): harden + namespace CloudFront edge-optimize wizard endpoints Apply review fixes to the CloudFront "Optimize at Edge" onboarding wizard: - Namespace the 16 wizard routes under /sites/:siteId/llmo/onboarding/cloudfront/* (routes, FACS internal-routes, required-capabilities exclusion list, OpenAPI api.yaml, regenerated docs). - Rename GET-semantic handlers + OpenAPI operationIds to list/fetch/create verbs (createEdgeOptimizeBootstrapUrl, listEdgeOptimizeDistributions, fetchEdgeOptimizeOrigins, fetchEdgeOptimizeBehaviors, fetchEdgeOptimizeLambdaStatus). - Extract parseEoCredentials helper to DRY the repeated accountId/externalId/ distributionId validation (behavior unchanged). - Validate lambdaVersionArn is a published, versioned us-east-1 Lambda@Edge ARN whose account segment matches the request account. - Harden the wizard handlers' outer catch to return 500 with a generic message instead of leaking raw AWS error text (connect's connected:false reason path is intentionally preserved). Co-authored-by: Cursor --- docs/index.html | 682 ++++++++++++++++++++++++++-- docs/openapi/api.yaml | 30 +- docs/openapi/llmo-api.yaml | 10 +- src/controllers/llmo/llmo.js | 294 +++++------- src/routes/facs-capabilities.js | 32 +- src/routes/index.js | 32 +- src/routes/required-capabilities.js | 32 +- test/controllers/llmo/llmo.test.js | 230 ++++++---- test/routes/index.test.js | 40 +- 9 files changed, 997 insertions(+), 385 deletions(-) diff --git a/docs/index.html b/docs/index.html index f90ab35b74..8d5cca9c6f 100644 --- a/docs/index.html +++ b/docs/index.html @@ -485,11 +485,11 @@ -

Create or upsert a brand (v2)

User does not have access to this organization

Production server

-
https://spacecat.experiencecloud.live/api/v1/v2/orgs/{spaceCatId}/brands

Request samples

Content type
application/json
{
  • "name": "string",
  • "status": "active",
  • "origin": "human",
  • "description": "string",
  • "brandContext": "string",
  • "mentionSentimentGuidance": "string",
  • "vertical": "string",
  • "region": [
    ],
  • "urls": [],
  • "socialAccounts": [],
  • "earnedContent": [],
  • "brandAliases": [
    ],
  • "competitors": [
    ]
}

Response samples

Content type
application/json
{
  • "id": "a1b2c3d4-5678-90ab-cdef-1234567890ab",
  • "name": "Example Brand",
  • "status": "active",
  • "origin": "human",
  • "description": "An example brand",
  • "brandContext": "Example Brand is a B2B analytics platform.",
  • "mentionSentimentGuidance": "Treat factual pricing mentions as neutral unless the response recommends against the brand.",
  • "vertical": "technology",
  • "region": [
    ],
  • "urls": [],
  • "socialAccounts": [],
  • "earnedContent": [],
  • "brandAliases": [
    ],
  • "competitors": [],
  • "siteIds": [
    ],
  • "updatedAt": "2026-03-15T10:30:00Z",
  • "updatedBy": "user@adobe.com"
}

Get a brand by ID (v2)

https://spacecat.experiencecloud.live/api/v1/v2/orgs/{spaceCatId}/brands

Request samples

Content type
application/json
{
  • "name": "string",
  • "status": "active",
  • "origin": "human",
  • "description": "string",
  • "brandContext": "string",
  • "mentionSentimentGuidance": "string",
  • "vertical": "string",
  • "region": [
    ],
  • "urls": [],
  • "socialAccounts": [],
  • "earnedContent": [],
  • "brandAliases": [
    ],
  • "competitors": [
    ]
}

Response samples

Content type
application/json
{
  • "id": "a1b2c3d4-5678-90ab-cdef-1234567890ab",
  • "name": "Example Brand",
  • "status": "active",
  • "origin": "human",
  • "description": "An example brand",
  • "brandContext": "Example Brand is a B2B analytics platform.",
  • "mentionSentimentGuidance": "Treat factual pricing mentions as neutral unless the response recommends against the brand.",
  • "vertical": "technology",
  • "region": [
    ],
  • "urls": [],
  • "socialAccounts": [],
  • "earnedContent": [],
  • "brandAliases": [
    ],
  • "competitors": [],
  • "siteIds": [
    ],
  • "updatedAt": "2026-03-15T10:30:00Z",
  • "updatedBy": "user@adobe.com"
}

Get a brand by ID (v2)

Retrieves a single brand by its UUID for the given organization.

Authorizations:
ims_keyapi_key
path Parameters
spaceCatId
required
string <uuid>
Example: a1b2c3d4-5678-90ab-cdef-1234567890ab

SpaceCat Organization ID (UUID)

@@ -4719,7 +4729,7 @@ " class="sc-iJuXkV sc-cBNeAB iNuSsz dyntKg">

Development server

https://spacecat.experiencecloud.live/api/ci/v2/orgs/{spaceCatId}/brands/{brandId}

Production server

-
https://spacecat.experiencecloud.live/api/v1/v2/orgs/{spaceCatId}/brands/{brandId}

Response samples

Content type
application/json
{
  • "id": "a1b2c3d4-5678-90ab-cdef-1234567890ab",
  • "name": "Example Brand",
  • "status": "active",
  • "origin": "human",
  • "description": "An example brand",
  • "brandContext": "Example Brand is a B2B analytics platform.",
  • "mentionSentimentGuidance": "Treat factual pricing mentions as neutral unless the response recommends against the brand.",
  • "vertical": "technology",
  • "region": [
    ],
  • "urls": [],
  • "socialAccounts": [],
  • "earnedContent": [],
  • "brandAliases": [
    ],
  • "competitors": [],
  • "siteIds": [
    ],
  • "updatedAt": "2026-03-15T10:30:00Z",
  • "updatedBy": "user@adobe.com"
}

Update a brand (v2)

https://spacecat.experiencecloud.live/api/v1/v2/orgs/{spaceCatId}/brands/{brandId}

Response samples

Content type
application/json
{
  • "id": "a1b2c3d4-5678-90ab-cdef-1234567890ab",
  • "name": "Example Brand",
  • "status": "active",
  • "origin": "human",
  • "description": "An example brand",
  • "brandContext": "Example Brand is a B2B analytics platform.",
  • "mentionSentimentGuidance": "Treat factual pricing mentions as neutral unless the response recommends against the brand.",
  • "vertical": "technology",
  • "region": [
    ],
  • "urls": [],
  • "socialAccounts": [],
  • "earnedContent": [],
  • "brandAliases": [
    ],
  • "competitors": [],
  • "siteIds": [
    ],
  • "updatedAt": "2026-03-15T10:30:00Z",
  • "updatedBy": "user@adobe.com"
}

Update a brand (v2)

Updates an existing brand. Only provided fields are updated. Nested arrays use full-replace semantics on each write.

@@ -4745,6 +4755,14 @@ " class="sc-iJuXkV sc-cBNeAB sc-cittYi iNuSsz eBjiEo hynizp">

User does not have access to this organization

Production server

-
https://spacecat.experiencecloud.live/api/v1/v2/orgs/{spaceCatId}/brands/{brandId}

Request samples

Content type
application/json
{
  • "name": "string",
  • "status": "active",
  • "origin": "human",
  • "description": "string",
  • "brandContext": "string",
  • "mentionSentimentGuidance": "string",
  • "vertical": "string",
  • "region": [
    ],
  • "urls": [],
  • "socialAccounts": [],
  • "earnedContent": [],
  • "brandAliases": [
    ],
  • "competitors": [
    ],
  • "pendingSemrushProvisioning": {
    }
}

Response samples

Content type
application/json
{
  • "id": "a1b2c3d4-5678-90ab-cdef-1234567890ab",
  • "name": "Example Brand",
  • "status": "active",
  • "origin": "human",
  • "description": "An example brand",
  • "brandContext": "Example Brand is a B2B analytics platform.",
  • "mentionSentimentGuidance": "Treat factual pricing mentions as neutral unless the response recommends against the brand.",
  • "vertical": "technology",
  • "region": [
    ],
  • "urls": [],
  • "socialAccounts": [],
  • "earnedContent": [],
  • "brandAliases": [
    ],
  • "competitors": [],
  • "siteIds": [
    ],
  • "updatedAt": "2026-03-15T10:30:00Z",
  • "updatedBy": "user@adobe.com",
  • "semrushRejectedAliases": [
    ]
}

Delete a brand (v2)

https://spacecat.experiencecloud.live/api/v1/v2/orgs/{spaceCatId}/brands/{brandId}

Request samples

Content type
application/json
{
  • "name": "string",
  • "status": "active",
  • "origin": "human",
  • "description": "string",
  • "brandContext": "string",
  • "mentionSentimentGuidance": "string",
  • "vertical": "string",
  • "region": [
    ],
  • "urls": [],
  • "socialAccounts": [],
  • "earnedContent": [],
  • "brandAliases": [
    ],
  • "competitors": [
    ],
  • "pendingSemrushProvisioning": {
    }
}

Response samples

Content type
application/json
{
  • "id": "a1b2c3d4-5678-90ab-cdef-1234567890ab",
  • "name": "Example Brand",
  • "status": "active",
  • "origin": "human",
  • "description": "An example brand",
  • "brandContext": "Example Brand is a B2B analytics platform.",
  • "mentionSentimentGuidance": "Treat factual pricing mentions as neutral unless the response recommends against the brand.",
  • "vertical": "technology",
  • "region": [
    ],
  • "urls": [],
  • "socialAccounts": [],
  • "earnedContent": [],
  • "brandAliases": [
    ],
  • "competitors": [],
  • "siteIds": [
    ],
  • "updatedAt": "2026-03-15T10:30:00Z",
  • "updatedBy": "user@adobe.com",
  • "semrushRejectedAliases": [
    ]
}

Delete a brand (v2)

Soft-deletes a brand by setting its status to deleted. The brand remains queryable with ?status=deleted.

@@ -4777,7 +4795,39 @@ " class="sc-iJuXkV sc-cBNeAB iNuSsz dyntKg">

Development server

https://spacecat.experiencecloud.live/api/ci/v2/orgs/{spaceCatId}/brands/{brandId}

Production server

-
https://spacecat.experiencecloud.live/api/v1/v2/orgs/{spaceCatId}/brands/{brandId}

Resolve the active brand for an (org, site) pair (v2)

https://spacecat.experiencecloud.live/api/v1/v2/orgs/{spaceCatId}/brands/{brandId}

Transition a brand's status (v2)

Explicitly transitions a brand's lifecycle status (approve -> active, +move-to-pending -> pending). This is the sanctioned path for an +active -> pending demotion. Once the companion demotion guard ships +(LLMO-5587 PR3), the generic +PATCH /v2/orgs/{spaceCatId}/brands/{brandId} will refuse that transition.

+
Authorizations:
ims_keyapi_key
path Parameters
spaceCatId
required
string <uuid>

SpaceCat Organization ID (UUID)

+
brandId
required
string <uuid>

Brand ID (UUID)

+
Request Body schema: application/json
required
status
required
string
Enum: "active" "pending"

Target brand status.

+

Responses

Request samples

Content type
application/json
{
  • "status": "active"
}

Response samples

Content type
application/json
{
  • "id": "a1b2c3d4-5678-90ab-cdef-1234567890ab",
  • "name": "Example Brand",
  • "status": "active",
  • "origin": "human",
  • "description": "An example brand",
  • "brandContext": "Example Brand is a B2B analytics platform.",
  • "mentionSentimentGuidance": "Treat factual pricing mentions as neutral unless the response recommends against the brand.",
  • "vertical": "technology",
  • "region": [
    ],
  • "urls": [],
  • "socialAccounts": [],
  • "earnedContent": [],
  • "brandAliases": [
    ],
  • "competitors": [],
  • "siteIds": [
    ],
  • "updatedAt": "2026-03-15T10:30:00Z",
  • "updatedBy": "user@adobe.com"
}

Resolve the active brand for an (org, site) pair (v2)

Development server

https://spacecat.experiencecloud.live/api/ci/v2/orgs/{spaceCatId}/sites/{siteId}/brand

Production server

-
https://spacecat.experiencecloud.live/api/v1/v2/orgs/{spaceCatId}/sites/{siteId}/brand

Response samples

Content type
application/json
{
  • "id": "a1b2c3d4-5678-90ab-cdef-1234567890ab",
  • "name": "Example Brand",
  • "status": "active",
  • "origin": "human",
  • "description": "An example brand",
  • "brandContext": "Example Brand is a B2B analytics platform.",
  • "mentionSentimentGuidance": "Treat factual pricing mentions as neutral unless the response recommends against the brand.",
  • "vertical": "technology",
  • "region": [
    ],
  • "urls": [],
  • "socialAccounts": [],
  • "earnedContent": [],
  • "brandAliases": [
    ],
  • "competitors": [],
  • "siteIds": [
    ],
  • "updatedAt": "2026-03-15T10:30:00Z",
  • "updatedBy": "user@adobe.com"
}

customer-config

https://spacecat.experiencecloud.live/api/v1/v2/orgs/{spaceCatId}/sites/{siteId}/brand

Response samples

Content type
application/json
{
  • "id": "a1b2c3d4-5678-90ab-cdef-1234567890ab",
  • "name": "Example Brand",
  • "status": "active",
  • "origin": "human",
  • "description": "An example brand",
  • "brandContext": "Example Brand is a B2B analytics platform.",
  • "mentionSentimentGuidance": "Treat factual pricing mentions as neutral unless the response recommends against the brand.",
  • "vertical": "technology",
  • "region": [
    ],
  • "urls": [],
  • "socialAccounts": [],
  • "earnedContent": [],
  • "brandAliases": [
    ],
  • "competitors": [],
  • "siteIds": [
    ],
  • "updatedAt": "2026-03-15T10:30:00Z",
  • "updatedBy": "user@adobe.com"
}

customer-config

Customer configuration operations including brands, categories, topics, and prompts

List brands for an organization (v2)

User does not have access to this organization

Production server

-
https://spacecat.experiencecloud.live/api/v1/v2/orgs/{spaceCatId}/brands/{brandId}

Resolve the active brand for an (org, site) pair (v2)

https://spacecat.experiencecloud.live/api/v1/v2/orgs/{spaceCatId}/brands/{brandId}

Transition a brand's status (v2)

Explicitly transitions a brand's lifecycle status (approve -> active, +move-to-pending -> pending). This is the sanctioned path for an +active -> pending demotion. Once the companion demotion guard ships +(LLMO-5587 PR3), the generic +PATCH /v2/orgs/{spaceCatId}/brands/{brandId} will refuse that transition.

+
Authorizations:
ims_keyapi_key
path Parameters
spaceCatId
required
string <uuid>

SpaceCat Organization ID (UUID)

+
brandId
required
string <uuid>

Brand ID (UUID)

+
Request Body schema: application/json
required
status
required
string
Enum: "active" "pending"

Target brand status.

+

Responses

Request samples

Content type
application/json
{
  • "status": "active"
}

Response samples

Content type
application/json
{
  • "id": "a1b2c3d4-5678-90ab-cdef-1234567890ab",
  • "name": "Example Brand",
  • "status": "active",
  • "origin": "human",
  • "description": "An example brand",
  • "brandContext": "Example Brand is a B2B analytics platform.",
  • "mentionSentimentGuidance": "Treat factual pricing mentions as neutral unless the response recommends against the brand.",
  • "vertical": "technology",
  • "region": [
    ],
  • "urls": [],
  • "socialAccounts": [],
  • "earnedContent": [],
  • "brandAliases": [
    ],
  • "competitors": [],
  • "siteIds": [
    ],
  • "updatedAt": "2026-03-15T10:30:00Z",
  • "updatedBy": "user@adobe.com"
}

Resolve the active brand for an (org, site) pair (v2)

Development server

https://spacecat.experiencecloud.live/api/ci/v2/orgs/{spaceCatId}/sites/{siteId}/brand

Production server

-
https://spacecat.experiencecloud.live/api/v1/v2/orgs/{spaceCatId}/sites/{siteId}/brand

Response samples

Content type
application/json
{
  • "id": "a1b2c3d4-5678-90ab-cdef-1234567890ab",
  • "name": "Example Brand",
  • "status": "active",
  • "origin": "human",
  • "description": "An example brand",
  • "brandContext": "Example Brand is a B2B analytics platform.",
  • "mentionSentimentGuidance": "Treat factual pricing mentions as neutral unless the response recommends against the brand.",
  • "vertical": "technology",
  • "region": [
    ],
  • "urls": [],
  • "socialAccounts": [],
  • "earnedContent": [],
  • "brandAliases": [
    ],
  • "competitors": [],
  • "siteIds": [
    ],
  • "updatedAt": "2026-03-15T10:30:00Z",
  • "updatedBy": "user@adobe.com"
}

List categories for an organization (v2)

https://spacecat.experiencecloud.live/api/v1/v2/orgs/{spaceCatId}/sites/{siteId}/brand

Response samples

Content type
application/json
{
  • "id": "a1b2c3d4-5678-90ab-cdef-1234567890ab",
  • "name": "Example Brand",
  • "status": "active",
  • "origin": "human",
  • "description": "An example brand",
  • "brandContext": "Example Brand is a B2B analytics platform.",
  • "mentionSentimentGuidance": "Treat factual pricing mentions as neutral unless the response recommends against the brand.",
  • "vertical": "technology",
  • "region": [
    ],
  • "urls": [],
  • "socialAccounts": [],
  • "earnedContent": [],
  • "brandAliases": [
    ],
  • "competitors": [],
  • "siteIds": [
    ],
  • "updatedAt": "2026-03-15T10:30:00Z",
  • "updatedBy": "user@adobe.com"
}

List categories for an organization (v2)

Development server

https://spacecat.experiencecloud.live/api/ci/v2/orgs/{spaceCatId}/categories

Production server

-
https://spacecat.experiencecloud.live/api/v1/v2/orgs/{spaceCatId}/categories

Response samples

Content type
application/json
{
  • "categories": [
    ]
}

Create a category (idempotent by name) (v2)

https://spacecat.experiencecloud.live/api/v1/v2/orgs/{spaceCatId}/categories

Response samples

Content type
application/json
{
  • "categories": [
    ]
}

Create a category (idempotent by name) (v2)

Development server

https://spacecat.experiencecloud.live/api/ci/v2/orgs/{spaceCatId}/categories

Production server

-
https://spacecat.experiencecloud.live/api/v1/v2/orgs/{spaceCatId}/categories

Request samples

Content type
application/json
{
  • "name": "Discovery & Research",
  • "origin": "human",
  • "status": "active"
}

Response samples

Content type
application/json
{
  • "id": "a1b2c3d4-5678-90ab-cdef-1234567890ab",
  • "uuid": "095be615-a8ad-4c33-8e9c-c7612fbf6c9f",
  • "name": "Discovery & Research",
  • "status": "active",
  • "origin": "human",
  • "createdAt": "2019-08-24T14:15:22Z",
  • "createdBy": "string",
  • "updatedAt": "2019-08-24T14:15:22Z",
  • "updatedBy": "string"
}

Update a category (v2)

https://spacecat.experiencecloud.live/api/v1/v2/orgs/{spaceCatId}/categories

Request samples

Content type
application/json
{
  • "name": "Discovery & Research",
  • "origin": "human",
  • "status": "active"
}

Response samples

Content type
application/json
{
  • "id": "a1b2c3d4-5678-90ab-cdef-1234567890ab",
  • "uuid": "095be615-a8ad-4c33-8e9c-c7612fbf6c9f",
  • "name": "Discovery & Research",
  • "status": "active",
  • "origin": "human",
  • "createdAt": "2019-08-24T14:15:22Z",
  • "createdBy": "string",
  • "updatedAt": "2019-08-24T14:15:22Z",
  • "updatedBy": "string"
}

Update a category (v2)

Updates an existing category. Only provided fields are updated. The @@ -5207,7 +5307,7 @@ " class="sc-iJuXkV sc-cBNeAB iNuSsz dyntKg">

Development server

https://spacecat.experiencecloud.live/api/ci/v2/orgs/{spaceCatId}/categories/{categoryId}

Production server

-
https://spacecat.experiencecloud.live/api/v1/v2/orgs/{spaceCatId}/categories/{categoryId}

Request samples

Content type
application/json
{
  • "name": "string",
  • "origin": "human",
  • "status": "active"
}

Response samples

Content type
application/json
{
  • "id": "a1b2c3d4-5678-90ab-cdef-1234567890ab",
  • "uuid": "095be615-a8ad-4c33-8e9c-c7612fbf6c9f",
  • "name": "Discovery & Research",
  • "status": "active",
  • "origin": "human",
  • "createdAt": "2019-08-24T14:15:22Z",
  • "createdBy": "string",
  • "updatedAt": "2019-08-24T14:15:22Z",
  • "updatedBy": "string"
}

Delete a category (v2)

https://spacecat.experiencecloud.live/api/v1/v2/orgs/{spaceCatId}/categories/{categoryId}

Request samples

Content type
application/json
{
  • "name": "string",
  • "origin": "human",
  • "status": "active"
}

Response samples

Content type
application/json
{
  • "id": "a1b2c3d4-5678-90ab-cdef-1234567890ab",
  • "uuid": "095be615-a8ad-4c33-8e9c-c7612fbf6c9f",
  • "name": "Discovery & Research",
  • "status": "active",
  • "origin": "human",
  • "createdAt": "2019-08-24T14:15:22Z",
  • "createdBy": "string",
  • "updatedAt": "2019-08-24T14:15:22Z",
  • "updatedBy": "string"
}

Delete a category (v2)

Soft-deletes a category by setting its status to deleted. The @@ -5261,7 +5361,7 @@ " class="sc-iJuXkV sc-cBNeAB iNuSsz dyntKg">

Development server

https://spacecat.experiencecloud.live/api/ci/v2/orgs/{spaceCatId}/topics

Production server

-
https://spacecat.experiencecloud.live/api/v1/v2/orgs/{spaceCatId}/topics

Response samples

Content type
application/json
{
  • "topics": [
    ]
}

Create or upsert a topic (v2)

https://spacecat.experiencecloud.live/api/v1/v2/orgs/{spaceCatId}/topics

Response samples

Content type
application/json
{
  • "topics": [
    ]
}

Create or upsert a topic (v2)

Creates a new topic for the organization. If a topic with the same ID @@ -5307,7 +5407,7 @@ " class="sc-iJuXkV sc-cBNeAB iNuSsz dyntKg">

Development server

https://spacecat.experiencecloud.live/api/ci/v2/orgs/{spaceCatId}/topics

Production server

-
https://spacecat.experiencecloud.live/api/v1/v2/orgs/{spaceCatId}/topics

Request samples

Content type
application/json
{
  • "id": "baseurl-discovery-research-topic",
  • "name": "Discovery & Research",
  • "description": "string",
  • "brandId": "0e9bcbb3-096e-49f9-aeea-7a13a201eff5",
  • "status": "active"
}

Response samples

Content type
application/json
{
  • "id": "baseurl-discovery-research-topic",
  • "uuid": "095be615-a8ad-4c33-8e9c-c7612fbf6c9f",
  • "name": "Discovery & Research",
  • "description": "string",
  • "status": "active",
  • "brandId": "0e9bcbb3-096e-49f9-aeea-7a13a201eff5",
  • "categoryUuids": [
    ],
  • "updatedAt": "2019-08-24T14:15:22Z",
  • "updatedBy": "string"
}

Update a topic (v2)

https://spacecat.experiencecloud.live/api/v1/v2/orgs/{spaceCatId}/topics

Request samples

Content type
application/json
{
  • "id": "baseurl-discovery-research-topic",
  • "name": "Discovery & Research",
  • "description": "string",
  • "brandId": "0e9bcbb3-096e-49f9-aeea-7a13a201eff5",
  • "status": "active"
}

Response samples

Content type
application/json
{
  • "id": "baseurl-discovery-research-topic",
  • "uuid": "095be615-a8ad-4c33-8e9c-c7612fbf6c9f",
  • "name": "Discovery & Research",
  • "description": "string",
  • "status": "active",
  • "brandId": "0e9bcbb3-096e-49f9-aeea-7a13a201eff5",
  • "categoryUuids": [
    ],
  • "updatedAt": "2019-08-24T14:15:22Z",
  • "updatedBy": "string"
}

Update a topic (v2)

Updates an existing topic. Only provided fields are updated.

Authorizations:
ims_keyapi_key
path Parameters
spaceCatId
required
string <uuid>
Example: a1b2c3d4-5678-90ab-cdef-1234567890ab

SpaceCat Organization ID (UUID)

@@ -5335,7 +5435,7 @@ " class="sc-iJuXkV sc-cBNeAB iNuSsz dyntKg">

Development server

https://spacecat.experiencecloud.live/api/ci/v2/orgs/{spaceCatId}/topics/{topicId}

Production server

-
https://spacecat.experiencecloud.live/api/v1/v2/orgs/{spaceCatId}/topics/{topicId}

Request samples

Content type
application/json
{
  • "name": "string",
  • "description": "string",
  • "status": "active",
  • "brandId": "0e9bcbb3-096e-49f9-aeea-7a13a201eff5"
}

Response samples

Content type
application/json
{
  • "id": "baseurl-discovery-research-topic",
  • "uuid": "095be615-a8ad-4c33-8e9c-c7612fbf6c9f",
  • "name": "Discovery & Research",
  • "description": "string",
  • "status": "active",
  • "brandId": "0e9bcbb3-096e-49f9-aeea-7a13a201eff5",
  • "categoryUuids": [
    ],
  • "updatedAt": "2019-08-24T14:15:22Z",
  • "updatedBy": "string"
}

Delete a topic (v2)

https://spacecat.experiencecloud.live/api/v1/v2/orgs/{spaceCatId}/topics/{topicId}

Request samples

Content type
application/json
{
  • "name": "string",
  • "description": "string",
  • "status": "active",
  • "brandId": "0e9bcbb3-096e-49f9-aeea-7a13a201eff5"
}

Response samples

Content type
application/json
{
  • "id": "baseurl-discovery-research-topic",
  • "uuid": "095be615-a8ad-4c33-8e9c-c7612fbf6c9f",
  • "name": "Discovery & Research",
  • "description": "string",
  • "status": "active",
  • "brandId": "0e9bcbb3-096e-49f9-aeea-7a13a201eff5",
  • "categoryUuids": [
    ],
  • "updatedAt": "2019-08-24T14:15:22Z",
  • "updatedBy": "string"
}

Delete a topic (v2)

Soft-deletes a topic by setting its status to deleted. The topic remains queryable with ?status=deleted.

@@ -5405,7 +5505,7 @@ " class="sc-iJuXkV sc-cBNeAB iNuSsz dyntKg">

Development server

https://spacecat.experiencecloud.live/api/ci/v2/orgs/{spaceCatId}/brands/{brandId}/prompts

Production server

-
https://spacecat.experiencecloud.live/api/v1/v2/orgs/{spaceCatId}/brands/{brandId}/prompts

Response samples

Content type
application/json
{
  • "items": [
    ],
  • "total": 0,
  • "limit": 0,
  • "page": 0
}

Create or upsert prompts (bulk)

https://spacecat.experiencecloud.live/api/v1/v2/orgs/{spaceCatId}/brands/{brandId}/prompts

Response samples

Content type
application/json
{
  • "items": [
    ],
  • "total": 0,
  • "limit": 0,
  • "page": 0
}

Create or upsert prompts (bulk)

Development server

https://spacecat.experiencecloud.live/api/ci/v2/orgs/{spaceCatId}/brands/{brandId}/prompts

Production server

-
https://spacecat.experiencecloud.live/api/v1/v2/orgs/{spaceCatId}/brands/{brandId}/prompts

Request samples

Content type
application/json
[
  • {
    }
]

Response samples

Content type
application/json
{
  • "created": 0,
  • "updated": 0,
  • "prompts": [
    ]
}

Get a single prompt

Authorizations:
ims_keyapi_key
path Parameters
spaceCatId
required
string <uuid>
brandId
required
string
promptId
required
string
https://spacecat.experiencecloud.live/api/v1/v2/orgs/{spaceCatId}/brands/{brandId}/prompts

Request samples

Content type
application/json
[
  • {
    }
]

Response samples

Content type
application/json
{
  • "created": 0,
  • "updated": 0,
  • "prompts": [
    ]
}

Get a single prompt

Authorizations:
ims_keyapi_key
path Parameters
spaceCatId
required
string <uuid>
brandId
required
string
promptId
required
string

prompt_id (business key)

Responses

Production server

-
https://spacecat.experiencecloud.live/api/v1/v2/orgs/{spaceCatId}/brands/{brandId}/prompts/{promptId}

Response samples

Content type
application/json
{
  • "id": "photoshop-prompt-1",
  • "uuid": "095be615-a8ad-4c33-8e9c-c7612fbf6c9f",
  • "prompt": "string",
  • "name": "string",
  • "regions": [
    ],
  • "categoryId": "337f5e5d-288b-40d5-be14-901cc3acacc0",
  • "topicId": "string",
  • "status": "active",
  • "origin": "ai",
  • "source": "config",
  • "intent": "informational",
  • "updatedAt": "2024-01-19T14:20:30Z",
  • "updatedBy": "string",
  • "brandId": "0e9bcbb3-096e-49f9-aeea-7a13a201eff5",
  • "brandName": "string",
  • "category": {
    },
  • "topic": {
    }
}

Update a single prompt

Authorizations:
ims_keyapi_key
path Parameters
spaceCatId
required
string <uuid>
brandId
required
string
promptId
required
string
https://spacecat.experiencecloud.live/api/v1/v2/orgs/{spaceCatId}/brands/{brandId}/prompts/{promptId}

Response samples

Content type
application/json
{
  • "id": "photoshop-prompt-1",
  • "uuid": "095be615-a8ad-4c33-8e9c-c7612fbf6c9f",
  • "prompt": "string",
  • "name": "string",
  • "regions": [
    ],
  • "categoryId": "337f5e5d-288b-40d5-be14-901cc3acacc0",
  • "topicId": "string",
  • "status": "active",
  • "origin": "ai",
  • "source": "config",
  • "intent": "informational",
  • "updatedAt": "2024-01-19T14:20:30Z",
  • "updatedBy": "string",
  • "brandId": "0e9bcbb3-096e-49f9-aeea-7a13a201eff5",
  • "brandName": "string",
  • "category": {
    },
  • "topic": {
    }
}

Update a single prompt

Authorizations:
ims_keyapi_key
path Parameters
spaceCatId
required
string <uuid>
brandId
required
string
promptId
required
string

prompt_id (business key)

Request Body schema: application/json
id
string

Optional business key; auto-generated if omitted

@@ -5503,7 +5603,7 @@ " class="sc-iJuXkV sc-cBNeAB iNuSsz dyntKg">

Development server

https://spacecat.experiencecloud.live/api/ci/v2/orgs/{spaceCatId}/brands/{brandId}/prompts/{promptId}

Production server

-
https://spacecat.experiencecloud.live/api/v1/v2/orgs/{spaceCatId}/brands/{brandId}/prompts/{promptId}

Request samples

Content type
application/json
{
  • "id": "string",
  • "prompt": "string",
  • "name": "string",
  • "regions": [ ],
  • "categoryId": "string",
  • "topicId": "string",
  • "status": "active",
  • "origin": "ai",
  • "source": "config",
  • "intent": "informational"
}

Response samples

Content type
application/json
{
  • "id": "photoshop-prompt-1",
  • "uuid": "095be615-a8ad-4c33-8e9c-c7612fbf6c9f",
  • "prompt": "string",
  • "name": "string",
  • "regions": [
    ],
  • "categoryId": "337f5e5d-288b-40d5-be14-901cc3acacc0",
  • "topicId": "string",
  • "status": "active",
  • "origin": "ai",
  • "source": "config",
  • "intent": "informational",
  • "updatedAt": "2024-01-19T14:20:30Z",
  • "updatedBy": "string",
  • "brandId": "0e9bcbb3-096e-49f9-aeea-7a13a201eff5",
  • "brandName": "string",
  • "category": {
    },
  • "topic": {
    }
}

Soft-delete a prompt

https://spacecat.experiencecloud.live/api/v1/v2/orgs/{spaceCatId}/brands/{brandId}/prompts/{promptId}

Request samples

Content type
application/json
{
  • "id": "string",
  • "prompt": "string",
  • "name": "string",
  • "regions": [ ],
  • "categoryId": "string",
  • "topicId": "string",
  • "status": "active",
  • "origin": "ai",
  • "source": "config",
  • "intent": "informational"
}

Response samples

Content type
application/json
{
  • "id": "photoshop-prompt-1",
  • "uuid": "095be615-a8ad-4c33-8e9c-c7612fbf6c9f",
  • "prompt": "string",
  • "name": "string",
  • "regions": [
    ],
  • "categoryId": "337f5e5d-288b-40d5-be14-901cc3acacc0",
  • "topicId": "string",
  • "status": "active",
  • "origin": "ai",
  • "source": "config",
  • "intent": "informational",
  • "updatedAt": "2024-01-19T14:20:30Z",
  • "updatedBy": "string",
  • "brandId": "0e9bcbb3-096e-49f9-aeea-7a13a201eff5",
  • "brandName": "string",
  • "category": {
    },
  • "topic": {
    }
}

Soft-delete a prompt

Sets status to 'deleted'.

Authorizations:
ims_keyapi_key
path Parameters
spaceCatId
required
string <uuid>
brandId
required
string
promptId
required
string

prompt_id (business key)

@@ -5545,7 +5645,7 @@ " class="sc-iJuXkV sc-cBNeAB iNuSsz dyntKg">

Development server

https://spacecat.experiencecloud.live/api/ci/v2/orgs/{spaceCatId}/brands/{brandId}/prompts/delete

Production server

-
https://spacecat.experiencecloud.live/api/v1/v2/orgs/{spaceCatId}/brands/{brandId}/prompts/delete

Request samples

Content type
application/json
{
  • "promptIds": [
    ]
}

Response samples

Content type
application/json
{
  • "metadata": {
    },
  • "failures": [
    ]
}

trigger

https://spacecat.experiencecloud.live/api/v1/v2/orgs/{spaceCatId}/brands/{brandId}/prompts/delete

Request samples

Content type
application/json
{
  • "promptIds": [
    ]
}

Response samples

Content type
application/json
{
  • "metadata": {
    },
  • "failures": [
    ]
}

trigger

Trigger operations

Trigger an audit Deprecated

Behaviour notes " class="sc-iJuXkV sc-cBNeAB iNuSsz dyntKg">

Development server

https://spacecat.experiencecloud.live/api/ci/sites/{siteId}/llmo/edge-optimize-config/stage

Production server

-
https://spacecat.experiencecloud.live/api/v1/sites/{siteId}/llmo/edge-optimize-config/stage

Request samples

Content type
application/json
{
  • "stagingDomains": [
    ]
}

Response samples

Content type
application/json
[
  • {
    }
]

Check Edge Optimize status for a site path

https://spacecat.experiencecloud.live/api/v1/sites/{siteId}/llmo/edge-optimize-config/stage

Request samples

Content type
application/json
{
  • "stagingDomains": [
    ]
}

Response samples

Content type
application/json
[
  • {
    }
]

Generate a CloudFormation quick-create URL for the Edge Optimize connector role

Returns a one-click AWS CloudFormation quick-create URL (with a server-side +presigned template URL) that the customer uses to create the cross-account +connector role in their own AWS account. The template bucket stays private — +presigning is done with the service execution role — so no public S3 endpoint +is required and the customer needs no S3 access of their own.

+
Authorizations:
api_key
path Parameters
siteId
required
string <uuid> (Id)
Example: 123e4567-e89b-12d3-a456-426614174000

The site ID in uuid format

+
Request Body schema: application/json
required
accountId
required
string^[0-9]{12}$

The 12-digit AWS account ID hosting the customer's CloudFront distribution

+

Responses

Request samples

Content type
application/json
{
  • "accountId": "682033462621"
}

Response samples

Content type
application/json
{}

Verify the Edge Optimize cross-account connector role is assumable

Used by the CloudFront "Deploy routing" wizard's "Allow access" step. Attempts to assume +the customer's cross-account connector role (created via the CloudFormation bootstrap) using +the per-session external ID. Returns connected: true when the role is assumable; otherwise +returns connected: false with a reason so the wizard can keep polling while the customer +finishes creating the role.

+
Authorizations:
api_key
path Parameters
siteId
required
string <uuid> (Id)
Example: 123e4567-e89b-12d3-a456-426614174000

The site ID in uuid format

+
Request Body schema: application/json
required
accountId
required
string^[0-9]{12}$

The 12-digit AWS account ID hosting the customer's CloudFront distribution.

+
externalId
required
string

Per-session external ID baked into the connector role's trust policy.

+

Responses

Request samples

Content type
application/json
{
  • "accountId": "682033462621",
  • "externalId": "7ff9518a-cf59-40b4-aa53-68a3cb2e24a5"
}

Response samples

Content type
application/json
{
  • "connected": true,
  • "accountId": "682033462621",
  • "roleArn": "arn:aws:iam::682033462621:role/AdobeLLMOptimizerCloudFrontConnectorRole"
}

List the customer's CloudFront distributions

Used by the CloudFront "Deploy routing" wizard's "Choose distribution" step. Assumes the +cross-account connector role and lists the customer's CloudFront distributions (read-only) +so the wizard can let the customer pick one to configure.

+
Authorizations:
api_key
path Parameters
siteId
required
string <uuid> (Id)
Example: 123e4567-e89b-12d3-a456-426614174000

The site ID in uuid format

+
Request Body schema: application/json
required
accountId
required
string^[0-9]{12}$

The 12-digit AWS account ID hosting the customer's CloudFront distribution.

+
externalId
required
string

Per-session external ID baked into the connector role's trust policy.

+

Responses

Request samples

Content type
application/json
{
  • "accountId": "682033462621",
  • "externalId": "7ff9518a-cf59-40b4-aa53-68a3cb2e24a5"
}

Response samples

Content type
application/json
{
  • "distributions": [
    ]
}

Run the Edge Optimize wizard pre-flight checks

Used by the CloudFront "Deploy routing" wizard to confirm the connector role is assumable +and grants CloudFront read access. Each check reports its ok status individually (with a +detail message on failure) so the wizard can show a per-check status rather than failing +the whole step. Always returns HTTP 200 — inspect each check's ok field.

+
Authorizations:
api_key
path Parameters
siteId
required
string <uuid> (Id)
Example: 123e4567-e89b-12d3-a456-426614174000

The site ID in uuid format

+
Request Body schema: application/json
required
accountId
required
string^[0-9]{12}$

The 12-digit AWS account ID hosting the customer's CloudFront distribution.

+
externalId
required
string

Per-session external ID baked into the connector role's trust policy.

+

Responses

Request samples

Content type
application/json
{
  • "accountId": "682033462621",
  • "externalId": "7ff9518a-cf59-40b4-aa53-68a3cb2e24a5"
}

Response samples

Content type
application/json
{
  • "checks": [
    ]
}

Read the origins configured on a CloudFront distribution

Used by the CloudFront "Deploy routing" wizard's "Review origins" step. Assumes the connector +role, reads the distribution config (read-only), and returns its origins plus a flag +indicating whether an Edge Optimize origin already exists.

+
Authorizations:
api_key
path Parameters
siteId
required
string <uuid> (Id)
Example: 123e4567-e89b-12d3-a456-426614174000

The site ID in uuid format

+
Request Body schema: application/json
required
accountId
required
string^[0-9]{12}$

The 12-digit AWS account ID hosting the customer's CloudFront distribution.

+
externalId
required
string

Per-session external ID baked into the connector role's trust policy.

+
distributionId
required
string

The CloudFront distribution ID to inspect.

+

Responses

Request samples

Content type
application/json
{
  • "accountId": "682033462621",
  • "externalId": "7ff9518a-cf59-40b4-aa53-68a3cb2e24a5",
  • "distributionId": "E2EXAMPLE123"
}

Response samples

Content type
application/json
{
  • "origins": [
    ],
  • "hasEdgeOptimizeOrigin": false
}

Read the cache behaviors configured on a CloudFront distribution

Used by the CloudFront "Deploy routing" wizard's "Review routing" step. Assumes the connector +role, reads the distribution config (read-only), and returns the default cache behavior +followed by each ordered cache behavior so the wizard can show how traffic is currently +routed.

+
Authorizations:
api_key
path Parameters
siteId
required
string <uuid> (Id)
Example: 123e4567-e89b-12d3-a456-426614174000

The site ID in uuid format

+
Request Body schema: application/json
required
accountId
required
string^[0-9]{12}$

The 12-digit AWS account ID hosting the customer's CloudFront distribution.

+
externalId
required
string

Per-session external ID baked into the connector role's trust policy.

+
distributionId
required
string

The CloudFront distribution ID to inspect.

+

Responses

Request samples

Content type
application/json
{
  • "accountId": "682033462621",
  • "externalId": "7ff9518a-cf59-40b4-aa53-68a3cb2e24a5",
  • "distributionId": "E2EXAMPLE123"
}

Response samples

Content type
application/json
{
  • "behaviors": [
    ]
}

Add the Edge Optimize origin to a CloudFront distribution

Used by the CloudFront "Deploy routing" wizard's "Create Edge Optimize origin" step. Assumes +the connector role and, if no Edge Optimize origin exists yet, adds a custom HTTPS origin +pointing at the Edge Optimize target domain via UpdateDistribution. Idempotent — returns +created: false, alreadyExisted: true when the origin is already present. (CloudFront deploy +propagates in the background; this call does not block on it.)

+
Authorizations:
api_key
path Parameters
siteId
required
string <uuid> (Id)
Example: 123e4567-e89b-12d3-a456-426614174000

The site ID in uuid format

+
Request Body schema: application/json
required
accountId
required
string^[0-9]{12}$

The 12-digit AWS account ID hosting the customer's CloudFront distribution.

+
externalId
required
string

Per-session external ID baked into the connector role's trust policy.

+
distributionId
required
string

The CloudFront distribution ID to inspect.

+

Responses

Request samples

Content type
application/json
{
  • "accountId": "682033462621",
  • "externalId": "7ff9518a-cf59-40b4-aa53-68a3cb2e24a5",
  • "distributionId": "E2EXAMPLE123"
}

Response samples

Content type
application/json
{
  • "created": true,
  • "alreadyExisted": false,
  • "originId": "EdgeOptimize_Origin"
}

Create or update the Edge Optimize routing CloudFront Function

Used by the CloudFront "Deploy routing" wizard's "Create routing function" step. Assumes the +connector role, derives the distribution's default-behavior target origin, and creates (or +updates) the edgeoptimize-routing CloudFront Function, then publishes it to LIVE. Idempotent.

+
Authorizations:
api_key
path Parameters
siteId
required
string <uuid> (Id)
Example: 123e4567-e89b-12d3-a456-426614174000

The site ID in uuid format

+
Request Body schema: application/json
required
accountId
required
string^[0-9]{12}$

The 12-digit AWS account ID hosting the customer's CloudFront distribution.

+
externalId
required
string

Per-session external ID baked into the connector role's trust policy.

+
distributionId
required
string

The CloudFront distribution ID to inspect.

+

Responses

Request samples

Content type
application/json
{
  • "accountId": "682033462621",
  • "externalId": "7ff9518a-cf59-40b4-aa53-68a3cb2e24a5",
  • "distributionId": "E2EXAMPLE123"
}

Response samples

Content type
application/json
{
  • "name": "edgeoptimize-routing",
  • "created": true,
  • "stage": "LIVE"
}

Forward the Edge Optimize headers in a behavior's cache policy

Used by the CloudFront "Deploy routing" wizard's "Apply cache headers" step. Assumes the +connector role and ensures the Edge Optimize routing headers +(x-edgeoptimize-config, x-edgeoptimize-url) are forwarded by the target behavior's +(custom) cache policy, setting the policy MinTTL to 0. Idempotent.

+
Authorizations:
api_key
path Parameters
siteId
required
string <uuid> (Id)
Example: 123e4567-e89b-12d3-a456-426614174000

The site ID in uuid format

+
Request Body schema: application/json
required
accountId
required
string^[0-9]{12}$

The 12-digit AWS account ID hosting the customer's CloudFront distribution.

+
externalId
required
string

Per-session external ID baked into the connector role's trust policy.

+
distributionId
required
string

The CloudFront distribution ID to modify.

+
pathPattern
string
Default: "default"

The cache behavior to target; use default for the default behavior.

+

Responses

Request samples

Content type
application/json
{
  • "accountId": "682033462621",
  • "externalId": "7ff9518a-cf59-40b4-aa53-68a3cb2e24a5",
  • "distributionId": "E2EXAMPLE123",
  • "pathPattern": "/api/*"
}

Response samples

Content type
application/json
{
  • "policyId": "1234abcd-12ab-34cd-56ef-1234567890ab",
  • "updated": true,
  • "alreadyForwarded": false
}

Create or update the Edge Optimize Lambda@Edge function

Used by the CloudFront "Deploy routing" wizard's "Create Lambda@Edge function" step. Assumes +the connector role, ensures the edgeoptimize-origin-role execution role exists (with a +bounded propagation wait), creates (or updates) the edgeoptimize-origin Lambda function, and +publishes a numbered version (required for Lambda@Edge). Returns the versioned ARN the +"Associate" step needs. Idempotent.

+
Authorizations:
api_key
path Parameters
siteId
required
string <uuid> (Id)
Example: 123e4567-e89b-12d3-a456-426614174000

The site ID in uuid format

+
Request Body schema: application/json
required
accountId
required
string^[0-9]{12}$

The 12-digit AWS account ID hosting the customer's CloudFront distribution.

+
externalId
required
string

Per-session external ID baked into the connector role's trust policy.

+

Responses

Request samples

Content type
application/json
{
  • "accountId": "682033462621",
  • "externalId": "7ff9518a-cf59-40b4-aa53-68a3cb2e24a5"
}

Response samples

Content type
application/json
{
  • "functionArn": "arn:aws:lambda:us-east-1:682033462621:function:edgeoptimize-origin",
  • "versionArn": "arn:aws:lambda:us-east-1:682033462621:function:edgeoptimize-origin:1",
  • "version": "1",
  • "roleArn": "arn:aws:iam::682033462621:role/edgeoptimize-origin-role",
  • "created": true
}

Read the Edge Optimize Lambda@Edge function status

Used by the CloudFront "Deploy routing" wizard to check on entry (and poll after a slow or +timed-out create) whether the edgeoptimize-origin Lambda@Edge function already exists and +has a published numbered version. Read-only — assumes the connector role and inspects the +function configuration + versions.

+
Authorizations:
api_key
path Parameters
siteId
required
string <uuid> (Id)
Example: 123e4567-e89b-12d3-a456-426614174000

The site ID in uuid format

+
Request Body schema: application/json
required
accountId
required
string^[0-9]{12}$

The 12-digit AWS account ID hosting the customer's CloudFront distribution.

+
externalId
required
string

Per-session external ID baked into the connector role's trust policy.

+

Responses

Request samples

Content type
application/json
{
  • "accountId": "682033462621",
  • "externalId": "7ff9518a-cf59-40b4-aa53-68a3cb2e24a5"
}

Response samples

Content type
application/json
{
  • "exists": true,
  • "state": "Active",
  • "lastUpdateStatus": "Successful",
  • "functionArn": "arn:aws:lambda:us-east-1:682033462621:function:edgeoptimize-origin",
  • "versionArn": "arn:aws:lambda:us-east-1:682033462621:function:edgeoptimize-origin:1",
  • "version": "1"
}

Associate the routing function and Lambda@Edge onto a behavior

Used by the CloudFront "Deploy routing" wizard's "Associate" step. Assumes the connector role +and wires the edgeoptimize-routing CloudFront Function (viewer-request) plus the +edgeoptimize-origin Lambda@Edge (origin-request and origin-response, versioned ARN) onto the +user-selected cache behavior via UpdateDistribution. Surfaces a clear error if a conflicting +viewer-request function is already associated.

+
Authorizations:
api_key
path Parameters
siteId
required
string <uuid> (Id)
Example: 123e4567-e89b-12d3-a456-426614174000

The site ID in uuid format

+
Request Body schema: application/json
required
accountId
required
string^[0-9]{12}$

The 12-digit AWS account ID hosting the customer's CloudFront distribution.

+
externalId
required
string

Per-session external ID baked into the connector role's trust policy.

+
distributionId
required
string

The CloudFront distribution ID to modify.

+
pathPattern
string
Default: "default"

The cache behavior to wire; use default for the default behavior.

+
lambdaVersionArn
required
string

The published, versioned Lambda@Edge ARN from the create-lambda step.

+

Responses

Request samples

Content type
application/json
{
  • "accountId": "682033462621",
  • "externalId": "7ff9518a-cf59-40b4-aa53-68a3cb2e24a5",
  • "distributionId": "E2EXAMPLE123",
  • "pathPattern": "/api/*",
  • "lambdaVersionArn": "arn:aws:lambda:us-east-1:682033462621:function:edgeoptimize-origin:1"
}

Response samples

Content type
application/json
{
  • "cfFunctionArn": "arn:aws:cloudfront::682033462621:function/edgeoptimize-routing",
  • "lambdaArn": "arn:aws:lambda:us-east-1:682033462621:function:edgeoptimize-origin:1"
}

Verify Edge Optimize routing end-to-end

Used by the CloudFront "Deploy routing" wizard's "Verify routing" step. Fetches the +distribution domain server-side as an agentic bot and as a human, then inspects the +x-edgeoptimize-* response headers. passed is true only when the bot response carries +x-edgeoptimize-request-id (page served from the Edge Optimize prerender cache) and the human +response is not optimized. x-edgeoptimize-fo means failover to origin — NOT success.

+
Authorizations:
api_key
path Parameters
siteId
required
string <uuid> (Id)
Example: 123e4567-e89b-12d3-a456-426614174000

The site ID in uuid format

+
Request Body schema: application/json
required
accountId
required
string^[0-9]{12}$

The 12-digit AWS account ID hosting the customer's CloudFront distribution.

+
externalId
required
string

Per-session external ID baked into the connector role's trust policy.

+
distributionId
required
string

The CloudFront distribution ID to inspect.

+

Responses

Request samples

Content type
application/json
{
  • "accountId": "682033462621",
  • "externalId": "7ff9518a-cf59-40b4-aa53-68a3cb2e24a5",
  • "distributionId": "E2EXAMPLE123"
}

Response samples

Content type
application/json
{
  • "passed": true,
  • "requestId": "req-123",
  • "details": {
    }
}

Orchestrate the full Edge Optimize CloudFront deploy (idempotent, step-on-poll)

Single endpoint that drives the CloudFront "Deploy routing" wizard end-to-end. The frontend +calls it once and then polls it every ~30s; each call advances the deploy sequence (origin → +routing function → cache policy → Lambda@Edge → association → verify) as far as it safely can +while staying well under the gateway's ~60s first-byte timeout, and returns the per-step +status. It is safe to call repeatedly: completed steps are gated and never re-mutated (no +CloudFront re-deploy churn, no routing-function re-publish). When the Lambda@Edge is still +provisioning the sequence pauses (associate/verify stay pending) and the next poll re-checks. +The caller passes the customer's selected distribution, failover origin, and behavior +explicitly; the Edge Optimize API key and forwarded host are derived server-side from the site. +Always returns HTTP 200 unless validation or the access gate fails; a step that errors is +surfaced on its own row with status: error rather than failing the whole response.

+
Authorizations:
api_key
path Parameters
siteId
required
string <uuid> (Id)
Example: 123e4567-e89b-12d3-a456-426614174000

The site ID in uuid format

+
Request Body schema: application/json
required
accountId
required
string^[0-9]{12}$

The 12-digit AWS account ID hosting the customer's CloudFront distribution.

+
externalId
required
string

Per-session external ID baked into the connector role's trust policy.

+
distributionId
required
string

The CloudFront distribution ID to configure.

+
originId
required
string

The customer's existing failover origin id (the default behavior's target origin), baked into the routing function's failover origin group.

+
behavior
required
string

The cache behavior path pattern to target; use default for the default behavior.

+
environment
string
Default: "production"
Enum: "production" "stage"

Which environment to route. Optional; defaults to production (backward-compatible). When stage, the backend resolves the site's single configured stage domain and uses the stage site's Edge Optimize API key + forwarded host instead of production's.

+

Responses

Request samples

Content type
application/json
{
  • "accountId": "682033462621",
  • "externalId": "7ff9518a-cf59-40b4-aa53-68a3cb2e24a5",
  • "distributionId": "E2EXAMPLE123",
  • "originId": "origin-aem",
  • "behavior": "default",
  • "environment": "production"
}

Response samples

Content type
application/json
{
  • "routingDeployed": false,
  • "verified": false,
  • "steps": [
    ]
}

Preview (non-mutating) the Edge Optimize CloudFront deploy plan

Read-only "Review & Deploy" preview for the CloudFront wizard. Mirrors the deploy endpoint +(same validation, access gate, role assumption, and server-derived Edge Optimize origin +headers) but runs the NON-mutating planner and returns the per-step plan plus +canProceed/blocker so the frontend can show exactly what will happen before the customer +commits. The caller passes the selected distribution, failover origin, and behavior; the Edge +Optimize API key and forwarded host are derived server-side from the site (env-aware via the +optional environment flag). The response also includes targetDomain — the resolved host +the backend will route to for the chosen environment.

+
Authorizations:
api_key
path Parameters
siteId
required
string <uuid> (Id)
Example: 123e4567-e89b-12d3-a456-426614174000

The site ID in uuid format

+
Request Body schema: application/json
required
accountId
required
string^[0-9]{12}$

The 12-digit AWS account ID hosting the customer's CloudFront distribution.

+
externalId
required
string

Per-session external ID baked into the connector role's trust policy.

+
distributionId
required
string

The CloudFront distribution ID to preview.

+
originId
required
string

The customer's existing failover origin id (the default behavior's target origin), baked into the routing function's failover origin group.

+
behavior
required
string

The cache behavior path pattern to target; use default for the default behavior.

+
environment
string
Default: "production"
Enum: "production" "stage"

Which environment to route. Optional; defaults to production (backward-compatible). When stage, the backend resolves the site's single configured stage domain and uses the stage site's Edge Optimize API key + forwarded host instead of production's.

+

Responses

Request samples

Content type
application/json
{
  • "accountId": "682033462621",
  • "externalId": "7ff9518a-cf59-40b4-aa53-68a3cb2e24a5",
  • "distributionId": "E2EXAMPLE123",
  • "originId": "origin-aem",
  • "behavior": "default",
  • "environment": "production"
}

Response samples

Content type
application/json
{
  • "canProceed": true,
  • "blocker": "string",
  • "targetDomain": "www.example.com",
  • "steps": [
    ]
}

Check Edge Optimize status for a site path

Behaviour notes " class="sc-iJuXkV sc-cBNeAB iNuSsz dyntKg">

Development server

https://spacecat.experiencecloud.live/api/ci/sites/{siteId}/llmo/edge-optimize-status

Production server

-
https://spacecat.experiencecloud.live/api/v1/sites/{siteId}/llmo/edge-optimize-status

Response samples

Content type
application/json
{
  • "edgeOptimizeEnabled": true
}

Edge Optimize connectivity probe — detect WAF/Bot Manager blocking

https://spacecat.experiencecloud.live/api/v1/sites/{siteId}/llmo/edge-optimize-status

Response samples

Content type
application/json
{
  • "edgeOptimizeEnabled": true
}

Edge Optimize connectivity probe — detect WAF/Bot Manager blocking

Behaviour notes " class="sc-iJuXkV sc-cBNeAB iNuSsz dyntKg">

Development server

https://spacecat.experiencecloud.live/api/ci/sites/{siteId}/llmo/probes/edge-optimize

Production server

-
https://spacecat.experiencecloud.live/api/v1/sites/{siteId}/llmo/probes/edge-optimize

Response samples

Content type
application/json
Example
{}

Update edge optimize routing for a site

https://spacecat.experiencecloud.live/api/v1/sites/{siteId}/llmo/probes/edge-optimize

Response samples

Content type
application/json
Example
{}

Update edge optimize routing for a site

Behaviour notes " class="sc-iJuXkV sc-cBNeAB iNuSsz dyntKg">

Development server

https://spacecat.experiencecloud.live/api/ci/sites/{siteId}/llmo/edge-optimize-routing

Production server

-
https://spacecat.experiencecloud.live/api/v1/sites/{siteId}/llmo/edge-optimize-routing

Request samples

Content type
application/json
{
  • "cdnType": "aem-cs-fastly",
  • "enabled": true
}

Response samples

Content type
application/json
{
  • "enabled": true,
  • "domain": "www.example.com",
  • "cdnType": "aem-cs-fastly"
}

Get the Cloudflare OAuth client ID for the browser PKCE flow

https://spacecat.experiencecloud.live/api/v1/sites/{siteId}/llmo/edge-optimize-routing

Request samples

Content type
application/json
{
  • "cdnType": "aem-cs-fastly",
  • "enabled": true
}

Response samples

Content type
application/json
{
  • "enabled": true,
  • "domain": "www.example.com",
  • "cdnType": "aem-cs-fastly"
}

Get the Cloudflare OAuth client ID for the browser PKCE flow

Behaviour notes " class="sc-iJuXkV sc-cBNeAB iNuSsz dyntKg">

Development server

https://spacecat.experiencecloud.live/api/ci/sites/{siteId}/llmo/cdn-onboard/cloudflare/config

Production server

-
https://spacecat.experiencecloud.live/api/v1/sites/{siteId}/llmo/cdn-onboard/cloudflare/config

Response samples

Content type
application/json
{
  • "clientId": "example-cloudflare-client-id"
}

List the Cloudflare accounts accessible to the supplied token

https://spacecat.experiencecloud.live/api/v1/sites/{siteId}/llmo/cdn-onboard/cloudflare/config

Response samples

Content type
application/json
{
  • "clientId": "example-cloudflare-client-id"
}

List the Cloudflare accounts accessible to the supplied token

Lists the Cloudflare accounts the supplied x-cloudflare-token can access. Used by the @@ -9259,7 +9871,7 @@

Behaviour notes

" class="sc-iJuXkV sc-cBNeAB iNuSsz dyntKg">

Development server

https://spacecat.experiencecloud.live/api/ci/sites/{siteId}/llmo/cdn-onboard/cloudflare/accounts

Production server

-
https://spacecat.experiencecloud.live/api/v1/sites/{siteId}/llmo/cdn-onboard/cloudflare/accounts

Response samples

Content type
application/json
[
  • {
    }
]

List the Cloudflare zones accessible to the supplied token

https://spacecat.experiencecloud.live/api/v1/sites/{siteId}/llmo/cdn-onboard/cloudflare/accounts

Response samples

Content type
application/json
[
  • {
    }
]

List the Cloudflare zones accessible to the supplied token

Lists the Cloudflare zones the supplied x-cloudflare-token can access. Used by the @@ -9287,7 +9899,7 @@

Behaviour notes

" class="sc-iJuXkV sc-cBNeAB iNuSsz dyntKg">

Development server

https://spacecat.experiencecloud.live/api/ci/sites/{siteId}/llmo/cdn-onboard/cloudflare/zones

Production server

-
https://spacecat.experiencecloud.live/api/v1/sites/{siteId}/llmo/cdn-onboard/cloudflare/zones

Response samples

Content type
application/json
[
  • {
    }
]

Deploy the Edge Optimize worker to a Cloudflare account

https://spacecat.experiencecloud.live/api/v1/sites/{siteId}/llmo/cdn-onboard/cloudflare/zones

Response samples

Content type
application/json
[
  • {
    }
]

Deploy the Edge Optimize worker to a Cloudflare account

Behaviour notes " class="sc-iJuXkV sc-cBNeAB iNuSsz dyntKg">

Development server

https://spacecat.experiencecloud.live/api/ci/sites/{siteId}/llmo/cdn-onboard/cloudflare/deploy

Production server

-
https://spacecat.experiencecloud.live/api/v1/sites/{siteId}/llmo/cdn-onboard/cloudflare/deploy

Request samples

Content type
application/json
{
  • "accountId": "0123456789abcdef0123456789abcdef",
  • "targetHost": "www.example.com"
}

Response samples

Content type
application/json
{
  • "scriptName": "string",
  • "accountId": "string",
  • "targetHost": "string"
}

Add a Cloudflare worker route to a zone

https://spacecat.experiencecloud.live/api/v1/sites/{siteId}/llmo/cdn-onboard/cloudflare/deploy

Request samples

Content type
application/json
{
  • "accountId": "0123456789abcdef0123456789abcdef",
  • "targetHost": "www.example.com"
}

Response samples

Content type
application/json
{
  • "scriptName": "string",
  • "accountId": "string",
  • "targetHost": "string"
}

Add a Cloudflare worker route to a zone

Behaviour notes " class="sc-iJuXkV sc-cBNeAB iNuSsz dyntKg">

Development server

https://spacecat.experiencecloud.live/api/ci/sites/{siteId}/llmo/cdn-onboard/cloudflare/zones/{zoneId}/routes

Production server

-
https://spacecat.experiencecloud.live/api/v1/sites/{siteId}/llmo/cdn-onboard/cloudflare/zones/{zoneId}/routes

Request samples

Content type
application/json
{
  • "pattern": "www.example.com/*"
}

Response samples

Content type
application/json
{
  • "id": "string",
  • "pattern": "string",
  • "script": "string"
}

Get LLMO strategy

https://spacecat.experiencecloud.live/api/v1/sites/{siteId}/llmo/cdn-onboard/cloudflare/zones/{zoneId}/routes

Request samples

Content type
application/json
{
  • "pattern": "www.example.com/*"
}

Response samples

Content type
application/json
{
  • "id": "string",
  • "pattern": "string",
  • "script": "string"
}

Get LLMO strategy

Behaviour notes " class="sc-iJuXkV sc-cBNeAB iNuSsz dyntKg">

Development server

https://spacecat.experiencecloud.live/api/ci/sites/{siteId}/llmo/strategy

Production server

-
https://spacecat.experiencecloud.live/api/v1/sites/{siteId}/llmo/strategy

Response samples

Content type
application/json
{
  • "data": { },
  • "version": "abc123def456"
}

Save LLMO strategy

https://spacecat.experiencecloud.live/api/v1/sites/{siteId}/llmo/strategy

Response samples

Content type
application/json
{
  • "data": { },
  • "version": "abc123def456"
}

Save LLMO strategy

Behaviour notes " class="sc-iJuXkV sc-cBNeAB iNuSsz dyntKg">

Development server

https://spacecat.experiencecloud.live/api/ci/sites/{siteId}/llmo/strategy

Production server

-
https://spacecat.experiencecloud.live/api/v1/sites/{siteId}/llmo/strategy

Request samples

Content type
application/json
{ }

Response samples

Content type
application/json
{
  • "version": "abc123def456",
  • "notifications": {
    }
}

llmo-probes

https://spacecat.experiencecloud.live/api/v1/sites/{siteId}/llmo/strategy

Request samples

Content type
application/json
{ }

Response samples

Content type
application/json
{
  • "version": "abc123def456",
  • "notifications": {
    }
}

llmo-probes

Connectivity probes for LLMO features. Currently covers edge-optimize WAF/Bot Manager detection.

Edge Optimize connectivity probe — detect WAF/Bot Manager blocking

Filters " class="sc-iJuXkV sc-cBNeAB iNuSsz dyntKg">

Production server

https://spacecat.experiencecloud.live/api/v1/monitoring/drs-bp-pg-audit

Response samples

Content type
application/json
{
  • "rows": [
    ],
  • "hasMore": true
}