Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
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'
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
13 changes: 13 additions & 0 deletions docs/openapi/parameters.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -397,6 +397,19 @@ xPromiseToken:
type: string
example: eyJhbGciOiJSUzI1NiIs...

xProduct:
name: x-product
description: |
Product code identifying which Spacecat product the call is scoped to (e.g. `ASO`, `LLMO`).
Used both for read-time filtering (e.g. listing only sites enrolled in the requested product)
and write-time tier-model compliance (e.g. auto-creating a `FREE_TRIAL` entitlement and site
enrollment when a site/organization is created).
in: header
required: false
schema:
type: string
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
(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
2,192 changes: 489 additions & 1,703 deletions package-lock.json

Large diffs are not rendered by default.

65 changes: 57 additions & 8 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,6 +23,8 @@ 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';
Expand Down Expand Up @@ -57,27 +60,73 @@ function OrganizationsController(ctx, env) {

const accessControlUtil = AccessControlUtil.fromContext(ctx);

/**
* Ensures the organization has an entitlement for the given product.
* `TierClient.createEntitlement` is idempotent at the library level: an existing
* entitlement is returned as-is, otherwise a new one is created. Errors (invalid tier,
* DB failures) bubble up to the caller.
*
* Triggered when callers pass an `x-product` header on POST /organizations; without the
* header the call is a no-op (preserves backward compatibility for existing integrations).
*
* @param {object} context - Request context (forwarded to TierClient).
* @param {object} organization - The newly created or existing organization entity.
* @param {string} productCode - Product code from the `x-product` header.
*/
const ensureOrgEntitlement = async (context, organization, productCode) => {
const { log } = ctx;
const tierClient = await TierClient.createForOrg(context, organization, productCode);
const { entitlement } = await tierClient.createEntitlement(
EntitlementModel.TIERS.FREE_TRIAL,
);
log.info(`Ensured ${productCode} entitlement ${entitlement.getId()} for organization ${organization.getId()}`);
};

/**
* Creates an organization. The organization ID is generated automatically.
*
* Tier-model compliance: when the caller provides an `x-product` header, a FREE_TRIAL
* entitlement for that product is created for the organization (idempotent — existing
* entitlements are reused). This brings POST /organizations in line with the entitlement
* model so that downstream product-scoped APIs can resolve the org without a separate
* manual provisioning step. Entitlement failures return 500 — `TierClient.createEntitlement`
* is idempotent so retries are safe.
*
* @param {object} context - Context of the request.
* @return {Promise<Response>} Organization response.
*/
const createOrganization = async (context) => {
if (!accessControlUtil.hasAdminAccess()) {
return forbidden('Only admins can create new Organizations');
}
const productCode = context.pathInfo?.headers?.['x-product'];
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 (hasText(productCode)) {
try {
await ensureOrgEntitlement(context, organization, productCode);
} catch (error) {
const { log } = ctx;
log.error(`Error ensuring ${productCode} entitlement for organization ${organization.getId()}: ${error.message}`, error);
return internalServerError(`Failed to ensure ${productCode} entitlement for organization`);
}
}

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

/**
Expand Down
67 changes: 56 additions & 11 deletions src/controllers/sites.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ import {
canonicalizeUrl,
composeBaseURL,
} from '@adobe/spacecat-shared-utils';
import { Site as SiteModel } from '@adobe/spacecat-shared-data-access';
import { Site as SiteModel, Entitlement as EntitlementModel } from '@adobe/spacecat-shared-data-access';
import { Config } from '@adobe/spacecat-shared-data-access/src/models/site/config.js';

import RUMAPIClient from '@adobe/spacecat-shared-rum-api-client';
Expand Down Expand Up @@ -272,6 +272,28 @@ function SitesController(ctx, log, env) {

const accessControlUtil = AccessControlUtil.fromContext(ctx);

/**
* Ensures the org has an entitlement for the given product and the site is enrolled
* under it. `TierClient.createEntitlement` is idempotent at the library level: existing
* entitlements/enrollments are returned as-is, missing pieces are filled in. Errors
* (invalid tier, DB failures) bubble up to the caller.
*
* Triggered when callers pass an `x-product` header on POST /sites; without the header
* the call is a no-op (preserves backward compatibility for existing integrations).
*
* @param {object} context - Request context (forwarded to TierClient).
* @param {object} site - The newly created or existing site entity.
* @param {string} productCode - Product code from the `x-product` header.
*/
const ensureSiteEntitlementAndEnrollment = async (context, site, productCode) => {
const tierClient = await TierClient.createForSite(context, site, productCode);
const {
entitlement,
siteEnrollment,
} = await tierClient.createEntitlement(EntitlementModel.TIERS.FREE_TRIAL);
log.info(`Ensured ${productCode} entitlement ${entitlement.getId()} and enrollment ${siteEnrollment?.getId()} for site ${site.getId()}`);
};

/**
* Creates a new site or returns an existing one if a site with the same baseURL already exists.
* Implements idempotent-create semantics.
Expand All @@ -286,6 +308,13 @@ function SitesController(ctx, log, env) {
*
* Alternative: If strict REST semantics are preferred, 409 Conflict is also valid.
*
* Tier-model compliance: when the caller provides an `x-product` header, the site is
* auto-enrolled into a FREE_TRIAL entitlement for that product (the org-level entitlement
* is created if missing). This keeps the site visible in product-scoped listing endpoints
* (e.g. `GET /organizations/:organizationId/sites`) without a separate manual onboarding
* step. Enrollment failures return 500 — `TierClient.createEntitlement` is idempotent so
* retries are safe (the site lookup will return the already-persisted site).
*
* @param {object} context - Request context containing site data
* @returns {Promise<Response>} HTTP 200 with existing site or 201 with new site
*/
Expand All @@ -296,27 +325,43 @@ function SitesController(ctx, log, env) {
if (!hasText(context.data?.baseURL)) {
return badRequest('Base URL required');
}
const productCode = context.pathInfo?.headers?.['x-product'];
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 (hasText(productCode)) {
try {
await ensureSiteEntitlementAndEnrollment(context, site, productCode);
} catch (error) {
log.error(`Error ensuring ${productCode} entitlement/enrollment for site ${site.getId()}: ${error.message}`, error);
return internalServerError(`Failed to ensure ${productCode} entitlement/enrollment for site`);
}
}

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

/**
Expand Down
Loading
Loading