feat: auto-create product entitlement and site enrollment via x-product header#2432
feat: auto-create product entitlement and site enrollment via x-product header#2432radhikagpt1208 wants to merge 8 commits into
Conversation
Regenerate the lockfile after main was merged in: the previous lockfile
referenced older versions of @adobe/spacecat-shared-* and @aws-sdk/*
packages and was missing xml-naming@0.1.0, causing `npm ci` to fail in
CI with EUSAGE ("lock file's X@a does not satisfy X@b").
Co-authored-by: Cursor <cursoragent@cursor.com>
|
This PR will trigger a minor release when merged. |
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
ravverma
left a comment
There was a problem hiding this comment.
Hey @radhikagpt1208,
Thanks for the careful write-up - the PR description's "Problem" and "Backward compatibility" sections made the intent obvious and saved a lot of context-gathering. The opt-in shape, the explicit retry contract, and the OpenAPI updates are all in good order. A few things worth addressing before merge, the most pressing of which is a real production hazard in the underlying TierClient that this PR is the first HTTP-reachable caller of.
Strengths
- Opt-in design with
hasText(productCode)guards (src/controllers/sites.js,src/controllers/organizations.js) preserves the existing contract for callers that don't pass the header. Backward compatibility is genuine. - Helper functions
ensureSiteEntitlementAndEnrollment/ensureOrgEntitlementare small, well-documented, and consistent in shape across both controllers. Easy to audit. - Failure path is fail-loud (logs
error.message+ the error object, returns 500) rather than swallowed - avoids the worst silent-failure pattern. - OpenAPI specs are updated alongside the implementation, including the previously-undeclared 200 response on
POST /organizations. - Existing
accessControlUtil.hasAdminAccess()gate is correctly preserved on both endpoints, so this is not a privilege escalation surface. - Unit-level branch coverage is genuinely complete for the new code (Codecov bot is correct).
Issues
Critical (Must Fix)
1. TierClient.createEntitlement(FREE_TRIAL) silently downgrades existing non-PAID entitlements.
src/controllers/sites.js:291-296 and src/controllers/organizations.js:73-80 call createEntitlement(EntitlementModel.TIERS.FREE_TRIAL). Per @adobe/spacecat-shared-tier-client/src/tier-client.js:148-155, if an entitlement already exists at a tier that is not PAID and not equal to FREE_TRIAL, the library mutates it:
if (currentTier !== tier && currentTier !== ENTITLEMENT_TIERS.PAID) {
existing.entitlement.setTier(tier);
await existing.entitlement.save();
}So a POST /sites with x-product: ASO against an org that currently holds a PLG or PRE_ONBOARD entitlement will silently downgrade it to FREE_TRIAL. The PR description's claim that "TierClient is idempotent - existing entitlements are reused" is not accurate for non-PAID existing tiers. Once the companion aem-aso-trial PR starts sending x-product: ASO on every site creation, this is reachable in normal operation - not an edge case.
Fix in this repo (cheapest): before calling createEntitlement, call tierClient.checkValidEntitlement() and short-circuit when an entitlement already exists at a non-FREE_TRIAL tier (log + return success, treat as already-provisioned). Add a unit test asserting that "calling x-product on an org with an existing PRE_ONBOARD entitlement does not modify the tier." The underlying library behavior is also worth a follow-up issue against spacecat-shared-tier-client, but that's out of scope for merging this PR safely.
2. New/modified endpoints ship without integration tests.
CLAUDE.md requires that "New or modified endpoints must include integration tests in test/it/". This PR modifies the externally-observable behavior of two POST endpoints (new header, new 500 partial-failure response, new declared 200 on organizations) and adds zero IT cases. The whole point of TierClient is the cross-table side effect (Entitlement + SiteEnrollment); unit tests stub the client and therefore prove only that the controller invokes it correctly. They cannot detect whether the persisted rows match what the OpenAPI doc claims, whether the partial-failure-and-retry contract actually holds, or whether the resulting site is now visible to the listing endpoint this PR was written to fix.
Fix: add IT cases under the existing POST /sites and POST /organizations blocks in test/it/shared/tests/ covering:
admin: POST /sites with x-product: ASOreturns 201 and materializes both anEntitlementrow and aSiteEnrollmentrow,- idempotent re-POST with the same header returns 200 with no duplicate rows,
admin: POST /organizations with x-product: ASOmaterializes anEntitlementrow,- regression guard: POST without the header creates no entitlement,
- end-to-end: after a successful POST with the header,
GET /organizations/:orgId/sites?productCode=ASOlists the new site (this is the original bug the PR set out to fix - the IT is the only way to prove the round-trip closes).
Important (Should Fix)
3. No productCode allowlist or normalization - typos silently provision unusable entitlements.
src/controllers/sites.js:328 and src/controllers/organizations.js:102 read pathInfo.headers['x-product'] and forward it verbatim to TierClient.createEntitlement. A canonical enum already exists at EntitlementModel.PRODUCT_CODES (used in src/support/utils.js, src/support/edge-routing-auth.js, and elsewhere). A typo like aso (lowercase) or ASO (trailing space) will pass through and create an entitlement whose product code does not match anything the read-time validateEntitlement flow filters on - reintroducing the exact orphan pattern this PR is trying to eliminate.
The 500 message also reflects unfiltered header input back into the response body (Failed to ensure ${productCode} entitlement for organization). Not exploitable today (JSON response, admin-only), but worth not making a habit of.
Fix: validate productCode against Object.values(EntitlementModel.PRODUCT_CODES) at the top of each controller and return badRequest('Unsupported product code') if not in the list. Drop productCode from the 500 user-facing message (keep it in the log).
4. Partial-failure leaves an orphan with no observability marker.
When Site.create succeeds and ensureSiteEntitlementAndEnrollment then fails, the response is 500 and the caller has no siteId in hand (src/controllers/sites.js:354-360). The PR description and OpenAPI both say "retries are safe because findByBaseURL returns the existing site," which is true if the client retries. If the client treats 500 as terminal (common bug in batch tooling, network drop between Lambda response and caller, exceeded backoff), the site sits in the DB without enrollment - the same orphan state the PR aims to eliminate. There is no metric, no DLQ, no alert, and the log.error is freeform string that will not aggregate cleanly in CloudWatch.
Two reasonable directions, pick one:
- Return 201 with a
warningsarray (e.g.{ site, warnings: ['entitlement_creation_failed'] }) so the caller has thesiteIdand can retry the entitlement step out-of-band. Cleanest separation of "the site was created" from "the side-effect failed." - Keep the 500 but add a structured log marker (fixed event key like
event=site_orphaned_after_create siteId=... productCode=...) and a CloudWatch alert so ops can find and reconcile these orphans. The existing audit pattern in this repo queues post-create work via SQS (per CLAUDE.md "Queue-Based Async Pattern") - that pattern would also fit cleanly here as a follow-up.
5. Missing 403 regression test for the new mutation path.
test/controllers/sites.test.js and test/controllers/organizations.test.js do not include a test that asserts non-admin callers cannot reach the entitlement code path. The whole security argument for this PR is "the admin gate already protects this." A future refactor that moved the admin check below the entitlement call would not be caught by any current test.
Fix: add one test per controller stubbing AccessControlUtil.fromContext to return { hasAdminAccess: () => false }, then assert response.status === 403 AND that TierClient.createForOrg / createForSite was never called.
6. The x-product header now carries three distinct semantics across the codebase.
In src/support/access-control-util.js:65, 242-244 the header is used for (a) read-time filter scope and (b) read-time authorization. This PR adds (c) write-time provisioning trigger. One header is now doing scope assertion, authorization, AND provisioning. In 12 to 18 months, when one product needs a different default tier than another, when one product wants enrollment to be transactional and another does not, or when a caller wants to provision multiple products in one POST, the options will be header proliferation (x-product-tier, x-auto-enroll, x-products) or a body field. Both break the current shape.
A more durable design would put provisioning intent in the request body (e.g. { baseURL, autoEnroll: { product: 'ASO', tier: 'FREE_TRIAL' } }) or behind a dedicated POST /sites/:siteId/enrollments endpoint. Bodies are extensible; magic headers are not. Worth raising with the aem-aso-trial team before this merges, since they are the intended caller.
Minimum mitigation if you keep the header shape: pin the current contract in a comment in both controllers and in parameters.yaml's xProduct definition ("write-time use is FREE_TRIAL only, single product, no override of existing tier") so the next person sees the constraints before extending it.
Minor (Nice to Have)
7. Duplicate header constant.
src/controllers/sites.js:1196 and src/controllers/organizations.js:40 already define const X_PRODUCT_HEADER = 'x-product'; in the same files; src/support/access-control-util.js:31 defines it again. The new code re-uses the magic string 'x-product' instead. Lift to a shared constant in src/support/access-control-util.js (export it) or src/support/constants.js and import everywhere.
8. await on a synchronous factory in organizations.js.
src/controllers/organizations.js:75 does const tierClient = await TierClient.createForOrg(...). Per tier-client.js:28, createForOrg is synchronous (returns new TierClient(...) directly). Awaiting a non-Promise is harmless at runtime but misleads the next reader and diverges from createForSite (which IS async at tier-client.js:45). Drop the await here; keep it on createForSite.
9. Misleading log when siteEnrollment is undefined.
src/controllers/sites.js:295 does log.info(\... and enrollment ${siteEnrollment?.getId()} for site ...`). If TierClient ever returns { entitlement, siteEnrollment: undefined }(a realistic idempotent path when the enrollment already exists), the line says... and enrollment undefined for site .... Either tighten the contract (drop the ?.and let it fail loudly) or handle the absent case:const enrollmentSuffix = siteEnrollment ? ` and enrollment ${siteEnrollment.getId()}` : '';`.
10. Test assertions worth tightening.
A cluster of small test-quality improvements:
test/controllers/organizations.test.jshas no equivalent of the sites file's empty-string header test. Add one for symmetry against futurehasTextswaps.- Existing-entity happy-path tests (
organizations.test.js:398-418,sites.test.js:427-437after the diff) assert status code andcreatenot called, but don't parseresponse.json()to confirm the body is the existing entity's DTO. A refactor that accidentally returns the new entity's data would still pass. Addexpect((await response.json()).id).to.equal(existingEntity.getId()). - The pre-existing "returns existing organization when imsOrgId matches" test at
organizations.test.js:347-365should also assertTierClient.createForOrgwas not called, since the PR refactored the existing-org branch to fall through the entitlement gate. expect(TierClient.createForSite.firstCall.args[2]).to.equal('ASO')is positional and fragile. Preferexpect(TierClient.createForSite).to.have.been.calledWith(sinon.match.any, sinon.match.any, 'ASO').
11. OpenAPI: no enum for the new header.
docs/openapi/parameters.yaml:397-410 defines xProduct with type: string and a single example. Either add enum: [ASO, LLMO, ACO] (sourced from EntitlementModel.PRODUCT_CODES) or reference the allowlist source in the description so consumers can see what's valid.
Recommendations
- Extract the two near-duplicate helpers (
ensureSiteEntitlementAndEnrollment,ensureOrgEntitlement) into asrc/support/tier-provisioning.jsmodule before a third caller forces it. They will need to evolve in lockstep when issue 1 is addressed. - Coordinate the shape with whoever owns
aem-aso-trial. If they are the only intended caller, consider whether this even needs to be a public REST surface vs. a directTierClientinvocation in the trial flow itself. A public header that exists for exactly one caller is a design smell worth flagging. - Treat the synchronous failure semantics as a temporary contract. The audit/import flows in this repo already use SQS for post-create work; that pattern fits cleanly here once a second product needs provisioning.
Out of scope, worth tracking
TierClient.createEntitlementsilently mutating an existing non-PAID tier (root cause of issue 1) lives in@adobe/spacecat-shared-tier-client. Worth a follow-up PR in that repo (e.g. an{ updateTier: false }flag or splitting create vs. ensure-current). For this PR, defensive handling in the controller is sufficient.
Assessment
Ready to merge? With fixes.
Reasoning: the change is well-scoped, opt-in, and the unit test coverage is honest at the controller level. But the underlying TierClient will silently downgrade non-PAID tiers (issue 1) the moment the companion aem-aso-trial PR ships, and that is reachable in normal operation against real customer data. Combined with the missing integration tests (CLAUDE.md policy) and no productCode validation, this is worth one more pass before merge.
Next Steps
- Address critical items 1 (tier-downgrade defensive check) and 2 (integration tests) first - both are reachable in normal operation once the companion PR ships.
- Address the important items: 3 (productCode allowlist), 4 (orphan-site observability), 5 (403 regression test), 6 (header overloading - at minimum the inline contract comments).
- Minor items 7 through 11 are cleanups that improve future maintainability but don't block merge individually.
|
@radhikagpt1208 as discussed in , lets make it more simpler.
|
ravverma
left a comment
There was a problem hiding this comment.
Hey @radhikagpt1208,
Thanks for the thorough rework - the new tier-provisioning.js module is a clean extraction and the IT coverage closes the round-trip gap from the previous review. Almost everything from last time is resolved; there is one remaining blocker from the three inline comments posted on the 28th, and a couple of new findings from the added code.
Strengths
- Previously flagged Critical 1 (silent tier downgrade of PLG/PRE_ONBOARD) is resolved.
shouldPreserveExistingEntitlementTierattier-provisioning.js:77-79short-circuits beforecreateEntitlement(FREE_TRIAL)in both the org and site paths, and the new IT case attest/it/shared/tests/sites.js:367-385proves the round-trip: POST withx-product: ASOagainst an org with an existing PAID entitlement, then GET the listing - site appears, tier stays PAID. - Previously flagged Critical 2 (no IT coverage) is resolved. The new IT cases genuinely round-trip: POST the resource, then read back via
GET /organizations/{id}/sites?x-product=ASOandGET /organizations/{id}/entitlementsto confirm side effects landed in the DB. These are not smoke tests. - Previously flagged Important 3 (no productCode allowlist) is resolved.
resolveWriteTimeProductCodeattier-provisioning.js:43-54validates againstEntitlementModel.PRODUCT_CODES, trims whitespace, and the 400 message no longer echoes the raw header. - Previously flagged Important 4 (orphan observability) is resolved.
logSiteOrphanedAfterCreateattier-provisioning.js:163-169emits a fixedevent=site_orphaned_after_create siteId=... productCode=...prefix with the free-textmessage=last, which is CloudWatch-queryable. - Previously flagged Important 5 (missing 403 regression test) is resolved. Unit test at
test/controllers/sites.test.js:488-499stubs non-admin access and asserts 403 + TierClient never called. - Previously flagged Minor 8, 9, 11 are all resolved (sync factory no longer awaited;
enrollmentSuffixternary;enum: [ASO, LLMO, ACO]on the OpenAPI parameter). tier-provisioning.jsis well-unit-tested across the standard tier matrix; thetest/support/tier-provisioning.test.jscovers the preserve/downgrade paths for all four declared tier values including the "existing enrollment missing" branch attest/support/tier-provisioning.test.js:199-226.
Issues
Critical (Must Fix)
OpenAPI declares an internal CDN-injected header as a client-facing parameter (consolidating the three unresolved inline comments from 2026-05-28):
docs/openapi/organizations-api.yaml:22-$ref: './parameters.yaml#/xProduct'onPOST /organizationsdocs/openapi/sites-api.yaml:91-104- the "Auto-enrollment (via x-product header)" docstring block and$ref: './parameters.yaml#/xProduct'onPOST /sitesdocs/openapi/parameters.yaml:400-420- thexProductdefinition itself
The x-product header on these write endpoints is CDN-injected, not client-supplied. Declaring it as a parameters entry tells SDK generators and API consumers "send this header yourself" - the opposite of the actual contract. The controller code reading pathInfo.headers['x-product'] is correct (CDN-injected headers arrive there); the misalignment is solely in the published spec.
Fix: remove the two $ref entries on the POST operations and the xProduct definition. Reword the "Auto-enrollment" prose in the sites-api.yaml and organizations-api.yaml descriptions to say "set by the CDN layer based on the calling product surface" rather than "when the caller supplies."
One question the author should answer in this PR before merge: does the CDN overwrite a client-supplied x-product on these routes, or only default it when absent? The consumer integration guide at docs/s2s/CONSUMER_INTEGRATION_GUIDE.md documents the Fastly rewrite behavior - confirm it applies to POST /sites and POST /organizations specifically. If the CDN only defaults (not overwrites), removing the OpenAPI declaration without a server-side strip leaves a functional but undocumented provisioning path reachable from the public internet. If it overwrites (which the guide implies), the doc removal is sufficient. Either way - answer it in the PR thread.
Important (Should Fix)
wouldDowngradeExistingTier has a misleading general signature for a safety primitive.
tier-provisioning.js:64-68. The function takes (currentTier, targetTier) but every callsite passes FREE_TRIAL_TIER as the target, and the logic only correctly encodes the "FREE_TRIAL is the floor, PAID is the ceiling" invariant for that specific target. If a future caller passes targetTier = 'PAID', the function returns true for every non-PAID current tier including FREE_TRIAL - claiming a FREE_TRIAL-to-PAID upgrade is a "downgrade." That is inverted.
This is the safety primitive for prior Critical 1. A misleading signature on a safety primitive is how that bug gets reintroduced. Either (a) drop the targetTier parameter, rename to existingTierIsAboveFreeTrial(currentTier), and update shouldPreserveExistingEntitlementTier to call it directly - this matches today's usage and is the lower-risk change; or (b) implement a proper tier-ordering comparison if you genuinely anticipate multiple target tiers.
tier-provisioning.js bypasses TierClient to write SiteEnrollment directly, with no test for the failure path.
tier-provisioning.js:136-143. When an existing entitlement is preserved but its enrollment is missing, the module calls SiteEnrollment.create directly on context.dataAccess, bypassing TierClient. Two concerns:
- Ownership:
@adobe/spacecat-shared-tier-clientis intended to be the single owner of the (entitlement, enrollment) pair. A direct write here bypasses any audit hooks, event emissions, or idempotency guards the client may add in the future without this code being aware. - Untested failure path: if
SiteEnrollment.createrejects here, the rejection propagates to the controller's catch block and fireslogSiteOrphanedAfterCreate- but there is no unit test for this branch. The existing test attier-provisioning.test.js:199-226only stubssiteEnrollmentCreateas resolving.
Fix: (a) add a unit test where SiteEnrollment.create rejects and assert the rejection bubbles so the controller logs the orphan event. (b) Add a comment at line 136 explaining why the direct write exists today (TierClient has no "ensure enrollment only" surface) and note the upstream fix needed. This contains the scope to this PR without requiring a spacecat-shared-tier-client change now.
Minor (Nice to Have)
Newlines in error.message can split the orphan log record across multiple CloudWatch lines. tier-provisioning.js:165. Because message= is last in the format string, commas and = signs in the message will not corrupt earlier fields - that part is correct. But a newline in the error message (common in AWS SDK / DynamoDB errors) splits the record, breaking parse @message "event=* siteId=* productCode=*" queries. Sanitize: error.message?.replace(/[\r\n]+/g, ' '), or rely on the raw error object already passed as the second argument to the logger and drop the message= field from the first-arg string.
wouldDowngradeExistingTier unit tests do not cover null/undefined inputs or unknown future tiers. test/support/tier-provisioning.test.js:65-75. Both null and undefined currently return true (treated as "preserve"), which is the safe default - but it is not pinned in a test. A future tier value like 'ENTERPRISE' also returns true by default. Add two assertions: (null, 'FREE_TRIAL') -> true and ('ENTERPRISE', 'FREE_TRIAL') -> true to document the "unknown tier defaults to preserve" contract.
Async asymmetry between TierClient.createForSite and TierClient.createForOrg is undocumented. tier-provisioning.js:91 and :121. Both are correct per the library (site factory is async because it resolves organization data; org factory is synchronous), but the asymmetry will surprise the next person. A one-line comment at each call site is enough.
Duplicate X_PRODUCT_HEADER constant still not fully resolved (prior Minor 7). src/controllers/organizations.js:46 still defines a local const X_PRODUCT_HEADER = 'x-product';. The write path imports from tier-provisioning.js (which re-exports from access-control-util.js) but the readAll-by-product path uses the local constant. Cleanup only.
Recommendations
- Consider replacing the
key=valueorphan-event string format with a structured log object:log.error({ event: SITE_ORPHANED_AFTER_CREATE_EVENT, siteId: site.getId(), productCode }, 'site orphaned after create', error). CloudWatch Insights handles structured JSON fields natively, and this eliminates the newline and delimiter concerns above. - The "missing enrollment for existing tier" branch (
tier-provisioning.js:136-143) is a gap the tier client should eventually close with aTierClient.ensureSiteEnrollment(...)method. Worth filing a follow-up againstspacecat-shared-tier-clientso the bypass comment does not become permanent. - The IT cases prove
PAID stays PAIDbut do not assert the new site is enrolled under the same pre-existing entitlement ID (no duplicate row created). Consider adding aGET /organizations/{id}/entitlementscount assertion before and after to pin this.
Assessment
Ready to merge? With fixes.
Reasoning: All prior Critical and Important findings are addressed, and tier-provisioning.js is the right abstraction for this safety logic. The single remaining blocker is the OpenAPI cleanup (three locations) and confirming the CDN overwrite behavior - both are compact changes. The Important items on the wouldDowngradeExistingTier signature and the SiteEnrollment.create failure path are worth closing in this PR since they touch the new safety module directly.
Next Steps
- Remove the three OpenAPI
x-productdeclarations and answer the CDN overwrite question in the PR thread. - Tighten
wouldDowngradeExistingTierto a single-argument function and add aSiteEnrollment.create-rejection unit test. - Minor items are optional cleanups and can follow in a separate commit or be deferred.
ravverma
left a comment
There was a problem hiding this comment.
LGTM
rebase branch before merge
Summary
Adds an opt-in
x-productheader toPOST /sitesandPOST /organizationsthat triggers auto-creation of the org's product entitlement (FREE_TRIALtier) and — for sites — the correspondingSiteEnrollment, by delegating toTierClient(which is idempotent at the library level: existing entitlements/enrollments are reused).Without the header the endpoints behave exactly as before, preserving backward compatibility for callers that manage entitlements separately.
Jira: SITES-44944
Problem
GET /organizations/:orgId/sitesfilters results throughfilterSitesForProductCode(), which keeps only sites that have aSiteEnrollmentrow for the org'sEntitlement:Sites onboarded through the
aem-aso-trialflow are persisted in Spacecat but never get anEntitlement/SiteEnrollment, so they get filtered out of the product-scoped listing. The downstream effect: ASO Preflight breaks ("site is not supported for this widget") for trial customers, blocking the technical-validation flow used in customer workshops.This was previously masked by loose
(env id + program)matching that picked the first matching site — effectively a bug, surfaced once provisioning was tightened. The team had been working around it with manualset imsorgSlack admin commands per site.Once the trial flow (
aem-aso-trial) passesx-product: ASOonPOST /sites(andPOST /organizations), new trial sites are enrolled out-of-the-box and Preflight works without any manual provisioning step.Original incident thread: Preflight broken for AEM Sites Trial.
Changes
src/controllers/sites.jsensureSiteEntitlementAndEnrollmentthat callsTierClient.createForSite(...).createEntitlement(FREE_TRIAL).createSitereadsx-productfrom headers and invokes the helper after the site is persisted (for both newly created and existing site paths). Status code logic hoisted into alet statusvariable so the entitlement step runs uniformly while preserving the existing 201/200 idempotent-create semantics.500with a descriptive message (Failed to ensure <PRODUCT> entitlement/enrollment for site); retries are safe becauseTierClientis idempotent and the site lookup on retry returns the already-persisted site.src/controllers/organizations.jsensureOrgEntitlementthat callsTierClient.createForOrg(...).createEntitlement(FREE_TRIAL).createOrganizationreadsx-productfrom headers and invokes the helper, with the same idempotent-create semantics (200 for existing, 201 for newly created).internalServerErrorimport from@adobe/spacecat-shared-http-utils.OpenAPI specs
xProductparameter underdocs/openapi/parameters.yaml.sites-api.yamlandorganizations-api.yamlPOSTendpoints to reference the new parameter and document the auto-enrollment behavior + failure modes.200response onPOST /organizationsfor the idempotent-existing case.Tests
createSite auto-enrollment via x-product header(6 cases) andcreateOrganization auto-entitlement via x-product header(5 cases) covering: success on newly created, success on existing, header missing, empty-string header, TierClient failure on newly created, TierClient failure on existing.Backward compatibility
Without the
x-productheader, both endpoints behave exactly as before — no entitlement/enrollment is created and noTierClientcall is made. Existing integrations (PLG onboarding, Slackonboard sitecommand, etc.) that manage enrollment via their own paths are unaffected.Test plan
npm test(mocha + c8): all new and existing tests pass; 100% branch coverage on the changed code.eslinton changed files — clean.npm run docs:lint— clean (only pre-existing warnings inexamples.yaml).npm run docs:build— succeeds.POST /siteswithx-product: ASOon stage → site appears inGET /organizations/:orgId/sitesfor the same product code.POST /siteswithoutx-product→ behavior unchanged.Follow-up
A companion PR in
aem-aso-trialwill wire upx-product: ASOon itsPOST /sitesandPOST /organizationscalls so trial sites are enrolled out-of-the-box. Tracked under SITES-44944.References