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
2 changes: 1 addition & 1 deletion .talismanrc
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
fileignoreconfig:
- filename: pnpm-lock.yaml
checksum: 4bad5f9428f5bc7ed837c91567b28afc97acefa7086eb03d2ffd90b4a2820233
checksum: 967802b1b9a598c3a6e3d6164cdb949d763d024486ec549341549bf2f9c38164
version: '1.0'
2 changes: 1 addition & 1 deletion packages/contentstack-command/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ abstract class ContentstackCommand extends Command {

get context() {
// @ts-ignore
return this.config.context || {};
return this.config.context || this.config.options?.context || {};
}

get email() {
Expand Down
19 changes: 19 additions & 0 deletions packages/contentstack-config/src/commands/config/set/region.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ export default class RegionSetCommand extends BaseCommand<typeof RegionSetComman
'cs-assets': _flags.string({
description: 'Custom host to set for Contentstack Assets API',
}),
'auth-api': _flags.string({
description: 'Custom host to set for Auth API',
}),
};
static examples = [
'$ csdx config:set:region',
Expand Down Expand Up @@ -82,6 +85,7 @@ export default class RegionSetCommand extends BaseCommand<typeof RegionSetComman
let launchHubUrl = regionSetFlags['launch'];
let composableStudioUrl = regionSetFlags['studio'];
let csAssetsUrl = regionSetFlags['cs-assets'];
let authUrl = regionSetFlags['auth-api'];
let selectedRegion = args.region;
if (!(cda && cma && uiHost && name) && !selectedRegion) {
selectedRegion = await interactive.askRegions();
Expand Down Expand Up @@ -115,6 +119,9 @@ export default class RegionSetCommand extends BaseCommand<typeof RegionSetComman
if (!csAssetsUrl) {
csAssetsUrl = this.transformUrl(cma, 'am-api');
}
if (!authUrl) {
authUrl = this.transformUrl(cma, 'auth-api');
}
let customRegion: Region = {
cda,
cma,
Expand All @@ -125,6 +132,17 @@ export default class RegionSetCommand extends BaseCommand<typeof RegionSetComman
launchHubUrl,
composableStudioUrl,
csAssetsUrl,
endpoints: {
contentManagement: cma,
contentDelivery: cda,
application: uiHost,
developerHub: developerHubUrl,
launch: launchHubUrl,
personalizeManagement: personalizeUrl,
composableStudio: composableStudioUrl,
assetManagement: csAssetsUrl,
auth: authUrl,
},
};
customRegion = regionHandler.setCustomRegion(customRegion);
await authHandler.setConfigData('logout'); //Todo: Handle this logout flow well through logout command call
Expand All @@ -137,6 +155,7 @@ export default class RegionSetCommand extends BaseCommand<typeof RegionSetComman
cliux.success(`Launch URL: ${customRegion.launchHubUrl}`);
cliux.success(`Studio URL: ${customRegion.composableStudioUrl}`);
cliux.success(`Contentstack Assets URL: ${customRegion.csAssetsUrl}`);
cliux.success(`Auth API URL: ${customRegion.endpoints?.auth}`);
} catch (error) {
handleAndLogError(error, { ...this.contextDetails, module: 'config-set-region' });
}
Expand Down
1 change: 1 addition & 0 deletions packages/contentstack-config/src/interfaces/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export interface Region {
launchHubUrl: string;
composableStudioUrl: string;
csAssetsUrl?: string;
endpoints?: Record<string, string>;
}

export interface Limit {
Expand Down
55 changes: 20 additions & 35 deletions packages/contentstack-config/src/utils/region-handler.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { configHandler } from '@contentstack/cli-utilities';
import { getContentstackEndpoint } from '@contentstack/utils';
import { configHandler, resolveCanonicalEndpoints, buildRegionFromEndpoints } from '@contentstack/cli-utilities';
import { Region, RegionsMap } from '../interfaces';

function validURL(str) {
Expand All @@ -23,28 +22,10 @@ function validURL(str) {
* @returns {object} Region object with all necessary URLs
*/
function getRegionObject(regionKey: string): Region {
try {
// getContentstackEndpoint handles all aliases defined in regions.json
const endpoints = getContentstackEndpoint(regionKey) as any;

if (typeof endpoints === 'string') {
throw new Error('Invalid endpoint response');
}

return {
name: regionKey,
cma: endpoints.contentManagement,
cda: endpoints.contentDelivery,
uiHost: endpoints.application,
developerHubUrl: endpoints.developerHub,
launchHubUrl: endpoints.launch,
personalizeUrl: endpoints.personalizeManagement,
composableStudioUrl: endpoints.composableStudio,
csAssetsUrl: endpoints.assetManagement,
};
} catch {
return null;
}
// resolveCanonicalEndpoints handles all aliases defined in regions.json
const endpoints = resolveCanonicalEndpoints(regionKey);
if (!endpoints) return null;
return buildRegionFromEndpoints(regionKey, endpoints) as Region;
}

/**
Expand Down Expand Up @@ -147,19 +128,23 @@ class UserConfig {
* @returns { object } JSON object with only valid keys for region
*/
sanitizeRegionObject(regionObject) {
const sanitizedRegion = {
cma: regionObject.cma,
cda: regionObject.cda,
uiHost: regionObject.uiHost,
name: regionObject.name,
developerHubUrl: regionObject['developerHubUrl'],
personalizeUrl: regionObject['personalizeUrl'],
launchHubUrl: regionObject['launchHubUrl'],
composableStudioUrl: regionObject['composableStudioUrl'],
csAssetsUrl: regionObject['csAssetsUrl'],
// endpoints is the single source of truth — every friendly field (cma, cda, ...,
// auth, csAssetsUrl) is derived from it below via buildRegionFromEndpoints, same
// as named regions. Falls back to synthesizing endpoints from the individual raw
// fields when the caller didn't supply one directly.
const endpoints = regionObject['endpoints'] ?? {
contentManagement: regionObject.cma,
contentDelivery: regionObject.cda,
application: regionObject.uiHost,
developerHub: regionObject['developerHubUrl'],
launch: regionObject['launchHubUrl'],
personalizeManagement: regionObject['personalizeUrl'],
composableStudio: regionObject['composableStudioUrl'],
assetManagement: regionObject['csAssetsUrl'],
auth: regionObject['authUrl'],
};

return sanitizedRegion;
return buildRegionFromEndpoints(regionObject.name, endpoints);
}
}

Expand Down
82 changes: 81 additions & 1 deletion packages/contentstack-config/test/unit/commands/region.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -313,7 +313,87 @@ describe('Region command', function () {
csAssetsUrl: 'https://custom-asset-management.com',
};
const result = UserConfig.setCustomRegion(customRegion);
expect(result).to.deep.equal(customRegion);
expect(result.cma).to.equal(customRegion.cma);
expect(result.cda).to.equal(customRegion.cda);
expect(result.uiHost).to.equal(customRegion.uiHost);
expect(result.name).to.equal(customRegion.name);
expect(result.developerHubUrl).to.equal(customRegion.developerHubUrl);
expect(result.personalizeUrl).to.equal(customRegion.personalizeUrl);
expect(result.launchHubUrl).to.equal(customRegion.launchHubUrl);
expect(result.composableStudioUrl).to.equal(customRegion.composableStudioUrl);
});

it('should include a matching endpoints object for a custom region without an auth host', function () {
const customRegion = {
cma: 'https://custom-cma.com',
cda: 'https://custom-cda.com',
uiHost: 'https://custom-ui.com',
name: 'Custom Region',
developerHubUrl: 'https://custom-developer-hub.com',
personalizeUrl: 'https://custom-personalize.com',
launchHubUrl: 'https://custom-launch.com',
composableStudioUrl: 'https://custom-composable-studio.com',
};
const result = UserConfig.setCustomRegion(customRegion);
expect(result.endpoints).to.deep.equal({
contentManagement: customRegion.cma,
contentDelivery: customRegion.cda,
application: customRegion.uiHost,
developerHub: customRegion.developerHubUrl,
launch: customRegion.launchHubUrl,
personalizeManagement: customRegion.personalizeUrl,
composableStudio: customRegion.composableStudioUrl,
assetManagement: undefined,
auth: undefined,
});
});

it('should preserve an explicitly supplied endpoints object for a custom region', function () {
const endpoints = {
contentManagement: 'https://custom-cma.com',
contentDelivery: 'https://custom-cda.com',
application: 'https://custom-ui.com',
auth: 'https://custom-auth.com',
};
const customRegion = {
cma: 'https://custom-cma.com',
cda: 'https://custom-cda.com',
uiHost: 'https://custom-ui.com',
name: 'Custom Region',
endpoints,
};
const result = UserConfig.setCustomRegion(customRegion);
expect(result.cma).to.equal(endpoints.contentManagement);
expect(result.cda).to.equal(endpoints.contentDelivery);
expect(result.uiHost).to.equal(endpoints.application);
expect(result.endpoints).to.deep.equal(endpoints);
});

it('should not fall back to a stale legacy authUrl input once endpoints.auth is supplied', function () {
// endpoints is the single source of truth: a stale/mismatched top-level
// authUrl on the raw input is never used — only endpoints.auth matters.
const customRegion = {
cma: 'https://custom-cma.com',
cda: 'https://custom-cda.com',
uiHost: 'https://custom-ui.com',
name: 'Custom Region',
authUrl: 'https://stale-auth.com',
endpoints: {
contentManagement: 'https://custom-cma.com',
contentDelivery: 'https://custom-cda.com',
application: 'https://custom-ui.com',
auth: 'https://correct-auth.com',
},
};
const result = UserConfig.setCustomRegion(customRegion);
expect(result.endpoints.auth).to.equal('https://correct-auth.com');
});

it('should include the full endpoints passthrough (incl. auth) for named regions', function () {
const result = UserConfig.setRegion('NA');
expect(result.endpoints).to.be.an('object');
expect(result.endpoints.auth).to.equal('https://auth-api.contentstack.com');
expect(result.endpoints.contentManagement).to.equal(result.cma);
});

it('should sanitize region object to only include valid properties', function () {
Expand Down
1 change: 1 addition & 0 deletions packages/contentstack-utilities/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"dependencies": {
"@contentstack/management": "~1.30.3",
"@contentstack/marketplace-sdk": "^1.5.2",
"@contentstack/utils": "~1.9.1",
"@oclif/core": "^4.11.4",
"axios": "^1.16.1",
"chalk": "^5.6.2",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import configHandler from '../config-handler';

export interface AuthHeaders {
[key: string]: string;
}

export function buildAuthHeaders(ctx?: {
managementToken?: string;
apiKey?: string;
authToken?: string;
orgUid?: string;
}): AuthHeaders {
// 1. Management token takes priority (no 'Bearer' prefix — API contract)
if (ctx?.managementToken && ctx?.apiKey) {
return {
Authorization: ctx.managementToken,
api_key: ctx.apiKey,
};
}

// 2. OAuth
const oauthToken = configHandler.get('oauthAccessToken') as string | undefined;
if (oauthToken) {
return { Authorization: `Bearer ${oauthToken}` };
}

// 3. Authtoken + organization_uid
const authtoken = ctx?.authToken ?? (configHandler.get('authtoken') as string | undefined);
const orgUid = ctx?.orgUid ?? (configHandler.get('oauthOrgUid') as string | undefined);
if (authtoken && orgUid) {
return { authtoken, organization_uid: orgUid };
}

// 4. Authtoken + api_key
if (authtoken && ctx?.apiKey) {
return { authtoken, api_key: ctx.apiKey };
}

throw new Error(
'PLAN_CHECK: Cannot build auth headers — no valid credentials available. ' +
'Please log in or provide a management token alias.',
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { HttpClient } from '../http-client';
import { resolveAuthHost } from './resolve-auth-host';
import { buildAuthHeaders } from './build-auth-headers';
import { FeatureStatus, FeatureCtx } from './types';

export async function isFeatureEnabled(featureUid: string, ctx?: FeatureCtx): Promise<FeatureStatus> {
const host = resolveAuthHost(ctx);
const headers = buildAuthHeaders(ctx);

const client = new HttpClient();
client.baseUrl(host).headers(headers);

const res = await client.get<FeatureStatus>(
`/v1/feature-status?feature_uid=${encodeURIComponent(featureUid)}`,
);

if (res.status < 200 || res.status >= 300) {
throw new Error(`PLAN_CHECK: feature-status API returned ${res.status} for "${featureUid}".`);
}

return res.data as FeatureStatus;
}

export async function assertFeatureEnabled(featureUid: string, ctx?: FeatureCtx): Promise<FeatureStatus> {
let status: FeatureStatus;
try {
status = await isFeatureEnabled(featureUid, ctx);
} catch (e) {
throw new Error(
`Could not verify your plan for "${featureUid}". ` +
`This command requires a confirmed plan status to run. ` +
`Please retry; if the problem persists contact support. (${(e as Error).message})`,
);
}

if (!status.is_part_of_plan) {
throw new Error(
`"${featureUid}" is not part of your current plan. Please upgrade your plan to use this feature.`,
);
}

if (status.status !== 'enabled') {
throw new Error(
`"${featureUid}" is not enabled for your plan (status: ${status.status}).`,
);
}

return status;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export const FEATURE = {
ASSET_MANAGEMENT: 'amAssets',
ASSET_SCANNING: 'assetsScan',
} as const;

export type FeatureUid = (typeof FEATURE)[keyof typeof FEATURE];
5 changes: 5 additions & 0 deletions packages/contentstack-utilities/src/feature-status/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export * from './types';
export * from './feature-uids';
export { resolveAuthHost } from './resolve-auth-host';
export { buildAuthHeaders } from './build-auth-headers';
export { isFeatureEnabled, assertFeatureEnabled } from './feature-status-handler';
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import configHandler from '../config-handler';

// Mirrors config:set:region's own custom-region fallback (region.ts's transformUrl) —
// same heuristic, applied retroactively for a custom region that predates auth-api.
// Computed fresh on each call, never persisted back to config.
function deriveAuthFromCma(cma: string): string {
let transformed = cma.replace('api', 'auth-api');
if (transformed.startsWith('http')) {
transformed = transformed.split('//')[1];
}
transformed = transformed.replace(/^dev\d+/, 'dev');
transformed = transformed.endsWith('io') ? transformed.replace('io', 'com') : transformed;
return `https://${transformed}`;
}

export function resolveAuthHost(ctx?: { region?: { endpoints?: { auth?: string }; cma?: string } }): string {
const region = (ctx?.region ?? configHandler.get('region')) as
| { endpoints?: { auth?: string }; cma?: string }
| undefined;

let authUrl = region?.endpoints?.auth;
if (!authUrl && region?.cma) {
authUrl = deriveAuthFromCma(region.cma);
}

if (!authUrl) {
throw new Error(
'PLAN_CHECK: Auth host is not configured for the current region. ' +
"Re-run `csdx config:set:region` to refresh region endpoints.",
);
}
return String(authUrl).replace(/\/$/, '');
}
Loading
Loading