diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 994fd9df..d97cdf33 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -85,6 +85,7 @@ const guideSidebar = [ {text: 'SLAS Commands', link: '/cli/slas'}, {text: 'Custom APIs', link: '/cli/custom-apis'}, {text: 'SCAPI Schemas', link: '/cli/scapi-schemas'}, + {text: 'SCAPI CORS Commands', link: '/cli/scapi-cors'}, {text: 'Setup Commands', link: '/cli/setup'}, {text: 'Scaffold Commands', link: '/cli/scaffold'}, {text: 'Docs Commands', link: '/cli/docs'}, diff --git a/docs/cli/scapi-cors.md b/docs/cli/scapi-cors.md new file mode 100644 index 00000000..3fb69d74 --- /dev/null +++ b/docs/cli/scapi-cors.md @@ -0,0 +1,221 @@ +# SCAPI CORS Commands + +Commands for managing Cross-Origin Resource Sharing (CORS) preferences for B2C Commerce sites. + +CORS preferences define which domains are permitted to access a site's APIs, creating exceptions to the same-origin policy that browsers enforce. + +## Global SCAPI CORS Flags + +These flags are available on all SCAPI CORS commands: + +| Flag | Environment Variable | Description | +|------|---------------------|-------------| +| `--tenant-id` | `SFCC_TENANT_ID` | (Required) Organization/tenant ID | +| `--short-code` | `SFCC_SHORTCODE` | SCAPI short code | +| `--site-id`, `-s` | `SFCC_SITE_ID` | (Required) Site ID to manage CORS for | + +## Authentication + +SCAPI CORS commands require an Account Manager API Client with OAuth credentials. The same client ID used for OAuth authentication is also the subject of the CORS configuration — per the CORS Preferences API spec, you configure CORS origins for the client you authenticate with. + +### Required Scopes + +The following scopes are automatically requested by the CLI: + +| Scope | Description | Commands | +|-------|-------------|---------| +| `sfcc.cors-preferences.rw` | Read-write access to CORS Preferences API | `get`, `set`, `delete` | +| `SALESFORCE_COMMERCE_API:` | Tenant-specific access scope | all | + +### Configuration + +```bash +# Set credentials via environment variables +export SFCC_CLIENT_ID=my-client-id +export SFCC_CLIENT_SECRET=my-secret +export SFCC_TENANT_ID=zzxy_prd +export SFCC_SHORTCODE=kv7kzm78 +export SFCC_SITE_ID=RefArch + +# Or provide via flags +b2c scapi cors get --client-id my-client-id --client-secret my-secret --tenant-id zzxy_prd --site-id RefArch +``` + +For complete setup instructions, see the [Authentication Guide](/guide/authentication#scapi-authentication). + +--- + +## b2c scapi cors get + +Get all CORS preferences configured for a site. + +### Usage + +```bash +b2c scapi cors get --tenant-id --site-id +``` + +### Flags + +| Flag | Short | Description | Default | +|------|-------|-------------|---------| +| `--tenant-id` | | (Required) Organization/tenant ID | | +| `--site-id` | `-s` | (Required) Site ID (1–32 characters) | `SFCC_SITE_ID` | +| `--json` | | Output results as JSON | `false` | + +### Examples + +```bash +# Get CORS preferences for a site +b2c scapi cors get --tenant-id zzxy_prd --site-id RefArch + +# Output as JSON +b2c scapi cors get --tenant-id zzxy_prd --site-id RefArch --json +``` + +### Output + +Default table output: + +``` +Found 1 client configuration(s) for site RefArch: + +Client ID Allowed Origins +────────────────────────────────────────────────────────────────── +abc123-0a37-4edb-a8e6-77f0aa72706d http://foo.com, https://bar.com +``` + +If no CORS preferences are configured: + +``` +No CORS preferences configured for site RefArch. +``` + +JSON output (`--json`): + +```json +{ + "siteId": "RefArch", + "corsClientPreferences": [ + { + "clientId": "abc123-0a37-4edb-a8e6-77f0aa72706d", + "origins": ["http://foo.com", "https://bar.com"] + } + ] +} +``` + +--- + +## b2c scapi cors set + +Create or replace all CORS preferences for a site. This is a full replacement — all existing preferences for the site are overwritten with the provided configuration. + +### Usage + +```bash +b2c scapi cors set --tenant-id --site-id --client-id [--origins ] +``` + +### Flags + +| Flag | Short | Description | Default | +|------|-------|-------------|---------| +| `--tenant-id` | | (Required) Organization/tenant ID | | +| `--site-id` | `-s` | (Required) Site ID (1–32 characters) | `SFCC_SITE_ID` | +| `--client-id` | | (Required) Account Manager client ID to configure CORS for | `SFCC_CLIENT_ID` | +| `--origins` | | Comma-separated list of allowed origins | `""` (known domains only) | +| `--json` | | Output results as JSON | `false` | + +### Origin Format + +Origins must follow the format `://.` — no port numbers or paths are allowed: + +| Origin | Valid | +|--------|-------| +| `http://foo.com` | ✅ | +| `https://bar.baz.com` | ✅ | +| `myapp://example.com` | ✅ | +| `http://foo.com:8080` | ❌ Port not allowed | +| `http://foo.com/path` | ❌ Path not allowed | +| `http://localhost` | ❌ Bare hostname (no TLD) | + +An empty `--origins ""` enables CORS for the client without custom origins — all known domain names and aliases for the site are automatically included. + +### Examples + +```bash +# Set CORS with specific allowed origins +b2c scapi cors set --tenant-id zzxy_prd --site-id RefArch --client-id my-client-id --origins http://foo.com,https://bar.com + +# Enable CORS for known domains only (no custom origins) +b2c scapi cors set --tenant-id zzxy_prd --site-id RefArch --client-id my-client-id --origins "" + +# Output result as JSON +b2c scapi cors set --tenant-id zzxy_prd --site-id RefArch --client-id my-client-id --origins http://foo.com --json +``` + +### Output + +``` +CORS preferences for site RefArch updated successfully. +``` + +JSON output (`--json`): + +```json +{ + "siteId": "RefArch", + "corsClientPreferences": [ + { + "clientId": "my-client-id", + "origins": ["http://foo.com", "https://bar.com"] + } + ] +} +``` + +--- + +## b2c scapi cors delete + +Delete all CORS preferences for a site. After deletion, CORS will not be active for the site until new preferences are configured. + +### Usage + +```bash +b2c scapi cors delete --tenant-id --site-id +``` + +### Flags + +| Flag | Short | Description | Default | +|------|-------|-------------|---------| +| `--tenant-id` | | (Required) Organization/tenant ID | | +| `--site-id` | `-s` | (Required) Site ID (1–32 characters) | `SFCC_SITE_ID` | +| `--json` | | Output results as JSON | `false` | + +### Examples + +```bash +# Delete all CORS preferences for a site +b2c scapi cors delete --tenant-id zzxy_prd --site-id RefArch + +# Output as JSON +b2c scapi cors delete --tenant-id zzxy_prd --site-id RefArch --json +``` + +### Output + +``` +CORS preferences for site RefArch deleted successfully. +``` + +JSON output (`--json`): + +```json +{ + "siteId": "RefArch", + "deleted": true +} +``` diff --git a/packages/b2c-cli/package.json b/packages/b2c-cli/package.json index 378481ab..4142b5f7 100644 --- a/packages/b2c-cli/package.json +++ b/packages/b2c-cli/package.json @@ -195,6 +195,9 @@ }, "schemas": { "description": "Browse and retrieve SCAPI schema specifications\n\nDocs: https://salesforcecommercecloud.github.io/b2c-developer-tooling/cli/scapi-schemas.html" + }, + "cors": { + "description": "Manage CORS Preferences for SCAPI sites\n\nDocs: https://salesforcecommercecloud.github.io/b2c-developer-tooling/cli/scapi-cors.html" } } }, diff --git a/packages/b2c-cli/src/commands/scapi/cors/delete.ts b/packages/b2c-cli/src/commands/scapi/cors/delete.ts new file mode 100644 index 00000000..50cad62f --- /dev/null +++ b/packages/b2c-cli/src/commands/scapi/cors/delete.ts @@ -0,0 +1,83 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +import {Flags} from '@oclif/core'; +import {ScapiCorsCommand, getApiErrorMessage} from '../../../utils/scapi/cors.js'; +import {t, withDocs} from '../../../i18n/index.js'; + +/** + * Response type for the delete command. + */ +interface CorsDeleteOutput { + siteId: string; + deleted: boolean; +} + +/** + * Command to delete all CORS preferences for a site. + */ +export default class ScapiCorsDelete extends ScapiCorsCommand { + static description = withDocs( + t('commands.scapi.cors.delete.description', 'Delete all CORS preferences for a site'), + '/cli/scapi-cors.html#b2c-scapi-cors-delete', + ); + + static enableJsonFlag = true; + + static examples = [ + '<%= config.bin %> <%= command.id %> --tenant-id f_ecom_zzxy_prd --site-id RefArch', + '<%= config.bin %> <%= command.id %> --tenant-id f_ecom_zzxy_prd --site-id RefArch --json', + ]; + + static flags = { + ...ScapiCorsCommand.baseFlags, + 'site-id': Flags.string({ + char: 's', + description: t('flags.siteId.description', 'The site ID to delete CORS preferences for'), + env: 'SFCC_SITE_ID', + }), + }; + + protected getProgressMessage(): string { + return t('commands.scapi.cors.delete.deleting', 'Deleting CORS preferences...'); + } + + async run(): Promise { + this.requireOAuthCredentials(); + + const siteId = this.requireSiteId(); + const client = this.getCorsClient(); + const organizationId = this.getOrganizationId(); + + const {error, response} = await client.DELETE('/organizations/{organizationId}/cors', { + params: { + path: {organizationId}, + query: {siteId}, + }, + }); + + if (error) { + this.error( + t('commands.scapi.cors.delete.error', 'Failed to delete CORS preferences: {{message}}', { + message: getApiErrorMessage(error, response), + }), + ); + } + + const output: CorsDeleteOutput = {siteId, deleted: true}; + + if (this.jsonEnabled()) { + return output; + } + + this.log( + t('commands.scapi.cors.delete.success', 'CORS preferences for site {{siteId}} deleted successfully.', { + siteId, + }), + ); + + return output; + } +} diff --git a/packages/b2c-cli/src/commands/scapi/cors/get.ts b/packages/b2c-cli/src/commands/scapi/cors/get.ts new file mode 100644 index 00000000..100a8966 --- /dev/null +++ b/packages/b2c-cli/src/commands/scapi/cors/get.ts @@ -0,0 +1,111 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +import {Flags} from '@oclif/core'; +import {TableRenderer, type ColumnDef} from '@salesforce/b2c-tooling-sdk/cli'; +import type {CorsClientPreferences} from '@salesforce/b2c-tooling-sdk/clients'; +import {ScapiCorsCommand, getApiErrorMessage} from '../../../utils/scapi/cors.js'; +import {t, withDocs} from '../../../i18n/index.js'; + +/** + * Response type for the get command. + */ +interface CorsGetOutput { + siteId: string; + corsClientPreferences: CorsClientPreferences[]; +} + +const COLUMNS: Record> = { + clientId: { + header: 'Client ID', + get: (p) => p.clientId, + }, + origins: { + header: 'Allowed Origins', + get: (p) => (p.origins.length > 0 ? p.origins.join(', ') : '(none)'), + }, +}; + +const DEFAULT_COLUMNS = ['clientId', 'origins']; + +const tableRenderer = new TableRenderer(COLUMNS); + +/** + * Command to get CORS preferences for a site. + */ +export default class ScapiCorsGet extends ScapiCorsCommand { + static description = withDocs( + t('commands.scapi.cors.get.description', 'Get CORS preferences for a site'), + '/cli/scapi-cors.html#b2c-scapi-cors-get', + ); + + static enableJsonFlag = true; + + static examples = [ + '<%= config.bin %> <%= command.id %> --tenant-id f_ecom_zzxy_prd --site-id RefArch', + '<%= config.bin %> <%= command.id %> --tenant-id f_ecom_zzxy_prd --site-id RefArch --json', + ]; + + static flags = { + ...ScapiCorsCommand.baseFlags, + 'site-id': Flags.string({ + char: 's', + description: t('flags.siteId.description', 'The site ID to retrieve CORS preferences for'), + env: 'SFCC_SITE_ID', + }), + }; + + protected getProgressMessage(): string { + return t('commands.scapi.cors.get.fetching', 'Fetching CORS preferences...'); + } + + async run(): Promise { + this.requireOAuthCredentials(); + + const siteId = this.requireSiteId(); + const client = this.getCorsClient(); + const organizationId = this.getOrganizationId(); + + const {data, error, response} = await client.GET('/organizations/{organizationId}/cors', { + params: { + path: {organizationId}, + query: {siteId}, + }, + }); + + if (error) { + this.error( + t('commands.scapi.cors.get.error', 'Failed to fetch CORS preferences: {{message}}', { + message: getApiErrorMessage(error, response), + }), + ); + } + + const preferences = data?.corsClientPreferences ?? []; + const output: CorsGetOutput = {siteId, corsClientPreferences: preferences}; + + if (this.jsonEnabled()) { + return output; + } + + if (preferences.length === 0) { + this.log( + t('commands.scapi.cors.get.noPreferences', 'No CORS preferences configured for site {{siteId}}.', {siteId}), + ); + return output; + } + + this.log( + t('commands.scapi.cors.get.count', 'Found {{count}} client configuration(s) for site {{siteId}}:', { + count: preferences.length, + siteId, + }), + ); + this.log(''); + tableRenderer.render(preferences, DEFAULT_COLUMNS); + + return output; + } +} diff --git a/packages/b2c-cli/src/commands/scapi/cors/set.ts b/packages/b2c-cli/src/commands/scapi/cors/set.ts new file mode 100644 index 00000000..3252d0ae --- /dev/null +++ b/packages/b2c-cli/src/commands/scapi/cors/set.ts @@ -0,0 +1,139 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +import {Flags} from '@oclif/core'; +import type {CorsClientPreferences, CorsPreferences} from '@salesforce/b2c-tooling-sdk/clients'; +import {ScapiCorsCommand, getApiErrorMessage} from '../../../utils/scapi/cors.js'; +import {t, withDocs} from '../../../i18n/index.js'; + +/** + * Response type for the set command. + */ +interface CorsSetOutput { + siteId: string; + corsClientPreferences: CorsClientPreferences[]; +} + +/** + * Command to create or replace all CORS preferences for a site. + */ +export default class ScapiCorsSet extends ScapiCorsCommand { + static description = withDocs( + t('commands.scapi.cors.set.description', 'Create or replace all CORS preferences for a site'), + '/cli/scapi-cors.html#b2c-scapi-cors-set', + ); + + static enableJsonFlag = true; + + static examples = [ + '<%= config.bin %> <%= command.id %> --tenant-id f_ecom_zzxy_prd --site-id RefArch --client-id abc123 --origins http://foo.com,https://bar.com', + '<%= config.bin %> <%= command.id %> --tenant-id f_ecom_zzxy_prd --site-id RefArch --client-id abc123 --origins ""', + '<%= config.bin %> <%= command.id %> --tenant-id f_ecom_zzxy_prd --site-id RefArch --client-id abc123 --origins http://foo.com --json', + ]; + + static flags = { + ...ScapiCorsCommand.baseFlags, + 'site-id': Flags.string({ + char: 's', + description: t('flags.siteId.description', 'The site ID to configure CORS preferences for'), + env: 'SFCC_SITE_ID', + }), + // Overrides the OAuth --client-id base flag. The same client ID is used for both + // OAuth authentication (must have sfcc.cors-preferences.rw scope) and as the CORS + // body target — per the CORS Preferences API spec, these are the same client. + 'client-id': Flags.string({ + description: t( + 'commands.scapi.cors.set.clientIdFlag', + 'The Account Manager client ID to configure CORS origins for (also used as the OAuth credential)', + ), + env: 'SFCC_CLIENT_ID', + helpGroup: 'AUTH', + required: true, + }), + origins: Flags.string({ + description: t( + 'commands.scapi.cors.set.originsFlag', + "Comma-separated list of allowed origins in '://.' format. Use empty string to allow known domains only.", + ), + default: '', + }), + }; + + protected getProgressMessage(): string { + return t('commands.scapi.cors.set.setting', 'Setting CORS preferences...'); + } + + async run(): Promise { + this.requireOAuthCredentials(); + + const siteId = this.requireSiteId(); + const {'client-id': clientId, origins: originsRaw} = this.flags; + + if (!/^[-a-zA-Z0-9]+$/.test(clientId!)) { + this.error( + t( + 'commands.scapi.cors.set.clientIdInvalid', + 'client-id must contain only letters, numbers, and hyphens (got "{{clientId}}").', + {clientId}, + ), + ); + } + + const origins = originsRaw + ? originsRaw + .split(',') + .map((o) => o.trim()) + .filter(Boolean) + : []; + + const originPattern = /^[a-zA-Z_0-9][-.+a-zA-Z_0-9]+:\/\/[-.a-zA-Z_0-9]{1,253}$/; + const invalidOrigins = origins.filter((o) => !originPattern.test(o)); + if (invalidOrigins.length > 0) { + this.error( + t( + 'commands.scapi.cors.set.originsInvalid', + "Invalid origins (must be '://.' without port or path): {{origins}}", + {origins: invalidOrigins.join(', ')}, + ), + ); + } + + const body: CorsPreferences = { + corsClientPreferences: [{clientId: clientId!, origins}], + }; + + const client = this.getCorsClient(); + const organizationId = this.getOrganizationId(); + + const {data, error, response} = await client.PUT('/organizations/{organizationId}/cors', { + params: { + path: {organizationId}, + query: {siteId}, + }, + body, + }); + + if (error) { + this.error( + t('commands.scapi.cors.set.error', 'Failed to set CORS preferences: {{message}}', { + message: getApiErrorMessage(error, response), + }), + ); + } + + const preferences = data?.corsClientPreferences ?? []; + const output: CorsSetOutput = {siteId, corsClientPreferences: preferences}; + + if (this.jsonEnabled()) { + return output; + } + + this.log( + t('commands.scapi.cors.set.success', 'CORS preferences for site {{siteId}} updated successfully.', {siteId}), + ); + + return output; + } +} diff --git a/packages/b2c-cli/src/i18n/locales/en.ts b/packages/b2c-cli/src/i18n/locales/en.ts index fe4a578e..d26dc87b 100644 --- a/packages/b2c-cli/src/i18n/locales/en.ts +++ b/packages/b2c-cli/src/i18n/locales/en.ts @@ -149,6 +149,32 @@ export const en = { ideNotes: 'See IDE documentation for skill configuration:', }, }, + scapi: { + cors: { + get: { + description: 'Get CORS preferences for a site', + fetching: 'Fetching CORS preferences...', + noPreferences: 'No CORS preferences configured for site {{siteId}}.', + count: 'Found {{count}} client configuration(s) for site {{siteId}}:', + error: 'Failed to fetch CORS preferences: {{message}}', + }, + set: { + description: 'Create or replace all CORS preferences for a site', + setting: 'Setting CORS preferences...', + success: 'CORS preferences for site {{siteId}} updated successfully.', + error: 'Failed to set CORS preferences: {{message}}', + clientIdFlag: 'The Account Manager client ID to configure CORS origins for', + originsFlag: + "Comma-separated list of allowed origins in '://.' format. Use empty string to allow known domains only.", + }, + delete: { + description: 'Delete all CORS preferences for a site', + deleting: 'Deleting CORS preferences...', + success: 'CORS preferences for site {{siteId}} deleted successfully.', + error: 'Failed to delete CORS preferences: {{message}}', + }, + }, + }, scaffold: { list: { description: 'List available project scaffolds', diff --git a/packages/b2c-cli/src/utils/scapi/cors.ts b/packages/b2c-cli/src/utils/scapi/cors.ts new file mode 100644 index 00000000..af7da9b7 --- /dev/null +++ b/packages/b2c-cli/src/utils/scapi/cors.ts @@ -0,0 +1,105 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +import {Command} from '@oclif/core'; +import {OAuthCommand} from '@salesforce/b2c-tooling-sdk/cli'; +import {createScapiCorsClient, toOrganizationId, type ScapiCorsClient} from '@salesforce/b2c-tooling-sdk/clients'; +import {configureLogger, getLogger} from '@salesforce/b2c-tooling-sdk'; +import {t} from '../../i18n/index.js'; + +export {getApiErrorMessage} from '@salesforce/b2c-tooling-sdk/clients'; + +/** + * Base command for SCAPI CORS Preferences operations. + * Provides common helpers for interacting with the CORS Preferences API. + */ +export abstract class ScapiCorsCommand extends OAuthCommand { + /** + * Get the SCAPI CORS client, ensuring short code and tenant ID are configured. + */ + protected getCorsClient(): ScapiCorsClient { + const {shortCode, tenantId} = this.resolvedConfig.values; + + if (!shortCode) { + this.error( + t( + 'error.shortCodeRequired', + 'SCAPI short code required. Provide --short-code, set SFCC_SHORTCODE, or configure short-code in dw.json.', + ), + ); + } + if (!tenantId) { + this.error( + t( + 'error.tenantIdRequired', + 'tenant-id is required. Provide via --tenant-id flag, SFCC_TENANT_ID env var, or tenant-id in dw.json.', + ), + ); + } + + const oauthStrategy = this.getOAuthStrategy(); + return createScapiCorsClient({shortCode, tenantId}, oauthStrategy); + } + + /** + * Get the organization ID (with f_ecom_ prefix) from resolved config. + * @throws Error if tenant ID is not configured + */ + protected getOrganizationId(): string { + const {tenantId} = this.resolvedConfig.values; + if (!tenantId) { + this.error( + t( + 'error.tenantIdRequired', + 'tenant-id is required. Provide via --tenant-id flag, SFCC_TENANT_ID env var, or tenant-id in dw.json.', + ), + ); + } + return toOrganizationId(tenantId); + } + + /** + * Each CORS command provides its own progress message shown before flag parsing. + * This ensures the INFO log appears even when parse errors occur (e.g. missing flag values). + */ + protected abstract getProgressMessage(): string; + + /** + * Pre-configure the pino logger and emit the progress message before flag parsing starts. + * This ensures both the INFO progress line and ERROR messages use the proper pino format + * even for oclif parse errors (which fire before configureLogging() runs in super.init()). + */ + override async init(): Promise { + // Route logs to stdout when SFCC_LOG_TO_STDOUT is set, matching base-command behaviour. + const fd = process.env.SFCC_LOG_TO_STDOUT ? 1 : 2; + configureLogger({level: 'info', fd}); + this.logger = getLogger(); + + if (!this.jsonEnabled()) { + this.log(this.getProgressMessage()); + } + + return super.init(); + } + + /** + * Require site-id to be provided, with a helpful error message. + * @throws Error if site-id is not provided + */ + protected requireSiteId(): string { + const siteId = (this.flags as Record)['site-id'] as string | undefined; + if (!siteId) { + this.error(t('error.siteIdRequired', 'site-id is required. Provide via --site-id flag or SFCC_SITE_ID env var.')); + } + if (siteId.length > 32) { + this.error( + t('error.siteIdTooLong', 'site-id must be between 1 and 32 characters (got {{length}}).', { + length: siteId.length, + }), + ); + } + return siteId; + } +} diff --git a/packages/b2c-cli/test/commands/scapi/cors/delete.test.ts b/packages/b2c-cli/test/commands/scapi/cors/delete.test.ts new file mode 100644 index 00000000..1e78e2f2 --- /dev/null +++ b/packages/b2c-cli/test/commands/scapi/cors/delete.test.ts @@ -0,0 +1,120 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +import {expect} from 'chai'; +import sinon from 'sinon'; +import {Config} from '@oclif/core'; +import ScapiCorsDelete from '../../../../src/commands/scapi/cors/delete.js'; +import {isolateConfig, restoreConfig} from '@salesforce/b2c-tooling-sdk/test-utils'; +import {stubParse} from '../../../helpers/stub-parse.js'; + +describe('scapi cors delete', () => { + let config: Config; + + beforeEach(async () => { + isolateConfig(); + config = await Config.load(); + }); + + afterEach(() => { + sinon.restore(); + restoreConfig(); + }); + + it('returns deleted: true in JSON mode on success', async () => { + const command: any = new ScapiCorsDelete([], config); + + stubParse(command, {'tenant-id': 'zzxy_prd', 'site-id': 'RefArch'}, {}); + await command.init(); + + sinon.stub(command, 'requireOAuthCredentials').returns(void 0); + sinon.stub(command, 'jsonEnabled').returns(true); + sinon.stub(command, 'resolvedConfig').get(() => ({values: {shortCode: 'kv7kzm78', tenantId: 'zzxy_prd'}})); + sinon.stub(command, 'getOAuthStrategy').returns({ + getAuthorizationHeader: async () => 'Bearer test', + }); + + const fetchStub = sinon.stub(globalThis, 'fetch').resolves(new Response(null, {status: 204})); + + const result = await command.run(); + + expect(fetchStub.called).to.equal(true); + expect(result.siteId).to.equal('RefArch'); + expect(result.deleted).to.equal(true); + }); + + it('sends the siteId as a query parameter', async () => { + const command: any = new ScapiCorsDelete([], config); + + stubParse(command, {'tenant-id': 'zzxy_prd', 'site-id': 'SiteB'}, {}); + await command.init(); + + sinon.stub(command, 'requireOAuthCredentials').returns(void 0); + sinon.stub(command, 'jsonEnabled').returns(true); + sinon.stub(command, 'resolvedConfig').get(() => ({values: {shortCode: 'kv7kzm78', tenantId: 'zzxy_prd'}})); + sinon.stub(command, 'getOAuthStrategy').returns({ + getAuthorizationHeader: async () => 'Bearer test', + }); + + const fetchStub = sinon.stub(globalThis, 'fetch').callsFake(async (url: Request | string | URL) => { + const requestUrl = typeof url === 'string' ? url : url instanceof URL ? url.toString() : url.url; + expect(requestUrl).to.include('siteId=SiteB'); + return new Response(null, {status: 204}); + }); + + await command.run(); + expect(fetchStub.called).to.equal(true); + }); + + it('calls command.error when site-id is missing', async () => { + const command: any = new ScapiCorsDelete([], config); + + stubParse(command, {'tenant-id': 'zzxy_prd'}, {}); + await command.init(); + + sinon.stub(command, 'requireOAuthCredentials').returns(void 0); + sinon.stub(command, 'resolvedConfig').get(() => ({values: {shortCode: 'kv7kzm78', tenantId: 'zzxy_prd'}})); + + const errorStub = sinon.stub(command, 'error').throws(new Error('Expected error')); + + try { + await command.run(); + expect.fail('Expected error'); + } catch { + expect(errorStub.calledOnce).to.equal(true); + } + }); + + it('calls command.error on API failure', async () => { + const command: any = new ScapiCorsDelete([], config); + + stubParse(command, {'tenant-id': 'zzxy_prd', 'site-id': 'RefArch'}, {}); + await command.init(); + + sinon.stub(command, 'requireOAuthCredentials').returns(void 0); + sinon.stub(command, 'jsonEnabled').returns(true); + sinon.stub(command, 'resolvedConfig').get(() => ({values: {shortCode: 'kv7kzm78', tenantId: 'zzxy_prd'}})); + sinon.stub(command, 'getOAuthStrategy').returns({ + getAuthorizationHeader: async () => 'Bearer test', + }); + + sinon.stub(globalThis, 'fetch').resolves( + new Response(JSON.stringify({title: 'Forbidden', type: 'error', detail: 'Insufficient permissions'}), { + status: 403, + headers: {'content-type': 'application/json'}, + }), + ); + + const errorStub = sinon.stub(command, 'error').throws(new Error('Expected error')); + + try { + await command.run(); + expect.fail('Expected error'); + } catch { + expect(errorStub.calledOnce).to.equal(true); + expect(errorStub.firstCall.args[0]).to.include('Failed to delete CORS preferences'); + } + }); +}); diff --git a/packages/b2c-cli/test/commands/scapi/cors/get.test.ts b/packages/b2c-cli/test/commands/scapi/cors/get.test.ts new file mode 100644 index 00000000..075f647c --- /dev/null +++ b/packages/b2c-cli/test/commands/scapi/cors/get.test.ts @@ -0,0 +1,147 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +import {expect} from 'chai'; +import sinon from 'sinon'; +import {Config} from '@oclif/core'; +import ScapiCorsGet from '../../../../src/commands/scapi/cors/get.js'; +import {isolateConfig, restoreConfig} from '@salesforce/b2c-tooling-sdk/test-utils'; +import {stubParse} from '../../../helpers/stub-parse.js'; + +describe('scapi cors get', () => { + let config: Config; + + beforeEach(async () => { + isolateConfig(); + config = await Config.load(); + }); + + afterEach(() => { + sinon.restore(); + restoreConfig(); + }); + + it('calls command.error when shortCode is missing from resolved config', async () => { + const command: any = new ScapiCorsGet([], config); + + stubParse(command, {'tenant-id': 'zzxy_prd', 'site-id': 'RefArch'}, {}); + await command.init(); + + sinon.stub(command, 'requireOAuthCredentials').returns(void 0); + sinon.stub(command, 'resolvedConfig').get(() => ({values: {shortCode: undefined, tenantId: 'zzxy_prd'}})); + + const errorStub = sinon.stub(command, 'error').throws(new Error('Expected error')); + + try { + await command.run(); + expect.fail('Expected error'); + } catch { + expect(errorStub.calledOnce).to.equal(true); + } + }); + + it('calls command.error when site-id is missing', async () => { + const command: any = new ScapiCorsGet([], config); + + stubParse(command, {'tenant-id': 'zzxy_prd'}, {}); + await command.init(); + + sinon.stub(command, 'requireOAuthCredentials').returns(void 0); + sinon.stub(command, 'resolvedConfig').get(() => ({values: {shortCode: 'kv7kzm78', tenantId: 'zzxy_prd'}})); + + const errorStub = sinon.stub(command, 'error').throws(new Error('Expected error')); + + try { + await command.run(); + expect.fail('Expected error'); + } catch { + expect(errorStub.calledOnce).to.equal(true); + } + }); + + it('returns CORS preferences in JSON mode', async () => { + const command: any = new ScapiCorsGet([], config); + + stubParse(command, {'tenant-id': 'zzxy_prd', 'site-id': 'RefArch'}, {}); + await command.init(); + + sinon.stub(command, 'requireOAuthCredentials').returns(void 0); + sinon.stub(command, 'jsonEnabled').returns(true); + sinon.stub(command, 'resolvedConfig').get(() => ({values: {shortCode: 'kv7kzm78', tenantId: 'zzxy_prd'}})); + sinon.stub(command, 'getOAuthStrategy').returns({ + getAuthorizationHeader: async () => 'Bearer test', + }); + + sinon.stub(globalThis, 'fetch').resolves( + new Response( + JSON.stringify({ + corsClientPreferences: [{clientId: 'abc-123', origins: ['http://foo.com']}], + }), + {status: 200, headers: {'content-type': 'application/json'}}, + ), + ); + + const result = await command.run(); + + expect(result.siteId).to.equal('RefArch'); + expect(result.corsClientPreferences).to.have.length(1); + expect(result.corsClientPreferences[0].clientId).to.equal('abc-123'); + expect(result.corsClientPreferences[0].origins).to.deep.equal(['http://foo.com']); + }); + + it('returns empty list when no preferences configured', async () => { + const command: any = new ScapiCorsGet([], config); + + stubParse(command, {'tenant-id': 'zzxy_prd', 'site-id': 'RefArch'}, {}); + await command.init(); + + sinon.stub(command, 'requireOAuthCredentials').returns(void 0); + sinon.stub(command, 'jsonEnabled').returns(true); + sinon.stub(command, 'resolvedConfig').get(() => ({values: {shortCode: 'kv7kzm78', tenantId: 'zzxy_prd'}})); + sinon.stub(command, 'getOAuthStrategy').returns({ + getAuthorizationHeader: async () => 'Bearer test', + }); + + sinon + .stub(globalThis, 'fetch') + .resolves(new Response(JSON.stringify({}), {status: 200, headers: {'content-type': 'application/json'}})); + + const result = await command.run(); + + expect(result.siteId).to.equal('RefArch'); + expect(result.corsClientPreferences).to.deep.equal([]); + }); + + it('calls command.error on API failure', async () => { + const command: any = new ScapiCorsGet([], config); + + stubParse(command, {'tenant-id': 'zzxy_prd', 'site-id': 'RefArch'}, {}); + await command.init(); + + sinon.stub(command, 'requireOAuthCredentials').returns(void 0); + sinon.stub(command, 'jsonEnabled').returns(true); + sinon.stub(command, 'resolvedConfig').get(() => ({values: {shortCode: 'kv7kzm78', tenantId: 'zzxy_prd'}})); + sinon.stub(command, 'getOAuthStrategy').returns({ + getAuthorizationHeader: async () => 'Bearer test', + }); + + sinon.stub(globalThis, 'fetch').resolves( + new Response(JSON.stringify({title: 'Not Found', type: 'error', detail: 'Site not found'}), { + status: 404, + headers: {'content-type': 'application/json'}, + }), + ); + + const errorStub = sinon.stub(command, 'error').throws(new Error('Expected error')); + + try { + await command.run(); + expect.fail('Expected error'); + } catch { + expect(errorStub.calledOnce).to.equal(true); + expect(errorStub.firstCall.args[0]).to.include('Failed to fetch CORS preferences'); + } + }); +}); diff --git a/packages/b2c-cli/test/commands/scapi/cors/set.test.ts b/packages/b2c-cli/test/commands/scapi/cors/set.test.ts new file mode 100644 index 00000000..76b6829a --- /dev/null +++ b/packages/b2c-cli/test/commands/scapi/cors/set.test.ts @@ -0,0 +1,279 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +import {expect} from 'chai'; +import sinon from 'sinon'; +import {Config} from '@oclif/core'; +import ScapiCorsSet from '../../../../src/commands/scapi/cors/set.js'; +import {isolateConfig, restoreConfig} from '@salesforce/b2c-tooling-sdk/test-utils'; +import {stubParse} from '../../../helpers/stub-parse.js'; + +const VALID_FLAGS = { + 'tenant-id': 'zzxy_prd', + 'site-id': 'RefArch', + 'client-id': 'abc-123', + origins: 'http://foo.com', +}; + +const RESOLVED_CONFIG = {values: {shortCode: 'kv7kzm78', tenantId: 'zzxy_prd', clientId: 'abc-123'}}; + +describe('scapi cors set', () => { + let config: Config; + + beforeEach(async () => { + isolateConfig(); + config = await Config.load(); + }); + + afterEach(() => { + sinon.restore(); + restoreConfig(); + }); + + describe('client-id validation', () => { + it('calls command.error when client-id contains invalid characters', async () => { + const command: any = new ScapiCorsSet([], config); + + stubParse(command, {...VALID_FLAGS, 'client-id': 'invalid@char!'}, {}); + await command.init(); + + sinon.stub(command, 'requireOAuthCredentials').returns(void 0); + sinon.stub(command, 'resolvedConfig').get(() => RESOLVED_CONFIG); + + const errorStub = sinon.stub(command, 'error').throws(new Error('Expected error')); + + try { + await command.run(); + expect.fail('Expected error'); + } catch { + expect(errorStub.calledOnce).to.equal(true); + expect(errorStub.firstCall.args[0]).to.include('client-id must contain only letters, numbers, and hyphens'); + } + }); + + it('accepts client-id with letters, numbers, and hyphens', async () => { + const command: any = new ScapiCorsSet([], config); + + stubParse(command, {...VALID_FLAGS, 'client-id': 'valid-client-123'}, {}); + await command.init(); + + sinon.stub(command, 'requireOAuthCredentials').returns(void 0); + sinon.stub(command, 'jsonEnabled').returns(true); + sinon.stub(command, 'resolvedConfig').get(() => RESOLVED_CONFIG); + sinon.stub(command, 'getOAuthStrategy').returns({getAuthorizationHeader: async () => 'Bearer test'}); + + sinon + .stub(globalThis, 'fetch') + .resolves( + new Response( + JSON.stringify({corsClientPreferences: [{clientId: 'valid-client-123', origins: ['http://foo.com']}]}), + {status: 200, headers: {'content-type': 'application/json'}}, + ), + ); + + const result = await command.run(); + expect(result.corsClientPreferences[0].clientId).to.equal('valid-client-123'); + }); + }); + + describe('origins validation', () => { + it('calls command.error when an origin has a port', async () => { + const command: any = new ScapiCorsSet([], config); + + stubParse(command, {...VALID_FLAGS, origins: 'http://foo.com:8080'}, {}); + await command.init(); + + sinon.stub(command, 'requireOAuthCredentials').returns(void 0); + sinon.stub(command, 'resolvedConfig').get(() => RESOLVED_CONFIG); + + const errorStub = sinon.stub(command, 'error').throws(new Error('Expected error')); + + try { + await command.run(); + expect.fail('Expected error'); + } catch { + expect(errorStub.calledOnce).to.equal(true); + expect(errorStub.firstCall.args[0]).to.include('http://foo.com:8080'); + } + }); + + it('calls command.error when an origin has a path', async () => { + const command: any = new ScapiCorsSet([], config); + + stubParse(command, {...VALID_FLAGS, origins: 'http://foo.com/path'}, {}); + await command.init(); + + sinon.stub(command, 'requireOAuthCredentials').returns(void 0); + sinon.stub(command, 'resolvedConfig').get(() => RESOLVED_CONFIG); + + const errorStub = sinon.stub(command, 'error').throws(new Error('Expected error')); + + try { + await command.run(); + expect.fail('Expected error'); + } catch { + expect(errorStub.calledOnce).to.equal(true); + expect(errorStub.firstCall.args[0]).to.include('http://foo.com/path'); + } + }); + + it('calls command.error when an origin has no scheme', async () => { + const command: any = new ScapiCorsSet([], config); + + stubParse(command, {...VALID_FLAGS, origins: 'foo.com'}, {}); + await command.init(); + + sinon.stub(command, 'requireOAuthCredentials').returns(void 0); + sinon.stub(command, 'resolvedConfig').get(() => RESOLVED_CONFIG); + + const errorStub = sinon.stub(command, 'error').throws(new Error('Expected error')); + + try { + await command.run(); + expect.fail('Expected error'); + } catch { + expect(errorStub.calledOnce).to.equal(true); + expect(errorStub.firstCall.args[0]).to.include('foo.com'); + } + }); + + it('reports all invalid origins together in one error', async () => { + const command: any = new ScapiCorsSet([], config); + + stubParse(command, {...VALID_FLAGS, origins: 'foo.com,http://bar.com:9000'}, {}); + await command.init(); + + sinon.stub(command, 'requireOAuthCredentials').returns(void 0); + sinon.stub(command, 'resolvedConfig').get(() => RESOLVED_CONFIG); + + const errorStub = sinon.stub(command, 'error').throws(new Error('Expected error')); + + try { + await command.run(); + expect.fail('Expected error'); + } catch { + expect(errorStub.calledOnce).to.equal(true); + expect(errorStub.firstCall.args[0]).to.include('foo.com'); + expect(errorStub.firstCall.args[0]).to.include('http://bar.com:9000'); + } + }); + + it('accepts empty origins string (allow known domains only)', async () => { + const command: any = new ScapiCorsSet([], config); + + stubParse(command, {...VALID_FLAGS, origins: ''}, {}); + await command.init(); + + sinon.stub(command, 'requireOAuthCredentials').returns(void 0); + sinon.stub(command, 'jsonEnabled').returns(true); + sinon.stub(command, 'resolvedConfig').get(() => RESOLVED_CONFIG); + sinon.stub(command, 'getOAuthStrategy').returns({getAuthorizationHeader: async () => 'Bearer test'}); + + const fetchStub = sinon.stub(globalThis, 'fetch').callsFake(async (req: Request | string | URL) => { + const body = (await (req as Request).json()) as { + corsClientPreferences: Array<{clientId: string; origins: string[]}>; + }; + expect(body.corsClientPreferences[0].origins).to.deep.equal([]); + return new Response(JSON.stringify({corsClientPreferences: [{clientId: 'abc-123', origins: []}]}), { + status: 200, + headers: {'content-type': 'application/json'}, + }); + }); + + await command.run(); + expect(fetchStub.called).to.equal(true); + }); + }); + + describe('successful API call', () => { + it('returns corsClientPreferences in JSON mode', async () => { + const command: any = new ScapiCorsSet([], config); + + stubParse(command, {...VALID_FLAGS, origins: 'http://foo.com,https://bar.com'}, {}); + await command.init(); + + sinon.stub(command, 'requireOAuthCredentials').returns(void 0); + sinon.stub(command, 'jsonEnabled').returns(true); + sinon.stub(command, 'resolvedConfig').get(() => RESOLVED_CONFIG); + sinon.stub(command, 'getOAuthStrategy').returns({getAuthorizationHeader: async () => 'Bearer test'}); + + sinon.stub(globalThis, 'fetch').resolves( + new Response( + JSON.stringify({ + corsClientPreferences: [{clientId: 'abc-123', origins: ['http://foo.com', 'https://bar.com']}], + }), + {status: 200, headers: {'content-type': 'application/json'}}, + ), + ); + + const result = await command.run(); + + expect(result.siteId).to.equal('RefArch'); + expect(result.corsClientPreferences[0].clientId).to.equal('abc-123'); + expect(result.corsClientPreferences[0].origins).to.deep.equal(['http://foo.com', 'https://bar.com']); + }); + + it('sends client-id and origins in the request body', async () => { + const command: any = new ScapiCorsSet([], config); + + stubParse(command, {...VALID_FLAGS, 'client-id': 'my-client', origins: 'http://foo.com'}, {}); + await command.init(); + + sinon.stub(command, 'requireOAuthCredentials').returns(void 0); + sinon.stub(command, 'jsonEnabled').returns(true); + sinon.stub(command, 'resolvedConfig').get(() => RESOLVED_CONFIG); + sinon.stub(command, 'getOAuthStrategy').returns({getAuthorizationHeader: async () => 'Bearer test'}); + + const fetchStub = sinon.stub(globalThis, 'fetch').callsFake(async (req: Request | string | URL) => { + const body = (await (req as Request).json()) as { + corsClientPreferences: Array<{clientId: string; origins: string[]}>; + }; + expect(body.corsClientPreferences[0].clientId).to.equal('my-client'); + expect(body.corsClientPreferences[0].origins).to.deep.equal(['http://foo.com']); + return new Response( + JSON.stringify({corsClientPreferences: [{clientId: 'my-client', origins: ['http://foo.com']}]}), + { + status: 200, + headers: {'content-type': 'application/json'}, + }, + ); + }); + + await command.run(); + expect(fetchStub.called).to.equal(true); + }); + + it('calls command.error on API failure', async () => { + const command: any = new ScapiCorsSet([], config); + + stubParse(command, VALID_FLAGS, {}); + await command.init(); + + sinon.stub(command, 'requireOAuthCredentials').returns(void 0); + sinon.stub(command, 'jsonEnabled').returns(true); + sinon.stub(command, 'resolvedConfig').get(() => RESOLVED_CONFIG); + sinon.stub(command, 'getOAuthStrategy').returns({getAuthorizationHeader: async () => 'Bearer test'}); + + sinon + .stub(globalThis, 'fetch') + .resolves( + new Response( + JSON.stringify({title: 'Bad Request', type: 'error', detail: 'Client ID must not be null or empty'}), + {status: 400, headers: {'content-type': 'application/json'}}, + ), + ); + + const errorStub = sinon.stub(command, 'error').throws(new Error('Expected error')); + + try { + await command.run(); + expect.fail('Expected error'); + } catch { + expect(errorStub.calledOnce).to.equal(true); + expect(errorStub.firstCall.args[0]).to.include('Failed to set CORS preferences'); + } + }); + }); +}); diff --git a/packages/b2c-tooling-sdk/package.json b/packages/b2c-tooling-sdk/package.json index 6806d9c1..81a2f308 100644 --- a/packages/b2c-tooling-sdk/package.json +++ b/packages/b2c-tooling-sdk/package.json @@ -309,7 +309,7 @@ "data" ], "scripts": { - "generate:types": "openapi-typescript specs/data-api.json -o src/clients/ocapi.generated.ts && openapi-typescript specs/slas-admin-v1.yaml -o src/clients/slas-admin.generated.ts && openapi-typescript specs/ods-api-v1.json -o src/clients/ods.generated.ts && openapi-typescript specs/mrt-api-v1.json -o src/clients/mrt.generated.ts && openapi-typescript specs/mrt-b2c.json -o src/clients/mrt-b2c.generated.ts && openapi-typescript specs/custom-apis-v1.yaml -o src/clients/custom-apis.generated.ts && openapi-typescript specs/scapi-schemas-v1.yaml -o src/clients/scapi-schemas.generated.ts && openapi-typescript specs/cdn-zones-v1.yaml -o src/clients/cdn-zones.generated.ts && openapi-typescript specs/am-users-api-v1.yaml -o src/clients/am-users-api.generated.ts && openapi-typescript specs/am-roles-api-v1.yaml -o src/clients/am-roles-api.generated.ts && openapi-typescript specs/am-apiclients-api-v1.yaml -o src/clients/am-apiclients-api.generated.ts", + "generate:types": "openapi-typescript specs/data-api.json -o src/clients/ocapi.generated.ts && openapi-typescript specs/slas-admin-v1.yaml -o src/clients/slas-admin.generated.ts && openapi-typescript specs/ods-api-v1.json -o src/clients/ods.generated.ts && openapi-typescript specs/mrt-api-v1.json -o src/clients/mrt.generated.ts && openapi-typescript specs/mrt-b2c.json -o src/clients/mrt-b2c.generated.ts && openapi-typescript specs/custom-apis-v1.yaml -o src/clients/custom-apis.generated.ts && openapi-typescript specs/scapi-schemas-v1.yaml -o src/clients/scapi-schemas.generated.ts && openapi-typescript specs/cdn-zones-v1.yaml -o src/clients/cdn-zones.generated.ts && openapi-typescript specs/am-users-api-v1.yaml -o src/clients/am-users-api.generated.ts && openapi-typescript specs/am-roles-api-v1.yaml -o src/clients/am-roles-api.generated.ts && openapi-typescript specs/am-apiclients-api-v1.yaml -o src/clients/am-apiclients-api.generated.ts && openapi-typescript specs/scapi-cors-v1.yaml -o src/clients/scapi-cors.generated.ts", "build": "pnpm run generate:types && pnpm run build:esm && pnpm run build:cjs", "build:esm": "tsc -p tsconfig.esm.json", "build:cjs": "tsc -p tsconfig.cjs.json && echo '{\"type\":\"commonjs\"}' > dist/cjs/package.json", diff --git a/packages/b2c-tooling-sdk/specs/scapi-cors-v1.yaml b/packages/b2c-tooling-sdk/specs/scapi-cors-v1.yaml new file mode 100644 index 00000000..b8221041 --- /dev/null +++ b/packages/b2c-tooling-sdk/specs/scapi-cors-v1.yaml @@ -0,0 +1,295 @@ +openapi: 3.0.3 +info: + x-api-type: Admin + x-api-family: Configuration + title: Cors + version: 1.1.0 + description: |- + [Download API specification](https://developer.salesforce.com/static/commercecloud/commerce-api/cors/cors-oas-v1-public.yaml) + + # API Overview + + The CORS Preferences API allows you to manage your Cross-Origin Resource Sharing (CORS) preferences. + + By specifying which domains are permitted to access a site, you can define exceptions to the same-site policy that browsers would otherwise enforce. + + ## Authentication & Authorization + + For resource access, you must use a client ID and client secret from Account Manager to request an access token. The access token is used as a bearer token and added to the Authorization header of your API request. + + You must include `sfcc.cors-preferences.rw` (read-write access) or `sfcc.cors-preferences` (read-only access) in the client ID used to generate the token. For a full list of permissions, see the [Authorization Scopes Catalog.](https://developer.salesforce.com/docs/commerce/commerce-api/guide/auth-z-scope-catalog.html) + + For detailed setup instructions, see the [Authorization for Admin APIs.](https://developer.salesforce.com/docs/commerce/commerce-api/guide/authorization-for-admin-apis.html) + + ## Use Cases + + ### Manage CORS configuration + + You can use the preferences/cors endpoint to retrieve, set, or delete your configuration for CORS origins. + + ## Usage Notes + + 1. This configuration is made per client ID and site. + 2. All known domain names and aliases are added to the list automatically and do not need to be configured for CORS explicitly. + 3. If a configuration is absent, CORS will not be active. To enable cors for a client without specifying custom origins, configure the client and site with an empty origin list. The known domain names and aliases will still apply. +servers: + - url: https://{shortCode}.api.commercecloud.salesforce.com/configuration/cors/v1 + variables: + shortCode: + default: shortCode +paths: + /organizations/{organizationId}/cors: + get: + summary: Return all CORS preferences for the given site. + description: Return all CORS preferences for the given site. + operationId: getCorsPreferences + parameters: + - $ref: '#/components/parameters/organizationId' + - $ref: '#/components/parameters/siteId' + responses: + '200': + description: CORS preferences for the given site returned successfully. + content: + application/json: + schema: + $ref: '#/components/schemas/CorsPreferences' + examples: + getCorsResultWithMappingsExample: + $ref: '#/components/examples/getCorsResultWithMappingsExample' + getCorsResultWithoutMappingsExample: + $ref: '#/components/examples/getCorsResultWithoutMappingsExample' + security: + - AmOAuth2: + - sfcc.cors-preferences + - sfcc.cors-preferences.rw + put: + summary: Create or replace all CORS preferences for the given site. + description: Create or replace all CORS preferences for the given site. + operationId: updateCorsPreferences + parameters: + - $ref: '#/components/parameters/organizationId' + - $ref: '#/components/parameters/siteId' + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/CorsPreferences' + examples: + CorsPutBodyExample: + $ref: '#/components/examples/CorsPutBodyExample' + required: true + responses: + '200': + description: CORS preferences for the given site replaced successfully. + content: + application/json: + schema: + $ref: '#/components/schemas/CorsPreferences' + examples: + updateCorsResultWithMappingsExample: + $ref: '#/components/examples/updateCorsResultWithMappingsExample' + updateCorsResultWithoutMappingsExample: + $ref: '#/components/examples/updateCorsResultWithoutMappingsExample' + '201': + description: CORS preferences successfully created for the given site. + content: + application/json: + schema: + $ref: '#/components/schemas/CorsPreferences' + examples: + updateCorsResultWithMappingsExample: + $ref: '#/components/examples/updateCorsResultWithMappingsExample' + updateCorsResultWithoutMappingsExample: + $ref: '#/components/examples/updateCorsResultWithoutMappingsExample' + '400': + description: |- + Possible reasons: + - The clientId is missing. + - The domain names in the origins list are invalid. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + examples: + CorsBadRequestMissingClientIdExample: + $ref: '#/components/examples/CorsBadRequestMissingClientIdExample' + CorsBadRequestInvalidDomainNamesExample: + $ref: '#/components/examples/CorsBadRequestInvalidDomainNamesExample' + security: + - AmOAuth2: + - sfcc.cors-preferences.rw + delete: + summary: Delete all CORS preferences for the given site. + description: Delete all CORS preferences for the given site. + operationId: deleteCorsPreferences + parameters: + - $ref: '#/components/parameters/organizationId' + - $ref: '#/components/parameters/siteId' + responses: + '204': + description: CORS preferences for the given site deleted successfully. + security: + - AmOAuth2: + - sfcc.cors-preferences.rw +components: + securitySchemes: + AmOAuth2: + type: oauth2 + description: AccountManager OAuth 2.0 bearer token Authentication. + flows: + clientCredentials: + tokenUrl: https://account.demandware.com/dwsso/oauth2/access_token + scopes: + sfcc.cors-preferences: CORS preferences READONLY scope + sfcc.cors-preferences.rw: CORS preferences scope + authorizationCode: + authorizationUrl: https://account.demandware.com/dwsso/oauth2/authorize + tokenUrl: https://account.demandware.com/dwsso/oauth2/access_token + scopes: + sfcc.cors-preferences: CORS preferences READONLY scope + sfcc.cors-preferences.rw: CORS preferences scope + schemas: + OrganizationId: + description: An identifier for the organization the request is being made by + example: f_ecom_zzxy_prd + type: string + minLength: 1 + maxLength: 32 + SiteId: + minLength: 1 + maxLength: 32 + description: The identifier of the site that a request is being made in the context of. Attributes might have site specific values, and some objects may only be assigned to specific sites + example: RefArch + type: string + CorsClientPreferences: + description: Document representing all CORS preferences of a specific client + properties: + clientId: + description: Client ID + type: string + example: 12345678-90ab-cdef-fedc-ba0987654321 + pattern: ^[-a-zA-Z0-9]+$ + origins: + description: List containing allowed origins in the format '://.' + type: array + items: + type: string + pattern: ^[a-zA-Z_0-9][-.+a-zA-Z_0-9]+://[-.a-zA-Z_0-9]{1,253}$ + example: + - http://foo.com + - https://foo.bar.com + - myapp://example.com + required: + - clientId + - origins + type: object + CorsPreferences: + description: Document representing all CORS preferences for a given site + properties: + corsClientPreferences: + description: List of client-specific CORS preferences + type: array + items: + $ref: '#/components/schemas/CorsClientPreferences' + type: object + ErrorResponse: + type: object + additionalProperties: true + properties: + title: + description: "A short, human-readable summary of the problem\ntype. It will not change from occurrence to occurrence of the \nproblem, except for purposes of localization\n" + type: string + maxLength: 256 + example: You do not have enough credit + type: + description: | + A URI reference [RFC3986] that identifies the + problem type. This specification encourages that, when + dereferenced, it provide human-readable documentation for the + problem type (e.g., using HTML [W3C.REC-html5-20141028]). When + this member is not present, its value is assumed to be + "about:blank". It accepts relative URIs; this means + that they must be resolved relative to the document's base URI, as + per [RFC3986], Section 5. + type: string + maxLength: 2048 + example: NotEnoughMoney + detail: + description: A human-readable explanation specific to this occurrence of the problem. + type: string + example: Your current balance is 30, but that costs 50 + instance: + description: | + A URI reference that identifies the specific + occurrence of the problem. It may or may not yield further + information if dereferenced. It accepts relative URIs; this means + that they must be resolved relative to the document's base URI, as + per [RFC3986], Section 5. + type: string + maxLength: 2048 + example: /account/12345/msgs/abc + required: + - title + - type + - detail + parameters: + organizationId: + description: An identifier for the organization the request is being made by + name: organizationId + in: path + required: true + example: f_ecom_zzxy_prd + schema: + $ref: '#/components/schemas/OrganizationId' + siteId: + description: The identifier of the site that a request is being made in the context of. Attributes might have site specific values, and some objects may only be assigned to specific sites. + name: siteId + in: query + required: true + examples: + SiteId: + value: RefArch + schema: + $ref: '#/components/schemas/SiteId' + examples: + getCorsResultWithMappingsExample: + value: + corsClientPreferences: + - clientId: 12345678-90ab-cdef-fedc-ba0987654321 + origins: + - http://foo.com + - https://foo.bar.com + - myapp://example.com + getCorsResultWithoutMappingsExample: + value: {} + CorsPutBodyExample: + value: + corsClientPreferences: + - clientId: 12345678-90ab-cdef-fedc-ba0987654321 + origins: + - http://foo.com + - https://foo.bar.com + - myapp://example.com + updateCorsResultWithMappingsExample: + value: + corsClientPreferences: + - clientId: 12345678-90ab-cdef-fedc-ba0987654321 + origins: + - http://foo.com + - https://foo.bar.com + - myapp://example.com + updateCorsResultWithoutMappingsExample: + value: {} + CorsBadRequestMissingClientIdExample: + value: + title: Bad Request + type: https://api.commercecloud.salesforce.com/documentation/error/v1/errors/bad-request + detail: 'Submitted content was invalid: Client ID must not be null or empty' + parameter: 'Submitted content was invalid: Client ID must not be null or empty' + CorsBadRequestInvalidDomainNamesExample: + value: + title: Bad Request + type: https://api.commercecloud.salesforce.com/documentation/error/v1/errors/bad-request + detail: 'Submitted content was invalid: Origins must contain only valid domain names in the format ''://.'', without a port or path.' + parameter: 'Submitted content was invalid: Origins must contain only valid domain names in the format ''://.'', without a port or path.' +x-sdk-classname: CORSPreferences diff --git a/packages/b2c-tooling-sdk/src/clients/index.ts b/packages/b2c-tooling-sdk/src/clients/index.ts index 8d7c0f45..202fe8ef 100644 --- a/packages/b2c-tooling-sdk/src/clients/index.ts +++ b/packages/b2c-tooling-sdk/src/clients/index.ts @@ -307,6 +307,18 @@ export type { CipQueryResult, } from './cip.js'; +export {createScapiCorsClient, SCAPI_CORS_READ_SCOPES, SCAPI_CORS_RW_SCOPES} from './scapi-cors.js'; +export type { + ScapiCorsClient, + ScapiCorsClientConfig, + ScapiCorsError, + ScapiCorsResponse, + CorsPreferences, + CorsClientPreferences, + paths as ScapiCorsPaths, + components as ScapiCorsComponents, +} from './scapi-cors.js'; + export {getApiErrorMessage} from './error-utils.js'; export {createTlsDispatcher} from './tls-dispatcher.js'; diff --git a/packages/b2c-tooling-sdk/src/clients/middleware-registry.ts b/packages/b2c-tooling-sdk/src/clients/middleware-registry.ts index 1174b6e1..2b108d2c 100644 --- a/packages/b2c-tooling-sdk/src/clients/middleware-registry.ts +++ b/packages/b2c-tooling-sdk/src/clients/middleware-registry.ts @@ -58,6 +58,7 @@ export type HttpClientType = | 'am-users-api' | 'am-roles-api' | 'am-apiclients-api' + | 'scapi-cors' | 'am-orgs-api'; /** diff --git a/packages/b2c-tooling-sdk/src/clients/scapi-cors.generated.ts b/packages/b2c-tooling-sdk/src/clients/scapi-cors.generated.ts new file mode 100644 index 00000000..3a2553c0 --- /dev/null +++ b/packages/b2c-tooling-sdk/src/clients/scapi-cors.generated.ts @@ -0,0 +1,229 @@ +/** + * This file was auto-generated by openapi-typescript. + * Do not make direct changes to the file. + */ + +export interface paths { + "/organizations/{organizationId}/cors": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Return all CORS preferences for the given site. + * @description Return all CORS preferences for the given site. + */ + get: operations["getCorsPreferences"]; + /** + * Create or replace all CORS preferences for the given site. + * @description Create or replace all CORS preferences for the given site. + */ + put: operations["updateCorsPreferences"]; + post?: never; + /** + * Delete all CORS preferences for the given site. + * @description Delete all CORS preferences for the given site. + */ + delete: operations["deleteCorsPreferences"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +export type webhooks = Record; +export interface components { + schemas: { + /** + * @description An identifier for the organization the request is being made by + * @example f_ecom_zzxy_prd + */ + OrganizationId: string; + /** + * @description The identifier of the site that a request is being made in the context of. Attributes might have site specific values, and some objects may only be assigned to specific sites + * @example RefArch + */ + SiteId: string; + /** @description Document representing all CORS preferences of a specific client */ + CorsClientPreferences: { + /** + * @description Client ID + * @example 12345678-90ab-cdef-fedc-ba0987654321 + */ + clientId: string; + /** @description List containing allowed origins in the format '://.' */ + origins: string[]; + }; + /** @description Document representing all CORS preferences for a given site */ + CorsPreferences: { + /** @description List of client-specific CORS preferences */ + corsClientPreferences?: components["schemas"]["CorsClientPreferences"][]; + }; + ErrorResponse: { + /** + * @description A short, human-readable summary of the problem + * type. It will not change from occurrence to occurrence of the + * problem, except for purposes of localization + * @example You do not have enough credit + */ + title: string; + /** + * @description A URI reference [RFC3986] that identifies the + * problem type. This specification encourages that, when + * dereferenced, it provide human-readable documentation for the + * problem type (e.g., using HTML [W3C.REC-html5-20141028]). When + * this member is not present, its value is assumed to be + * "about:blank". It accepts relative URIs; this means + * that they must be resolved relative to the document's base URI, as + * per [RFC3986], Section 5. + * @example NotEnoughMoney + */ + type: string; + /** + * @description A human-readable explanation specific to this occurrence of the problem. + * @example Your current balance is 30, but that costs 50 + */ + detail: string; + /** + * @description A URI reference that identifies the specific + * occurrence of the problem. It may or may not yield further + * information if dereferenced. It accepts relative URIs; this means + * that they must be resolved relative to the document's base URI, as + * per [RFC3986], Section 5. + * @example /account/12345/msgs/abc + */ + instance?: string; + } & { + [key: string]: unknown; + }; + }; + responses: never; + parameters: { + /** + * @description An identifier for the organization the request is being made by + * @example f_ecom_zzxy_prd + */ + organizationId: components["schemas"]["OrganizationId"]; + /** @description The identifier of the site that a request is being made in the context of. Attributes might have site specific values, and some objects may only be assigned to specific sites. */ + siteId: components["schemas"]["SiteId"]; + }; + requestBodies: never; + headers: never; + pathItems: never; +} +export type $defs = Record; +export interface operations { + getCorsPreferences: { + parameters: { + query: { + /** @description The identifier of the site that a request is being made in the context of. Attributes might have site specific values, and some objects may only be assigned to specific sites. */ + siteId: components["parameters"]["siteId"]; + }; + header?: never; + path: { + /** + * @description An identifier for the organization the request is being made by + * @example f_ecom_zzxy_prd + */ + organizationId: components["parameters"]["organizationId"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description CORS preferences for the given site returned successfully. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CorsPreferences"]; + }; + }; + }; + }; + updateCorsPreferences: { + parameters: { + query: { + /** @description The identifier of the site that a request is being made in the context of. Attributes might have site specific values, and some objects may only be assigned to specific sites. */ + siteId: components["parameters"]["siteId"]; + }; + header?: never; + path: { + /** + * @description An identifier for the organization the request is being made by + * @example f_ecom_zzxy_prd + */ + organizationId: components["parameters"]["organizationId"]; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CorsPreferences"]; + }; + }; + responses: { + /** @description CORS preferences for the given site replaced successfully. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CorsPreferences"]; + }; + }; + /** @description CORS preferences successfully created for the given site. */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CorsPreferences"]; + }; + }; + /** + * @description Possible reasons: + * - The clientId is missing. + * - The domain names in the origins list are invalid. + */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + deleteCorsPreferences: { + parameters: { + query: { + /** @description The identifier of the site that a request is being made in the context of. Attributes might have site specific values, and some objects may only be assigned to specific sites. */ + siteId: components["parameters"]["siteId"]; + }; + header?: never; + path: { + /** + * @description An identifier for the organization the request is being made by + * @example f_ecom_zzxy_prd + */ + organizationId: components["parameters"]["organizationId"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description CORS preferences for the given site deleted successfully. */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; +} diff --git a/packages/b2c-tooling-sdk/src/clients/scapi-cors.ts b/packages/b2c-tooling-sdk/src/clients/scapi-cors.ts new file mode 100644 index 00000000..e6d2fc0d --- /dev/null +++ b/packages/b2c-tooling-sdk/src/clients/scapi-cors.ts @@ -0,0 +1,175 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +/** + * SCAPI CORS Preferences API client for B2C Commerce. + * + * Provides a fully typed client for the CORS Preferences API, which allows managing + * Cross-Origin Resource Sharing (CORS) preferences per site. Specifying permitted domains + * defines exceptions to the same-site policy that browsers would otherwise enforce. + * + * @module clients/scapi-cors + */ +import createClient, {type Client} from 'openapi-fetch'; +import type {AuthStrategy} from '../auth/types.js'; +import {OAuthStrategy} from '../auth/oauth.js'; +import type {paths, components} from './scapi-cors.generated.js'; +import {createAuthMiddleware, createLoggingMiddleware} from './middleware.js'; +import {globalMiddlewareRegistry, type MiddlewareRegistry} from './middleware-registry.js'; +import {toOrganizationId, normalizeTenantId, buildTenantScope} from './custom-apis.js'; + +/** + * Re-export generated types for external use. + */ +export type {paths, components}; + +/** + * Re-export organization/tenant utilities for convenience. + */ +export {toOrganizationId, normalizeTenantId, buildTenantScope}; + +/** + * The typed SCAPI CORS Preferences client. + * + * ## Endpoints + * + * | Method | Path | Description | + * |--------|------|-------------| + * | GET | `/organizations/{organizationId}/cors` | Get all CORS preferences for a site | + * | PUT | `/organizations/{organizationId}/cors` | Create or replace all CORS preferences for a site | + * | DELETE | `/organizations/{organizationId}/cors` | Delete all CORS preferences for a site | + * + * @example + * ```typescript + * import { createScapiCorsClient, toOrganizationId } from '@salesforce/b2c-tooling-sdk/clients'; + * import { OAuthStrategy } from '@salesforce/b2c-tooling-sdk/auth'; + * + * const auth = new OAuthStrategy({ + * clientId: 'your-client-id', + * clientSecret: 'your-client-secret', + * }); + * + * const client = createScapiCorsClient( + * { shortCode: 'kv7kzm78', tenantId: 'zzxy_prd' }, + * auth + * ); + * + * // Get CORS preferences for a site + * const { data, error } = await client.GET('/organizations/{organizationId}/cors', { + * params: { + * path: { organizationId: toOrganizationId('zzxy_prd') }, + * query: { siteId: 'RefArch' }, + * } + * }); + * ``` + * + * @see {@link createScapiCorsClient} for instantiation + * @see {@link https://developer.salesforce.com/docs/commerce/commerce-api/references/cors | CORS Preferences API Reference} + */ +export type ScapiCorsClient = Client; + +/** + * Helper type to extract response data from an operation. + */ +export type ScapiCorsResponse = T extends {content: {'application/json': infer R}} ? R : never; + +/** + * CORS Preferences API error response structure. + */ +export type ScapiCorsError = components['schemas']['ErrorResponse']; + +/** + * CORS preferences for a site (a list of per-client CORS configurations). + */ +export type CorsPreferences = components['schemas']['CorsPreferences']; + +/** + * CORS preferences for a specific client (clientId + allowed origins). + */ +export type CorsClientPreferences = components['schemas']['CorsClientPreferences']; + +/** OAuth scopes required for read-only CORS Preferences access */ +export const SCAPI_CORS_READ_SCOPES = ['sfcc.cors-preferences']; + +/** OAuth scopes required for read-write CORS Preferences access */ +export const SCAPI_CORS_RW_SCOPES = ['sfcc.cors-preferences.rw']; + +/** + * Configuration for creating a SCAPI CORS Preferences client. + */ +export interface ScapiCorsClientConfig { + /** + * The short code for the SCAPI instance. + * @example "kv7kzm78" + */ + shortCode: string; + + /** + * The tenant ID (with or without f_ecom_ prefix). + * Used to build the organizationId path parameter and tenant-specific OAuth scope. + * @example "zzxy_prd" or "f_ecom_zzxy_prd" + */ + tenantId: string; + + /** + * Optional scope override. If not provided, defaults to read-write scope + * (sfcc.cors-preferences.rw) plus tenant-specific scope (SALESFORCE_COMMERCE_API:{tenant}). + */ + scopes?: string[]; + + /** + * Middleware registry to use for this client. + * If not specified, uses the global middleware registry. + */ + middlewareRegistry?: MiddlewareRegistry; +} + +/** + * Creates a typed SCAPI CORS Preferences API client. + * + * Returns the openapi-fetch client directly, with authentication handled via middleware. + * The client automatically handles OAuth scope requirements — defaulting to read-write + * scope so all three operations (GET, PUT, DELETE) are available. + * + * @param config - CORS client configuration including shortCode and tenantId + * @param auth - Authentication strategy (typically OAuth) + * @returns Typed openapi-fetch client + * + * @example + * const client = createScapiCorsClient( + * { shortCode: 'kv7kzm78', tenantId: 'zzxy_prd' }, + * oauthStrategy + * ); + * + * // Get CORS preferences + * const { data } = await client.GET('/organizations/{organizationId}/cors', { + * params: { + * path: { organizationId: toOrganizationId('zzxy_prd') }, + * query: { siteId: 'RefArch' }, + * } + * }); + */ +export function createScapiCorsClient(config: ScapiCorsClientConfig, auth: AuthStrategy): ScapiCorsClient { + const registry = config.middlewareRegistry ?? globalMiddlewareRegistry; + + const client = createClient({ + baseUrl: `https://${config.shortCode}.api.commercecloud.salesforce.com/configuration/cors/v1`, + }); + + // Default to rw scope so GET, PUT, and DELETE all work with a single token + const requiredScopes = config.scopes ?? [...SCAPI_CORS_RW_SCOPES, buildTenantScope(config.tenantId)]; + + const scopedAuth = auth instanceof OAuthStrategy ? auth.withAdditionalScopes(requiredScopes) : auth; + + client.use(createAuthMiddleware(scopedAuth)); + + for (const middleware of registry.getMiddleware('scapi-cors')) { + client.use(middleware); + } + + client.use(createLoggingMiddleware('SCAPI-CORS')); + + return client; +} diff --git a/packages/b2c-tooling-sdk/src/index.ts b/packages/b2c-tooling-sdk/src/index.ts index 5cda8a8d..9e0f2644 100644 --- a/packages/b2c-tooling-sdk/src/index.ts +++ b/packages/b2c-tooling-sdk/src/index.ts @@ -80,6 +80,7 @@ export { fetchRoleMapping, resolveToInternalRole, resolveFromInternalRole, + createScapiCorsClient, ORGANIZATION_ID_PREFIX, ROLE_TENANT_FILTER_PATTERN, SCAPI_TENANT_SCOPE_PREFIX, @@ -88,6 +89,8 @@ export { CDN_ZONES_RW_SCOPES, DEFAULT_CIP_HOST, DEFAULT_CIP_STAGING_HOST, + SCAPI_CORS_READ_SCOPES, + SCAPI_CORS_RW_SCOPES, } from './clients/index.js'; export type { PropfindEntry, @@ -161,6 +164,14 @@ export type { CipFrame, CipQueryOptions, CipQueryResult, + ScapiCorsClient, + ScapiCorsClientConfig, + ScapiCorsError, + ScapiCorsResponse, + CorsPreferences, + CorsClientPreferences, + ScapiCorsPaths, + ScapiCorsComponents, } from './clients/index.js'; // Operations - Code diff --git a/packages/b2c-tooling-sdk/test/clients/scapi-cors.test.ts b/packages/b2c-tooling-sdk/test/clients/scapi-cors.test.ts new file mode 100644 index 00000000..202609e4 --- /dev/null +++ b/packages/b2c-tooling-sdk/test/clients/scapi-cors.test.ts @@ -0,0 +1,172 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +import {expect} from 'chai'; +import {http, HttpResponse} from 'msw'; +import {setupServer} from 'msw/node'; +import {createScapiCorsClient, SCAPI_CORS_READ_SCOPES, SCAPI_CORS_RW_SCOPES} from '@salesforce/b2c-tooling-sdk/clients'; +import {MockAuthStrategy} from '../helpers/mock-auth.js'; + +const SHORT_CODE = 'kv7kzm78'; +const TENANT_ID = 'zzxy_prd'; +const BASE_URL = `https://${SHORT_CODE}.api.commercecloud.salesforce.com/configuration/cors/v1`; +const ORG_ID = 'f_ecom_zzxy_prd'; +const SITE_ID = 'RefArch'; + +describe('clients/scapi-cors', () => { + describe('createScapiCorsClient', () => { + const server = setupServer(); + + before(() => { + server.listen({onUnhandledRequest: 'error'}); + }); + + afterEach(() => { + server.resetHandlers(); + }); + + after(() => { + server.close(); + }); + + it('creates a client with the correct base URL and auth header', async () => { + server.use( + http.get(`${BASE_URL}/organizations/:organizationId/cors`, ({request}) => { + expect(request.headers.get('Authorization')).to.equal('Bearer test-token'); + return HttpResponse.json({corsClientPreferences: []}); + }), + ); + + const auth = new MockAuthStrategy(); + const client = createScapiCorsClient({shortCode: SHORT_CODE, tenantId: TENANT_ID}, auth); + + const {data, error} = await client.GET('/organizations/{organizationId}/cors', { + params: {path: {organizationId: ORG_ID}, query: {siteId: SITE_ID}}, + }); + + expect(error).to.be.undefined; + expect(data?.corsClientPreferences).to.deep.equal([]); + }); + + it('GET returns CORS preferences for a site', async () => { + server.use( + http.get(`${BASE_URL}/organizations/:organizationId/cors`, ({request}) => { + const url = new URL(request.url); + expect(url.searchParams.get('siteId')).to.equal(SITE_ID); + return HttpResponse.json({ + corsClientPreferences: [{clientId: 'abc-123', origins: ['http://foo.com', 'https://bar.com']}], + }); + }), + ); + + const auth = new MockAuthStrategy(); + const client = createScapiCorsClient({shortCode: SHORT_CODE, tenantId: TENANT_ID}, auth); + + const {data} = await client.GET('/organizations/{organizationId}/cors', { + params: {path: {organizationId: ORG_ID}, query: {siteId: SITE_ID}}, + }); + + expect(data?.corsClientPreferences).to.have.length(1); + expect(data?.corsClientPreferences?.[0]?.clientId).to.equal('abc-123'); + expect(data?.corsClientPreferences?.[0]?.origins).to.deep.equal(['http://foo.com', 'https://bar.com']); + }); + + it('GET returns empty preferences when none configured', async () => { + server.use( + http.get(`${BASE_URL}/organizations/:organizationId/cors`, () => { + return HttpResponse.json({}); + }), + ); + + const auth = new MockAuthStrategy(); + const client = createScapiCorsClient({shortCode: SHORT_CODE, tenantId: TENANT_ID}, auth); + + const {data, error} = await client.GET('/organizations/{organizationId}/cors', { + params: {path: {organizationId: ORG_ID}, query: {siteId: SITE_ID}}, + }); + + expect(error).to.be.undefined; + expect(data?.corsClientPreferences).to.be.undefined; + }); + + it('PUT creates or replaces CORS preferences', async () => { + const requestBody = {corsClientPreferences: [{clientId: 'abc-123', origins: ['http://foo.com']}]}; + + server.use( + http.put(`${BASE_URL}/organizations/:organizationId/cors`, async ({request}) => { + const body = await request.json(); + expect(body).to.deep.equal(requestBody); + return HttpResponse.json(requestBody, {status: 200}); + }), + ); + + const auth = new MockAuthStrategy(); + const client = createScapiCorsClient({shortCode: SHORT_CODE, tenantId: TENANT_ID}, auth); + + const {data, error} = await client.PUT('/organizations/{organizationId}/cors', { + params: {path: {organizationId: ORG_ID}, query: {siteId: SITE_ID}}, + body: requestBody, + }); + + expect(error).to.be.undefined; + expect(data?.corsClientPreferences?.[0]?.clientId).to.equal('abc-123'); + }); + + it('DELETE removes CORS preferences and returns 204', async () => { + server.use( + http.delete(`${BASE_URL}/organizations/:organizationId/cors`, ({request}) => { + const url = new URL(request.url); + expect(url.searchParams.get('siteId')).to.equal(SITE_ID); + return new HttpResponse(null, {status: 204}); + }), + ); + + const auth = new MockAuthStrategy(); + const client = createScapiCorsClient({shortCode: SHORT_CODE, tenantId: TENANT_ID}, auth); + + const {error} = await client.DELETE('/organizations/{organizationId}/cors', { + params: {path: {organizationId: ORG_ID}, query: {siteId: SITE_ID}}, + }); + + expect(error).to.be.undefined; + }); + + it('handles API error responses', async () => { + server.use( + http.get(`${BASE_URL}/organizations/:organizationId/cors`, () => { + return HttpResponse.json( + { + title: 'Not Found', + type: 'https://api.commercecloud.salesforce.com/documentation/error/v1/errors/not-found', + detail: 'Site not found', + }, + {status: 404}, + ); + }), + ); + + const auth = new MockAuthStrategy(); + const client = createScapiCorsClient({shortCode: SHORT_CODE, tenantId: TENANT_ID}, auth); + + const {data, error} = await client.GET('/organizations/{organizationId}/cors', { + params: {path: {organizationId: ORG_ID}, query: {siteId: 'NonExistent'}}, + }); + + expect(data).to.be.undefined; + expect(error).to.have.property('title', 'Not Found'); + expect(error).to.have.property('detail', 'Site not found'); + }); + }); + + describe('scope constants', () => { + it('SCAPI_CORS_READ_SCOPES contains the read-only scope', () => { + expect(SCAPI_CORS_READ_SCOPES).to.deep.equal(['sfcc.cors-preferences']); + }); + + it('SCAPI_CORS_RW_SCOPES contains the read-write scope', () => { + expect(SCAPI_CORS_RW_SCOPES).to.deep.equal(['sfcc.cors-preferences.rw']); + }); + }); +}); diff --git a/skills/b2c-cli/skills/b2c-scapi-cors/SKILL.md b/skills/b2c-cli/skills/b2c-scapi-cors/SKILL.md new file mode 100644 index 00000000..c63bde76 --- /dev/null +++ b/skills/b2c-cli/skills/b2c-scapi-cors/SKILL.md @@ -0,0 +1,86 @@ +--- +name: b2c-scapi-cors +description: Manage CORS (Cross-Origin Resource Sharing) preferences for B2C Commerce sites using the b2c CLI. Use when getting, setting, or deleting CORS configurations, managing allowed origins for API clients, or configuring cross-domain access for SCAPI. +--- + +# B2C SCAPI CORS Skill + +Use the `b2c` CLI to manage CORS preferences for B2C Commerce sites via the SCAPI CORS Preferences API. + +> **Tip:** If `b2c` is not installed globally, use `npx @salesforce/b2c-cli` instead. + +## Required Configuration + +All SCAPI CORS commands require: + +- `--tenant-id` — your B2C Commerce tenant ID (e.g., `zzxy_prd`) +- `--site-id` — the site to manage CORS for (e.g., `RefArch`) +- `--client-id` — (for `set`) the Account Manager client ID to configure origins for; also used as the OAuth credential + +These can be provided via flags or environment variables: + +```bash +export SFCC_TENANT_ID=zzxy_prd +export SFCC_SHORTCODE=kv7kzm78 +export SFCC_CLIENT_ID=my-client-id +export SFCC_CLIENT_SECRET=my-secret +export SFCC_SITE_ID=RefArch +``` + +## Examples + +### Get CORS Preferences + +```bash +# get CORS preferences for a site +b2c scapi cors get --tenant-id zzxy_prd --site-id RefArch + +# output as JSON +b2c scapi cors get --tenant-id zzxy_prd --site-id RefArch --json +``` + +### Set CORS Preferences + +```bash +# set allowed origins for a client +b2c scapi cors set --tenant-id zzxy_prd --site-id RefArch --client-id my-client-id \ + --origins http://foo.com,https://bar.com + +# enable CORS for known domains only (no custom origins) +b2c scapi cors set --tenant-id zzxy_prd --site-id RefArch --client-id my-client-id --origins "" + +# output result as JSON +b2c scapi cors set --tenant-id zzxy_prd --site-id RefArch --client-id my-client-id \ + --origins http://foo.com --json +``` + +### Delete CORS Preferences + +```bash +# delete all CORS preferences for a site +b2c scapi cors delete --tenant-id zzxy_prd --site-id RefArch + +# output as JSON +b2c scapi cors delete --tenant-id zzxy_prd --site-id RefArch --json +``` + +## Origin Format + +Origins must follow `://.` — no ports or paths: + +- ✅ `http://foo.com` +- ✅ `https://bar.baz.com` +- ✅ `myapp://example.com` +- ❌ `http://foo.com:8080` (port not allowed) +- ❌ `http://foo.com/path` (path not allowed) + +## Notes + +- `scapi cors set` is a **full replacement** — all existing preferences for the site are overwritten. +- An empty `--origins ""` enables CORS without custom origins; all known domain aliases for the site are still included automatically. +- The client ID passed to `--client-id` must match the OAuth credential used to authenticate (same client). +- `site-id` must be between 1 and 32 characters. + +## More Commands + +See `b2c scapi cors --help` for a full list of commands and options.