Skip to content
22 changes: 21 additions & 1 deletion docs/openapi/organizations-api.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,35 @@ organizations:
- organization
summary: Create a new organization
description: |
This endpoint is useful for creating a new organization.
Creates a new organization (or returns the existing one with HTTP 200 if an organization
with the same `imsOrgId` already exists).

**Auto-entitlement (via `x-product` header):** When the caller supplies an `x-product`
header (e.g. `ASO`), a `FREE_TRIAL` entitlement for that product is created for the
organization. The underlying TierClient is idempotent — existing entitlements for the
same `(orgId, productCode)` pair are reused — so retries are safe. This brings POST
`/organizations` in line with the entitlement model so that downstream product-scoped
APIs can resolve the organization without a separate manual provisioning step. If the
entitlement step fails (e.g. transient DB error), the endpoint returns HTTP 500 even
though the org itself was persisted; the caller should retry. Without the header, no
entitlement is created (backward-compatible for callers that manage entitlements
separately).
operationId: createOrganization
parameters:
- $ref: './parameters.yaml#/xProduct'
Comment thread
radhikagpt1208 marked this conversation as resolved.
Outdated
requestBody:
required: true
content:
application/json:
schema:
$ref: './schemas.yaml#/OrganizationCreate'
responses:
'200':
description: Existing organization returned (idempotent create — an organization with the same `imsOrgId` already exists)
content:
application/json:
schema:
$ref: './schemas.yaml#/Organization'
'201':
description: Organization created successfully
content:
Expand Down
21 changes: 21 additions & 0 deletions docs/openapi/parameters.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -397,6 +397,27 @@ xPromiseToken:
type: string
example: eyJhbGciOiJSUzI1NiIs...

xProduct:
name: x-product
Comment thread
radhikagpt1208 marked this conversation as resolved.
Outdated
description: |
Product code identifying which Spacecat product the call is scoped to.
Read-time: required on some list endpoints (e.g. `GET /organizations/{organizationId}/sites`)
to filter sites by product enrollment.
Write-time (optional on `POST /sites` and `POST /organizations`): when present, provisions a
single product at `FREE_TRIAL` only if the organization has no entitlement or is already on
`FREE_TRIAL`. Existing `PAID`, `PLG`, and `PRE_ONBOARD` entitlements are never downgraded;
missing site enrollments are still created when possible. Omit this header to leave tier
provisioning to the caller (backward compatible).
in: header
required: false
schema:
type: string
enum:
- ASO
- LLMO
- ACO
example: ASO

suggestionView:
name: view
description: |
Expand Down
14 changes: 14 additions & 0 deletions docs/openapi/sites-api.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,21 @@ sites:
- Schema prepended if missing

This idempotent behavior allows clients to safely retry site creation requests without creating duplicates.

**Auto-enrollment (via `x-product` header):** When the caller supplies an `x-product` header
Comment thread
radhikagpt1208 marked this conversation as resolved.
Outdated
(e.g. `ASO`), the site is auto-enrolled into a `FREE_TRIAL` entitlement for that product
(the org-level entitlement is created if missing, and a `SiteEnrollment` is created linking
the site to it). This keeps the site visible in product-scoped listing endpoints such as
`GET /organizations/{organizationId}/sites` without a separate onboarding step.
The underlying TierClient is idempotent — existing entitlements/enrollments are reused — so
retries are safe. If the entitlement/enrollment step fails (e.g. transient DB error), the
endpoint returns HTTP 500 even though the site itself was persisted; the caller should retry
(the site lookup will return the already-persisted site). Without the header, no
entitlement/enrollment is created (backward-compatible for callers that manage enrollment
separately).
operationId: createSite
parameters:
- $ref: './parameters.yaml#/xProduct'
requestBody:
required: true
content:
Expand Down
51 changes: 41 additions & 10 deletions src/controllers/organizations.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import {
createResponse,
badRequest,
internalServerError,
notFound,
ok, forbidden,
} from '@adobe/spacecat-shared-http-utils';
Expand All @@ -22,16 +23,18 @@ import {
isString,
isValidUUID,
} from '@adobe/spacecat-shared-utils';

import { Entitlement as EntitlementModel } from '@adobe/spacecat-shared-data-access';
import TierClient from '@adobe/spacecat-shared-tier-client';

import { OrganizationDto } from '../dto/organization.js';
import { ProjectDto } from '../dto/project.js';
import { SiteDto } from '../dto/site.js';
import AccessControlUtil from '../support/access-control-util.js';
import { CAP_ORG_READ_ALL } from '../routes/capability-constants.js';
import { filterSitesForProductCode, CUSTOMER_VISIBLE_TIERS } from '../support/utils.js';
import {
ensureOrgEntitlement,
resolveWriteTimeProductCode,
} from '../support/tier-provisioning.js';
/**
* Organizations controller. Provides methods to create, read, update and delete organizations.
* @param {object} ctx - Context of the request.
Expand Down Expand Up @@ -62,25 +65,53 @@ function OrganizationsController(ctx, env) {

/**
* Creates an organization. The organization ID is generated automatically.
*
* Write-time tier provisioning: optional `x-product` header provisions a single product
* at FREE_TRIAL when the org has no entitlement or is already on FREE_TRIAL. Existing PAID,
* PLG, and PRE_ONBOARD entitlements are never downgraded (see `tier-provisioning.js`).
* Entitlement failures return 500; retries are safe when the same imsOrgId is re-posted.
*
* @param {object} context - Context of the request.
* @return {Promise<Response>} Organization response.
*/
const createOrganization = async (context) => {
const { log } = ctx;
if (!accessControlUtil.hasAdminAccess()) {
return forbidden('Only admins can create new Organizations');
}
const { productCode, error: productCodeError } = resolveWriteTimeProductCode(context);
if (productCodeError) {
return badRequest(productCodeError);
}
let organization;
let status;
// check if the organization already exists
const organization = await Organization.findByImsOrgId(context.data.imsOrgId);
if (organization) {
return createResponse(OrganizationDto.toJSON(organization), 200);
const existingOrganization = await Organization.findByImsOrgId(context.data.imsOrgId);
if (existingOrganization) {
organization = existingOrganization;
status = 200;
} else {
try {
organization = await Organization.create(context.data);
status = 201;
} catch (e) {
return badRequest(e.message);
}
}

try {
const organizationCreated = await Organization.create(context.data);
return createResponse(OrganizationDto.toJSON(organizationCreated), 201);
} catch (e) {
return badRequest(e.message);
if (productCode) {
try {
await ensureOrgEntitlement(context, organization, productCode, log);
} catch (error) {
log.error(
`Error ensuring entitlement for organization ${organization.getId()}: ${error.message}`,
error,
);
return internalServerError('Failed to ensure entitlement for organization');
}
}

return createResponse(OrganizationDto.toJSON(organization), status);
};

/**
Expand Down
50 changes: 40 additions & 10 deletions src/controllers/sites.js
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,11 @@ import { CAP_SITE_READ_ALL } from '../routes/capability-constants.js';
import { auditTargetURLsPatchGuard } from '../support/audit-target-urls-validation.js';
import { updateRumConfig } from '../support/rum-config-service.js';
import { triggerBrandProfileAgent } from '../support/brand-profile-trigger.js';
import {
ensureSiteEntitlementAndEnrollment,
logSiteOrphanedAfterCreate,
resolveWriteTimeProductCode,
} from '../support/tier-provisioning.js';

/**
* Builds the standard resolve-site success payload.
Expand Down Expand Up @@ -354,6 +359,12 @@ function SitesController(ctx, log, env) {
*
* Alternative: If strict REST semantics are preferred, 409 Conflict is also valid.
*
* Write-time tier provisioning: optional `x-product` header enrolls the site for that product
* (creating a FREE_TRIAL org entitlement only when none exists or tier is already FREE_TRIAL).
* PAID, PLG, and PRE_ONBOARD entitlements are never downgraded; missing enrollments are still
* created. Keeps the site visible in product-scoped listings without separate onboarding.
* Enrollment failures return 500 with `event=site_orphaned_after_create`; retries are safe.
*
* @param {object} context - Request context containing site data
* @returns {Promise<Response>} HTTP 200 with existing site or 201 with new site
*/
Expand All @@ -364,27 +375,46 @@ function SitesController(ctx, log, env) {
if (!hasText(context.data?.baseURL)) {
return badRequest('Base URL required');
}
const { productCode, error: productCodeError } = resolveWriteTimeProductCode(context);
if (productCodeError) {
return badRequest(productCodeError);
}
let site;
let status;
try {
const baseURL = composeBaseURL(context.data.baseURL);
const existingSite = await Site.findByBaseURL(baseURL);
if (existingSite) {
// Idempotent behavior: return existing site with 200 (not 409)
log.info(`Site already exists for baseURL: ${baseURL}, returning existing site ${existingSite.getId()}`);
return createResponse(SiteDto.toJSON(existingSite), 200);
site = existingSite;
status = 200;
} else {
site = await Site.create({
organizationId: env.DEFAULT_ORGANIZATION_ID,
...context.data,
baseURL, // override with normalized value
});
updateRumConfig(site, context).catch((e) => {
log.warn(`[sites] RUM config update failed for ${site.getBaseURL()}: ${e.message}`);
});
status = 201;
}
const site = await Site.create({
organizationId: env.DEFAULT_ORGANIZATION_ID,
...context.data,
baseURL, // override with normalized value
});
updateRumConfig(site, context).catch((e) => {
log.warn(`[sites] RUM config update failed for ${site.getBaseURL()}: ${e.message}`);
});
return createResponse(SiteDto.toJSON(site), 201);
} catch (error) {
log.error(`Error creating site: ${error.message}`, error);
return internalServerError('Failed to create site');
}

if (productCode) {
try {
await ensureSiteEntitlementAndEnrollment(context, site, productCode, log);
} catch (error) {
logSiteOrphanedAfterCreate(log, site, productCode, error);
return internalServerError('Failed to ensure entitlement/enrollment for site');
}
}

return createResponse(SiteDto.toJSON(site), status);
};

/**
Expand Down
2 changes: 1 addition & 1 deletion src/support/access-control-util.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ const ANONYMOUS_ENDPOINTS = [
/^POST \/hooks\/site-detection.+/,
];
const SERVICE_CODE = 'dx_aem_perf';
const X_PRODUCT_HEADER = 'x-product';
export const X_PRODUCT_HEADER = 'x-product';

function isAnonymous(endpoint) {
return ANONYMOUS_ENDPOINTS.some((rgx) => rgx.test(endpoint));
Expand Down
Loading
Loading