Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
22e0c3c
feat(llmo-akamai): fast plan + deploy-status reconciliation
Aug 7, 2026
b5cf11b
test: point akamai-client at pre-publish gist tarball (TEMPORARY — re…
Aug 7, 2026
34a3896
fix(routes): register akamai deploy-status in the capability maps
Aug 7, 2026
3607776
Merge branch 'main' into fix/akamai-oae-fast-plan-deploy-status
Aug 7, 2026
7738667
test(routes): add akamai deploy-status to the route-handlers snapshot
Aug 7, 2026
696e195
feat(llmo-akamai): confirm a re-onboard write via fetcher-key in depl…
Aug 7, 2026
03bffe9
feat(llmo-akamai): give the deploy PUT a 120s (env-overridable) timeout
Aug 7, 2026
65ed71a
feat(llmo-akamai): block activation of a version with validation errors
Aug 7, 2026
2e268e2
feat(llmo-akamai): honor retryVersion in deploy() to resume, not pile…
Aug 7, 2026
9fa957d
Merge branch 'main' into fix/akamai-oae-fast-plan-deploy-status
Aug 7, 2026
1ef3bc4
feat(llmo-akamai): raise deploy rule-tree timeout 120s → 300s
Aug 10, 2026
f209706
Merge branch 'main' into fix/akamai-oae-fast-plan-deploy-status
ABHA61 Aug 10, 2026
2561b0e
feat(llmo-akamai): deploy-status validate=true reports activatable + …
Aug 10, 2026
efd0682
Merge remote-tracking branch 'origin/fix/akamai-oae-fast-plan-deploy-…
Aug 10, 2026
2a138c1
docs: regenerate bundled docs after merge
Aug 10, 2026
376b71f
fix(llmo-akamai): deploy via JSON-Patch delta, not full-tree PUT
Aug 10, 2026
0855126
fix(llmo-akamai): lower deploy rule-tree timeout ceiling 600s → 300s
Aug 10, 2026
f006548
Merge branch 'main' into fix/akamai-oae-fast-plan-deploy-status
ABHA61 Aug 10, 2026
ef046f0
Merge remote-tracking branch 'origin/main' into fix/akamai-oae-fast-p…
Aug 10, 2026
912aee9
docs(llmo-akamai): tighten comments; fix stale dry-run/PUT references
Aug 10, 2026
1f89784
Merge remote-tracking branch 'origin/fix/akamai-oae-fast-plan-deploy-…
Aug 10, 2026
1039ecc
fix(akamai): redact PAPI errors, guard retryVersion, refresh stale docs
Aug 10, 2026
d61acd0
Merge remote-tracking branch 'origin/main' into fix/akamai-oae-fast-p…
Aug 11, 2026
311b62b
fix(akamai): redact PAPI errors in the deploy-status response too
Aug 11, 2026
573cebb
build(deps): use published @adobe/spacecat-shared-akamai-client@1.2.1
Aug 12, 2026
e1e984d
Merge remote-tracking branch 'origin/main' into fix/akamai-oae-fast-p…
Aug 12, 2026
04bb20d
Merge branch 'main' into fix/akamai-oae-fast-plan-deploy-status
ABHA61 Aug 12, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 79 additions & 19 deletions docs/index.html

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions docs/openapi/api.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -778,6 +778,8 @@ paths:
$ref: './llmo-api.yaml#/site-llmo-akamai-plan'
/sites/{siteId}/llmo/cdn-onboard/akamai/deploy:
$ref: './llmo-api.yaml#/site-llmo-akamai-deploy'
/sites/{siteId}/llmo/cdn-onboard/akamai/deploy-status:
$ref: './llmo-api.yaml#/site-llmo-akamai-deploy-status'
/sites/{siteId}/llmo/cdn-onboard/akamai/activate:
$ref: './llmo-api.yaml#/site-llmo-akamai-activate'
/sites/{siteId}/llmo/cdn-onboard/akamai/activation-status:
Expand Down
152 changes: 152 additions & 0 deletions docs/openapi/llmo-api.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6624,6 +6624,151 @@ site-llmo-akamai-activation-status:
security:
- session_token: [ ]

site-llmo-akamai-deploy-status:
parameters:
- $ref: './parameters.yaml#/siteId'
- $ref: './parameters.yaml#/akamaiHost'
- $ref: './parameters.yaml#/akamaiClientToken'
- $ref: './parameters.yaml#/akamaiClientSecret'
- $ref: './parameters.yaml#/akamaiAccessToken'
- $ref: './parameters.yaml#/akamaiAccountSwitchKey'
- name: propertyId
in: query
required: true
description: Akamai property ID (e.g. `prp_1253269`).
schema:
type: string
pattern: '^prp_[A-Za-z0-9]+$'
- name: contractId
in: query
required: true
description: Akamai contract ID (e.g. `ctr_1-ABC123`).
schema:
type: string
pattern: '^ctr_[A-Za-z0-9-]+$'
- name: groupId
in: query
required: true
description: Akamai group ID (e.g. `grp_18385`).
schema:
type: string
pattern: '^grp_[A-Za-z0-9]+$'
- name: version
in: query
required: false
description: >-
Specific property version to check (e.g. a prior deploy's `newVersion`). When omitted, the
latest version is checked — which is the version a deploy's createVersion produced.
schema:
type: integer
minimum: 1
- name: baseVersion
in: query
required: false
description: >-
The version the deploy was cloned from (the reviewed version). When supplied, the response
adds `freshWrite`: on a re-onboard a freshly-created version is a clone of its base, so
`deployed` alone can't tell a landed write from an inherited clone. Comparing the per-deploy
fetcher key against the base disambiguates it. The key value is compared server-side only,
never returned.
schema:
type: integer
minimum: 1
- name: validate
in: query
required: false
description: >-
When 'true', also run PAPI validation on the checked version and add `activatable`,
`errorCount`, a bounded `errors` array, and `warningCount` to the response — so the caller
can block activation and show the errors up front. Off by default (a plain presence read is
cheap); validation latency scales with rule-tree size, so time-box the request.
schema:
type: string
enum: ['true']
get:
operationId: getLlmoAkamaiDeployStatus
summary: Check whether the Optimize-at-Edge rule landed in a property version
description: |
Read-only. Re-reads live Akamai state to report whether the managed Optimize-at-Edge rule is
present in a property version. This is the source of truth when a deploy's own HTTP response
was lost to the CDN first-byte timeout: the backend keeps running and the write may have
completed after the caller received a 503, so the caller polls this to learn the real outcome.
Cheap by design (a version lookup plus one rule-tree read), so it never itself times out.

**Note:** This endpoint requires LLMO administrator access for the site.
tags:
- llmo
responses:
'200':
description: The deploy status
content:
application/json:
schema:
type: object
properties:
propertyId:
type: string
version:
type: integer
description: The version that was checked.
latestVersion:
type: integer
description: >-
The property's latest version. When `deployed` is false, this is the version to
resume the write into.
deployed:
type: boolean
description: >-
True when the Optimize-at-Edge rule is present in `version` — the definitive
answer after a deploy whose HTTP response was not received.
managedRulesPresent:
type: array
items:
type: string
description: The managed rule names found at the top level of the checked version.
freshWrite:
type: boolean
description: >-
Present only when `baseVersion` was supplied. True ⇒ this deploy's write is
confirmed (the per-deploy fetcher key differs from the base clone). False ⇒ the
version is an unwritten clone still carrying a previous onboard's rule. Absent ⇒
not checked (first-onboard case, where `deployed` is already unambiguous).
activatable:
type: boolean
description: >-
Present only when `validate=true`: whether the version has zero blocking
validation errors (i.e. Akamai will accept it for activation).
errorCount:
type: integer
description: >-
Present only when `validate=true` — the number of blocking validation errors.
errors:
type: array
items:
type: object
additionalProperties: true
description: >-
Present only when `validate=true`: the validation errors (bounded to 25). Errors
describe rules by name/path and never include injected secret header values.
warningCount:
type: integer
description: >-
Present only when `validate=true` — the number of non-blocking warnings.
'400':
$ref: './responses.yaml#/400'
'401':
$ref: './responses.yaml#/401'
'403':
$ref: './responses.yaml#/403'
'404':
$ref: './responses.yaml#/404'
'429':
$ref: './responses.yaml#/429'
'502':
$ref: './responses.yaml#/502'
security:
- ims_key: [ ]

akamai-property-ref:
type: object
required:
Expand Down Expand Up @@ -6660,6 +6805,13 @@ akamai-property-ref:
description: >-
(deploy only) Optional property version to copy the new version from. Defaults to the
property's latest version.
retryVersion:
type: integer
minimum: 1
description: >-
(deploy only) A version a PRIOR attempt already created but failed to write rules into.
When supplied, deploy RESUMES into it (writes directly, skipping createVersion) instead of
minting a fresh version — the guard against piling up empty versions on repeated retries.

akamai-property:
type: object
Expand Down
6 changes: 4 additions & 2 deletions package-lock.json

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

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@
"@adobe/helix-universal-logger": "3.0.29",
"@adobe/mysticat-shared-seo-client": "1.8.0",
"@adobe/spacecat-helix-content-sdk": "1.4.33",
"@adobe/spacecat-shared-akamai-client": "1.2.0",
"@adobe/spacecat-shared-akamai-client": "1.2.1",
"@adobe/spacecat-shared-athena-client": "1.11.0",
"@adobe/spacecat-shared-brand-client": "1.4.0",
"@adobe/spacecat-shared-cloudflare-client": "1.3.0",
Expand Down
116 changes: 116 additions & 0 deletions src/controllers/llmo/llmo-akamai-utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -476,6 +476,27 @@ export function managedRuleNames(cfg) {
return [cfg.ruleNames.parent, cfg.ruleNames.routing, cfg.ruleNames.failoverTest];
}

/**
* Detects which managed "Optimize at Edge" rules are already present at the TOP LEVEL of a rule
* tree, by trimmed name. Used by the deploy-status endpoint to answer "did the OAE rule actually
* land in this version?" by re-reading live Akamai state — the source of truth when a deploy's own
* HTTP response was lost to a CDN timeout. Matches both the current wrapped layout (only the parent
* `"Optimize at Edge"` sits at top level, with routing/failover-test nested inside it) and the
* older flat layout (routing/failover-test at top level), and tolerates a legacy trailing space.
* Uses the frozen EDGE_OPTIMIZE_DEFAULTS names — no cfg needed, since a status check has no
* hostname/apiKey to build one from.
* @param {object} ruleTree - a PAPI rule tree ({ rules: {...} })
* @returns {string[]} the managed rule names found at top level (deduped); empty if none
*/
export function detectManagedRuleNames(ruleTree) {
const managed = new Set(Object.values(EDGE_OPTIMIZE_DEFAULTS.ruleNames));
const children = ruleTree?.rules?.children || [];
const found = children
.map((c) => (c?.name ?? '').trim())
.filter((name) => managed.has(name));
return [...new Set(found)];
}

/**
* Builds a JSON Patch (RFC 6902) that inserts the managed "Optimize at Edge" wrapper rule (and its
* PMUSER cache-key variable) into an existing rule tree WITHOUT re-serialising any existing rule or
Expand Down Expand Up @@ -590,6 +611,101 @@ export function redactSecrets(tree) {
return clone;
}

// PAPI validation errors/details echo back the rules we sent, so they can carry the injected
// x-edgeoptimize-api-key / x-edgeoptimize-fetcher-key header values — scrub those before returning
// them to a client. Redacts any explicitly-known secret value, any value following a secret header
// name, and any 32-byte hex token (the shape of a minted fetcher key).
const SECRET_HEADER_VALUE_RE = /(x-edgeoptimize-(?:api|fetcher)-key["'\s]*[:=]["'\s]*)([^"'\s,}\]]+)/gi;
const MINTED_FETCHER_KEY_RE = /\b[0-9a-f]{64}\b/gi;

function scrubSecretText(text, extraSecrets) {
let out = String(text);
extraSecrets.forEach((v) => {
if (typeof v === 'string' && v.length >= 4) {
out = out.split(v).join(REDACTED);
}
});
return out.replace(SECRET_HEADER_VALUE_RE, `$1${REDACTED}`).replace(MINTED_FETCHER_KEY_RE, REDACTED);
}

/**
* Redacts injected secrets from PAPI errors before they leave the server. Accepts the errors array
* (deploy's validateRules result) or the raw detail string (activation's 400 body) and returns the
* same shape — arrays bounded to `max` entries. Pass any secret values known at the call site (e.g.
* the deploy's apiKey/fetcherKey); header-name and hex-token patterns catch the rest.
* @param {Array|string|null} errors
* @param {string[]} [extraSecrets] - explicit secret values to redact
* @param {number} [max] - max array entries to keep
* @returns {Array|string|null} the redacted errors, same shape as the input
*/
export function redactPapiErrors(errors, extraSecrets = [], max = 25) {
if (errors == null) {
return errors;
}
if (typeof errors === 'string') {
return scrubSecretText(errors, extraSecrets);
}
const bounded = Array.isArray(errors) ? errors.slice(0, max) : errors;
return JSON.parse(scrubSecretText(JSON.stringify(bounded), extraSecrets));
}

/**
* Returns the fetcher-key value (the x-edgeoptimize-fetcher-key incoming-request header the managed
* routing rule injects) from a rule tree, or null if absent. A fresh fetcher key is minted on every
* deploy, so it's a per-deploy fingerprint: deploy-status compares it between a version and its
* base to tell "this deploy's fresh write landed" (keys differ) from "the version is just an
* unwritten clone inheriting the previous onboard's rule" (keys identical).
* NEVER return this value to a client — it's a secret (redactSecrets scrubs it from responses); it
* is only compared server-side. Walks the whole tree for the first matching header.
* @param {object} tree - a PAPI rule tree ({ rules: {...} })
* @returns {string|null} the fetcher-key header value, or null when the tree has no managed rule
*/
export function getManagedFetcherKey(tree) {
let found = null;
const walk = (rule) => {
if (found !== null || !rule || typeof rule !== 'object') {
return;
}
(rule.behaviors || []).forEach((b) => {
if (found === null
&& b?.name === 'modifyIncomingRequestHeader'
&& b.options?.customHeaderName === FETCHER_KEY_HEADER
&& typeof b.options?.headerValue === 'string') {
found = b.options.headerValue;
}
});
(rule.children || []).forEach(walk);
};
walk(tree?.rules);
return found;
}

/**
* Estimates how expensive Akamai's own PAPI `validateRules` pass will be for a rule tree, by
* summing behaviors + criteria (match conditions) across every rule, recursively. This mirrors the
* exact metric PAPI itself enforces a hard ceiling on — a property reports "Current usage is X out
* of 3000 available" for this same behaviors+matches total when exceeded (confirmed against a real
* Akamai property while testing a large-property fix). Used as a fast, pre-flight proxy for "will
* this take too long to validate" before ever attempting the slow validate/write call — Akamai's
* own processing time scales with this same total, and unlike raw rule count it's tied to a real,
* already-observed Akamai constraint rather than an arbitrary number.
* @param {object} ruleTree - a PAPI rule tree ({ rules: {...} })
* @returns {number} total behaviors + criteria across every rule in the tree
*/
export function estimateRuleTreeComplexity(ruleTree) {
let total = 0;
const walk = (rule) => {
if (!rule || typeof rule !== 'object') {
return;
}
total += (rule.behaviors || []).length;
total += (rule.criteria || []).length;
(rule.children || []).forEach(walk);
};
walk(ruleTree?.rules);
return total;
}

// ---------------------------------------------------------------------------
// Config assembly
// ---------------------------------------------------------------------------
Expand Down
Loading
Loading