diff --git a/docs/openapi/api.yaml b/docs/openapi/api.yaml index a8f8e4b01..a2ce395f3 100644 --- a/docs/openapi/api.yaml +++ b/docs/openapi/api.yaml @@ -625,6 +625,34 @@ 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/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/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/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: + $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 a8e07d498..4405fc96d 100644 --- a/docs/openapi/llmo-api.yaml +++ b/docs/openapi/llmo-api.yaml @@ -2332,6 +2332,1034 @@ 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: 900 + '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: + - 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: [ ] + +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-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: + - 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: [ ] + +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: + - 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 + +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 + +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/package-lock.json b/package-lock.json index 2cc03e14b..79783a045 100644 --- a/package-lock.json +++ b/package-lock.json @@ -34,10 +34,14 @@ "@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-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", "@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", @@ -246,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", @@ -364,6 +424,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 +820,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 +9097,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 +10480,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 +10596,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 +10651,123 @@ "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" + }, + "engines": { + "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" @@ -10407,11 +10776,34 @@ "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", @@ -10467,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", @@ -10481,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", @@ -10495,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", @@ -10509,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", @@ -10523,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", @@ -10537,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", @@ -10552,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", @@ -11601,10 +11986,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 +12040,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 +12053,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 +12066,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 +12079,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 +12092,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 +12105,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 +12119,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 +12126,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 +12584,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 +12601,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 +12665,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 +12683,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 +13339,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 +13363,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 +14340,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 +14637,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 +14843,6 @@ "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=8.0.0" } @@ -14631,7 +15007,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 +17024,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 +17271,6 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -16944,7 +17317,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 +17781,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 +18530,6 @@ "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -20389,7 +20759,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 +25080,6 @@ "integrity": "sha512-PRsaiG84bK+AMvxziE/lCFss8juXjNaWzVbN5tXAm4XjeaS9NAHhop+PjQxz2A9h8Q4M/xGmzP8vqNwy6JeK0A==", "dev": true, "license": "MIT", - "peer": true, "bin": { "marked": "bin/marked.js" }, @@ -25768,6 +26136,7 @@ "integrity": "sha512-2K9QYubXx/NAjv4VLq1d1Ly8pWNC5L3BrixtdkyTegXWJIqY+zLNDhhX/A+ZwWt70tB1S8H4BE8FLYEFyNoOBw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "obliterator": "^1.6.1" } @@ -25778,7 +26147,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 +28727,6 @@ "dev": true, "inBundle": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -28833,7 +29200,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 +29255,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 +30442,6 @@ "integrity": "sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==", "devOptional": true, "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -30086,7 +30452,6 @@ "integrity": "sha512-ibrK8llX2a4eOskq1mXKu/TGZj9qzomO+sNfO98M6d9zIPOEhlBkMkBUBLd1vgS0gQsLDBzA+8jJBVXDnfHmJg==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -30850,7 +31215,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 +32579,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 +33585,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 +34391,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 +34682,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 +34691,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 d7dea7a9f..3fe69f55a 100644 --- a/package.json +++ b/package.json @@ -98,10 +98,14 @@ "@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-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", "@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 7e2846ec4..2f8db4fce 100644 --- a/src/controllers/llmo/llmo.js +++ b/src/controllers/llmo/llmo.js @@ -33,6 +33,19 @@ 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, + getDistributionConfig, + createEdgeOptimizeOrigin, + createEdgeOptimizeRoutingFunction, + applyEdgeOptimizeCacheHeaders, + createEdgeOptimizeLambda, + getEdgeOptimizeLambdaStatus, + applyEdgeOptimizeAssociations, + verifyEdgeOptimizeRouting, + runEdgeOptimizeDeployStep, +} from '../../support/edge-optimize.js'; import { UnauthorizedProductError } from '../../support/errors.js'; import { cachedOk } from '../../support/cached-response.js'; import { @@ -2064,7 +2077,736 @@ 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'); + } + + // 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'; + 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'; + // 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'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'; + + // 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)); + } + }; + + // 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 { site }; + }; + + // 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)); + } + }; + + // 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)); + } + }; + + // 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, 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, + { 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); + 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)); + } + }; + + // 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) => { + 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. + // + // 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 }); + 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)); + } + }; + + // 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, + getEdgeOptimizeDistributions, + checkEdgeOptimizePrerequisites, + getEdgeOptimizeOrigins, + getEdgeOptimizeBehaviors, + createEdgeOptimizeOrigin: createEdgeOptimizeOriginHandler, + createEdgeOptimizeRoutingFunction: createEdgeOptimizeRoutingFunctionHandler, + applyEdgeOptimizeCache: applyEdgeOptimizeCacheHandler, + createEdgeOptimizeLambda: createEdgeOptimizeLambdaHandler, + getEdgeOptimizeLambdaStatus: getEdgeOptimizeLambdaStatusHandler, + applyEdgeOptimizeAssociations: applyEdgeOptimizeAssociationsHandler, + verifyEdgeOptimizeRouting: verifyEdgeOptimizeRoutingHandler, + deployEdgeOptimize: deployEdgeOptimizeHandler, getLlmoSheetData, queryLlmoSheetData, getLlmoGlobalSheetData, diff --git a/src/routes/index.js b/src/routes/index.js index be8d79338..fb42b2eb9 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -483,6 +483,20 @@ 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, + '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, + '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/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 14c9ec6a1..2436784ae 100644 --- a/src/routes/required-capabilities.js +++ b/src/routes/required-capabilities.js @@ -130,6 +130,20 @@ 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', + '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', '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 000000000..853712745 --- /dev/null +++ b/src/support/edge-optimize.js @@ -0,0 +1,1346 @@ +/* + * 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, + 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'; + +// 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 = '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']; +// 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); +}); + +/** + * 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, + }; +} + +/** + * 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' }; +} + +/** + * 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 && 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, + }; + } + + // 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, + }; + } + + // ── 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; + } + + // 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, + }; +} + +// 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'); + // 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, 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. 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: EDGE_OPTIMIZE_LAMBDA_FUNCTION_NAME }), + ); + } 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: 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, + })); + 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, EDGE_OPTIMIZE_LAMBDA_FUNCTION_NAME); + 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: EDGE_OPTIMIZE_LAMBDA_FUNCTION_NAME, + 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 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 }); + 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( + new GetFunctionConfigurationCommand({ FunctionName: EDGE_OPTIMIZE_LAMBDA_FUNCTION_NAME }), + ); + } catch (err) { + if (err.name === 'ResourceNotFoundException') { + 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, + }; +} + +/** + * 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 } }; +} + +// 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 — 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 { + 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'; + 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 6b3be10f0..6cf30a924 100644 --- a/test/controllers/llmo/llmo.test.js +++ b/test/controllers/llmo/llmo.test.js @@ -86,6 +86,17 @@ describe('LlmoController', () => { let llmoConfigSchemaStub; let triggerBrandProfileAgentStub; let updateModifiedByDetailsStub; + let assumeConnectorRoleStub; + let listCloudFrontDistributionsStub; + let getDistributionConfigStub; + let createEdgeOptimizeOriginStub; + let createEdgeOptimizeRoutingFunctionStub; + let applyEdgeOptimizeCacheHeadersStub; + let createEdgeOptimizeLambdaStub; + let getEdgeOptimizeLambdaStatusStub; + let applyEdgeOptimizeAssociationsStub; + let verifyEdgeOptimizeRoutingStub; + let runEdgeOptimizeDeployStepStub; let mockTokowakaClient; let readStrategyStub; let writeStrategyStub; @@ -194,6 +205,17 @@ describe('LlmoController', () => { this.timeout(120000); triggerBrandProfileAgentStub = sinon.stub().resolves('exec-123'); updateModifiedByDetailsStub = sinon.stub(); + assumeConnectorRoleStub = sinon.stub(); + listCloudFrontDistributionsStub = sinon.stub(); + getDistributionConfigStub = sinon.stub(); + createEdgeOptimizeOriginStub = sinon.stub(); + createEdgeOptimizeRoutingFunctionStub = sinon.stub(); + applyEdgeOptimizeCacheHeadersStub = sinon.stub(); + createEdgeOptimizeLambdaStub = sinon.stub(); + getEdgeOptimizeLambdaStatusStub = sinon.stub(); + applyEdgeOptimizeAssociationsStub = sinon.stub(); + verifyEdgeOptimizeRoutingStub = sinon.stub(); + runEdgeOptimizeDeployStepStub = sinon.stub(); // Initialize mock TokowakaClient mockTokowakaClient = { @@ -262,6 +284,21 @@ 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), + }, '@adobe/spacecat-shared-ims-client': { ImsClient: function MockImsClient() { this.getServicePrincipalAccessToken = (...args) => ( @@ -7554,6 +7591,1410 @@ 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 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(); + 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('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('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('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, updated: false, originId: 'EdgeOptimize_Origin', + }); + mockTokowakaClient.fetchMetaconfig.resolves({ apiKeys: ['eo-key-123'] }); + 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, updated: false, originId: 'EdgeOptimize_Origin', + }); + expect(assumeConnectorRoleStub.calledOnce).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 () => { + 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('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, updated: false, originId: 'EdgeOptimize_Origin', + }); + + const result = await controller.createEdgeOptimizeOrigin(originContext); + const body = await result.json(); + 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); + }); + + 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('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; + + 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('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 722732e7c..ba0c0d321 100755 --- a/test/routes/index.test.js +++ b/test/routes/index.test.js @@ -327,6 +327,19 @@ describe('getRouteHandlers', () => { getLlmoRationale: () => null, getBrandClaims: () => null, createOrUpdateEdgeConfig: () => null, + connectEdgeOptimize: () => null, + getEdgeOptimizeDistributions: () => null, + checkEdgeOptimizePrerequisites: () => null, + getEdgeOptimizeOrigins: () => null, + getEdgeOptimizeBehaviors: () => null, + createEdgeOptimizeOrigin: () => null, + createEdgeOptimizeRoutingFunction: () => null, + applyEdgeOptimizeCache: () => null, + createEdgeOptimizeLambda: () => null, + getEdgeOptimizeLambdaStatus: () => null, + applyEdgeOptimizeAssociations: () => null, + verifyEdgeOptimizeRouting: () => null, + deployEdgeOptimize: () => null, getEdgeConfig: () => null, createOrUpdateStageEdgeConfig: () => null, checkEdgeOptimizeStatus: () => null, @@ -1082,6 +1095,20 @@ 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', + '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', '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 000000000..6717c92f3 --- /dev/null +++ b/test/support/edge-optimize.test.js @@ -0,0 +1,1421 @@ +/* + * 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'), + 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('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', () => { + // 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 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' } }, + }, + }, + }, + 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('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('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: {}, + }); + + 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 }); + + // 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'); + + 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'); + + 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'); + + 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 () => { + 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({}); + + expect(result).to.deep.equal({ + roleExists: 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' } })); + 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.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 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({}); + + 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('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 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 (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' } }, 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'); + // 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); + }); + }); +});