@adobe/spacecat-shared-tokowaka-client 1.19.1 → 1.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1863 @@
1
+ /*
2
+ * Copyright 2026 Adobe. All rights reserved.
3
+ * This file is licensed to you under the Apache License, Version 2.0 (the "License");
4
+ * you may not use this file except in compliance with the License. You may obtain a copy
5
+ * of the License at http://www.apache.org/licenses/LICENSE-2.0
6
+ *
7
+ * Unless required by applicable law or agreed to in writing, software distributed under
8
+ * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9
+ * OF ANY KIND, either express or implied. See the License for the specific language
10
+ * governing permissions and limitations under the License.
11
+ */
12
+
13
+ import { deflateRawSync } from 'node:zlib';
14
+ import { STSClient, AssumeRoleCommand } from '@aws-sdk/client-sts';
15
+ import {
16
+ CloudFrontClient,
17
+ ListDistributionsCommand,
18
+ GetDistributionCommand,
19
+ GetDistributionConfigCommand,
20
+ GetCachePolicyConfigCommand,
21
+ GetCachePolicyCommand,
22
+ ListCachePoliciesCommand,
23
+ CreateCachePolicyCommand,
24
+ UpdateCachePolicyCommand,
25
+ CreateFunctionCommand,
26
+ UpdateFunctionCommand,
27
+ DescribeFunctionCommand,
28
+ PublishFunctionCommand,
29
+ UpdateDistributionCommand,
30
+ } from '@aws-sdk/client-cloudfront';
31
+ import {
32
+ IAMClient,
33
+ CreateRoleCommand,
34
+ GetRoleCommand,
35
+ GetRolePolicyCommand,
36
+ PutRolePolicyCommand,
37
+ UpdateAssumeRolePolicyCommand,
38
+ } from '@aws-sdk/client-iam';
39
+ import {
40
+ LambdaClient,
41
+ CreateFunctionCommand as LambdaCreateFunctionCommand,
42
+ GetFunctionConfigurationCommand,
43
+ ListVersionsByFunctionCommand,
44
+ PublishVersionCommand,
45
+ } from '@aws-sdk/client-lambda';
46
+ import { hasText } from '@adobe/spacecat-shared-utils';
47
+
48
+ // Edge runtime code (Lambda@Edge handler + CloudFront routing function) lives in its own
49
+ // module for readability; imported for use here and re-exported to keep the public surface.
50
+ import { buildEdgeOptimizeLambdaCode, buildCloudfrontFunctionCode } from './edge-code.js';
51
+
52
+ export { buildEdgeOptimizeLambdaCode, buildCloudfrontFunctionCode };
53
+
54
+ // CloudFront is a global service; its control plane lives in us-east-1.
55
+ export const EDGE_OPTIMIZE_REGION = 'us-east-1';
56
+ export const EDGE_OPTIMIZE_DEFAULT_ROLE_NAME = 'AdobeLLMOptimizerCloudFrontConnectorRole';
57
+ const SESSION_NAME = 'llmo-edge-optimize';
58
+ const SESSION_DURATION_SECONDS = 900;
59
+
60
+ // The connector role only permits writes to these exact resource names — keep them in sync
61
+ // with the standalone connect-aws-wizard (server.mjs) and the customer-bootstrap-role policy.
62
+ export const EDGE_OPTIMIZE_ORIGIN_ID = 'EdgeOptimize_Origin';
63
+ export const EDGE_OPTIMIZE_DEFAULT_ORIGIN_DOMAIN = 'live.edgeoptimize.net';
64
+ export const EDGE_OPTIMIZE_FUNCTION_NAME = 'edgeoptimize-routing';
65
+ export const EDGE_OPTIMIZE_LAMBDA_FUNCTION_NAME = 'edgeoptimize-origin';
66
+ export const EDGE_OPTIMIZE_LAMBDA_ROLE_NAME = 'edgeoptimize-origin-role';
67
+
68
+ // Per-distribution resource names — the `-adobe-<distId>` suffix keeps the account-level
69
+ // CloudFront function, Lambda@Edge function, and its IAM execution role unique per distribution
70
+ // (so one AWS account fronting multiple distributions never collides). All stay within the
71
+ // connector role's `edgeoptimize-*` (Lambda/role) and `Resource: '*'` (CloudFront) grants, so no
72
+ // customer re-onboarding is needed. The EO origin id is intentionally NOT suffixed — it is scoped
73
+ // inside the distribution config and cannot collide.
74
+ export const eoRoutingFunctionName = (distributionId) => `${EDGE_OPTIMIZE_FUNCTION_NAME}-adobe-${distributionId}`;
75
+ export const eoLambdaFunctionName = (distributionId) => `${EDGE_OPTIMIZE_LAMBDA_FUNCTION_NAME}-adobe-${distributionId}`;
76
+ export const eoLambdaRoleName = (distributionId) => `${EDGE_OPTIMIZE_LAMBDA_ROLE_NAME}-adobe-${distributionId}`;
77
+ // Headers the routing CloudFront Function sets and that must reach the EO origin uncached.
78
+ export const EDGE_OPTIMIZE_CACHE_HEADERS = ['x-edgeoptimize-config', 'x-edgeoptimize-url'];
79
+ // Name of the custom cache policy we create when cloning an AWS-managed policy.
80
+ export const EDGE_OPTIMIZE_CACHE_POLICY_NAME = 'edgeoptimize-cache';
81
+
82
+ // Per the BYOCDN doc, force the cache policy MinTTL to 0 so agentic responses are not
83
+ // over-cached — UNLESS the current MinTTL is already short (<= this many seconds), in which
84
+ // case we leave it exactly as the customer configured it.
85
+ export const EDGE_OPTIMIZE_MIN_TTL_KEEP_THRESHOLD = 5;
86
+
87
+ /**
88
+ * Build the per-distribution name for a cache policy cloned from a managed (AWS) policy.
89
+ * Strips the AWS `Managed-` prefix (a custom policy must not carry it) and appends an
90
+ * `-adobe-<distributionId>` suffix so each distribution gets its own clone (no account-level
91
+ * collision when one account fronts multiple distributions). Capped at the 128-char AWS limit.
92
+ *
93
+ * @param {string} sourceName - the source (managed) policy name, e.g. `Managed-CachingOptimized`.
94
+ * @param {string} distributionId - the CloudFront distribution id.
95
+ * @returns {string} e.g. `CachingOptimized-adobe-E2VLBZCBR857CC`.
96
+ */
97
+ export function buildEoClonedCachePolicyName(sourceName, distributionId) {
98
+ const base = String(sourceName || 'cache').replace(/^Managed-/i, '');
99
+ return `${base}-adobe-${distributionId}`.slice(0, 128);
100
+ }
101
+
102
+ const delay = (ms) => new Promise((resolve) => {
103
+ setTimeout(resolve, ms);
104
+ });
105
+
106
+ /**
107
+ * Assume the customer's cross-account connector role and return short-lived credentials.
108
+ *
109
+ * The api-service Lambda execution role (the default credential chain) assumes the role the
110
+ * customer created via the CloudFormation bootstrap, scoped by the per-session external ID.
111
+ * Credentials are short-lived — callers should use them immediately for a single operation
112
+ * and never persist them in the browser.
113
+ *
114
+ * @param {object} params
115
+ * @param {string} params.accountId - 12-digit customer AWS account ID.
116
+ * @param {string} params.externalId - external ID baked into the connector role trust policy.
117
+ * @param {string} [params.roleName] - connector role name (defaults to the standard name).
118
+ * @param {string} [params.region] - STS region.
119
+ * @returns {Promise<{roleArn: string, accountId: string, credentials: object}>}
120
+ */
121
+ export async function assumeConnectorRole({
122
+ accountId,
123
+ externalId,
124
+ roleName = EDGE_OPTIMIZE_DEFAULT_ROLE_NAME,
125
+ region = EDGE_OPTIMIZE_REGION,
126
+ }) {
127
+ if (!/^[0-9]{12}$/.test(String(accountId))) {
128
+ throw new Error('accountId must be a 12-digit AWS account ID');
129
+ }
130
+ if (!hasText(externalId)) {
131
+ throw new Error('externalId is required');
132
+ }
133
+
134
+ const roleArn = `arn:aws:iam::${accountId}:role/${roleName}`;
135
+ const sts = new STSClient({ region });
136
+ const response = await sts.send(new AssumeRoleCommand({
137
+ RoleArn: roleArn,
138
+ RoleSessionName: SESSION_NAME,
139
+ ExternalId: externalId,
140
+ DurationSeconds: SESSION_DURATION_SECONDS,
141
+ }));
142
+
143
+ const creds = response?.Credentials;
144
+ if (!creds?.AccessKeyId || !creds?.SecretAccessKey || !creds?.SessionToken) {
145
+ throw new Error('Failed to assume connector role: no credentials returned');
146
+ }
147
+
148
+ return {
149
+ roleArn,
150
+ accountId: String(accountId),
151
+ credentials: {
152
+ accessKeyId: creds.AccessKeyId,
153
+ secretAccessKey: creds.SecretAccessKey,
154
+ sessionToken: creds.SessionToken,
155
+ expiration: creds.Expiration,
156
+ },
157
+ };
158
+ }
159
+
160
+ /**
161
+ * List the CloudFront distributions in the customer account using assumed-role credentials.
162
+ *
163
+ * @param {object} credentials - temporary credentials from {@link assumeConnectorRole}.
164
+ * @param {string} [region] - CloudFront control-plane region.
165
+ * @returns {Promise<Array<object>>} distributions projected to the fields the wizard needs.
166
+ */
167
+ export async function listDistributions(credentials, region = EDGE_OPTIMIZE_REGION) {
168
+ const client = new CloudFrontClient({ region, credentials });
169
+ // Paginate: ListDistributions returns at most 100 items per page; accounts fronting more than
170
+ // 100 distributions would otherwise have the target silently missing from the picker. Follow the
171
+ // Marker/IsTruncated/NextMarker chain, accumulating every page before projecting.
172
+ const items = [];
173
+ let marker;
174
+ /* eslint-disable no-await-in-loop */
175
+ do {
176
+ const response = await client.send(new ListDistributionsCommand(
177
+ marker ? { Marker: marker } : {},
178
+ ));
179
+ const list = response?.DistributionList || {};
180
+ items.push(...(list.Items || []));
181
+ marker = list.IsTruncated ? list.NextMarker : undefined;
182
+ } while (marker);
183
+ /* eslint-enable no-await-in-loop */
184
+ return items.map((dist) => ({
185
+ id: dist.Id,
186
+ domainName: dist.DomainName,
187
+ aliases: dist.Aliases?.Items || [],
188
+ status: dist.Status,
189
+ enabled: dist.Enabled === true,
190
+ comment: dist.Comment || '',
191
+ }));
192
+ }
193
+
194
+ /**
195
+ * Fetch a single CloudFront distribution's configuration using assumed-role credentials.
196
+ *
197
+ * Returns the parsed origins, default cache behavior, and ordered cache behaviors projected to
198
+ * the fields the wizard needs to inspect routing. Read-only — uses GetDistributionConfig.
199
+ *
200
+ * @param {object} credentials - temporary credentials from {@link assumeConnectorRole}.
201
+ * @param {string} distributionId - the CloudFront distribution ID.
202
+ * @param {string} [region] - CloudFront control-plane region.
203
+ * @returns {Promise<{origins: Array<object>, defaultCacheBehavior: object|null,
204
+ * cacheBehaviors: Array<object>}>}
205
+ */
206
+ export async function getDistributionConfig(
207
+ credentials,
208
+ distributionId,
209
+ region = EDGE_OPTIMIZE_REGION,
210
+ ) {
211
+ if (!hasText(distributionId)) {
212
+ throw new Error('distributionId is required');
213
+ }
214
+ const client = new CloudFrontClient({ region, credentials });
215
+ const response = await client.send(new GetDistributionConfigCommand({ Id: distributionId }));
216
+ const config = response?.DistributionConfig || {};
217
+
218
+ const origins = (config.Origins?.Items || []).map((origin) => ({
219
+ id: origin.Id,
220
+ domainName: origin.DomainName,
221
+ originPath: origin.OriginPath || '',
222
+ }));
223
+
224
+ const mapBehavior = (behavior) => ({
225
+ pathPattern: behavior.PathPattern,
226
+ targetOriginId: behavior.TargetOriginId,
227
+ });
228
+
229
+ const defaultCacheBehavior = config.DefaultCacheBehavior
230
+ ? mapBehavior({ ...config.DefaultCacheBehavior, PathPattern: 'Default (*)' })
231
+ : null;
232
+
233
+ const cacheBehaviors = (config.CacheBehaviors?.Items || []).map(mapBehavior);
234
+
235
+ return { origins, defaultCacheBehavior, cacheBehaviors };
236
+ }
237
+
238
+ /**
239
+ * Locate a behavior on a parsed DistributionConfig by its path pattern. The default behavior is
240
+ * addressed with the pseudo-pattern `default` (or `Default (*)`, the projection used by the read
241
+ * endpoints).
242
+ *
243
+ * @param {object} config - a raw CloudFront DistributionConfig.
244
+ * @param {string} pathPattern - the behavior path pattern, or `default`/`Default (*)`.
245
+ * @returns {object} the raw behavior object (mutating it mutates the config).
246
+ */
247
+ function getBehaviorFromConfig(config, pathPattern) {
248
+ if (pathPattern === 'default' || pathPattern === 'Default (*)') {
249
+ return config.DefaultCacheBehavior;
250
+ }
251
+ const behavior = (config.CacheBehaviors?.Items || []).find((b) => b.PathPattern === pathPattern);
252
+ if (!behavior) {
253
+ throw new Error(`Behavior not found: ${pathPattern}`);
254
+ }
255
+ return behavior;
256
+ }
257
+
258
+ /**
259
+ * Build the custom-header items the EO origin must carry. Mirrors the standalone wizard's
260
+ * apiCreateOrigin (server.mjs) + the CloudFormation installer: `x-edgeoptimize-api-key`
261
+ * authenticates the prerender request to Edge Optimize, `x-forwarded-host` tells EO which site's
262
+ * content to serve, and the optional `x-edgeoptimize-fetcher-key` is for WAF-allowlisted customers.
263
+ * Without these the origin returns no `x-edgeoptimize-request-id` and Verify never goes green.
264
+ *
265
+ * @param {object} headers
266
+ * @param {string} [headers.apiKey] - the site's Edge Optimize API key.
267
+ * @param {string} [headers.forwardedHost] - the customer's canonical site host.
268
+ * @param {string} [headers.fetcherKey] - optional fetcher key (WAF allowlist).
269
+ * @returns {Array<{HeaderName: string, HeaderValue: string}>}
270
+ */
271
+ function buildEdgeOptimizeOriginHeaders({ apiKey, forwardedHost, fetcherKey } = {}) {
272
+ const items = [];
273
+ if (hasText(apiKey)) {
274
+ items.push({ HeaderName: 'x-edgeoptimize-api-key', HeaderValue: apiKey });
275
+ }
276
+ if (hasText(forwardedHost)) {
277
+ items.push({ HeaderName: 'x-forwarded-host', HeaderValue: forwardedHost });
278
+ }
279
+ if (hasText(fetcherKey)) {
280
+ items.push({ HeaderName: 'x-edgeoptimize-fetcher-key', HeaderValue: fetcherKey });
281
+ }
282
+ return items;
283
+ }
284
+
285
+ /**
286
+ * Add the Edge Optimize origin to a CloudFront distribution (idempotent + self-healing).
287
+ *
288
+ * Reads the distribution config and, if no Edge Optimize origin exists yet, appends a custom HTTPS
289
+ * origin pointing at the EO target domain with the EO request headers. If the origin already exists
290
+ * but its custom headers do not match the desired set (e.g. it was created header-less by an
291
+ * earlier version), the headers are patched in place. Writes are applied via UpdateDistribution
292
+ * (deploy propagates in the background; we do not block on it).
293
+ *
294
+ * @param {object} credentials - temporary credentials from {@link assumeConnectorRole}.
295
+ * @param {string} distributionId - the CloudFront distribution ID.
296
+ * @param {string} [originDomain] - EO origin domain (env-driven; defaults to the dev EO domain).
297
+ * @param {object} [headers] - EO origin headers ({ apiKey, forwardedHost, fetcherKey }).
298
+ * @param {string} [region] - CloudFront control-plane region.
299
+ * @returns {Promise<{created, alreadyExisted, updated, originId}>} origin mutation outcome.
300
+ */
301
+ export async function createOrigin(
302
+ credentials,
303
+ distributionId,
304
+ originDomain = EDGE_OPTIMIZE_DEFAULT_ORIGIN_DOMAIN,
305
+ headers = {},
306
+ region = EDGE_OPTIMIZE_REGION,
307
+ ) {
308
+ if (!hasText(distributionId)) {
309
+ throw new Error('distributionId is required');
310
+ }
311
+ const desiredHeaderItems = buildEdgeOptimizeOriginHeaders(headers);
312
+
313
+ const client = new CloudFrontClient({ region, credentials });
314
+ const result = await client.send(new GetDistributionConfigCommand({ Id: distributionId }));
315
+ const config = result.DistributionConfig;
316
+ const etag = result.ETag;
317
+ const origins = config.Origins?.Items || [];
318
+
319
+ const existing = origins.find((o) => o.Id === EDGE_OPTIMIZE_ORIGIN_ID);
320
+ const existingDomainOrigin = origins.find((o) => o.DomainName === originDomain);
321
+
322
+ if (!existing && existingDomainOrigin) {
323
+ throw new Error(`An origin for ${originDomain} already exists as ${existingDomainOrigin.Id}; refusing to reuse a non-Edge Optimize origin`);
324
+ }
325
+
326
+ if (existing) {
327
+ // Idempotent — but self-heal an origin created without the EO headers (earlier bug): patch its
328
+ // CustomHeaders to the desired set when they differ. Never wipe headers if none were supplied.
329
+ const toMap = (arr) => (arr || []).reduce((acc, h) => {
330
+ acc[h.HeaderName.toLowerCase()] = h.HeaderValue;
331
+ return acc;
332
+ }, {});
333
+ const current = toMap(existing.CustomHeaders?.Items);
334
+ const desired = toMap(desiredHeaderItems);
335
+ const headersMatch = Object.keys(desired).length === Object.keys(current).length
336
+ && Object.entries(desired).every(([k, v]) => current[k] === v);
337
+
338
+ if (desiredHeaderItems.length === 0 || headersMatch) {
339
+ return {
340
+ created: false, alreadyExisted: true, updated: false, originId: EDGE_OPTIMIZE_ORIGIN_ID,
341
+ };
342
+ }
343
+
344
+ existing.CustomHeaders = { Quantity: desiredHeaderItems.length, Items: desiredHeaderItems };
345
+ await client.send(new UpdateDistributionCommand({
346
+ Id: distributionId,
347
+ IfMatch: etag,
348
+ DistributionConfig: config,
349
+ }));
350
+ return {
351
+ created: false, alreadyExisted: true, updated: true, originId: EDGE_OPTIMIZE_ORIGIN_ID,
352
+ };
353
+ }
354
+
355
+ origins.push({
356
+ Id: EDGE_OPTIMIZE_ORIGIN_ID,
357
+ DomainName: originDomain,
358
+ OriginPath: '',
359
+ CustomHeaders: { Quantity: desiredHeaderItems.length, Items: desiredHeaderItems },
360
+ CustomOriginConfig: {
361
+ HTTPPort: 80,
362
+ HTTPSPort: 443,
363
+ OriginProtocolPolicy: 'https-only',
364
+ OriginSslProtocols: { Quantity: 1, Items: ['TLSv1.2'] },
365
+ OriginReadTimeout: 30,
366
+ OriginKeepaliveTimeout: 5,
367
+ },
368
+ ConnectionAttempts: 3,
369
+ ConnectionTimeout: 10,
370
+ });
371
+ config.Origins = { Quantity: origins.length, Items: origins };
372
+
373
+ await client.send(new UpdateDistributionCommand({
374
+ Id: distributionId,
375
+ IfMatch: etag,
376
+ DistributionConfig: config,
377
+ }));
378
+
379
+ return {
380
+ created: true, alreadyExisted: false, updated: false, originId: EDGE_OPTIMIZE_ORIGIN_ID,
381
+ };
382
+ }
383
+
384
+ /**
385
+ * Create or update the `edgeoptimize-routing` CloudFront Function and publish it to LIVE
386
+ * (idempotent). Mirrors the standalone wizard's create-function step.
387
+ *
388
+ * @param {object} credentials - temporary credentials from {@link assumeConnectorRole}.
389
+ * @param {string} defaultOriginId - the default-behavior target origin id (baked into the code).
390
+ * @param {string[]|null} [targetedPaths] - explicit paths to target, or null for all HTML pages.
391
+ * @param {string} [region] - CloudFront control-plane region.
392
+ * @returns {Promise<{name: string, created: boolean, stage: string}>}
393
+ */
394
+ export async function createCloudFrontFunction(
395
+ credentials,
396
+ defaultOriginId,
397
+ distributionId,
398
+ targetedPaths = null,
399
+ region = EDGE_OPTIMIZE_REGION,
400
+ ) {
401
+ if (!hasText(defaultOriginId)) {
402
+ throw new Error('defaultOriginId is required');
403
+ }
404
+ if (!hasText(distributionId)) {
405
+ throw new Error('distributionId is required');
406
+ }
407
+ const functionName = eoRoutingFunctionName(distributionId);
408
+ const client = new CloudFrontClient({ region, credentials });
409
+ const code = Buffer.from(buildCloudfrontFunctionCode(defaultOriginId, targetedPaths), 'utf-8');
410
+ const functionConfig = {
411
+ Comment: 'EdgeOptimize agentic bot routing — managed by LLM Optimizer',
412
+ Runtime: 'cloudfront-js-2.0',
413
+ };
414
+
415
+ // Look up the DEVELOPMENT stage to get its ETag (needed to update an existing function).
416
+ let existingEtag = null;
417
+ try {
418
+ const desc = await client.send(new DescribeFunctionCommand({
419
+ Name: functionName,
420
+ Stage: 'DEVELOPMENT',
421
+ }));
422
+ existingEtag = desc.ETag;
423
+ } catch (err) {
424
+ if (err.name !== 'NoSuchFunctionExists') {
425
+ throw err;
426
+ }
427
+ }
428
+
429
+ let etag;
430
+ if (existingEtag) {
431
+ const updated = await client.send(new UpdateFunctionCommand({
432
+ Name: functionName,
433
+ IfMatch: existingEtag,
434
+ FunctionConfig: functionConfig,
435
+ FunctionCode: code,
436
+ }));
437
+ etag = updated.ETag;
438
+ } else {
439
+ const created = await client.send(new CreateFunctionCommand({
440
+ Name: functionName,
441
+ FunctionConfig: functionConfig,
442
+ FunctionCode: code,
443
+ }));
444
+ etag = created.ETag;
445
+ }
446
+
447
+ await client.send(new PublishFunctionCommand({
448
+ Name: functionName,
449
+ IfMatch: etag,
450
+ }));
451
+
452
+ return { name: functionName, created: !existingEtag, stage: 'LIVE' };
453
+ }
454
+
455
+ /**
456
+ * Add the Edge Optimize routing headers to the cache key/forwarded set for the target behavior.
457
+ *
458
+ * Ported from the standalone wizard's detect-cache + apply-cache (server.mjs). Handles all three
459
+ * scenarios the wizard supports, because real distributions commonly use an AWS-managed policy:
460
+ * - `legacy` — behavior has no CachePolicyId (uses ForwardedValues): add EO headers there.
461
+ * - `custom` — behavior uses a customer-owned cache policy: UpdateCachePolicy to add EO headers.
462
+ * - `managed` — behavior uses an AWS-managed policy (cannot be updated → "update is not allowed
463
+ * for this policy"): CLONE it into a custom `edgeoptimize-cache` policy with EO headers and
464
+ * repoint the behavior to it. Idempotent by policy name.
465
+ * `setMinTTLZero` (default true) forces MinTTL to 0 so agentic responses are not over-cached.
466
+ *
467
+ * @param {object} credentials - temporary credentials from {@link assumeConnectorRole}.
468
+ * @param {string} distributionId - the CloudFront distribution ID.
469
+ * @param {string} pathPattern - the behavior to target (`default` for the default behavior).
470
+ * @param {object} [opts]
471
+ * @param {boolean} [opts.setMinTTLZero=true] - force the policy MinTTL to 0.
472
+ * @param {string} [opts.region] - CloudFront control-plane region.
473
+ * @returns {Promise<{scenario: string, policyId: string|null, updated: boolean,
474
+ * alreadyForwarded: boolean, reused?: boolean}>}
475
+ */
476
+ export async function updateCacheSettings(
477
+ credentials,
478
+ distributionId,
479
+ pathPattern,
480
+ { setMinTTLZero = true, region = EDGE_OPTIMIZE_REGION } = {},
481
+ ) {
482
+ if (!hasText(distributionId)) {
483
+ throw new Error('distributionId is required');
484
+ }
485
+ if (!hasText(pathPattern)) {
486
+ throw new Error('pathPattern is required');
487
+ }
488
+ const client = new CloudFrontClient({ region, credentials });
489
+
490
+ const distResult = await client.send(new GetDistributionConfigCommand({ Id: distributionId }));
491
+ const config = distResult.DistributionConfig;
492
+ const behavior = getBehaviorFromConfig(config, pathPattern);
493
+ const policyId = behavior.CachePolicyId;
494
+
495
+ // ── Scenario A: legacy (ForwardedValues, no CachePolicyId) ──────────────
496
+ if (!policyId) {
497
+ const fv = behavior.ForwardedValues || {};
498
+ const items = fv.Headers?.Items || [];
499
+ const lower = items.map((x) => x.toLowerCase());
500
+ let changed = false;
501
+ if (!lower.includes('*')) {
502
+ EDGE_OPTIMIZE_CACHE_HEADERS.forEach((h) => {
503
+ if (!lower.includes(h)) {
504
+ items.push(h);
505
+ changed = true;
506
+ }
507
+ });
508
+ fv.Headers = { Quantity: items.length, Items: items };
509
+ behavior.ForwardedValues = fv;
510
+ }
511
+ if (setMinTTLZero && Number(behavior.MinTTL ?? 0) > EDGE_OPTIMIZE_MIN_TTL_KEEP_THRESHOLD) {
512
+ behavior.MinTTL = 0;
513
+ changed = true;
514
+ }
515
+ if (!changed) {
516
+ return {
517
+ scenario: 'legacy', policyId: null, updated: false, alreadyForwarded: true,
518
+ };
519
+ }
520
+ await client.send(new UpdateDistributionCommand({
521
+ Id: distributionId, IfMatch: distResult.ETag, DistributionConfig: config,
522
+ }));
523
+ return {
524
+ scenario: 'legacy', policyId: null, updated: true, alreadyForwarded: false,
525
+ };
526
+ }
527
+
528
+ // Determine whether the attached policy is AWS-managed (managed policies cannot be updated).
529
+ const managedList = await client.send(new ListCachePoliciesCommand({ Type: 'managed' }));
530
+ const managedIds = new Set(
531
+ (managedList.CachePolicyList?.Items || []).map((i) => i.CachePolicy.Id),
532
+ );
533
+ const isManaged = managedIds.has(policyId);
534
+
535
+ // Helper: add the EO headers to a HeadersConfig in place; returns true if anything changed.
536
+ const addEoHeaders = (params) => {
537
+ const hc = params.HeadersConfig || { HeaderBehavior: 'none' };
538
+ if (hc.HeaderBehavior === 'allViewer' || hc.HeaderBehavior === 'all') {
539
+ return false;
540
+ }
541
+ const items = hc.Headers?.Items || [];
542
+ const lower = items.map((x) => x.toLowerCase());
543
+ const missing = EDGE_OPTIMIZE_CACHE_HEADERS.filter((h) => !lower.includes(h));
544
+ if (missing.length === 0) {
545
+ return false;
546
+ }
547
+ missing.forEach((h) => items.push(h));
548
+ hc.HeaderBehavior = 'whitelist';
549
+ hc.Headers = { Quantity: items.length, Items: items };
550
+ // eslint-disable-next-line no-param-reassign
551
+ params.HeadersConfig = hc;
552
+ return true;
553
+ };
554
+
555
+ // ── Scenario B: custom policy → update it in place ──────────────────────
556
+ if (!isManaged) {
557
+ const pcResult = await client.send(new GetCachePolicyConfigCommand({ Id: policyId }));
558
+ const pc = pcResult.CachePolicyConfig;
559
+ const params = pc.ParametersInCacheKeyAndForwardedToOrigin || {};
560
+ const headersChanged = addEoHeaders(params);
561
+ pc.ParametersInCacheKeyAndForwardedToOrigin = params;
562
+ const needsMinTtl = setMinTTLZero
563
+ && Number(pc.MinTTL ?? 0) > EDGE_OPTIMIZE_MIN_TTL_KEEP_THRESHOLD;
564
+ if (!headersChanged && !needsMinTtl) {
565
+ return {
566
+ scenario: 'custom', policyId, updated: false, alreadyForwarded: true,
567
+ };
568
+ }
569
+ if (needsMinTtl) {
570
+ pc.MinTTL = 0;
571
+ }
572
+ await client.send(new UpdateCachePolicyCommand({
573
+ Id: policyId, IfMatch: pcResult.ETag, CachePolicyConfig: pc,
574
+ }));
575
+ return {
576
+ scenario: 'custom', policyId, updated: true, alreadyForwarded: false,
577
+ };
578
+ }
579
+
580
+ // ── Scenario C: managed policy → clone into edgeoptimize-cache + repoint ──
581
+ const srcResult = await client.send(new GetCachePolicyCommand({ Id: policyId }));
582
+ const cloned = JSON.parse(JSON.stringify(srcResult.CachePolicy.CachePolicyConfig));
583
+ const sourceName = cloned.Name;
584
+ const clonedName = buildEoClonedCachePolicyName(sourceName, distributionId);
585
+ cloned.Name = clonedName;
586
+ cloned.Comment = `Cloned from ${sourceName} with Edge Optimize headers — managed by LLM Optimizer`;
587
+ if (setMinTTLZero && Number(cloned.MinTTL ?? 0) > EDGE_OPTIMIZE_MIN_TTL_KEEP_THRESHOLD) {
588
+ cloned.MinTTL = 0;
589
+ }
590
+ const clonedParams = cloned.ParametersInCacheKeyAndForwardedToOrigin || {};
591
+ addEoHeaders(clonedParams);
592
+ cloned.ParametersInCacheKeyAndForwardedToOrigin = clonedParams;
593
+
594
+ // Idempotent: reuse an existing clone only when it matches the FULL derived name (exact)
595
+ // <sourceName-without-Managed->-adobe-<distId>. If the customer re-pointed the behavior to a
596
+ // different source since a prior run, the derived name differs, so we create a clone matching the
597
+ // CURRENT source instead of reusing a clone built from a different base.
598
+ const customList = await client.send(new ListCachePoliciesCommand({ Type: 'custom' }));
599
+ const existing = (customList.CachePolicyList?.Items || []).find(
600
+ (i) => i.CachePolicy.CachePolicyConfig.Name === clonedName,
601
+ );
602
+ let newPolicyId;
603
+ let reused = false;
604
+ if (existing) {
605
+ newPolicyId = existing.CachePolicy.Id;
606
+ reused = true;
607
+ } else {
608
+ const created = await client.send(new CreateCachePolicyCommand({ CachePolicyConfig: cloned }));
609
+ newPolicyId = created.CachePolicy.Id;
610
+ }
611
+
612
+ // Re-read the distribution for a fresh ETag, repoint the behavior to the new custom policy.
613
+ const freshDist = await client.send(new GetDistributionConfigCommand({ Id: distributionId }));
614
+ const freshConfig = freshDist.DistributionConfig;
615
+ const freshBehavior = getBehaviorFromConfig(freshConfig, pathPattern);
616
+ freshBehavior.CachePolicyId = newPolicyId;
617
+ delete freshBehavior.ForwardedValues; // cannot coexist with CachePolicyId
618
+ await client.send(new UpdateDistributionCommand({
619
+ Id: distributionId, IfMatch: freshDist.ETag, DistributionConfig: freshConfig,
620
+ }));
621
+
622
+ return {
623
+ scenario: 'managed', policyId: newPolicyId, updated: true, alreadyForwarded: false, reused,
624
+ };
625
+ }
626
+
627
+ const LAMBDA_TRUST_POLICY = JSON.stringify({
628
+ Version: '2012-10-17',
629
+ Statement: [{
630
+ Effect: 'Allow',
631
+ Principal: { Service: ['lambda.amazonaws.com', 'edgelambda.amazonaws.com'] },
632
+ Action: 'sts:AssumeRole',
633
+ }],
634
+ });
635
+
636
+ function buildCwLogsPolicy(accountId, functionName) {
637
+ return JSON.stringify({
638
+ Version: '2012-10-17',
639
+ Statement: [
640
+ {
641
+ Effect: 'Allow',
642
+ Action: 'logs:CreateLogGroup',
643
+ Resource: `arn:aws:logs:*:${accountId}:*`,
644
+ },
645
+ {
646
+ Effect: 'Allow',
647
+ Action: ['logs:CreateLogStream', 'logs:PutLogEvents'],
648
+ Resource: [`arn:aws:logs:*:${accountId}:log-group:/aws/lambda/us-east-1.${functionName}:*`],
649
+ },
650
+ ],
651
+ });
652
+ }
653
+
654
+ // ── Minimal zip builder (no external deps) — ported from the standalone wizard's buildZip. ──
655
+ // CRC32 + ZIP local/central directory headers are inherently bit-twiddling and densely packed; the
656
+ // helix lint rules against bitwise ops, multiple statements per line, and long lines do not fit
657
+ // binary-format code, so they are disabled for this block only.
658
+ /* eslint-disable no-bitwise, max-statements-per-line, max-len */
659
+ const CRC32_TABLE = (() => {
660
+ const t = new Uint32Array(256);
661
+ for (let i = 0; i < 256; i += 1) {
662
+ let c = i;
663
+ for (let j = 0; j < 8; j += 1) { c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1); }
664
+ t[i] = c;
665
+ }
666
+ return t;
667
+ })();
668
+
669
+ function crc32(buf) {
670
+ let c = 0xFFFFFFFF;
671
+ for (let i = 0; i < buf.length; i += 1) { c = (c >>> 8) ^ CRC32_TABLE[(c ^ buf[i]) & 0xFF]; }
672
+ return (c ^ 0xFFFFFFFF) >>> 0;
673
+ }
674
+
675
+ /**
676
+ * Build an in-memory zip containing a single file. Used to package the Lambda@Edge code without
677
+ * adding a zip dependency to the runtime bundle.
678
+ *
679
+ * @param {string} filename - the entry name inside the zip (e.g. `index.mjs`).
680
+ * @param {string|Buffer} content - the file content.
681
+ * @returns {Buffer} the zip archive bytes.
682
+ */
683
+ export function buildLambdaZip(filename, content) {
684
+ const data = Buffer.isBuffer(content) ? content : Buffer.from(content, 'utf-8');
685
+ const compressed = deflateRawSync(data, { level: 9 });
686
+ const crcVal = crc32(data);
687
+ const fn = Buffer.from(filename, 'utf-8');
688
+ // Fixed DOS date/time (1980-01-01 00:00:00) so the zip — and thus the Lambda CodeSha256 — is
689
+ // deterministic for identical source. A timestamp here would change the hash on every call,
690
+ // causing needless code updates and version churn.
691
+ const dosDate = (0 << 9) | (1 << 5) | 1;
692
+ const dosTime = 0;
693
+
694
+ const lh = Buffer.alloc(30 + fn.length);
695
+ lh.writeUInt32LE(0x04034b50, 0); lh.writeUInt16LE(20, 4); lh.writeUInt16LE(0, 6);
696
+ lh.writeUInt16LE(8, 8); lh.writeUInt16LE(dosTime, 10); lh.writeUInt16LE(dosDate, 12);
697
+ lh.writeUInt32LE(crcVal, 14); lh.writeUInt32LE(compressed.length, 18);
698
+ lh.writeUInt32LE(data.length, 22); lh.writeUInt16LE(fn.length, 26); lh.writeUInt16LE(0, 28);
699
+ fn.copy(lh, 30);
700
+
701
+ const cd = Buffer.alloc(46 + fn.length);
702
+ cd.writeUInt32LE(0x02014b50, 0); cd.writeUInt16LE(20, 4); cd.writeUInt16LE(20, 6);
703
+ cd.writeUInt16LE(0, 8); cd.writeUInt16LE(8, 10); cd.writeUInt16LE(dosTime, 12);
704
+ cd.writeUInt16LE(dosDate, 14); cd.writeUInt32LE(crcVal, 16);
705
+ cd.writeUInt32LE(compressed.length, 20); cd.writeUInt32LE(data.length, 24);
706
+ cd.writeUInt16LE(fn.length, 28); cd.writeUInt16LE(0, 30); cd.writeUInt16LE(0, 32);
707
+ cd.writeUInt16LE(0, 34); cd.writeUInt16LE(0, 36); cd.writeUInt32LE(0, 38);
708
+ cd.writeUInt32LE(0, 42); fn.copy(cd, 46);
709
+
710
+ const eocd = Buffer.alloc(22);
711
+ eocd.writeUInt32LE(0x06054b50, 0); eocd.writeUInt16LE(0, 4); eocd.writeUInt16LE(0, 6);
712
+ eocd.writeUInt16LE(1, 8); eocd.writeUInt16LE(1, 10);
713
+ eocd.writeUInt32LE(cd.length, 12); eocd.writeUInt32LE(lh.length + compressed.length, 16);
714
+ eocd.writeUInt16LE(0, 20);
715
+
716
+ return Buffer.concat([lh, compressed, cd, eocd]);
717
+ }
718
+ /* eslint-enable no-bitwise, max-statements-per-line, max-len */
719
+
720
+ // Latest published numbered version (skips $LATEST). Returns { versionArn, version, codeSha256 }
721
+ // or null when no numbered version has been published yet.
722
+ async function getLatestLambdaVersion(lambda, functionName) {
723
+ const resp = await lambda.send(
724
+ new ListVersionsByFunctionCommand({ FunctionName: functionName }),
725
+ );
726
+ const numbered = (resp.Versions || []).filter((v) => v.Version && v.Version !== '$LATEST');
727
+ if (numbered.length === 0) {
728
+ return null;
729
+ }
730
+ const latest = numbered.sort((a, b) => Number(b.Version) - Number(a.Version))[0];
731
+ return { versionArn: latest.FunctionArn, version: latest.Version, codeSha256: latest.CodeSha256 };
732
+ }
733
+
734
+ /**
735
+ * Create (or update) the `edgeoptimize-origin` Lambda@Edge function and publish a version
736
+ * (idempotent). Mirrors the standalone wizard's create-lambda step: ensure the exec role exists
737
+ * (trusting lambda + edgelambda) with a basic CloudWatch-logs inline policy, then create/update the
738
+ * function code and publish a numbered version. Newly-created IAM roles take a few seconds to
739
+ * propagate, so the create path retries CreateFunction with a bounded back-off
740
+ * (up to ~5×5s, within ~30s).
741
+ *
742
+ * @param {object} credentials - temporary credentials from {@link assumeConnectorRole}.
743
+ * @param {string} accountId - the 12-digit customer AWS account ID (for the logs-policy ARNs).
744
+ * @param {object} [opts]
745
+ * @param {string} [opts.region] - control-plane region (Lambda@Edge must be us-east-1).
746
+ * @param {number} [opts.retryDelayMs] - back-off between CreateFunction role-propagation retries.
747
+ * @returns {Promise<{functionArn: string, versionArn: string, version: string,
748
+ * roleArn: string, created: boolean}>}
749
+ */
750
+ export async function createLambdaAtEdge(
751
+ credentials,
752
+ accountId,
753
+ {
754
+ region = EDGE_OPTIMIZE_REGION,
755
+ distributionId,
756
+ originDomain = EDGE_OPTIMIZE_DEFAULT_ORIGIN_DOMAIN,
757
+ retryDelayMs = 5000,
758
+ } = {},
759
+ ) {
760
+ if (!/^[0-9]{12}$/.test(String(accountId))) {
761
+ throw new Error('accountId must be a 12-digit AWS account ID');
762
+ }
763
+ if (!hasText(distributionId)) {
764
+ throw new Error('distributionId is required');
765
+ }
766
+ const lambdaName = eoLambdaFunctionName(distributionId);
767
+ const roleName = eoLambdaRoleName(distributionId);
768
+ const lambda = new LambdaClient({ region, credentials });
769
+ const iam = new IAMClient({ region, credentials });
770
+
771
+ // Bake the EO origin domain into the handler so it matches the EO origin's DomainName per env.
772
+ const zipBuffer = buildLambdaZip('index.mjs', buildEdgeOptimizeLambdaCode(originDomain));
773
+
774
+ // ── 1. Ensure the exec role exists with the current trust policy. ──
775
+ let roleArn;
776
+ let roleIsNew = false;
777
+ try {
778
+ const existing = await iam.send(
779
+ new GetRoleCommand({ RoleName: roleName }),
780
+ );
781
+ roleArn = existing.Role.Arn;
782
+ await iam.send(new UpdateAssumeRolePolicyCommand({
783
+ RoleName: roleName,
784
+ PolicyDocument: LAMBDA_TRUST_POLICY,
785
+ }));
786
+ } catch (err) {
787
+ if (err.name !== 'NoSuchEntityException') {
788
+ throw err;
789
+ }
790
+ const created = await iam.send(new CreateRoleCommand({
791
+ RoleName: roleName,
792
+ AssumeRolePolicyDocument: LAMBDA_TRUST_POLICY,
793
+ Description: 'Execution role for EdgeOptimize Lambda@Edge function',
794
+ }));
795
+ roleArn = created.Role.Arn;
796
+ roleIsNew = true;
797
+ }
798
+
799
+ // ── 2. Attach the CloudWatch-logs inline policy. ──
800
+ await iam.send(new PutRolePolicyCommand({
801
+ RoleName: roleName,
802
+ PolicyName: 'EdgeOptimizeLambdaLogging',
803
+ PolicyDocument: buildCwLogsPolicy(String(accountId), lambdaName),
804
+ }));
805
+
806
+ // ── 3. Advance the function state machine WITHOUT blocking on provisioning. ──
807
+ // This runs behind a CDN/gateway with a ~60s first-byte timeout, so we must never wait for a
808
+ // fresh function to become Active (30–60s) inside the request. Each call does at most one fast
809
+ // step and returns `status: 'provisioning' | 'ready'`; the UI polls until ready.
810
+ let cfg = null;
811
+ try {
812
+ cfg = await lambda.send(
813
+ new GetFunctionConfigurationCommand({ FunctionName: lambdaName }),
814
+ );
815
+ } catch (err) {
816
+ if (err.name !== 'ResourceNotFoundException') {
817
+ throw err;
818
+ }
819
+ }
820
+
821
+ // Function does not exist yet → create it (returns fast in Pending) and report provisioning.
822
+ if (!cfg) {
823
+ // A brand-new IAM role takes several seconds to propagate before Lambda can assume it.
824
+ // Waiting for that propagation AND running CreateFunction inside this same request can exceed
825
+ // the CDN/gateway ~60s first-byte timeout → the FE sees a 503. So when the role was just
826
+ // created in THIS call, return provisioning immediately and let the next poll do the create:
827
+ // by then GetRole succeeds (roleIsNew === false), the role has had the full poll interval to
828
+ // propagate, and we fall through to CreateFunction below.
829
+ if (roleIsNew) {
830
+ return {
831
+ status: 'provisioning', functionArn: null, roleArn, created: true, versionArn: null,
832
+ };
833
+ }
834
+ let lastErr;
835
+ let createdArn;
836
+ /* eslint-disable no-await-in-loop */
837
+ for (let attempt = 0; attempt < 3; attempt += 1) {
838
+ try {
839
+ const created = await lambda.send(new LambdaCreateFunctionCommand({
840
+ FunctionName: lambdaName,
841
+ Runtime: 'nodejs24.x',
842
+ Role: roleArn,
843
+ Handler: 'index.handler',
844
+ Code: { ZipFile: zipBuffer },
845
+ Description: 'EdgeOptimize origin request/response handler (Lambda@Edge)',
846
+ Timeout: 5,
847
+ MemorySize: 128,
848
+ }));
849
+ createdArn = created.FunctionArn;
850
+ lastErr = null;
851
+ break;
852
+ } catch (createErr) {
853
+ lastErr = createErr;
854
+ // A just-created role may not have propagated yet — short bounded retry, then give up
855
+ // (the next poll will succeed once it propagates) so we never block long.
856
+ const isRolePropagation = createErr.name === 'InvalidParameterValueException'
857
+ && (createErr.message || '').toLowerCase().includes('role');
858
+ if (createErr.name === 'ResourceConflictException') {
859
+ // Created concurrently by a prior (timed-out) call — treat as provisioning.
860
+ lastErr = null;
861
+ break;
862
+ }
863
+ if (!isRolePropagation || attempt >= 2) {
864
+ throw createErr;
865
+ }
866
+ await delay(retryDelayMs);
867
+ }
868
+ }
869
+ /* eslint-enable no-await-in-loop */
870
+ // Defensive: the loop always either breaks (success/conflict) or throws (attempt >= 2 or a
871
+ // non-role-propagation error), so a lingering lastErr here is unreachable in practice.
872
+ /* c8 ignore next 3 */
873
+ if (lastErr) {
874
+ throw lastErr;
875
+ }
876
+ return {
877
+ status: 'provisioning', functionArn: createdArn, roleArn, created: true, versionArn: null,
878
+ };
879
+ }
880
+
881
+ // Still finalizing a create/update → report provisioning, don't touch it (avoids conflicts).
882
+ if (cfg.State === 'Pending' || cfg.LastUpdateStatus === 'InProgress') {
883
+ return {
884
+ status: 'provisioning', functionArn: cfg.FunctionArn, roleArn, created: false, versionArn: null,
885
+ };
886
+ }
887
+
888
+ // Active and idle. If a numbered version already exists, reuse it (idempotent).
889
+ const existingVersion = await getLatestLambdaVersion(lambda, lambdaName);
890
+ if (existingVersion) {
891
+ return {
892
+ status: 'ready',
893
+ functionArn: cfg.FunctionArn,
894
+ versionArn: existingVersion.versionArn,
895
+ version: existingVersion.version,
896
+ roleArn,
897
+ created: false,
898
+ alreadyExisted: true,
899
+ };
900
+ }
901
+
902
+ // Active, idle, no version yet → publish one (fast on an idle function).
903
+ const published = await lambda.send(new PublishVersionCommand({
904
+ FunctionName: lambdaName,
905
+ Description: 'Published by LLM Optimizer CloudFront wizard',
906
+ }));
907
+ return {
908
+ status: 'ready',
909
+ functionArn: cfg.FunctionArn,
910
+ versionArn: published.FunctionArn, // includes the :N version suffix
911
+ version: published.Version,
912
+ roleArn,
913
+ created: false,
914
+ alreadyExisted: false,
915
+ };
916
+ }
917
+
918
+ /**
919
+ * Read-only inspection of the Lambda@Edge execution role: whether it exists and is correctly
920
+ * configured. Checks the trust policy (must allow both lambda + edgelambda) and the CloudWatch-logs
921
+ * inline policy the deploy attaches. Drives both the Review wording and the deploy's heal decision.
922
+ *
923
+ * @param {IAMClient} iam - an IAM client built with the connector credentials.
924
+ * @param {string} roleName - the EO Lambda@Edge execution role name.
925
+ * @returns {Promise<{exists: boolean, trustOk?: boolean, logsPolicyOk?: boolean}>}
926
+ */
927
+ async function inspectEdgeOptimizeLambdaRole(iam, roleName) {
928
+ let role;
929
+ try {
930
+ const res = await iam.send(new GetRoleCommand({ RoleName: roleName }));
931
+ role = res.Role;
932
+ } catch (err) {
933
+ if (err.name === 'NoSuchEntityException') {
934
+ return { exists: false };
935
+ }
936
+ throw err;
937
+ }
938
+
939
+ // Trust must allow both lambda.amazonaws.com and edgelambda.amazonaws.com (Lambda@Edge).
940
+ let trustOk = false;
941
+ const rawTrust = role.AssumeRolePolicyDocument || '';
942
+ if (rawTrust) {
943
+ let doc = null;
944
+ try {
945
+ doc = JSON.parse(decodeURIComponent(rawTrust));
946
+ } catch {
947
+ doc = null;
948
+ }
949
+ const services = ((doc && doc.Statement) || []).flatMap((st) => {
950
+ const svc = st.Principal && st.Principal.Service;
951
+ return Array.isArray(svc) ? svc : [svc];
952
+ }).filter(Boolean);
953
+ trustOk = services.includes('lambda.amazonaws.com')
954
+ && services.includes('edgelambda.amazonaws.com');
955
+ }
956
+
957
+ // The CloudWatch-logs inline policy the deploy attaches.
958
+ let logsPolicyOk = false;
959
+ try {
960
+ await iam.send(new GetRolePolicyCommand({
961
+ RoleName: roleName,
962
+ PolicyName: 'EdgeOptimizeLambdaLogging',
963
+ }));
964
+ logsPolicyOk = true;
965
+ } catch (err) {
966
+ if (err.name !== 'NoSuchEntityException') {
967
+ throw err;
968
+ }
969
+ }
970
+
971
+ return { exists: true, trustOk, logsPolicyOk };
972
+ }
973
+
974
+ /**
975
+ * Read-only status of the Edge Optimize Lambda@Edge function AND its execution role, so the wizard
976
+ * can check on entry (and poll after a slow/timed-out create) whether the function exists and has a
977
+ * published version, and whether the role is present (`roleExists`) and correctly configured
978
+ * (`roleOk` = exists + trust + logs policy). `roleOk` lets the deploy heal a missing or
979
+ * mis-configured role even when the function is already published.
980
+ *
981
+ * @param {object} credentials - temporary credentials from {@link assumeConnectorRole}.
982
+ * @param {string} [region] - control-plane region.
983
+ * @returns {Promise<{exists: boolean, roleExists: boolean, roleOk: boolean, state?: string,
984
+ * lastUpdateStatus?: string, functionArn?: string, versionArn: string|null, version?: string,
985
+ * ready: boolean}>}
986
+ */
987
+ export async function getLambdaAtEdgeStatus(
988
+ credentials,
989
+ distributionId,
990
+ region = EDGE_OPTIMIZE_REGION,
991
+ ) {
992
+ if (!hasText(distributionId)) {
993
+ throw new Error('distributionId is required');
994
+ }
995
+ const lambdaName = eoLambdaFunctionName(distributionId);
996
+ const roleName = eoLambdaRoleName(distributionId);
997
+ const lambda = new LambdaClient({ region, credentials });
998
+ const iam = new IAMClient({ region, credentials });
999
+
1000
+ // Execution role status: present AND correctly configured (trust + logs policy). roleOk gates the
1001
+ // deploy's "done" decision so a missing OR mis-configured role is healed even when the function
1002
+ // is already published.
1003
+ const role = await inspectEdgeOptimizeLambdaRole(iam, roleName);
1004
+ const roleExists = Boolean(role.exists);
1005
+ const roleOk = Boolean(role.exists && role.trustOk && role.logsPolicyOk);
1006
+
1007
+ // Function status.
1008
+ let cfg;
1009
+ try {
1010
+ cfg = await lambda.send(
1011
+ new GetFunctionConfigurationCommand({ FunctionName: lambdaName }),
1012
+ );
1013
+ } catch (err) {
1014
+ if (err.name === 'ResourceNotFoundException') {
1015
+ return {
1016
+ roleExists, roleOk, exists: false, versionArn: null, ready: false,
1017
+ };
1018
+ }
1019
+ throw err;
1020
+ }
1021
+ const latest = await getLatestLambdaVersion(lambda, lambdaName);
1022
+ const ready = cfg.State === 'Active' && cfg.LastUpdateStatus !== 'InProgress' && !!latest;
1023
+ return {
1024
+ roleExists,
1025
+ roleOk,
1026
+ exists: true,
1027
+ state: cfg.State,
1028
+ lastUpdateStatus: cfg.LastUpdateStatus,
1029
+ functionArn: cfg.FunctionArn,
1030
+ versionArn: latest?.versionArn || null,
1031
+ version: latest?.version,
1032
+ ready,
1033
+ };
1034
+ }
1035
+
1036
+ // Edge Optimize owns exactly these association slots on a behavior; every other association is the
1037
+ // customer's and must be preserved. A non-EO association ON one of these slots is a conflict we
1038
+ // refuse (rather than overwrite), so customer edge logic is never silently removed.
1039
+ const EDGE_OPTIMIZE_LAMBDA_EVENTS = ['origin-request', 'origin-response'];
1040
+ const isEdgeOptimizeFunctionArn = (arn) => /edgeoptimize-routing/i.test(arn || '');
1041
+ const isEdgeOptimizeLambdaArn = (arn) => /edgeoptimize-origin/i.test(arn || '');
1042
+
1043
+ /**
1044
+ * Inspect a behavior's existing associations and return a conflict message when a NON-Edge-Optimize
1045
+ * association occupies a slot EO needs (a different viewer-request function, a viewer-request
1046
+ * Lambda@Edge that CloudFront forbids alongside a function, or an origin-request/origin-response
1047
+ * Lambda@Edge). Returns null when EO can be wired in while preserving everything else. EO's own
1048
+ * prior associations (matched by name) are never flagged, so re-deploys stay idempotent.
1049
+ *
1050
+ * @param {object} behavior - the cache behavior config.
1051
+ * @param {string} pathPattern - the behavior label (for the message).
1052
+ * @returns {string|null}
1053
+ */
1054
+ function findEdgeOptimizeAssociationConflict(behavior, pathPattern) {
1055
+ const fns = behavior?.FunctionAssociations?.Items || [];
1056
+ const lambdas = behavior?.LambdaFunctionAssociations?.Items || [];
1057
+
1058
+ const viewerFn = fns.find(
1059
+ (a) => a.EventType === 'viewer-request' && !isEdgeOptimizeFunctionArn(a.FunctionARN),
1060
+ );
1061
+ if (viewerFn) {
1062
+ return `Behavior '${pathPattern}' already has a different viewer-request function associated `
1063
+ + `(${viewerFn.FunctionARN}). Remove it before applying Edge Optimize routing.`;
1064
+ }
1065
+ const viewerLambda = lambdas.find((a) => a.EventType === 'viewer-request');
1066
+ if (viewerLambda) {
1067
+ return `Behavior '${pathPattern}' already has a viewer-request Lambda@Edge `
1068
+ + `(${viewerLambda.LambdaFunctionARN}) which conflicts with the Edge Optimize routing `
1069
+ + 'function. Remove it before applying Edge Optimize routing.';
1070
+ }
1071
+ const originLambda = lambdas.find(
1072
+ (a) => EDGE_OPTIMIZE_LAMBDA_EVENTS.includes(a.EventType)
1073
+ && !isEdgeOptimizeLambdaArn(a.LambdaFunctionARN),
1074
+ );
1075
+ if (originLambda) {
1076
+ return `Behavior '${pathPattern}' already has a different ${originLambda.EventType} `
1077
+ + `Lambda@Edge associated (${originLambda.LambdaFunctionARN}). Remove it before applying `
1078
+ + 'Edge Optimize routing.';
1079
+ }
1080
+ return null;
1081
+ }
1082
+
1083
+ /**
1084
+ * Wire the routing CloudFront Function (viewer-request) and the Lambda@Edge function
1085
+ * (origin-request + origin-response) onto a selected cache behavior. Mirrors the standalone
1086
+ * wizard's apply-associations step.
1087
+ *
1088
+ * @param {object} credentials - temporary credentials from {@link assumeConnectorRole}.
1089
+ * @param {string} distributionId - the CloudFront distribution ID.
1090
+ * @param {string} pathPattern - the behavior to wire (`default` for the default behavior).
1091
+ * @param {string} lambdaVersionArn - the published, versioned Lambda@Edge ARN.
1092
+ * @param {string} [region] - CloudFront control-plane region.
1093
+ * @returns {Promise<{cloudFrontFunctionArn: string, lambdaArn: string}>}
1094
+ */
1095
+ export async function applyAssociations(
1096
+ credentials,
1097
+ distributionId,
1098
+ pathPattern,
1099
+ lambdaVersionArn,
1100
+ region = EDGE_OPTIMIZE_REGION,
1101
+ ) {
1102
+ if (!hasText(distributionId)) {
1103
+ throw new Error('distributionId is required');
1104
+ }
1105
+ if (!hasText(pathPattern)) {
1106
+ throw new Error('pathPattern is required');
1107
+ }
1108
+ if (!hasText(lambdaVersionArn)) {
1109
+ throw new Error('lambdaVersionArn is required');
1110
+ }
1111
+ const client = new CloudFrontClient({ region, credentials });
1112
+ const functionName = eoRoutingFunctionName(distributionId);
1113
+
1114
+ const fnResult = await client.send(new DescribeFunctionCommand({
1115
+ Name: functionName,
1116
+ Stage: 'LIVE',
1117
+ }));
1118
+ const cloudFrontFunctionArn = fnResult.FunctionSummary?.FunctionMetadata?.FunctionARN;
1119
+ if (!cloudFrontFunctionArn) {
1120
+ throw new Error(`CloudFront function '${functionName}' not found or not published to LIVE`);
1121
+ }
1122
+
1123
+ const distResult = await client.send(new GetDistributionConfigCommand({ Id: distributionId }));
1124
+ const config = distResult.DistributionConfig;
1125
+ const behavior = getBehaviorFromConfig(config, pathPattern);
1126
+
1127
+ // Refuse (rather than silently clobber) if the customer already owns a slot EO needs.
1128
+ const conflict = findEdgeOptimizeAssociationConflict(behavior, pathPattern);
1129
+ if (conflict) {
1130
+ throw new Error(conflict);
1131
+ }
1132
+
1133
+ // Merge, don't replace: preserve every association on event types EO does NOT own (e.g. a
1134
+ // viewer-response function, a viewer-response lambda) and (re)set ONLY EO's own slots —
1135
+ // viewer-request (function) + origin-request/origin-response (lambda). Wholesale replacement here
1136
+ // would drop the customer's edge logic.
1137
+ const existingFns = behavior.FunctionAssociations?.Items || [];
1138
+ const existingLambdas = behavior.LambdaFunctionAssociations?.Items || [];
1139
+ const mergedFns = [
1140
+ ...existingFns.filter((a) => a.EventType !== 'viewer-request'),
1141
+ { FunctionARN: cloudFrontFunctionArn, EventType: 'viewer-request' },
1142
+ ];
1143
+ const mergedLambdas = [
1144
+ ...existingLambdas.filter(
1145
+ (a) => a.EventType !== 'viewer-request' && !EDGE_OPTIMIZE_LAMBDA_EVENTS.includes(a.EventType),
1146
+ ),
1147
+ { LambdaFunctionARN: lambdaVersionArn, EventType: 'origin-request', IncludeBody: false },
1148
+ { LambdaFunctionARN: lambdaVersionArn, EventType: 'origin-response', IncludeBody: false },
1149
+ ];
1150
+ behavior.FunctionAssociations = { Quantity: mergedFns.length, Items: mergedFns };
1151
+ behavior.LambdaFunctionAssociations = { Quantity: mergedLambdas.length, Items: mergedLambdas };
1152
+
1153
+ await client.send(new UpdateDistributionCommand({
1154
+ Id: distributionId,
1155
+ IfMatch: distResult.ETag,
1156
+ DistributionConfig: config,
1157
+ }));
1158
+
1159
+ return { cloudFrontFunctionArn, lambdaArn: lambdaVersionArn };
1160
+ }
1161
+
1162
+ // Bounded per-probe timeout for the verify fetches. 20s is generous enough for a slow/cold
1163
+ // `ChatGPT-User` prerender response, yet safely under the ~60s CDN/gateway first-byte budget — so a
1164
+ // hung origin can never block the request long enough to cascade into a gateway 503.
1165
+ export const EDGE_OPTIMIZE_VERIFY_PROBE_TIMEOUT_MS = 20000;
1166
+
1167
+ async function fetchEdgeOptimizeHeaders(url, userAgent) {
1168
+ // Abort the probe if the origin does not respond within the bounded timeout. On timeout/abort OR
1169
+ // any network error we resolve to a NON-passing result ({ status: 0, headers: {} }) instead of
1170
+ // throwing: verifyRouting then returns passed:false and the FE's poll loop simply
1171
+ // retries on the next poll (verification persistence lives in the poll loop, not one fetch — a
1172
+ // slow origin just spans more polls).
1173
+ const controller = new AbortController();
1174
+ const timer = setTimeout(() => controller.abort(), EDGE_OPTIMIZE_VERIFY_PROBE_TIMEOUT_MS);
1175
+ try {
1176
+ const response = await fetch(url, {
1177
+ redirect: 'manual',
1178
+ headers: { 'user-agent': userAgent },
1179
+ signal: controller.signal,
1180
+ });
1181
+ const headers = {};
1182
+ response.headers.forEach((value, key) => {
1183
+ if (key.toLowerCase().startsWith('x-edgeoptimize')) {
1184
+ headers[key.toLowerCase()] = value;
1185
+ }
1186
+ });
1187
+ // Drain the body so the connection can be reused/closed.
1188
+ await response.arrayBuffer().catch(() => {});
1189
+ return { status: response.status, headers };
1190
+ } catch (err) {
1191
+ // `timedOut` lets callers distinguish "still warming up" (abort) from a hard network failure.
1192
+ const timedOut = err?.name === 'AbortError';
1193
+ return { status: 0, headers: {}, ...(timedOut ? { timedOut: true } : {}) };
1194
+ // The catch always returns (never rethrows), so V8 marks the finally's exceptional-entry branch
1195
+ // as unreachable — suppress just that phantom branch; clearTimeout itself is exercised.
1196
+ /* c8 ignore next */
1197
+ } finally {
1198
+ clearTimeout(timer);
1199
+ }
1200
+ }
1201
+
1202
+ /**
1203
+ * Verify Edge Optimize routing end-to-end by fetching the distribution domain as an agentic bot
1204
+ * and as a human, then inspecting the `x-edgeoptimize-*` headers. Mirrors the standalone wizard's
1205
+ * verify logic: success REQUIRES `x-edgeoptimize-request-id` on the bot response (served from the
1206
+ * Edge Optimize prerender cache). `x-edgeoptimize-fo` means failover to origin — routing worked but
1207
+ * the page is NOT optimised, which is NOT success.
1208
+ *
1209
+ * @param {string} url - the URL to probe (typically `https://<distribution-domain>/`).
1210
+ * @returns {Promise<{passed: boolean, requestId: string|null,
1211
+ * details: {bot: object, human: object}}>}
1212
+ */
1213
+ export async function verifyRouting(url) {
1214
+ if (!hasText(url)) {
1215
+ throw new Error('url is required');
1216
+ }
1217
+ const botUa = 'chatgpt-user';
1218
+ const humanUa = 'Mozilla/5.0';
1219
+ const [bot, human] = await Promise.all([
1220
+ fetchEdgeOptimizeHeaders(url, botUa),
1221
+ fetchEdgeOptimizeHeaders(url, humanUa),
1222
+ ]);
1223
+
1224
+ const requestId = bot.headers['x-edgeoptimize-request-id'] || null;
1225
+ const passed = Boolean(requestId)
1226
+ && !human.headers['x-edgeoptimize-request-id']
1227
+ && !human.headers['x-edgeoptimize-fo']
1228
+ && human.headers['x-edgeoptimize-proxy'] !== '1';
1229
+
1230
+ // `ua` is carried through so the wizard can show which User-Agent each probe used.
1231
+ return {
1232
+ passed,
1233
+ requestId,
1234
+ details: { bot: { ua: botUa, ...bot }, human: { ua: humanUa, ...human } },
1235
+ };
1236
+ }
1237
+
1238
+ // The ordered deploy steps + their human labels, in the sequence the orchestrator advances them.
1239
+ // Exported so the controller/tests can assert the contract without re-declaring it.
1240
+ export const EDGE_OPTIMIZE_DEPLOY_STEPS = [
1241
+ { key: 'origin', label: 'Edge Optimize origin' },
1242
+ { key: 'function', label: 'Routing function' },
1243
+ { key: 'cache', label: 'Cache policy' },
1244
+ { key: 'lambda', label: 'Lambda@Edge' },
1245
+ { key: 'associate', label: 'Association' },
1246
+ { key: 'propagation', label: 'Propagation' },
1247
+ { key: 'verify', label: 'Verify routing' },
1248
+ ];
1249
+
1250
+ /**
1251
+ * True when the `edgeoptimize-routing` CloudFront Function is already published to LIVE.
1252
+ * Used to gate the function step so we never re-publish (which causes deploy churn).
1253
+ *
1254
+ * @param {CloudFrontClient} client - a CloudFront client built with the connector credentials.
1255
+ * @returns {Promise<boolean>}
1256
+ */
1257
+ async function isRoutingFunctionLive(client, distributionId) {
1258
+ try {
1259
+ const desc = await client.send(new DescribeFunctionCommand({
1260
+ Name: eoRoutingFunctionName(distributionId),
1261
+ Stage: 'LIVE',
1262
+ }));
1263
+ return Boolean(desc?.FunctionSummary?.FunctionMetadata?.FunctionARN);
1264
+ } catch (err) {
1265
+ if (err.name === 'NoSuchFunctionExists') {
1266
+ return false;
1267
+ }
1268
+ throw err;
1269
+ }
1270
+ }
1271
+
1272
+ /**
1273
+ * True when the target behavior already has BOTH the Edge Optimize routing CloudFront Function
1274
+ * (viewer-request) AND the Edge Optimize Lambda@Edge (origin-request) associated. Used to gate the
1275
+ * associate step so we never re-issue UpdateDistribution (needless re-deploy) once wired.
1276
+ *
1277
+ * @param {CloudFrontClient} client - a CloudFront client built with the connector credentials.
1278
+ * @param {string} distributionId - the CloudFront distribution ID.
1279
+ * @param {string} pathPattern - the behavior to inspect (`default` for the default behavior).
1280
+ * @returns {Promise<boolean>}
1281
+ */
1282
+ async function isBehaviorAlreadyAssociated(client, distributionId, pathPattern) {
1283
+ const result = await client.send(new GetDistributionConfigCommand({ Id: distributionId }));
1284
+ const config = result.DistributionConfig || {};
1285
+ let behavior;
1286
+ if (pathPattern === 'default' || pathPattern === 'Default (*)') {
1287
+ behavior = config.DefaultCacheBehavior;
1288
+ } else {
1289
+ behavior = (config.CacheBehaviors?.Items || []).find((b) => b.PathPattern === pathPattern);
1290
+ }
1291
+ if (!behavior) {
1292
+ return false;
1293
+ }
1294
+ const hasCfFunction = (behavior.FunctionAssociations?.Items || []).some(
1295
+ (a) => a.EventType === 'viewer-request' && /edgeoptimize-routing/i.test(a.FunctionARN || ''),
1296
+ );
1297
+ const hasLambda = (behavior.LambdaFunctionAssociations?.Items || []).some(
1298
+ (a) => a.EventType === 'origin-request' && /edgeoptimize-origin/i.test(a.LambdaFunctionARN || ''),
1299
+ );
1300
+ return hasCfFunction && hasLambda;
1301
+ }
1302
+
1303
+ /**
1304
+ * Run one poll of the idempotent Edge Optimize "Deploy routing" orchestrator.
1305
+ *
1306
+ * Advances the deploy sequence (origin → function → cache → lambda → associate → verify) as far as
1307
+ * it safely can in a single call, staying well under the CDN/gateway ~60s first-byte timeout. Each
1308
+ * step is gated so a re-poll never re-mutates already-completed work (no CloudFront re-deploy
1309
+ * churn, no CF-function re-publish). Designed to be called once and then polled every ~30s by the
1310
+ * wizard UI: each call returns the per-step status and the FE keeps polling until verify is green.
1311
+ *
1312
+ * Stops advancing (returning earlier steps' real status and later steps `pending`) when the
1313
+ * Lambda@Edge is still provisioning — the next poll re-checks. A step that throws is marked
1314
+ * `error` on its own row (with later steps `pending`); the caller still returns HTTP 200 so the FE
1315
+ * shows the failure on that row and a re-poll retries idempotently.
1316
+ *
1317
+ * @param {object} credentials - temporary credentials from {@link assumeConnectorRole}.
1318
+ * @param {object} params
1319
+ * @param {string} params.distributionId - the CloudFront distribution ID.
1320
+ * @param {string} params.originId - the default-behavior target origin id (failover origin).
1321
+ * @param {string} params.behavior - the cache behavior to target (`default` for the default).
1322
+ * @param {string} [params.originDomain] - the Edge Optimize origin domain.
1323
+ * @param {object} [params.originHeaders] - EO origin headers ({ apiKey, forwardedHost }).
1324
+ * @param {string} params.accountId - the 12-digit customer AWS account ID.
1325
+ * @param {string} [region] - CloudFront control-plane region.
1326
+ * @returns {Promise<{routingDeployed: boolean, verified: boolean, steps: Array<object>}>}
1327
+ */
1328
+ export async function runDeployStep(
1329
+ credentials,
1330
+ {
1331
+ distributionId, originId, behavior, originDomain, originHeaders, accountId,
1332
+ },
1333
+ region = EDGE_OPTIMIZE_REGION,
1334
+ ) {
1335
+ // Start every step pending; each handler flips its own row to done/in_progress/error.
1336
+ const steps = EDGE_OPTIMIZE_DEPLOY_STEPS.map((s) => ({ ...s, status: 'pending' }));
1337
+ const byKey = (key) => steps.find((s) => s.key === key);
1338
+ const client = new CloudFrontClient({ region, credentials });
1339
+
1340
+ let routingDeployed = false;
1341
+ let verified = false;
1342
+ let lambdaVersionArn = null;
1343
+
1344
+ // ── 1. origin — already idempotent (no UpdateDistribution when headers match). ──
1345
+ try {
1346
+ await createOrigin(
1347
+ credentials,
1348
+ distributionId,
1349
+ originDomain,
1350
+ originHeaders,
1351
+ region,
1352
+ );
1353
+ byKey('origin').status = 'done';
1354
+ } catch (err) {
1355
+ byKey('origin').status = 'error';
1356
+ byKey('origin').detail = err.message;
1357
+ return { routingDeployed, verified, steps };
1358
+ }
1359
+
1360
+ // ── 2. function — GATE: skip the create+publish when already LIVE (avoids re-publish churn). ──
1361
+ try {
1362
+ if (await isRoutingFunctionLive(client, distributionId)) {
1363
+ byKey('function').status = 'done';
1364
+ } else {
1365
+ await createCloudFrontFunction(credentials, originId, distributionId, null, region);
1366
+ byKey('function').status = 'done';
1367
+ }
1368
+ } catch (err) {
1369
+ byKey('function').status = 'error';
1370
+ byKey('function').detail = err.message;
1371
+ return { routingDeployed, verified, steps };
1372
+ }
1373
+
1374
+ // ── 3. cache — idempotent (skips UpdateDistribution/UpdateCachePolicy when already applied). ──
1375
+ try {
1376
+ await updateCacheSettings(credentials, distributionId, behavior, { region });
1377
+ byKey('cache').status = 'done';
1378
+ } catch (err) {
1379
+ byKey('cache').status = 'error';
1380
+ byKey('cache').detail = err.message;
1381
+ return { routingDeployed, verified, steps };
1382
+ }
1383
+
1384
+ // ── 4. lambda — drive the create/publish state machine each poll (idempotent + non-blocking). ──
1385
+ // createLambdaAtEdge creates the function when missing, no-ops while it is Pending, and —
1386
+ // crucially — PUBLISHES a numbered version once the function is Active (which is what flips it to
1387
+ // ready). We must call it on EVERY not-ready poll, not only when the function is missing: the
1388
+ // version is published on a later poll (after the function reaches Active), so if we merely
1389
+ // status-check while it "exists", the version never gets published and the step hangs at
1390
+ // "provisioning" forever.
1391
+ try {
1392
+ const ls = await getLambdaAtEdgeStatus(credentials, distributionId, region);
1393
+ // Done only when the function is ready AND the role is present + correctly configured. If the
1394
+ // role is missing or mis-configured (even with a published function), fall through to
1395
+ // createLambdaAtEdge — it (re)creates the role + heals its trust/logs policy; the
1396
+ // function already exists so it just reuses the published version and returns ready.
1397
+ if (ls.ready && ls.roleOk) {
1398
+ lambdaVersionArn = ls.versionArn;
1399
+ byKey('lambda').status = 'done';
1400
+ } else {
1401
+ const created = await createLambdaAtEdge(
1402
+ credentials,
1403
+ accountId,
1404
+ { region, distributionId, originDomain },
1405
+ );
1406
+ if (created.status === 'ready') {
1407
+ lambdaVersionArn = created.versionArn;
1408
+ byKey('lambda').status = 'done';
1409
+ } else {
1410
+ byKey('lambda').status = 'in_progress';
1411
+ byKey('lambda').detail = ls.exists
1412
+ ? 'Lambda@Edge is still provisioning'
1413
+ : 'Lambda@Edge create started';
1414
+ return { routingDeployed, verified, steps };
1415
+ }
1416
+ }
1417
+ } catch (err) {
1418
+ byKey('lambda').status = 'error';
1419
+ byKey('lambda').detail = err.message;
1420
+ return { routingDeployed, verified, steps };
1421
+ }
1422
+
1423
+ // ── 5. associate — GATE: skip UpdateDistribution when the behavior is already wired. ──
1424
+ try {
1425
+ if (await isBehaviorAlreadyAssociated(client, distributionId, behavior)) {
1426
+ byKey('associate').status = 'done';
1427
+ } else {
1428
+ await applyAssociations(
1429
+ credentials,
1430
+ distributionId,
1431
+ behavior,
1432
+ lambdaVersionArn,
1433
+ region,
1434
+ );
1435
+ byKey('associate').status = 'done';
1436
+ }
1437
+ routingDeployed = true;
1438
+ } catch (err) {
1439
+ byKey('associate').status = 'error';
1440
+ byKey('associate').detail = err.message;
1441
+ return { routingDeployed, verified, steps };
1442
+ }
1443
+
1444
+ // ── 6. propagation — GATE: wait for the distribution to finish deploying before we verify. ──
1445
+ // CloudFront reports `Status: 'InProgress'` while it propagates the new behavior/Lambda globally
1446
+ // (the console shows "Deploying"); once `Deployed`, edge nodes have the change. Verifying before
1447
+ // that just churns, so we hold here and surface the propagation status. A direct GetDistribution
1448
+ // (instead of scanning the paginated list) reads this distribution's Status/DomainName in one
1449
+ // call; distDomain is reused by the verify step.
1450
+ let distDomain = '';
1451
+ try {
1452
+ const distResult = await client.send(new GetDistributionCommand({ Id: distributionId }));
1453
+ const distribution = distResult?.Distribution;
1454
+ distDomain = distribution?.DomainName || '';
1455
+ if (!distribution) {
1456
+ byKey('propagation').status = 'in_progress';
1457
+ byKey('propagation').detail = 'waiting for the distribution to appear';
1458
+ return { routingDeployed, verified, steps };
1459
+ }
1460
+ if (distribution.Status !== 'Deployed') {
1461
+ byKey('propagation').status = 'in_progress';
1462
+ byKey('propagation').detail = `Deploying — CloudFront is propagating the change globally (status: ${distribution.Status})`;
1463
+ return { routingDeployed, verified, steps };
1464
+ }
1465
+ byKey('propagation').status = 'done';
1466
+ byKey('propagation').detail = 'Propagated — the change is live on all edge locations';
1467
+ } catch (err) {
1468
+ byKey('propagation').status = 'in_progress';
1469
+ byKey('propagation').detail = err.message;
1470
+ return { routingDeployed, verified, steps };
1471
+ }
1472
+
1473
+ // ── 7. verify — BEST-EFFORT: in_progress (not error) until the agentic probe is optimized. ──
1474
+ try {
1475
+ const domain = String(originHeaders?.forwardedHost || '').trim() || distDomain;
1476
+ if (!hasText(domain)) {
1477
+ byKey('verify').status = 'in_progress';
1478
+ byKey('verify').detail = 'waiting for domain';
1479
+ return { routingDeployed, verified, steps };
1480
+ }
1481
+ const result = await verifyRouting(`https://${domain}/`);
1482
+ // Per-probe summary the wizard renders (Human vs Agentic): UA, HTTP status, the
1483
+ // x-edgeoptimize-request-id value (or null), and whether it failed over to the origin.
1484
+ const toProbe = (d) => ({
1485
+ ua: d.ua,
1486
+ status: d.status,
1487
+ requestId: d.headers['x-edgeoptimize-request-id'] || null,
1488
+ failover: Boolean(d.headers['x-edgeoptimize-fo']),
1489
+ });
1490
+ byKey('verify').probe = {
1491
+ domain,
1492
+ bot: toProbe(result.details.bot),
1493
+ human: toProbe(result.details.human),
1494
+ };
1495
+ if (result.passed) {
1496
+ verified = true;
1497
+ byKey('verify').status = 'done';
1498
+ byKey('verify').detail = `Agentic routing verified — x-edgeoptimize-request-id: ${result.requestId}`;
1499
+ } else if (result.details.bot.headers['x-edgeoptimize-fo'] || result.details.human.headers['x-edgeoptimize-fo']) {
1500
+ byKey('verify').status = 'in_progress';
1501
+ byKey('verify').detail = 'Edge Optimize returned failover (x-edgeoptimize-fo) — serving the origin, not optimized; still retrying';
1502
+ } else {
1503
+ byKey('verify').status = 'in_progress';
1504
+ byKey('verify').detail = 'waiting for propagation';
1505
+ }
1506
+ /* c8 ignore start */
1507
+ } catch (err) {
1508
+ // Defensive only: the verify probes are now bounded (a timeout/abort or network error resolves
1509
+ // to a non-passing probe instead of throwing), so verifyRouting no longer rejects
1510
+ // for a valid URL and this branch is unreachable in practice. Kept as a safety net so a future
1511
+ // throw here can never fail the whole deploy — surface as in_progress and let the poll retry.
1512
+ byKey('verify').status = 'in_progress';
1513
+ byKey('verify').detail = err.message;
1514
+ }
1515
+ /* c8 ignore stop */
1516
+
1517
+ return { routingDeployed, verified, steps };
1518
+ }
1519
+
1520
+ /**
1521
+ * Read-only "preview" of what {@link runDeployStep} would do, without mutating
1522
+ * anything. Powers the wizard's "Review & Deploy" screen: it inspects the distribution config,
1523
+ * the attached cache policy, the routing CloudFront Function, and the Lambda@Edge function, and
1524
+ * returns a per-step plan (create | exists | update | blocked) plus an overall canProceed/blocker.
1525
+ *
1526
+ * Only reads are issued (GetDistributionConfig, ListCachePolicies, DescribeFunction,
1527
+ * GetFunctionConfiguration/ListVersions via the existing gates). It is intentionally defensive:
1528
+ * a missing resource is "create", and a read that genuinely errors is surfaced in that step's
1529
+ * detail while still allowing the plan to proceed — the ONLY hard blocker is a behavior that is
1530
+ * already associated with EO routes (that is the one case the automation refuses to touch).
1531
+ *
1532
+ * @param {object} credentials - temporary credentials from {@link assumeConnectorRole}.
1533
+ * @param {object} params
1534
+ * @param {string} params.distributionId - the CloudFront distribution ID.
1535
+ * @param {string} params.originId - the default-behavior target origin id (failover origin).
1536
+ * @param {string} params.behavior - the cache behavior to target (`default` for the default).
1537
+ * @param {string} [params.originDomain] - the Edge Optimize origin domain.
1538
+ * @param {object} [params.originHeaders] - EO origin headers ({ apiKey, forwardedHost }).
1539
+ * @param {string} [params.accountId] - the 12-digit customer AWS account ID.
1540
+ * @param {string} [region] - CloudFront control-plane region.
1541
+ * @returns {Promise<{canProceed: boolean, blocker: string|null,
1542
+ * steps: Array<{key: string, label: string, action: string, detail: string}>}>}
1543
+ */
1544
+ export async function planDeploy(
1545
+ credentials,
1546
+ {
1547
+ distributionId, behavior, originDomain = EDGE_OPTIMIZE_DEFAULT_ORIGIN_DOMAIN, originHeaders,
1548
+ },
1549
+ region = EDGE_OPTIMIZE_REGION,
1550
+ ) {
1551
+ if (!hasText(distributionId)) {
1552
+ throw new Error('distributionId is required');
1553
+ }
1554
+ if (!hasText(behavior)) {
1555
+ throw new Error('behavior is required');
1556
+ }
1557
+ const client = new CloudFrontClient({ region, credentials });
1558
+
1559
+ // Plan rows mirror EDGE_OPTIMIZE_DEPLOY_STEPS (sans `verify`, which is a post-deploy probe).
1560
+ // The `|| key` fallback is defensive — labelOf is only ever called with keys present in
1561
+ // EDGE_OPTIMIZE_DEPLOY_STEPS, so the missing-label case is unreachable.
1562
+ /* c8 ignore next */
1563
+ const labelOf = (key) => EDGE_OPTIMIZE_DEPLOY_STEPS.find((s) => s.key === key)?.label || key;
1564
+ const steps = ['origin', 'function', 'cache', 'lambda', 'associate'].map((key) => ({
1565
+ key, label: labelOf(key), action: 'create', detail: '',
1566
+ }));
1567
+ const byKey = (key) => steps.find((s) => s.key === key);
1568
+
1569
+ let canProceed = true;
1570
+ let blocker = null;
1571
+
1572
+ // Read the distribution config ONCE for the origin + cache inspections.
1573
+ let config = null;
1574
+ try {
1575
+ const distResult = await client.send(new GetDistributionConfigCommand({ Id: distributionId }));
1576
+ config = distResult.DistributionConfig || null;
1577
+ } catch (err) {
1578
+ // A read failure here doesn't block — surface it on the origin/cache rows and keep going.
1579
+ byKey('origin').detail = `could not read distribution config: ${err.message}`;
1580
+ byKey('cache').detail = `could not read distribution config: ${err.message}`;
1581
+ }
1582
+
1583
+ // ── origin ──────────────────────────────────────────────────────────────
1584
+ // 'exists' when the EO origin is already present WITH the required custom headers; otherwise
1585
+ // 'create' (a header-less existing origin still needs the headers patched → treated as create).
1586
+ const desiredHeaderItems = buildEdgeOptimizeOriginHeaders(originHeaders || {});
1587
+ if (config) {
1588
+ const origins = config.Origins?.Items || [];
1589
+ const existing = origins.find((o) => o.Id === EDGE_OPTIMIZE_ORIGIN_ID);
1590
+ const existingDomainOrigin = origins.find((o) => o.DomainName === originDomain);
1591
+ if (!existing && existingDomainOrigin) {
1592
+ byKey('origin').action = 'blocked';
1593
+ byKey('origin').detail = `An origin for ${originDomain} already exists as ${existingDomainOrigin.Id}; refusing to reuse a non-Edge Optimize origin`;
1594
+ canProceed = false;
1595
+ blocker = byKey('origin').detail;
1596
+ } else if (existing) {
1597
+ const toMap = (arr) => (arr || []).reduce((acc, h) => {
1598
+ acc[h.HeaderName.toLowerCase()] = h.HeaderValue;
1599
+ return acc;
1600
+ }, {});
1601
+ const current = toMap(existing.CustomHeaders?.Items);
1602
+ const desired = toMap(desiredHeaderItems);
1603
+ const headersMatch = desiredHeaderItems.length === 0
1604
+ || (Object.keys(desired).length === Object.keys(current).length
1605
+ && Object.entries(desired).every(([k, v]) => current[k] === v));
1606
+ if (headersMatch) {
1607
+ byKey('origin').action = 'exists';
1608
+ byKey('origin').detail = `Edge Optimize origin already present (${existing.DomainName})`;
1609
+ } else {
1610
+ byKey('origin').detail = `patch Edge Optimize origin headers (${existing.DomainName})`;
1611
+ }
1612
+ } else {
1613
+ byKey('origin').detail = `add Edge Optimize origin (${originDomain})`;
1614
+ }
1615
+ } else if (!byKey('origin').detail) {
1616
+ byKey('origin').detail = `add Edge Optimize origin (${originDomain})`;
1617
+ }
1618
+
1619
+ // ── function ────────────────────────────────────────────────────────────
1620
+ // 'exists' when the routing CloudFront Function is already published to LIVE.
1621
+ try {
1622
+ if (await isRoutingFunctionLive(client, distributionId)) {
1623
+ byKey('function').action = 'exists';
1624
+ byKey('function').detail = `routing function ${eoRoutingFunctionName(distributionId)} already published to LIVE`;
1625
+ } else {
1626
+ byKey('function').detail = `create routing function ${eoRoutingFunctionName(distributionId)}`;
1627
+ }
1628
+ } catch (err) {
1629
+ byKey('function').detail = `could not read routing function status: ${err.message}`;
1630
+ }
1631
+
1632
+ // ── cache ───────────────────────────────────────────────────────────────
1633
+ // Detect the scenario the deploy would hit (legacy / custom / managed) and describe it without
1634
+ // mutating. Mirrors updateCacheSettings' detection logic.
1635
+ try {
1636
+ if (!config) {
1637
+ throw new Error('distribution config unavailable');
1638
+ }
1639
+ const targetBehavior = getBehaviorFromConfig(config, behavior);
1640
+ const policyId = targetBehavior.CachePolicyId;
1641
+ // Only mention the MinTTL change when it would ACTUALLY change — i.e. the current MinTTL is
1642
+ // above the keep threshold (<= 5s is left as-is). Empty string otherwise so we don't show it.
1643
+ const ttlNote = (currentMinTtl) => (
1644
+ Number(currentMinTtl ?? 0) > EDGE_OPTIMIZE_MIN_TTL_KEEP_THRESHOLD
1645
+ ? ' Minimum TTL will be set to 0.'
1646
+ : ''
1647
+ );
1648
+ if (!policyId) {
1649
+ // Legacy: ForwardedValues. 'exists' when EO headers are already forwarded.
1650
+ const fv = targetBehavior.ForwardedValues || {};
1651
+ const lower = (fv.Headers?.Items || []).map((x) => x.toLowerCase());
1652
+ const allForwarded = lower.includes('*')
1653
+ || EDGE_OPTIMIZE_CACHE_HEADERS.every((h) => lower.includes(h));
1654
+ if (allForwarded) {
1655
+ byKey('cache').action = 'exists';
1656
+ byKey('cache').detail = 'This behavior already forwards the Edge Optimize headers.';
1657
+ } else {
1658
+ byKey('cache').action = 'update';
1659
+ byKey('cache').detail = `Add the Edge Optimize headers to this behavior.${ttlNote(targetBehavior.MinTTL)}`;
1660
+ }
1661
+ } else {
1662
+ const managedList = await client.send(new ListCachePoliciesCommand({ Type: 'managed' }));
1663
+ const managedIds = new Set(
1664
+ (managedList.CachePolicyList?.Items || []).map((i) => i.CachePolicy.Id),
1665
+ );
1666
+ const isManaged = managedIds.has(policyId);
1667
+ if (!isManaged) {
1668
+ // Custom policy → updated in place (idempotent). If our headers are already in the cache
1669
+ // key AND the MinTTL won't change, it is already configured (e.g. our own clone from a
1670
+ // prior deploy) → 'No change'; otherwise 'update'. Mirrors updateCacheSettings.
1671
+ const pcResult = await client.send(new GetCachePolicyConfigCommand({ Id: policyId }));
1672
+ const pc = pcResult.CachePolicyConfig || {};
1673
+ const hc = pc.ParametersInCacheKeyAndForwardedToOrigin?.HeadersConfig || {};
1674
+ const headerItems = (hc.Headers?.Items || []).map((x) => x.toLowerCase());
1675
+ const headersPresent = hc.HeaderBehavior === 'allViewer' || hc.HeaderBehavior === 'all'
1676
+ || EDGE_OPTIMIZE_CACHE_HEADERS.every((h) => headerItems.includes(h));
1677
+ const ttlChange = ttlNote(pc.MinTTL);
1678
+ if (headersPresent && !ttlChange) {
1679
+ byKey('cache').action = 'exists';
1680
+ byKey('cache').detail = `Current policy: ${pc.Name || 'custom'}. Already has the Edge Optimize headers.`;
1681
+ } else {
1682
+ byKey('cache').action = 'update';
1683
+ byKey('cache').detail = `Current policy: ${pc.Name || 'custom'}. Add the Edge Optimize headers in place.${ttlChange}`;
1684
+ }
1685
+ } else {
1686
+ // Managed → must clone. 'exists' when the per-dist clone already exists (idempotent).
1687
+ const srcResult = await client.send(new GetCachePolicyCommand({ Id: policyId }));
1688
+ const srcConfig = srcResult.CachePolicy?.CachePolicyConfig || {};
1689
+ const sourceName = srcConfig.Name || 'cache';
1690
+ const clonedName = buildEoClonedCachePolicyName(sourceName, distributionId);
1691
+ // Match the FULL derived name (exact): <sourceName-without-Managed->-adobe-<distId> — not
1692
+ // just the suffix. If the customer re-pointed the behavior to a different source, a clone
1693
+ // with a different prefix is NOT a match, so the deploy creates one for the current source.
1694
+ const customList = await client.send(new ListCachePoliciesCommand({ Type: 'custom' }));
1695
+ const cloneExists = (customList.CachePolicyList?.Items || []).some(
1696
+ (i) => i.CachePolicy.CachePolicyConfig.Name === clonedName,
1697
+ );
1698
+ if (cloneExists) {
1699
+ // The copy exists from a prior run, but the behavior is still on the AWS-managed policy
1700
+ // (if it were already on the copy we'd be in the custom branch above) — created but not
1701
+ // associated. The deploy will switch the behavior to the copy, so this is an 'update',
1702
+ // not a no-op. Surface both names + that it isn't associated yet.
1703
+ byKey('cache').action = 'update';
1704
+ byKey('cache').detail = `Current policy: ${sourceName} (AWS-managed). A copy `
1705
+ + `"${clonedName}" already exists but is not associated with this behavior `
1706
+ + `yet — the behavior will be switched to it.${ttlNote(srcConfig.MinTTL)}`;
1707
+ } else {
1708
+ byKey('cache').action = 'create';
1709
+ byKey('cache').detail = `Current policy: ${sourceName} (AWS-managed, can't be edited). A copy will be created: ${clonedName}.${ttlNote(srcConfig.MinTTL)}`;
1710
+ }
1711
+ }
1712
+ }
1713
+ } catch (err) {
1714
+ // Don't block the plan on a cache read failure — surface it on the row.
1715
+ byKey('cache').action = 'update';
1716
+ byKey('cache').detail = `could not determine cache scenario: ${err.message}`;
1717
+ }
1718
+
1719
+ // ── lambda ──────────────────────────────────────────────────────────────
1720
+ // 'exists' when the Lambda@Edge function exists (ready or still provisioning); else 'create'.
1721
+ // Also surface the execution role: a role with our name may already exist from a prior partial
1722
+ // run. We say whether it is correctly configured — the deploy ALWAYS conforms it to the required
1723
+ // trust (lambda + edgelambda) + logs policy, so a mismatch is auto-corrected, not a blocker.
1724
+ try {
1725
+ const roleName = eoLambdaRoleName(distributionId);
1726
+ // getLambdaAtEdgeStatus already inspects the role (roleExists + roleOk = trust + logs),
1727
+ // so we derive the role note from it — no separate IAM read needed.
1728
+ const ls = await getLambdaAtEdgeStatus(credentials, distributionId, region);
1729
+ let roleNote;
1730
+ if (!ls.roleExists) {
1731
+ roleNote = ` Execution role ${roleName} will be created.`;
1732
+ } else if (ls.roleOk) {
1733
+ roleNote = ` Execution role ${roleName} already exists and is correctly configured `
1734
+ + '(trust + logs) — it will be reused.';
1735
+ } else {
1736
+ roleNote = ` Execution role ${roleName} already exists but is not correctly configured `
1737
+ + '— the deploy will correct its trust + logs policy.';
1738
+ }
1739
+ if (ls.exists) {
1740
+ byKey('lambda').action = 'exists';
1741
+ byKey('lambda').detail = (ls.ready
1742
+ ? `Lambda@Edge ${eoLambdaFunctionName(distributionId)} already published.`
1743
+ : `Lambda@Edge ${eoLambdaFunctionName(distributionId)} exists (still provisioning).`)
1744
+ + roleNote;
1745
+ } else {
1746
+ byKey('lambda').detail = `create Lambda@Edge ${eoLambdaFunctionName(distributionId)}.${roleNote}`;
1747
+ }
1748
+ } catch (err) {
1749
+ byKey('lambda').detail = `could not read Lambda@Edge status: ${err.message}`;
1750
+ }
1751
+
1752
+ // ── associate ───────────────────────────────────────────────────────────
1753
+ // HARD BLOCK in two cases: (1) the behavior is already EO-associated (nothing to do), or (2) the
1754
+ // customer already owns a slot EO needs (a different viewer-request function, a viewer-request
1755
+ // lambda, or an origin-request/response lambda) — we refuse rather than remove their edge logic.
1756
+ // Otherwise EO is merged in, preserving every other association on the behavior.
1757
+ try {
1758
+ const assocBehavior = config ? getBehaviorFromConfig(config, behavior) : null;
1759
+ const assocConflict = assocBehavior
1760
+ ? findEdgeOptimizeAssociationConflict(assocBehavior, behavior) : null;
1761
+ if (await isBehaviorAlreadyAssociated(client, distributionId, behavior)) {
1762
+ byKey('associate').action = 'blocked';
1763
+ byKey('associate').detail = 'this behaviour is already associated with Edge Optimize routes';
1764
+ canProceed = false;
1765
+ blocker = "This behaviour is already associated with routes, please recheck — can't proceed with this automation.";
1766
+ } else if (assocConflict) {
1767
+ byKey('associate').action = 'blocked';
1768
+ byKey('associate').detail = assocConflict;
1769
+ canProceed = false;
1770
+ blocker = assocConflict;
1771
+ } else {
1772
+ byKey('associate').detail = 'will add the routing function + Lambda@Edge, '
1773
+ + 'preserving your other associations on this behavior';
1774
+ }
1775
+ } catch (err) {
1776
+ byKey('associate').detail = `could not read behavior associations: ${err.message}`;
1777
+ }
1778
+
1779
+ return { canProceed, blocker, steps };
1780
+ }
1781
+
1782
+ /**
1783
+ * Per-request CloudFront control-plane client initialized with short-lived AWS credentials.
1784
+ *
1785
+ * This is a light wrapper over the free functions below: callers still assume the customer
1786
+ * connector role per request, then create one client instance for that customer's operation.
1787
+ */
1788
+ export class CloudFrontEdgeClient {
1789
+ constructor({ credentials, region = EDGE_OPTIMIZE_REGION } = {}) {
1790
+ if (!credentials) {
1791
+ throw new Error('credentials are required');
1792
+ }
1793
+ this.credentials = credentials;
1794
+ this.region = region;
1795
+ }
1796
+
1797
+ listDistributions() {
1798
+ return listDistributions(this.credentials, this.region);
1799
+ }
1800
+
1801
+ getDistributionConfig(distributionId) {
1802
+ return getDistributionConfig(this.credentials, distributionId, this.region);
1803
+ }
1804
+
1805
+ createOrigin(distributionId, originDomain, headers) {
1806
+ return createOrigin(
1807
+ this.credentials,
1808
+ distributionId,
1809
+ originDomain,
1810
+ headers,
1811
+ this.region,
1812
+ );
1813
+ }
1814
+
1815
+ createCloudFrontFunction(defaultOriginId, distributionId, targetedPaths = null) {
1816
+ return createCloudFrontFunction(
1817
+ this.credentials,
1818
+ defaultOriginId,
1819
+ distributionId,
1820
+ targetedPaths,
1821
+ this.region,
1822
+ );
1823
+ }
1824
+
1825
+ updateCacheSettings(distributionId, pathPattern, opts = {}) {
1826
+ return updateCacheSettings(
1827
+ this.credentials,
1828
+ distributionId,
1829
+ pathPattern,
1830
+ { ...opts, region: opts.region || this.region },
1831
+ );
1832
+ }
1833
+
1834
+ createLambdaAtEdge(accountId, opts = {}) {
1835
+ return createLambdaAtEdge(
1836
+ this.credentials,
1837
+ accountId,
1838
+ { ...opts, region: opts.region || this.region },
1839
+ );
1840
+ }
1841
+
1842
+ getLambdaAtEdgeStatus(distributionId) {
1843
+ return getLambdaAtEdgeStatus(this.credentials, distributionId, this.region);
1844
+ }
1845
+
1846
+ applyAssociations(distributionId, pathPattern, lambdaVersionArn) {
1847
+ return applyAssociations(
1848
+ this.credentials,
1849
+ distributionId,
1850
+ pathPattern,
1851
+ lambdaVersionArn,
1852
+ this.region,
1853
+ );
1854
+ }
1855
+
1856
+ runDeployStep(params) {
1857
+ return runDeployStep(this.credentials, params, this.region);
1858
+ }
1859
+
1860
+ planDeploy(params) {
1861
+ return planDeploy(this.credentials, params, this.region);
1862
+ }
1863
+ }