Skip to content
9 changes: 8 additions & 1 deletion docs/openapi/organizations-api.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ 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).
operationId: createOrganization
requestBody:
required: true
Expand All @@ -13,6 +14,12 @@ organizations:
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
50 changes: 40 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,
resolveProductCode,
} 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,52 @@ function OrganizationsController(ctx, env) {

/**
* Creates an organization. The organization ID is generated automatically.
*
* Write-time tier provisioning: when an organization is newly created, it ensures org
* entitlement via TierClient using the existing tier when present, otherwise FREE_TRIAL.
* Idempotent re-POSTs do not run provisioning.
*
* @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 } = resolveProductCode(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 && status === 201) {
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
49 changes: 39 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,
resolveProductCode,
} from '../support/tier-provisioning.js';

/**
* Builds the standard resolve-site success payload.
Expand Down Expand Up @@ -354,6 +359,11 @@ function SitesController(ctx, log, env) {
*
* Alternative: If strict REST semantics are preferred, 409 Conflict is also valid.
*
* Write-time tier provisioning: when a site is newly created, it ensures org entitlement and
* site enrollment via TierClient using the existing tier when present, otherwise FREE_TRIAL.
* Idempotent re-POSTs do not run provisioning.
* Provisioning failures return 500 with `event=site_orphaned_after_create`.
*
* @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 +374,46 @@ function SitesController(ctx, log, env) {
if (!hasText(context.data?.baseURL)) {
return badRequest('Base URL required');
}
const { productCode, error: productCodeError } = resolveProductCode(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 && status === 201) {
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
111 changes: 111 additions & 0 deletions src/support/tier-provisioning.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
/*
* Copyright 2026 Adobe. All rights reserved.
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. You may obtain a copy
* of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
* OF ANY KIND, either express or implied. See the License for the specific language
* governing permissions and limitations under the License.
*/

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

import { X_PRODUCT_HEADER } from './access-control-util.js';

export { X_PRODUCT_HEADER };

const VALID_PRODUCT_CODES = new Set(Object.values(EntitlementModel.PRODUCT_CODES));
const FREE_TRIAL_TIER = EntitlementModel.TIERS.FREE_TRIAL;

/**
* Structured log marker when a site was persisted but entitlement/enrollment failed.
* Use in CloudWatch queries: `event=site_orphaned_after_create`.
*/
export const SITE_ORPHANED_AFTER_CREATE_EVENT = 'site_orphaned_after_create';

/**
* Reads and validates the `x-product` header for write-time tier provisioning.
* The header is set at the CDN layer; when absent, provisioning is skipped.
*
* @param {object} context - Request context with `pathInfo.headers`.
* @returns {{ productCode: string } | { error: string }} Validated product code or error message.
*/
export function resolveProductCode(context) {
const raw = context.pathInfo?.headers?.[X_PRODUCT_HEADER];
if (!hasText(raw)) {
return { error: null, productCode: null };
}
const productCode = raw.trim();
if (!VALID_PRODUCT_CODES.has(productCode)) {
const allowed = [...VALID_PRODUCT_CODES].join(', ');
return { error: `Unsupported product code. Must be one of: ${allowed}` };
}
return { error: null, productCode };
}

/**
* @param {object} tierClient - TierClient instance with `checkValidEntitlement`.
* @returns {Promise<string>} Tier to pass to `createEntitlement`.
*/
async function resolveProvisioningTier(tierClient) {
const existing = await tierClient.checkValidEntitlement();
return existing.entitlement?.getTier?.() ?? FREE_TRIAL_TIER;
}

/**
* Ensures org-level entitlement for `productCode` on newly created organizations.
*
* @param {object} context - Request context.
* @param {object} organization - Organization entity.
* @param {string} productCode - Validated product code.
* @param {object} log - Logger.
* @returns {Promise<object>} Created or updated entitlement entity.
*/
export async function ensureOrgEntitlement(context, organization, productCode, log) {
const tierClient = TierClient.createForOrg(context, organization, productCode);
const tier = await resolveProvisioningTier(tierClient);
const { entitlement } = await tierClient.createEntitlement(tier);
log.info(`Ensured ${productCode} entitlement ${entitlement.getId()} for organization ${organization.getId()}`);
return entitlement;
}

/**
* Ensures org entitlement and site enrollment for `productCode` on newly created sites.
*
* @param {object} context - Request context.
* @param {object} site - Site entity.
* @param {string} productCode - Validated product code.
* @param {object} log - Logger.
* @returns {Promise<{ entitlement: object, siteEnrollment?: object }>}
*/
export async function ensureSiteEntitlementAndEnrollment(context, site, productCode, log) {
const tierClient = await TierClient.createForSite(context, site, productCode);
const tier = await resolveProvisioningTier(tierClient);
const {
entitlement,
siteEnrollment,
} = await tierClient.createEntitlement(tier);
const enrollmentSuffix = siteEnrollment ? ` and enrollment ${siteEnrollment.getId()}` : '';
log.info(`Ensured ${productCode} entitlement ${entitlement.getId()}${enrollmentSuffix} for site ${site.getId()}`);
return { entitlement, siteEnrollment };
}

/**
* Logs a structured orphan-site event after site create succeeded but provisioning failed.
*
* @param {object} log - Logger.
* @param {object} site - Persisted site entity.
* @param {string} productCode - Product code from the request header.
* @param {Error} error - Provisioning error.
*/
export function logSiteOrphanedAfterCreate(log, site, productCode, error) {
log.error(
`event=${SITE_ORPHANED_AFTER_CREATE_EVENT} siteId=${site.getId()} productCode=${productCode} `
+ `message=${error.message}`,
error,
);
}
Loading
Loading