From baee3a3d3f145e09cbceccfc0a22401c3c11e6c9 Mon Sep 17 00:00:00 2001 From: Charles Lavery Date: Fri, 8 May 2026 12:22:19 -0400 Subject: [PATCH 01/22] Add SCAPI Jobs API support with backend abstraction Introduces a JobsBackend interface so job commands can transparently use either OCAPI or SCAPI. Auto mode prefers SCAPI when shortCode and tenantId are configured, falling back to OCAPI on invalid_scope errors. - New SCAPI Jobs client (operation/jobs/v1) with optimistic sfcc.jobs.rw scope and read-only downgrade for read operations - Canonical JobExecutionResult type bridges OCAPI snake_case and SCAPI camelCase response shapes - --api-backend flag and apiBackend dw.json field for explicit control - New job execution delete command (SCAPI only) - job:run, job:search, job:wait, job:log migrated to backend abstraction - job:import and job:export remain OCAPI-only for now --- .changeset/scapi-jobs-migration.md | 6 + docs/cli/jobs.md | 75 +- docs/guide/configuration.md | 1 + .../src/commands/job/execution/delete.ts | 55 ++ packages/b2c-cli/src/commands/job/log.ts | 38 +- packages/b2c-cli/src/commands/job/run.ts | 55 +- packages/b2c-cli/src/commands/job/search.ts | 33 +- packages/b2c-cli/src/commands/job/wait.ts | 19 +- .../commands/job/execution/delete.test.ts | 70 ++ .../b2c-cli/test/commands/job/log.test.ts | 90 +- .../b2c-cli/test/commands/job/run.test.ts | 83 +- .../b2c-cli/test/commands/job/search.test.ts | 33 +- .../b2c-cli/test/commands/job/wait.test.ts | 31 +- packages/b2c-tooling-sdk/package.json | 2 +- .../specs/operations-jobs-v1.yaml | 793 ++++++++++++++++++ packages/b2c-tooling-sdk/src/cli/config.ts | 2 + .../src/cli/instance-command.ts | 6 + .../b2c-tooling-sdk/src/cli/job-command.ts | 91 +- packages/b2c-tooling-sdk/src/clients/index.ts | 11 + .../src/clients/middleware-registry.ts | 3 +- .../src/clients/scapi-jobs.generated.ts | 535 ++++++++++++ .../b2c-tooling-sdk/src/clients/scapi-jobs.ts | 58 ++ .../b2c-tooling-sdk/src/config/dw-json.ts | 2 + .../b2c-tooling-sdk/src/config/mapping.ts | 8 + packages/b2c-tooling-sdk/src/config/types.ts | 4 + packages/b2c-tooling-sdk/src/index.ts | 14 + .../src/operations/jobs/backend.ts | 164 ++++ .../src/operations/jobs/index.ts | 8 + .../src/operations/jobs/ocapi-backend.ts | 99 +++ .../src/operations/jobs/scapi-backend.ts | 280 +++++++ .../src/operations/jobs/types.ts | 61 ++ skills/b2c-cli/skills/b2c-job/SKILL.md | 28 + 32 files changed, 2550 insertions(+), 208 deletions(-) create mode 100644 .changeset/scapi-jobs-migration.md create mode 100644 packages/b2c-cli/src/commands/job/execution/delete.ts create mode 100644 packages/b2c-cli/test/commands/job/execution/delete.test.ts create mode 100644 packages/b2c-tooling-sdk/specs/operations-jobs-v1.yaml create mode 100644 packages/b2c-tooling-sdk/src/clients/scapi-jobs.generated.ts create mode 100644 packages/b2c-tooling-sdk/src/clients/scapi-jobs.ts create mode 100644 packages/b2c-tooling-sdk/src/operations/jobs/backend.ts create mode 100644 packages/b2c-tooling-sdk/src/operations/jobs/ocapi-backend.ts create mode 100644 packages/b2c-tooling-sdk/src/operations/jobs/scapi-backend.ts create mode 100644 packages/b2c-tooling-sdk/src/operations/jobs/types.ts diff --git a/.changeset/scapi-jobs-migration.md b/.changeset/scapi-jobs-migration.md new file mode 100644 index 000000000..f483fdbf5 --- /dev/null +++ b/.changeset/scapi-jobs-migration.md @@ -0,0 +1,6 @@ +--- +'@salesforce/b2c-cli': minor +'@salesforce/b2c-tooling-sdk': minor +--- + +Add SCAPI Jobs API support with automatic backend selection. Job commands (`job run`, `job search`, `job wait`, `job log`) now use SCAPI when `shortCode` and `tenantId` are configured, falling back to OCAPI if SCAPI scopes are unavailable. Use `--api-backend ocapi|scapi|auto` or `apiBackend` in dw.json to control explicitly. New `job execution delete` command (SCAPI only) deletes job execution records. diff --git a/docs/cli/jobs.md b/docs/cli/jobs.md index fd6244da7..fa5d5f73c 100644 --- a/docs/cli/jobs.md +++ b/docs/cli/jobs.md @@ -6,11 +6,51 @@ description: Commands for executing jobs, importing and exporting site archives, Commands for executing and monitoring jobs on B2C Commerce instances. +## API Backend + +Job commands support both OCAPI and SCAPI backends. By default (`auto` mode), SCAPI is preferred when `shortCode` and `tenantId` are configured. If SCAPI scopes are unavailable, the CLI falls back to OCAPI transparently. + +Use `--api-backend` to control explicitly: + +```bash +# Force SCAPI +b2c job run my-job --api-backend scapi + +# Force OCAPI +b2c job run my-job --api-backend ocapi + +# Auto-detect (default) +b2c job run my-job --api-backend auto +``` + +Or set in `dw.json`: + +```json +{ + "api-backend": "scapi" +} +``` + +Or via environment variable: `SFCC_API_BACKEND=scapi`. + +::: tip +The `job import` and `job export` commands currently use OCAPI only, regardless of the `--api-backend` setting. +::: + ## Authentication -Job commands require OAuth authentication with OCAPI permissions. +### SCAPI (recommended) + +When using SCAPI, your API client needs the appropriate scopes in Account Manager: + +| Scope | Operations | +|-------|------------| +| `sfcc.jobs.rw` | Execute, delete, search, and get job executions (recommended) | +| `sfcc.jobs` | Search and get job executions (read-only) | -### Required OCAPI Permissions +You also need `shortCode` and `tenantId` configured (in `dw.json` or via flags). + +### OCAPI Configure these resources in Business Manager under **Administration** > **Site Development** > **Open Commerce API Settings**: @@ -253,6 +293,37 @@ b2c job log my-custom-job > job.log --- +## b2c job execution delete + +Delete a job execution record. This command requires the SCAPI backend (`sfcc.jobs.rw` scope). + +### Usage + +```bash +b2c job execution delete JOBID EXECUTIONID +``` + +### Arguments + +| Argument | Description | Required | +|----------|-------------|----------| +| `JOBID` | Job ID | Yes | +| `EXECUTIONID` | Execution ID to delete | Yes | + +### Examples + +```bash +# Delete a specific execution +b2c job execution delete my-job abc123-def456 +``` + +### Notes + +- Requires SCAPI backend — not available via OCAPI. +- Requires the `sfcc.jobs.rw` scope on your API client. + +--- + ## b2c job import Import a site archive to a B2C Commerce instance using the `sfcc-site-archive-import` system job. diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index c4c4fc9da..b2cffcedc 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -236,6 +236,7 @@ For the full command reference with all flags, see [Setup Commands](/cli/setup). | `certificate` | Path to PKCS12 certificate for two-factor auth (mTLS) | | `certificate-passphrase` | Passphrase for the certificate. Also accepts `passphrase`. | | `self-signed` | Allow self-signed server certificates. Also accepts `selfsigned`. | +| `api-backend` | API backend for operations: `ocapi`, `scapi`, or `auto` (default). Auto prefers SCAPI when `shortCode` and `tenant-id` are set. | ### Two-Factor Authentication (mTLS) diff --git a/packages/b2c-cli/src/commands/job/execution/delete.ts b/packages/b2c-cli/src/commands/job/execution/delete.ts new file mode 100644 index 000000000..56272b463 --- /dev/null +++ b/packages/b2c-cli/src/commands/job/execution/delete.ts @@ -0,0 +1,55 @@ +/* + * 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 {Args} from '@oclif/core'; +import {JobCommand} from '@salesforce/b2c-tooling-sdk/cli'; +import {t, withDocs} from '../../../i18n/index.js'; + +export default class JobExecutionDelete extends JobCommand { + static args = { + jobId: Args.string({ + description: 'Job ID', + required: true, + }), + executionId: Args.string({ + description: 'Execution ID to delete', + required: true, + }), + }; + + static description = withDocs( + t('commands.job.execution.delete.description', 'Delete a job execution record (requires SCAPI)'), + '/cli/jobs.html#b2c-job-execution-delete', + ); + + static examples = [ + '<%= config.bin %> <%= command.id %> my-job abc123-def456', + '<%= config.bin %> <%= command.id %> my-job abc123-def456 --api-backend scapi', + ]; + + static flags = { + ...JobCommand.baseFlags, + }; + + async run(): Promise { + this.requireOAuthCredentials(); + + const {jobId, executionId} = this.args; + + const backend = this.createJobsBackend(); + this.logger.debug(`Using ${backend.name} backend for execution delete`); + + this.log( + t('commands.job.execution.delete.deleting', 'Deleting execution {{executionId}} for job {{jobId}}...', { + jobId, + executionId, + }), + ); + + await backend.deleteJobExecution(jobId, executionId); + + this.log(t('commands.job.execution.delete.deleted', 'Execution {{executionId}} deleted.', {executionId})); + } +} diff --git a/packages/b2c-cli/src/commands/job/log.ts b/packages/b2c-cli/src/commands/job/log.ts index 7771bd878..5103077b0 100644 --- a/packages/b2c-cli/src/commands/job/log.ts +++ b/packages/b2c-cli/src/commands/job/log.ts @@ -4,22 +4,17 @@ * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 */ import {Args, Flags} from '@oclif/core'; -import {InstanceCommand} from '@salesforce/b2c-tooling-sdk/cli'; -import { - searchJobExecutions, - getJobExecution, - getJobLog, - type JobExecution, -} from '@salesforce/b2c-tooling-sdk/operations/jobs'; +import {JobCommand} from '@salesforce/b2c-tooling-sdk/cli'; +import {type JobExecutionResult} from '@salesforce/b2c-tooling-sdk/operations/jobs'; import {t, withDocs} from '../../i18n/index.js'; import {highlightLogText} from '../../utils/logs/index.js'; interface JobLogResult { - execution: JobExecution; + execution: JobExecutionResult; log: string; } -export default class JobLog extends InstanceCommand { +export default class JobLog extends JobCommand { static args = { jobId: Args.string({ description: 'Job ID', @@ -46,7 +41,7 @@ export default class JobLog extends InstanceCommand { ]; static flags = { - ...InstanceCommand.baseFlags, + ...JobCommand.baseFlags, failed: Flags.boolean({ description: 'Find the most recent failed execution with a log', default: false, @@ -57,19 +52,16 @@ export default class JobLog extends InstanceCommand { }), }; - protected operations = { - searchJobExecutions, - getJobExecution, - getJobLog, - }; - async run(): Promise { this.requireOAuthCredentials(); const {jobId, executionId} = this.args; const {failed} = this.flags; - let execution: JobExecution; + const backend = this.createJobsBackend(); + this.logger.debug(`Using ${backend.name} backend for job log`); + + let execution: JobExecutionResult; if (executionId) { this.log( @@ -78,7 +70,7 @@ export default class JobLog extends InstanceCommand { executionId, }), ); - execution = await this.operations.getJobExecution(this.instance, jobId, executionId); + execution = await backend.getJobExecution(jobId, executionId); } else { this.log( failed @@ -92,7 +84,7 @@ export default class JobLog extends InstanceCommand { }), ); - const results = await this.operations.searchJobExecutions(this.instance, { + const results = await backend.searchJobExecutions({ jobId, status: failed ? ['ERROR'] : undefined, count: 10, @@ -100,7 +92,7 @@ export default class JobLog extends InstanceCommand { sortOrder: 'desc', }); - const match = results.hits.find((hit) => hit.is_log_file_existing); + const match = results.hits.find((hit) => hit.isLogFileExisting); if (!match) { const msg = failed ? t( @@ -117,18 +109,18 @@ export default class JobLog extends InstanceCommand { execution = match; } - if (!execution.is_log_file_existing) { + if (!execution.isLogFileExisting) { this.error(t('commands.job.log.noLogFile', 'No log file exists for this execution')); } this.log( t('commands.job.log.foundExecution', 'Found execution {{executionId}} ({{status}})', { executionId: execution.id ?? 'unknown', - status: execution.exit_status?.code || execution.execution_status || 'unknown', + status: execution.exitStatus?.code || execution.executionStatus || 'unknown', }), ); - const log = await this.operations.getJobLog(this.instance, execution); + const log = await backend.getJobLog(execution); if (!this.jsonEnabled()) { const useColor = !this.flags['no-color'] && process.stdout.isTTY; diff --git a/packages/b2c-cli/src/commands/job/run.ts b/packages/b2c-cli/src/commands/job/run.ts index 961d7ffcd..112656b66 100644 --- a/packages/b2c-cli/src/commands/job/run.ts +++ b/packages/b2c-cli/src/commands/job/run.ts @@ -6,10 +6,10 @@ import {Args, Flags} from '@oclif/core'; import {JobCommand, type B2COperationContext} from '@salesforce/b2c-tooling-sdk/cli'; import { - executeJob, - waitForJob, + waitForJobExecution, JobExecutionError, - type JobExecution, + type JobsBackend, + type JobExecutionResult, } from '@salesforce/b2c-tooling-sdk/operations/jobs'; import {t, withDocs} from '../../i18n/index.js'; @@ -76,12 +76,7 @@ export default class JobRun extends JobCommand { }), }; - protected operations = { - executeJob, - waitForJob, - }; - - async run(): Promise { + async run(): Promise { this.requireOAuthCredentials(); const {jobId} = this.args; @@ -96,7 +91,6 @@ export default class JobRun extends JobCommand { } = this.flags; // Safety evaluation — check rules for this job before executing. - // Command-level rules are already evaluated generically in BaseCommand.init(). const jobEvaluation = this.safetyGuard.evaluate({type: 'job', jobId}); if (jobEvaluation.action === 'block') { this.error(jobEvaluation.reason, {exit: 1}); @@ -109,6 +103,11 @@ export default class JobRun extends JobCommand { const parameters = this.parseParameters(param || []); const rawBody = body ? this.parseBody(body) : undefined; + // When --body is used with auto mode, force OCAPI since raw bodies use OCAPI format + const backend = this.resolveBackend(rawBody); + + this.logger.debug(`Using ${backend.name} backend for job operations`); + // Create lifecycle context const context = this.createContext('job:run', { jobId, @@ -126,8 +125,11 @@ export default class JobRun extends JobCommand { reason: beforeResult.skipReason || 'skipped by plugin', }), ); - // Return a mock execution for JSON output - return {execution_status: 'finished', exit_status: {code: 'skipped'}} as unknown as JobExecution; + return { + id: '', + jobId, + executionStatus: 'finished', + } as unknown as JobExecutionResult; } this.log( @@ -137,9 +139,9 @@ export default class JobRun extends JobCommand { }), ); - let execution: JobExecution; + let execution: JobExecutionResult; try { - execution = await this.operations.executeJob(this.instance, jobId, { + execution = await backend.executeJob(jobId, { parameters: rawBody ? undefined : parameters, body: rawBody, waitForRunning: !noWaitRunning, @@ -151,13 +153,14 @@ export default class JobRun extends JobCommand { this.log( t('commands.job.run.started', 'Job started: {{executionId}} (status: {{status}})', { executionId: execution.id, - status: execution.execution_status, + status: execution.executionStatus, }), ); // Wait for completion if requested if (wait) { execution = await this.waitForJobCompletion({ + backend, jobId, executionId: execution.id!, timeout, @@ -166,7 +169,6 @@ export default class JobRun extends JobCommand { context, }); } else { - // Not waiting - run afterOperation hooks with current state await this.runAfterHooks(context, { success: true, duration: Date.now() - context.startTime, @@ -177,8 +179,16 @@ export default class JobRun extends JobCommand { return execution; } + private resolveBackend(rawBody: Record | undefined): JobsBackend { + const preference = this.resolvedConfig.values.apiBackend ?? 'auto'; + if (rawBody && preference === 'auto') { + this.logger.debug('Raw body provided with auto mode; using OCAPI backend'); + return this.createJobsBackend(); + } + return this.createJobsBackend(); + } + private handleExecutionError(error: unknown, context: B2COperationContext): never { - // Run afterOperation hooks with failure (fire-and-forget, errors ignored) this.runAfterHooks(context, { success: false, error: error instanceof Error ? error : new Error(String(error)), @@ -192,7 +202,6 @@ export default class JobRun extends JobCommand { } private async handleWaitError(error: unknown, showLog: boolean, context: B2COperationContext): Promise { - // Run afterOperation hooks with failure await this.runAfterHooks(context, { success: false, error: error instanceof Error ? error : new Error(String(error)), @@ -237,18 +246,19 @@ export default class JobRun extends JobCommand { } private async waitForJobCompletion(options: { + backend: JobsBackend; jobId: string; executionId: string; timeout: number | undefined; pollInterval: number | undefined; showLog: boolean; context: B2COperationContext; - }): Promise { - const {jobId, executionId, timeout, pollInterval, showLog, context} = options; + }): Promise { + const {backend, jobId, executionId, timeout, pollInterval, showLog, context} = options; this.log(t('commands.job.run.waiting', 'Waiting for job to complete...')); try { - const execution = await this.operations.waitForJob(this.instance, jobId, executionId, { + const execution = await waitForJobExecution(backend, jobId, executionId, { timeoutSeconds: timeout, pollIntervalSeconds: pollInterval, onPoll: (info) => { @@ -266,12 +276,11 @@ export default class JobRun extends JobCommand { const durationSec = execution.duration ? (execution.duration / 1000).toFixed(1) : 'N/A'; this.log( t('commands.job.run.completed', 'Job completed: {{status}} (duration: {{duration}}s)', { - status: execution.exit_status?.code || execution.execution_status, + status: execution.exitStatus?.code || execution.executionStatus, duration: durationSec, }), ); - // Run afterOperation hooks with success await this.runAfterHooks(context, { success: true, duration: Date.now() - context.startTime, diff --git a/packages/b2c-cli/src/commands/job/search.ts b/packages/b2c-cli/src/commands/job/search.ts index fac40acd7..e4112f33e 100644 --- a/packages/b2c-cli/src/commands/job/search.ts +++ b/packages/b2c-cli/src/commands/job/search.ts @@ -4,36 +4,32 @@ * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 */ import {Flags, ux} from '@oclif/core'; -import {InstanceCommand, createTable, type ColumnDef} from '@salesforce/b2c-tooling-sdk/cli'; -import { - searchJobExecutions, - type JobExecutionSearchResult, - type JobExecution, -} from '@salesforce/b2c-tooling-sdk/operations/jobs'; +import {JobCommand, createTable, type ColumnDef} from '@salesforce/b2c-tooling-sdk/cli'; +import {type JobExecutionResult, type JobExecutionSearchResults} from '@salesforce/b2c-tooling-sdk/operations/jobs'; import {t, withDocs} from '../../i18n/index.js'; -const COLUMNS: Record> = { +const COLUMNS: Record> = { id: { header: 'Execution ID', get: (e) => e.id ?? '-', }, jobId: { header: 'Job ID', - get: (e) => e.job_id ?? '-', + get: (e) => e.jobId ?? '-', }, status: { header: 'Status', - get: (e) => e.exit_status?.code || e.execution_status || '-', + get: (e) => e.exitStatus?.code || e.executionStatus || '-', }, startTime: { header: 'Start Time', - get: (e) => (e.start_time ? new Date(e.start_time).toISOString().replace('T', ' ').slice(0, 19) : '-'), + get: (e) => (e.startTime ? new Date(e.startTime).toISOString().replace('T', ' ').slice(0, 19) : '-'), }, }; const DEFAULT_COLUMNS = ['id', 'jobId', 'status', 'startTime']; -export default class JobSearch extends InstanceCommand { +export default class JobSearch extends JobCommand { static description = withDocs( t('commands.job.search.description', 'Search for job executions on a B2C Commerce instance'), '/cli/jobs.html#b2c-job-search', @@ -50,7 +46,7 @@ export default class JobSearch extends InstanceCommand { ]; static flags = { - ...InstanceCommand.baseFlags, + ...JobCommand.baseFlags, 'job-id': Flags.string({ char: 'j', description: 'Filter by job ID', @@ -82,22 +78,21 @@ export default class JobSearch extends InstanceCommand { }), }; - protected operations = { - searchJobExecutions, - }; - - async run(): Promise { + async run(): Promise { this.requireOAuthCredentials(); const {'job-id': jobId, status, count, start, 'sort-by': sortBy, 'sort-order': sortOrder} = this.flags; + const backend = this.createJobsBackend(); + this.logger.debug(`Using ${backend.name} backend for job search`); + this.log( t('commands.job.search.searching', 'Searching job executions on {{hostname}}...', { hostname: this.resolvedConfig.values.hostname!, }), ); - const results = await this.operations.searchJobExecutions(this.instance, { + const results = await backend.searchJobExecutions({ jobId, status, count, @@ -106,12 +101,10 @@ export default class JobSearch extends InstanceCommand { sortOrder: sortOrder as 'asc' | 'desc', }); - // JSON output handled by oclif if (this.jsonEnabled()) { return results; } - // Human-readable output if (results.total === 0) { ux.stdout(t('commands.job.search.noResults', 'No job executions found.')); return results; diff --git a/packages/b2c-cli/src/commands/job/wait.ts b/packages/b2c-cli/src/commands/job/wait.ts index 6e43410c7..693977ad9 100644 --- a/packages/b2c-cli/src/commands/job/wait.ts +++ b/packages/b2c-cli/src/commands/job/wait.ts @@ -5,7 +5,11 @@ */ import {Args, Flags} from '@oclif/core'; import {JobCommand} from '@salesforce/b2c-tooling-sdk/cli'; -import {waitForJob, JobExecutionError, type JobExecution} from '@salesforce/b2c-tooling-sdk/operations/jobs'; +import { + waitForJobExecution, + JobExecutionError, + type JobExecutionResult, +} from '@salesforce/b2c-tooling-sdk/operations/jobs'; import {t, withDocs} from '../../i18n/index.js'; export default class JobWait extends JobCommand { @@ -49,16 +53,15 @@ export default class JobWait extends JobCommand { }), }; - protected operations = { - waitForJob, - }; - - async run(): Promise { + async run(): Promise { this.requireOAuthCredentials(); const {jobId, executionId} = this.args; const {timeout, 'poll-interval': pollInterval, 'show-log': showLog} = this.flags; + const backend = this.createJobsBackend(); + this.logger.debug(`Using ${backend.name} backend for job wait`); + this.log( t('commands.job.wait.waiting', 'Waiting for job {{jobId}} execution {{executionId}}...', { jobId, @@ -67,7 +70,7 @@ export default class JobWait extends JobCommand { ); try { - const execution = await this.operations.waitForJob(this.instance, jobId, executionId, { + const execution = await waitForJobExecution(backend, jobId, executionId, { timeoutSeconds: timeout, pollIntervalSeconds: pollInterval, onPoll: (info) => { @@ -85,7 +88,7 @@ export default class JobWait extends JobCommand { const durationSec = execution.duration ? (execution.duration / 1000).toFixed(1) : 'N/A'; this.log( t('commands.job.wait.completed', 'Job completed: {{status}} (duration: {{duration}}s)', { - status: execution.exit_status?.code || execution.execution_status, + status: execution.exitStatus?.code || execution.executionStatus, duration: durationSec, }), ); diff --git a/packages/b2c-cli/test/commands/job/execution/delete.test.ts b/packages/b2c-cli/test/commands/job/execution/delete.test.ts new file mode 100644 index 000000000..4f087a6f3 --- /dev/null +++ b/packages/b2c-cli/test/commands/job/execution/delete.test.ts @@ -0,0 +1,70 @@ +/* + * 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 {afterEach, beforeEach} from 'mocha'; +import sinon from 'sinon'; +import JobExecutionDelete from '../../../../src/commands/job/execution/delete.js'; +import {createIsolatedConfigHooks, createTestCommand, runSilent} from '../../../helpers/test-setup.js'; + +describe('job execution delete', () => { + const hooks = createIsolatedConfigHooks(); + + beforeEach(hooks.beforeEach); + + afterEach(hooks.afterEach); + + async function createCommand(flags: Record, args: Record) { + return createTestCommand(JobExecutionDelete, hooks.getConfig(), flags, args); + } + + function createMockBackend() { + return { + name: 'scapi' as const, + executeJob: sinon.stub(), + getJobExecution: sinon.stub(), + searchJobExecutions: sinon.stub(), + deleteJobExecution: sinon.stub(), + getJobLog: sinon.stub(), + }; + } + + function stubCommon(command: any) { + sinon.stub(command, 'requireOAuthCredentials').returns(void 0); + sinon.stub(command, 'resolvedConfig').get(() => ({values: {hostname: 'example.com'}})); + sinon.stub(command, 'instance').get(() => ({config: {hostname: 'example.com'}})); + const backend = createMockBackend(); + sinon.stub(command, 'createJobsBackend').returns(backend); + return backend; + } + + it('deletes a job execution', async () => { + const command: any = await createCommand({}, {jobId: 'my-job', executionId: 'exec-1'}); + const backend = stubCommon(command); + backend.deleteJobExecution.resolves(); + + await runSilent(() => command.run()); + + expect(backend.deleteJobExecution.calledOnce).to.equal(true); + expect(backend.deleteJobExecution.getCall(0).args[0]).to.equal('my-job'); + expect(backend.deleteJobExecution.getCall(0).args[1]).to.equal('exec-1'); + }); + + it('throws when OCAPI backend does not support delete', async () => { + const command: any = await createCommand({}, {jobId: 'my-job', executionId: 'exec-1'}); + const backend = stubCommon(command); + backend.deleteJobExecution.rejects( + new Error('Delete job execution is not supported via OCAPI. Use --api-backend scapi.'), + ); + + try { + await command.run(); + expect.fail('should have thrown'); + } catch (error: any) { + expect(error.message).to.include('not supported via OCAPI'); + } + }); +}); diff --git a/packages/b2c-cli/test/commands/job/log.test.ts b/packages/b2c-cli/test/commands/job/log.test.ts index 9358c83e1..3c9aca707 100644 --- a/packages/b2c-cli/test/commands/job/log.test.ts +++ b/packages/b2c-cli/test/commands/job/log.test.ts @@ -21,80 +21,86 @@ describe('job log', () => { return createTestCommand(JobLog, hooks.getConfig(), flags, args); } + function createMockBackend() { + return { + name: 'ocapi' as const, + executeJob: sinon.stub(), + getJobExecution: sinon.stub(), + searchJobExecutions: sinon.stub(), + deleteJobExecution: sinon.stub(), + getJobLog: sinon.stub(), + }; + } + function stubCommon(command: any) { - const instance = {config: {hostname: 'example.com'}}; sinon.stub(command, 'requireOAuthCredentials').returns(void 0); sinon.stub(command, 'resolvedConfig').get(() => ({values: {hostname: 'example.com'}})); - sinon.stub(command, 'instance').get(() => instance); + sinon.stub(command, 'instance').get(() => ({config: {hostname: 'example.com'}})); sinon.stub(command, 'log').returns(void 0); - return instance; + const backend = createMockBackend(); + sinon.stub(command, 'createJobsBackend').returns(backend); + return backend; } it('fetches log for a specific execution', async () => { const command: any = await createCommand({}, {jobId: 'my-job', executionId: 'exec-1'}); - const instance = stubCommon(command); + const backend = stubCommon(command); sinon.stub(command, 'jsonEnabled').returns(false); - const execution = {id: 'exec-1', job_id: 'my-job', is_log_file_existing: true, exit_status: {code: 'OK'}}; - const getJobExecutionStub = sinon.stub().resolves(execution); - const getJobLogStub = sinon.stub().resolves('log content here'); - command.operations = {...command.operations, getJobExecution: getJobExecutionStub, getJobLog: getJobLogStub}; + const execution = {id: 'exec-1', jobId: 'my-job', isLogFileExisting: true, exitStatus: {code: 'OK'}}; + backend.getJobExecution.resolves(execution); + backend.getJobLog.resolves('log content here'); const result = (await runSilent(() => command.run())) as {execution: unknown; log: string}; - expect(getJobExecutionStub.calledOnce).to.equal(true); - expect(getJobExecutionStub.getCall(0).args[0]).to.equal(instance); - expect(getJobExecutionStub.getCall(0).args[1]).to.equal('my-job'); - expect(getJobExecutionStub.getCall(0).args[2]).to.equal('exec-1'); - expect(getJobLogStub.calledOnce).to.equal(true); + expect(backend.getJobExecution.calledOnce).to.equal(true); + expect(backend.getJobExecution.getCall(0).args[0]).to.equal('my-job'); + expect(backend.getJobExecution.getCall(0).args[1]).to.equal('exec-1'); + expect(backend.getJobLog.calledOnce).to.equal(true); expect(result.log).to.equal('log content here'); expect(result.execution).to.equal(execution); }); it('searches for most recent execution with log', async () => { const command: any = await createCommand({}, {jobId: 'my-job'}); - const instance = stubCommon(command); + const backend = stubCommon(command); sinon.stub(command, 'jsonEnabled').returns(false); - const execWithoutLog = {id: 'exec-1', job_id: 'my-job', is_log_file_existing: false}; - const execWithLog = {id: 'exec-2', job_id: 'my-job', is_log_file_existing: true, exit_status: {code: 'OK'}}; - const searchStub = sinon.stub().resolves({total: 2, hits: [execWithoutLog, execWithLog]}); - const getJobLogStub = sinon.stub().resolves('log from exec-2'); - command.operations = {...command.operations, searchJobExecutions: searchStub, getJobLog: getJobLogStub}; + const execWithoutLog = {id: 'exec-1', jobId: 'my-job', isLogFileExisting: false}; + const execWithLog = {id: 'exec-2', jobId: 'my-job', isLogFileExisting: true, exitStatus: {code: 'OK'}}; + backend.searchJobExecutions.resolves({total: 2, hits: [execWithoutLog, execWithLog]}); + backend.getJobLog.resolves('log from exec-2'); const result = (await runSilent(() => command.run())) as {log: string}; - expect(searchStub.calledOnce).to.equal(true); - expect(searchStub.getCall(0).args[0]).to.equal(instance); - expect(searchStub.getCall(0).args[1]).to.deep.include({jobId: 'my-job'}); - expect(getJobLogStub.calledOnce).to.equal(true); - expect(getJobLogStub.getCall(0).args[1]).to.equal(execWithLog); + expect(backend.searchJobExecutions.calledOnce).to.equal(true); + expect(backend.searchJobExecutions.getCall(0).args[0]).to.deep.include({jobId: 'my-job'}); + expect(backend.getJobLog.calledOnce).to.equal(true); + expect(backend.getJobLog.getCall(0).args[0]).to.equal(execWithLog); expect(result.log).to.equal('log from exec-2'); }); it('searches for most recent failed execution with --failed', async () => { const command: any = await createCommand({failed: true}, {jobId: 'my-job'}); - stubCommon(command); + const backend = stubCommon(command); sinon.stub(command, 'jsonEnabled').returns(false); - const execution = {id: 'exec-3', job_id: 'my-job', is_log_file_existing: true, exit_status: {code: 'ERROR'}}; - const searchStub = sinon.stub().resolves({total: 1, hits: [execution]}); - const getJobLogStub = sinon.stub().resolves('error log'); - command.operations = {...command.operations, searchJobExecutions: searchStub, getJobLog: getJobLogStub}; + const execution = {id: 'exec-3', jobId: 'my-job', isLogFileExisting: true, exitStatus: {code: 'ERROR'}}; + backend.searchJobExecutions.resolves({total: 1, hits: [execution]}); + backend.getJobLog.resolves('error log'); const result = (await runSilent(() => command.run())) as {log: string}; - expect(searchStub.getCall(0).args[1]).to.deep.include({status: ['ERROR']}); + expect(backend.searchJobExecutions.getCall(0).args[0]).to.deep.include({status: ['ERROR']}); expect(result.log).to.equal('error log'); }); it('errors when specific execution has no log file', async () => { const command: any = await createCommand({}, {jobId: 'my-job', executionId: 'exec-1'}); - stubCommon(command); + const backend = stubCommon(command); - const execution = {id: 'exec-1', job_id: 'my-job', is_log_file_existing: false}; - sinon.stub().resolves(execution); - command.operations = {...command.operations, getJobExecution: sinon.stub().resolves(execution)}; + const execution = {id: 'exec-1', jobId: 'my-job', isLogFileExisting: false}; + backend.getJobExecution.resolves(execution); try { await command.run(); @@ -106,10 +112,9 @@ describe('job log', () => { it('errors when no executions with log found', async () => { const command: any = await createCommand({}, {jobId: 'my-job'}); - stubCommon(command); + const backend = stubCommon(command); - const searchStub = sinon.stub().resolves({total: 0, hits: []}); - command.operations = {...command.operations, searchJobExecutions: searchStub}; + backend.searchJobExecutions.resolves({total: 0, hits: []}); try { await command.run(); @@ -121,15 +126,12 @@ describe('job log', () => { it('returns structured result in json mode', async () => { const command: any = await createCommand({json: true}, {jobId: 'my-job', executionId: 'exec-1'}); - stubCommon(command); + const backend = stubCommon(command); sinon.stub(command, 'jsonEnabled').returns(true); - const execution = {id: 'exec-1', job_id: 'my-job', is_log_file_existing: true, exit_status: {code: 'OK'}}; - command.operations = { - ...command.operations, - getJobExecution: sinon.stub().resolves(execution), - getJobLog: sinon.stub().resolves('json log content'), - }; + const execution = {id: 'exec-1', jobId: 'my-job', isLogFileExisting: true, exitStatus: {code: 'OK'}}; + backend.getJobExecution.resolves(execution); + backend.getJobLog.resolves('json log content'); const result = await command.run(); diff --git a/packages/b2c-cli/test/commands/job/run.test.ts b/packages/b2c-cli/test/commands/job/run.test.ts index ace94e0da..0734c82a3 100644 --- a/packages/b2c-cli/test/commands/job/run.test.ts +++ b/packages/b2c-cli/test/commands/job/run.test.ts @@ -21,18 +21,30 @@ describe('job run', () => { return createTestCommand(JobRun, hooks.getConfig(), flags, args); } + function createMockBackend() { + return { + name: 'ocapi' as const, + executeJob: sinon.stub(), + getJobExecution: sinon.stub(), + searchJobExecutions: sinon.stub(), + deleteJobExecution: sinon.stub(), + getJobLog: sinon.stub(), + }; + } + function stubCommon(command: any) { - const instance = {config: {hostname: 'example.com'}}; sinon.stub(command, 'requireOAuthCredentials').returns(void 0); sinon.stub(command, 'resolvedConfig').get(() => ({values: {hostname: 'example.com'}})); - sinon.stub(command, 'instance').get(() => instance); + sinon.stub(command, 'instance').get(() => ({config: {hostname: 'example.com'}})); sinon.stub(command, 'log').returns(void 0); sinon.stub(command, 'createContext').callsFake((operationType: any, metadata: any) => ({ operationType, metadata, startTime: Date.now(), })); - return instance; + const backend = createMockBackend(); + sinon.stub(command, 'createJobsBackend').returns(backend); + return backend; } it('errors on invalid -P param format', async () => { @@ -53,39 +65,41 @@ describe('job run', () => { it('executes without waiting when --wait is false', async () => { const command: any = await createCommand({param: ['A=1'], json: true}, {jobId: 'my-job'}); - const instance = stubCommon(command); + const backend = stubCommon(command); sinon.stub(command, 'runBeforeHooks').resolves({skip: false}); sinon.stub(command, 'runAfterHooks').resolves(void 0); - const execStub = sinon.stub().resolves({id: 'e1', execution_status: 'running'}); - const waitStub = sinon.stub().rejects(new Error('Unexpected wait')); - command.operations = {...command.operations, executeJob: execStub, waitForJob: waitStub}; + backend.executeJob.resolves({id: 'e1', executionStatus: 'running'}); const result = await command.run(); - expect(execStub.calledOnce).to.equal(true); - expect(execStub.getCall(0).args[0]).to.equal(instance); - expect(waitStub.called).to.equal(false); + expect(backend.executeJob.calledOnce).to.equal(true); + expect(backend.executeJob.getCall(0).args[0]).to.equal('my-job'); expect(result.id).to.equal('e1'); }); it('waits when --wait is true', async () => { - const command: any = await createCommand({wait: true, timeout: 1, json: true}, {jobId: 'my-job'}); - const instance = stubCommon(command); + const command: any = await createCommand( + {wait: true, timeout: 10, 'poll-interval': 1, json: true}, + {jobId: 'my-job'}, + ); + const backend = stubCommon(command); sinon.stub(command, 'runBeforeHooks').resolves({skip: false}); sinon.stub(command, 'runAfterHooks').resolves(void 0); - const execStub = sinon.stub().resolves({id: 'e1', execution_status: 'running'}); - const waitStub = sinon.stub().resolves({id: 'e1', execution_status: 'finished'}); - command.operations = {...command.operations, executeJob: execStub, waitForJob: waitStub}; + backend.executeJob.resolves({id: 'e1', executionStatus: 'running'}); + backend.getJobExecution.resolves({ + id: 'e1', + executionStatus: 'finished', + exitStatus: {code: 'OK', status: 'ok'}, + }); const result = await command.run(); - expect(waitStub.calledOnce).to.equal(true); - expect(waitStub.getCall(0).args[0]).to.equal(instance); - expect(result.execution_status).to.equal('finished'); + expect(backend.getJobExecution.called).to.equal(true); + expect(result.executionStatus).to.equal('finished'); }); it('returns early when before hooks skip', async () => { @@ -96,7 +110,7 @@ describe('job run', () => { const result = await command.run(); - expect(result.exit_status.code).to.equal('skipped'); + expect(result.executionStatus).to.equal('finished'); }); it('errors on invalid --body JSON', async () => { @@ -114,35 +128,4 @@ describe('job run', () => { expect(errorStub.calledOnce).to.equal(true); }); - - it('shows job log and errors on JobExecutionError when waiting and show-log is true', async () => { - const command: any = await createCommand({wait: true, json: true, 'show-log': true}, {jobId: 'my-job'}); - stubCommon(command); - - command.flags = {...command.flags, wait: true, json: true, 'show-log': true}; - - sinon.stub(command, 'runBeforeHooks').resolves({skip: false}); - sinon.stub(command, 'runAfterHooks').resolves(void 0); - const execStub = sinon.stub().resolves({id: 'e1', execution_status: 'running'}); - command.operations = {...command.operations, executeJob: execStub}; - sinon.stub(command, 'showJobLog').resolves(void 0); - - const exec: any = {execution_status: 'finished', exit_status: {code: 'ERROR'}}; - const {JobExecutionError} = await import('@salesforce/b2c-tooling-sdk/operations/jobs'); - const jobError = new JobExecutionError('failed', exec); - expect(jobError).to.be.instanceOf(JobExecutionError); - const waitStub = sinon.stub().rejects(jobError); - command.operations = {...command.operations, waitForJob: waitStub}; - - const errorStub = sinon.stub(command, 'error').throws(new Error('Expected error')); - - try { - await command.run(); - expect.fail('Should have thrown'); - } catch { - // expected - } - - expect(errorStub.called).to.equal(true); - }); }); diff --git a/packages/b2c-cli/test/commands/job/search.test.ts b/packages/b2c-cli/test/commands/job/search.test.ts index dba4f9bd3..cbbbfa318 100644 --- a/packages/b2c-cli/test/commands/job/search.test.ts +++ b/packages/b2c-cli/test/commands/job/search.test.ts @@ -22,45 +22,54 @@ describe('job search', () => { return createTestCommand(JobSearch, hooks.getConfig(), flags, args); } + function createMockBackend() { + return { + name: 'ocapi' as const, + executeJob: sinon.stub(), + getJobExecution: sinon.stub(), + searchJobExecutions: sinon.stub(), + deleteJobExecution: sinon.stub(), + getJobLog: sinon.stub(), + }; + } + function stubCommon(command: any) { - const instance = {config: {hostname: 'example.com'}}; sinon.stub(command, 'requireOAuthCredentials').returns(void 0); sinon.stub(command, 'resolvedConfig').get(() => ({values: {hostname: 'example.com'}})); - sinon.stub(command, 'instance').get(() => instance); + sinon.stub(command, 'instance').get(() => ({config: {hostname: 'example.com'}})); sinon.stub(command, 'log').returns(void 0); - return instance; + const backend = createMockBackend(); + sinon.stub(command, 'createJobsBackend').returns(backend); + return backend; } it('returns results in json mode', async () => { const command: any = await createCommand({json: true}, {}); - const instance = stubCommon(command); + const backend = stubCommon(command); sinon.stub(command, 'jsonEnabled').returns(true); - const searchStub = sinon.stub().resolves({total: 1, hits: [{id: 'e1'}]}); - command.operations = {...command.operations, searchJobExecutions: searchStub}; + backend.searchJobExecutions.resolves({total: 1, hits: [{id: 'e1'}]}); const uxStub = sinon.stub(ux, 'stdout'); const result = await command.run(); - expect(searchStub.calledOnce).to.equal(true); - expect(searchStub.getCall(0).args[0]).to.equal(instance); + expect(backend.searchJobExecutions.calledOnce).to.equal(true); expect(uxStub.called).to.equal(false); expect(result.total).to.equal(1); }); it('prints no results in non-json mode', async () => { const command: any = await createCommand({}, {}); - const instance = stubCommon(command); + const backend = stubCommon(command); sinon.stub(command, 'jsonEnabled').returns(false); - const searchStub = sinon.stub().resolves({total: 0, hits: []}); - command.operations = {...command.operations, searchJobExecutions: searchStub}; + backend.searchJobExecutions.resolves({total: 0, hits: []}); const uxStub = sinon.stub(ux, 'stdout'); const result = await command.run(); expect(result.total).to.equal(0); expect(uxStub.calledOnce).to.equal(true); - expect(searchStub.getCall(0).args[0]).to.equal(instance); + expect(backend.searchJobExecutions.calledOnce).to.equal(true); }); }); diff --git a/packages/b2c-cli/test/commands/job/wait.test.ts b/packages/b2c-cli/test/commands/job/wait.test.ts index fd2600e22..eae257426 100644 --- a/packages/b2c-cli/test/commands/job/wait.test.ts +++ b/packages/b2c-cli/test/commands/job/wait.test.ts @@ -21,23 +21,38 @@ describe('job wait', () => { return createTestCommand(JobWait, hooks.getConfig(), flags, args); } - it('waits using wrapper without real polling', async () => { - const command: any = await createCommand({'poll-interval': 1, json: true}, {jobId: 'my-job', executionId: 'e1'}); + function createMockBackend() { + return { + name: 'ocapi' as const, + executeJob: sinon.stub(), + getJobExecution: sinon.stub(), + searchJobExecutions: sinon.stub(), + deleteJobExecution: sinon.stub(), + getJobLog: sinon.stub(), + }; + } - const instance = {config: {hostname: 'example.com'}}; + it('waits using backend polling', async () => { + const command: any = await createCommand({'poll-interval': 1, json: true}, {jobId: 'my-job', executionId: 'e1'}); sinon.stub(command, 'requireOAuthCredentials').returns(void 0); - sinon.stub(command, 'instance').get(() => instance); + sinon.stub(command, 'resolvedConfig').get(() => ({values: {hostname: 'example.com'}})); + sinon.stub(command, 'instance').get(() => ({config: {hostname: 'example.com'}})); sinon.stub(command, 'log').returns(void 0); sinon.stub(command, 'jsonEnabled').returns(true); - const waitStub = sinon.stub().resolves({id: 'e1', execution_status: 'finished'}); - command.operations = {...command.operations, waitForJob: waitStub}; + const backend = createMockBackend(); + backend.getJobExecution.resolves({ + id: 'e1', + jobId: 'my-job', + executionStatus: 'finished', + exitStatus: {code: 'OK', status: 'ok'}, + }); + sinon.stub(command, 'createJobsBackend').returns(backend); const result = await command.run(); - expect(waitStub.calledOnce).to.equal(true); - expect(waitStub.getCall(0).args[0]).to.equal(instance); + expect(backend.getJobExecution.called).to.equal(true); expect(result.id).to.equal('e1'); }); }); diff --git a/packages/b2c-tooling-sdk/package.json b/packages/b2c-tooling-sdk/package.json index cef85cb60..f5264eee1 100644 --- a/packages/b2c-tooling-sdk/package.json +++ b/packages/b2c-tooling-sdk/package.json @@ -397,7 +397,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 && openapi-typescript specs/granular-replications-v1.yaml -o src/clients/granular-replications.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/granular-replications-v1.yaml -o src/clients/granular-replications.generated.ts && openapi-typescript specs/operations-jobs-v1.yaml -o src/clients/scapi-jobs.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/operations-jobs-v1.yaml b/packages/b2c-tooling-sdk/specs/operations-jobs-v1.yaml new file mode 100644 index 000000000..dc13f3242 --- /dev/null +++ b/packages/b2c-tooling-sdk/specs/operations-jobs-v1.yaml @@ -0,0 +1,793 @@ +openapi: 3.0.3 +info: + title: Jobs + version: 1.0.0 + x-api-type: Admin + x-api-family: Operation +servers: + - url: "https://{shortCode}.api.commercecloud.salesforce.com/operation/jobs/v1" + variables: + shortCode: + default: 123456gf +paths: + /organizations/{organizationId}/job-execution-search: + post: + operationId: searchJobExecutions + parameters: + - name: organizationId + in: path + required: true + style: simple + explode: false + schema: + $ref: "#/components/schemas/OrganizationId" + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/JobExecutionSearchRequest" + required: true + responses: + 200: + description: Returns job execution search results + content: + application/json: + schema: + $ref: "#/components/schemas/JobExecutionSearchResult" + 400: + description: Bad Request - Malformed search query or invalid parameters + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ErrorResponse" + 401: + description: Your access token is invalid or expired and can’t be used to identify a user. + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ErrorResponse" + 403: + description: Forbidden. Your access token is valid, but you don’t have the required permissions to access the resource. + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ErrorResponse" + security: + - AmOAuth2: [sfcc.jobs, sfcc.jobs.rw] + /organizations/{organizationId}/jobs/{jobId}/executions: + post: + operationId: createJobExecution + parameters: + - name: organizationId + in: path + required: true + style: simple + explode: false + schema: + $ref: "#/components/schemas/OrganizationId" + - name: jobId + in: path + required: true + style: simple + explode: false + schema: + type: string + maxLength: 256 + minLength: 1 + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/JobExecutionRequest" + required: false + responses: + 200: + description: The job execution was successfully created + content: + application/json: + schema: + $ref: "#/components/schemas/JobExecution" + 400: + description: Bad Request - Invalid job execution request + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ErrorResponse" + 401: + description: Your access token is invalid or expired and can’t be used to identify a user. + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ErrorResponse" + 403: + description: Forbidden. Your access token is valid, but you don’t have the required permissions to access the resource. + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ErrorResponse" + 404: + description: Job not found + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ErrorResponse" + security: + - AmOAuth2: [sfcc.jobs.rw] + /organizations/{organizationId}/jobs/{jobId}/executions/{executionId}: + get: + operationId: getJobExecution + parameters: + - name: organizationId + in: path + required: true + style: simple + explode: false + schema: + $ref: "#/components/schemas/OrganizationId" + - name: jobId + in: path + required: true + style: simple + explode: false + schema: + type: string + maxLength: 256 + minLength: 1 + - name: executionId + in: path + required: true + style: simple + explode: false + schema: + type: string + maxLength: 256 + minLength: 1 + responses: + 200: + description: Returns the job execution details + content: + application/json: + schema: + $ref: "#/components/schemas/JobExecution" + 401: + description: Your access token is invalid or expired and can’t be used to identify a user. + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ErrorResponse" + 403: + description: Forbidden. Your access token is valid, but you don’t have the required permissions to access the resource. + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ErrorResponse" + 404: + description: Job execution not found + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ErrorResponse" + security: + - AmOAuth2: [sfcc.jobs, sfcc.jobs.rw] + delete: + operationId: deleteJobExecution + parameters: + - name: organizationId + in: path + required: true + style: simple + explode: false + schema: + $ref: "#/components/schemas/OrganizationId" + - name: jobId + in: path + required: true + style: simple + explode: false + schema: + type: string + maxLength: 256 + minLength: 1 + - name: executionId + in: path + required: true + style: simple + explode: false + schema: + type: string + maxLength: 256 + minLength: 1 + responses: + 204: + description: The job execution was successfully deleted + 401: + description: Your access token is invalid or expired and can’t be used to identify a user. + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ErrorResponse" + 403: + description: Forbidden. Your access token is valid, but you don’t have the required permissions to access the resource. + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ErrorResponse" + 404: + description: Job execution not found + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ErrorResponse" + security: + - AmOAuth2: [sfcc.jobs.rw] +components: + schemas: + OrganizationId: + type: string + maxLength: 32 + minLength: 1 + Query: + type: object + additionalProperties: false + maxProperties: 1 + minProperties: 1 + properties: + boolQuery: + $ref: "#/components/schemas/BoolQuery" + filteredQuery: + $ref: "#/components/schemas/FilteredQuery" + matchAllQuery: + $ref: "#/components/schemas/MatchAllQuery" + nestedQuery: + $ref: "#/components/schemas/NestedQuery" + termQuery: + $ref: "#/components/schemas/TermQuery" + textQuery: + $ref: "#/components/schemas/TextQuery" + BoolQuery: + type: object + additionalProperties: false + properties: + must: + type: array + items: + $ref: "#/components/schemas/Query" + type: string + mustNot: + type: array + items: + $ref: "#/components/schemas/Query" + type: string + should: + type: array + items: + $ref: "#/components/schemas/Query" + type: string + Filter: + type: object + additionalProperties: false + maxProperties: 1 + minProperties: 1 + properties: + boolFilter: + $ref: "#/components/schemas/BoolFilter" + queryFilter: + $ref: "#/components/schemas/QueryFilter" + range2Filter: + $ref: "#/components/schemas/Range2Filter" + rangeFilter: + $ref: "#/components/schemas/RangeFilter" + termFilter: + $ref: "#/components/schemas/TermFilter" + BoolFilter: + type: object + additionalProperties: false + properties: + filters: + type: array + items: + $ref: "#/components/schemas/Filter" + type: string + operator: + type: string + enum: [and, or, not] + required: [operator] + QueryFilter: + type: object + properties: + query: + $ref: "#/components/schemas/Query" + required: [query] + Field: + type: string + maxLength: 260 + Range2Filter: + type: object + additionalProperties: false + properties: + filterMode: + type: string + default: overlap + enum: [overlap, containing, contained] + fromField: + allOf: + - $ref: "#/components/schemas/Field" + fromInclusive: + type: boolean + default: true + fromValue: {} + toField: + allOf: + - $ref: "#/components/schemas/Field" + toInclusive: + type: boolean + default: true + toValue: {} + required: [fromField, toField] + RangeFilter: + type: object + properties: + field: + allOf: + - $ref: "#/components/schemas/Field" + from: + oneOf: + - type: string + format: date-time + - type: integer + - type: number + fromInclusive: + type: boolean + default: true + to: + oneOf: + - type: string + format: date-time + - type: integer + - type: number + toInclusive: + type: boolean + default: true + required: [field] + TermFilter: + type: object + additionalProperties: false + properties: + field: + allOf: + - $ref: "#/components/schemas/Field" + operator: + type: string + enum: [is, one_of, is_null, is_not_null, less, greater, not_in, neq] + values: + type: array + items: + type: string + required: [field, operator] + FilteredQuery: + type: object + additionalProperties: false + properties: + filter: + $ref: "#/components/schemas/Filter" + query: + $ref: "#/components/schemas/Query" + required: [filter, query] + MatchAllQuery: + type: object + NestedQuery: + type: object + additionalProperties: false + properties: + path: + type: string + maxLength: 2048 + query: + $ref: "#/components/schemas/Query" + scoreMode: + type: string + enum: [avg, total, max, none] + required: [path, query] + TermQuery: + type: object + properties: + fields: + type: array + items: + $ref: "#/components/schemas/Field" + type: string + minItems: 1 + operator: + type: string + enum: [is, one_of, is_null, is_not_null, less, greater, not_in, neq] + values: + type: array + items: + oneOf: + - type: string + - type: number + - type: boolean + - type: integer + type: string + required: [fields, operator] + TextQuery: + type: object + additionalProperties: false + properties: + fields: + type: array + items: + $ref: "#/components/schemas/Field" + type: string + minItems: 1 + searchPhrase: + type: string + required: [fields, searchPhrase] + Sort: + type: object + additionalProperties: false + properties: + field: + type: string + maxLength: 256 + sortOrder: + type: string + default: asc + enum: [asc, desc] + required: [field] + Offset: + type: integer + format: int32 + default: 0 + minimum: 0 + SearchRequest: + type: object + properties: + limit: + type: integer + format: int32 + maximum: 200 + minimum: 1 + query: + $ref: "#/components/schemas/Query" + sorts: + type: array + items: + $ref: "#/components/schemas/Sort" + type: string + offset: + $ref: "#/components/schemas/Offset" + required: [query] + JobExecutionSearchRequest: + allOf: + - $ref: "#/components/schemas/SearchRequest" + Total: + type: integer + format: int32 + default: 0 + minimum: 0 + ResultBase: + type: object + properties: + limit: + type: integer + format: int32 + total: + $ref: "#/components/schemas/Total" + required: [limit, total] + PaginatedResultBase: + allOf: + - $ref: "#/components/schemas/ResultBase" + properties: + offset: + $ref: "#/components/schemas/Offset" + required: [limit, offset, total] + PaginatedSearchResult: + additionalProperties: false + allOf: + - $ref: "#/components/schemas/PaginatedResultBase" + properties: + query: + $ref: "#/components/schemas/Query" + sorts: + type: array + items: + $ref: "#/components/schemas/Sort" + type: string + hits: + type: array + items: + type: object + required: [query] + ExecutionStatus: + type: string + enum: [pending, running, pausing, paused, resuming, resumed, restarting, restarted, retrying, retried, aborting, aborted, finished, unknown] + ExitStatus: + type: object + properties: + code: + type: string + maxLength: 256 + message: + type: string + maxLength: 4000 + status: + type: string + enum: [ok, error] + StatusMetadata: + type: object + properties: + clientId: + type: string + maxLength: 256 + reason: + type: string + maxLength: 4000 + userLogin: + type: string + maxLength: 256 + JobParameter: + type: object + properties: + name: + type: string + maxLength: 256 + minLength: 1 + pattern: \S|(\S(.*)\S) + value: + type: string + maxLength: 1000 + minLength: 0 + pattern: \S|(\S(.*)\S) + required: [name, value] + JobExecutionRetryInformation: + type: object + properties: + currentRetryAttempt: + type: integer + format: int32 + maxRetries: + type: integer + format: int32 + JobExecutionContinueInformation: + type: object + properties: + isPending: + type: boolean + continueStatus: + type: string + maxLength: 256 + JobStepExecution: + type: object + properties: + id: + type: string + maxLength: 256 + minLength: 1 + stepId: + type: string + maxLength: 256 + minLength: 1 + stepDescription: + type: string + maxLength: 4000 + stepTypeId: + type: string + maxLength: 256 + stepTypeInfo: + type: string + maxLength: 4000 + executionScope: + type: string + maxLength: 256 + executionStatus: + allOf: + - $ref: "#/components/schemas/ExecutionStatus" + status: + type: string + maxLength: 256 + startTime: + type: string + format: date-time + endTime: + type: string + format: date-time + duration: + type: integer + format: int64 + modificationTime: + type: string + format: date-time + statusMetadata: + allOf: + - $ref: "#/components/schemas/StatusMetadata" + exitStatus: + allOf: + - $ref: "#/components/schemas/ExitStatus" + includeStepsFromJobId: + type: string + maxLength: 256 + isChunkOriented: + type: boolean + chunkSize: + type: integer + format: int32 + itemFilterCount: + type: integer + format: int32 + itemWriteCount: + type: integer + format: int32 + totalItemCount: + type: integer + format: int64 + JobExecution: + type: object + properties: + id: + type: string + maxLength: 256 + minLength: 1 + jobId: + type: string + maxLength: 256 + minLength: 1 + jobDescription: + type: string + maxLength: 4000 + clientId: + type: string + maxLength: 256 + userLogin: + type: string + maxLength: 256 + executionStatus: + allOf: + - $ref: "#/components/schemas/ExecutionStatus" + status: + type: string + maxLength: 256 + startTime: + type: string + format: date-time + endTime: + type: string + format: date-time + creationDate: + type: string + format: date-time + duration: + type: integer + format: int64 + effectiveDuration: + type: integer + format: int64 + modificationTime: + type: string + format: date-time + lastModified: + type: string + format: date-time + executedServerId: + type: string + maxLength: 256 + exitStatus: + allOf: + - $ref: "#/components/schemas/ExitStatus" + statusMetadata: + allOf: + - $ref: "#/components/schemas/StatusMetadata" + isLogFileExisting: + type: boolean + isRestart: + type: boolean + logFilePath: + type: string + maxLength: 4000 + parameters: + type: array + items: + $ref: "#/components/schemas/JobParameter" + type: string + executionScopes: + type: array + items: + type: string + maxLength: 256 + retryInformation: + allOf: + - $ref: "#/components/schemas/JobExecutionRetryInformation" + continueInformation: + allOf: + - $ref: "#/components/schemas/JobExecutionContinueInformation" + stepExecutions: + type: array + items: + $ref: "#/components/schemas/JobStepExecution" + type: string + required: [id, jobId, status] + JobExecutionSearchResult: + allOf: + - $ref: "#/components/schemas/PaginatedSearchResult" + properties: + hits: + type: array + items: + $ref: "#/components/schemas/JobExecution" + type: string + required: [hits, query] + ErrorResponse: + type: object + additionalProperties: true + properties: + title: + type: string + maxLength: 256 + type: + type: string + maxLength: 2048 + detail: + type: string + instance: + type: string + maxLength: 2048 + required: [detail, title, type] + JobExecutionRequest: + type: object + properties: + parameters: + type: array + items: + $ref: "#/components/schemas/JobParameter" + type: string + responses: + 401unauthorized: + description: Your access token is invalid or expired and can’t be used to identify a user. + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ErrorResponse" + 403forbidden: + description: Forbidden. Your access token is valid, but you don’t have the required permissions to access the resource. + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ErrorResponse" + parameters: + organizationId: + name: organizationId + in: path + required: true + style: simple + explode: false + schema: + $ref: "#/components/schemas/OrganizationId" + jobId: + name: jobId + in: path + required: true + style: simple + explode: false + schema: + type: string + maxLength: 256 + minLength: 1 + executionId: + name: executionId + in: path + required: true + style: simple + explode: false + schema: + type: string + maxLength: 256 + minLength: 1 + securitySchemes: + AmOAuth2: + type: oauth2 + flows: + clientCredentials: + tokenUrl: "https://account.demandware.com/dw/oauth2/access_token" + scopes: + sfcc.jobs: Read access to job resources + sfcc.jobs.rw: Read and write access to job resources diff --git a/packages/b2c-tooling-sdk/src/cli/config.ts b/packages/b2c-tooling-sdk/src/cli/config.ts index 27ac12d4a..f51111fde 100644 --- a/packages/b2c-tooling-sdk/src/cli/config.ts +++ b/packages/b2c-tooling-sdk/src/cli/config.ts @@ -112,6 +112,8 @@ export function extractInstanceFlags(flags: ParsedFlags): Partial extends OAuthCom allowNo: true, helpGroup: 'AUTH', }), + 'api-backend': Flags.option({ + description: 'API backend for operations (auto detects SCAPI availability)', + options: ['ocapi', 'scapi', 'auto'] as const, + env: 'SFCC_API_BACKEND', + helpGroup: 'INSTANCE', + })(), }; private _instance?: B2CInstance; diff --git a/packages/b2c-tooling-sdk/src/cli/job-command.ts b/packages/b2c-tooling-sdk/src/cli/job-command.ts index 29db7725d..bb901e6f3 100644 --- a/packages/b2c-tooling-sdk/src/cli/job-command.ts +++ b/packages/b2c-tooling-sdk/src/cli/job-command.ts @@ -6,41 +6,85 @@ import {Command} from '@oclif/core'; import {InstanceCommand} from './instance-command.js'; import {getJobLog, getJobErrorMessage, type JobExecution} from '../operations/jobs/index.js'; +import {createJobsBackend, type JobsBackend, type JobExecutionResult} from '../operations/jobs/index.js'; import {t} from '../i18n/index.js'; /** * Base command for job operations. * * Extends InstanceCommand with job-specific functionality like - * displaying job logs on failure. + * displaying job logs on failure and creating backend-aware job clients. * * @example * export default class MyJobCommand extends JobCommand { * async run(): Promise { - * try { - * await executeJob(this.instance, 'my-job'); - * } catch (error) { - * if (error instanceof JobExecutionError) { - * await this.showJobLog(error.execution); - * } - * throw error; - * } + * const backend = this.createJobsBackend(); + * const execution = await backend.executeJob('my-job'); * } * } */ export abstract class JobCommand extends InstanceCommand { + /** + * Creates a jobs backend based on the resolved configuration. + * In auto mode (default), prefers SCAPI when shortCode+tenantId are configured, + * falling back to OCAPI if SCAPI scopes are unavailable. + */ + protected createJobsBackend(): JobsBackend { + const preference = this.resolvedConfig.values.apiBackend ?? 'auto'; + return createJobsBackend({ + preference, + instance: this.instance, + shortCode: this.resolvedConfig.values.shortCode, + tenantId: this.resolvedConfig.values.tenantId, + auth: this.hasOAuthCredentials() ? this.getOAuthStrategy() : undefined, + }); + } + /** * Display a job's log file content and error message if available. + * Accepts both canonical JobExecutionResult and legacy OCAPI JobExecution. * Outputs to stderr since this is typically shown for failed jobs. - * - * @param execution - Job execution with log file info */ - protected async showJobLog(execution: JobExecution): Promise { - // Extract error message from failed step executions + protected async showJobLog(execution: JobExecutionResult | JobExecution): Promise { + if (isCanonicalExecution(execution)) { + return this.showCanonicalJobLog(execution); + } + return this.showOcapiJobLog(execution); + } + + private async showCanonicalJobLog(execution: JobExecutionResult): Promise { + const errorMessage = getCanonicalJobErrorMessage(execution); + + if (!execution.isLogFileExisting) { + if (errorMessage) { + this.logger.error({errorMessage}, errorMessage); + } + return; + } + + try { + const backend = this.createJobsBackend(); + const log = await backend.getJobLog(execution); + const logFileName = execution.logFilePath?.split('/').pop() ?? 'job.log'; + + const header = t('cli.job.logHeader', 'Job log ({{logFileName}}):', {logFileName}); + this.logger.error({log, errorMessage}, `${header}\n${log}`); + + if (errorMessage) { + this.logger.error(t('cli.job.errorMessage', 'Error: {{message}}', {message: errorMessage})); + } + } catch { + this.warn(t('cli.job.logFetchFailed', 'Could not retrieve job log')); + if (errorMessage) { + this.logger.error({errorMessage}, errorMessage); + } + } + } + + private async showOcapiJobLog(execution: JobExecution): Promise { const errorMessage = getJobErrorMessage(execution); if (!execution.is_log_file_existing) { - // No log file, but we may still have an error message if (errorMessage) { this.logger.error({errorMessage}, errorMessage); } @@ -54,16 +98,31 @@ export abstract class JobCommand extends InstanceComma const header = t('cli.job.logHeader', 'Job log ({{logFileName}}):', {logFileName}); this.logger.error({log, errorMessage}, `${header}\n${log}`); - // Log the error message separately if available if (errorMessage) { this.logger.error(t('cli.job.errorMessage', 'Error: {{message}}', {message: errorMessage})); } } catch { this.warn(t('cli.job.logFetchFailed', 'Could not retrieve job log')); - // Still try to show error message even if log fetch failed if (errorMessage) { this.logger.error({errorMessage}, errorMessage); } } } } + +function isCanonicalExecution(execution: JobExecutionResult | JobExecution): execution is JobExecutionResult { + return 'executionStatus' in execution; +} + +function getCanonicalJobErrorMessage(execution: JobExecutionResult): string | undefined { + if (!execution.stepExecutions || execution.stepExecutions.length === 0) { + return undefined; + } + for (let i = execution.stepExecutions.length - 1; i >= 0; i--) { + const step = execution.stepExecutions[i]; + if (step.exitStatus?.status === 'error' && step.exitStatus?.message) { + return step.exitStatus.message; + } + } + return undefined; +} diff --git a/packages/b2c-tooling-sdk/src/clients/index.ts b/packages/b2c-tooling-sdk/src/clients/index.ts index c58154e06..e341f4ebb 100644 --- a/packages/b2c-tooling-sdk/src/clients/index.ts +++ b/packages/b2c-tooling-sdk/src/clients/index.ts @@ -325,6 +325,17 @@ export type { components as GranularReplicationsComponents, } from './granular-replications.js'; +// SCAPI Jobs +export {createScapiJobsClient, SCAPI_JOBS_READ_SCOPES, SCAPI_JOBS_RW_SCOPES} from './scapi-jobs.js'; +export type { + ScapiJobsClient, + ScapiJobsClientConfig, + ScapiJobsError, + ScapiJobsResponse, + paths as ScapiJobsPaths, + components as ScapiJobsComponents, +} from './scapi-jobs.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 126abfdfa..adc83652e 100644 --- a/packages/b2c-tooling-sdk/src/clients/middleware-registry.ts +++ b/packages/b2c-tooling-sdk/src/clients/middleware-registry.ts @@ -59,7 +59,8 @@ export type HttpClientType = | 'am-users-api' | 'am-roles-api' | 'am-apiclients-api' - | 'am-orgs-api'; + | 'am-orgs-api' + | 'scapi-jobs'; /** * Middleware interface compatible with openapi-fetch. diff --git a/packages/b2c-tooling-sdk/src/clients/scapi-jobs.generated.ts b/packages/b2c-tooling-sdk/src/clients/scapi-jobs.generated.ts new file mode 100644 index 000000000..f749aca46 --- /dev/null +++ b/packages/b2c-tooling-sdk/src/clients/scapi-jobs.generated.ts @@ -0,0 +1,535 @@ +/** + * This file was auto-generated by openapi-typescript. + * Do not make direct changes to the file. + */ + +export interface paths { + "/organizations/{organizationId}/job-execution-search": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["searchJobExecutions"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/organizations/{organizationId}/jobs/{jobId}/executions": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["createJobExecution"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/organizations/{organizationId}/jobs/{jobId}/executions/{executionId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getJobExecution"]; + put?: never; + post?: never; + delete: operations["deleteJobExecution"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +export type webhooks = Record; +export interface components { + schemas: { + OrganizationId: string; + Query: { + boolQuery?: components["schemas"]["BoolQuery"]; + filteredQuery?: components["schemas"]["FilteredQuery"]; + matchAllQuery?: components["schemas"]["MatchAllQuery"]; + nestedQuery?: components["schemas"]["NestedQuery"]; + termQuery?: components["schemas"]["TermQuery"]; + textQuery?: components["schemas"]["TextQuery"]; + }; + BoolQuery: { + must?: components["schemas"]["Query"][]; + mustNot?: components["schemas"]["Query"][]; + should?: components["schemas"]["Query"][]; + }; + Filter: { + boolFilter?: components["schemas"]["BoolFilter"]; + queryFilter?: components["schemas"]["QueryFilter"]; + range2Filter?: components["schemas"]["Range2Filter"]; + rangeFilter?: components["schemas"]["RangeFilter"]; + termFilter?: components["schemas"]["TermFilter"]; + }; + BoolFilter: { + filters?: components["schemas"]["Filter"][]; + /** @enum {string} */ + operator: "and" | "or" | "not"; + }; + QueryFilter: { + query: components["schemas"]["Query"]; + }; + Field: string; + Range2Filter: { + /** + * @default overlap + * @enum {string} + */ + filterMode: "overlap" | "containing" | "contained"; + fromField: components["schemas"]["Field"]; + /** @default true */ + fromInclusive: boolean; + fromValue?: unknown; + toField: components["schemas"]["Field"]; + /** @default true */ + toInclusive: boolean; + toValue?: unknown; + }; + RangeFilter: { + field: components["schemas"]["Field"]; + from?: string | number; + /** @default true */ + fromInclusive: boolean; + to?: string | number; + /** @default true */ + toInclusive: boolean; + }; + TermFilter: { + field: components["schemas"]["Field"]; + /** @enum {string} */ + operator: "is" | "one_of" | "is_null" | "is_not_null" | "less" | "greater" | "not_in" | "neq"; + values?: string[]; + }; + FilteredQuery: { + filter: components["schemas"]["Filter"]; + query: components["schemas"]["Query"]; + }; + MatchAllQuery: Record; + NestedQuery: { + path: string; + query: components["schemas"]["Query"]; + /** @enum {string} */ + scoreMode?: "avg" | "total" | "max" | "none"; + }; + TermQuery: { + fields: components["schemas"]["Field"][]; + /** @enum {string} */ + operator: "is" | "one_of" | "is_null" | "is_not_null" | "less" | "greater" | "not_in" | "neq"; + values?: (string | number | boolean)[]; + }; + TextQuery: { + fields: components["schemas"]["Field"][]; + searchPhrase: string; + }; + Sort: { + field: string; + /** + * @default asc + * @enum {string} + */ + sortOrder: "asc" | "desc"; + }; + /** + * Format: int32 + * @default 0 + */ + Offset: number; + SearchRequest: { + /** Format: int32 */ + limit?: number; + query: components["schemas"]["Query"]; + sorts?: components["schemas"]["Sort"][]; + offset?: components["schemas"]["Offset"]; + }; + JobExecutionSearchRequest: components["schemas"]["SearchRequest"]; + /** + * Format: int32 + * @default 0 + */ + Total: number; + ResultBase: { + /** Format: int32 */ + limit: number; + total: components["schemas"]["Total"]; + }; + PaginatedResultBase: { + offset: components["schemas"]["Offset"]; + } & WithRequired; + PaginatedSearchResult: { + query: components["schemas"]["Query"]; + sorts?: components["schemas"]["Sort"][]; + hits?: Record[]; + } & components["schemas"]["PaginatedResultBase"]; + /** @enum {string} */ + ExecutionStatus: "pending" | "running" | "pausing" | "paused" | "resuming" | "resumed" | "restarting" | "restarted" | "retrying" | "retried" | "aborting" | "aborted" | "finished" | "unknown"; + ExitStatus: { + code?: string; + message?: string; + /** @enum {string} */ + status?: "ok" | "error"; + }; + StatusMetadata: { + clientId?: string; + reason?: string; + userLogin?: string; + }; + JobParameter: { + name: string; + value: string; + }; + JobExecutionRetryInformation: { + /** Format: int32 */ + currentRetryAttempt?: number; + /** Format: int32 */ + maxRetries?: number; + }; + JobExecutionContinueInformation: { + isPending?: boolean; + continueStatus?: string; + }; + JobStepExecution: { + id?: string; + stepId?: string; + stepDescription?: string; + stepTypeId?: string; + stepTypeInfo?: string; + executionScope?: string; + executionStatus?: components["schemas"]["ExecutionStatus"]; + status?: string; + /** Format: date-time */ + startTime?: string; + /** Format: date-time */ + endTime?: string; + /** Format: int64 */ + duration?: number; + /** Format: date-time */ + modificationTime?: string; + statusMetadata?: components["schemas"]["StatusMetadata"]; + exitStatus?: components["schemas"]["ExitStatus"]; + includeStepsFromJobId?: string; + isChunkOriented?: boolean; + /** Format: int32 */ + chunkSize?: number; + /** Format: int32 */ + itemFilterCount?: number; + /** Format: int32 */ + itemWriteCount?: number; + /** Format: int64 */ + totalItemCount?: number; + }; + JobExecution: { + id: string; + jobId: string; + jobDescription?: string; + clientId?: string; + userLogin?: string; + executionStatus?: components["schemas"]["ExecutionStatus"]; + status: string; + /** Format: date-time */ + startTime?: string; + /** Format: date-time */ + endTime?: string; + /** Format: date-time */ + creationDate?: string; + /** Format: int64 */ + duration?: number; + /** Format: int64 */ + effectiveDuration?: number; + /** Format: date-time */ + modificationTime?: string; + /** Format: date-time */ + lastModified?: string; + executedServerId?: string; + exitStatus?: components["schemas"]["ExitStatus"]; + statusMetadata?: components["schemas"]["StatusMetadata"]; + isLogFileExisting?: boolean; + isRestart?: boolean; + logFilePath?: string; + parameters?: components["schemas"]["JobParameter"][]; + executionScopes?: string[]; + retryInformation?: components["schemas"]["JobExecutionRetryInformation"]; + continueInformation?: components["schemas"]["JobExecutionContinueInformation"]; + stepExecutions?: components["schemas"]["JobStepExecution"][]; + }; + JobExecutionSearchResult: { + hits: components["schemas"]["JobExecution"][]; + } & WithRequired; + ErrorResponse: { + title: string; + type: string; + detail: string; + instance?: string; + } & { + [key: string]: unknown; + }; + JobExecutionRequest: { + parameters?: components["schemas"]["JobParameter"][]; + }; + }; + responses: { + /** @description Your access token is invalid or expired and can’t be used to identify a user. */ + "401unauthorized": { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Forbidden. Your access token is valid, but you don’t have the required permissions to access the resource. */ + "403forbidden": { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + parameters: { + organizationId: components["schemas"]["OrganizationId"]; + jobId: string; + executionId: string; + }; + requestBodies: never; + headers: never; + pathItems: never; +} +export type $defs = Record; +export interface operations { + searchJobExecutions: { + parameters: { + query?: never; + header?: never; + path: { + organizationId: components["schemas"]["OrganizationId"]; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["JobExecutionSearchRequest"]; + }; + }; + responses: { + /** @description Returns job execution search results */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["JobExecutionSearchResult"]; + }; + }; + /** @description Bad Request - Malformed search query or invalid parameters */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Your access token is invalid or expired and can’t be used to identify a user. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Forbidden. Your access token is valid, but you don’t have the required permissions to access the resource. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + createJobExecution: { + parameters: { + query?: never; + header?: never; + path: { + organizationId: components["schemas"]["OrganizationId"]; + jobId: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["JobExecutionRequest"]; + }; + }; + responses: { + /** @description The job execution was successfully created */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["JobExecution"]; + }; + }; + /** @description Bad Request - Invalid job execution request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Your access token is invalid or expired and can’t be used to identify a user. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Forbidden. Your access token is valid, but you don’t have the required permissions to access the resource. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Job not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + getJobExecution: { + parameters: { + query?: never; + header?: never; + path: { + organizationId: components["schemas"]["OrganizationId"]; + jobId: string; + executionId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Returns the job execution details */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["JobExecution"]; + }; + }; + /** @description Your access token is invalid or expired and can’t be used to identify a user. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Forbidden. Your access token is valid, but you don’t have the required permissions to access the resource. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Job execution not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + deleteJobExecution: { + parameters: { + query?: never; + header?: never; + path: { + organizationId: components["schemas"]["OrganizationId"]; + jobId: string; + executionId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The job execution was successfully deleted */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Your access token is invalid or expired and can’t be used to identify a user. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Forbidden. Your access token is valid, but you don’t have the required permissions to access the resource. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Job execution not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; +} +type WithRequired = T & { + [P in K]-?: T[P]; +}; diff --git a/packages/b2c-tooling-sdk/src/clients/scapi-jobs.ts b/packages/b2c-tooling-sdk/src/clients/scapi-jobs.ts new file mode 100644 index 000000000..56683fb96 --- /dev/null +++ b/packages/b2c-tooling-sdk/src/clients/scapi-jobs.ts @@ -0,0 +1,58 @@ +/* + * 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 createClient, {type Client} from 'openapi-fetch'; +import type {AuthStrategy} from '../auth/types.js'; +import type {paths, components} from './scapi-jobs.generated.js'; +import {createAuthMiddleware, createLoggingMiddleware, createRateLimitMiddleware} from './middleware.js'; +import {globalMiddlewareRegistry, type MiddlewareRegistry} from './middleware-registry.js'; +import {OAuthStrategy} from '../auth/oauth.js'; +import {buildTenantScope, toOrganizationId, normalizeTenantId} from './custom-apis.js'; + +export {toOrganizationId, normalizeTenantId, buildTenantScope}; + +export type {paths, components}; +export type ScapiJobsClient = Client; +export type ScapiJobsResponse = T extends {content: {'application/json': infer R}} ? R : never; +export type ScapiJobsError = components['schemas']['ErrorResponse']; + +export type JobExecution = components['schemas']['JobExecution']; +export type JobStepExecution = components['schemas']['JobStepExecution']; +export type JobParameter = components['schemas']['JobParameter']; +export type ExecutionStatus = components['schemas']['ExecutionStatus']; +export type ExitStatus = components['schemas']['ExitStatus']; +export type JobExecutionSearchResult = components['schemas']['JobExecutionSearchResult']; + +export const SCAPI_JOBS_READ_SCOPES = ['sfcc.jobs']; +export const SCAPI_JOBS_RW_SCOPES = ['sfcc.jobs.rw']; + +export interface ScapiJobsClientConfig { + shortCode: string; + tenantId: string; + scopes?: string[]; + middlewareRegistry?: MiddlewareRegistry; +} + +export function createScapiJobsClient(config: ScapiJobsClientConfig, auth: AuthStrategy): ScapiJobsClient { + const registry = config.middlewareRegistry ?? globalMiddlewareRegistry; + + const client = createClient({ + baseUrl: `https://${config.shortCode}.api.commercecloud.salesforce.com/operation/jobs/v1`, + }); + + const requiredScopes = config.scopes ?? [...SCAPI_JOBS_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-jobs')) { + client.use(middleware); + } + + client.use(createRateLimitMiddleware({prefix: 'SCAPI-JOBS'})); + client.use(createLoggingMiddleware('SCAPI-JOBS')); + + return client; +} diff --git a/packages/b2c-tooling-sdk/src/config/dw-json.ts b/packages/b2c-tooling-sdk/src/config/dw-json.ts index 8ab1e4656..9488d5c6a 100644 --- a/packages/b2c-tooling-sdk/src/config/dw-json.ts +++ b/packages/b2c-tooling-sdk/src/config/dw-json.ts @@ -93,6 +93,8 @@ export interface DwJsonConfig { certificatePassphrase?: string; /** Whether to skip SSL/TLS certificate verification (self-signed certs) */ selfSigned?: boolean; + /** API backend preference for operations that support both OCAPI and SCAPI */ + apiBackend?: 'ocapi' | 'scapi' | 'auto'; /** * Safety configuration for this instance. * diff --git a/packages/b2c-tooling-sdk/src/config/mapping.ts b/packages/b2c-tooling-sdk/src/config/mapping.ts index 5e0ed06ea..a2e0b33b5 100644 --- a/packages/b2c-tooling-sdk/src/config/mapping.ts +++ b/packages/b2c-tooling-sdk/src/config/mapping.ts @@ -71,6 +71,7 @@ export const CONFIG_KEY_ALIASES: Record = { 'oauth-scopes': 'oauthScopes', 'auth-methods': 'authMethods', 'cip-host': 'cipHost', + 'api-backend': 'apiBackend', }; /** @@ -173,6 +174,8 @@ export function mapDwJsonToNormalizedConfig(json: DwJsonConfig): NormalizedConfi certificate: json.certificate, certificatePassphrase: json.certificatePassphrase, selfSigned: json.selfSigned, + // API backend + apiBackend: json.apiBackend, // Safety safety: mapDwJsonSafety(json.safety), }; @@ -308,6 +311,9 @@ export function mapNormalizedConfigToDwJson(config: Partial, n if (config.selfSigned !== undefined) { result.selfSigned = config.selfSigned; } + if (config.apiBackend !== undefined) { + result.apiBackend = config.apiBackend; + } if (config.safety !== undefined) { result.safety = { level: config.safety.level, @@ -443,6 +449,8 @@ export function mergeConfigsWithProtection( certificate: overrides.certificate ?? base.certificate, certificatePassphrase: overrides.certificatePassphrase ?? base.certificatePassphrase, selfSigned: overrides.selfSigned ?? base.selfSigned, + // API backend + apiBackend: overrides.apiBackend ?? base.apiBackend, // Safety safety: overrides.safety ?? base.safety, }, diff --git a/packages/b2c-tooling-sdk/src/config/types.ts b/packages/b2c-tooling-sdk/src/config/types.ts index b28411202..c28505575 100644 --- a/packages/b2c-tooling-sdk/src/config/types.ts +++ b/packages/b2c-tooling-sdk/src/config/types.ts @@ -127,6 +127,10 @@ export interface NormalizedConfig { /** Whether to skip SSL/TLS certificate verification (self-signed certs) */ selfSigned?: boolean; + // API backend + /** API backend preference for operations that support both OCAPI and SCAPI */ + apiBackend?: 'ocapi' | 'scapi' | 'auto'; + // Safety /** Safety configuration for this instance */ safety?: { diff --git a/packages/b2c-tooling-sdk/src/index.ts b/packages/b2c-tooling-sdk/src/index.ts index e651cfc91..93f93a0d1 100644 --- a/packages/b2c-tooling-sdk/src/index.ts +++ b/packages/b2c-tooling-sdk/src/index.ts @@ -215,6 +215,12 @@ export { siteArchiveImport, siteArchiveExport, siteArchiveExportToPath, + // Backend abstraction + createJobsBackend, + waitForJobExecution, + FallbackJobsBackend, + OcapiJobsBackend, + ScapiJobsBackend, } from './operations/jobs/index.js'; export type { JobExecution, @@ -226,6 +232,14 @@ export type { WaitForJobPollInfo, SearchJobExecutionsOptions, JobExecutionSearchResult, + // Backend abstraction types + JobsBackend, + JobsBackendConfig, + ApiBackendPreference, + JobExecutionResult, + JobStepExecutionResult, + JobExecutionSearchResults, + ScapiJobsBackendConfig, SiteArchiveImportOptions, SiteArchiveImportResult, SiteArchiveExportOptions, diff --git a/packages/b2c-tooling-sdk/src/operations/jobs/backend.ts b/packages/b2c-tooling-sdk/src/operations/jobs/backend.ts new file mode 100644 index 000000000..6932196e1 --- /dev/null +++ b/packages/b2c-tooling-sdk/src/operations/jobs/backend.ts @@ -0,0 +1,164 @@ +/* + * 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 type {B2CInstance} from '../../instance/index.js'; +import type {AuthStrategy} from '../../auth/types.js'; +import type {JobsBackend, JobExecutionResult, JobExecutionSearchResults} from './types.js'; +import type {ExecuteJobOptions, SearchJobExecutionsOptions, WaitForJobOptions, WaitForJobPollInfo} from './run.js'; +import {OcapiJobsBackend} from './ocapi-backend.js'; +import {ScapiJobsBackend} from './scapi-backend.js'; +import {getLogger} from '../../logging/logger.js'; + +export type ApiBackendPreference = 'ocapi' | 'scapi' | 'auto'; + +export interface JobsBackendConfig { + preference: ApiBackendPreference; + instance: B2CInstance; + shortCode?: string; + tenantId?: string; + auth?: AuthStrategy; +} + +export function createJobsBackend(config: JobsBackendConfig): JobsBackend { + const resolved = resolveBackend(config); + + if (resolved === 'ocapi') { + return new OcapiJobsBackend(config.instance); + } + + const scapiBackend = new ScapiJobsBackend({ + shortCode: config.shortCode!, + tenantId: config.tenantId!, + auth: config.auth!, + instance: config.instance, + }); + + if (config.preference === 'scapi') { + return scapiBackend; + } + + // Auto mode: wrap with fallback + const ocapiBackend = new OcapiJobsBackend(config.instance); + return new FallbackJobsBackend(scapiBackend, ocapiBackend); +} + +function resolveBackend(config: JobsBackendConfig): 'ocapi' | 'scapi' { + if (config.preference === 'ocapi') return 'ocapi'; + if (config.preference === 'scapi') { + if (!config.shortCode || !config.tenantId) { + throw new Error('SCAPI backend requires shortCode and tenantId configuration.'); + } + if (!config.auth) { + throw new Error('SCAPI backend requires OAuth credentials.'); + } + return 'scapi'; + } + + // Auto: prefer SCAPI when config available + if (config.shortCode && config.tenantId && config.auth) { + return 'scapi'; + } + return 'ocapi'; +} + +export class FallbackJobsBackend implements JobsBackend { + private resolvedBackend?: JobsBackend; + + constructor( + private scapiBackend: ScapiJobsBackend, + private ocapiBackend: OcapiJobsBackend, + ) {} + + get name(): 'ocapi' | 'scapi' { + return (this.resolvedBackend?.name ?? 'scapi') as 'ocapi' | 'scapi'; + } + + async executeJob(jobId: string, options?: ExecuteJobOptions): Promise { + return this.withFallback((backend) => backend.executeJob(jobId, options)); + } + + async getJobExecution(jobId: string, executionId: string): Promise { + return this.withFallback((backend) => backend.getJobExecution(jobId, executionId)); + } + + async searchJobExecutions(options?: SearchJobExecutionsOptions): Promise { + return this.withFallback((backend) => backend.searchJobExecutions(options)); + } + + async deleteJobExecution(jobId: string, executionId: string): Promise { + return this.withFallback((backend) => backend.deleteJobExecution(jobId, executionId)); + } + + async getJobLog(execution: JobExecutionResult): Promise { + return this.withFallback((backend) => backend.getJobLog(execution)); + } + + private async withFallback(fn: (backend: JobsBackend) => Promise): Promise { + if (this.resolvedBackend) { + return fn(this.resolvedBackend); + } + + try { + const result = await fn(this.scapiBackend); + this.resolvedBackend = this.scapiBackend; + return result; + } catch (error) { + if (isInvalidScopeError(error)) { + const logger = getLogger(); + logger.info('SCAPI jobs scope unavailable, falling back to OCAPI'); + this.resolvedBackend = this.ocapiBackend; + return fn(this.ocapiBackend); + } + throw error; + } + } +} + +function isInvalidScopeError(error: unknown): boolean { + return error instanceof Error && error.message.includes('invalid_scope'); +} + +export async function waitForJobExecution( + backend: JobsBackend, + jobId: string, + executionId: string, + options: WaitForJobOptions = {}, +): Promise { + const {pollIntervalSeconds = 3, timeoutSeconds = 0, onPoll} = options; + const sleepFn = options.sleep ?? defaultSleep; + const startTime = Date.now(); + const pollIntervalMs = pollIntervalSeconds * 1000; + const timeoutMs = timeoutSeconds * 1000; + await sleepFn(pollIntervalMs); + + while (true) { + const elapsedSeconds = Math.round((Date.now() - startTime) / 1000); + + if (timeoutSeconds > 0 && Date.now() - startTime > timeoutMs) { + throw new Error(`Timeout waiting for job ${jobId} execution ${executionId}`); + } + + const execution = await backend.getJobExecution(jobId, executionId); + const currentStatus = execution.executionStatus; + + const pollInfo: WaitForJobPollInfo = {jobId, executionId, elapsedSeconds, status: currentStatus}; + onPoll?.(pollInfo); + + if (execution.executionStatus === 'aborted' || execution.exitStatus?.status === 'error') { + const {JobExecutionError} = await import('./run.js'); + throw new JobExecutionError(`Job ${jobId} failed`, execution._raw as never); + } + + if (execution.executionStatus === 'finished') { + return execution; + } + + await sleepFn(pollIntervalMs); + } +} + +async function defaultSleep(ms: number): Promise { + await new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/packages/b2c-tooling-sdk/src/operations/jobs/index.ts b/packages/b2c-tooling-sdk/src/operations/jobs/index.ts index 8f19d36f7..6164484d4 100644 --- a/packages/b2c-tooling-sdk/src/operations/jobs/index.ts +++ b/packages/b2c-tooling-sdk/src/operations/jobs/index.ts @@ -90,6 +90,14 @@ export type { JobExecutionSearchResult, } from './run.js'; +// Backend abstraction +export {createJobsBackend, waitForJobExecution, FallbackJobsBackend} from './backend.js'; +export type {JobsBackendConfig, ApiBackendPreference} from './backend.js'; +export {OcapiJobsBackend} from './ocapi-backend.js'; +export {ScapiJobsBackend} from './scapi-backend.js'; +export type {ScapiJobsBackendConfig} from './scapi-backend.js'; +export type {JobsBackend, JobExecutionResult, JobStepExecutionResult, JobExecutionSearchResults} from './types.js'; + // Site archive import/export export { siteArchiveImport, diff --git a/packages/b2c-tooling-sdk/src/operations/jobs/ocapi-backend.ts b/packages/b2c-tooling-sdk/src/operations/jobs/ocapi-backend.ts new file mode 100644 index 000000000..a58e0b6f1 --- /dev/null +++ b/packages/b2c-tooling-sdk/src/operations/jobs/ocapi-backend.ts @@ -0,0 +1,99 @@ +/* + * 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 type {B2CInstance} from '../../instance/index.js'; +import type {JobsBackend, JobExecutionResult, JobStepExecutionResult, JobExecutionSearchResults} from './types.js'; +import type {ExecuteJobOptions, SearchJobExecutionsOptions, JobExecution, JobStepExecution} from './run.js'; +import { + executeJob as ocapiExecuteJob, + getJobExecution as ocapiGetJobExecution, + searchJobExecutions as ocapiSearchJobExecutions, + getJobLog as ocapiGetJobLog, +} from './run.js'; + +function mapStepExecution(step: JobStepExecution): JobStepExecutionResult { + return { + id: step.id, + stepId: step.step_id, + executionStatus: step.execution_status, + exitStatus: step.exit_status + ? { + code: step.exit_status.code ?? '', + message: step.exit_status.message, + status: step.exit_status.status as 'ok' | 'error' | undefined, + } + : undefined, + duration: step.duration, + }; +} + +function mapOcapiExecution(ocapi: JobExecution): JobExecutionResult { + return { + id: ocapi.id ?? '', + jobId: ocapi.job_id ?? '', + executionStatus: (ocapi.execution_status ?? 'unknown') as JobExecutionResult['executionStatus'], + exitStatus: ocapi.exit_status + ? { + code: ocapi.exit_status.code ?? '', + message: ocapi.exit_status.message, + status: ocapi.exit_status.status as 'ok' | 'error' | undefined, + } + : undefined, + startTime: ocapi.start_time, + endTime: ocapi.end_time, + duration: ocapi.duration, + stepExecutions: ocapi.step_executions?.map(mapStepExecution), + logFilePath: ocapi.log_file_path, + isLogFileExisting: ocapi.is_log_file_existing, + parameters: ocapi.parameters, + _raw: ocapi, + }; +} + +export class OcapiJobsBackend implements JobsBackend { + readonly name = 'ocapi' as const; + + constructor(private instance: B2CInstance) {} + + async executeJob(jobId: string, options?: ExecuteJobOptions): Promise { + const result = await ocapiExecuteJob(this.instance, jobId, options); + return mapOcapiExecution(result); + } + + async getJobExecution(jobId: string, executionId: string): Promise { + const result = await ocapiGetJobExecution(this.instance, jobId, executionId); + return mapOcapiExecution(result); + } + + async searchJobExecutions(options?: SearchJobExecutionsOptions): Promise { + const result = await ocapiSearchJobExecutions(this.instance, options); + return { + total: result.total, + limit: result.count, + offset: result.start, + hits: result.hits.map(mapOcapiExecution), + }; + } + + async deleteJobExecution(_jobId: string, _executionId: string): Promise { + throw new Error('Delete job execution is not supported via OCAPI. Use --api-backend scapi.'); + } + + async getJobLog(execution: JobExecutionResult): Promise { + const ocapiExecution = execution._raw as JobExecution; + if (ocapiExecution) { + return ocapiGetJobLog(this.instance, ocapiExecution); + } + if (!execution.logFilePath) { + throw new Error('No log file path available'); + } + if (!execution.isLogFileExisting) { + throw new Error('Log file does not exist'); + } + const logPath = execution.logFilePath.replace(/^\/Sites\//, ''); + const content = await this.instance.webdav.get(logPath); + return new TextDecoder().decode(content); + } +} diff --git a/packages/b2c-tooling-sdk/src/operations/jobs/scapi-backend.ts b/packages/b2c-tooling-sdk/src/operations/jobs/scapi-backend.ts new file mode 100644 index 000000000..6af2f6c39 --- /dev/null +++ b/packages/b2c-tooling-sdk/src/operations/jobs/scapi-backend.ts @@ -0,0 +1,280 @@ +/* + * 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 type {B2CInstance} from '../../instance/index.js'; +import type {AuthStrategy} from '../../auth/types.js'; +import type {JobsBackend, JobExecutionResult, JobStepExecutionResult, JobExecutionSearchResults} from './types.js'; +import type {ExecuteJobOptions, SearchJobExecutionsOptions} from './run.js'; +import { + createScapiJobsClient, + SCAPI_JOBS_RW_SCOPES, + SCAPI_JOBS_READ_SCOPES, + type ScapiJobsClient, + type ScapiJobsClientConfig, + type JobExecution as ScapiJobExecution, + type JobStepExecution as ScapiJobStepExecution, +} from '../../clients/scapi-jobs.js'; +import {buildTenantScope, toOrganizationId} from '../../clients/custom-apis.js'; +import {getLogger} from '../../logging/logger.js'; + +function mapStepExecution(step: ScapiJobStepExecution): JobStepExecutionResult { + return { + id: step.id, + stepId: step.stepId, + executionStatus: step.executionStatus, + exitStatus: step.exitStatus + ? { + code: step.exitStatus.code ?? '', + message: step.exitStatus.message, + status: step.exitStatus.status, + } + : undefined, + duration: step.duration, + }; +} + +function mapScapiExecution(scapi: ScapiJobExecution): JobExecutionResult { + return { + id: scapi.id, + jobId: scapi.jobId, + executionStatus: (scapi.executionStatus ?? 'unknown') as JobExecutionResult['executionStatus'], + exitStatus: scapi.exitStatus + ? { + code: scapi.exitStatus.code ?? '', + message: scapi.exitStatus.message, + status: scapi.exitStatus.status, + } + : undefined, + startTime: scapi.startTime, + endTime: scapi.endTime, + duration: scapi.duration, + stepExecutions: scapi.stepExecutions?.map(mapStepExecution), + logFilePath: scapi.logFilePath, + isLogFileExisting: scapi.isLogFileExisting, + parameters: scapi.parameters, + _raw: scapi, + }; +} + +export interface ScapiJobsBackendConfig { + shortCode: string; + tenantId: string; + auth: AuthStrategy; + instance: B2CInstance; +} + +export class ScapiJobsBackend implements JobsBackend { + readonly name = 'scapi' as const; + + private resolvedScopeTier?: 'rw' | 'read-only'; + private rwClient?: ScapiJobsClient; + private readClient?: ScapiJobsClient; + private organizationId: string; + + constructor(private config: ScapiJobsBackendConfig) { + this.organizationId = toOrganizationId(config.tenantId); + } + + async executeJob(jobId: string, options?: ExecuteJobOptions): Promise { + const client = await this.getClientForWrite(); + const {parameters = [], body: rawBody} = options ?? {}; + + let requestBody: Record | undefined; + if (rawBody) { + requestBody = rawBody; + } else if (parameters.length > 0) { + requestBody = {parameters}; + } + + const {data, error, response} = await client.POST('/organizations/{organizationId}/jobs/{jobId}/executions', { + params: {path: {organizationId: this.organizationId, jobId}}, + body: requestBody as unknown as {parameters?: Array<{name: string; value: string}>}, + }); + + if (response.status === 400) { + const errorBody = error as unknown as {title?: string; type?: string; detail?: string; jobId?: string}; + if (errorBody?.type?.includes('job-already-running') || errorBody?.title === 'Job Already Running') { + if (options?.waitForRunning !== false) { + const logger = getLogger(); + logger.warn({jobId}, `Job ${jobId} already running, waiting for it to finish...`); + const running = await this.findRunningExecution(jobId); + if (running) { + await this.waitForTerminal(jobId, running.id); + } + return this.executeJob(jobId, {...options, waitForRunning: false}); + } + throw new Error(`Job ${jobId} is already running`); + } + } + + if (error || !data) { + const errorBody = error as unknown as {detail?: string; title?: string}; + const message = errorBody?.detail ?? errorBody?.title ?? `Failed to execute job ${jobId}`; + throw new Error(message); + } + + return mapScapiExecution(data); + } + + async getJobExecution(jobId: string, executionId: string): Promise { + const client = await this.getClientForRead(); + + const {data, error} = await client.GET('/organizations/{organizationId}/jobs/{jobId}/executions/{executionId}', { + params: {path: {organizationId: this.organizationId, jobId, executionId}}, + }); + + if (error || !data) { + const errorBody = error as unknown as {detail?: string; title?: string}; + const message = errorBody?.detail ?? `Failed to get job execution ${executionId}`; + throw new Error(message); + } + + return mapScapiExecution(data); + } + + async searchJobExecutions(options?: SearchJobExecutionsOptions): Promise { + const client = await this.getClientForRead(); + const {jobId, status, count = 25, start = 0, sortBy = 'start_time', sortOrder = 'desc'} = options ?? {}; + + const queries: unknown[] = []; + if (jobId) { + queries.push({termQuery: {fields: ['job_id'], operator: 'is', values: [jobId]}}); + } + if (status) { + const statusValues = Array.isArray(status) ? status : [status]; + queries.push({termQuery: {fields: ['status'], operator: 'one_of', values: statusValues}}); + } + + let query: unknown; + if (queries.length === 0) { + query = {matchAllQuery: {}}; + } else if (queries.length === 1) { + query = queries[0]; + } else { + query = {boolQuery: {must: queries}}; + } + + const {data, error} = await client.POST('/organizations/{organizationId}/job-execution-search', { + params: {path: {organizationId: this.organizationId}}, + body: { + query, + limit: count, + offset: start, + sorts: [{field: sortBy, sortOrder}], + } as never, + }); + + if (error || !data) { + const errorBody = error as unknown as {detail?: string; title?: string}; + const message = errorBody?.detail ?? 'Failed to search job executions'; + throw new Error(message); + } + + const result = data as unknown as {total?: number; limit?: number; offset?: number; hits?: ScapiJobExecution[]}; + return { + total: result.total ?? 0, + limit: result.limit ?? count, + offset: result.offset ?? start, + hits: (result.hits ?? []).map(mapScapiExecution), + }; + } + + async deleteJobExecution(jobId: string, executionId: string): Promise { + const client = await this.getClientForWrite(); + + const {error} = await client.DELETE('/organizations/{organizationId}/jobs/{jobId}/executions/{executionId}', { + params: {path: {organizationId: this.organizationId, jobId, executionId}}, + }); + + if (error) { + const errorBody = error as unknown as {detail?: string; title?: string}; + const message = errorBody?.detail ?? `Failed to delete job execution ${executionId}`; + throw new Error(message); + } + } + + async getJobLog(execution: JobExecutionResult): Promise { + if (!execution.logFilePath) { + throw new Error('No log file path available'); + } + if (!execution.isLogFileExisting) { + throw new Error('Log file does not exist'); + } + const logPath = execution.logFilePath.replace(/^\/Sites\//, ''); + const content = await this.config.instance.webdav.get(logPath); + return new TextDecoder().decode(content); + } + + private async getClientForWrite(): Promise { + if (this.resolvedScopeTier === 'rw' && this.rwClient) { + return this.rwClient; + } + if (this.resolvedScopeTier === 'read-only') { + throw new Error( + 'SCAPI Jobs API requires the "sfcc.jobs.rw" scope to execute or delete jobs. ' + + 'Add this scope to your API client in Account Manager.', + ); + } + if (!this.rwClient) { + this.rwClient = this.buildClient(SCAPI_JOBS_RW_SCOPES); + } + this.resolvedScopeTier = 'rw'; + return this.rwClient; + } + + private async getClientForRead(): Promise { + if (this.resolvedScopeTier && this.rwClient) { + return this.rwClient; + } + if (this.resolvedScopeTier === 'read-only' && this.readClient) { + return this.readClient; + } + if (!this.rwClient) { + this.rwClient = this.buildClient(SCAPI_JOBS_RW_SCOPES); + } + this.resolvedScopeTier = 'rw'; + return this.rwClient; + } + + /** + * Called when we detect an invalid_scope error on the rw client for a read operation. + * Downgrades to read-only scope. + */ + downgradeToReadOnly(): void { + this.resolvedScopeTier = 'read-only'; + this.readClient = this.buildClient(SCAPI_JOBS_READ_SCOPES); + } + + private buildClient(scopes: string[]): ScapiJobsClient { + const clientConfig: ScapiJobsClientConfig = { + shortCode: this.config.shortCode, + tenantId: this.config.tenantId, + scopes: [...scopes, buildTenantScope(this.config.tenantId)], + }; + return createScapiJobsClient(clientConfig, this.config.auth); + } + + private async findRunningExecution(jobId: string): Promise { + const results = await this.searchJobExecutions({ + jobId, + status: ['RUNNING', 'PENDING'], + sortBy: 'start_time', + sortOrder: 'asc', + count: 1, + }); + return results.hits[0]; + } + + private async waitForTerminal(jobId: string, executionId: string): Promise { + const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + while (true) { + await sleep(3000); + const execution = await this.getJobExecution(jobId, executionId); + if (execution.executionStatus === 'finished' || execution.executionStatus === 'aborted') { + return; + } + } + } +} diff --git a/packages/b2c-tooling-sdk/src/operations/jobs/types.ts b/packages/b2c-tooling-sdk/src/operations/jobs/types.ts new file mode 100644 index 000000000..efc3bed1a --- /dev/null +++ b/packages/b2c-tooling-sdk/src/operations/jobs/types.ts @@ -0,0 +1,61 @@ +/* + * 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 type {ExecuteJobOptions, WaitForJobOptions, SearchJobExecutionsOptions} from './run.js'; + +export type {ExecuteJobOptions, WaitForJobOptions, SearchJobExecutionsOptions}; + +export interface JobExecutionResult { + id: string; + jobId: string; + executionStatus: + | 'pending' + | 'running' + | 'pausing' + | 'paused' + | 'resuming' + | 'resumed' + | 'restarting' + | 'restarted' + | 'retrying' + | 'retried' + | 'aborting' + | 'aborted' + | 'finished' + | 'unknown'; + exitStatus?: {code: string; message?: string; status?: 'ok' | 'error'}; + startTime?: string; + endTime?: string; + duration?: number; + stepExecutions?: JobStepExecutionResult[]; + logFilePath?: string; + isLogFileExisting?: boolean; + parameters?: Array<{name: string; value: string}>; + _raw?: unknown; +} + +export interface JobStepExecutionResult { + id?: string; + stepId?: string; + executionStatus?: string; + exitStatus?: {code: string; message?: string; status?: 'ok' | 'error'}; + duration?: number; +} + +export interface JobExecutionSearchResults { + total: number; + limit: number; + offset: number; + hits: JobExecutionResult[]; +} + +export interface JobsBackend { + readonly name: 'ocapi' | 'scapi'; + executeJob(jobId: string, options?: ExecuteJobOptions): Promise; + getJobExecution(jobId: string, executionId: string): Promise; + searchJobExecutions(options?: SearchJobExecutionsOptions): Promise; + deleteJobExecution(jobId: string, executionId: string): Promise; + getJobLog(execution: JobExecutionResult): Promise; +} diff --git a/skills/b2c-cli/skills/b2c-job/SKILL.md b/skills/b2c-cli/skills/b2c-job/SKILL.md index f4fa0c94d..4b4ed4474 100644 --- a/skills/b2c-cli/skills/b2c-job/SKILL.md +++ b/skills/b2c-cli/skills/b2c-job/SKILL.md @@ -184,6 +184,34 @@ b2c job search --sort-by start_time --sort-order desc b2c job search --json ``` +### Delete Job Executions + +```bash +# delete a job execution record (requires SCAPI) +b2c job execution delete my-job abc123-def456 +``` + +### API Backend Selection + +Job commands support both OCAPI and SCAPI backends. By default, SCAPI is preferred when `shortCode` and `tenantId` are configured. + +```bash +# force SCAPI backend +b2c job run my-job --api-backend scapi + +# force OCAPI backend +b2c job run my-job --api-backend ocapi + +# auto-detect (default) - prefers SCAPI when configured, falls back to OCAPI +b2c job run my-job --api-backend auto +``` + +Set via dw.json: `"api-backend": "scapi"` or env: `SFCC_API_BACKEND=scapi`. + +**SCAPI scopes**: `sfcc.jobs.rw` (recommended) for full access, or `sfcc.jobs` for read-only (search, wait, log). + +> **Note:** `job import` and `job export` currently always use OCAPI regardless of `--api-backend`. + ### Wait for Job Completion ```bash From ae68648b3f1289ad4e16271e022ba7a61b911385 Mon Sep 17 00:00:00 2001 From: Charles Lavery Date: Fri, 8 May 2026 13:22:35 -0400 Subject: [PATCH 02/22] Extract reusable SCAPI/OCAPI dual-backend pattern from jobs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pulls three domain-agnostic utilities out of jobs into shared modules ahead of applying the same pattern to scripts, users, and roles: - isInvalidScopeError, ApiBackendPreference, resolveScapiOrOcapi — scope-error detection and preference resolution - ScapiFallbackBackend — generic fallback wrapper that tries SCAPI first and falls back to OCAPI on invalid_scope - ScopeTierManager — manages dual rw/read-only client tiers with optimistic rw + downgrade on scope error Refactors ScapiJobsBackend and FallbackJobsBackend to use the new utilities without behavior change. Removes the unused downgradeToReadOnly() method and confusing tier-resolution logic. --- packages/b2c-tooling-sdk/src/clients/index.ts | 7 ++ .../src/clients/scapi-backend-utils.ts | 87 +++++++++++++++ .../src/clients/scapi-fallback-backend.ts | 75 +++++++++++++ .../src/clients/scapi-scope-tier.ts | 102 ++++++++++++++++++ .../src/operations/jobs/backend.ts | 68 +++--------- .../src/operations/jobs/scapi-backend.ts | 59 +++------- 6 files changed, 295 insertions(+), 103 deletions(-) create mode 100644 packages/b2c-tooling-sdk/src/clients/scapi-backend-utils.ts create mode 100644 packages/b2c-tooling-sdk/src/clients/scapi-fallback-backend.ts create mode 100644 packages/b2c-tooling-sdk/src/clients/scapi-scope-tier.ts diff --git a/packages/b2c-tooling-sdk/src/clients/index.ts b/packages/b2c-tooling-sdk/src/clients/index.ts index e341f4ebb..08b5082cc 100644 --- a/packages/b2c-tooling-sdk/src/clients/index.ts +++ b/packages/b2c-tooling-sdk/src/clients/index.ts @@ -336,6 +336,13 @@ export type { components as ScapiJobsComponents, } from './scapi-jobs.js'; +// SCAPI dual-backend utilities (shared across SCAPI/OCAPI domains) +export {isInvalidScopeError, resolveScapiOrOcapi} from './scapi-backend-utils.js'; +export type {ApiBackendPreference, BackendBase, ResolveBackendOptions} from './scapi-backend-utils.js'; +export {ScapiFallbackBackend} from './scapi-fallback-backend.js'; +export {ScopeTierManager} from './scapi-scope-tier.js'; +export type {ScopeTier, ScopeTierManagerOptions} from './scapi-scope-tier.js'; + export {getApiErrorMessage} from './error-utils.js'; export {createTlsDispatcher} from './tls-dispatcher.js'; diff --git a/packages/b2c-tooling-sdk/src/clients/scapi-backend-utils.ts b/packages/b2c-tooling-sdk/src/clients/scapi-backend-utils.ts new file mode 100644 index 000000000..87a79d965 --- /dev/null +++ b/packages/b2c-tooling-sdk/src/clients/scapi-backend-utils.ts @@ -0,0 +1,87 @@ +/* + * 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 + */ +/** + * Shared utilities for SCAPI/OCAPI dual-backend domains. + * + * Each domain that supports both OCAPI (legacy) and SCAPI (modern) shares + * these utilities to keep behavior consistent: backend preference resolution, + * scope-error detection, and the canonical `ApiBackendPreference` type. + * + * @module clients/scapi-backend-utils + */ + +/** + * User-facing API backend preference. + * + * - `'ocapi'`: force OCAPI (always use the legacy Data API). + * - `'scapi'`: force SCAPI (requires shortCode + tenantId; fails loudly if scopes missing). + * - `'auto'`: prefer SCAPI when configured, transparently fall back to OCAPI on `invalid_scope`. + */ +export type ApiBackendPreference = 'ocapi' | 'scapi' | 'auto'; + +/** + * Common shape of every dual-backend implementation. Each canonical backend + * (e.g., `JobsBackend`) extends this so a generic fallback wrapper can read + * `name` to know which backend served the last call. + */ +export interface BackendBase { + readonly name: 'ocapi' | 'scapi'; +} + +/** + * Detects an Account Manager `invalid_scope` error. + * + * When a client's API client doesn't have the requested scope configured, + * Account Manager returns `{"error":"invalid_scope", ...}` on the token + * request. The OAuth strategy surfaces that as an Error whose message + * contains `invalid_scope`. + * + * Used by fallback wrappers to decide whether to downgrade to OCAPI. + */ +export function isInvalidScopeError(error: unknown): boolean { + return error instanceof Error && error.message.includes('invalid_scope'); +} + +/** + * Inputs to `resolveScapiOrOcapi`. + */ +export interface ResolveBackendOptions { + /** User preference (from `--api-backend` flag or `apiBackend` config). */ + preference: ApiBackendPreference; + /** True iff shortCode + tenantId + auth are all available. */ + hasScapiConfig: boolean; + /** Domain name used in error messages, e.g. `'Jobs'`, `'Scripts'`. */ + domainName: string; +} + +/** + * Resolves a user preference + config availability into a concrete backend choice. + * + * - Explicit `'ocapi'` always returns `'ocapi'`. + * - Explicit `'scapi'` requires SCAPI config and throws if missing. + * - `'auto'` returns `'scapi'` if SCAPI config is available, otherwise `'ocapi'`. + * + * Throws an error with the domain name in the message when explicit SCAPI is + * requested without the required configuration. + */ +export function resolveScapiOrOcapi(opts: ResolveBackendOptions): 'ocapi' | 'scapi' { + const {preference, hasScapiConfig, domainName} = opts; + + if (preference === 'ocapi') return 'ocapi'; + + if (preference === 'scapi') { + if (!hasScapiConfig) { + throw new Error( + `${domainName} SCAPI backend requires shortCode, tenantId, and OAuth credentials. ` + + `Configure them in dw.json or use --api-backend ocapi.`, + ); + } + return 'scapi'; + } + + // auto + return hasScapiConfig ? 'scapi' : 'ocapi'; +} diff --git a/packages/b2c-tooling-sdk/src/clients/scapi-fallback-backend.ts b/packages/b2c-tooling-sdk/src/clients/scapi-fallback-backend.ts new file mode 100644 index 000000000..3d5c7e192 --- /dev/null +++ b/packages/b2c-tooling-sdk/src/clients/scapi-fallback-backend.ts @@ -0,0 +1,75 @@ +/* + * 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 + */ +/** + * Generic fallback wrapper for SCAPI/OCAPI dual backends. + * + * Each domain (jobs, scripts, users, roles) gets a thin subclass that + * delegates each interface method through `withFallback`. The wrapper itself + * holds no domain knowledge — it only implements the "try SCAPI first; on + * `invalid_scope`, fall back to OCAPI; cache the choice" behavior. + * + * @module clients/scapi-fallback-backend + */ +import {getLogger} from '../logging/logger.js'; +import {isInvalidScopeError, type BackendBase} from './scapi-backend-utils.js'; + +/** + * Base class for `Fallback*Backend` implementations. Subclasses implement + * the domain interface (e.g., `JobsBackend`) by delegating each method to + * `withFallback`. + * + * @example + * ```ts + * class FallbackJobsBackend extends ScapiFallbackBackend implements JobsBackend { + * async executeJob(jobId: string, options?: ExecuteJobOptions) { + * return this.withFallback((b) => b.executeJob(jobId, options)); + * } + * // ... one delegating method per interface method + * } + * ``` + */ +export abstract class ScapiFallbackBackend { + protected resolvedBackend?: T; + + constructor( + protected scapiBackend: T, + protected ocapiBackend: T, + /** Used in fallback log messages, e.g. `'jobs'`, `'scripts'`. */ + protected domainName: string, + ) {} + + /** + * Reports the backend that served the last successful call. Defaults to + * `'scapi'` before the first call, since that's what we'd try first. + */ + get name(): 'ocapi' | 'scapi' { + return this.resolvedBackend?.name ?? this.scapiBackend.name; + } + + /** + * Runs `fn` against the resolved backend, or against SCAPI first with + * automatic OCAPI fallback on `invalid_scope`. The choice is cached: once + * a backend has succeeded (or fallen back), all subsequent calls go to it. + */ + protected async withFallback(fn: (backend: T) => Promise): Promise { + if (this.resolvedBackend) { + return fn(this.resolvedBackend); + } + + try { + const result = await fn(this.scapiBackend); + this.resolvedBackend = this.scapiBackend; + return result; + } catch (error) { + if (isInvalidScopeError(error)) { + getLogger().info(`SCAPI ${this.domainName} scope unavailable, falling back to OCAPI`); + this.resolvedBackend = this.ocapiBackend; + return fn(this.ocapiBackend); + } + throw error; + } + } +} diff --git a/packages/b2c-tooling-sdk/src/clients/scapi-scope-tier.ts b/packages/b2c-tooling-sdk/src/clients/scapi-scope-tier.ts new file mode 100644 index 000000000..b90273ccd --- /dev/null +++ b/packages/b2c-tooling-sdk/src/clients/scapi-scope-tier.ts @@ -0,0 +1,102 @@ +/* + * 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 + */ +/** + * Scope-tier client manager for SCAPI domains with dual scopes. + * + * Many SCAPI Admin APIs expose two scopes — read-only and read-write + * (e.g., `sfcc.jobs` and `sfcc.jobs.rw`). A given API client may have only + * one of them configured in Account Manager. The optimistic strategy is to + * request `rw` first and downgrade to read-only only when `invalid_scope` + * is detected on a read operation. + * + * `ScopeTierManager` encapsulates that state machine so each SCAPI backend + * doesn't have to reimplement it. Write operations always require `rw`; + * if we already know the client only has read scope, the manager throws + * a descriptive error rather than making a doomed request. + * + * @module clients/scapi-scope-tier + */ + +export type ScopeTier = 'rw' | 'read-only'; + +export interface ScopeTierManagerOptions { + /** Builds a typed SCAPI client with the given OAuth scopes. */ + buildClient(scopes: string[]): C; + /** Scopes for read-write operations, e.g., `['sfcc.jobs.rw']`. */ + rwScopes: string[]; + /** Scopes for read-only operations, e.g., `['sfcc.jobs']`. */ + readScopes: string[]; + /** Domain name surfaced in error messages, e.g. `'Jobs'`, `'Scripts'`. */ + domainName: string; +} + +/** + * Lazy-initialized manager for clients at different scope tiers. + * + * - First read or write call builds the rw client and caches it. + * - If the caller detects an `invalid_scope` error on a read attempt, it + * calls `downgradeToReadOnly()` and the next read uses the read-only client. + * - Once downgraded, write requests throw — the API client lacks rw scope. + * + * The same rw client serves both read and write while the rw scope is valid; + * we only build a separate read-only client after a downgrade. + */ +export class ScopeTierManager { + private rwClient?: C; + private readClient?: C; + private resolved?: ScopeTier; + + constructor(private opts: ScopeTierManagerOptions) {} + + /** The currently-resolved tier, or undefined before first use. */ + get resolvedTier(): ScopeTier | undefined { + return this.resolved; + } + + /** + * Returns a client suitable for write operations. Throws if we've already + * downgraded to read-only — the API client doesn't have the rw scope. + */ + getClientForWrite(): C { + if (this.resolved === 'read-only') { + throw new Error( + `SCAPI ${this.opts.domainName} API requires the "${this.opts.rwScopes.join(' ')}" scope. ` + + `Add this scope to your API client in Account Manager.`, + ); + } + if (!this.rwClient) { + this.rwClient = this.opts.buildClient(this.opts.rwScopes); + } + this.resolved = 'rw'; + return this.rwClient; + } + + /** + * Returns a client suitable for read operations. Prefers the rw client if + * it's already been used successfully (rw scope grants read too). + */ + getClientForRead(): C { + if (this.resolved === 'read-only') { + // Already downgraded; readClient is built in downgradeToReadOnly() + return this.readClient!; + } + if (!this.rwClient) { + this.rwClient = this.opts.buildClient(this.opts.rwScopes); + } + this.resolved = 'rw'; + return this.rwClient; + } + + /** + * Marks the rw scope as unavailable and builds a read-only client. + * Subsequent `getClientForWrite()` calls will throw; reads use the + * read-only client. + */ + downgradeToReadOnly(): void { + this.resolved = 'read-only'; + this.readClient = this.opts.buildClient(this.opts.readScopes); + } +} diff --git a/packages/b2c-tooling-sdk/src/operations/jobs/backend.ts b/packages/b2c-tooling-sdk/src/operations/jobs/backend.ts index 6932196e1..75f4d4667 100644 --- a/packages/b2c-tooling-sdk/src/operations/jobs/backend.ts +++ b/packages/b2c-tooling-sdk/src/operations/jobs/backend.ts @@ -9,9 +9,10 @@ import type {JobsBackend, JobExecutionResult, JobExecutionSearchResults} from '. import type {ExecuteJobOptions, SearchJobExecutionsOptions, WaitForJobOptions, WaitForJobPollInfo} from './run.js'; import {OcapiJobsBackend} from './ocapi-backend.js'; import {ScapiJobsBackend} from './scapi-backend.js'; -import {getLogger} from '../../logging/logger.js'; +import {ScapiFallbackBackend} from '../../clients/scapi-fallback-backend.js'; +import {resolveScapiOrOcapi, type ApiBackendPreference} from '../../clients/scapi-backend-utils.js'; -export type ApiBackendPreference = 'ocapi' | 'scapi' | 'auto'; +export type {ApiBackendPreference}; export interface JobsBackendConfig { preference: ApiBackendPreference; @@ -22,7 +23,12 @@ export interface JobsBackendConfig { } export function createJobsBackend(config: JobsBackendConfig): JobsBackend { - const resolved = resolveBackend(config); + const hasScapiConfig = Boolean(config.shortCode && config.tenantId && config.auth); + const resolved = resolveScapiOrOcapi({ + preference: config.preference, + hasScapiConfig, + domainName: 'Jobs', + }); if (resolved === 'ocapi') { return new OcapiJobsBackend(config.instance); @@ -44,35 +50,9 @@ export function createJobsBackend(config: JobsBackendConfig): JobsBackend { return new FallbackJobsBackend(scapiBackend, ocapiBackend); } -function resolveBackend(config: JobsBackendConfig): 'ocapi' | 'scapi' { - if (config.preference === 'ocapi') return 'ocapi'; - if (config.preference === 'scapi') { - if (!config.shortCode || !config.tenantId) { - throw new Error('SCAPI backend requires shortCode and tenantId configuration.'); - } - if (!config.auth) { - throw new Error('SCAPI backend requires OAuth credentials.'); - } - return 'scapi'; - } - - // Auto: prefer SCAPI when config available - if (config.shortCode && config.tenantId && config.auth) { - return 'scapi'; - } - return 'ocapi'; -} - -export class FallbackJobsBackend implements JobsBackend { - private resolvedBackend?: JobsBackend; - - constructor( - private scapiBackend: ScapiJobsBackend, - private ocapiBackend: OcapiJobsBackend, - ) {} - - get name(): 'ocapi' | 'scapi' { - return (this.resolvedBackend?.name ?? 'scapi') as 'ocapi' | 'scapi'; +export class FallbackJobsBackend extends ScapiFallbackBackend implements JobsBackend { + constructor(scapiBackend: ScapiJobsBackend, ocapiBackend: OcapiJobsBackend) { + super(scapiBackend, ocapiBackend, 'jobs'); } async executeJob(jobId: string, options?: ExecuteJobOptions): Promise { @@ -94,30 +74,6 @@ export class FallbackJobsBackend implements JobsBackend { async getJobLog(execution: JobExecutionResult): Promise { return this.withFallback((backend) => backend.getJobLog(execution)); } - - private async withFallback(fn: (backend: JobsBackend) => Promise): Promise { - if (this.resolvedBackend) { - return fn(this.resolvedBackend); - } - - try { - const result = await fn(this.scapiBackend); - this.resolvedBackend = this.scapiBackend; - return result; - } catch (error) { - if (isInvalidScopeError(error)) { - const logger = getLogger(); - logger.info('SCAPI jobs scope unavailable, falling back to OCAPI'); - this.resolvedBackend = this.ocapiBackend; - return fn(this.ocapiBackend); - } - throw error; - } - } -} - -function isInvalidScopeError(error: unknown): boolean { - return error instanceof Error && error.message.includes('invalid_scope'); } export async function waitForJobExecution( diff --git a/packages/b2c-tooling-sdk/src/operations/jobs/scapi-backend.ts b/packages/b2c-tooling-sdk/src/operations/jobs/scapi-backend.ts index 6af2f6c39..4a5df3e06 100644 --- a/packages/b2c-tooling-sdk/src/operations/jobs/scapi-backend.ts +++ b/packages/b2c-tooling-sdk/src/operations/jobs/scapi-backend.ts @@ -17,6 +17,7 @@ import { type JobStepExecution as ScapiJobStepExecution, } from '../../clients/scapi-jobs.js'; import {buildTenantScope, toOrganizationId} from '../../clients/custom-apis.js'; +import {ScopeTierManager} from '../../clients/scapi-scope-tier.js'; import {getLogger} from '../../logging/logger.js'; function mapStepExecution(step: ScapiJobStepExecution): JobStepExecutionResult { @@ -68,17 +69,21 @@ export interface ScapiJobsBackendConfig { export class ScapiJobsBackend implements JobsBackend { readonly name = 'scapi' as const; - private resolvedScopeTier?: 'rw' | 'read-only'; - private rwClient?: ScapiJobsClient; - private readClient?: ScapiJobsClient; private organizationId: string; + private scopeTier: ScopeTierManager; constructor(private config: ScapiJobsBackendConfig) { this.organizationId = toOrganizationId(config.tenantId); + this.scopeTier = new ScopeTierManager({ + buildClient: (scopes) => this.buildClient(scopes), + rwScopes: SCAPI_JOBS_RW_SCOPES, + readScopes: SCAPI_JOBS_READ_SCOPES, + domainName: 'Jobs', + }); } async executeJob(jobId: string, options?: ExecuteJobOptions): Promise { - const client = await this.getClientForWrite(); + const client = this.scopeTier.getClientForWrite(); const {parameters = [], body: rawBody} = options ?? {}; let requestBody: Record | undefined; @@ -119,7 +124,7 @@ export class ScapiJobsBackend implements JobsBackend { } async getJobExecution(jobId: string, executionId: string): Promise { - const client = await this.getClientForRead(); + const client = this.scopeTier.getClientForRead(); const {data, error} = await client.GET('/organizations/{organizationId}/jobs/{jobId}/executions/{executionId}', { params: {path: {organizationId: this.organizationId, jobId, executionId}}, @@ -135,7 +140,7 @@ export class ScapiJobsBackend implements JobsBackend { } async searchJobExecutions(options?: SearchJobExecutionsOptions): Promise { - const client = await this.getClientForRead(); + const client = this.scopeTier.getClientForRead(); const {jobId, status, count = 25, start = 0, sortBy = 'start_time', sortOrder = 'desc'} = options ?? {}; const queries: unknown[] = []; @@ -182,7 +187,7 @@ export class ScapiJobsBackend implements JobsBackend { } async deleteJobExecution(jobId: string, executionId: string): Promise { - const client = await this.getClientForWrite(); + const client = this.scopeTier.getClientForWrite(); const {error} = await client.DELETE('/organizations/{organizationId}/jobs/{jobId}/executions/{executionId}', { params: {path: {organizationId: this.organizationId, jobId, executionId}}, @@ -207,46 +212,6 @@ export class ScapiJobsBackend implements JobsBackend { return new TextDecoder().decode(content); } - private async getClientForWrite(): Promise { - if (this.resolvedScopeTier === 'rw' && this.rwClient) { - return this.rwClient; - } - if (this.resolvedScopeTier === 'read-only') { - throw new Error( - 'SCAPI Jobs API requires the "sfcc.jobs.rw" scope to execute or delete jobs. ' + - 'Add this scope to your API client in Account Manager.', - ); - } - if (!this.rwClient) { - this.rwClient = this.buildClient(SCAPI_JOBS_RW_SCOPES); - } - this.resolvedScopeTier = 'rw'; - return this.rwClient; - } - - private async getClientForRead(): Promise { - if (this.resolvedScopeTier && this.rwClient) { - return this.rwClient; - } - if (this.resolvedScopeTier === 'read-only' && this.readClient) { - return this.readClient; - } - if (!this.rwClient) { - this.rwClient = this.buildClient(SCAPI_JOBS_RW_SCOPES); - } - this.resolvedScopeTier = 'rw'; - return this.rwClient; - } - - /** - * Called when we detect an invalid_scope error on the rw client for a read operation. - * Downgrades to read-only scope. - */ - downgradeToReadOnly(): void { - this.resolvedScopeTier = 'read-only'; - this.readClient = this.buildClient(SCAPI_JOBS_READ_SCOPES); - } - private buildClient(scopes: string[]): ScapiJobsClient { const clientConfig: ScapiJobsClientConfig = { shortCode: this.config.shortCode, From de537a7b0189370f82fd2944f2ef266472dbd5f2 Mon Sep 17 00:00:00 2001 From: Charles Lavery Date: Fri, 8 May 2026 13:35:56 -0400 Subject: [PATCH 03/22] Add SCAPI Scripts (code versions) support with backend abstraction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migrates code list/activate/delete commands to use the dual-backend pattern. New CodeCommand base class exposes createScriptsBackend(), which selects between OCAPI and SCAPI based on --api-backend. - New SCAPI Scripts client (dx/scripts/v1) reusing the shared ScopeTierManager and ScapiFallbackBackend utilities - ScriptsBackend interface with canonical CodeVersionInfo shape (camelCase, _raw escape hatch) - reloadCodeVersion remains OCAPI-only — SCAPI backend throws, auto mode falls back to OCAPI on the first reload call --- .../b2c-cli/src/commands/code/activate.ts | 37 +- packages/b2c-cli/src/commands/code/delete.ts | 15 +- packages/b2c-cli/src/commands/code/list.ts | 26 +- .../test/commands/code/activate.test.ts | 100 ++--- .../b2c-cli/test/commands/code/delete.test.ts | 59 +-- .../b2c-cli/test/commands/code/list.test.ts | 42 +- packages/b2c-tooling-sdk/package.json | 2 +- .../b2c-tooling-sdk/specs/dx-scripts-v1.yaml | 409 ++++++++++++++++++ .../b2c-tooling-sdk/src/cli/code-command.ts | 32 ++ packages/b2c-tooling-sdk/src/cli/index.ts | 1 + packages/b2c-tooling-sdk/src/clients/index.ts | 11 + .../src/clients/middleware-registry.ts | 3 +- .../src/clients/scapi-scripts.generated.ts | 393 +++++++++++++++++ .../src/clients/scapi-scripts.ts | 52 +++ packages/b2c-tooling-sdk/src/index.ts | 14 + .../src/operations/code/index.ts | 8 + .../operations/code/ocapi-scripts-backend.ts | 63 +++ .../operations/code/scapi-scripts-backend.ts | 126 ++++++ .../src/operations/code/scripts-backend.ts | 77 ++++ .../src/operations/code/scripts-types.ts | 54 +++ 20 files changed, 1368 insertions(+), 156 deletions(-) create mode 100644 packages/b2c-tooling-sdk/specs/dx-scripts-v1.yaml create mode 100644 packages/b2c-tooling-sdk/src/cli/code-command.ts create mode 100644 packages/b2c-tooling-sdk/src/clients/scapi-scripts.generated.ts create mode 100644 packages/b2c-tooling-sdk/src/clients/scapi-scripts.ts create mode 100644 packages/b2c-tooling-sdk/src/operations/code/ocapi-scripts-backend.ts create mode 100644 packages/b2c-tooling-sdk/src/operations/code/scapi-scripts-backend.ts create mode 100644 packages/b2c-tooling-sdk/src/operations/code/scripts-backend.ts create mode 100644 packages/b2c-tooling-sdk/src/operations/code/scripts-types.ts diff --git a/packages/b2c-cli/src/commands/code/activate.ts b/packages/b2c-cli/src/commands/code/activate.ts index 868db0639..f126f2b05 100644 --- a/packages/b2c-cli/src/commands/code/activate.ts +++ b/packages/b2c-cli/src/commands/code/activate.ts @@ -4,11 +4,10 @@ * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 */ import {Args, Flags} from '@oclif/core'; -import {InstanceCommand} from '@salesforce/b2c-tooling-sdk/cli'; -import {activateCodeVersion, reloadCodeVersion} from '@salesforce/b2c-tooling-sdk/operations/code'; +import {CodeCommand} from '@salesforce/b2c-tooling-sdk/cli'; import {t, withDocs} from '../../i18n/index.js'; -export default class CodeActivate extends InstanceCommand { +export default class CodeActivate extends CodeCommand { static args = { codeVersion: Args.string({ description: 'Code version ID to activate', @@ -29,10 +28,10 @@ export default class CodeActivate extends InstanceCommand { ]; static flags = { - ...InstanceCommand.baseFlags, + ...CodeCommand.baseFlags, reload: Flags.boolean({ char: 'r', - description: 'Reload the code version (toggle activation to force reload)', + description: 'Reload the code version (OCAPI only — forces a code cache reload via toggle)', default: false, }), }; @@ -45,11 +44,21 @@ export default class CodeActivate extends InstanceCommand { const codeVersionArg = this.args.codeVersion; const hostname = this.resolvedConfig.values.hostname!; - // Get code version from arg, flag, or config const codeVersion = codeVersionArg ?? this.resolvedConfig.values.codeVersion; + if (!this.flags.reload && !codeVersion) { + this.error( + t( + 'commands.code.activate.versionRequired', + 'Code version is required. Provide as argument or use --code-version flag.', + ), + ); + } + + const backend = this.createScriptsBackend(); + this.logger.debug(`Using ${backend.name} backend for code activate`); + if (this.flags.reload) { - // Reload mode - re-activate the code version this.log( t('commands.code.activate.reloading', 'Reloading code version{{version}} on {{hostname}}...', { hostname, @@ -58,7 +67,7 @@ export default class CodeActivate extends InstanceCommand { ); try { - await reloadCodeVersion(this.instance, codeVersion); + await backend.reloadCodeVersion(codeVersion); this.log( t('commands.code.activate.reloaded', 'Code version{{version}} reloaded successfully', { version: codeVersion ? ` ${codeVersion}` : '', @@ -75,16 +84,6 @@ export default class CodeActivate extends InstanceCommand { throw error; } } else { - // Activate mode - just activate the code version - if (!codeVersion) { - this.error( - t( - 'commands.code.activate.versionRequired', - 'Code version is required. Provide as argument or use --code-version flag.', - ), - ); - } - this.log( t('commands.code.activate.activating', 'Activating code version {{codeVersion}} on {{hostname}}...', { hostname, @@ -93,7 +92,7 @@ export default class CodeActivate extends InstanceCommand { ); try { - await activateCodeVersion(this.instance, codeVersion); + await backend.activateCodeVersion(codeVersion); this.log( t('commands.code.activate.activated', 'Code version {{codeVersion}} activated successfully', {codeVersion}), ); diff --git a/packages/b2c-cli/src/commands/code/delete.ts b/packages/b2c-cli/src/commands/code/delete.ts index aa5310ce6..ee61a563b 100644 --- a/packages/b2c-cli/src/commands/code/delete.ts +++ b/packages/b2c-cli/src/commands/code/delete.ts @@ -4,12 +4,11 @@ * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 */ import {Args, Flags} from '@oclif/core'; -import {InstanceCommand} from '@salesforce/b2c-tooling-sdk/cli'; -import {deleteCodeVersion} from '@salesforce/b2c-tooling-sdk/operations/code'; +import {CodeCommand} from '@salesforce/b2c-tooling-sdk/cli'; import {t, withDocs} from '../../i18n/index.js'; import {confirm} from '../../prompts.js'; -export default class CodeDelete extends InstanceCommand { +export default class CodeDelete extends CodeCommand { static args = { codeVersion: Args.string({ description: 'Code version ID to delete', @@ -29,7 +28,7 @@ export default class CodeDelete extends InstanceCommand { ]; static flags = { - ...InstanceCommand.baseFlags, + ...CodeCommand.baseFlags, force: Flags.boolean({ char: 'f', description: 'Skip confirmation prompt', @@ -41,11 +40,9 @@ export default class CodeDelete extends InstanceCommand { protected operations = { confirm, - deleteCodeVersion, }; async run(): Promise { - // Prevent deletion in safe mode this.assertDestructiveOperationAllowed('delete code version'); this.requireOAuthCredentials(); @@ -53,7 +50,6 @@ export default class CodeDelete extends InstanceCommand { const codeVersion = this.args.codeVersion; const hostname = this.resolvedConfig.values.hostname!; - // Confirm deletion unless --force is used if (!this.flags.force) { const confirmed = await this.operations.confirm( t( @@ -69,6 +65,9 @@ export default class CodeDelete extends InstanceCommand { } } + const backend = this.createScriptsBackend(); + this.logger.debug(`Using ${backend.name} backend for code delete`); + this.log( t('commands.code.delete.deleting', 'Deleting code version {{codeVersion}} from {{hostname}}...', { hostname, @@ -76,7 +75,7 @@ export default class CodeDelete extends InstanceCommand { }), ); - await this.operations.deleteCodeVersion(this.instance, codeVersion); + await backend.deleteCodeVersion(codeVersion); this.log(t('commands.code.delete.deleted', 'Code version {{codeVersion}} deleted successfully', {codeVersion})); } } diff --git a/packages/b2c-cli/src/commands/code/list.ts b/packages/b2c-cli/src/commands/code/list.ts index 134705c1c..dc947cfa6 100644 --- a/packages/b2c-cli/src/commands/code/list.ts +++ b/packages/b2c-cli/src/commands/code/list.ts @@ -5,16 +5,16 @@ */ import {ux} from '@oclif/core'; import { - InstanceCommand, + CodeCommand, TableRenderer, columnFlagsFor, selectColumns, type ColumnDef, } from '@salesforce/b2c-tooling-sdk/cli'; -import {listCodeVersions, type CodeVersion, type CodeVersionResult} from '@salesforce/b2c-tooling-sdk/operations/code'; +import {type CodeVersionInfo} from '@salesforce/b2c-tooling-sdk/operations/code'; import {t, withDocs} from '../../i18n/index.js'; -const COLUMNS: Record> = { +const COLUMNS: Record> = { id: { header: 'ID', get: (v) => v.id || '-', @@ -29,7 +29,7 @@ const COLUMNS: Record> = { }, lastModified: { header: 'Last Modified', - get: (v) => (v.last_modification_time ? new Date(v.last_modification_time).toLocaleString() : '-'), + get: (v) => (v.lastModificationTime ? new Date(v.lastModificationTime).toLocaleString() : '-'), }, cartridges: { header: 'Cartridges', @@ -41,7 +41,13 @@ const DEFAULT_COLUMNS = ['id', 'active', 'rollback', 'lastModified', 'cartridges const tableRenderer = new TableRenderer(COLUMNS); -export default class CodeList extends InstanceCommand { +interface CodeListResult { + count: number; + data: CodeVersionInfo[]; + total: number; +} + +export default class CodeList extends CodeCommand { static description = withDocs( t('commands.code.list.description', 'List code versions on a B2C Commerce instance'), '/cli/code.html#b2c-code-list', @@ -63,27 +69,27 @@ export default class CodeList extends InstanceCommand { static hiddenAliases = ['code:list']; - async run(): Promise { + async run(): Promise { this.requireOAuthCredentials(); const hostname = this.resolvedConfig.values.hostname!; + const backend = this.createScriptsBackend(); + this.logger.debug(`Using ${backend.name} backend for code list`); this.log(t('commands.code.list.fetching', 'Fetching code versions from {{hostname}}...', {hostname})); - const versions = await listCodeVersions(this.instance); + const versions = await backend.listCodeVersions(); - const result: CodeVersionResult = { + const result: CodeListResult = { count: versions.length, data: versions, total: versions.length, }; - // In JSON mode, just return the data - oclif handles output to stdout if (this.jsonEnabled()) { return result; } - // Human-readable table output to stdout if (versions.length === 0) { ux.stdout(t('commands.code.list.noVersions', 'No code versions found.')); return result; diff --git a/packages/b2c-cli/test/commands/code/activate.test.ts b/packages/b2c-cli/test/commands/code/activate.test.ts index f6d5f691d..82f65d543 100644 --- a/packages/b2c-cli/test/commands/code/activate.test.ts +++ b/packages/b2c-cli/test/commands/code/activate.test.ts @@ -21,34 +21,41 @@ describe('code activate', () => { return createTestCommand(CodeActivate, hooks.getConfig(), flags, args); } - it('activates when --reload is not set', async () => { - const command: any = await createCommand({}, {codeVersion: 'v1'}); + function createMockBackend() { + return { + name: 'ocapi' as const, + listCodeVersions: sinon.stub(), + getActiveCodeVersion: sinon.stub(), + activateCodeVersion: sinon.stub(), + deleteCodeVersion: sinon.stub(), + createCodeVersion: sinon.stub(), + reloadCodeVersion: sinon.stub(), + }; + } + function stubCommon(command: any) { sinon.stub(command, 'requireOAuthCredentials').returns(void 0); sinon.stub(command, 'log').returns(void 0); - sinon.stub(command, 'resolvedConfig').get(() => ({values: {hostname: 'example.com', codeVersion: undefined}})); + sinon.stub(command, 'instance').get(() => ({config: {hostname: 'example.com'}})); + const backend = createMockBackend(); + sinon.stub(command, 'createScriptsBackend').returns(backend); + return backend; + } - const patchStub = sinon.stub().resolves({data: {}, error: undefined}); - sinon.stub(command, 'instance').get(() => ({ - ocapi: { - PATCH: patchStub, - GET: sinon.stub().rejects(new Error('Unexpected ocapi.GET')), - }, - })); + it('activates when --reload is not set', async () => { + const command: any = await createCommand({}, {codeVersion: 'v1'}); + const backend = stubCommon(command); + backend.activateCodeVersion.resolves(); await command.run(); - expect(patchStub.calledOnce).to.be.true; - const [path, options] = patchStub.firstCall.args; - expect(path).to.equal('/code_versions/{code_version_id}'); - expect(options?.params?.path).to.deep.equal({code_version_id: 'v1'}); - expect(options?.body).to.deep.equal({active: true}); + expect(backend.activateCodeVersion.calledOnce).to.be.true; + expect(backend.activateCodeVersion.firstCall.args[0]).to.equal('v1'); }); it('errors when no code version is provided for activate mode', async () => { const command: any = await createCommand({}, {}); - sinon.stub(command, 'requireOAuthCredentials').returns(void 0); sinon.stub(command, 'resolvedConfig').get(() => ({values: {hostname: 'example.com', codeVersion: undefined}})); @@ -62,67 +69,19 @@ describe('code activate', () => { it('reloads the active code version when --reload is set and no arg is provided', async () => { const command: any = await createCommand({reload: true}, {}); - - sinon.stub(command, 'requireOAuthCredentials').returns(void 0); - sinon.stub(command, 'log').returns(void 0); - - sinon.stub(command, 'resolvedConfig').get(() => ({values: {hostname: 'example.com', codeVersion: undefined}})); - - const getStub = sinon.stub().resolves({ - data: { - data: [ - {id: 'v1', active: true}, - {id: 'v2', active: false}, - ], - }, - error: undefined, - }); - - const patchStub = sinon.stub().resolves({data: {}, error: undefined}); - - sinon.stub(command, 'instance').get(() => ({ - ocapi: { - GET: getStub, - PATCH: patchStub, - }, - })); + const backend = stubCommon(command); + backend.reloadCodeVersion.resolves(); await command.run(); - expect(getStub.calledOnce).to.be.true; - expect(patchStub.callCount).to.equal(2); - // Reload toggles to alternate then back to active. - const calledIds = patchStub.getCalls().map((c) => c.args[1]?.params?.path?.code_version_id); - expect(calledIds).to.deep.equal(['v2', 'v1']); + expect(backend.reloadCodeVersion.calledOnce).to.be.true; + expect(backend.reloadCodeVersion.firstCall.args[0]).to.equal(undefined); }); it('calls command.error when reload fails with an error message', async () => { const command: any = await createCommand({reload: true}, {codeVersion: 'v1'}); - - sinon.stub(command, 'requireOAuthCredentials').returns(void 0); - sinon.stub(command, 'log').returns(void 0); - - sinon.stub(command, 'resolvedConfig').get(() => ({values: {hostname: 'example.com', codeVersion: undefined}})); - - // Reload toggles active → alternate → active, so we need at least two versions. - const getStub = sinon.stub().resolves({ - data: { - data: [ - {id: 'v1', active: true}, - {id: 'v2', active: false}, - ], - }, - error: undefined, - }); - - const patchStub = sinon.stub().resolves({data: {}, error: {message: 'boom'}}); - - sinon.stub(command, 'instance').get(() => ({ - ocapi: { - GET: getStub, - PATCH: patchStub, - }, - })); + const backend = stubCommon(command); + backend.reloadCodeVersion.rejects(new Error('boom')); const errorStub = sinon.stub(command, 'error').throws(new Error('Expected error')); @@ -130,6 +89,5 @@ describe('code activate', () => { expect(errorStub.calledOnce).to.be.true; expect(errorStub.firstCall.args[0]).to.include('Failed to reload code version'); - expect(patchStub.called).to.be.true; }); }); diff --git a/packages/b2c-cli/test/commands/code/delete.test.ts b/packages/b2c-cli/test/commands/code/delete.test.ts index 3e4d3aabb..74b2dbdf6 100644 --- a/packages/b2c-cli/test/commands/code/delete.test.ts +++ b/packages/b2c-cli/test/commands/code/delete.test.ts @@ -21,60 +21,63 @@ describe('code delete', () => { return createTestCommand(CodeDelete, hooks.getConfig(), flags, args); } - it('deletes without prompting when --force is set', async () => { - const command: any = await createCommand({force: true}, {codeVersion: 'v1'}); - - const instance = {config: {hostname: 'example.com'}}; + function createMockBackend() { + return { + name: 'ocapi' as const, + listCodeVersions: sinon.stub(), + getActiveCodeVersion: sinon.stub(), + activateCodeVersion: sinon.stub(), + deleteCodeVersion: sinon.stub(), + createCodeVersion: sinon.stub(), + reloadCodeVersion: sinon.stub(), + }; + } + function stubCommon(command: any) { sinon.stub(command, 'requireOAuthCredentials').returns(void 0); sinon.stub(command, 'resolvedConfig').get(() => ({values: {hostname: 'example.com'}})); - sinon.stub(command, 'instance').get(() => instance); + sinon.stub(command, 'instance').get(() => ({config: {hostname: 'example.com'}})); sinon.stub(command, 'log').returns(void 0); + const backend = createMockBackend(); + sinon.stub(command, 'createScriptsBackend').returns(backend); + return backend; + } - const deleteStub = sinon.stub().resolves(void 0); - command.operations = {...command.operations, deleteCodeVersion: deleteStub}; + it('deletes without prompting when --force is set', async () => { + const command: any = await createCommand({force: true}, {codeVersion: 'v1'}); + const backend = stubCommon(command); + backend.deleteCodeVersion.resolves(); await command.run(); - expect(deleteStub.calledOnceWithExactly(instance, 'v1')).to.equal(true); + + expect(backend.deleteCodeVersion.calledOnceWithExactly('v1')).to.equal(true); }); it('does not delete when prompt is declined', async () => { const command: any = await createCommand({}, {codeVersion: 'v1'}); + const backend = stubCommon(command); + backend.deleteCodeVersion.rejects(new Error('Unexpected delete')); - const instance = {config: {hostname: 'example.com'}}; - - sinon.stub(command, 'requireOAuthCredentials').returns(void 0); - sinon.stub(command, 'resolvedConfig').get(() => ({values: {hostname: 'example.com'}})); - sinon.stub(command, 'instance').get(() => instance); - sinon.stub(command, 'log').returns(void 0); - - const deleteStub = sinon.stub().rejects(new Error('Unexpected delete')); const confirmStub = sinon.stub().resolves(false); - command.operations = {...command.operations, confirm: confirmStub, deleteCodeVersion: deleteStub}; + command.operations = {...command.operations, confirm: confirmStub}; await command.run(); expect(confirmStub.calledOnce).to.equal(true); - expect(deleteStub.called).to.equal(false); + expect(backend.deleteCodeVersion.called).to.equal(false); }); it('deletes when prompt is accepted', async () => { const command: any = await createCommand({}, {codeVersion: 'v1'}); + const backend = stubCommon(command); + backend.deleteCodeVersion.resolves(); - const instance = {config: {hostname: 'example.com'}}; - - sinon.stub(command, 'requireOAuthCredentials').returns(void 0); - sinon.stub(command, 'resolvedConfig').get(() => ({values: {hostname: 'example.com'}})); - sinon.stub(command, 'instance').get(() => instance); - sinon.stub(command, 'log').returns(void 0); - - const deleteStub = sinon.stub().resolves(void 0); const confirmStub = sinon.stub().resolves(true); - command.operations = {...command.operations, confirm: confirmStub, deleteCodeVersion: deleteStub}; + command.operations = {...command.operations, confirm: confirmStub}; await command.run(); expect(confirmStub.calledOnce).to.equal(true); - expect(deleteStub.calledOnceWithExactly(instance, 'v1')).to.equal(true); + expect(backend.deleteCodeVersion.calledOnceWithExactly('v1')).to.equal(true); }); }); diff --git a/packages/b2c-cli/test/commands/code/list.test.ts b/packages/b2c-cli/test/commands/code/list.test.ts index 2fa4c8c26..434eedd55 100644 --- a/packages/b2c-cli/test/commands/code/list.test.ts +++ b/packages/b2c-cli/test/commands/code/list.test.ts @@ -22,20 +22,34 @@ describe('code list', () => { return createTestCommand(CodeList, hooks.getConfig(), flags, {}); } - it('returns data in json mode', async () => { - const command: any = await createCommand({json: true}); + function createMockBackend() { + return { + name: 'ocapi' as const, + listCodeVersions: sinon.stub(), + getActiveCodeVersion: sinon.stub(), + activateCodeVersion: sinon.stub(), + deleteCodeVersion: sinon.stub(), + createCodeVersion: sinon.stub(), + reloadCodeVersion: sinon.stub(), + }; + } + function stubCommon(command: any) { sinon.stub(command, 'requireOAuthCredentials').returns(void 0); sinon.stub(command, 'log').returns(void 0); sinon.stub(command, 'resolvedConfig').get(() => ({values: {hostname: 'example.com'}})); + sinon.stub(command, 'instance').get(() => ({config: {hostname: 'example.com'}})); + const backend = createMockBackend(); + sinon.stub(command, 'createScriptsBackend').returns(backend); + return backend; + } + + it('returns data in json mode', async () => { + const command: any = await createCommand({json: true}); + const backend = stubCommon(command); sinon.stub(command, 'jsonEnabled').returns(true); - const getStub = sinon.stub().resolves({data: {data: [{id: 'v1', active: true}]}, error: undefined}); - sinon.stub(command, 'instance').get(() => ({ - ocapi: { - GET: getStub, - }, - })); + backend.listCodeVersions.resolves([{id: 'v1', active: true}]); const uxStub = sinon.stub(ux, 'stdout'); @@ -47,18 +61,10 @@ describe('code list', () => { it('prints a message when no code versions are returned in non-json mode', async () => { const command: any = await createCommand({}); - - sinon.stub(command, 'requireOAuthCredentials').returns(void 0); - sinon.stub(command, 'log').returns(void 0); - sinon.stub(command, 'resolvedConfig').get(() => ({values: {hostname: 'example.com'}})); + const backend = stubCommon(command); sinon.stub(command, 'jsonEnabled').returns(false); - const getStub = sinon.stub().resolves({data: {data: []}, error: undefined}); - sinon.stub(command, 'instance').get(() => ({ - ocapi: { - GET: getStub, - }, - })); + backend.listCodeVersions.resolves([]); const uxStub = sinon.stub(ux, 'stdout'); diff --git a/packages/b2c-tooling-sdk/package.json b/packages/b2c-tooling-sdk/package.json index 610131a23..e10162158 100644 --- a/packages/b2c-tooling-sdk/package.json +++ b/packages/b2c-tooling-sdk/package.json @@ -419,7 +419,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 && openapi-typescript specs/granular-replications-v1.yaml -o src/clients/granular-replications.generated.ts && openapi-typescript specs/operations-jobs-v1.yaml -o src/clients/scapi-jobs.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/granular-replications-v1.yaml -o src/clients/granular-replications.generated.ts && openapi-typescript specs/operations-jobs-v1.yaml -o src/clients/scapi-jobs.generated.ts && openapi-typescript specs/dx-scripts-v1.yaml -o src/clients/scapi-scripts.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/dx-scripts-v1.yaml b/packages/b2c-tooling-sdk/specs/dx-scripts-v1.yaml new file mode 100644 index 000000000..5f10b212b --- /dev/null +++ b/packages/b2c-tooling-sdk/specs/dx-scripts-v1.yaml @@ -0,0 +1,409 @@ +openapi: 3.0.3 +info: + title: Scripts + version: 1.0.0 + x-api-type: Admin + x-api-family: DX +servers: + - url: "https://{shortCode}.api.commercecloud.salesforce.com/dx/scripts/v1" + variables: + shortCode: + default: shortCode +paths: + /organizations/{organizationId}/code-versions: + get: + operationId: getCodeVersions + parameters: + - name: organizationId + in: path + required: true + style: simple + explode: false + schema: + $ref: "#/components/schemas/OrganizationId" + - name: expand + in: query + required: false + style: form + explode: false + schema: + type: array + items: + type: string + enum: [size] + responses: + 200: + description: List of code versions successfully retrieved. + content: + application/json: + schema: + $ref: "#/components/schemas/CodeVersionResult" + 401: + description: Your access token is invalid or expired and can’t be used to identify a user. + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ErrorResponse" + 403: + description: Forbidden. Your access token is valid, but you don’t have the required permissions to access the resource. + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ErrorResponse" + security: + - AmOAuth2: [sfcc.scripts, sfcc.scripts.rw] + /organizations/{organizationId}/code-versions/{codeVersionId}: + get: + operationId: getCodeVersion + parameters: + - name: organizationId + in: path + required: true + style: simple + explode: false + schema: + $ref: "#/components/schemas/OrganizationId" + - name: codeVersionId + in: path + required: true + style: simple + explode: false + schema: + type: string + maxLength: 256 + minLength: 1 + - name: expand + in: query + required: false + style: form + explode: false + schema: + type: array + items: + type: string + enum: [size] + responses: + 200: + description: Code version successfully retrieved. + content: + application/json: + schema: + $ref: "#/components/schemas/CodeVersion" + 401: + description: Your access token is invalid or expired and can’t be used to identify a user. + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ErrorResponse" + 403: + description: Forbidden. Your access token is valid, but you don’t have the required permissions to access the resource. + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ErrorResponse" + 404: + description: Code version not found. + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ErrorResponse" + security: + - AmOAuth2: [sfcc.scripts, sfcc.scripts.rw] + put: + operationId: createCodeVersion + parameters: + - name: organizationId + in: path + required: true + style: simple + explode: false + schema: + $ref: "#/components/schemas/OrganizationId" + - name: codeVersionId + in: path + required: true + style: simple + explode: false + schema: + type: string + maxLength: 256 + minLength: 1 + responses: + 200: + description: Code version successfully replaced. + content: + application/json: + schema: + $ref: "#/components/schemas/CodeVersion" + 201: + description: Code version successfully created. + content: + application/json: + schema: + $ref: "#/components/schemas/CodeVersion" + 401: + description: Your access token is invalid or expired and can’t be used to identify a user. + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ErrorResponse" + 403: + description: Forbidden. Your access token is valid, but you don’t have the required permissions to access the resource. + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ErrorResponse" + 409: + description: A code version with the given ID already exists. + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ErrorResponse" + security: + - AmOAuth2: [sfcc.scripts.rw] + delete: + operationId: deleteCodeVersion + parameters: + - name: organizationId + in: path + required: true + style: simple + explode: false + schema: + $ref: "#/components/schemas/OrganizationId" + - name: codeVersionId + in: path + required: true + style: simple + explode: false + schema: + type: string + maxLength: 256 + minLength: 1 + responses: + 204: + description: Code version successfully deleted. + 400: + description: The active code version cannot be deleted. + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ErrorResponse" + 401: + description: Your access token is invalid or expired and can’t be used to identify a user. + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ErrorResponse" + 403: + description: Forbidden. Your access token is valid, but you don’t have the required permissions to access the resource. + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ErrorResponse" + 404: + description: Code version not found. + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ErrorResponse" + security: + - AmOAuth2: [sfcc.scripts.rw] + patch: + operationId: updateCodeVersion + parameters: + - name: organizationId + in: path + required: true + style: simple + explode: false + schema: + $ref: "#/components/schemas/OrganizationId" + - name: codeVersionId + in: path + required: true + style: simple + explode: false + schema: + type: string + maxLength: 256 + minLength: 1 + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/CodeVersion" + required: true + responses: + 200: + description: Code version successfully updated. + content: + application/json: + schema: + $ref: "#/components/schemas/CodeVersion" + 400: + description: The active code version cannot be modified. + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ErrorResponse" + 401: + description: Your access token is invalid or expired and can’t be used to identify a user. + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ErrorResponse" + 403: + description: Forbidden. Your access token is valid, but you don’t have the required permissions to access the resource. + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ErrorResponse" + 404: + description: Code version not found. + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ErrorResponse" + 409: + description: A code version with the given ID already exists (when renaming). + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ErrorResponse" + security: + - AmOAuth2: [sfcc.scripts.rw] +components: + schemas: + OrganizationId: + type: string + maxLength: 32 + minLength: 1 + Total: + type: integer + format: int32 + default: 0 + minimum: 0 + ResultBase: + type: object + properties: + limit: + type: integer + format: int32 + total: + $ref: "#/components/schemas/Total" + required: [limit, total] + CodeVersion: + type: object + properties: + id: + type: string + maxLength: 256 + minLength: 1 + active: + type: boolean + cartridges: + type: array + items: + type: string + maxLength: 256 + compatibilityMode: + type: string + maxLength: 100 + activationTime: + type: string + format: date-time + lastModificationTime: + type: string + format: date-time + rollback: + type: boolean + totalSize: + type: integer + format: int64 + webDavUrl: + type: string + maxLength: 4000 + CodeVersionResult: + allOf: + - $ref: "#/components/schemas/ResultBase" + properties: + data: + type: array + items: + $ref: "#/components/schemas/CodeVersion" + type: string + ErrorResponse: + type: object + additionalProperties: true + properties: + title: + type: string + maxLength: 256 + type: + type: string + maxLength: 2048 + detail: + type: string + instance: + type: string + maxLength: 2048 + required: [detail, title, type] + responses: + 401unauthorized: + description: Your access token is invalid or expired and can’t be used to identify a user. + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ErrorResponse" + 403forbidden: + description: Forbidden. Your access token is valid, but you don’t have the required permissions to access the resource. + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ErrorResponse" + parameters: + organizationId: + name: organizationId + in: path + required: true + style: simple + explode: false + schema: + $ref: "#/components/schemas/OrganizationId" + expand: + name: expand + in: query + required: false + style: form + explode: false + schema: + type: array + items: + type: string + enum: [size] + codeVersionId: + name: codeVersionId + in: path + required: true + style: simple + explode: false + schema: + type: string + maxLength: 256 + minLength: 1 + securitySchemes: + AmOAuth2: + type: oauth2 + flows: + clientCredentials: + tokenUrl: "https://account.demandware.com/dwsso/oauth2/access_token" + scopes: + sfcc.scripts: Scripts API READONLY scope + sfcc.scripts.rw: Scripts API scope + authorizationCode: + authorizationUrl: "https://account.demandware.com/dwsso/oauth2/authorize" + tokenUrl: "https://account.demandware.com/dwsso/oauth2/access_token" + scopes: + sfcc.scripts: Scripts API READONLY scope + sfcc.scripts.rw: Scripts API scope diff --git a/packages/b2c-tooling-sdk/src/cli/code-command.ts b/packages/b2c-tooling-sdk/src/cli/code-command.ts new file mode 100644 index 000000000..082bbf390 --- /dev/null +++ b/packages/b2c-tooling-sdk/src/cli/code-command.ts @@ -0,0 +1,32 @@ +/* + * 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 {InstanceCommand} from './instance-command.js'; +import {createScriptsBackend, type ScriptsBackend} from '../operations/code/index.js'; + +/** + * Base command for code-version (Scripts) operations. + * + * Provides `createScriptsBackend()` which selects between OCAPI and SCAPI + * based on the `--api-backend` flag and `apiBackend` config field. In auto + * mode, prefers SCAPI when shortCode + tenantId are configured, falling + * back to OCAPI on `invalid_scope`. + */ +export abstract class CodeCommand extends InstanceCommand { + /** + * Creates a Scripts backend based on the resolved configuration. + */ + protected createScriptsBackend(): ScriptsBackend { + const preference = this.resolvedConfig.values.apiBackend ?? 'auto'; + return createScriptsBackend({ + preference, + instance: this.instance, + shortCode: this.resolvedConfig.values.shortCode, + tenantId: this.resolvedConfig.values.tenantId, + auth: this.hasOAuthCredentials() ? this.getOAuthStrategy() : undefined, + }); + } +} diff --git a/packages/b2c-tooling-sdk/src/cli/index.ts b/packages/b2c-tooling-sdk/src/cli/index.ts index 6bd989111..a732faf93 100644 --- a/packages/b2c-tooling-sdk/src/cli/index.ts +++ b/packages/b2c-tooling-sdk/src/cli/index.ts @@ -97,6 +97,7 @@ export {OAuthCommand} from './oauth-command.js'; export {InstanceCommand} from './instance-command.js'; export {CartridgeCommand} from './cartridge-command.js'; export {JobCommand} from './job-command.js'; +export {CodeCommand} from './code-command.js'; export {MrtCommand} from './mrt-command.js'; export {OdsCommand} from './ods-command.js'; export {AmCommand} from './am-command.js'; diff --git a/packages/b2c-tooling-sdk/src/clients/index.ts b/packages/b2c-tooling-sdk/src/clients/index.ts index 08b5082cc..2f41d06ae 100644 --- a/packages/b2c-tooling-sdk/src/clients/index.ts +++ b/packages/b2c-tooling-sdk/src/clients/index.ts @@ -336,6 +336,17 @@ export type { components as ScapiJobsComponents, } from './scapi-jobs.js'; +// SCAPI Scripts (code versions) +export {createScapiScriptsClient, SCAPI_SCRIPTS_READ_SCOPES, SCAPI_SCRIPTS_RW_SCOPES} from './scapi-scripts.js'; +export type { + ScapiScriptsClient, + ScapiScriptsClientConfig, + ScapiScriptsError, + ScapiScriptsResponse, + paths as ScapiScriptsPaths, + components as ScapiScriptsComponents, +} from './scapi-scripts.js'; + // SCAPI dual-backend utilities (shared across SCAPI/OCAPI domains) export {isInvalidScopeError, resolveScapiOrOcapi} from './scapi-backend-utils.js'; export type {ApiBackendPreference, BackendBase, ResolveBackendOptions} from './scapi-backend-utils.js'; diff --git a/packages/b2c-tooling-sdk/src/clients/middleware-registry.ts b/packages/b2c-tooling-sdk/src/clients/middleware-registry.ts index adc83652e..33313678c 100644 --- a/packages/b2c-tooling-sdk/src/clients/middleware-registry.ts +++ b/packages/b2c-tooling-sdk/src/clients/middleware-registry.ts @@ -60,7 +60,8 @@ export type HttpClientType = | 'am-roles-api' | 'am-apiclients-api' | 'am-orgs-api' - | 'scapi-jobs'; + | 'scapi-jobs' + | 'scapi-scripts'; /** * Middleware interface compatible with openapi-fetch. diff --git a/packages/b2c-tooling-sdk/src/clients/scapi-scripts.generated.ts b/packages/b2c-tooling-sdk/src/clients/scapi-scripts.generated.ts new file mode 100644 index 000000000..4f5972544 --- /dev/null +++ b/packages/b2c-tooling-sdk/src/clients/scapi-scripts.generated.ts @@ -0,0 +1,393 @@ +/** + * This file was auto-generated by openapi-typescript. + * Do not make direct changes to the file. + */ + +export interface paths { + "/organizations/{organizationId}/code-versions": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getCodeVersions"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/organizations/{organizationId}/code-versions/{codeVersionId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getCodeVersion"]; + put: operations["createCodeVersion"]; + post?: never; + delete: operations["deleteCodeVersion"]; + options?: never; + head?: never; + patch: operations["updateCodeVersion"]; + trace?: never; + }; +} +export type webhooks = Record; +export interface components { + schemas: { + OrganizationId: string; + /** + * Format: int32 + * @default 0 + */ + Total: number; + ResultBase: { + /** Format: int32 */ + limit: number; + total: components["schemas"]["Total"]; + }; + CodeVersion: { + id?: string; + active?: boolean; + cartridges?: string[]; + compatibilityMode?: string; + /** Format: date-time */ + activationTime?: string; + /** Format: date-time */ + lastModificationTime?: string; + rollback?: boolean; + /** Format: int64 */ + totalSize?: number; + webDavUrl?: string; + }; + CodeVersionResult: { + data?: components["schemas"]["CodeVersion"][]; + } & components["schemas"]["ResultBase"]; + ErrorResponse: { + title: string; + type: string; + detail: string; + instance?: string; + } & { + [key: string]: unknown; + }; + }; + responses: { + /** @description Your access token is invalid or expired and can’t be used to identify a user. */ + "401unauthorized": { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Forbidden. Your access token is valid, but you don’t have the required permissions to access the resource. */ + "403forbidden": { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + parameters: { + organizationId: components["schemas"]["OrganizationId"]; + expand: "size"[]; + codeVersionId: string; + }; + requestBodies: never; + headers: never; + pathItems: never; +} +export type $defs = Record; +export interface operations { + getCodeVersions: { + parameters: { + query?: { + expand?: "size"[]; + }; + header?: never; + path: { + organizationId: components["schemas"]["OrganizationId"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description List of code versions successfully retrieved. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CodeVersionResult"]; + }; + }; + /** @description Your access token is invalid or expired and can’t be used to identify a user. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Forbidden. Your access token is valid, but you don’t have the required permissions to access the resource. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + getCodeVersion: { + parameters: { + query?: { + expand?: "size"[]; + }; + header?: never; + path: { + organizationId: components["schemas"]["OrganizationId"]; + codeVersionId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Code version successfully retrieved. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CodeVersion"]; + }; + }; + /** @description Your access token is invalid or expired and can’t be used to identify a user. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Forbidden. Your access token is valid, but you don’t have the required permissions to access the resource. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Code version not found. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + createCodeVersion: { + parameters: { + query?: never; + header?: never; + path: { + organizationId: components["schemas"]["OrganizationId"]; + codeVersionId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Code version successfully replaced. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CodeVersion"]; + }; + }; + /** @description Code version successfully created. */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CodeVersion"]; + }; + }; + /** @description Your access token is invalid or expired and can’t be used to identify a user. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Forbidden. Your access token is valid, but you don’t have the required permissions to access the resource. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description A code version with the given ID already exists. */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + deleteCodeVersion: { + parameters: { + query?: never; + header?: never; + path: { + organizationId: components["schemas"]["OrganizationId"]; + codeVersionId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Code version successfully deleted. */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description The active code version cannot be deleted. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Your access token is invalid or expired and can’t be used to identify a user. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Forbidden. Your access token is valid, but you don’t have the required permissions to access the resource. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Code version not found. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + updateCodeVersion: { + parameters: { + query?: never; + header?: never; + path: { + organizationId: components["schemas"]["OrganizationId"]; + codeVersionId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CodeVersion"]; + }; + }; + responses: { + /** @description Code version successfully updated. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CodeVersion"]; + }; + }; + /** @description The active code version cannot be modified. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Your access token is invalid or expired and can’t be used to identify a user. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Forbidden. Your access token is valid, but you don’t have the required permissions to access the resource. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Code version not found. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description A code version with the given ID already exists (when renaming). */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; +} diff --git a/packages/b2c-tooling-sdk/src/clients/scapi-scripts.ts b/packages/b2c-tooling-sdk/src/clients/scapi-scripts.ts new file mode 100644 index 000000000..ac8cd23cc --- /dev/null +++ b/packages/b2c-tooling-sdk/src/clients/scapi-scripts.ts @@ -0,0 +1,52 @@ +/* + * 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 createClient, {type Client} from 'openapi-fetch'; +import type {AuthStrategy} from '../auth/types.js'; +import type {paths, components} from './scapi-scripts.generated.js'; +import {createAuthMiddleware, createLoggingMiddleware, createRateLimitMiddleware} from './middleware.js'; +import {globalMiddlewareRegistry, type MiddlewareRegistry} from './middleware-registry.js'; +import {OAuthStrategy} from '../auth/oauth.js'; +import {buildTenantScope} from './custom-apis.js'; + +export type {paths, components}; +export type ScapiScriptsClient = Client; +export type ScapiScriptsResponse = T extends {content: {'application/json': infer R}} ? R : never; +export type ScapiScriptsError = components['schemas']['ErrorResponse']; + +export type CodeVersion = components['schemas']['CodeVersion']; + +export const SCAPI_SCRIPTS_READ_SCOPES = ['sfcc.scripts']; +export const SCAPI_SCRIPTS_RW_SCOPES = ['sfcc.scripts.rw']; + +export interface ScapiScriptsClientConfig { + shortCode: string; + tenantId: string; + /** Override scopes (default: sfcc.scripts.rw + tenant scope). */ + scopes?: string[]; + middlewareRegistry?: MiddlewareRegistry; +} + +export function createScapiScriptsClient(config: ScapiScriptsClientConfig, auth: AuthStrategy): ScapiScriptsClient { + const registry = config.middlewareRegistry ?? globalMiddlewareRegistry; + + const client = createClient({ + baseUrl: `https://${config.shortCode}.api.commercecloud.salesforce.com/dx/scripts/v1`, + }); + + const requiredScopes = config.scopes ?? [...SCAPI_SCRIPTS_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-scripts')) { + client.use(middleware); + } + + client.use(createRateLimitMiddleware({prefix: 'SCAPI-SCRIPTS'})); + client.use(createLoggingMiddleware('SCAPI-SCRIPTS')); + + return client; +} diff --git a/packages/b2c-tooling-sdk/src/index.ts b/packages/b2c-tooling-sdk/src/index.ts index d5661432a..f98a709cb 100644 --- a/packages/b2c-tooling-sdk/src/index.ts +++ b/packages/b2c-tooling-sdk/src/index.ts @@ -204,6 +204,20 @@ export type { WatchResult, } from './operations/code/index.js'; +// Scripts (code versions) backend abstraction +export { + createScriptsBackend, + FallbackScriptsBackend, + OcapiScriptsBackend, + ScapiScriptsBackend, +} from './operations/code/index.js'; +export type { + ScriptsBackend, + ScriptsBackendConfig, + CodeVersionInfo, + ScapiScriptsBackendConfig, +} from './operations/code/index.js'; + // Operations - Jobs export { executeJob, diff --git a/packages/b2c-tooling-sdk/src/operations/code/index.ts b/packages/b2c-tooling-sdk/src/operations/code/index.ts index 329a63c34..f2386d2e4 100644 --- a/packages/b2c-tooling-sdk/src/operations/code/index.ts +++ b/packages/b2c-tooling-sdk/src/operations/code/index.ts @@ -81,6 +81,14 @@ export { } from './versions.js'; export type {CodeVersion, CodeVersionResult} from './versions.js'; +// Scripts (code versions) backend abstraction — supports OCAPI + SCAPI +export {createScriptsBackend, FallbackScriptsBackend} from './scripts-backend.js'; +export type {ScriptsBackendConfig} from './scripts-backend.js'; +export {OcapiScriptsBackend} from './ocapi-scripts-backend.js'; +export {ScapiScriptsBackend} from './scapi-scripts-backend.js'; +export type {ScapiScriptsBackendConfig} from './scapi-scripts-backend.js'; +export type {ScriptsBackend, CodeVersionInfo} from './scripts-types.js'; + // Deployment export {findAndDeployCartridges, uploadCartridges, deleteCartridges} from './deploy.js'; export type {DeployOptions, DeployResult, UploadOptions, UploadProgressInfo} from './deploy.js'; diff --git a/packages/b2c-tooling-sdk/src/operations/code/ocapi-scripts-backend.ts b/packages/b2c-tooling-sdk/src/operations/code/ocapi-scripts-backend.ts new file mode 100644 index 000000000..397d2513a --- /dev/null +++ b/packages/b2c-tooling-sdk/src/operations/code/ocapi-scripts-backend.ts @@ -0,0 +1,63 @@ +/* + * 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 type {B2CInstance} from '../../instance/index.js'; +import type {ScriptsBackend, CodeVersionInfo} from './scripts-types.js'; +import type {CodeVersion as OcapiCodeVersion} from './versions.js'; +import { + listCodeVersions as ocapiListCodeVersions, + getActiveCodeVersion as ocapiGetActiveCodeVersion, + activateCodeVersion as ocapiActivateCodeVersion, + deleteCodeVersion as ocapiDeleteCodeVersion, + createCodeVersion as ocapiCreateCodeVersion, + reloadCodeVersion as ocapiReloadCodeVersion, +} from './versions.js'; + +function mapOcapiCodeVersion(ocapi: OcapiCodeVersion): CodeVersionInfo { + return { + id: ocapi.id ?? '', + active: ocapi.active, + cartridges: ocapi.cartridges, + compatibilityMode: ocapi.compatibility_mode, + activationTime: ocapi.activation_time, + lastModificationTime: ocapi.last_modification_time, + rollback: ocapi.rollback, + totalSize: ocapi.total_size, + webDavUrl: ocapi.web_dav_url, + _raw: ocapi, + }; +} + +export class OcapiScriptsBackend implements ScriptsBackend { + readonly name = 'ocapi' as const; + + constructor(private instance: B2CInstance) {} + + async listCodeVersions(): Promise { + const versions = await ocapiListCodeVersions(this.instance); + return versions.map(mapOcapiCodeVersion); + } + + async getActiveCodeVersion(): Promise { + const active = await ocapiGetActiveCodeVersion(this.instance); + return active ? mapOcapiCodeVersion(active) : undefined; + } + + async activateCodeVersion(codeVersionId: string): Promise { + await ocapiActivateCodeVersion(this.instance, codeVersionId); + } + + async deleteCodeVersion(codeVersionId: string): Promise { + await ocapiDeleteCodeVersion(this.instance, codeVersionId); + } + + async createCodeVersion(codeVersionId: string): Promise { + await ocapiCreateCodeVersion(this.instance, codeVersionId); + } + + async reloadCodeVersion(codeVersionId?: string): Promise { + await ocapiReloadCodeVersion(this.instance, codeVersionId); + } +} diff --git a/packages/b2c-tooling-sdk/src/operations/code/scapi-scripts-backend.ts b/packages/b2c-tooling-sdk/src/operations/code/scapi-scripts-backend.ts new file mode 100644 index 000000000..5cb4e7609 --- /dev/null +++ b/packages/b2c-tooling-sdk/src/operations/code/scapi-scripts-backend.ts @@ -0,0 +1,126 @@ +/* + * 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 type {AuthStrategy} from '../../auth/types.js'; +import type {ScriptsBackend, CodeVersionInfo} from './scripts-types.js'; +import { + createScapiScriptsClient, + SCAPI_SCRIPTS_RW_SCOPES, + SCAPI_SCRIPTS_READ_SCOPES, + type ScapiScriptsClient, + type ScapiScriptsClientConfig, + type CodeVersion as ScapiCodeVersion, +} from '../../clients/scapi-scripts.js'; +import {buildTenantScope, toOrganizationId} from '../../clients/custom-apis.js'; +import {ScopeTierManager} from '../../clients/scapi-scope-tier.js'; +import {getLogger} from '../../logging/logger.js'; + +function mapScapiCodeVersion(scapi: ScapiCodeVersion): CodeVersionInfo { + return { + id: scapi.id ?? '', + active: scapi.active, + cartridges: scapi.cartridges, + compatibilityMode: scapi.compatibilityMode, + activationTime: scapi.activationTime, + lastModificationTime: scapi.lastModificationTime, + rollback: scapi.rollback, + totalSize: scapi.totalSize, + webDavUrl: scapi.webDavUrl, + _raw: scapi, + }; +} + +export interface ScapiScriptsBackendConfig { + shortCode: string; + tenantId: string; + auth: AuthStrategy; +} + +export class ScapiScriptsBackend implements ScriptsBackend { + readonly name = 'scapi' as const; + + private organizationId: string; + private scopeTier: ScopeTierManager; + + constructor(private config: ScapiScriptsBackendConfig) { + this.organizationId = toOrganizationId(config.tenantId); + this.scopeTier = new ScopeTierManager({ + buildClient: (scopes) => this.buildClient(scopes), + rwScopes: SCAPI_SCRIPTS_RW_SCOPES, + readScopes: SCAPI_SCRIPTS_READ_SCOPES, + domainName: 'Scripts', + }); + } + + async listCodeVersions(): Promise { + const client = this.scopeTier.getClientForRead(); + const {data, error} = await client.GET('/organizations/{organizationId}/code-versions', { + params: {path: {organizationId: this.organizationId}}, + }); + if (error || !data) { + throw new Error(toErrorMessage(error, 'Failed to list code versions')); + } + const result = data as unknown as {data?: ScapiCodeVersion[]}; + return (result.data ?? []).map(mapScapiCodeVersion); + } + + async getActiveCodeVersion(): Promise { + const versions = await this.listCodeVersions(); + return versions.find((v) => v.active); + } + + async activateCodeVersion(codeVersionId: string): Promise { + const client = this.scopeTier.getClientForWrite(); + const logger = getLogger(); + logger.debug({codeVersionId}, `Activating code version ${codeVersionId}`); + + const {error} = await client.PATCH('/organizations/{organizationId}/code-versions/{codeVersionId}', { + params: {path: {organizationId: this.organizationId, codeVersionId}}, + body: {active: true} as unknown as ScapiCodeVersion, + }); + if (error) { + throw new Error(toErrorMessage(error, `Failed to activate code version ${codeVersionId}`)); + } + logger.debug({codeVersionId}, `Code version ${codeVersionId} activated`); + } + + async deleteCodeVersion(codeVersionId: string): Promise { + const client = this.scopeTier.getClientForWrite(); + const {error} = await client.DELETE('/organizations/{organizationId}/code-versions/{codeVersionId}', { + params: {path: {organizationId: this.organizationId, codeVersionId}}, + }); + if (error) { + throw new Error(toErrorMessage(error, `Failed to delete code version ${codeVersionId}`)); + } + } + + async createCodeVersion(codeVersionId: string): Promise { + const client = this.scopeTier.getClientForWrite(); + const {error} = await client.PUT('/organizations/{organizationId}/code-versions/{codeVersionId}', { + params: {path: {organizationId: this.organizationId, codeVersionId}}, + }); + if (error) { + throw new Error(toErrorMessage(error, `Failed to create code version ${codeVersionId}`)); + } + } + + async reloadCodeVersion(_codeVersionId?: string): Promise { + throw new Error('Reloading code versions is not supported via SCAPI. Use --api-backend ocapi to reload.'); + } + + private buildClient(scopes: string[]): ScapiScriptsClient { + const clientConfig: ScapiScriptsClientConfig = { + shortCode: this.config.shortCode, + tenantId: this.config.tenantId, + scopes: [...scopes, buildTenantScope(this.config.tenantId)], + }; + return createScapiScriptsClient(clientConfig, this.config.auth); + } +} + +function toErrorMessage(error: unknown, fallback: string): string { + const e = error as {detail?: string; title?: string} | undefined; + return e?.detail ?? e?.title ?? fallback; +} diff --git a/packages/b2c-tooling-sdk/src/operations/code/scripts-backend.ts b/packages/b2c-tooling-sdk/src/operations/code/scripts-backend.ts new file mode 100644 index 000000000..3874fd215 --- /dev/null +++ b/packages/b2c-tooling-sdk/src/operations/code/scripts-backend.ts @@ -0,0 +1,77 @@ +/* + * 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 type {B2CInstance} from '../../instance/index.js'; +import type {AuthStrategy} from '../../auth/types.js'; +import type {ScriptsBackend, CodeVersionInfo} from './scripts-types.js'; +import {OcapiScriptsBackend} from './ocapi-scripts-backend.js'; +import {ScapiScriptsBackend} from './scapi-scripts-backend.js'; +import {ScapiFallbackBackend} from '../../clients/scapi-fallback-backend.js'; +import {resolveScapiOrOcapi, type ApiBackendPreference} from '../../clients/scapi-backend-utils.js'; + +export interface ScriptsBackendConfig { + preference: ApiBackendPreference; + instance: B2CInstance; + shortCode?: string; + tenantId?: string; + auth?: AuthStrategy; +} + +export function createScriptsBackend(config: ScriptsBackendConfig): ScriptsBackend { + const hasScapiConfig = Boolean(config.shortCode && config.tenantId && config.auth); + const resolved = resolveScapiOrOcapi({ + preference: config.preference, + hasScapiConfig, + domainName: 'Scripts', + }); + + if (resolved === 'ocapi') { + return new OcapiScriptsBackend(config.instance); + } + + const scapiBackend = new ScapiScriptsBackend({ + shortCode: config.shortCode!, + tenantId: config.tenantId!, + auth: config.auth!, + }); + + if (config.preference === 'scapi') { + return scapiBackend; + } + + // Auto mode: wrap with fallback + const ocapiBackend = new OcapiScriptsBackend(config.instance); + return new FallbackScriptsBackend(scapiBackend, ocapiBackend); +} + +export class FallbackScriptsBackend extends ScapiFallbackBackend implements ScriptsBackend { + constructor(scapiBackend: ScapiScriptsBackend, ocapiBackend: OcapiScriptsBackend) { + super(scapiBackend, ocapiBackend, 'scripts'); + } + + async listCodeVersions(): Promise { + return this.withFallback((b) => b.listCodeVersions()); + } + + async getActiveCodeVersion(): Promise { + return this.withFallback((b) => b.getActiveCodeVersion()); + } + + async activateCodeVersion(codeVersionId: string): Promise { + return this.withFallback((b) => b.activateCodeVersion(codeVersionId)); + } + + async deleteCodeVersion(codeVersionId: string): Promise { + return this.withFallback((b) => b.deleteCodeVersion(codeVersionId)); + } + + async createCodeVersion(codeVersionId: string): Promise { + return this.withFallback((b) => b.createCodeVersion(codeVersionId)); + } + + async reloadCodeVersion(codeVersionId?: string): Promise { + return this.withFallback((b) => b.reloadCodeVersion(codeVersionId)); + } +} diff --git a/packages/b2c-tooling-sdk/src/operations/code/scripts-types.ts b/packages/b2c-tooling-sdk/src/operations/code/scripts-types.ts new file mode 100644 index 000000000..a0d8e18ce --- /dev/null +++ b/packages/b2c-tooling-sdk/src/operations/code/scripts-types.ts @@ -0,0 +1,54 @@ +/* + * 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 + */ +/** + * Canonical types and backend interface for code-version (Scripts) operations. + * + * The OCAPI Data API and the SCAPI Scripts API both manage code versions on a + * B2C instance. We expose a single canonical shape here so command code is + * agnostic to which backend serves the request. + * + * @module operations/code/scripts-types + */ +import type {BackendBase} from '../../clients/scapi-backend-utils.js'; + +/** + * Canonical code version. CamelCase fields match SCAPI; OCAPI mapping + * converts from snake_case. + */ +export interface CodeVersionInfo { + id: string; + active?: boolean; + cartridges?: string[]; + compatibilityMode?: string; + activationTime?: string; + lastModificationTime?: string; + rollback?: boolean; + totalSize?: number; + webDavUrl?: string; + /** Original backend response, for advanced consumers. */ + _raw?: unknown; +} + +/** + * Backend contract for code-version operations. + * + * `reloadCodeVersion` is OCAPI-only — the SCAPI backend's implementation + * throws to advertise that. In auto mode the fallback wrapper will fall + * through to OCAPI on the first call (since reload requires the OCAPI cache + * rebuild semantics). + */ +export interface ScriptsBackend extends BackendBase { + listCodeVersions(): Promise; + getActiveCodeVersion(): Promise; + activateCodeVersion(codeVersionId: string): Promise; + deleteCodeVersion(codeVersionId: string): Promise; + createCodeVersion(codeVersionId: string): Promise; + /** + * Re-activates the current code version to force a code cache reload. + * Implemented only by the OCAPI backend. + */ + reloadCodeVersion(codeVersionId?: string): Promise; +} From bdcbcecc6db49b9d6994752cbaf7119084b73500 Mon Sep 17 00:00:00 2001 From: Charles Lavery Date: Fri, 8 May 2026 13:45:36 -0400 Subject: [PATCH 04/22] Add SCAPI Merchant Users support with backend abstraction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migrates bm users list/get/update/delete commands to the dual-backend pattern. New BmCommand base class exposes createUsersBackend(), which selects between OCAPI and SCAPI based on --api-backend. - New SCAPI Merchant Users client (merchant/users/v1) - UsersBackend interface with canonical UserInfo (camelCase) - bm users search, bm whoami, bm access-key * stay OCAPI-only — no SCAPI equivalents - SCAPI updateUser does not support `disabled`; the SCAPI backend throws when --disabled is passed, prompting the user to use OCAPI --- .../b2c-cli/src/commands/bm/users/delete.ts | 10 +- packages/b2c-cli/src/commands/bm/users/get.ts | 31 +- .../b2c-cli/src/commands/bm/users/list.ts | 39 +- .../b2c-cli/src/commands/bm/users/update.ts | 25 +- .../test/commands/bm/users/delete.test.ts | 40 +- .../test/commands/bm/users/get.test.ts | 48 ++- .../test/commands/bm/users/list.test.ts | 51 ++- .../test/commands/bm/users/update.test.ts | 57 +-- packages/b2c-tooling-sdk/package.json | 2 +- .../specs/merchant-users-v1.yaml | 398 ++++++++++++++++++ .../b2c-tooling-sdk/src/cli/bm-command.ts | 31 ++ packages/b2c-tooling-sdk/src/cli/index.ts | 1 + packages/b2c-tooling-sdk/src/clients/index.ts | 15 + .../src/clients/middleware-registry.ts | 3 +- .../clients/scapi-merchant-users.generated.ts | 300 +++++++++++++ .../src/clients/scapi-merchant-users.ts | 57 +++ packages/b2c-tooling-sdk/src/index.ts | 18 + .../src/operations/bm-users/backend.ts | 79 ++++ .../src/operations/bm-users/index.ts | 15 + .../src/operations/bm-users/ocapi-backend.ts | 106 +++++ .../src/operations/bm-users/scapi-backend.ts | 177 ++++++++ .../src/operations/bm-users/types.ts | 97 +++++ 22 files changed, 1464 insertions(+), 136 deletions(-) create mode 100644 packages/b2c-tooling-sdk/specs/merchant-users-v1.yaml create mode 100644 packages/b2c-tooling-sdk/src/cli/bm-command.ts create mode 100644 packages/b2c-tooling-sdk/src/clients/scapi-merchant-users.generated.ts create mode 100644 packages/b2c-tooling-sdk/src/clients/scapi-merchant-users.ts create mode 100644 packages/b2c-tooling-sdk/src/operations/bm-users/backend.ts create mode 100644 packages/b2c-tooling-sdk/src/operations/bm-users/ocapi-backend.ts create mode 100644 packages/b2c-tooling-sdk/src/operations/bm-users/scapi-backend.ts create mode 100644 packages/b2c-tooling-sdk/src/operations/bm-users/types.ts diff --git a/packages/b2c-cli/src/commands/bm/users/delete.ts b/packages/b2c-cli/src/commands/bm/users/delete.ts index ed44a0979..f234dc9cd 100644 --- a/packages/b2c-cli/src/commands/bm/users/delete.ts +++ b/packages/b2c-cli/src/commands/bm/users/delete.ts @@ -4,8 +4,7 @@ * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 */ import {Args, Flags} from '@oclif/core'; -import {InstanceCommand} from '@salesforce/b2c-tooling-sdk/cli'; -import {deleteBmUser} from '@salesforce/b2c-tooling-sdk/operations/bm-users'; +import {BmCommand} from '@salesforce/b2c-tooling-sdk/cli'; import {confirm} from '@salesforce/b2c-tooling-sdk/ux'; import {t} from '../../../i18n/index.js'; @@ -15,7 +14,7 @@ interface DeleteResult { hostname: string; } -export default class BmUsersDelete extends InstanceCommand { +export default class BmUsersDelete extends BmCommand { static args = { login: Args.string({ description: 'User login (email) to delete', @@ -57,9 +56,12 @@ export default class BmUsersDelete extends InstanceCommand } } + const backend = this.createUsersBackend(); + this.logger.debug(`Using ${backend.name} backend for users delete`); + this.log(t('commands.bm.users.delete.deleting', 'Deleting user {{login}} from {{hostname}}...', {login, hostname})); - await deleteBmUser(this.instance, login); + await backend.deleteUser(login); const result = {success: true, login, hostname}; diff --git a/packages/b2c-cli/src/commands/bm/users/get.ts b/packages/b2c-cli/src/commands/bm/users/get.ts index ad5c4e591..efeef2bd8 100644 --- a/packages/b2c-cli/src/commands/bm/users/get.ts +++ b/packages/b2c-cli/src/commands/bm/users/get.ts @@ -4,11 +4,11 @@ * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 */ import {Args} from '@oclif/core'; -import {InstanceCommand, printFieldsBlock} from '@salesforce/b2c-tooling-sdk/cli'; -import {getBmUser, type BmUser} from '@salesforce/b2c-tooling-sdk/operations/bm-users'; +import {BmCommand, printFieldsBlock} from '@salesforce/b2c-tooling-sdk/cli'; +import {type UserInfo} from '@salesforce/b2c-tooling-sdk/operations/bm-users'; import {t} from '../../../i18n/index.js'; -export default class BmUsersGet extends InstanceCommand { +export default class BmUsersGet extends BmCommand { static args = { login: Args.string({ description: 'User login (email)', @@ -25,15 +25,18 @@ export default class BmUsersGet extends InstanceCommand { '<%= config.bin %> <%= command.id %> user@example.com --json', ]; - async run(): Promise { + async run(): Promise { this.requireOAuthCredentials(); const {login} = this.args; const hostname = this.resolvedConfig.values.hostname!; + const backend = this.createUsersBackend(); + this.logger.debug(`Using ${backend.name} backend for users get`); + this.log(t('commands.bm.users.get.fetching', 'Fetching user {{login}} from {{hostname}}...', {login, hostname})); - const user = await getBmUser(this.instance, login); + const user = await backend.getUser(login); if (this.jsonEnabled()) { return user; @@ -44,18 +47,16 @@ export default class BmUsersGet extends InstanceCommand { [ ['Login', user.login], ['Email', user.email], - ['First Name', user.first_name], - ['Last Name', user.last_name], - ['External ID', user.external_id], + ['First Name', user.firstName], + ['Last Name', user.lastName], + ['External ID', user.externalId], ['Disabled', user.disabled?.toString()], ['Locked', user.locked?.toString()], - ['Preferred UI Locale', user.preferred_ui_locale], - ['Preferred Data Locale', user.preferred_data_locale], - ['Last Login', user.last_login_date], - ['Password Modified', user.password_modification_date], - ['Password Expires', user.password_expiration_date], - ['Created', user.creation_date], - ['Last Modified', user.last_modified], + ['Preferred UI Locale', user.preferredUiLocale], + ['Preferred Data Locale', user.preferredDataLocale], + ['Last Login', user.lastLoginDate], + ['Password Modified', user.passwordModificationDate], + ['Password Expires', user.passwordExpirationDate], ], { sections: user.roles && user.roles.length > 0 ? [{title: 'Roles', lines: user.roles}] : [], diff --git a/packages/b2c-cli/src/commands/bm/users/list.ts b/packages/b2c-cli/src/commands/bm/users/list.ts index acbb4cdd7..539668391 100644 --- a/packages/b2c-cli/src/commands/bm/users/list.ts +++ b/packages/b2c-cli/src/commands/bm/users/list.ts @@ -4,17 +4,11 @@ * 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 { - InstanceCommand, - TableRenderer, - columnFlagsFor, - selectColumns, - type ColumnDef, -} from '@salesforce/b2c-tooling-sdk/cli'; -import {listBmUsers, type BmUser, type BmUsers} from '@salesforce/b2c-tooling-sdk/operations/bm-users'; +import {BmCommand, TableRenderer, columnFlagsFor, selectColumns, type ColumnDef} from '@salesforce/b2c-tooling-sdk/cli'; +import {type UserInfo, type ListUsersResult} from '@salesforce/b2c-tooling-sdk/operations/bm-users'; import {t} from '../../../i18n/index.js'; -const COLUMNS: Record> = { +const COLUMNS: Record> = { login: { header: 'Login', get: (u) => u.login || '-', @@ -25,7 +19,7 @@ const COLUMNS: Record> = { }, name: { header: 'Name', - get: (u) => [u.first_name, u.last_name].filter(Boolean).join(' ') || '-', + get: (u) => [u.firstName, u.lastName].filter(Boolean).join(' ') || '-', }, disabled: { header: 'Disabled', @@ -37,12 +31,12 @@ const COLUMNS: Record> = { }, lastLogin: { header: 'Last Login', - get: (u) => u.last_login_date || '-', + get: (u) => u.lastLoginDate || '-', extended: true, }, externalId: { header: 'External ID', - get: (u) => u.external_id || '-', + get: (u) => u.externalId || '-', extended: true, }, }; @@ -51,7 +45,7 @@ const DEFAULT_COLUMNS = ['login', 'name', 'disabled', 'locked']; const tableRenderer = new TableRenderer(COLUMNS); -export default class BmUsersList extends InstanceCommand { +export default class BmUsersList extends BmCommand { static description = t('commands.bm.users.list.description', 'List Business Manager users on an instance'); static enableJsonFlag = true; @@ -75,37 +69,40 @@ export default class BmUsersList extends InstanceCommand { ...columnFlagsFor(COLUMNS), }; - async run(): Promise { + async run(): Promise { this.requireOAuthCredentials(); const hostname = this.resolvedConfig.values.hostname!; const {count, start} = this.flags; + const backend = this.createUsersBackend(); + this.logger.debug(`Using ${backend.name} backend for users list`); + this.log(t('commands.bm.users.list.fetching', 'Fetching users from {{hostname}}...', {hostname})); - const users = await listBmUsers(this.instance, {count, start}); + const result = await backend.listUsers({count, start}); if (this.jsonEnabled()) { - return users; + return result; } - const items = users.data ?? []; + const items = result.hits; if (items.length === 0) { this.log(t('commands.bm.users.list.noUsers', 'No users found.')); - return users; + return result; } tableRenderer.render(items, selectColumns(this.flags, tableRenderer, DEFAULT_COLUMNS, this.warn.bind(this))); - if (users.total && users.total > items.length) { + if (result.total && result.total > items.length) { this.log( t('commands.bm.users.list.moreUsers', '{{count}} of {{total}} users shown.', { count: items.length, - total: users.total, + total: result.total, }), ); } - return users; + return result; } } diff --git a/packages/b2c-cli/src/commands/bm/users/update.ts b/packages/b2c-cli/src/commands/bm/users/update.ts index 9b5a961c7..35eab2bf7 100644 --- a/packages/b2c-cli/src/commands/bm/users/update.ts +++ b/packages/b2c-cli/src/commands/bm/users/update.ts @@ -4,11 +4,11 @@ * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 */ import {Args, Flags} from '@oclif/core'; -import {InstanceCommand} from '@salesforce/b2c-tooling-sdk/cli'; -import {updateBmUser, type BmUser, type UpdateBmUserChanges} from '@salesforce/b2c-tooling-sdk/operations/bm-users'; +import {BmCommand} from '@salesforce/b2c-tooling-sdk/cli'; +import {type UserInfo, type UpdateUserChanges} from '@salesforce/b2c-tooling-sdk/operations/bm-users'; import {t} from '../../../i18n/index.js'; -export default class BmUsersUpdate extends InstanceCommand { +export default class BmUsersUpdate extends BmCommand { static args = { login: Args.string({ description: 'User login (email)', @@ -56,21 +56,21 @@ export default class BmUsersUpdate extends InstanceCommand }), }; - async run(): Promise { + async run(): Promise { this.requireOAuthCredentials(); const {login} = this.args; const flags = this.flags; const hostname = this.resolvedConfig.values.hostname!; - const changes: UpdateBmUserChanges = {}; + const changes: UpdateUserChanges = {}; if (flags.disabled !== undefined) changes.disabled = flags.disabled; - if (flags['first-name'] !== undefined) changes.first_name = flags['first-name']; - if (flags['last-name'] !== undefined) changes.last_name = flags['last-name']; + if (flags['first-name'] !== undefined) changes.firstName = flags['first-name']; + if (flags['last-name'] !== undefined) changes.lastName = flags['last-name']; if (flags.email !== undefined) changes.email = flags.email; - if (flags['external-id'] !== undefined) changes.external_id = flags['external-id']; - if (flags['preferred-ui-locale'] !== undefined) changes.preferred_ui_locale = flags['preferred-ui-locale']; - if (flags['preferred-data-locale'] !== undefined) changes.preferred_data_locale = flags['preferred-data-locale']; + if (flags['external-id'] !== undefined) changes.externalId = flags['external-id']; + if (flags['preferred-ui-locale'] !== undefined) changes.preferredUiLocale = flags['preferred-ui-locale']; + if (flags['preferred-data-locale'] !== undefined) changes.preferredDataLocale = flags['preferred-data-locale']; if (Object.keys(changes).length === 0) { this.error( @@ -81,9 +81,12 @@ export default class BmUsersUpdate extends InstanceCommand ); } + const backend = this.createUsersBackend(); + this.logger.debug(`Using ${backend.name} backend for users update`); + this.log(t('commands.bm.users.update.updating', 'Updating user {{login}} on {{hostname}}...', {login, hostname})); - const user = await updateBmUser(this.instance, login, changes); + const user = await backend.updateUser(login, changes); if (this.jsonEnabled()) { return user; diff --git a/packages/b2c-cli/test/commands/bm/users/delete.test.ts b/packages/b2c-cli/test/commands/bm/users/delete.test.ts index 3d684dded..d8d11b192 100644 --- a/packages/b2c-cli/test/commands/bm/users/delete.test.ts +++ b/packages/b2c-cli/test/commands/bm/users/delete.test.ts @@ -21,50 +21,58 @@ describe('bm users delete', () => { return createTestCommand(BmUsersDelete, hooks.getConfig(), flags, args); } + function createMockBackend() { + return { + name: 'ocapi' as const, + listUsers: sinon.stub(), + getUser: sinon.stub(), + createOrReplaceUser: sinon.stub(), + updateUser: sinon.stub(), + deleteUser: sinon.stub(), + }; + } + function stubCommon(command: any, {jsonEnabled}: {jsonEnabled: boolean}) { sinon.stub(command, 'requireOAuthCredentials').returns(void 0); sinon.stub(command, 'resolvedConfig').get(() => ({values: {hostname: 'example.com'}})); + sinon.stub(command, 'instance').get(() => ({config: {hostname: 'example.com'}})); sinon.stub(command, 'jsonEnabled').returns(jsonEnabled); + const backend = createMockBackend(); + sinon.stub(command, 'createUsersBackend').returns(backend); + return backend; } it('deletes user with --force in JSON mode', async () => { const command: any = await createCommand({force: true}, {login: 'user@x.com'}); - stubCommon(command, {jsonEnabled: true}); + const backend = stubCommon(command, {jsonEnabled: true}); - const ocapiDelete = sinon.stub().resolves({data: undefined, error: undefined}); - sinon.stub(command, 'instance').get(() => ({ocapi: {DELETE: ocapiDelete}})); + backend.deleteUser.resolves(); const result = await command.run(); expect(result.success).to.equal(true); expect(result.login).to.equal('user@x.com'); expect(result.hostname).to.equal('example.com'); - expect(ocapiDelete.calledOnce).to.equal(true); + expect(backend.deleteUser.calledOnce).to.equal(true); }); it('throws on 404', async () => { const command: any = await createCommand({force: true}, {login: 'missing@x.com'}); - sinon.stub(command, 'requireOAuthCredentials').returns(void 0); - sinon.stub(command, 'resolvedConfig').get(() => ({values: {hostname: 'example.com'}})); + const backend = stubCommon(command, {jsonEnabled: false}); + sinon.stub(command, 'log').returns(void 0); - const ocapiDelete = sinon.stub().resolves({ - data: undefined, - error: {fault: {message: 'User not found'}}, - response: {status: 404, statusText: 'Not Found'}, - }); - sinon.stub(command, 'instance').get(() => ({ocapi: {DELETE: ocapiDelete}})); + backend.deleteUser.rejects(new Error('Failed to delete user missing@x.com: User not found')); await expectError(() => command.run(), /Failed to delete user/); }); it('skips confirmation prompt in JSON mode without --force', async () => { const command: any = await createCommand({}, {login: 'user@x.com'}); - stubCommon(command, {jsonEnabled: true}); + const backend = stubCommon(command, {jsonEnabled: true}); - const ocapiDelete = sinon.stub().resolves({data: undefined, error: undefined}); - sinon.stub(command, 'instance').get(() => ({ocapi: {DELETE: ocapiDelete}})); + backend.deleteUser.resolves(); const result = await command.run(); expect(result.success).to.equal(true); - expect(ocapiDelete.calledOnce).to.equal(true); + expect(backend.deleteUser.calledOnce).to.equal(true); }); }); diff --git a/packages/b2c-cli/test/commands/bm/users/get.test.ts b/packages/b2c-cli/test/commands/bm/users/get.test.ts index 2124dc431..5dca8da51 100644 --- a/packages/b2c-cli/test/commands/bm/users/get.test.ts +++ b/packages/b2c-cli/test/commands/bm/users/get.test.ts @@ -22,40 +22,51 @@ describe('bm users get', () => { return createTestCommand(BmUsersGet, hooks.getConfig(), flags, args); } + function createMockBackend() { + return { + name: 'ocapi' as const, + listUsers: sinon.stub(), + getUser: sinon.stub(), + createOrReplaceUser: sinon.stub(), + updateUser: sinon.stub(), + deleteUser: sinon.stub(), + }; + } + function stubCommon(command: any, {jsonEnabled}: {jsonEnabled: boolean}) { sinon.stub(command, 'requireOAuthCredentials').returns(void 0); sinon.stub(command, 'resolvedConfig').get(() => ({values: {hostname: 'example.com'}})); + sinon.stub(command, 'instance').get(() => ({config: {hostname: 'example.com'}})); sinon.stub(command, 'jsonEnabled').returns(jsonEnabled); + const backend = createMockBackend(); + sinon.stub(command, 'createUsersBackend').returns(backend); + return backend; } it('returns user details in JSON mode', async () => { const command: any = await createCommand({}, {login: 'user@x.com'}); - stubCommon(command, {jsonEnabled: true}); + const backend = stubCommon(command, {jsonEnabled: true}); - const mockUser = { + backend.getUser.resolves({ login: 'user@x.com', email: 'user@x.com', - first_name: 'Test', - last_name: 'User', + firstName: 'Test', + lastName: 'User', disabled: false, - }; - const ocapiGet = sinon.stub().resolves({data: mockUser, error: undefined}); - sinon.stub(command, 'instance').get(() => ({ocapi: {GET: ocapiGet}})); + }); const result = await command.run(); expect(result.login).to.equal('user@x.com'); - expect(result.first_name).to.equal('Test'); - expect(ocapiGet.calledOnce).to.equal(true); + expect(result.firstName).to.equal('Test'); + expect(backend.getUser.calledOnce).to.equal(true); }); it('displays user details in non-JSON mode', async () => { const command: any = await createCommand({}, {login: 'user@x.com'}); - stubCommon(command, {jsonEnabled: false}); + const backend = stubCommon(command, {jsonEnabled: false}); sinon.stub(command, 'log').returns(void 0); - const mockUser = {login: 'user@x.com', email: 'user@x.com', first_name: 'Test', last_name: 'User'}; - const ocapiGet = sinon.stub().resolves({data: mockUser, error: undefined}); - sinon.stub(command, 'instance').get(() => ({ocapi: {GET: ocapiGet}})); + backend.getUser.resolves({login: 'user@x.com', email: 'user@x.com', firstName: 'Test', lastName: 'User'}); const stdoutStub = sinon.stub(ux, 'stdout').returns(void 0 as any); @@ -66,15 +77,10 @@ describe('bm users get', () => { it('throws on 404', async () => { const command: any = await createCommand({}, {login: 'missing@x.com'}); - sinon.stub(command, 'requireOAuthCredentials').returns(void 0); - sinon.stub(command, 'resolvedConfig').get(() => ({values: {hostname: 'example.com'}})); + const backend = stubCommon(command, {jsonEnabled: false}); + sinon.stub(command, 'log').returns(void 0); - const ocapiGet = sinon.stub().resolves({ - data: undefined, - error: {fault: {message: 'User not found'}}, - response: {status: 404, statusText: 'Not Found'}, - }); - sinon.stub(command, 'instance').get(() => ({ocapi: {GET: ocapiGet}})); + backend.getUser.rejects(new Error('Failed to get user missing@x.com: User not found')); await expectError(() => command.run(), /Failed to get user/); }); diff --git a/packages/b2c-cli/test/commands/bm/users/list.test.ts b/packages/b2c-cli/test/commands/bm/users/list.test.ts index 5a6409cef..95eb2fe0b 100644 --- a/packages/b2c-cli/test/commands/bm/users/list.test.ts +++ b/packages/b2c-cli/test/commands/bm/users/list.test.ts @@ -21,51 +21,62 @@ describe('bm users list', () => { return createTestCommand(BmUsersList, hooks.getConfig(), flags); } + function createMockBackend() { + return { + name: 'ocapi' as const, + listUsers: sinon.stub(), + getUser: sinon.stub(), + createOrReplaceUser: sinon.stub(), + updateUser: sinon.stub(), + deleteUser: sinon.stub(), + }; + } + function stubCommon(command: any, {jsonEnabled}: {jsonEnabled: boolean}) { sinon.stub(command, 'requireOAuthCredentials').returns(void 0); sinon.stub(command, 'resolvedConfig').get(() => ({values: {hostname: 'example.com'}})); + sinon.stub(command, 'instance').get(() => ({config: {hostname: 'example.com'}})); sinon.stub(command, 'jsonEnabled').returns(jsonEnabled); + const backend = createMockBackend(); + sinon.stub(command, 'createUsersBackend').returns(backend); + return backend; } it('returns data in JSON mode', async () => { const command: any = await createCommand(); - stubCommon(command, {jsonEnabled: true}); + const backend = stubCommon(command, {jsonEnabled: true}); - const mockUsers = {count: 2, total: 2, data: [{login: 'a@x.com'}, {login: 'b@x.com'}]}; - const ocapiGet = sinon.stub().resolves({data: mockUsers, error: undefined}); - sinon.stub(command, 'instance').get(() => ({ocapi: {GET: ocapiGet}})); + backend.listUsers.resolves({ + total: 2, + start: 0, + count: 2, + hits: [{login: 'a@x.com'}, {login: 'b@x.com'}], + }); const result = await command.run(); expect(result.count).to.equal(2); - expect(result.data).to.have.length(2); - expect(ocapiGet.calledOnce).to.equal(true); - expect(ocapiGet.firstCall.args[0]).to.equal('/users'); + expect(result.hits).to.have.length(2); + expect(backend.listUsers.calledOnce).to.equal(true); }); it('prints "no users" message when empty in non-JSON mode', async () => { const command: any = await createCommand(); - stubCommon(command, {jsonEnabled: false}); + const backend = stubCommon(command, {jsonEnabled: false}); const logStub = sinon.stub(command, 'log').returns(void 0); - const ocapiGet = sinon.stub().resolves({data: {count: 0, total: 0, data: []}, error: undefined}); - sinon.stub(command, 'instance').get(() => ({ocapi: {GET: ocapiGet}})); + backend.listUsers.resolves({total: 0, start: 0, count: 0, hits: []}); const result = await command.run(); - expect(result.count).to.equal(0); + expect(result.total).to.equal(0); expect(logStub.calledWith(sinon.match(/No users found/))).to.equal(true); }); - it('throws when OCAPI returns error', async () => { + it('throws when backend returns error', async () => { const command: any = await createCommand(); - sinon.stub(command, 'requireOAuthCredentials').returns(void 0); - sinon.stub(command, 'resolvedConfig').get(() => ({values: {hostname: 'example.com'}})); + const backend = stubCommon(command, {jsonEnabled: false}); + sinon.stub(command, 'log').returns(void 0); - const ocapiGet = sinon.stub().resolves({ - data: undefined, - error: {fault: {message: 'forbidden'}}, - response: {status: 403, statusText: 'Forbidden'}, - }); - sinon.stub(command, 'instance').get(() => ({ocapi: {GET: ocapiGet}})); + backend.listUsers.rejects(new Error('Failed to list users: forbidden')); await expectError(() => command.run(), 'Failed to list users'); }); diff --git a/packages/b2c-cli/test/commands/bm/users/update.test.ts b/packages/b2c-cli/test/commands/bm/users/update.test.ts index 0d4346af5..74586b7f0 100644 --- a/packages/b2c-cli/test/commands/bm/users/update.test.ts +++ b/packages/b2c-cli/test/commands/bm/users/update.test.ts @@ -21,43 +21,55 @@ describe('bm users update', () => { return createTestCommand(BmUsersUpdate, hooks.getConfig(), flags, args); } + function createMockBackend() { + return { + name: 'ocapi' as const, + listUsers: sinon.stub(), + getUser: sinon.stub(), + createOrReplaceUser: sinon.stub(), + updateUser: sinon.stub(), + deleteUser: sinon.stub(), + }; + } + function stubCommon(command: any, {jsonEnabled}: {jsonEnabled: boolean}) { sinon.stub(command, 'requireOAuthCredentials').returns(void 0); sinon.stub(command, 'resolvedConfig').get(() => ({values: {hostname: 'example.com'}})); + sinon.stub(command, 'instance').get(() => ({config: {hostname: 'example.com'}})); sinon.stub(command, 'jsonEnabled').returns(jsonEnabled); + const backend = createMockBackend(); + sinon.stub(command, 'createUsersBackend').returns(backend); + return backend; } it('updates user with --disabled in JSON mode', async () => { const command: any = await createCommand({disabled: true}, {login: 'user@x.com'}); - stubCommon(command, {jsonEnabled: true}); + const backend = stubCommon(command, {jsonEnabled: true}); - const mockUser = {login: 'user@x.com', disabled: true}; - const ocapiPatch = sinon.stub().resolves({data: mockUser, error: undefined}); - sinon.stub(command, 'instance').get(() => ({ocapi: {PATCH: ocapiPatch}})); + backend.updateUser.resolves({login: 'user@x.com', disabled: true}); const result = await command.run(); expect(result.disabled).to.equal(true); - expect(ocapiPatch.calledOnce).to.equal(true); - const body = ocapiPatch.firstCall.args[1].body; - expect(body).to.deep.equal({disabled: true}); + expect(backend.updateUser.calledOnce).to.equal(true); + const changes = backend.updateUser.firstCall.args[1]; + expect(changes).to.deep.equal({disabled: true}); }); - it('combines multiple field flags into PATCH body', async () => { + it('combines multiple field flags into changes', async () => { const command: any = await createCommand( {'first-name': 'Jane', 'last-name': 'Doe', 'preferred-ui-locale': 'en_US'}, {login: 'user@x.com'}, ); - stubCommon(command, {jsonEnabled: true}); + const backend = stubCommon(command, {jsonEnabled: true}); - const ocapiPatch = sinon.stub().resolves({data: {login: 'user@x.com'}, error: undefined}); - sinon.stub(command, 'instance').get(() => ({ocapi: {PATCH: ocapiPatch}})); + backend.updateUser.resolves({login: 'user@x.com'}); await command.run(); - const body = ocapiPatch.firstCall.args[1].body; - expect(body).to.deep.equal({ - first_name: 'Jane', - last_name: 'Doe', - preferred_ui_locale: 'en_US', + const changes = backend.updateUser.firstCall.args[1]; + expect(changes).to.deep.equal({ + firstName: 'Jane', + lastName: 'Doe', + preferredUiLocale: 'en_US', }); }); @@ -65,22 +77,15 @@ describe('bm users update', () => { const command: any = await createCommand({}, {login: 'user@x.com'}); stubCommon(command, {jsonEnabled: true}); - sinon.stub(command, 'instance').get(() => ({ocapi: {PATCH: sinon.stub()}})); - await expectError(() => command.run(), /No fields specified/); }); it('throws on 404', async () => { const command: any = await createCommand({disabled: true}, {login: 'missing@x.com'}); - sinon.stub(command, 'requireOAuthCredentials').returns(void 0); - sinon.stub(command, 'resolvedConfig').get(() => ({values: {hostname: 'example.com'}})); + const backend = stubCommon(command, {jsonEnabled: false}); + sinon.stub(command, 'log').returns(void 0); - const ocapiPatch = sinon.stub().resolves({ - data: undefined, - error: {fault: {message: 'User not found'}}, - response: {status: 404, statusText: 'Not Found'}, - }); - sinon.stub(command, 'instance').get(() => ({ocapi: {PATCH: ocapiPatch}})); + backend.updateUser.rejects(new Error('Failed to update user missing@x.com: User not found')); await expectError(() => command.run(), 'Failed to update user'); }); diff --git a/packages/b2c-tooling-sdk/package.json b/packages/b2c-tooling-sdk/package.json index e10162158..aa58cc8f7 100644 --- a/packages/b2c-tooling-sdk/package.json +++ b/packages/b2c-tooling-sdk/package.json @@ -419,7 +419,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 && openapi-typescript specs/granular-replications-v1.yaml -o src/clients/granular-replications.generated.ts && openapi-typescript specs/operations-jobs-v1.yaml -o src/clients/scapi-jobs.generated.ts && openapi-typescript specs/dx-scripts-v1.yaml -o src/clients/scapi-scripts.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/granular-replications-v1.yaml -o src/clients/granular-replications.generated.ts && openapi-typescript specs/operations-jobs-v1.yaml -o src/clients/scapi-jobs.generated.ts && openapi-typescript specs/dx-scripts-v1.yaml -o src/clients/scapi-scripts.generated.ts && openapi-typescript specs/merchant-users-v1.yaml -o src/clients/scapi-merchant-users.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/merchant-users-v1.yaml b/packages/b2c-tooling-sdk/specs/merchant-users-v1.yaml new file mode 100644 index 000000000..6f0222586 --- /dev/null +++ b/packages/b2c-tooling-sdk/specs/merchant-users-v1.yaml @@ -0,0 +1,398 @@ +openapi: 3.0.3 +info: + title: Users + version: 1.0.0 + x-api-type: Admin + x-api-family: Merchant +servers: + - url: "https://{shortCode}.api.commercecloud.salesforce.com/merchant/users/v1" + variables: + shortCode: + default: 123456gf +paths: + /organizations/{organizationId}/users: + get: + operationId: getUsers + parameters: + - name: organizationId + in: path + required: true + style: simple + explode: false + schema: + $ref: "#/components/schemas/OrganizationId" + - name: select + in: query + required: false + style: form + explode: true + schema: + $ref: "#/components/schemas/Select" + - name: limit + in: query + required: false + style: form + explode: true + schema: + type: integer + format: int32 + default: 25 + maximum: 200 + minimum: 1 + - name: offset + in: query + required: false + style: form + explode: true + schema: + type: integer + format: int32 + default: 0 + minimum: 0 + responses: + 200: + description: Returns the collection of users + content: + application/json: + schema: + $ref: "#/components/schemas/UserSearch" + security: + - AmOAuth2: [sfcc.users, sfcc.users.rw] + /organizations/{organizationId}/users/{login}: + get: + operationId: getUser + parameters: + - name: organizationId + in: path + required: true + style: simple + explode: false + schema: + $ref: "#/components/schemas/OrganizationId" + - name: login + in: path + required: true + style: simple + explode: false + schema: + type: string + maxLength: 256 + minLength: 1 + responses: + 200: + description: Returns the user details + content: + application/json: + schema: + $ref: "#/components/schemas/User" + 404: + description: User not found + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ErrorResponse" + security: + - AmOAuth2: [sfcc.users, sfcc.users.rw] + put: + operationId: createOrReplaceUser + parameters: + - name: organizationId + in: path + required: true + style: simple + explode: false + schema: + $ref: "#/components/schemas/OrganizationId" + - name: login + in: path + required: true + style: simple + explode: false + schema: + type: string + maxLength: 256 + minLength: 1 + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/User" + required: true + responses: + 200: + description: The user was successfully updated + content: + application/json: + schema: + $ref: "#/components/schemas/User" + 201: + description: The user was successfully created + content: + application/json: + schema: + $ref: "#/components/schemas/User" + 400: + description: Bad Request - Invalid user request + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ErrorResponse" + security: + - AmOAuth2: [sfcc.users.rw] + delete: + operationId: deleteUser + parameters: + - name: organizationId + in: path + required: true + style: simple + explode: false + schema: + $ref: "#/components/schemas/OrganizationId" + - name: login + in: path + required: true + style: simple + explode: false + schema: + type: string + maxLength: 256 + minLength: 1 + responses: + 204: + description: The user was successfully deleted + 404: + description: User not found + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ErrorResponse" + security: + - AmOAuth2: [sfcc.users.rw] + patch: + operationId: updateUser + parameters: + - name: organizationId + in: path + required: true + style: simple + explode: false + schema: + $ref: "#/components/schemas/OrganizationId" + - name: login + in: path + required: true + style: simple + explode: false + schema: + type: string + maxLength: 256 + minLength: 1 + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/UserUpdateRequest" + required: true + responses: + 200: + description: The user was successfully updated + content: + application/json: + schema: + $ref: "#/components/schemas/User" + 400: + description: Bad Request - Invalid user update request + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ErrorResponse" + 404: + description: User not found + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ErrorResponse" + security: + - AmOAuth2: [sfcc.users.rw] +components: + schemas: + OrganizationId: + type: string + maxLength: 32 + minLength: 1 + Select: + type: string + minLength: 1 + pattern: ^[(].*[)]$ + Total: + type: integer + format: int32 + default: 0 + minimum: 0 + ResultBase: + type: object + properties: + limit: + type: integer + format: int32 + total: + $ref: "#/components/schemas/Total" + required: [limit, total] + Offset: + type: integer + format: int32 + default: 0 + minimum: 0 + PaginatedResultBase: + allOf: + - $ref: "#/components/schemas/ResultBase" + properties: + offset: + $ref: "#/components/schemas/Offset" + required: [limit, offset, total] + LanguageCountry: + type: string + pattern: ^[a-z][a-z]-[A-Z][A-Z]$ + LanguageCode: + type: string + pattern: ^[a-z][a-z]$ + DefaultFallback: + type: string + default: default + pattern: ^default$ + LocaleCode: + oneOf: + - $ref: "#/components/schemas/LanguageCountry" + - $ref: "#/components/schemas/LanguageCode" + - $ref: "#/components/schemas/DefaultFallback" + User: + type: object + properties: + login: + type: string + maxLength: 256 + minLength: 1 + password: + type: string + maxLength: 256 + email: + type: string + maxLength: 256 + firstName: + type: string + maxLength: 256 + lastName: + type: string + maxLength: 256 + externalId: + type: string + maxLength: 256 + disabled: + type: boolean + locked: + type: boolean + lastLoginDate: + type: string + format: date + passwordExpirationDate: + type: string + format: date-time + passwordModificationDate: + type: string + format: date-time + preferredDataLocale: + allOf: + - $ref: "#/components/schemas/LocaleCode" + preferredUiLocale: + allOf: + - $ref: "#/components/schemas/LocaleCode" + roles: + type: array + items: + type: string + maxLength: 256 + required: [email, login] + UserSearch: + allOf: + - $ref: "#/components/schemas/PaginatedResultBase" + properties: + data: + type: array + items: + $ref: "#/components/schemas/User" + type: string + required: [data] + ErrorResponse: + type: object + additionalProperties: true + properties: + title: + type: string + maxLength: 256 + type: + type: string + maxLength: 2048 + detail: + type: string + instance: + type: string + maxLength: 2048 + required: [detail, title, type] + UserUpdateRequest: + type: object + properties: + email: + type: string + maxLength: 256 + firstName: + type: string + maxLength: 256 + lastName: + type: string + maxLength: 256 + externalId: + type: string + maxLength: 256 + preferredDataLocale: + allOf: + - $ref: "#/components/schemas/LocaleCode" + preferredUiLocale: + allOf: + - $ref: "#/components/schemas/LocaleCode" + parameters: + organizationId: + name: organizationId + in: path + required: true + style: simple + explode: false + schema: + $ref: "#/components/schemas/OrganizationId" + select: + name: select + in: query + required: false + style: form + explode: true + schema: + $ref: "#/components/schemas/Select" + login: + name: login + in: path + required: true + style: simple + explode: false + schema: + type: string + maxLength: 256 + minLength: 1 + securitySchemes: + AmOAuth2: + type: oauth2 + flows: + clientCredentials: + tokenUrl: "https://account.demandware.com/dw/oauth2/access_token" + scopes: + sfcc.users: Read access to user resources + sfcc.users.rw: Read and write access to user resources diff --git a/packages/b2c-tooling-sdk/src/cli/bm-command.ts b/packages/b2c-tooling-sdk/src/cli/bm-command.ts new file mode 100644 index 000000000..d2ed80fa3 --- /dev/null +++ b/packages/b2c-tooling-sdk/src/cli/bm-command.ts @@ -0,0 +1,31 @@ +/* + * 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 {InstanceCommand} from './instance-command.js'; +import {createUsersBackend, type UsersBackend} from '../operations/bm-users/index.js'; + +/** + * Base command for Business Manager (instance-level) operations. + * + * Provides backend factories that select between OCAPI and SCAPI based on + * `--api-backend`. In auto mode, prefers SCAPI when shortCode + tenantId are + * configured, falling back to OCAPI on `invalid_scope`. + */ +export abstract class BmCommand extends InstanceCommand { + /** + * Creates a Users backend for `bm users *` commands. + */ + protected createUsersBackend(): UsersBackend { + const preference = this.resolvedConfig.values.apiBackend ?? 'auto'; + return createUsersBackend({ + preference, + instance: this.instance, + shortCode: this.resolvedConfig.values.shortCode, + tenantId: this.resolvedConfig.values.tenantId, + auth: this.hasOAuthCredentials() ? this.getOAuthStrategy() : undefined, + }); + } +} diff --git a/packages/b2c-tooling-sdk/src/cli/index.ts b/packages/b2c-tooling-sdk/src/cli/index.ts index a732faf93..b60e61aec 100644 --- a/packages/b2c-tooling-sdk/src/cli/index.ts +++ b/packages/b2c-tooling-sdk/src/cli/index.ts @@ -98,6 +98,7 @@ export {InstanceCommand} from './instance-command.js'; export {CartridgeCommand} from './cartridge-command.js'; export {JobCommand} from './job-command.js'; export {CodeCommand} from './code-command.js'; +export {BmCommand} from './bm-command.js'; export {MrtCommand} from './mrt-command.js'; export {OdsCommand} from './ods-command.js'; export {AmCommand} from './am-command.js'; diff --git a/packages/b2c-tooling-sdk/src/clients/index.ts b/packages/b2c-tooling-sdk/src/clients/index.ts index 2f41d06ae..d2c850bc3 100644 --- a/packages/b2c-tooling-sdk/src/clients/index.ts +++ b/packages/b2c-tooling-sdk/src/clients/index.ts @@ -336,6 +336,21 @@ export type { components as ScapiJobsComponents, } from './scapi-jobs.js'; +// SCAPI Merchant Users +export { + createScapiMerchantUsersClient, + SCAPI_MERCHANT_USERS_READ_SCOPES, + SCAPI_MERCHANT_USERS_RW_SCOPES, +} from './scapi-merchant-users.js'; +export type { + ScapiMerchantUsersClient, + ScapiMerchantUsersClientConfig, + ScapiMerchantUsersError, + ScapiMerchantUsersResponse, + paths as ScapiMerchantUsersPaths, + components as ScapiMerchantUsersComponents, +} from './scapi-merchant-users.js'; + // SCAPI Scripts (code versions) export {createScapiScriptsClient, SCAPI_SCRIPTS_READ_SCOPES, SCAPI_SCRIPTS_RW_SCOPES} from './scapi-scripts.js'; export type { diff --git a/packages/b2c-tooling-sdk/src/clients/middleware-registry.ts b/packages/b2c-tooling-sdk/src/clients/middleware-registry.ts index 33313678c..7e1011932 100644 --- a/packages/b2c-tooling-sdk/src/clients/middleware-registry.ts +++ b/packages/b2c-tooling-sdk/src/clients/middleware-registry.ts @@ -61,7 +61,8 @@ export type HttpClientType = | 'am-apiclients-api' | 'am-orgs-api' | 'scapi-jobs' - | 'scapi-scripts'; + | 'scapi-scripts' + | 'scapi-merchant-users'; /** * Middleware interface compatible with openapi-fetch. diff --git a/packages/b2c-tooling-sdk/src/clients/scapi-merchant-users.generated.ts b/packages/b2c-tooling-sdk/src/clients/scapi-merchant-users.generated.ts new file mode 100644 index 000000000..34f472962 --- /dev/null +++ b/packages/b2c-tooling-sdk/src/clients/scapi-merchant-users.generated.ts @@ -0,0 +1,300 @@ +/** + * This file was auto-generated by openapi-typescript. + * Do not make direct changes to the file. + */ + +export interface paths { + "/organizations/{organizationId}/users": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getUsers"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/organizations/{organizationId}/users/{login}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getUser"]; + put: operations["createOrReplaceUser"]; + post?: never; + delete: operations["deleteUser"]; + options?: never; + head?: never; + patch: operations["updateUser"]; + trace?: never; + }; +} +export type webhooks = Record; +export interface components { + schemas: { + OrganizationId: string; + Select: string; + /** + * Format: int32 + * @default 0 + */ + Total: number; + ResultBase: { + /** Format: int32 */ + limit: number; + total: components["schemas"]["Total"]; + }; + /** + * Format: int32 + * @default 0 + */ + Offset: number; + PaginatedResultBase: { + offset: components["schemas"]["Offset"]; + } & WithRequired; + LanguageCountry: string; + LanguageCode: string; + /** @default default */ + DefaultFallback: string; + LocaleCode: components["schemas"]["LanguageCountry"] | components["schemas"]["LanguageCode"] | components["schemas"]["DefaultFallback"]; + User: { + login: string; + password?: string; + email: string; + firstName?: string; + lastName?: string; + externalId?: string; + disabled?: boolean; + locked?: boolean; + /** Format: date */ + lastLoginDate?: string; + /** Format: date-time */ + passwordExpirationDate?: string; + /** Format: date-time */ + passwordModificationDate?: string; + preferredDataLocale?: components["schemas"]["LocaleCode"]; + preferredUiLocale?: components["schemas"]["LocaleCode"]; + roles?: string[]; + }; + UserSearch: { + data: components["schemas"]["User"][]; + } & components["schemas"]["PaginatedResultBase"]; + ErrorResponse: { + title: string; + type: string; + detail: string; + instance?: string; + } & { + [key: string]: unknown; + }; + UserUpdateRequest: { + email?: string; + firstName?: string; + lastName?: string; + externalId?: string; + preferredDataLocale?: components["schemas"]["LocaleCode"]; + preferredUiLocale?: components["schemas"]["LocaleCode"]; + }; + }; + responses: never; + parameters: { + organizationId: components["schemas"]["OrganizationId"]; + select: components["schemas"]["Select"]; + login: string; + }; + requestBodies: never; + headers: never; + pathItems: never; +} +export type $defs = Record; +export interface operations { + getUsers: { + parameters: { + query?: { + select?: components["schemas"]["Select"]; + limit?: number; + offset?: number; + }; + header?: never; + path: { + organizationId: components["schemas"]["OrganizationId"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Returns the collection of users */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UserSearch"]; + }; + }; + }; + }; + getUser: { + parameters: { + query?: never; + header?: never; + path: { + organizationId: components["schemas"]["OrganizationId"]; + login: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Returns the user details */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["User"]; + }; + }; + /** @description User not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + createOrReplaceUser: { + parameters: { + query?: never; + header?: never; + path: { + organizationId: components["schemas"]["OrganizationId"]; + login: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["User"]; + }; + }; + responses: { + /** @description The user was successfully updated */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["User"]; + }; + }; + /** @description The user was successfully created */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["User"]; + }; + }; + /** @description Bad Request - Invalid user request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + deleteUser: { + parameters: { + query?: never; + header?: never; + path: { + organizationId: components["schemas"]["OrganizationId"]; + login: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The user was successfully deleted */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description User not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + updateUser: { + parameters: { + query?: never; + header?: never; + path: { + organizationId: components["schemas"]["OrganizationId"]; + login: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UserUpdateRequest"]; + }; + }; + responses: { + /** @description The user was successfully updated */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["User"]; + }; + }; + /** @description Bad Request - Invalid user update request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description User not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; +} +type WithRequired = T & { + [P in K]-?: T[P]; +}; diff --git a/packages/b2c-tooling-sdk/src/clients/scapi-merchant-users.ts b/packages/b2c-tooling-sdk/src/clients/scapi-merchant-users.ts new file mode 100644 index 000000000..384d47f65 --- /dev/null +++ b/packages/b2c-tooling-sdk/src/clients/scapi-merchant-users.ts @@ -0,0 +1,57 @@ +/* + * 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 createClient, {type Client} from 'openapi-fetch'; +import type {AuthStrategy} from '../auth/types.js'; +import type {paths, components} from './scapi-merchant-users.generated.js'; +import {createAuthMiddleware, createLoggingMiddleware, createRateLimitMiddleware} from './middleware.js'; +import {globalMiddlewareRegistry, type MiddlewareRegistry} from './middleware-registry.js'; +import {OAuthStrategy} from '../auth/oauth.js'; +import {buildTenantScope} from './custom-apis.js'; + +export type {paths, components}; +export type ScapiMerchantUsersClient = Client; +export type ScapiMerchantUsersResponse = T extends {content: {'application/json': infer R}} ? R : never; +export type ScapiMerchantUsersError = components['schemas']['ErrorResponse']; + +export type User = components['schemas']['User']; +export type UserUpdateRequest = components['schemas']['UserUpdateRequest']; +export type UserSearch = components['schemas']['UserSearch']; + +export const SCAPI_MERCHANT_USERS_READ_SCOPES = ['sfcc.users']; +export const SCAPI_MERCHANT_USERS_RW_SCOPES = ['sfcc.users.rw']; + +export interface ScapiMerchantUsersClientConfig { + shortCode: string; + tenantId: string; + /** Override scopes (default: sfcc.users.rw + tenant scope). */ + scopes?: string[]; + middlewareRegistry?: MiddlewareRegistry; +} + +export function createScapiMerchantUsersClient( + config: ScapiMerchantUsersClientConfig, + auth: AuthStrategy, +): ScapiMerchantUsersClient { + const registry = config.middlewareRegistry ?? globalMiddlewareRegistry; + + const client = createClient({ + baseUrl: `https://${config.shortCode}.api.commercecloud.salesforce.com/merchant/users/v1`, + }); + + const requiredScopes = config.scopes ?? [...SCAPI_MERCHANT_USERS_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-merchant-users')) { + client.use(middleware); + } + + client.use(createRateLimitMiddleware({prefix: 'SCAPI-USERS'})); + client.use(createLoggingMiddleware('SCAPI-USERS')); + + return client; +} diff --git a/packages/b2c-tooling-sdk/src/index.ts b/packages/b2c-tooling-sdk/src/index.ts index f98a709cb..e7810ae6c 100644 --- a/packages/b2c-tooling-sdk/src/index.ts +++ b/packages/b2c-tooling-sdk/src/index.ts @@ -218,6 +218,24 @@ export type { ScapiScriptsBackendConfig, } from './operations/code/index.js'; +// Users (BM) backend abstraction +export { + createUsersBackend, + FallbackUsersBackend, + OcapiUsersBackend, + ScapiUsersBackend, +} from './operations/bm-users/index.js'; +export type { + UsersBackend, + UsersBackendConfig, + UserInfo, + ListUsersResult, + ListUsersOptions, + CreateUserInput, + UpdateUserChanges, + ScapiUsersBackendConfig, +} from './operations/bm-users/index.js'; + // Operations - Jobs export { executeJob, diff --git a/packages/b2c-tooling-sdk/src/operations/bm-users/backend.ts b/packages/b2c-tooling-sdk/src/operations/bm-users/backend.ts new file mode 100644 index 000000000..3a8263958 --- /dev/null +++ b/packages/b2c-tooling-sdk/src/operations/bm-users/backend.ts @@ -0,0 +1,79 @@ +/* + * 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 type {B2CInstance} from '../../instance/index.js'; +import type {AuthStrategy} from '../../auth/types.js'; +import type { + UsersBackend, + UserInfo, + ListUsersResult, + ListUsersOptions, + UpdateUserChanges, + CreateUserInput, +} from './types.js'; +import {OcapiUsersBackend} from './ocapi-backend.js'; +import {ScapiUsersBackend} from './scapi-backend.js'; +import {ScapiFallbackBackend} from '../../clients/scapi-fallback-backend.js'; +import {resolveScapiOrOcapi, type ApiBackendPreference} from '../../clients/scapi-backend-utils.js'; + +export interface UsersBackendConfig { + preference: ApiBackendPreference; + instance: B2CInstance; + shortCode?: string; + tenantId?: string; + auth?: AuthStrategy; +} + +export function createUsersBackend(config: UsersBackendConfig): UsersBackend { + const hasScapiConfig = Boolean(config.shortCode && config.tenantId && config.auth); + const resolved = resolveScapiOrOcapi({ + preference: config.preference, + hasScapiConfig, + domainName: 'Users', + }); + + if (resolved === 'ocapi') { + return new OcapiUsersBackend(config.instance); + } + + const scapiBackend = new ScapiUsersBackend({ + shortCode: config.shortCode!, + tenantId: config.tenantId!, + auth: config.auth!, + }); + + if (config.preference === 'scapi') { + return scapiBackend; + } + + const ocapiBackend = new OcapiUsersBackend(config.instance); + return new FallbackUsersBackend(scapiBackend, ocapiBackend); +} + +export class FallbackUsersBackend extends ScapiFallbackBackend implements UsersBackend { + constructor(scapiBackend: ScapiUsersBackend, ocapiBackend: OcapiUsersBackend) { + super(scapiBackend, ocapiBackend, 'users'); + } + + async listUsers(options?: ListUsersOptions): Promise { + return this.withFallback((b) => b.listUsers(options)); + } + + async getUser(login: string): Promise { + return this.withFallback((b) => b.getUser(login)); + } + + async createOrReplaceUser(login: string, input: CreateUserInput): Promise { + return this.withFallback((b) => b.createOrReplaceUser(login, input)); + } + + async updateUser(login: string, changes: UpdateUserChanges): Promise { + return this.withFallback((b) => b.updateUser(login, changes)); + } + + async deleteUser(login: string): Promise { + return this.withFallback((b) => b.deleteUser(login)); + } +} diff --git a/packages/b2c-tooling-sdk/src/operations/bm-users/index.ts b/packages/b2c-tooling-sdk/src/operations/bm-users/index.ts index 3de7da64f..27d00fdb0 100644 --- a/packages/b2c-tooling-sdk/src/operations/bm-users/index.ts +++ b/packages/b2c-tooling-sdk/src/operations/bm-users/index.ts @@ -75,3 +75,18 @@ export type { SearchBmUsersOptions, UpdateBmUserChanges, } from './users.js'; + +// Users backend abstraction — supports OCAPI + SCAPI +export {createUsersBackend, FallbackUsersBackend} from './backend.js'; +export type {UsersBackendConfig} from './backend.js'; +export {OcapiUsersBackend} from './ocapi-backend.js'; +export {ScapiUsersBackend} from './scapi-backend.js'; +export type {ScapiUsersBackendConfig} from './scapi-backend.js'; +export type { + UsersBackend, + UserInfo, + ListUsersResult, + ListUsersOptions, + CreateUserInput, + UpdateUserChanges, +} from './types.js'; diff --git a/packages/b2c-tooling-sdk/src/operations/bm-users/ocapi-backend.ts b/packages/b2c-tooling-sdk/src/operations/bm-users/ocapi-backend.ts new file mode 100644 index 000000000..2fb94d01b --- /dev/null +++ b/packages/b2c-tooling-sdk/src/operations/bm-users/ocapi-backend.ts @@ -0,0 +1,106 @@ +/* + * 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 type {B2CInstance} from '../../instance/index.js'; +import type { + UsersBackend, + UserInfo, + ListUsersResult, + ListUsersOptions, + UpdateUserChanges, + CreateUserInput, +} from './types.js'; +import { + listBmUsers as ocapiListBmUsers, + getBmUser as ocapiGetBmUser, + updateBmUser as ocapiUpdateBmUser, + deleteBmUser as ocapiDeleteBmUser, + type BmUser, +} from './users.js'; +import {getApiErrorMessage} from '../../clients/error-utils.js'; +import type {components} from '../../clients/ocapi.generated.js'; + +function mapOcapiUser(ocapi: BmUser): UserInfo { + return { + login: ocapi.login ?? '', + email: ocapi.email, + firstName: ocapi.first_name, + lastName: ocapi.last_name, + externalId: ocapi.external_id, + disabled: ocapi.disabled, + locked: ocapi.locked, + lastLoginDate: ocapi.last_login_date, + passwordExpirationDate: ocapi.password_expiration_date, + passwordModificationDate: ocapi.password_modification_date, + preferredDataLocale: ocapi.preferred_data_locale, + preferredUiLocale: ocapi.preferred_ui_locale, + roles: ocapi.roles, + _raw: ocapi, + }; +} + +export class OcapiUsersBackend implements UsersBackend { + readonly name = 'ocapi' as const; + + constructor(private instance: B2CInstance) {} + + async listUsers(options: ListUsersOptions = {}): Promise { + const result = await ocapiListBmUsers(this.instance, options); + const users = (result.data ?? []) as BmUser[]; + return { + total: result.total ?? 0, + start: result.start ?? 0, + count: result.count ?? users.length, + hits: users.map(mapOcapiUser), + }; + } + + async getUser(login: string): Promise { + const user = await ocapiGetBmUser(this.instance, login); + return mapOcapiUser(user); + } + + async createOrReplaceUser(login: string, input: CreateUserInput): Promise { + // Map canonical camelCase → OCAPI snake_case. + const body: Record = { + login: input.login, + email: input.email, + first_name: input.firstName, + last_name: input.lastName, + external_id: input.externalId, + password: input.password, + disabled: input.disabled, + preferred_data_locale: input.preferredDataLocale, + preferred_ui_locale: input.preferredUiLocale, + roles: input.roles, + }; + const {data, error, response} = await this.instance.ocapi.PUT('/users/{login}', { + params: {path: {login}}, + body: body as components['schemas']['user'], + }); + if (error) { + throw new Error(`Failed to create user ${login}: ${getApiErrorMessage(error, response)}`); + } + return mapOcapiUser(data as BmUser); + } + + async updateUser(login: string, changes: UpdateUserChanges): Promise { + const ocapiChanges: Record = { + email: changes.email, + first_name: changes.firstName, + last_name: changes.lastName, + external_id: changes.externalId, + disabled: changes.disabled, + preferred_data_locale: changes.preferredDataLocale, + preferred_ui_locale: changes.preferredUiLocale, + }; + const updated = await ocapiUpdateBmUser(this.instance, login, ocapiChanges); + return mapOcapiUser(updated); + } + + async deleteUser(login: string): Promise { + await ocapiDeleteBmUser(this.instance, login); + } +} diff --git a/packages/b2c-tooling-sdk/src/operations/bm-users/scapi-backend.ts b/packages/b2c-tooling-sdk/src/operations/bm-users/scapi-backend.ts new file mode 100644 index 000000000..84ad3b244 --- /dev/null +++ b/packages/b2c-tooling-sdk/src/operations/bm-users/scapi-backend.ts @@ -0,0 +1,177 @@ +/* + * 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 type {AuthStrategy} from '../../auth/types.js'; +import type { + UsersBackend, + UserInfo, + ListUsersResult, + ListUsersOptions, + UpdateUserChanges, + CreateUserInput, +} from './types.js'; +import { + createScapiMerchantUsersClient, + SCAPI_MERCHANT_USERS_RW_SCOPES, + SCAPI_MERCHANT_USERS_READ_SCOPES, + type ScapiMerchantUsersClient, + type ScapiMerchantUsersClientConfig, + type User as ScapiUser, + type UserUpdateRequest, + type UserSearch, +} from '../../clients/scapi-merchant-users.js'; +import {buildTenantScope, toOrganizationId} from '../../clients/custom-apis.js'; +import {ScopeTierManager} from '../../clients/scapi-scope-tier.js'; + +function mapScapiUser(scapi: ScapiUser): UserInfo { + return { + login: scapi.login, + email: scapi.email, + firstName: scapi.firstName, + lastName: scapi.lastName, + externalId: scapi.externalId, + disabled: scapi.disabled, + locked: scapi.locked, + lastLoginDate: scapi.lastLoginDate, + passwordExpirationDate: scapi.passwordExpirationDate, + passwordModificationDate: scapi.passwordModificationDate, + preferredDataLocale: scapi.preferredDataLocale as string | undefined, + preferredUiLocale: scapi.preferredUiLocale as string | undefined, + roles: scapi.roles, + _raw: scapi, + }; +} + +export interface ScapiUsersBackendConfig { + shortCode: string; + tenantId: string; + auth: AuthStrategy; +} + +export class ScapiUsersBackend implements UsersBackend { + readonly name = 'scapi' as const; + + private organizationId: string; + private scopeTier: ScopeTierManager; + + constructor(private config: ScapiUsersBackendConfig) { + this.organizationId = toOrganizationId(config.tenantId); + this.scopeTier = new ScopeTierManager({ + buildClient: (scopes) => this.buildClient(scopes), + rwScopes: SCAPI_MERCHANT_USERS_RW_SCOPES, + readScopes: SCAPI_MERCHANT_USERS_READ_SCOPES, + domainName: 'Users', + }); + } + + async listUsers(options: ListUsersOptions = {}): Promise { + const client = this.scopeTier.getClientForRead(); + const {start = 0, count = 25} = options; + + const {data, error} = await client.GET('/organizations/{organizationId}/users', { + params: { + path: {organizationId: this.organizationId}, + query: {limit: count, offset: start}, + }, + }); + if (error || !data) { + throw new Error(toErrorMessage(error, 'Failed to list users')); + } + const result = data as UserSearch; + return { + total: result.total ?? 0, + start: result.offset ?? start, + count: result.limit ?? count, + hits: (result.data ?? []).map(mapScapiUser), + }; + } + + async getUser(login: string): Promise { + const client = this.scopeTier.getClientForRead(); + const {data, error} = await client.GET('/organizations/{organizationId}/users/{login}', { + params: {path: {organizationId: this.organizationId, login}}, + }); + if (error || !data) { + throw new Error(toErrorMessage(error, `Failed to get user ${login}`)); + } + return mapScapiUser(data); + } + + async createOrReplaceUser(login: string, input: CreateUserInput): Promise { + const client = this.scopeTier.getClientForWrite(); + const body: ScapiUser = { + login: input.login, + email: input.email, + firstName: input.firstName, + lastName: input.lastName, + externalId: input.externalId, + password: input.password, + disabled: input.disabled, + preferredDataLocale: input.preferredDataLocale, + preferredUiLocale: input.preferredUiLocale, + roles: input.roles, + }; + const {data, error} = await client.PUT('/organizations/{organizationId}/users/{login}', { + params: {path: {organizationId: this.organizationId, login}}, + body, + }); + if (error || !data) { + throw new Error(toErrorMessage(error, `Failed to create user ${login}`)); + } + return mapScapiUser(data); + } + + async updateUser(login: string, changes: UpdateUserChanges): Promise { + const client = this.scopeTier.getClientForWrite(); + // SCAPI UserUpdateRequest doesn't include `disabled`. To toggle disabled, + // callers must use createOrReplaceUser (PUT) on SCAPI or the OCAPI backend. + const body: UserUpdateRequest = { + email: changes.email, + firstName: changes.firstName, + lastName: changes.lastName, + externalId: changes.externalId, + preferredDataLocale: changes.preferredDataLocale, + preferredUiLocale: changes.preferredUiLocale, + }; + if (changes.disabled !== undefined) { + throw new Error( + 'SCAPI Users API does not support updating the `disabled` flag via PATCH. ' + + 'Use --api-backend ocapi to change disabled status.', + ); + } + const {data, error} = await client.PATCH('/organizations/{organizationId}/users/{login}', { + params: {path: {organizationId: this.organizationId, login}}, + body, + }); + if (error || !data) { + throw new Error(toErrorMessage(error, `Failed to update user ${login}`)); + } + return mapScapiUser(data); + } + + async deleteUser(login: string): Promise { + const client = this.scopeTier.getClientForWrite(); + const {error} = await client.DELETE('/organizations/{organizationId}/users/{login}', { + params: {path: {organizationId: this.organizationId, login}}, + }); + if (error) { + throw new Error(toErrorMessage(error, `Failed to delete user ${login}`)); + } + } + + private buildClient(scopes: string[]): ScapiMerchantUsersClient { + const clientConfig: ScapiMerchantUsersClientConfig = { + shortCode: this.config.shortCode, + tenantId: this.config.tenantId, + scopes: [...scopes, buildTenantScope(this.config.tenantId)], + }; + return createScapiMerchantUsersClient(clientConfig, this.config.auth); + } +} + +function toErrorMessage(error: unknown, fallback: string): string { + const e = error as {detail?: string; title?: string} | undefined; + return e?.detail ?? e?.title ?? fallback; +} diff --git a/packages/b2c-tooling-sdk/src/operations/bm-users/types.ts b/packages/b2c-tooling-sdk/src/operations/bm-users/types.ts new file mode 100644 index 000000000..2948fdd16 --- /dev/null +++ b/packages/b2c-tooling-sdk/src/operations/bm-users/types.ts @@ -0,0 +1,97 @@ +/* + * 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 + */ +/** + * Canonical types and backend interface for Business Manager user operations. + * + * Both the OCAPI Data API (`/users`) and the SCAPI Merchant Users API + * (`merchant/users/v1`) manage instance-level users on a B2C Commerce + * instance. We expose a single canonical shape (camelCase, matching SCAPI) + * so command code is agnostic to which backend serves the request. + * + * @module operations/bm-users/types + */ +import type {BackendBase} from '../../clients/scapi-backend-utils.js'; + +/** + * Canonical Business Manager user. CamelCase fields match SCAPI; OCAPI + * mapping converts from snake_case. + */ +export interface UserInfo { + login: string; + email?: string; + firstName?: string; + lastName?: string; + externalId?: string; + disabled?: boolean; + locked?: boolean; + lastLoginDate?: string; + passwordExpirationDate?: string; + passwordModificationDate?: string; + preferredDataLocale?: string; + preferredUiLocale?: string; + roles?: string[]; + /** Original backend response, for advanced consumers. */ + _raw?: unknown; +} + +/** + * Patch fields. SCAPI uses camelCase; OCAPI backend translates to snake_case. + */ +export interface UpdateUserChanges { + email?: string; + firstName?: string; + lastName?: string; + externalId?: string; + disabled?: boolean; + preferredDataLocale?: string; + preferredUiLocale?: string; +} + +/** + * Result of listing users — paginated. + */ +export interface ListUsersResult { + total: number; + start: number; + count: number; + hits: UserInfo[]; +} + +export interface ListUsersOptions { + start?: number; + count?: number; +} + +/** + * Body for create/replace (PUT). Required: login. + */ +export interface CreateUserInput { + login: string; + email: string; + firstName?: string; + lastName?: string; + externalId?: string; + password?: string; + disabled?: boolean; + preferredDataLocale?: string; + preferredUiLocale?: string; + roles?: string[]; +} + +/** + * Backend contract for BM user operations. + * + * Note: search and access-key operations remain OCAPI-only — they have + * no SCAPI equivalent in `merchant/users/v1`. The `whoami` operation is + * also OCAPI-only (resolves the BM identity behind the OAuth token). + */ +export interface UsersBackend extends BackendBase { + listUsers(options?: ListUsersOptions): Promise; + getUser(login: string): Promise; + createOrReplaceUser(login: string, input: CreateUserInput): Promise; + updateUser(login: string, changes: UpdateUserChanges): Promise; + deleteUser(login: string): Promise; +} From d46e734384f846d813bd6c49dd825cb78760d6f1 Mon Sep 17 00:00:00 2001 From: Charles Lavery Date: Fri, 8 May 2026 13:58:11 -0400 Subject: [PATCH 05/22] Add SCAPI Merchant Roles support with backend abstraction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migrates bm roles list/get/create/delete/grant/revoke and bm roles permissions get/set commands to the dual-backend pattern. BmCommand now exposes createRolesBackend() alongside createUsersBackend(). - New SCAPI Merchant Roles client (merchant/roles/v1) - RolesBackend with canonical RoleInfo and RolePermissionsInfo (camelCase; OCAPI mapping converts snake_case fields like locale_id → localeId) - Permissions display updated to use canonical camelCase fields - Bm roles get --expand users continues to work via the _raw escape hatch (handles both OCAPI snake_case and SCAPI camelCase user fields) --- .../b2c-cli/src/commands/bm/roles/create.ts | 13 +- .../b2c-cli/src/commands/bm/roles/delete.ts | 10 +- packages/b2c-cli/src/commands/bm/roles/get.ts | 41 +- .../b2c-cli/src/commands/bm/roles/grant.ts | 26 +- .../b2c-cli/src/commands/bm/roles/list.ts | 37 +- .../src/commands/bm/roles/permissions/get.ts | 19 +- .../src/commands/bm/roles/permissions/set.ts | 17 +- .../b2c-cli/src/commands/bm/roles/revoke.ts | 10 +- .../b2c-cli/src/commands/code/activate.ts | 2 +- .../test/commands/bm/roles/create.test.ts | 42 +- .../test/commands/bm/roles/delete.test.ts | 47 +- .../test/commands/bm/roles/get.test.ts | 43 +- .../test/commands/bm/roles/grant.test.ts | 45 +- .../test/commands/bm/roles/list.test.ts | 48 +- .../test/commands/bm/roles/revoke.test.ts | 41 +- packages/b2c-tooling-sdk/package.json | 2 +- .../specs/merchant-roles-v1.yaml | 1085 +++++++++++++++++ .../b2c-tooling-sdk/src/cli/bm-command.ts | 15 + packages/b2c-tooling-sdk/src/clients/index.ts | 15 + .../src/clients/middleware-registry.ts | 3 +- .../clients/scapi-merchant-roles.generated.ts | 726 +++++++++++ .../src/clients/scapi-merchant-roles.ts | 57 + packages/b2c-tooling-sdk/src/index.ts | 18 + .../src/operations/bm-roles/backend.ts | 91 ++ .../src/operations/bm-roles/index.ts | 15 + .../src/operations/bm-roles/ocapi-backend.ts | 168 +++ .../src/operations/bm-roles/scapi-backend.ts | 178 +++ .../src/operations/bm-roles/types.ts | 58 + 28 files changed, 2695 insertions(+), 177 deletions(-) create mode 100644 packages/b2c-tooling-sdk/specs/merchant-roles-v1.yaml create mode 100644 packages/b2c-tooling-sdk/src/clients/scapi-merchant-roles.generated.ts create mode 100644 packages/b2c-tooling-sdk/src/clients/scapi-merchant-roles.ts create mode 100644 packages/b2c-tooling-sdk/src/operations/bm-roles/backend.ts create mode 100644 packages/b2c-tooling-sdk/src/operations/bm-roles/ocapi-backend.ts create mode 100644 packages/b2c-tooling-sdk/src/operations/bm-roles/scapi-backend.ts create mode 100644 packages/b2c-tooling-sdk/src/operations/bm-roles/types.ts diff --git a/packages/b2c-cli/src/commands/bm/roles/create.ts b/packages/b2c-cli/src/commands/bm/roles/create.ts index 14682eee9..d604531d3 100644 --- a/packages/b2c-cli/src/commands/bm/roles/create.ts +++ b/packages/b2c-cli/src/commands/bm/roles/create.ts @@ -4,11 +4,11 @@ * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 */ import {Args, Flags} from '@oclif/core'; -import {InstanceCommand} from '@salesforce/b2c-tooling-sdk/cli'; -import {createBmRole, type BmRole} from '@salesforce/b2c-tooling-sdk/operations/bm-roles'; +import {BmCommand} from '@salesforce/b2c-tooling-sdk/cli'; +import {type RoleInfo} from '@salesforce/b2c-tooling-sdk/operations/bm-roles'; import {t} from '../../../i18n/index.js'; -export default class BmRolesCreate extends InstanceCommand { +export default class BmRolesCreate extends BmCommand { static args = { role: Args.string({ description: 'Role ID to create', @@ -33,16 +33,19 @@ export default class BmRolesCreate extends InstanceCommand }), }; - async run(): Promise { + async run(): Promise { this.requireOAuthCredentials(); const {role: roleId} = this.args; const {description} = this.flags; const hostname = this.resolvedConfig.values.hostname!; + const backend = this.createRolesBackend(); + this.logger.debug(`Using ${backend.name} backend for roles create`); + this.log(t('commands.bm.roles.create.creating', 'Creating role {{roleId}} on {{hostname}}...', {roleId, hostname})); - const role = await createBmRole(this.instance, roleId, {description}); + const role = await backend.createRole(roleId, {description}); if (this.jsonEnabled()) { return role; diff --git a/packages/b2c-cli/src/commands/bm/roles/delete.ts b/packages/b2c-cli/src/commands/bm/roles/delete.ts index 163e9b51b..b59eb524b 100644 --- a/packages/b2c-cli/src/commands/bm/roles/delete.ts +++ b/packages/b2c-cli/src/commands/bm/roles/delete.ts @@ -4,8 +4,7 @@ * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 */ import {Args} from '@oclif/core'; -import {InstanceCommand} from '@salesforce/b2c-tooling-sdk/cli'; -import {deleteBmRole} from '@salesforce/b2c-tooling-sdk/operations/bm-roles'; +import {BmCommand} from '@salesforce/b2c-tooling-sdk/cli'; import {t} from '../../../i18n/index.js'; interface DeleteResult { @@ -14,7 +13,7 @@ interface DeleteResult { hostname: string; } -export default class BmRolesDelete extends InstanceCommand { +export default class BmRolesDelete extends BmCommand { static args = { role: Args.string({ description: 'Role ID to delete', @@ -37,11 +36,14 @@ export default class BmRolesDelete extends InstanceCommand const {role: roleId} = this.args; const hostname = this.resolvedConfig.values.hostname!; + const backend = this.createRolesBackend(); + this.logger.debug(`Using ${backend.name} backend for roles delete`); + this.log( t('commands.bm.roles.delete.deleting', 'Deleting role {{roleId}} from {{hostname}}...', {roleId, hostname}), ); - await deleteBmRole(this.instance, roleId); + await backend.deleteRole(roleId); const result = {success: true, role: roleId, hostname}; diff --git a/packages/b2c-cli/src/commands/bm/roles/get.ts b/packages/b2c-cli/src/commands/bm/roles/get.ts index 74a9eee5e..b7b55c383 100644 --- a/packages/b2c-cli/src/commands/bm/roles/get.ts +++ b/packages/b2c-cli/src/commands/bm/roles/get.ts @@ -4,11 +4,19 @@ * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 */ import {Args, Flags} from '@oclif/core'; -import {InstanceCommand, printFieldsBlock, type DetailSection} from '@salesforce/b2c-tooling-sdk/cli'; -import {getBmRole, type BmRole} from '@salesforce/b2c-tooling-sdk/operations/bm-roles'; +import {BmCommand, printFieldsBlock, type DetailSection} from '@salesforce/b2c-tooling-sdk/cli'; +import {type RoleInfo} from '@salesforce/b2c-tooling-sdk/operations/bm-roles'; import {t} from '../../../i18n/index.js'; -export default class BmRolesGet extends InstanceCommand { +interface ExpandedUser { + login?: string; + first_name?: string; + last_name?: string; + firstName?: string; + lastName?: string; +} + +export default class BmRolesGet extends BmCommand { static args = { role: Args.string({ description: 'Role ID (e.g. "Administrator")', @@ -29,33 +37,42 @@ export default class BmRolesGet extends InstanceCommand { static flags = { expand: Flags.string({ char: 'e', - description: 'Expansions to apply (e.g. users, permissions)', + description: 'Expansions to apply (users, permissions)', multiple: true, + options: ['users', 'permissions'], }), }; - async run(): Promise { + async run(): Promise { this.requireOAuthCredentials(); const {role: roleId} = this.args; const {expand} = this.flags; const hostname = this.resolvedConfig.values.hostname!; + const backend = this.createRolesBackend(); + this.logger.debug(`Using ${backend.name} backend for roles get`); + this.log(t('commands.bm.roles.get.fetching', 'Fetching role {{roleId}} from {{hostname}}...', {roleId, hostname})); - const role = await getBmRole(this.instance, roleId, {expand}); + const role = await backend.getRole(roleId, {expand: expand as ('permissions' | 'users')[] | undefined}); if (this.jsonEnabled()) { return role; } const sections: DetailSection[] = []; - if (role.users && role.users.length > 0) { + // Users may be present on _raw (both OCAPI and SCAPI return them under role.users when --expand users). + const raw = role._raw as undefined | {users?: ExpandedUser[]}; + const users = raw?.users; + if (users && users.length > 0) { sections.push({ title: 'Assigned Users', - lines: role.users.map((user) => { + lines: users.map((user) => { const login = user.login || '-'; - const name = [user.first_name, user.last_name].filter(Boolean).join(' '); + const first = user.firstName ?? user.first_name; + const last = user.lastName ?? user.last_name; + const name = [first, last].filter(Boolean).join(' '); return name ? `${login} ${name}` : login; }), }); @@ -66,10 +83,8 @@ export default class BmRolesGet extends InstanceCommand { [ ['ID', role.id], ['Description', role.description], - ['User Count', role.user_count?.toString()], - ['User Manager', role.user_manager?.toString()], - ['Created', role.creation_date], - ['Last Modified', role.last_modified], + ['User Count', role.userCount?.toString()], + ['User Manager', role.userManager?.toString()], ], {sections}, ); diff --git a/packages/b2c-cli/src/commands/bm/roles/grant.ts b/packages/b2c-cli/src/commands/bm/roles/grant.ts index 95a0434f8..b00d0dfff 100644 --- a/packages/b2c-cli/src/commands/bm/roles/grant.ts +++ b/packages/b2c-cli/src/commands/bm/roles/grant.ts @@ -4,14 +4,17 @@ * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 */ import {Args, Flags} from '@oclif/core'; -import {InstanceCommand} from '@salesforce/b2c-tooling-sdk/cli'; -import {grantBmRole} from '@salesforce/b2c-tooling-sdk/operations/bm-roles'; -import type {OcapiComponents} from '@salesforce/b2c-tooling-sdk'; +import {BmCommand} from '@salesforce/b2c-tooling-sdk/cli'; import {t} from '../../../i18n/index.js'; -type OcapiUser = OcapiComponents['schemas']['user']; +interface GrantResult { + success: boolean; + role: string; + login: string; + hostname: string; +} -export default class BmRolesGrant extends InstanceCommand { +export default class BmRolesGrant extends BmCommand { static args = { login: Args.string({ description: 'User login (email)', @@ -39,13 +42,16 @@ export default class BmRolesGrant extends InstanceCommand { }), }; - async run(): Promise { + async run(): Promise { this.requireOAuthCredentials(); const {login} = this.args; const {role} = this.flags; const hostname = this.resolvedConfig.values.hostname!; + const backend = this.createRolesBackend(); + this.logger.debug(`Using ${backend.name} backend for roles grant`); + this.log( t('commands.bm.roles.grant.granting', 'Granting role {{role}} to {{login}} on {{hostname}}...', { role, @@ -54,10 +60,12 @@ export default class BmRolesGrant extends InstanceCommand { }), ); - const user = await grantBmRole(this.instance, role, login); + await backend.grantRole(role, login); + + const result: GrantResult = {success: true, role, login, hostname}; if (this.jsonEnabled()) { - return user; + return result; } this.log( @@ -68,6 +76,6 @@ export default class BmRolesGrant extends InstanceCommand { }), ); - return user; + return result; } } diff --git a/packages/b2c-cli/src/commands/bm/roles/list.ts b/packages/b2c-cli/src/commands/bm/roles/list.ts index 680541dff..6cc709740 100644 --- a/packages/b2c-cli/src/commands/bm/roles/list.ts +++ b/packages/b2c-cli/src/commands/bm/roles/list.ts @@ -4,17 +4,11 @@ * 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 { - InstanceCommand, - TableRenderer, - columnFlagsFor, - selectColumns, - type ColumnDef, -} from '@salesforce/b2c-tooling-sdk/cli'; -import {listBmRoles, type BmRole, type BmRoles} from '@salesforce/b2c-tooling-sdk/operations/bm-roles'; +import {BmCommand, TableRenderer, columnFlagsFor, selectColumns, type ColumnDef} from '@salesforce/b2c-tooling-sdk/cli'; +import {type RoleInfo, type ListRolesResult} from '@salesforce/b2c-tooling-sdk/operations/bm-roles'; import {t} from '../../../i18n/index.js'; -const COLUMNS: Record> = { +const COLUMNS: Record> = { id: { header: 'ID', get: (r) => r.id || '-', @@ -26,11 +20,11 @@ const COLUMNS: Record> = { }, userCount: { header: 'Users', - get: (r) => r.user_count?.toString() ?? '-', + get: (r) => r.userCount?.toString() ?? '-', }, userManager: { header: 'User Manager', - get: (r) => (r.user_manager ? 'Yes' : 'No'), + get: (r) => (r.userManager ? 'Yes' : 'No'), extended: true, }, }; @@ -39,7 +33,7 @@ const DEFAULT_COLUMNS = ['id', 'userCount']; const tableRenderer = new TableRenderer(COLUMNS); -export default class BmRolesList extends InstanceCommand { +export default class BmRolesList extends BmCommand { static description = t('commands.bm.roles.list.description', 'List Business Manager access roles on an instance'); static enableJsonFlag = true; @@ -64,37 +58,40 @@ export default class BmRolesList extends InstanceCommand { ...columnFlagsFor(COLUMNS), }; - async run(): Promise { + async run(): Promise { this.requireOAuthCredentials(); const hostname = this.resolvedConfig.values.hostname!; const {count, start} = this.flags; + const backend = this.createRolesBackend(); + this.logger.debug(`Using ${backend.name} backend for roles list`); + this.log(t('commands.bm.roles.list.fetching', 'Fetching roles from {{hostname}}...', {hostname})); - const roles = await listBmRoles(this.instance, {count, start}); + const result = await backend.listRoles({count, start}); if (this.jsonEnabled()) { - return roles; + return result; } - const items = roles.data ?? []; + const items = result.hits; if (items.length === 0) { this.log(t('commands.bm.roles.list.noRoles', 'No roles found.')); - return roles; + return result; } tableRenderer.render(items, selectColumns(this.flags, tableRenderer, DEFAULT_COLUMNS, this.warn.bind(this))); - if (roles.total && roles.total > items.length) { + if (result.total && result.total > items.length) { this.log( t('commands.bm.roles.list.moreRoles', '{{count}} of {{total}} roles shown.', { count: items.length, - total: roles.total, + total: result.total, }), ); } - return roles; + return result; } } diff --git a/packages/b2c-cli/src/commands/bm/roles/permissions/get.ts b/packages/b2c-cli/src/commands/bm/roles/permissions/get.ts index 544736549..7eaa9d68f 100644 --- a/packages/b2c-cli/src/commands/bm/roles/permissions/get.ts +++ b/packages/b2c-cli/src/commands/bm/roles/permissions/get.ts @@ -6,11 +6,11 @@ import fs from 'node:fs'; import {Args, Flags, ux} from '@oclif/core'; import cliui from 'cliui'; -import {InstanceCommand} from '@salesforce/b2c-tooling-sdk/cli'; -import {getBmRolePermissions, type BmRolePermissions} from '@salesforce/b2c-tooling-sdk/operations/bm-roles'; +import {BmCommand} from '@salesforce/b2c-tooling-sdk/cli'; +import {type RolePermissionsInfo} from '@salesforce/b2c-tooling-sdk/operations/bm-roles'; import {t} from '../../../../i18n/index.js'; -export default class BmRolesPermissionsGet extends InstanceCommand { +export default class BmRolesPermissionsGet extends BmCommand { static args = { role: Args.string({ description: 'Role ID (e.g. "Administrator")', @@ -38,13 +38,16 @@ export default class BmRolesPermissionsGet extends InstanceCommand { + async run(): Promise { this.requireOAuthCredentials(); const {role: roleId} = this.args; const {output} = this.flags; const hostname = this.resolvedConfig.values.hostname!; + const backend = this.createRolesBackend(); + this.logger.debug(`Using ${backend.name} backend for roles permissions get`); + this.log( t('commands.bm.roles.permissions.get.fetching', 'Fetching permissions for role {{roleId}} on {{hostname}}...', { roleId, @@ -52,7 +55,7 @@ export default class BmRolesPermissionsGet extends InstanceCommand p.name)], ['Functional (site)', functionalSite.length, functionalSite.map((p) => p.name)], ['Module (organization)', moduleOrg.length, moduleOrg.map((p) => `${p.application}:${p.name}`)], ['Module (site)', moduleSite.length, moduleSite.map((p) => `${p.application}:${p.name}`)], - ['Locale', localeUnscoped.length, localeUnscoped.map((p) => p.locale_id)], + ['Locale', localeUnscoped.length, localeUnscoped.map((p) => p.localeId)], ['WebDAV', webdavUnscoped.length, webdavUnscoped.map((p) => p.folder)], ]; diff --git a/packages/b2c-cli/src/commands/bm/roles/permissions/set.ts b/packages/b2c-cli/src/commands/bm/roles/permissions/set.ts index adc4d8b40..51331b7af 100644 --- a/packages/b2c-cli/src/commands/bm/roles/permissions/set.ts +++ b/packages/b2c-cli/src/commands/bm/roles/permissions/set.ts @@ -5,11 +5,11 @@ */ import fs from 'node:fs'; import {Args, Flags} from '@oclif/core'; -import {InstanceCommand} from '@salesforce/b2c-tooling-sdk/cli'; -import {setBmRolePermissions, type BmRolePermissions} from '@salesforce/b2c-tooling-sdk/operations/bm-roles'; +import {BmCommand} from '@salesforce/b2c-tooling-sdk/cli'; +import {type RolePermissionsInfo} from '@salesforce/b2c-tooling-sdk/operations/bm-roles'; import {t} from '../../../../i18n/index.js'; -export default class BmRolesPermissionsSet extends InstanceCommand { +export default class BmRolesPermissionsSet extends BmCommand { static args = { role: Args.string({ description: 'Role ID (e.g. "Administrator")', @@ -34,7 +34,7 @@ export default class BmRolesPermissionsSet extends InstanceCommand { + async run(): Promise { this.requireOAuthCredentials(); const {role: roleId} = this.args; @@ -45,14 +45,17 @@ export default class BmRolesPermissionsSet extends InstanceCommand { +export default class BmRolesRevoke extends BmCommand { static args = { login: Args.string({ description: 'User login (email)', @@ -50,6 +49,9 @@ export default class BmRolesRevoke extends InstanceCommand const {role} = this.flags; const hostname = this.resolvedConfig.values.hostname!; + const backend = this.createRolesBackend(); + this.logger.debug(`Using ${backend.name} backend for roles revoke`); + this.log( t('commands.bm.roles.revoke.revoking', 'Revoking role {{role}} from {{login}} on {{hostname}}...', { role, @@ -58,7 +60,7 @@ export default class BmRolesRevoke extends InstanceCommand }), ); - await revokeBmRole(this.instance, role, login); + await backend.revokeRole(role, login); const result = {success: true, role, login, hostname}; diff --git a/packages/b2c-cli/src/commands/code/activate.ts b/packages/b2c-cli/src/commands/code/activate.ts index f126f2b05..f51b8b8e6 100644 --- a/packages/b2c-cli/src/commands/code/activate.ts +++ b/packages/b2c-cli/src/commands/code/activate.ts @@ -92,7 +92,7 @@ export default class CodeActivate extends CodeCommand { ); try { - await backend.activateCodeVersion(codeVersion); + await backend.activateCodeVersion(codeVersion!); this.log( t('commands.code.activate.activated', 'Code version {{codeVersion}} activated successfully', {codeVersion}), ); diff --git a/packages/b2c-cli/test/commands/bm/roles/create.test.ts b/packages/b2c-cli/test/commands/bm/roles/create.test.ts index 0e2bcc83b..fccb808ba 100644 --- a/packages/b2c-cli/test/commands/bm/roles/create.test.ts +++ b/packages/b2c-cli/test/commands/bm/roles/create.test.ts @@ -21,32 +21,47 @@ describe('bm roles create', () => { return createTestCommand(BmRolesCreate, hooks.getConfig(), flags, args); } + function createMockBackend() { + return { + name: 'ocapi' as const, + listRoles: sinon.stub(), + getRole: sinon.stub(), + createRole: sinon.stub(), + deleteRole: sinon.stub(), + getPermissions: sinon.stub(), + setPermissions: sinon.stub(), + grantRole: sinon.stub(), + revokeRole: sinon.stub(), + }; + } + function stubCommon(command: any, {jsonEnabled}: {jsonEnabled: boolean}) { sinon.stub(command, 'requireOAuthCredentials').returns(void 0); sinon.stub(command, 'resolvedConfig').get(() => ({values: {hostname: 'example.com'}})); + sinon.stub(command, 'instance').get(() => ({config: {hostname: 'example.com'}})); sinon.stub(command, 'jsonEnabled').returns(jsonEnabled); + const backend = createMockBackend(); + sinon.stub(command, 'createRolesBackend').returns(backend); + return backend; } it('creates role and returns in JSON mode', async () => { const command: any = await createCommand({description: 'Test role'}, {role: 'TestRole'}); - stubCommon(command, {jsonEnabled: true}); + const backend = stubCommon(command, {jsonEnabled: true}); - const mockRole = {id: 'TestRole', description: 'Test role'}; - const ocapiPut = sinon.stub().resolves({data: mockRole, error: undefined}); - sinon.stub(command, 'instance').get(() => ({ocapi: {PUT: ocapiPut}})); + backend.createRole.resolves({id: 'TestRole', description: 'Test role'}); const result = await command.run(); expect(result.id).to.equal('TestRole'); - expect(ocapiPut.calledOnce).to.equal(true); + expect(backend.createRole.calledOnce).to.equal(true); }); it('logs success in non-JSON mode', async () => { const command: any = await createCommand({}, {role: 'TestRole'}); - stubCommon(command, {jsonEnabled: false}); + const backend = stubCommon(command, {jsonEnabled: false}); const logStub = sinon.stub(command, 'log').returns(void 0); - const ocapiPut = sinon.stub().resolves({data: {id: 'TestRole'}, error: undefined}); - sinon.stub(command, 'instance').get(() => ({ocapi: {PUT: ocapiPut}})); + backend.createRole.resolves({id: 'TestRole'}); await command.run(); expect(logStub.calledWith(sinon.match('TestRole'))).to.equal(true); @@ -54,15 +69,10 @@ describe('bm roles create', () => { it('throws on 403 for reserved roles', async () => { const command: any = await createCommand({}, {role: 'Support'}); - sinon.stub(command, 'requireOAuthCredentials').returns(void 0); - sinon.stub(command, 'resolvedConfig').get(() => ({values: {hostname: 'example.com'}})); + const backend = stubCommon(command, {jsonEnabled: false}); + sinon.stub(command, 'log').returns(void 0); - const ocapiPut = sinon.stub().resolves({ - data: undefined, - error: {fault: {message: 'Operation not allowed'}}, - response: {status: 403, statusText: 'Forbidden'}, - }); - sinon.stub(command, 'instance').get(() => ({ocapi: {PUT: ocapiPut}})); + backend.createRole.rejects(new Error('Failed to create role Support: Operation not allowed')); try { await command.run(); diff --git a/packages/b2c-cli/test/commands/bm/roles/delete.test.ts b/packages/b2c-cli/test/commands/bm/roles/delete.test.ts index 18521ad9a..8d2402eb1 100644 --- a/packages/b2c-cli/test/commands/bm/roles/delete.test.ts +++ b/packages/b2c-cli/test/commands/bm/roles/delete.test.ts @@ -21,36 +21,48 @@ describe('bm roles delete', () => { return createTestCommand(BmRolesDelete, hooks.getConfig(), flags, args); } + function createMockBackend() { + return { + name: 'ocapi' as const, + listRoles: sinon.stub(), + getRole: sinon.stub(), + createRole: sinon.stub(), + deleteRole: sinon.stub(), + getPermissions: sinon.stub(), + setPermissions: sinon.stub(), + grantRole: sinon.stub(), + revokeRole: sinon.stub(), + }; + } + function stubCommon(command: any, {jsonEnabled}: {jsonEnabled: boolean}) { sinon.stub(command, 'requireOAuthCredentials').returns(void 0); sinon.stub(command, 'resolvedConfig').get(() => ({values: {hostname: 'example.com'}})); + sinon.stub(command, 'instance').get(() => ({config: {hostname: 'example.com'}})); sinon.stub(command, 'jsonEnabled').returns(jsonEnabled); + const backend = createMockBackend(); + sinon.stub(command, 'createRolesBackend').returns(backend); + return backend; } it('deletes role and returns result in JSON mode', async () => { const command: any = await createCommand({}, {role: 'TestRole'}); - stubCommon(command, {jsonEnabled: true}); + const backend = stubCommon(command, {jsonEnabled: true}); - const ocapiDelete = sinon.stub().resolves({data: undefined, error: undefined}); - sinon.stub(command, 'instance').get(() => ({ocapi: {DELETE: ocapiDelete}})); + backend.deleteRole.resolves(); const result = await command.run(); expect(result.success).to.equal(true); expect(result.role).to.equal('TestRole'); - expect(ocapiDelete.calledOnce).to.equal(true); + expect(backend.deleteRole.calledOnce).to.equal(true); }); it('throws on 403 for system roles', async () => { const command: any = await createCommand({}, {role: 'Administrator'}); - sinon.stub(command, 'requireOAuthCredentials').returns(void 0); - sinon.stub(command, 'resolvedConfig').get(() => ({values: {hostname: 'example.com'}})); + const backend = stubCommon(command, {jsonEnabled: false}); + sinon.stub(command, 'log').returns(void 0); - const ocapiDelete = sinon.stub().resolves({ - data: undefined, - error: {fault: {message: 'Deletion not allowed'}}, - response: {status: 403, statusText: 'Forbidden'}, - }); - sinon.stub(command, 'instance').get(() => ({ocapi: {DELETE: ocapiDelete}})); + backend.deleteRole.rejects(new Error('Failed to delete role Administrator: Deletion not allowed')); try { await command.run(); @@ -62,15 +74,10 @@ describe('bm roles delete', () => { it('throws on 404 for non-existent role', async () => { const command: any = await createCommand({}, {role: 'NoSuchRole'}); - sinon.stub(command, 'requireOAuthCredentials').returns(void 0); - sinon.stub(command, 'resolvedConfig').get(() => ({values: {hostname: 'example.com'}})); + const backend = stubCommon(command, {jsonEnabled: false}); + sinon.stub(command, 'log').returns(void 0); - const ocapiDelete = sinon.stub().resolves({ - data: undefined, - error: {fault: {message: 'Role not found'}}, - response: {status: 404, statusText: 'Not Found'}, - }); - sinon.stub(command, 'instance').get(() => ({ocapi: {DELETE: ocapiDelete}})); + backend.deleteRole.rejects(new Error('Failed to delete role NoSuchRole: Role not found')); try { await command.run(); diff --git a/packages/b2c-cli/test/commands/bm/roles/get.test.ts b/packages/b2c-cli/test/commands/bm/roles/get.test.ts index d9bd5a869..d979087b8 100644 --- a/packages/b2c-cli/test/commands/bm/roles/get.test.ts +++ b/packages/b2c-cli/test/commands/bm/roles/get.test.ts @@ -22,33 +22,47 @@ describe('bm roles get', () => { return createTestCommand(BmRolesGet, hooks.getConfig(), flags, args); } + function createMockBackend() { + return { + name: 'ocapi' as const, + listRoles: sinon.stub(), + getRole: sinon.stub(), + createRole: sinon.stub(), + deleteRole: sinon.stub(), + getPermissions: sinon.stub(), + setPermissions: sinon.stub(), + grantRole: sinon.stub(), + revokeRole: sinon.stub(), + }; + } + function stubCommon(command: any, {jsonEnabled}: {jsonEnabled: boolean}) { sinon.stub(command, 'requireOAuthCredentials').returns(void 0); sinon.stub(command, 'resolvedConfig').get(() => ({values: {hostname: 'example.com'}})); + sinon.stub(command, 'instance').get(() => ({config: {hostname: 'example.com'}})); sinon.stub(command, 'jsonEnabled').returns(jsonEnabled); + const backend = createMockBackend(); + sinon.stub(command, 'createRolesBackend').returns(backend); + return backend; } it('returns role details in JSON mode', async () => { const command: any = await createCommand({}, {role: 'Administrator'}); - stubCommon(command, {jsonEnabled: true}); + const backend = stubCommon(command, {jsonEnabled: true}); - const mockRole = {id: 'Administrator', description: 'Admin role', user_count: 5, user_manager: true}; - const ocapiGet = sinon.stub().resolves({data: mockRole, error: undefined}); - sinon.stub(command, 'instance').get(() => ({ocapi: {GET: ocapiGet}})); + backend.getRole.resolves({id: 'Administrator', description: 'Admin role', userCount: 5, userManager: true}); const result = await command.run(); expect(result.id).to.equal('Administrator'); - expect(result.user_count).to.equal(5); + expect(result.userCount).to.equal(5); }); it('displays role details in non-JSON mode', async () => { const command: any = await createCommand({}, {role: 'Administrator'}); - stubCommon(command, {jsonEnabled: false}); + const backend = stubCommon(command, {jsonEnabled: false}); sinon.stub(command, 'log').returns(void 0); - const mockRole = {id: 'Administrator', description: 'Admin role', user_count: 5}; - const ocapiGet = sinon.stub().resolves({data: mockRole, error: undefined}); - sinon.stub(command, 'instance').get(() => ({ocapi: {GET: ocapiGet}})); + backend.getRole.resolves({id: 'Administrator', description: 'Admin role', userCount: 5}); const stdoutStub = sinon.stub(ux, 'stdout').returns(void 0 as any); @@ -59,15 +73,10 @@ describe('bm roles get', () => { it('throws on 404', async () => { const command: any = await createCommand({}, {role: 'NonExistent'}); - sinon.stub(command, 'requireOAuthCredentials').returns(void 0); - sinon.stub(command, 'resolvedConfig').get(() => ({values: {hostname: 'example.com'}})); + const backend = stubCommon(command, {jsonEnabled: false}); + sinon.stub(command, 'log').returns(void 0); - const ocapiGet = sinon.stub().resolves({ - data: undefined, - error: {fault: {message: 'Role not found'}}, - response: {status: 404, statusText: 'Not Found'}, - }); - sinon.stub(command, 'instance').get(() => ({ocapi: {GET: ocapiGet}})); + backend.getRole.rejects(new Error('Failed to get role NonExistent: Role not found')); try { await command.run(); diff --git a/packages/b2c-cli/test/commands/bm/roles/grant.test.ts b/packages/b2c-cli/test/commands/bm/roles/grant.test.ts index 4f4bb5d96..5af64a475 100644 --- a/packages/b2c-cli/test/commands/bm/roles/grant.test.ts +++ b/packages/b2c-cli/test/commands/bm/roles/grant.test.ts @@ -21,32 +21,48 @@ describe('bm roles grant', () => { return createTestCommand(BmRolesGrant, hooks.getConfig(), flags, args); } + function createMockBackend() { + return { + name: 'ocapi' as const, + listRoles: sinon.stub(), + getRole: sinon.stub(), + createRole: sinon.stub(), + deleteRole: sinon.stub(), + getPermissions: sinon.stub(), + setPermissions: sinon.stub(), + grantRole: sinon.stub(), + revokeRole: sinon.stub(), + }; + } + function stubCommon(command: any, {jsonEnabled}: {jsonEnabled: boolean}) { sinon.stub(command, 'requireOAuthCredentials').returns(void 0); sinon.stub(command, 'resolvedConfig').get(() => ({values: {hostname: 'example.com'}})); + sinon.stub(command, 'instance').get(() => ({config: {hostname: 'example.com'}})); sinon.stub(command, 'jsonEnabled').returns(jsonEnabled); + const backend = createMockBackend(); + sinon.stub(command, 'createRolesBackend').returns(backend); + return backend; } - it('grants role and returns user in JSON mode', async () => { + it('grants role in JSON mode', async () => { const command: any = await createCommand({role: 'Administrator'}, {login: 'user@example.com'}); - stubCommon(command, {jsonEnabled: true}); + const backend = stubCommon(command, {jsonEnabled: true}); - const mockUser = {login: 'user@example.com', first_name: 'Test', last_name: 'User'}; - const ocapiPut = sinon.stub().resolves({data: mockUser, error: undefined}); - sinon.stub(command, 'instance').get(() => ({ocapi: {PUT: ocapiPut}})); + backend.grantRole.resolves(); const result = await command.run(); + expect(result.success).to.equal(true); expect(result.login).to.equal('user@example.com'); - expect(ocapiPut.calledOnce).to.equal(true); + expect(backend.grantRole.calledOnce).to.equal(true); }); it('logs success in non-JSON mode', async () => { const command: any = await createCommand({role: 'Administrator'}, {login: 'user@example.com'}); - stubCommon(command, {jsonEnabled: false}); + const backend = stubCommon(command, {jsonEnabled: false}); const logStub = sinon.stub(command, 'log').returns(void 0); - const ocapiPut = sinon.stub().resolves({data: {login: 'user@example.com'}, error: undefined}); - sinon.stub(command, 'instance').get(() => ({ocapi: {PUT: ocapiPut}})); + backend.grantRole.resolves(); await command.run(); expect(logStub.calledWith(sinon.match('user@example.com'))).to.equal(true); @@ -54,15 +70,10 @@ describe('bm roles grant', () => { it('throws on 400 for invalid role or user', async () => { const command: any = await createCommand({role: 'BadRole'}, {login: 'user@example.com'}); - sinon.stub(command, 'requireOAuthCredentials').returns(void 0); - sinon.stub(command, 'resolvedConfig').get(() => ({values: {hostname: 'example.com'}})); + const backend = stubCommon(command, {jsonEnabled: false}); + sinon.stub(command, 'log').returns(void 0); - const ocapiPut = sinon.stub().resolves({ - data: undefined, - error: {fault: {message: 'Invalid role'}}, - response: {status: 400, statusText: 'Bad Request'}, - }); - sinon.stub(command, 'instance').get(() => ({ocapi: {PUT: ocapiPut}})); + backend.grantRole.rejects(new Error('Failed to grant role BadRole to user@example.com: Invalid role')); try { await command.run(); diff --git a/packages/b2c-cli/test/commands/bm/roles/list.test.ts b/packages/b2c-cli/test/commands/bm/roles/list.test.ts index c7501dc3b..5953f1abd 100644 --- a/packages/b2c-cli/test/commands/bm/roles/list.test.ts +++ b/packages/b2c-cli/test/commands/bm/roles/list.test.ts @@ -21,49 +21,59 @@ describe('bm roles list', () => { return createTestCommand(BmRolesList, hooks.getConfig(), flags); } + function createMockBackend() { + return { + name: 'ocapi' as const, + listRoles: sinon.stub(), + getRole: sinon.stub(), + createRole: sinon.stub(), + deleteRole: sinon.stub(), + getPermissions: sinon.stub(), + setPermissions: sinon.stub(), + grantRole: sinon.stub(), + revokeRole: sinon.stub(), + }; + } + function stubCommon(command: any, {jsonEnabled}: {jsonEnabled: boolean}) { sinon.stub(command, 'requireOAuthCredentials').returns(void 0); sinon.stub(command, 'resolvedConfig').get(() => ({values: {hostname: 'example.com'}})); + sinon.stub(command, 'instance').get(() => ({config: {hostname: 'example.com'}})); sinon.stub(command, 'jsonEnabled').returns(jsonEnabled); + const backend = createMockBackend(); + sinon.stub(command, 'createRolesBackend').returns(backend); + return backend; } it('returns data in JSON mode', async () => { const command: any = await createCommand(); - stubCommon(command, {jsonEnabled: true}); + const backend = stubCommon(command, {jsonEnabled: true}); - const mockRoles = {count: 2, total: 2, data: [{id: 'Administrator'}, {id: 'Editor'}]}; - const ocapiGet = sinon.stub().resolves({data: mockRoles, error: undefined}); - sinon.stub(command, 'instance').get(() => ({ocapi: {GET: ocapiGet}})); + backend.listRoles.resolves({total: 2, start: 0, count: 2, hits: [{id: 'Administrator'}, {id: 'Editor'}]}); const result = await command.run(); expect(result.count).to.equal(2); - expect(result.data).to.have.length(2); - expect(ocapiGet.calledOnce).to.equal(true); + expect(result.hits).to.have.length(2); + expect(backend.listRoles.calledOnce).to.equal(true); }); it('prints "no roles" message when empty in non-JSON mode', async () => { const command: any = await createCommand(); - stubCommon(command, {jsonEnabled: false}); + const backend = stubCommon(command, {jsonEnabled: false}); sinon.stub(command, 'log').returns(void 0); - const ocapiGet = sinon.stub().resolves({data: {count: 0, total: 0, data: []}, error: undefined}); - sinon.stub(command, 'instance').get(() => ({ocapi: {GET: ocapiGet}})); + backend.listRoles.resolves({total: 0, start: 0, count: 0, hits: []}); const result = await command.run(); - expect(result.count).to.equal(0); + expect(result.total).to.equal(0); }); - it('throws when OCAPI returns error', async () => { + it('throws when backend returns error', async () => { const command: any = await createCommand(); - sinon.stub(command, 'requireOAuthCredentials').returns(void 0); - sinon.stub(command, 'resolvedConfig').get(() => ({values: {hostname: 'example.com'}})); + const backend = stubCommon(command, {jsonEnabled: false}); + sinon.stub(command, 'log').returns(void 0); - const ocapiGet = sinon.stub().resolves({ - data: undefined, - error: {fault: {message: 'boom'}}, - response: {status: 500, statusText: 'Error'}, - }); - sinon.stub(command, 'instance').get(() => ({ocapi: {GET: ocapiGet}})); + backend.listRoles.rejects(new Error('Failed to list roles: boom')); try { await command.run(); diff --git a/packages/b2c-cli/test/commands/bm/roles/revoke.test.ts b/packages/b2c-cli/test/commands/bm/roles/revoke.test.ts index 900a1bfb4..cc9258fd8 100644 --- a/packages/b2c-cli/test/commands/bm/roles/revoke.test.ts +++ b/packages/b2c-cli/test/commands/bm/roles/revoke.test.ts @@ -21,33 +21,49 @@ describe('bm roles revoke', () => { return createTestCommand(BmRolesRevoke, hooks.getConfig(), flags, args); } + function createMockBackend() { + return { + name: 'ocapi' as const, + listRoles: sinon.stub(), + getRole: sinon.stub(), + createRole: sinon.stub(), + deleteRole: sinon.stub(), + getPermissions: sinon.stub(), + setPermissions: sinon.stub(), + grantRole: sinon.stub(), + revokeRole: sinon.stub(), + }; + } + function stubCommon(command: any, {jsonEnabled}: {jsonEnabled: boolean}) { sinon.stub(command, 'requireOAuthCredentials').returns(void 0); sinon.stub(command, 'resolvedConfig').get(() => ({values: {hostname: 'example.com'}})); + sinon.stub(command, 'instance').get(() => ({config: {hostname: 'example.com'}})); sinon.stub(command, 'jsonEnabled').returns(jsonEnabled); + const backend = createMockBackend(); + sinon.stub(command, 'createRolesBackend').returns(backend); + return backend; } it('revokes role and returns result in JSON mode', async () => { const command: any = await createCommand({role: 'Administrator'}, {login: 'user@example.com'}); - stubCommon(command, {jsonEnabled: true}); + const backend = stubCommon(command, {jsonEnabled: true}); - const ocapiDelete = sinon.stub().resolves({data: undefined, error: undefined}); - sinon.stub(command, 'instance').get(() => ({ocapi: {DELETE: ocapiDelete}})); + backend.revokeRole.resolves(); const result = await command.run(); expect(result.success).to.equal(true); expect(result.role).to.equal('Administrator'); expect(result.login).to.equal('user@example.com'); - expect(ocapiDelete.calledOnce).to.equal(true); + expect(backend.revokeRole.calledOnce).to.equal(true); }); it('logs success in non-JSON mode', async () => { const command: any = await createCommand({role: 'Administrator'}, {login: 'user@example.com'}); - stubCommon(command, {jsonEnabled: false}); + const backend = stubCommon(command, {jsonEnabled: false}); const logStub = sinon.stub(command, 'log').returns(void 0); - const ocapiDelete = sinon.stub().resolves({data: undefined, error: undefined}); - sinon.stub(command, 'instance').get(() => ({ocapi: {DELETE: ocapiDelete}})); + backend.revokeRole.resolves(); await command.run(); expect(logStub.calledWith(sinon.match('user@example.com'))).to.equal(true); @@ -55,15 +71,10 @@ describe('bm roles revoke', () => { it('throws on 404 for non-existent assignment', async () => { const command: any = await createCommand({role: 'Administrator'}, {login: 'nobody@example.com'}); - sinon.stub(command, 'requireOAuthCredentials').returns(void 0); - sinon.stub(command, 'resolvedConfig').get(() => ({values: {hostname: 'example.com'}})); + const backend = stubCommon(command, {jsonEnabled: false}); + sinon.stub(command, 'log').returns(void 0); - const ocapiDelete = sinon.stub().resolves({ - data: undefined, - error: {fault: {message: 'Not found'}}, - response: {status: 404, statusText: 'Not Found'}, - }); - sinon.stub(command, 'instance').get(() => ({ocapi: {DELETE: ocapiDelete}})); + backend.revokeRole.rejects(new Error('Failed to revoke role Administrator from nobody@example.com: Not found')); try { await command.run(); diff --git a/packages/b2c-tooling-sdk/package.json b/packages/b2c-tooling-sdk/package.json index aa58cc8f7..7e1314566 100644 --- a/packages/b2c-tooling-sdk/package.json +++ b/packages/b2c-tooling-sdk/package.json @@ -419,7 +419,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 && openapi-typescript specs/granular-replications-v1.yaml -o src/clients/granular-replications.generated.ts && openapi-typescript specs/operations-jobs-v1.yaml -o src/clients/scapi-jobs.generated.ts && openapi-typescript specs/dx-scripts-v1.yaml -o src/clients/scapi-scripts.generated.ts && openapi-typescript specs/merchant-users-v1.yaml -o src/clients/scapi-merchant-users.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/granular-replications-v1.yaml -o src/clients/granular-replications.generated.ts && openapi-typescript specs/operations-jobs-v1.yaml -o src/clients/scapi-jobs.generated.ts && openapi-typescript specs/dx-scripts-v1.yaml -o src/clients/scapi-scripts.generated.ts && openapi-typescript specs/merchant-users-v1.yaml -o src/clients/scapi-merchant-users.generated.ts && openapi-typescript specs/merchant-roles-v1.yaml -o src/clients/scapi-merchant-roles.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/merchant-roles-v1.yaml b/packages/b2c-tooling-sdk/specs/merchant-roles-v1.yaml new file mode 100644 index 000000000..2a651980c --- /dev/null +++ b/packages/b2c-tooling-sdk/specs/merchant-roles-v1.yaml @@ -0,0 +1,1085 @@ +openapi: 3.0.3 +info: + title: Roles + version: 1.0.0 + x-api-type: Admin + x-api-family: Merchant +servers: + - url: "https://{shortCode}.api.commercecloud.salesforce.com/merchant/roles/v1" + variables: + shortCode: + default: 123456gf +paths: + /organizations/{organizationId}/roles: + get: + operationId: getRoles + parameters: + - name: organizationId + in: path + required: true + style: simple + explode: false + schema: + $ref: "#/components/schemas/OrganizationId" + - name: expand + in: query + required: false + style: form + explode: false + schema: + type: array + items: + type: string + enum: [users, permissions] + - name: select + in: query + required: false + style: form + explode: true + schema: + $ref: "#/components/schemas/Select" + - name: limit + in: query + required: false + style: form + explode: true + schema: + type: integer + format: int32 + default: 25 + maximum: 200 + minimum: 1 + - name: offset + in: query + required: false + style: form + explode: true + schema: + type: integer + format: int32 + default: 0 + minimum: 0 + responses: + 200: + description: Returns the collection of access roles + content: + application/json: + schema: + $ref: "#/components/schemas/RoleSearch" + security: + - AmOAuth2: [sfcc.roles, sfcc.roles.rw] + /organizations/{organizationId}/roles/{roleId}: + get: + operationId: getRole + parameters: + - name: organizationId + in: path + required: true + style: simple + explode: false + schema: + $ref: "#/components/schemas/OrganizationId" + - name: roleId + in: path + required: true + style: simple + explode: false + schema: + type: string + maxLength: 256 + minLength: 1 + - name: expand + in: query + required: false + style: form + explode: false + schema: + type: array + items: + type: string + enum: [users, permissions] + responses: + 200: + description: Returns the access role details + content: + application/json: + schema: + $ref: "#/components/schemas/Role" + 404: + description: Role not found + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ErrorResponse" + security: + - AmOAuth2: [sfcc.roles, sfcc.roles.rw] + put: + operationId: createRole + parameters: + - name: organizationId + in: path + required: true + style: simple + explode: false + schema: + $ref: "#/components/schemas/OrganizationId" + - name: roleId + in: path + required: true + style: simple + explode: false + schema: + type: string + maxLength: 256 + minLength: 1 + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/Role" + required: true + responses: + 200: + description: The access role was successfully updated + content: + application/json: + schema: + $ref: "#/components/schemas/Role" + 201: + description: The access role was successfully created + content: + application/json: + schema: + $ref: "#/components/schemas/Role" + 400: + description: Bad Request - Invalid role request + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ErrorResponse" + security: + - AmOAuth2: [sfcc.roles.rw] + delete: + operationId: deleteRole + parameters: + - name: organizationId + in: path + required: true + style: simple + explode: false + schema: + $ref: "#/components/schemas/OrganizationId" + - name: roleId + in: path + required: true + style: simple + explode: false + schema: + type: string + maxLength: 256 + minLength: 1 + responses: + 204: + description: The access role was successfully deleted + 404: + description: Role not found + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ErrorResponse" + security: + - AmOAuth2: [sfcc.roles.rw] + /organizations/{organizationId}/roles/{roleId}/permissions: + get: + operationId: getRolePermissions + parameters: + - name: organizationId + in: path + required: true + style: simple + explode: false + schema: + $ref: "#/components/schemas/OrganizationId" + - name: roleId + in: path + required: true + style: simple + explode: false + schema: + type: string + maxLength: 256 + minLength: 1 + responses: + 200: + description: Returns the role permissions + content: + application/json: + schema: + $ref: "#/components/schemas/RolePermissions" + 404: + description: Role not found + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ErrorResponse" + security: + - AmOAuth2: [sfcc.roles, sfcc.roles.rw] + put: + operationId: setRolePermissions + parameters: + - name: organizationId + in: path + required: true + style: simple + explode: false + schema: + $ref: "#/components/schemas/OrganizationId" + - name: roleId + in: path + required: true + style: simple + explode: false + schema: + type: string + maxLength: 256 + minLength: 1 + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/RolePermissions" + required: true + responses: + 200: + description: The permissions were successfully updated + content: + application/json: + schema: + $ref: "#/components/schemas/RolePermissions" + 201: + description: The permissions were successfully assigned + content: + application/json: + schema: + $ref: "#/components/schemas/RolePermissions" + 400: + description: Bad Request - Invalid permissions request + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ErrorResponse" + 404: + description: Role not found + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ErrorResponse" + security: + - AmOAuth2: [sfcc.roles.rw] + /organizations/{organizationId}/roles/{roleId}/user-search: + post: + operationId: searchRoleUsers + parameters: + - name: organizationId + in: path + required: true + style: simple + explode: false + schema: + $ref: "#/components/schemas/OrganizationId" + - name: roleId + in: path + required: true + style: simple + explode: false + schema: + type: string + maxLength: 256 + minLength: 1 + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/RoleUserSearchRequest" + required: true + responses: + 200: + description: Returns role user search results + content: + application/json: + schema: + $ref: "#/components/schemas/RoleUserSearchResult" + 400: + description: Bad Request - Malformed search query or invalid parameters + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ErrorResponse" + 404: + description: Role not found + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ErrorResponse" + security: + - AmOAuth2: [sfcc.roles, sfcc.roles.rw] + /organizations/{organizationId}/roles/{roleId}/users: + get: + operationId: getRoleUsers + parameters: + - name: organizationId + in: path + required: true + style: simple + explode: false + schema: + $ref: "#/components/schemas/OrganizationId" + - name: roleId + in: path + required: true + style: simple + explode: false + schema: + type: string + maxLength: 256 + minLength: 1 + - name: select + in: query + required: false + style: form + explode: true + schema: + $ref: "#/components/schemas/Select" + - name: limit + in: query + required: false + style: form + explode: true + schema: + type: integer + format: int32 + default: 25 + maximum: 200 + minimum: 1 + - name: offset + in: query + required: false + style: form + explode: true + schema: + type: integer + format: int32 + default: 0 + minimum: 0 + responses: + 200: + description: Returns the collection of users assigned to the role + content: + application/json: + schema: + $ref: "#/components/schemas/UserSearch" + 404: + description: Role not found + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ErrorResponse" + security: + - AmOAuth2: [sfcc.roles, sfcc.roles.rw] + /organizations/{organizationId}/roles/{roleId}/users/{login}: + put: + operationId: assignUserToRole + parameters: + - name: organizationId + in: path + required: true + style: simple + explode: false + schema: + $ref: "#/components/schemas/OrganizationId" + - name: roleId + in: path + required: true + style: simple + explode: false + schema: + type: string + maxLength: 256 + minLength: 1 + - name: login + in: path + required: true + style: simple + explode: false + schema: + type: string + maxLength: 256 + minLength: 1 + responses: + 200: + description: The user was successfully re-assigned to the role + content: + application/json: + schema: + $ref: "#/components/schemas/User" + 201: + description: The user was successfully assigned to the role + content: + application/json: + schema: + $ref: "#/components/schemas/User" + 404: + description: Role or user not found + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ErrorResponse" + security: + - AmOAuth2: [sfcc.roles.rw] + delete: + operationId: unassignUserFromRole + parameters: + - name: organizationId + in: path + required: true + style: simple + explode: false + schema: + $ref: "#/components/schemas/OrganizationId" + - name: roleId + in: path + required: true + style: simple + explode: false + schema: + type: string + maxLength: 256 + minLength: 1 + - name: login + in: path + required: true + style: simple + explode: false + schema: + type: string + maxLength: 256 + minLength: 1 + responses: + 204: + description: The user was successfully unassigned from the role + 404: + description: Role or user not found + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ErrorResponse" + security: + - AmOAuth2: [sfcc.roles.rw] +components: + schemas: + OrganizationId: + type: string + maxLength: 32 + minLength: 1 + Select: + type: string + minLength: 1 + pattern: ^[(].*[)]$ + Total: + type: integer + format: int32 + default: 0 + minimum: 0 + ResultBase: + type: object + properties: + limit: + type: integer + format: int32 + total: + $ref: "#/components/schemas/Total" + required: [limit, total] + Offset: + type: integer + format: int32 + default: 0 + minimum: 0 + PaginatedResultBase: + allOf: + - $ref: "#/components/schemas/ResultBase" + properties: + offset: + $ref: "#/components/schemas/Offset" + required: [limit, offset, total] + RoleModulePermission: + type: object + properties: + name: + type: string + maxLength: 256 + minLength: 1 + type: + type: string + maxLength: 256 + minLength: 1 + application: + type: string + maxLength: 256 + minLength: 1 + system: + type: boolean + value: + type: string + maxLength: 256 + values: + type: object + additionalProperties: + type: string + maxLength: 256 + required: [application, name, type] + RoleModulePermissions: + type: object + properties: + organization: + type: array + items: + $ref: "#/components/schemas/RoleModulePermission" + type: string + site: + type: array + items: + $ref: "#/components/schemas/RoleModulePermission" + type: string + RoleFunctionalPermission: + type: object + properties: + name: + type: string + maxLength: 256 + minLength: 1 + type: + type: string + maxLength: 256 + minLength: 1 + value: + type: string + maxLength: 256 + values: + type: object + additionalProperties: + type: string + maxLength: 256 + required: [name, type] + RoleFunctionalPermissions: + type: object + properties: + organization: + type: array + items: + $ref: "#/components/schemas/RoleFunctionalPermission" + type: string + site: + type: array + items: + $ref: "#/components/schemas/RoleFunctionalPermission" + type: string + LanguageCountry: + type: string + pattern: ^[a-z][a-z]-[A-Z][A-Z]$ + LanguageCode: + type: string + pattern: ^[a-z][a-z]$ + DefaultFallback: + type: string + default: default + pattern: ^default$ + LocaleCode: + oneOf: + - $ref: "#/components/schemas/LanguageCountry" + - $ref: "#/components/schemas/LanguageCode" + - $ref: "#/components/schemas/DefaultFallback" + RoleLocalePermission: + type: object + properties: + localeId: + allOf: + - $ref: "#/components/schemas/LocaleCode" + type: + type: string + maxLength: 256 + minLength: 1 + value: + type: string + maxLength: 256 + values: + type: object + additionalProperties: + type: string + maxLength: 256 + required: [localeId, type] + RoleLocalePermissions: + type: object + properties: + unscoped: + type: array + items: + $ref: "#/components/schemas/RoleLocalePermission" + type: string + RoleWebdavPermission: + type: object + properties: + folder: + type: string + maxLength: 256 + minLength: 1 + type: + type: string + maxLength: 256 + minLength: 1 + value: + type: string + maxLength: 256 + values: + type: object + additionalProperties: + type: string + maxLength: 256 + required: [folder, type] + RoleWebdavPermissions: + type: object + properties: + unscoped: + type: array + items: + $ref: "#/components/schemas/RoleWebdavPermission" + type: string + RolePermissions: + type: object + properties: + module: + $ref: "#/components/schemas/RoleModulePermissions" + functional: + $ref: "#/components/schemas/RoleFunctionalPermissions" + locale: + $ref: "#/components/schemas/RoleLocalePermissions" + webdav: + $ref: "#/components/schemas/RoleWebdavPermissions" + User: + type: object + properties: + login: + type: string + maxLength: 256 + minLength: 1 + password: + type: string + maxLength: 256 + email: + type: string + maxLength: 256 + firstName: + type: string + maxLength: 256 + lastName: + type: string + maxLength: 256 + externalId: + type: string + maxLength: 256 + disabled: + type: boolean + locked: + type: boolean + lastLoginDate: + type: string + format: date + passwordExpirationDate: + type: string + format: date-time + passwordModificationDate: + type: string + format: date-time + preferredDataLocale: + allOf: + - $ref: "#/components/schemas/LocaleCode" + preferredUiLocale: + allOf: + - $ref: "#/components/schemas/LocaleCode" + roles: + type: array + items: + type: string + maxLength: 256 + required: [email, login] + Role: + type: object + properties: + id: + type: string + maxLength: 256 + minLength: 1 + description: + type: string + maxLength: 4000 + userCount: + type: integer + format: int32 + userManager: + type: boolean + permissions: + $ref: "#/components/schemas/RolePermissions" + users: + type: array + items: + $ref: "#/components/schemas/User" + type: string + RoleSearch: + allOf: + - $ref: "#/components/schemas/PaginatedResultBase" + properties: + data: + type: array + items: + $ref: "#/components/schemas/Role" + type: string + required: [data] + ErrorResponse: + type: object + additionalProperties: true + properties: + title: + type: string + maxLength: 256 + type: + type: string + maxLength: 2048 + detail: + type: string + instance: + type: string + maxLength: 2048 + required: [detail, title, type] + Query: + type: object + additionalProperties: false + maxProperties: 1 + minProperties: 1 + properties: + boolQuery: + $ref: "#/components/schemas/BoolQuery" + filteredQuery: + $ref: "#/components/schemas/FilteredQuery" + matchAllQuery: + $ref: "#/components/schemas/MatchAllQuery" + nestedQuery: + $ref: "#/components/schemas/NestedQuery" + termQuery: + $ref: "#/components/schemas/TermQuery" + textQuery: + $ref: "#/components/schemas/TextQuery" + BoolQuery: + type: object + additionalProperties: false + properties: + must: + type: array + items: + $ref: "#/components/schemas/Query" + type: string + mustNot: + type: array + items: + $ref: "#/components/schemas/Query" + type: string + should: + type: array + items: + $ref: "#/components/schemas/Query" + type: string + Filter: + type: object + additionalProperties: false + maxProperties: 1 + minProperties: 1 + properties: + boolFilter: + $ref: "#/components/schemas/BoolFilter" + queryFilter: + $ref: "#/components/schemas/QueryFilter" + range2Filter: + $ref: "#/components/schemas/Range2Filter" + rangeFilter: + $ref: "#/components/schemas/RangeFilter" + termFilter: + $ref: "#/components/schemas/TermFilter" + BoolFilter: + type: object + additionalProperties: false + properties: + filters: + type: array + items: + $ref: "#/components/schemas/Filter" + type: string + operator: + type: string + enum: [and, or, not] + required: [operator] + QueryFilter: + type: object + properties: + query: + $ref: "#/components/schemas/Query" + required: [query] + Field: + type: string + maxLength: 260 + Range2Filter: + type: object + additionalProperties: false + properties: + filterMode: + type: string + default: overlap + enum: [overlap, containing, contained] + fromField: + allOf: + - $ref: "#/components/schemas/Field" + fromInclusive: + type: boolean + default: true + fromValue: {} + toField: + allOf: + - $ref: "#/components/schemas/Field" + toInclusive: + type: boolean + default: true + toValue: {} + required: [fromField, toField] + RangeFilter: + type: object + properties: + field: + allOf: + - $ref: "#/components/schemas/Field" + from: + oneOf: + - type: string + format: date-time + - type: integer + - type: number + fromInclusive: + type: boolean + default: true + to: + oneOf: + - type: string + format: date-time + - type: integer + - type: number + toInclusive: + type: boolean + default: true + required: [field] + TermFilter: + type: object + additionalProperties: false + properties: + field: + allOf: + - $ref: "#/components/schemas/Field" + operator: + type: string + enum: [is, one_of, is_null, is_not_null, less, greater, not_in, neq] + values: + type: array + items: + type: string + required: [field, operator] + FilteredQuery: + type: object + additionalProperties: false + properties: + filter: + $ref: "#/components/schemas/Filter" + query: + $ref: "#/components/schemas/Query" + required: [filter, query] + MatchAllQuery: + type: object + NestedQuery: + type: object + additionalProperties: false + properties: + path: + type: string + maxLength: 2048 + query: + $ref: "#/components/schemas/Query" + scoreMode: + type: string + enum: [avg, total, max, none] + required: [path, query] + TermQuery: + type: object + properties: + fields: + type: array + items: + $ref: "#/components/schemas/Field" + type: string + minItems: 1 + operator: + type: string + enum: [is, one_of, is_null, is_not_null, less, greater, not_in, neq] + values: + type: array + items: + oneOf: + - type: string + - type: number + - type: boolean + - type: integer + type: string + required: [fields, operator] + TextQuery: + type: object + additionalProperties: false + properties: + fields: + type: array + items: + $ref: "#/components/schemas/Field" + type: string + minItems: 1 + searchPhrase: + type: string + required: [fields, searchPhrase] + Sort: + type: object + additionalProperties: false + properties: + field: + type: string + maxLength: 256 + sortOrder: + type: string + default: asc + enum: [asc, desc] + required: [field] + SearchRequest: + type: object + properties: + limit: + type: integer + format: int32 + maximum: 200 + minimum: 1 + query: + $ref: "#/components/schemas/Query" + sorts: + type: array + items: + $ref: "#/components/schemas/Sort" + type: string + offset: + $ref: "#/components/schemas/Offset" + required: [query] + RoleUserSearchRequest: + allOf: + - $ref: "#/components/schemas/SearchRequest" + PaginatedSearchResult: + additionalProperties: false + allOf: + - $ref: "#/components/schemas/PaginatedResultBase" + properties: + query: + $ref: "#/components/schemas/Query" + sorts: + type: array + items: + $ref: "#/components/schemas/Sort" + type: string + hits: + type: array + items: + type: object + required: [query] + RoleUserSearchResult: + allOf: + - $ref: "#/components/schemas/PaginatedSearchResult" + properties: + hits: + type: array + items: + $ref: "#/components/schemas/User" + type: string + required: [hits, query] + UserSearch: + allOf: + - $ref: "#/components/schemas/PaginatedResultBase" + properties: + data: + type: array + items: + $ref: "#/components/schemas/User" + type: string + required: [data] + parameters: + organizationId: + name: organizationId + in: path + required: true + style: simple + explode: false + schema: + $ref: "#/components/schemas/OrganizationId" + expand: + name: expand + in: query + required: false + style: form + explode: false + schema: + type: array + items: + type: string + enum: [users, permissions] + select: + name: select + in: query + required: false + style: form + explode: true + schema: + $ref: "#/components/schemas/Select" + roleId: + name: roleId + in: path + required: true + style: simple + explode: false + schema: + type: string + maxLength: 256 + minLength: 1 + login: + name: login + in: path + required: true + style: simple + explode: false + schema: + type: string + maxLength: 256 + minLength: 1 + securitySchemes: + AmOAuth2: + type: oauth2 + flows: + clientCredentials: + tokenUrl: "https://account.demandware.com/dw/oauth2/access_token" + scopes: + sfcc.roles: Read access to role resources + sfcc.roles.rw: Read and write access to role resources diff --git a/packages/b2c-tooling-sdk/src/cli/bm-command.ts b/packages/b2c-tooling-sdk/src/cli/bm-command.ts index d2ed80fa3..bef30b390 100644 --- a/packages/b2c-tooling-sdk/src/cli/bm-command.ts +++ b/packages/b2c-tooling-sdk/src/cli/bm-command.ts @@ -6,6 +6,7 @@ import {Command} from '@oclif/core'; import {InstanceCommand} from './instance-command.js'; import {createUsersBackend, type UsersBackend} from '../operations/bm-users/index.js'; +import {createRolesBackend, type RolesBackend} from '../operations/bm-roles/index.js'; /** * Base command for Business Manager (instance-level) operations. @@ -28,4 +29,18 @@ export abstract class BmCommand extends InstanceComman auth: this.hasOAuthCredentials() ? this.getOAuthStrategy() : undefined, }); } + + /** + * Creates a Roles backend for `bm roles *` commands. + */ + protected createRolesBackend(): RolesBackend { + const preference = this.resolvedConfig.values.apiBackend ?? 'auto'; + return createRolesBackend({ + preference, + instance: this.instance, + shortCode: this.resolvedConfig.values.shortCode, + tenantId: this.resolvedConfig.values.tenantId, + auth: this.hasOAuthCredentials() ? this.getOAuthStrategy() : undefined, + }); + } } diff --git a/packages/b2c-tooling-sdk/src/clients/index.ts b/packages/b2c-tooling-sdk/src/clients/index.ts index d2c850bc3..bbdba1832 100644 --- a/packages/b2c-tooling-sdk/src/clients/index.ts +++ b/packages/b2c-tooling-sdk/src/clients/index.ts @@ -336,6 +336,21 @@ export type { components as ScapiJobsComponents, } from './scapi-jobs.js'; +// SCAPI Merchant Roles +export { + createScapiMerchantRolesClient, + SCAPI_MERCHANT_ROLES_READ_SCOPES, + SCAPI_MERCHANT_ROLES_RW_SCOPES, +} from './scapi-merchant-roles.js'; +export type { + ScapiMerchantRolesClient, + ScapiMerchantRolesClientConfig, + ScapiMerchantRolesError, + ScapiMerchantRolesResponse, + paths as ScapiMerchantRolesPaths, + components as ScapiMerchantRolesComponents, +} from './scapi-merchant-roles.js'; + // SCAPI Merchant Users export { createScapiMerchantUsersClient, diff --git a/packages/b2c-tooling-sdk/src/clients/middleware-registry.ts b/packages/b2c-tooling-sdk/src/clients/middleware-registry.ts index 7e1011932..fa428b7c2 100644 --- a/packages/b2c-tooling-sdk/src/clients/middleware-registry.ts +++ b/packages/b2c-tooling-sdk/src/clients/middleware-registry.ts @@ -62,7 +62,8 @@ export type HttpClientType = | 'am-orgs-api' | 'scapi-jobs' | 'scapi-scripts' - | 'scapi-merchant-users'; + | 'scapi-merchant-users' + | 'scapi-merchant-roles'; /** * Middleware interface compatible with openapi-fetch. diff --git a/packages/b2c-tooling-sdk/src/clients/scapi-merchant-roles.generated.ts b/packages/b2c-tooling-sdk/src/clients/scapi-merchant-roles.generated.ts new file mode 100644 index 000000000..97e405a1c --- /dev/null +++ b/packages/b2c-tooling-sdk/src/clients/scapi-merchant-roles.generated.ts @@ -0,0 +1,726 @@ +/** + * This file was auto-generated by openapi-typescript. + * Do not make direct changes to the file. + */ + +export interface paths { + "/organizations/{organizationId}/roles": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getRoles"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/organizations/{organizationId}/roles/{roleId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getRole"]; + put: operations["createRole"]; + post?: never; + delete: operations["deleteRole"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/organizations/{organizationId}/roles/{roleId}/permissions": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getRolePermissions"]; + put: operations["setRolePermissions"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/organizations/{organizationId}/roles/{roleId}/user-search": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["searchRoleUsers"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/organizations/{organizationId}/roles/{roleId}/users": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getRoleUsers"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/organizations/{organizationId}/roles/{roleId}/users/{login}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put: operations["assignUserToRole"]; + post?: never; + delete: operations["unassignUserFromRole"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +export type webhooks = Record; +export interface components { + schemas: { + OrganizationId: string; + Select: string; + /** + * Format: int32 + * @default 0 + */ + Total: number; + ResultBase: { + /** Format: int32 */ + limit: number; + total: components["schemas"]["Total"]; + }; + /** + * Format: int32 + * @default 0 + */ + Offset: number; + PaginatedResultBase: { + offset: components["schemas"]["Offset"]; + } & WithRequired; + RoleModulePermission: { + name: string; + type: string; + application: string; + system?: boolean; + value?: string; + values?: { + [key: string]: string; + }; + }; + RoleModulePermissions: { + organization?: components["schemas"]["RoleModulePermission"][]; + site?: components["schemas"]["RoleModulePermission"][]; + }; + RoleFunctionalPermission: { + name: string; + type: string; + value?: string; + values?: { + [key: string]: string; + }; + }; + RoleFunctionalPermissions: { + organization?: components["schemas"]["RoleFunctionalPermission"][]; + site?: components["schemas"]["RoleFunctionalPermission"][]; + }; + LanguageCountry: string; + LanguageCode: string; + /** @default default */ + DefaultFallback: string; + LocaleCode: components["schemas"]["LanguageCountry"] | components["schemas"]["LanguageCode"] | components["schemas"]["DefaultFallback"]; + RoleLocalePermission: { + localeId: components["schemas"]["LocaleCode"]; + type: string; + value?: string; + values?: { + [key: string]: string; + }; + }; + RoleLocalePermissions: { + unscoped?: components["schemas"]["RoleLocalePermission"][]; + }; + RoleWebdavPermission: { + folder: string; + type: string; + value?: string; + values?: { + [key: string]: string; + }; + }; + RoleWebdavPermissions: { + unscoped?: components["schemas"]["RoleWebdavPermission"][]; + }; + RolePermissions: { + module?: components["schemas"]["RoleModulePermissions"]; + functional?: components["schemas"]["RoleFunctionalPermissions"]; + locale?: components["schemas"]["RoleLocalePermissions"]; + webdav?: components["schemas"]["RoleWebdavPermissions"]; + }; + User: { + login: string; + password?: string; + email: string; + firstName?: string; + lastName?: string; + externalId?: string; + disabled?: boolean; + locked?: boolean; + /** Format: date */ + lastLoginDate?: string; + /** Format: date-time */ + passwordExpirationDate?: string; + /** Format: date-time */ + passwordModificationDate?: string; + preferredDataLocale?: components["schemas"]["LocaleCode"]; + preferredUiLocale?: components["schemas"]["LocaleCode"]; + roles?: string[]; + }; + Role: { + id?: string; + description?: string; + /** Format: int32 */ + userCount?: number; + userManager?: boolean; + permissions?: components["schemas"]["RolePermissions"]; + users?: components["schemas"]["User"][]; + }; + RoleSearch: { + data: components["schemas"]["Role"][]; + } & components["schemas"]["PaginatedResultBase"]; + ErrorResponse: { + title: string; + type: string; + detail: string; + instance?: string; + } & { + [key: string]: unknown; + }; + Query: { + boolQuery?: components["schemas"]["BoolQuery"]; + filteredQuery?: components["schemas"]["FilteredQuery"]; + matchAllQuery?: components["schemas"]["MatchAllQuery"]; + nestedQuery?: components["schemas"]["NestedQuery"]; + termQuery?: components["schemas"]["TermQuery"]; + textQuery?: components["schemas"]["TextQuery"]; + }; + BoolQuery: { + must?: components["schemas"]["Query"][]; + mustNot?: components["schemas"]["Query"][]; + should?: components["schemas"]["Query"][]; + }; + Filter: { + boolFilter?: components["schemas"]["BoolFilter"]; + queryFilter?: components["schemas"]["QueryFilter"]; + range2Filter?: components["schemas"]["Range2Filter"]; + rangeFilter?: components["schemas"]["RangeFilter"]; + termFilter?: components["schemas"]["TermFilter"]; + }; + BoolFilter: { + filters?: components["schemas"]["Filter"][]; + /** @enum {string} */ + operator: "and" | "or" | "not"; + }; + QueryFilter: { + query: components["schemas"]["Query"]; + }; + Field: string; + Range2Filter: { + /** + * @default overlap + * @enum {string} + */ + filterMode: "overlap" | "containing" | "contained"; + fromField: components["schemas"]["Field"]; + /** @default true */ + fromInclusive: boolean; + fromValue?: unknown; + toField: components["schemas"]["Field"]; + /** @default true */ + toInclusive: boolean; + toValue?: unknown; + }; + RangeFilter: { + field: components["schemas"]["Field"]; + from?: string | number; + /** @default true */ + fromInclusive: boolean; + to?: string | number; + /** @default true */ + toInclusive: boolean; + }; + TermFilter: { + field: components["schemas"]["Field"]; + /** @enum {string} */ + operator: "is" | "one_of" | "is_null" | "is_not_null" | "less" | "greater" | "not_in" | "neq"; + values?: string[]; + }; + FilteredQuery: { + filter: components["schemas"]["Filter"]; + query: components["schemas"]["Query"]; + }; + MatchAllQuery: Record; + NestedQuery: { + path: string; + query: components["schemas"]["Query"]; + /** @enum {string} */ + scoreMode?: "avg" | "total" | "max" | "none"; + }; + TermQuery: { + fields: components["schemas"]["Field"][]; + /** @enum {string} */ + operator: "is" | "one_of" | "is_null" | "is_not_null" | "less" | "greater" | "not_in" | "neq"; + values?: (string | number | boolean)[]; + }; + TextQuery: { + fields: components["schemas"]["Field"][]; + searchPhrase: string; + }; + Sort: { + field: string; + /** + * @default asc + * @enum {string} + */ + sortOrder: "asc" | "desc"; + }; + SearchRequest: { + /** Format: int32 */ + limit?: number; + query: components["schemas"]["Query"]; + sorts?: components["schemas"]["Sort"][]; + offset?: components["schemas"]["Offset"]; + }; + RoleUserSearchRequest: components["schemas"]["SearchRequest"]; + PaginatedSearchResult: { + query: components["schemas"]["Query"]; + sorts?: components["schemas"]["Sort"][]; + hits?: Record[]; + } & components["schemas"]["PaginatedResultBase"]; + RoleUserSearchResult: { + hits: components["schemas"]["User"][]; + } & WithRequired; + UserSearch: { + data: components["schemas"]["User"][]; + } & components["schemas"]["PaginatedResultBase"]; + }; + responses: never; + parameters: { + organizationId: components["schemas"]["OrganizationId"]; + expand: ("users" | "permissions")[]; + select: components["schemas"]["Select"]; + roleId: string; + login: string; + }; + requestBodies: never; + headers: never; + pathItems: never; +} +export type $defs = Record; +export interface operations { + getRoles: { + parameters: { + query?: { + expand?: ("users" | "permissions")[]; + select?: components["schemas"]["Select"]; + limit?: number; + offset?: number; + }; + header?: never; + path: { + organizationId: components["schemas"]["OrganizationId"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Returns the collection of access roles */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["RoleSearch"]; + }; + }; + }; + }; + getRole: { + parameters: { + query?: { + expand?: ("users" | "permissions")[]; + }; + header?: never; + path: { + organizationId: components["schemas"]["OrganizationId"]; + roleId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Returns the access role details */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Role"]; + }; + }; + /** @description Role not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + createRole: { + parameters: { + query?: never; + header?: never; + path: { + organizationId: components["schemas"]["OrganizationId"]; + roleId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["Role"]; + }; + }; + responses: { + /** @description The access role was successfully updated */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Role"]; + }; + }; + /** @description The access role was successfully created */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Role"]; + }; + }; + /** @description Bad Request - Invalid role request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + deleteRole: { + parameters: { + query?: never; + header?: never; + path: { + organizationId: components["schemas"]["OrganizationId"]; + roleId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The access role was successfully deleted */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Role not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + getRolePermissions: { + parameters: { + query?: never; + header?: never; + path: { + organizationId: components["schemas"]["OrganizationId"]; + roleId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Returns the role permissions */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["RolePermissions"]; + }; + }; + /** @description Role not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + setRolePermissions: { + parameters: { + query?: never; + header?: never; + path: { + organizationId: components["schemas"]["OrganizationId"]; + roleId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["RolePermissions"]; + }; + }; + responses: { + /** @description The permissions were successfully updated */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["RolePermissions"]; + }; + }; + /** @description The permissions were successfully assigned */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["RolePermissions"]; + }; + }; + /** @description Bad Request - Invalid permissions request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Role not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + searchRoleUsers: { + parameters: { + query?: never; + header?: never; + path: { + organizationId: components["schemas"]["OrganizationId"]; + roleId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["RoleUserSearchRequest"]; + }; + }; + responses: { + /** @description Returns role user search results */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["RoleUserSearchResult"]; + }; + }; + /** @description Bad Request - Malformed search query or invalid parameters */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Role not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + getRoleUsers: { + parameters: { + query?: { + select?: components["schemas"]["Select"]; + limit?: number; + offset?: number; + }; + header?: never; + path: { + organizationId: components["schemas"]["OrganizationId"]; + roleId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Returns the collection of users assigned to the role */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UserSearch"]; + }; + }; + /** @description Role not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + assignUserToRole: { + parameters: { + query?: never; + header?: never; + path: { + organizationId: components["schemas"]["OrganizationId"]; + roleId: string; + login: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The user was successfully re-assigned to the role */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["User"]; + }; + }; + /** @description The user was successfully assigned to the role */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["User"]; + }; + }; + /** @description Role or user not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + unassignUserFromRole: { + parameters: { + query?: never; + header?: never; + path: { + organizationId: components["schemas"]["OrganizationId"]; + roleId: string; + login: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The user was successfully unassigned from the role */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Role or user not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; +} +type WithRequired = T & { + [P in K]-?: T[P]; +}; diff --git a/packages/b2c-tooling-sdk/src/clients/scapi-merchant-roles.ts b/packages/b2c-tooling-sdk/src/clients/scapi-merchant-roles.ts new file mode 100644 index 000000000..592f04a1b --- /dev/null +++ b/packages/b2c-tooling-sdk/src/clients/scapi-merchant-roles.ts @@ -0,0 +1,57 @@ +/* + * 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 createClient, {type Client} from 'openapi-fetch'; +import type {AuthStrategy} from '../auth/types.js'; +import type {paths, components} from './scapi-merchant-roles.generated.js'; +import {createAuthMiddleware, createLoggingMiddleware, createRateLimitMiddleware} from './middleware.js'; +import {globalMiddlewareRegistry, type MiddlewareRegistry} from './middleware-registry.js'; +import {OAuthStrategy} from '../auth/oauth.js'; +import {buildTenantScope} from './custom-apis.js'; + +export type {paths, components}; +export type ScapiMerchantRolesClient = Client; +export type ScapiMerchantRolesResponse = T extends {content: {'application/json': infer R}} ? R : never; +export type ScapiMerchantRolesError = components['schemas']['ErrorResponse']; + +export type Role = components['schemas']['Role']; +export type RolePermissions = components['schemas']['RolePermissions']; +export type RoleSearch = components['schemas']['RoleSearch']; + +export const SCAPI_MERCHANT_ROLES_READ_SCOPES = ['sfcc.roles']; +export const SCAPI_MERCHANT_ROLES_RW_SCOPES = ['sfcc.roles.rw']; + +export interface ScapiMerchantRolesClientConfig { + shortCode: string; + tenantId: string; + /** Override scopes (default: sfcc.roles.rw + tenant scope). */ + scopes?: string[]; + middlewareRegistry?: MiddlewareRegistry; +} + +export function createScapiMerchantRolesClient( + config: ScapiMerchantRolesClientConfig, + auth: AuthStrategy, +): ScapiMerchantRolesClient { + const registry = config.middlewareRegistry ?? globalMiddlewareRegistry; + + const client = createClient({ + baseUrl: `https://${config.shortCode}.api.commercecloud.salesforce.com/merchant/roles/v1`, + }); + + const requiredScopes = config.scopes ?? [...SCAPI_MERCHANT_ROLES_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-merchant-roles')) { + client.use(middleware); + } + + client.use(createRateLimitMiddleware({prefix: 'SCAPI-ROLES'})); + client.use(createLoggingMiddleware('SCAPI-ROLES')); + + return client; +} diff --git a/packages/b2c-tooling-sdk/src/index.ts b/packages/b2c-tooling-sdk/src/index.ts index e7810ae6c..981d562e3 100644 --- a/packages/b2c-tooling-sdk/src/index.ts +++ b/packages/b2c-tooling-sdk/src/index.ts @@ -236,6 +236,24 @@ export type { ScapiUsersBackendConfig, } from './operations/bm-users/index.js'; +// Roles (BM) backend abstraction +export { + createRolesBackend, + FallbackRolesBackend, + OcapiRolesBackend, + ScapiRolesBackend, +} from './operations/bm-roles/index.js'; +export type { + RolesBackend, + RolesBackendConfig, + RoleInfo, + RolePermissionsInfo, + ListRolesResult, + ListRolesOptions as ListBmRolesScopedOptions, + CreateRoleInput, + ScapiRolesBackendConfig, +} from './operations/bm-roles/index.js'; + // Operations - Jobs export { executeJob, diff --git a/packages/b2c-tooling-sdk/src/operations/bm-roles/backend.ts b/packages/b2c-tooling-sdk/src/operations/bm-roles/backend.ts new file mode 100644 index 000000000..4f85ec470 --- /dev/null +++ b/packages/b2c-tooling-sdk/src/operations/bm-roles/backend.ts @@ -0,0 +1,91 @@ +/* + * 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 type {B2CInstance} from '../../instance/index.js'; +import type {AuthStrategy} from '../../auth/types.js'; +import type { + RolesBackend, + RoleInfo, + ListRolesResult, + ListRolesOptions, + RolePermissionsInfo, + CreateRoleInput, +} from './types.js'; +import {OcapiRolesBackend} from './ocapi-backend.js'; +import {ScapiRolesBackend} from './scapi-backend.js'; +import {ScapiFallbackBackend} from '../../clients/scapi-fallback-backend.js'; +import {resolveScapiOrOcapi, type ApiBackendPreference} from '../../clients/scapi-backend-utils.js'; + +export interface RolesBackendConfig { + preference: ApiBackendPreference; + instance: B2CInstance; + shortCode?: string; + tenantId?: string; + auth?: AuthStrategy; +} + +export function createRolesBackend(config: RolesBackendConfig): RolesBackend { + const hasScapiConfig = Boolean(config.shortCode && config.tenantId && config.auth); + const resolved = resolveScapiOrOcapi({ + preference: config.preference, + hasScapiConfig, + domainName: 'Roles', + }); + + if (resolved === 'ocapi') { + return new OcapiRolesBackend(config.instance); + } + + const scapiBackend = new ScapiRolesBackend({ + shortCode: config.shortCode!, + tenantId: config.tenantId!, + auth: config.auth!, + }); + + if (config.preference === 'scapi') { + return scapiBackend; + } + + const ocapiBackend = new OcapiRolesBackend(config.instance); + return new FallbackRolesBackend(scapiBackend, ocapiBackend); +} + +export class FallbackRolesBackend extends ScapiFallbackBackend implements RolesBackend { + constructor(scapiBackend: ScapiRolesBackend, ocapiBackend: OcapiRolesBackend) { + super(scapiBackend, ocapiBackend, 'roles'); + } + + async listRoles(options?: ListRolesOptions): Promise { + return this.withFallback((b) => b.listRoles(options)); + } + + async getRole(roleId: string, options?: {expand?: ('users' | 'permissions')[]}): Promise { + return this.withFallback((b) => b.getRole(roleId, options)); + } + + async createRole(roleId: string, input?: CreateRoleInput): Promise { + return this.withFallback((b) => b.createRole(roleId, input)); + } + + async deleteRole(roleId: string): Promise { + return this.withFallback((b) => b.deleteRole(roleId)); + } + + async getPermissions(roleId: string): Promise { + return this.withFallback((b) => b.getPermissions(roleId)); + } + + async setPermissions(roleId: string, permissions: RolePermissionsInfo): Promise { + return this.withFallback((b) => b.setPermissions(roleId, permissions)); + } + + async grantRole(roleId: string, login: string): Promise { + return this.withFallback((b) => b.grantRole(roleId, login)); + } + + async revokeRole(roleId: string, login: string): Promise { + return this.withFallback((b) => b.revokeRole(roleId, login)); + } +} diff --git a/packages/b2c-tooling-sdk/src/operations/bm-roles/index.ts b/packages/b2c-tooling-sdk/src/operations/bm-roles/index.ts index 96e3e74b2..13669796f 100644 --- a/packages/b2c-tooling-sdk/src/operations/bm-roles/index.ts +++ b/packages/b2c-tooling-sdk/src/operations/bm-roles/index.ts @@ -65,3 +65,18 @@ export { } from './roles.js'; export type {BmRole, BmRoles, BmRolePermissions, ListBmRolesOptions, GetBmRoleOptions} from './roles.js'; + +// Roles backend abstraction — supports OCAPI + SCAPI +export {createRolesBackend, FallbackRolesBackend} from './backend.js'; +export type {RolesBackendConfig} from './backend.js'; +export {OcapiRolesBackend} from './ocapi-backend.js'; +export {ScapiRolesBackend} from './scapi-backend.js'; +export type {ScapiRolesBackendConfig} from './scapi-backend.js'; +export type { + RolesBackend, + RoleInfo, + RolePermissionsInfo, + ListRolesResult, + ListRolesOptions, + CreateRoleInput, +} from './types.js'; diff --git a/packages/b2c-tooling-sdk/src/operations/bm-roles/ocapi-backend.ts b/packages/b2c-tooling-sdk/src/operations/bm-roles/ocapi-backend.ts new file mode 100644 index 000000000..999e7a6ab --- /dev/null +++ b/packages/b2c-tooling-sdk/src/operations/bm-roles/ocapi-backend.ts @@ -0,0 +1,168 @@ +/* + * 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 type {B2CInstance} from '../../instance/index.js'; +import type { + RolesBackend, + RoleInfo, + ListRolesResult, + ListRolesOptions, + RolePermissionsInfo, + CreateRoleInput, +} from './types.js'; +import type {BmRole, BmRolePermissions} from './roles.js'; +import { + listBmRoles as ocapiListBmRoles, + getBmRole as ocapiGetBmRole, + createBmRole as ocapiCreateBmRole, + deleteBmRole as ocapiDeleteBmRole, + getBmRolePermissions as ocapiGetBmRolePermissions, + setBmRolePermissions as ocapiSetBmRolePermissions, + grantBmRole as ocapiGrantBmRole, + revokeBmRole as ocapiRevokeBmRole, +} from './roles.js'; + +function mapOcapiRole(ocapi: BmRole): RoleInfo { + return { + id: ocapi.id ?? '', + description: ocapi.description, + userCount: ocapi.user_count, + userManager: ocapi.user_manager, + // OCAPI permissions shape uses snake_case nested groups; the canonical + // type uses SCAPI's camelCase shape. We avoid converting the deep + // structure here (it's only exposed via the permissions endpoints). + _raw: ocapi, + }; +} + +type LocalePermissionOcapi = {locale_id?: string; type?: string; values?: string[]; display_name?: unknown}; +type WebdavPermissionOcapi = {folder?: string; type?: string; values?: string[]}; +type ModulePermissionOcapi = {application?: string; name?: string; values?: string[]}; +type FunctionalPermissionOcapi = {name?: string; values?: string[]}; + +function mapOcapiPermissions(ocapi: BmRolePermissions): RolePermissionsInfo { + // OCAPI uses snake_case for innermost permission fields (locale_id, etc.) + // while SCAPI uses camelCase (localeId). Convert at this boundary. + const result: Record = {}; + if (ocapi.module) { + result.module = { + organization: ((ocapi.module.organization ?? []) as ModulePermissionOcapi[]).map((p) => ({ + application: p.application, + name: p.name, + values: p.values, + })), + site: ((ocapi.module.site ?? []) as ModulePermissionOcapi[]).map((p) => ({ + application: p.application, + name: p.name, + values: p.values, + })), + }; + } + if (ocapi.functional) { + result.functional = { + organization: ((ocapi.functional.organization ?? []) as FunctionalPermissionOcapi[]).map((p) => ({ + name: p.name, + values: p.values, + })), + site: ((ocapi.functional.site ?? []) as FunctionalPermissionOcapi[]).map((p) => ({ + name: p.name, + values: p.values, + })), + }; + } + if (ocapi.locale) { + result.locale = { + unscoped: ((ocapi.locale.unscoped ?? []) as LocalePermissionOcapi[]).map((p) => ({ + localeId: p.locale_id, + type: p.type, + values: p.values, + })), + }; + } + if (ocapi.webdav) { + result.webdav = { + unscoped: ((ocapi.webdav.unscoped ?? []) as WebdavPermissionOcapi[]).map((p) => ({ + folder: p.folder, + type: p.type, + values: p.values, + })), + }; + } + return result as RolePermissionsInfo; +} + +function mapScapiPermissionsToOcapi(perms: RolePermissionsInfo): BmRolePermissions { + // Reverse: camelCase → snake_case for the inner locale field. + const result: Record = {}; + if (perms.module) { + result.module = perms.module; + } + if (perms.functional) { + result.functional = perms.functional; + } + if (perms.locale) { + type LocaleScapi = {localeId?: string; type?: string; values?: unknown}; + result.locale = { + unscoped: ((perms.locale.unscoped ?? []) as LocaleScapi[]).map((p) => ({ + locale_id: p.localeId, + type: p.type, + values: p.values, + })), + }; + } + if (perms.webdav) { + result.webdav = perms.webdav; + } + return result as BmRolePermissions; +} + +export class OcapiRolesBackend implements RolesBackend { + readonly name = 'ocapi' as const; + + constructor(private instance: B2CInstance) {} + + async listRoles(options: ListRolesOptions = {}): Promise { + const result = await ocapiListBmRoles(this.instance, {start: options.start, count: options.count}); + const items = (result.data ?? []) as BmRole[]; + return { + total: result.total ?? 0, + start: result.start ?? 0, + count: result.count ?? items.length, + hits: items.map(mapOcapiRole), + }; + } + + async getRole(roleId: string, options?: {expand?: ('users' | 'permissions')[]}): Promise { + const role = await ocapiGetBmRole(this.instance, roleId, {expand: options?.expand}); + return mapOcapiRole(role); + } + + async createRole(roleId: string, input?: CreateRoleInput): Promise { + const role = await ocapiCreateBmRole(this.instance, roleId, {description: input?.description}); + return mapOcapiRole(role); + } + + async deleteRole(roleId: string): Promise { + await ocapiDeleteBmRole(this.instance, roleId); + } + + async getPermissions(roleId: string): Promise { + const perms = await ocapiGetBmRolePermissions(this.instance, roleId); + return mapOcapiPermissions(perms); + } + + async setPermissions(roleId: string, permissions: RolePermissionsInfo): Promise { + const updated = await ocapiSetBmRolePermissions(this.instance, roleId, mapScapiPermissionsToOcapi(permissions)); + return mapOcapiPermissions(updated); + } + + async grantRole(roleId: string, login: string): Promise { + await ocapiGrantBmRole(this.instance, roleId, login); + } + + async revokeRole(roleId: string, login: string): Promise { + await ocapiRevokeBmRole(this.instance, roleId, login); + } +} diff --git a/packages/b2c-tooling-sdk/src/operations/bm-roles/scapi-backend.ts b/packages/b2c-tooling-sdk/src/operations/bm-roles/scapi-backend.ts new file mode 100644 index 000000000..4623080f1 --- /dev/null +++ b/packages/b2c-tooling-sdk/src/operations/bm-roles/scapi-backend.ts @@ -0,0 +1,178 @@ +/* + * 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 type {AuthStrategy} from '../../auth/types.js'; +import type { + RolesBackend, + RoleInfo, + ListRolesResult, + ListRolesOptions, + RolePermissionsInfo, + CreateRoleInput, +} from './types.js'; +import { + createScapiMerchantRolesClient, + SCAPI_MERCHANT_ROLES_RW_SCOPES, + SCAPI_MERCHANT_ROLES_READ_SCOPES, + type ScapiMerchantRolesClient, + type ScapiMerchantRolesClientConfig, + type Role as ScapiRole, + type RoleSearch, +} from '../../clients/scapi-merchant-roles.js'; +import {buildTenantScope, toOrganizationId} from '../../clients/custom-apis.js'; +import {ScopeTierManager} from '../../clients/scapi-scope-tier.js'; + +function mapScapiRole(scapi: ScapiRole): RoleInfo { + return { + id: scapi.id ?? '', + description: scapi.description, + userCount: scapi.userCount, + userManager: scapi.userManager, + permissions: scapi.permissions, + _raw: scapi, + }; +} + +export interface ScapiRolesBackendConfig { + shortCode: string; + tenantId: string; + auth: AuthStrategy; +} + +export class ScapiRolesBackend implements RolesBackend { + readonly name = 'scapi' as const; + + private organizationId: string; + private scopeTier: ScopeTierManager; + + constructor(private config: ScapiRolesBackendConfig) { + this.organizationId = toOrganizationId(config.tenantId); + this.scopeTier = new ScopeTierManager({ + buildClient: (scopes) => this.buildClient(scopes), + rwScopes: SCAPI_MERCHANT_ROLES_RW_SCOPES, + readScopes: SCAPI_MERCHANT_ROLES_READ_SCOPES, + domainName: 'Roles', + }); + } + + async listRoles(options: ListRolesOptions = {}): Promise { + const client = this.scopeTier.getClientForRead(); + const {start = 0, count = 25, expand} = options; + + const {data, error} = await client.GET('/organizations/{organizationId}/roles', { + params: { + path: {organizationId: this.organizationId}, + query: {limit: count, offset: start, expand}, + }, + }); + if (error || !data) { + throw new Error(toErrorMessage(error, 'Failed to list roles')); + } + const result = data as RoleSearch; + return { + total: result.total ?? 0, + start: result.offset ?? start, + count: result.limit ?? count, + hits: (result.data ?? []).map(mapScapiRole), + }; + } + + async getRole(roleId: string, options?: {expand?: ('users' | 'permissions')[]}): Promise { + const client = this.scopeTier.getClientForRead(); + const {data, error} = await client.GET('/organizations/{organizationId}/roles/{roleId}', { + params: { + path: {organizationId: this.organizationId, roleId}, + query: {expand: options?.expand}, + }, + }); + if (error || !data) { + throw new Error(toErrorMessage(error, `Failed to get role ${roleId}`)); + } + return mapScapiRole(data); + } + + async createRole(roleId: string, input?: CreateRoleInput): Promise { + const client = this.scopeTier.getClientForWrite(); + const body: ScapiRole = { + id: roleId, + description: input?.description, + }; + const {data, error} = await client.PUT('/organizations/{organizationId}/roles/{roleId}', { + params: {path: {organizationId: this.organizationId, roleId}}, + body, + }); + if (error || !data) { + throw new Error(toErrorMessage(error, `Failed to create role ${roleId}`)); + } + return mapScapiRole(data); + } + + async deleteRole(roleId: string): Promise { + const client = this.scopeTier.getClientForWrite(); + const {error} = await client.DELETE('/organizations/{organizationId}/roles/{roleId}', { + params: {path: {organizationId: this.organizationId, roleId}}, + }); + if (error) { + throw new Error(toErrorMessage(error, `Failed to delete role ${roleId}`)); + } + } + + async getPermissions(roleId: string): Promise { + const client = this.scopeTier.getClientForRead(); + const {data, error} = await client.GET('/organizations/{organizationId}/roles/{roleId}/permissions', { + params: {path: {organizationId: this.organizationId, roleId}}, + }); + if (error || !data) { + throw new Error(toErrorMessage(error, `Failed to get permissions for role ${roleId}`)); + } + return data; + } + + async setPermissions(roleId: string, permissions: RolePermissionsInfo): Promise { + const client = this.scopeTier.getClientForWrite(); + const {data, error} = await client.PUT('/organizations/{organizationId}/roles/{roleId}/permissions', { + params: {path: {organizationId: this.organizationId, roleId}}, + body: permissions, + }); + if (error || !data) { + throw new Error(toErrorMessage(error, `Failed to set permissions for role ${roleId}`)); + } + return data; + } + + async grantRole(roleId: string, login: string): Promise { + const client = this.scopeTier.getClientForWrite(); + const {error} = await client.PUT('/organizations/{organizationId}/roles/{roleId}/users/{login}', { + params: {path: {organizationId: this.organizationId, roleId, login}}, + }); + if (error) { + throw new Error(toErrorMessage(error, `Failed to grant role ${roleId} to ${login}`)); + } + } + + async revokeRole(roleId: string, login: string): Promise { + const client = this.scopeTier.getClientForWrite(); + const {error} = await client.DELETE('/organizations/{organizationId}/roles/{roleId}/users/{login}', { + params: {path: {organizationId: this.organizationId, roleId, login}}, + }); + if (error) { + throw new Error(toErrorMessage(error, `Failed to revoke role ${roleId} from ${login}`)); + } + } + + private buildClient(scopes: string[]): ScapiMerchantRolesClient { + const clientConfig: ScapiMerchantRolesClientConfig = { + shortCode: this.config.shortCode, + tenantId: this.config.tenantId, + scopes: [...scopes, buildTenantScope(this.config.tenantId)], + }; + return createScapiMerchantRolesClient(clientConfig, this.config.auth); + } +} + +function toErrorMessage(error: unknown, fallback: string): string { + const e = error as {detail?: string; title?: string} | undefined; + return e?.detail ?? e?.title ?? fallback; +} diff --git a/packages/b2c-tooling-sdk/src/operations/bm-roles/types.ts b/packages/b2c-tooling-sdk/src/operations/bm-roles/types.ts new file mode 100644 index 000000000..5e24eb37d --- /dev/null +++ b/packages/b2c-tooling-sdk/src/operations/bm-roles/types.ts @@ -0,0 +1,58 @@ +/* + * 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 + */ +/** + * Canonical types and backend interface for Business Manager role operations. + * + * The OCAPI Data API and the SCAPI Merchant Roles API both manage instance- + * level access roles. Permission shapes are virtually identical across the + * two APIs — module/functional/locale/webdav permission groups — so the + * canonical type re-exports the SCAPI shape and the OCAPI backend converts. + * + * @module operations/bm-roles/types + */ +import type {BackendBase} from '../../clients/scapi-backend-utils.js'; +import type {RolePermissions as ScapiRolePermissions} from '../../clients/scapi-merchant-roles.js'; + +export type RolePermissionsInfo = ScapiRolePermissions; + +export interface RoleInfo { + id: string; + description?: string; + userCount?: number; + userManager?: boolean; + permissions?: RolePermissionsInfo; + /** Original backend response, for advanced consumers. */ + _raw?: unknown; +} + +export interface ListRolesResult { + total: number; + start: number; + count: number; + hits: RoleInfo[]; +} + +export interface ListRolesOptions { + start?: number; + count?: number; + expand?: ('users' | 'permissions')[]; +} + +export interface CreateRoleInput { + description?: string; +} + +export interface RolesBackend extends BackendBase { + listRoles(options?: ListRolesOptions): Promise; + getRole(roleId: string, options?: {expand?: ('users' | 'permissions')[]}): Promise; + createRole(roleId: string, input?: CreateRoleInput): Promise; + deleteRole(roleId: string): Promise; + getPermissions(roleId: string): Promise; + setPermissions(roleId: string, permissions: RolePermissionsInfo): Promise; + /** Assigns a user to a role. Returns void; OCAPI returns the user but we don't surface that. */ + grantRole(roleId: string, login: string): Promise; + revokeRole(roleId: string, login: string): Promise; +} From d08d69bd38aa5fa5e7990e75eddfa47824a940b6 Mon Sep 17 00:00:00 2001 From: Charles Lavery Date: Fri, 8 May 2026 14:02:22 -0400 Subject: [PATCH 06/22] Document SCAPI migration across code, bm, and configuration - Configuration guide: clarify api-backend applies to job, code, bm users, and bm roles commands - code.md: new "API Backend" section with SCAPI scopes, fallback behavior, and notes on reload/deploy/download/watch staying OCAPI/WebDAV - bm.md: new "API Backend" section with per-command compatibility table (users search, whoami, access-key remain OCAPI-only) - b2c-code skill: backend selection examples - b2c-bm-users-roles skill: backend selection notes including the --disabled fallback caveat - Single changeset replaces the jobs-only one --- .changeset/scapi-jobs-migration.md | 6 ---- .changeset/scapi-migration.md | 6 ++++ docs/cli/bm.md | 30 +++++++++++++++++- docs/cli/code.md | 31 ++++++++++++++++--- docs/guide/configuration.md | 2 +- .../skills/b2c-bm-users-roles/SKILL.md | 16 ++++++++++ skills/b2c-cli/skills/b2c-code/SKILL.md | 14 +++++++++ 7 files changed, 93 insertions(+), 12 deletions(-) delete mode 100644 .changeset/scapi-jobs-migration.md create mode 100644 .changeset/scapi-migration.md diff --git a/.changeset/scapi-jobs-migration.md b/.changeset/scapi-jobs-migration.md deleted file mode 100644 index f483fdbf5..000000000 --- a/.changeset/scapi-jobs-migration.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -'@salesforce/b2c-cli': minor -'@salesforce/b2c-tooling-sdk': minor ---- - -Add SCAPI Jobs API support with automatic backend selection. Job commands (`job run`, `job search`, `job wait`, `job log`) now use SCAPI when `shortCode` and `tenantId` are configured, falling back to OCAPI if SCAPI scopes are unavailable. Use `--api-backend ocapi|scapi|auto` or `apiBackend` in dw.json to control explicitly. New `job execution delete` command (SCAPI only) deletes job execution records. diff --git a/.changeset/scapi-migration.md b/.changeset/scapi-migration.md new file mode 100644 index 000000000..201cbaf52 --- /dev/null +++ b/.changeset/scapi-migration.md @@ -0,0 +1,6 @@ +--- +'@salesforce/b2c-cli': minor +'@salesforce/b2c-tooling-sdk': minor +--- + +Migrate `job`, `code`, `bm users`, and `bm roles` commands to support SCAPI alongside OCAPI. In auto mode (the default), the CLI prefers SCAPI when `shortCode` and `tenantId` are configured and silently falls back to OCAPI if the SCAPI scopes aren't granted. Use `--api-backend ocapi|scapi|auto` or `apiBackend` in dw.json to control explicitly. SCAPI scopes: `sfcc.jobs.rw`, `sfcc.scripts.rw`, `sfcc.users.rw`, `sfcc.roles.rw`. New `job execution delete` command (SCAPI only). diff --git a/docs/cli/bm.md b/docs/cli/bm.md index 1e496608b..32df81b88 100644 --- a/docs/cli/bm.md +++ b/docs/cli/bm.md @@ -4,7 +4,35 @@ description: Commands for administering Business Manager resources on a B2C Comm # Business Manager Commands -Commands for administering instance-level Business Manager resources via the OCAPI Data API. These are distinct from [Account Manager commands](/cli/account-manager) which manage cross-instance identity. +Commands for administering instance-level Business Manager resources. These are distinct from [Account Manager commands](/cli/account-manager) which manage cross-instance identity. + +## API Backend + +Most `bm users` and `bm roles` commands support both the OCAPI Data API and the SCAPI Merchant Users / Merchant Roles APIs. By default (`auto` mode), SCAPI is preferred when `shortCode` and `tenantId` are configured. If the SCAPI scopes aren't granted on your API client, the CLI silently falls back to OCAPI. + +```bash +# Force SCAPI backend +b2c bm users list --api-backend scapi + +# Force OCAPI backend +b2c bm roles get Administrator --api-backend ocapi +``` + +Or set in `dw.json`: `"api-backend": "scapi"`. Or `SFCC_API_BACKEND=scapi` env var. + +| Command | SCAPI | OCAPI | +|---|---|---| +| `bm users list/get/update/delete` | ✓ (`sfcc.users.rw`) | ✓ | +| `bm users search` | ✗ — OCAPI only | ✓ | +| `bm whoami` | ✗ — OCAPI only | ✓ | +| `bm access-key *` | ✗ — OCAPI only | ✓ | +| `bm roles list/get/create/delete` | ✓ (`sfcc.roles.rw`) | ✓ | +| `bm roles grant/revoke` | ✓ (`sfcc.roles.rw`) | ✓ | +| `bm roles permissions get/set` | ✓ (`sfcc.roles.rw`) | ✓ | + +::: warning +The SCAPI Users PATCH endpoint does not support changing the `disabled` flag. `bm users update --disabled` falls back to OCAPI in auto mode; with `--api-backend scapi` it errors with a clear message. +::: ## Authentication diff --git a/docs/cli/code.md b/docs/cli/code.md index a0beb2d25..f308d92a2 100644 --- a/docs/cli/code.md +++ b/docs/cli/code.md @@ -6,6 +6,28 @@ description: Commands for deploying, downloading, activating code versions, and Commands for managing cartridge code on B2C Commerce instances. +## API Backend + +The `code list`, `code activate`, and `code delete` commands support both OCAPI and SCAPI backends. By default (`auto` mode), SCAPI is preferred when `shortCode` and `tenantId` are configured. If SCAPI scopes are unavailable, the CLI falls back to OCAPI transparently. + +```bash +# Force SCAPI +b2c code list --api-backend scapi + +# Force OCAPI +b2c code list --api-backend ocapi +``` + +Or set in `dw.json`: `"api-backend": "scapi"`. Or `SFCC_API_BACKEND=scapi` env var. + +::: tip +The `code activate --reload` flag forces an OCAPI call regardless of `--api-backend`, since SCAPI does not expose the cache-rebuild operation. +::: + +::: tip +The `code deploy`, `code download`, and `code watch` commands always use WebDAV (no SCAPI equivalent for cartridge file transfer). +::: + ## Authentication Code commands use different authentication depending on the operation: @@ -13,7 +35,8 @@ Code commands use different authentication depending on the operation: | Operation | Auth Required | |-----------|--------------| | `code deploy`, `code download`, `code watch` | WebDAV (Basic Auth or OAuth) | -| `code list`, `code activate`, `code delete` | OAuth + OCAPI | +| `code list`, `code activate`, `code delete` (SCAPI) | OAuth + `sfcc.scripts` (read) or `sfcc.scripts.rw` (write) + tenant scope | +| `code list`, `code activate`, `code delete` (OCAPI) | OAuth + OCAPI permissions for `/code_versions` | ### WebDAV Operations (deploy, download, watch) @@ -24,16 +47,16 @@ export SFCC_USERNAME=your-bm-username export SFCC_PASSWORD=your-webdav-access-key ``` -### OCAPI Operations (list, activate, delete) +### SCAPI / OCAPI Operations (list, activate, delete) -These commands require OAuth authentication with OCAPI permissions for the `/code_versions` resource configured in Business Manager. +These commands require OAuth authentication. For SCAPI, configure the `sfcc.scripts.rw` scope on your API client in Account Manager. For OCAPI, configure permissions for the `/code_versions` resource in Business Manager. ```bash export SFCC_CLIENT_ID=your-client-id export SFCC_CLIENT_SECRET=your-client-secret ``` -For complete setup instructions including OCAPI configuration, see the [Authentication Guide](/guide/authentication). +For complete setup instructions, see the [Authentication Guide](/guide/authentication). --- diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index ee512f218..6d21ba1ad 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -268,7 +268,7 @@ For the full command reference with all flags, see [Setup Commands](/cli/setup). | `certificate` | Path to PKCS12 certificate for two-factor auth (mTLS) | | `certificate-passphrase` | Passphrase for the certificate. Also accepts `passphrase`. | | `self-signed` | Allow self-signed server certificates. Also accepts `selfsigned`. | -| `api-backend` | API backend for operations: `ocapi`, `scapi`, or `auto` (default). Auto prefers SCAPI when `shortCode` and `tenant-id` are set. | +| `api-backend` | API backend for `job`, `code`, `bm users`, and `bm roles` commands: `ocapi`, `scapi`, or `auto` (default). Auto prefers SCAPI when `shortCode` and `tenant-id` are set, falling back to OCAPI on missing scopes. | ### Two-Factor Authentication (mTLS) diff --git a/skills/b2c-cli/skills/b2c-bm-users-roles/SKILL.md b/skills/b2c-cli/skills/b2c-bm-users-roles/SKILL.md index 9a4848aae..12e28d2e3 100644 --- a/skills/b2c-cli/skills/b2c-bm-users-roles/SKILL.md +++ b/skills/b2c-cli/skills/b2c-bm-users-roles/SKILL.md @@ -11,6 +11,22 @@ Use the `b2c bm` commands to administer instance-level Business Manager resource For **Account Manager** user/role/client management (cross-instance, scoped to tenants), see the `b2c-cli:b2c-am` skill instead. +## API Backend + +`bm users` (list, get, update, delete) and `bm roles` (all subcommands including permissions) support both the OCAPI Data API and the SCAPI Merchant Users / Merchant Roles APIs. Auto mode (default) prefers SCAPI when `shortCode` and `tenantId` are configured. + +```bash +# force SCAPI (requires sfcc.users.rw / sfcc.roles.rw scope) +b2c bm users list --api-backend scapi + +# force OCAPI +b2c bm roles get Administrator --api-backend ocapi +``` + +OCAPI-only commands (no SCAPI equivalent): `bm users search`, `bm whoami`, `bm access-key *`. + +`bm users update --disabled` requires OCAPI (SCAPI's PATCH endpoint doesn't support changing `disabled`). Auto mode falls back to OCAPI for that case. + ## Authentication Most BM commands accept either client credentials or browser-based user auth. A handful require a *real BM user identity* and the CLI defaults those to user-auth automatically. diff --git a/skills/b2c-cli/skills/b2c-code/SKILL.md b/skills/b2c-cli/skills/b2c-code/SKILL.md index 1712b54bd..f4fe4c1f2 100644 --- a/skills/b2c-cli/skills/b2c-code/SKILL.md +++ b/skills/b2c-cli/skills/b2c-code/SKILL.md @@ -106,6 +106,20 @@ b2c code activate --reload b2c code delete ``` +### API Backend Selection + +`code list`, `code activate`, and `code delete` support both OCAPI and SCAPI. Auto mode (default) prefers SCAPI when `shortCode` and `tenantId` are configured. + +```bash +# force SCAPI (requires sfcc.scripts.rw scope) +b2c code list --api-backend scapi + +# force OCAPI +b2c code list --api-backend ocapi +``` + +`code activate --reload` always uses OCAPI (no SCAPI cache-rebuild equivalent). `code deploy`, `code download`, `code watch` always use WebDAV. + ### More Commands See `b2c code --help` for a full list of available commands and options in the `code` topic. From d79244ba343e2c3c861415ebe29c14c90cc58b7a Mon Sep 17 00:00:00 2001 From: Charles Lavery Date: Fri, 8 May 2026 14:45:07 -0400 Subject: [PATCH 07/22] Refactor SCAPI dual-backend pattern: extract generics, fix bugs Three correctness fixes plus four DRY extractions across the SCAPI migration. Tests stay green (1722 SDK + 1219 CLI) and the API surface is unchanged for consumers. Bugs fixed: - code activate --reload now works in auto mode. The reload toggle (list + activate(alt) + activate(target)) is implementable on any backend, so reloadCodeVersion is a backend-agnostic free function that takes a ScriptsBackend. The OCAPI-only stub in ScapiScriptsBackend that previously broke fallback is gone. - job run --body is no longer subject to a special-case backend switch. SCAPI accepts raw bodies for system jobs (just with a slightly different payload shape) so we pass --body through to whichever backend the user picked. Removes a no-op resolveBackend helper that called createJobsBackend twice. - Scope merging now works for any AuthStrategy. The instanceof OAuthStrategy check silently dropped scopes for ImplicitOAuthStrategy and StatefulOAuthStrategy. AuthStrategy gains an optional withAdditionalScopes; a new withScopes() helper centralizes the method-presence check across all SCAPI client factories. DRY extractions: - Fallback*Backend subclasses (jobs, scripts, users, roles) replaced with a single Proxy-based createFallbackBackend(). Each domain shed ~25 lines of mechanical method delegation. The proxy traps reads of `name` and routes method calls through the same withFallback logic. - create*Backend factory functions (jobs, scripts, users, roles) collapsed into createDualBackend() that takes constructors. Each domain backend.ts shrunk from ~50 lines to ~10. - createScapi*Client factories collapsed into buildScapiClient

() that takes a path segment, scope set, and middleware key. Each domain client.ts shrunk from ~60 lines to ~30. - InstanceCommand gained createBackend() so per-domain command base classes (JobCommand/CodeCommand/BmCommand) shrink to one-line wrappers. Other: - Renamed JobExecutionResult to JobExecutionInfo for consistency with CodeVersionInfo, UserInfo, RoleInfo across the canonical types. --- .../b2c-cli/src/commands/code/activate.ts | 3 +- packages/b2c-cli/src/commands/code/deploy.ts | 3 +- packages/b2c-cli/src/commands/job/log.ts | 6 +- packages/b2c-cli/src/commands/job/run.ts | 23 +-- packages/b2c-cli/src/commands/job/search.ts | 4 +- packages/b2c-cli/src/commands/job/wait.ts | 4 +- .../test/commands/code/activate.test.ts | 16 ++- .../b2c-cli/test/commands/code/deploy.test.ts | 5 +- packages/b2c-tooling-sdk/src/auth/types.ts | 12 ++ .../b2c-tooling-sdk/src/cli/bm-command.ts | 24 +--- .../b2c-tooling-sdk/src/cli/code-command.ts | 12 +- .../src/cli/instance-command.ts | 24 ++++ .../b2c-tooling-sdk/src/cli/job-command.ts | 26 +--- .../src/clients/dual-backend-factory.ts | 99 +++++++++++++ packages/b2c-tooling-sdk/src/clients/index.ts | 8 +- .../src/clients/scapi-backend-utils.ts | 16 +++ .../src/clients/scapi-client-factory.ts | 109 ++++++++++++++ .../src/clients/scapi-fallback-backend.ts | 136 +++++++++++------- .../b2c-tooling-sdk/src/clients/scapi-jobs.ts | 42 ++---- .../src/clients/scapi-merchant-roles.ts | 44 ++---- .../src/clients/scapi-merchant-users.ts | 44 ++---- .../src/clients/scapi-scripts.ts | 44 ++---- packages/b2c-tooling-sdk/src/index.ts | 24 +--- .../src/operations/bm-roles/backend.ts | 84 +---------- .../src/operations/bm-roles/index.ts | 2 +- .../src/operations/bm-roles/scapi-backend.ts | 2 + .../src/operations/bm-users/backend.ts | 72 +--------- .../src/operations/bm-users/index.ts | 2 +- .../src/operations/bm-users/scapi-backend.ts | 2 + .../src/operations/code/deploy.ts | 6 +- .../src/operations/code/index.ts | 3 +- .../operations/code/ocapi-scripts-backend.ts | 5 - .../operations/code/scapi-scripts-backend.ts | 6 +- .../src/operations/code/scripts-backend.ts | 89 +++++------- .../src/operations/code/scripts-types.ts | 12 +- .../src/operations/code/versions.ts | 48 ------- .../src/operations/jobs/backend.ts | 73 ++-------- .../src/operations/jobs/index.ts | 4 +- .../src/operations/jobs/ocapi-backend.ts | 12 +- .../src/operations/jobs/scapi-backend.ts | 14 +- .../src/operations/jobs/types.ts | 10 +- .../test/operations/code/versions.test.ts | 11 +- 42 files changed, 545 insertions(+), 640 deletions(-) create mode 100644 packages/b2c-tooling-sdk/src/clients/dual-backend-factory.ts create mode 100644 packages/b2c-tooling-sdk/src/clients/scapi-client-factory.ts diff --git a/packages/b2c-cli/src/commands/code/activate.ts b/packages/b2c-cli/src/commands/code/activate.ts index f51b8b8e6..11b820f74 100644 --- a/packages/b2c-cli/src/commands/code/activate.ts +++ b/packages/b2c-cli/src/commands/code/activate.ts @@ -5,6 +5,7 @@ */ import {Args, Flags} from '@oclif/core'; import {CodeCommand} from '@salesforce/b2c-tooling-sdk/cli'; +import {reloadCodeVersion} from '@salesforce/b2c-tooling-sdk/operations/code'; import {t, withDocs} from '../../i18n/index.js'; export default class CodeActivate extends CodeCommand { @@ -67,7 +68,7 @@ export default class CodeActivate extends CodeCommand { ); try { - await backend.reloadCodeVersion(codeVersion); + await reloadCodeVersion(backend, codeVersion); this.log( t('commands.code.activate.reloaded', 'Code version{{version}} reloaded successfully', { version: codeVersion ? ` ${codeVersion}` : '', diff --git a/packages/b2c-cli/src/commands/code/deploy.ts b/packages/b2c-cli/src/commands/code/deploy.ts index a64ea4dd8..a0b3ff717 100644 --- a/packages/b2c-cli/src/commands/code/deploy.ts +++ b/packages/b2c-cli/src/commands/code/deploy.ts @@ -10,6 +10,7 @@ import { getActiveCodeVersion, activateCodeVersion, reloadCodeVersion, + OcapiScriptsBackend, type DeployResult, } from '@salesforce/b2c-tooling-sdk/operations/code'; import {CartridgeCommand} from '@salesforce/b2c-tooling-sdk/cli'; @@ -203,7 +204,7 @@ export default class CodeDeploy extends CartridgeCommand { await this.operations.activateCodeVersion(this.instance, version); activated = true; } else if (this.flags.reload) { - await this.operations.reloadCodeVersion(this.instance, version); + await this.operations.reloadCodeVersion(new OcapiScriptsBackend(this.instance), version); activated = true; reloaded = true; } diff --git a/packages/b2c-cli/src/commands/job/log.ts b/packages/b2c-cli/src/commands/job/log.ts index 5103077b0..828e31061 100644 --- a/packages/b2c-cli/src/commands/job/log.ts +++ b/packages/b2c-cli/src/commands/job/log.ts @@ -5,12 +5,12 @@ */ import {Args, Flags} from '@oclif/core'; import {JobCommand} from '@salesforce/b2c-tooling-sdk/cli'; -import {type JobExecutionResult} from '@salesforce/b2c-tooling-sdk/operations/jobs'; +import {type JobExecutionInfo} from '@salesforce/b2c-tooling-sdk/operations/jobs'; import {t, withDocs} from '../../i18n/index.js'; import {highlightLogText} from '../../utils/logs/index.js'; interface JobLogResult { - execution: JobExecutionResult; + execution: JobExecutionInfo; log: string; } @@ -61,7 +61,7 @@ export default class JobLog extends JobCommand { const backend = this.createJobsBackend(); this.logger.debug(`Using ${backend.name} backend for job log`); - let execution: JobExecutionResult; + let execution: JobExecutionInfo; if (executionId) { this.log( diff --git a/packages/b2c-cli/src/commands/job/run.ts b/packages/b2c-cli/src/commands/job/run.ts index 91699db4c..fb3ae01bc 100644 --- a/packages/b2c-cli/src/commands/job/run.ts +++ b/packages/b2c-cli/src/commands/job/run.ts @@ -9,7 +9,7 @@ import { waitForJobExecution, JobExecutionError, type JobsBackend, - type JobExecutionResult, + type JobExecutionInfo, } from '@salesforce/b2c-tooling-sdk/operations/jobs'; import {t, withDocs} from '../../i18n/index.js'; @@ -76,7 +76,7 @@ export default class JobRun extends JobCommand { static hiddenAliases = ['job:run']; - async run(): Promise { + async run(): Promise { this.requireOAuthCredentials(); const {jobId} = this.args; @@ -103,9 +103,7 @@ export default class JobRun extends JobCommand { const parameters = this.parseParameters(param || []); const rawBody = body ? this.parseBody(body) : undefined; - // When --body is used with auto mode, force OCAPI since raw bodies use OCAPI format - const backend = this.resolveBackend(rawBody); - + const backend = this.createJobsBackend(); this.logger.debug(`Using ${backend.name} backend for job operations`); // Create lifecycle context @@ -129,7 +127,7 @@ export default class JobRun extends JobCommand { id: '', jobId, executionStatus: 'finished', - } as unknown as JobExecutionResult; + } as unknown as JobExecutionInfo; } this.log( @@ -139,7 +137,7 @@ export default class JobRun extends JobCommand { }), ); - let execution: JobExecutionResult; + let execution: JobExecutionInfo; try { execution = await backend.executeJob(jobId, { parameters: rawBody ? undefined : parameters, @@ -241,15 +239,6 @@ export default class JobRun extends JobCommand { }); } - private resolveBackend(rawBody: Record | undefined): JobsBackend { - const preference = this.resolvedConfig.values.apiBackend ?? 'auto'; - if (rawBody && preference === 'auto') { - this.logger.debug('Raw body provided with auto mode; using OCAPI backend'); - return this.createJobsBackend(); - } - return this.createJobsBackend(); - } - private async waitForJobCompletion(options: { backend: JobsBackend; jobId: string; @@ -258,7 +247,7 @@ export default class JobRun extends JobCommand { pollInterval: number | undefined; showLog: boolean; context: B2COperationContext; - }): Promise { + }): Promise { const {backend, jobId, executionId, timeout, pollInterval, showLog, context} = options; this.log(t('commands.job.run.waiting', 'Waiting for job to complete...')); diff --git a/packages/b2c-cli/src/commands/job/search.ts b/packages/b2c-cli/src/commands/job/search.ts index dfe9247f6..9a387f205 100644 --- a/packages/b2c-cli/src/commands/job/search.ts +++ b/packages/b2c-cli/src/commands/job/search.ts @@ -11,10 +11,10 @@ import { selectColumns, type ColumnDef, } from '@salesforce/b2c-tooling-sdk/cli'; -import {type JobExecutionResult, type JobExecutionSearchResults} from '@salesforce/b2c-tooling-sdk/operations/jobs'; +import {type JobExecutionInfo, type JobExecutionSearchResults} from '@salesforce/b2c-tooling-sdk/operations/jobs'; import {t, withDocs} from '../../i18n/index.js'; -const COLUMNS: Record> = { +const COLUMNS: Record> = { id: { header: 'Execution ID', get: (e) => e.id ?? '-', diff --git a/packages/b2c-cli/src/commands/job/wait.ts b/packages/b2c-cli/src/commands/job/wait.ts index 693977ad9..cdc9454ed 100644 --- a/packages/b2c-cli/src/commands/job/wait.ts +++ b/packages/b2c-cli/src/commands/job/wait.ts @@ -8,7 +8,7 @@ import {JobCommand} from '@salesforce/b2c-tooling-sdk/cli'; import { waitForJobExecution, JobExecutionError, - type JobExecutionResult, + type JobExecutionInfo, } from '@salesforce/b2c-tooling-sdk/operations/jobs'; import {t, withDocs} from '../../i18n/index.js'; @@ -53,7 +53,7 @@ export default class JobWait extends JobCommand { }), }; - async run(): Promise { + async run(): Promise { this.requireOAuthCredentials(); const {jobId, executionId} = this.args; diff --git a/packages/b2c-cli/test/commands/code/activate.test.ts b/packages/b2c-cli/test/commands/code/activate.test.ts index 82f65d543..d38d94335 100644 --- a/packages/b2c-cli/test/commands/code/activate.test.ts +++ b/packages/b2c-cli/test/commands/code/activate.test.ts @@ -29,7 +29,6 @@ describe('code activate', () => { activateCodeVersion: sinon.stub(), deleteCodeVersion: sinon.stub(), createCodeVersion: sinon.stub(), - reloadCodeVersion: sinon.stub(), }; } @@ -70,18 +69,25 @@ describe('code activate', () => { it('reloads the active code version when --reload is set and no arg is provided', async () => { const command: any = await createCommand({reload: true}, {}); const backend = stubCommon(command); - backend.reloadCodeVersion.resolves(); + // reloadCodeVersion is now backend-agnostic: list+activate(alt)+activate(target) + backend.listCodeVersions.resolves([ + {id: 'v1', active: true}, + {id: 'v2', active: false}, + ]); + backend.activateCodeVersion.resolves(); await command.run(); - expect(backend.reloadCodeVersion.calledOnce).to.be.true; - expect(backend.reloadCodeVersion.firstCall.args[0]).to.equal(undefined); + // Called twice: alternate then target + expect(backend.activateCodeVersion.callCount).to.equal(2); + expect(backend.activateCodeVersion.getCall(0).args[0]).to.equal('v2'); + expect(backend.activateCodeVersion.getCall(1).args[0]).to.equal('v1'); }); it('calls command.error when reload fails with an error message', async () => { const command: any = await createCommand({reload: true}, {codeVersion: 'v1'}); const backend = stubCommon(command); - backend.reloadCodeVersion.rejects(new Error('boom')); + backend.listCodeVersions.rejects(new Error('boom')); const errorStub = sinon.stub(command, 'error').throws(new Error('Expected error')); diff --git a/packages/b2c-cli/test/commands/code/deploy.test.ts b/packages/b2c-cli/test/commands/code/deploy.test.ts index b7e9fa1dd..c95f8ca34 100644 --- a/packages/b2c-cli/test/commands/code/deploy.test.ts +++ b/packages/b2c-cli/test/commands/code/deploy.test.ts @@ -86,7 +86,10 @@ describe('code deploy', () => { expect(uploadStub.calledOnce).to.be.true; expect(uploadStub.firstCall.args[0]).to.equal(instance); expect(uploadStub.firstCall.args[1]).to.equal(cartridges); - expect(reloadStub.calledOnceWithExactly(instance, 'v1')).to.be.true; + expect(reloadStub.calledOnce).to.be.true; + // First arg is now a ScriptsBackend (OcapiScriptsBackend wrapping the instance), not the instance directly + expect(reloadStub.firstCall.args[0]).to.have.property('listCodeVersions'); + expect(reloadStub.firstCall.args[1]).to.equal('v1'); expect(result).to.deep.include({codeVersion: 'v1', activated: true, reloaded: true}); expect(afterHooksStub.calledOnce).to.be.true; diff --git a/packages/b2c-tooling-sdk/src/auth/types.ts b/packages/b2c-tooling-sdk/src/auth/types.ts index 92d74effe..835cc0e12 100644 --- a/packages/b2c-tooling-sdk/src/auth/types.ts +++ b/packages/b2c-tooling-sdk/src/auth/types.ts @@ -31,6 +31,18 @@ export interface AuthStrategy { * Used by middleware to retry requests after receiving a 401 response. */ invalidateToken?(): void; + + /** + * Optional: Returns a copy of this strategy with the given scopes merged into + * its requested scope set. SCAPI client factories use this to ensure the + * domain scope (e.g., `sfcc.jobs.rw`) and the tenant scope are present. + * + * Implemented by `OAuthStrategy` and `JwtOAuthStrategy`. Strategies that + * obtain tokens by other means (basic, api-key, implicit-via-stored-session) + * may not implement this; callers should treat them as "scopes already + * established at construction time." + */ + withAdditionalScopes?(additionalScopes: string[]): AuthStrategy; } /** diff --git a/packages/b2c-tooling-sdk/src/cli/bm-command.ts b/packages/b2c-tooling-sdk/src/cli/bm-command.ts index bef30b390..4155e6cc2 100644 --- a/packages/b2c-tooling-sdk/src/cli/bm-command.ts +++ b/packages/b2c-tooling-sdk/src/cli/bm-command.ts @@ -16,31 +16,11 @@ import {createRolesBackend, type RolesBackend} from '../operations/bm-roles/inde * configured, falling back to OCAPI on `invalid_scope`. */ export abstract class BmCommand extends InstanceCommand { - /** - * Creates a Users backend for `bm users *` commands. - */ protected createUsersBackend(): UsersBackend { - const preference = this.resolvedConfig.values.apiBackend ?? 'auto'; - return createUsersBackend({ - preference, - instance: this.instance, - shortCode: this.resolvedConfig.values.shortCode, - tenantId: this.resolvedConfig.values.tenantId, - auth: this.hasOAuthCredentials() ? this.getOAuthStrategy() : undefined, - }); + return this.createBackend(createUsersBackend); } - /** - * Creates a Roles backend for `bm roles *` commands. - */ protected createRolesBackend(): RolesBackend { - const preference = this.resolvedConfig.values.apiBackend ?? 'auto'; - return createRolesBackend({ - preference, - instance: this.instance, - shortCode: this.resolvedConfig.values.shortCode, - tenantId: this.resolvedConfig.values.tenantId, - auth: this.hasOAuthCredentials() ? this.getOAuthStrategy() : undefined, - }); + return this.createBackend(createRolesBackend); } } diff --git a/packages/b2c-tooling-sdk/src/cli/code-command.ts b/packages/b2c-tooling-sdk/src/cli/code-command.ts index 082bbf390..a0c23246b 100644 --- a/packages/b2c-tooling-sdk/src/cli/code-command.ts +++ b/packages/b2c-tooling-sdk/src/cli/code-command.ts @@ -16,17 +16,7 @@ import {createScriptsBackend, type ScriptsBackend} from '../operations/code/inde * back to OCAPI on `invalid_scope`. */ export abstract class CodeCommand extends InstanceCommand { - /** - * Creates a Scripts backend based on the resolved configuration. - */ protected createScriptsBackend(): ScriptsBackend { - const preference = this.resolvedConfig.values.apiBackend ?? 'auto'; - return createScriptsBackend({ - preference, - instance: this.instance, - shortCode: this.resolvedConfig.values.shortCode, - tenantId: this.resolvedConfig.values.tenantId, - auth: this.hasOAuthCredentials() ? this.getOAuthStrategy() : undefined, - }); + return this.createBackend(createScriptsBackend); } } diff --git a/packages/b2c-tooling-sdk/src/cli/instance-command.ts b/packages/b2c-tooling-sdk/src/cli/instance-command.ts index 209fbb54a..e3c8a4679 100644 --- a/packages/b2c-tooling-sdk/src/cli/instance-command.ts +++ b/packages/b2c-tooling-sdk/src/cli/instance-command.ts @@ -190,6 +190,30 @@ export abstract class InstanceCommand extends OAuthCom return loadConfig(extractInstanceFlags(this.flags as Record), this.getBaseConfigOptions()); } + /** + * Creates a SCAPI/OCAPI dual backend by passing the resolved configuration + * (apiBackend preference, instance, shortCode, tenantId, OAuth) to the + * supplied factory. Each backend domain (jobs, scripts, users, roles) + * exports its own factory; this helper supplies the same plumbing for all. + * + * @example + * ```ts + * const backend = this.createBackend(createJobsBackend); + * await backend.executeJob('my-job'); + * ``` + */ + protected createBackend( + factory: (config: import('../clients/dual-backend-factory.js').DualBackendConfig) => T, + ): T { + return factory({ + preference: this.resolvedConfig.values.apiBackend ?? 'auto', + instance: this.instance, + shortCode: this.resolvedConfig.values.shortCode, + tenantId: this.resolvedConfig.values.tenantId, + auth: this.hasOAuthCredentials() ? this.getOAuthStrategy() : undefined, + }); + } + /** * Gets the B2CInstance for this command. * diff --git a/packages/b2c-tooling-sdk/src/cli/job-command.ts b/packages/b2c-tooling-sdk/src/cli/job-command.ts index bb901e6f3..ea9753d1f 100644 --- a/packages/b2c-tooling-sdk/src/cli/job-command.ts +++ b/packages/b2c-tooling-sdk/src/cli/job-command.ts @@ -6,7 +6,7 @@ import {Command} from '@oclif/core'; import {InstanceCommand} from './instance-command.js'; import {getJobLog, getJobErrorMessage, type JobExecution} from '../operations/jobs/index.js'; -import {createJobsBackend, type JobsBackend, type JobExecutionResult} from '../operations/jobs/index.js'; +import {createJobsBackend, type JobsBackend, type JobExecutionInfo} from '../operations/jobs/index.js'; import {t} from '../i18n/index.js'; /** @@ -24,35 +24,23 @@ import {t} from '../i18n/index.js'; * } */ export abstract class JobCommand extends InstanceCommand { - /** - * Creates a jobs backend based on the resolved configuration. - * In auto mode (default), prefers SCAPI when shortCode+tenantId are configured, - * falling back to OCAPI if SCAPI scopes are unavailable. - */ protected createJobsBackend(): JobsBackend { - const preference = this.resolvedConfig.values.apiBackend ?? 'auto'; - return createJobsBackend({ - preference, - instance: this.instance, - shortCode: this.resolvedConfig.values.shortCode, - tenantId: this.resolvedConfig.values.tenantId, - auth: this.hasOAuthCredentials() ? this.getOAuthStrategy() : undefined, - }); + return this.createBackend(createJobsBackend); } /** * Display a job's log file content and error message if available. - * Accepts both canonical JobExecutionResult and legacy OCAPI JobExecution. + * Accepts both canonical JobExecutionInfo and legacy OCAPI JobExecution. * Outputs to stderr since this is typically shown for failed jobs. */ - protected async showJobLog(execution: JobExecutionResult | JobExecution): Promise { + protected async showJobLog(execution: JobExecutionInfo | JobExecution): Promise { if (isCanonicalExecution(execution)) { return this.showCanonicalJobLog(execution); } return this.showOcapiJobLog(execution); } - private async showCanonicalJobLog(execution: JobExecutionResult): Promise { + private async showCanonicalJobLog(execution: JobExecutionInfo): Promise { const errorMessage = getCanonicalJobErrorMessage(execution); if (!execution.isLogFileExisting) { @@ -110,11 +98,11 @@ export abstract class JobCommand extends InstanceComma } } -function isCanonicalExecution(execution: JobExecutionResult | JobExecution): execution is JobExecutionResult { +function isCanonicalExecution(execution: JobExecutionInfo | JobExecution): execution is JobExecutionInfo { return 'executionStatus' in execution; } -function getCanonicalJobErrorMessage(execution: JobExecutionResult): string | undefined { +function getCanonicalJobErrorMessage(execution: JobExecutionInfo): string | undefined { if (!execution.stepExecutions || execution.stepExecutions.length === 0) { return undefined; } diff --git a/packages/b2c-tooling-sdk/src/clients/dual-backend-factory.ts b/packages/b2c-tooling-sdk/src/clients/dual-backend-factory.ts new file mode 100644 index 000000000..6d243673b --- /dev/null +++ b/packages/b2c-tooling-sdk/src/clients/dual-backend-factory.ts @@ -0,0 +1,99 @@ +/* + * 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 + */ +/** + * Generic factory for SCAPI/OCAPI dual backends. + * + * Replaces the per-domain `create*Backend()` functions (jobs, scripts, + * users, roles) which were 100% structurally identical. Each domain now + * supplies its constructors and config and delegates to {@link createDualBackend}. + * + * @module clients/dual-backend-factory + */ +import type {AuthStrategy} from '../auth/types.js'; +import type {B2CInstance} from '../instance/index.js'; +import {createFallbackBackend} from './scapi-fallback-backend.js'; +import {resolveScapiOrOcapi, type ApiBackendPreference, type BackendBase} from './scapi-backend-utils.js'; + +/** + * Common shape of every dual-backend factory's input. + */ +export interface DualBackendConfig { + preference: ApiBackendPreference; + instance: B2CInstance; + shortCode?: string; + tenantId?: string; + auth?: AuthStrategy; +} + +/** + * Configuration passed to a SCAPI backend constructor. Domains add their + * own optional fields (e.g., `instance` for log/WebDAV access on jobs) but + * always include shortCode + tenantId + auth. + */ +export interface ScapiBackendCtorConfig { + shortCode: string; + tenantId: string; + auth: AuthStrategy; + instance: B2CInstance; +} + +/** + * Constructors needed to build a dual-backend instance. Each domain plugs in + * its own SCAPI/OCAPI backend classes; the factory wires them together. + */ +export interface DualBackendCtors { + domainName: string; + Scapi: new (config: ScapiBackendCtorConfig) => T; + Ocapi: new (instance: B2CInstance) => T; +} + +/** + * Resolves the user's preference + config availability into a concrete + * backend instance. + * + * - Explicit `'ocapi'` returns an OCAPI backend. + * - Explicit `'scapi'` returns a SCAPI backend (throws if config missing). + * - `'auto'` returns a fallback Proxy that tries SCAPI first, falls back to + * OCAPI on `invalid_scope`. + * + * @example + * ```ts + * export function createJobsBackend(config: JobsBackendConfig): JobsBackend { + * return createDualBackend(config, { + * domainName: 'Jobs', + * Scapi: ScapiJobsBackend, + * Ocapi: OcapiJobsBackend, + * }); + * } + * ``` + */ +export function createDualBackend(config: DualBackendConfig, ctors: DualBackendCtors): T { + const hasScapiConfig = Boolean(config.shortCode && config.tenantId && config.auth); + const resolved = resolveScapiOrOcapi({ + preference: config.preference, + hasScapiConfig, + domainName: ctors.domainName, + }); + + if (resolved === 'ocapi') { + return new ctors.Ocapi(config.instance); + } + + const scapiBackend = new ctors.Scapi({ + shortCode: config.shortCode!, + tenantId: config.tenantId!, + auth: config.auth!, + instance: config.instance, + }); + + if (config.preference === 'scapi') { + return scapiBackend; + } + + // Auto mode: wrap with fallback + const ocapiBackend = new ctors.Ocapi(config.instance); + return createFallbackBackend(scapiBackend, ocapiBackend, ctors.domainName.toLowerCase()); +} diff --git a/packages/b2c-tooling-sdk/src/clients/index.ts b/packages/b2c-tooling-sdk/src/clients/index.ts index bbdba1832..9fcbf9181 100644 --- a/packages/b2c-tooling-sdk/src/clients/index.ts +++ b/packages/b2c-tooling-sdk/src/clients/index.ts @@ -378,9 +378,13 @@ export type { } from './scapi-scripts.js'; // SCAPI dual-backend utilities (shared across SCAPI/OCAPI domains) -export {isInvalidScopeError, resolveScapiOrOcapi} from './scapi-backend-utils.js'; +export {isInvalidScopeError, resolveScapiOrOcapi, withScopes} from './scapi-backend-utils.js'; export type {ApiBackendPreference, BackendBase, ResolveBackendOptions} from './scapi-backend-utils.js'; -export {ScapiFallbackBackend} from './scapi-fallback-backend.js'; +export {createFallbackBackend} from './scapi-fallback-backend.js'; +export {createDualBackend} from './dual-backend-factory.js'; +export type {DualBackendConfig, DualBackendCtors, ScapiBackendCtorConfig} from './dual-backend-factory.js'; +export {buildScapiClient} from './scapi-client-factory.js'; +export type {BuildScapiClientOptions, ScapiClientConfig} from './scapi-client-factory.js'; export {ScopeTierManager} from './scapi-scope-tier.js'; export type {ScopeTier, ScopeTierManagerOptions} from './scapi-scope-tier.js'; diff --git a/packages/b2c-tooling-sdk/src/clients/scapi-backend-utils.ts b/packages/b2c-tooling-sdk/src/clients/scapi-backend-utils.ts index 87a79d965..fbdc5d838 100644 --- a/packages/b2c-tooling-sdk/src/clients/scapi-backend-utils.ts +++ b/packages/b2c-tooling-sdk/src/clients/scapi-backend-utils.ts @@ -12,6 +12,7 @@ * * @module clients/scapi-backend-utils */ +import type {AuthStrategy} from '../auth/types.js'; /** * User-facing API backend preference. @@ -31,6 +32,21 @@ export interface BackendBase { readonly name: 'ocapi' | 'scapi'; } +/** + * Returns a copy of `auth` with `additionalScopes` merged in, or the original + * `auth` if the strategy doesn't support scope merging (e.g., basic/api-key + * auth, or a stored-session strategy where scopes were fixed at acquisition). + * + * Centralized so SCAPI client factories don't have to keep extending an + * `instanceof` chain as new OAuth strategy types are added. + */ +export function withScopes(auth: AuthStrategy, additionalScopes: string[]): AuthStrategy { + if (typeof auth.withAdditionalScopes === 'function') { + return auth.withAdditionalScopes(additionalScopes); + } + return auth; +} + /** * Detects an Account Manager `invalid_scope` error. * diff --git a/packages/b2c-tooling-sdk/src/clients/scapi-client-factory.ts b/packages/b2c-tooling-sdk/src/clients/scapi-client-factory.ts new file mode 100644 index 000000000..73f272bf5 --- /dev/null +++ b/packages/b2c-tooling-sdk/src/clients/scapi-client-factory.ts @@ -0,0 +1,109 @@ +/* + * 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 + */ +/** + * Generic builder for SCAPI Admin API clients. + * + * The four new SCAPI clients (jobs, scripts, merchant-users, merchant-roles) + * each had ~20 lines of nearly-identical setup: build the openapi-fetch + * client with a domain URL, install auth middleware with merged scopes, + * install plugin middleware from the registry, then rate-limit and logging. + * + * This module collapses that setup into one helper. + * + * @module clients/scapi-client-factory + */ +import createClient, {type Client} from 'openapi-fetch'; +import type {AuthStrategy} from '../auth/types.js'; +import {createAuthMiddleware, createLoggingMiddleware, createRateLimitMiddleware} from './middleware.js'; +import {globalMiddlewareRegistry, type HttpClientType, type MiddlewareRegistry} from './middleware-registry.js'; +import {buildTenantScope} from './custom-apis.js'; +import {withScopes} from './scapi-backend-utils.js'; + +export interface BuildScapiClientOptions { + /** + * URL path segment after the SCAPI host root, e.g. `'operation/jobs/v1'`. + */ + pathSegment: string; + /** + * Middleware registry key, e.g. `'scapi-jobs'`. Plugin middleware + * registered under this key gets installed on the client. + */ + domainKey: HttpClientType; + /** + * Default scopes to request when the caller doesn't override `config.scopes`. + * Typically the rw scope; the tenant scope is added automatically. + */ + defaultScopes: string[]; + /** + * Logging/rate-limit prefix, e.g. `'SCAPI-JOBS'`. Used in log lines. + */ + logPrefix: string; +} + +export interface ScapiClientConfig { + shortCode: string; + tenantId: string; + /** + * Override the requested scopes. When omitted, defaults to + * `[...defaultScopes, buildTenantScope(tenantId)]`. + */ + scopes?: string[]; + /** Override the global middleware registry (mainly for tests). */ + middlewareRegistry?: MiddlewareRegistry; +} + +/** + * Builds a typed openapi-fetch client for a SCAPI Admin API. + * + * @param options - Domain-specific URL/key/scopes/log-prefix + * @param config - Caller-supplied shortCode, tenantId, optional overrides + * @param auth - Auth strategy (scopes are merged via {@link withScopes}) + * + * @example + * ```ts + * export function createScapiJobsClient(config: ScapiClientConfig, auth: AuthStrategy): ScapiJobsClient { + * return buildScapiClient( + * { + * pathSegment: 'operation/jobs/v1', + * domainKey: 'scapi-jobs', + * defaultScopes: SCAPI_JOBS_RW_SCOPES, + * logPrefix: 'SCAPI-JOBS', + * }, + * config, + * auth, + * ); + * } + * ``` + */ +// `paths` types from openapi-typescript are `interface paths { ... }` shapes +// which don't satisfy `Record`. The unconstrained generic +// is fine since openapi-fetch's `Client

` constraint handles the shape check. +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function buildScapiClient

>( + options: BuildScapiClientOptions, + config: ScapiClientConfig, + auth: AuthStrategy, +): Client

{ + const registry = config.middlewareRegistry ?? globalMiddlewareRegistry; + + const client = createClient

({ + baseUrl: `https://${config.shortCode}.api.commercecloud.salesforce.com/${options.pathSegment}`, + }); + + const requiredScopes = config.scopes ?? [...options.defaultScopes, buildTenantScope(config.tenantId)]; + const scopedAuth = withScopes(auth, requiredScopes); + + client.use(createAuthMiddleware(scopedAuth)); + + for (const middleware of registry.getMiddleware(options.domainKey)) { + client.use(middleware); + } + + client.use(createRateLimitMiddleware({prefix: options.logPrefix})); + client.use(createLoggingMiddleware(options.logPrefix)); + + return client; +} diff --git a/packages/b2c-tooling-sdk/src/clients/scapi-fallback-backend.ts b/packages/b2c-tooling-sdk/src/clients/scapi-fallback-backend.ts index 3d5c7e192..ac3b61e53 100644 --- a/packages/b2c-tooling-sdk/src/clients/scapi-fallback-backend.ts +++ b/packages/b2c-tooling-sdk/src/clients/scapi-fallback-backend.ts @@ -6,10 +6,11 @@ /** * Generic fallback wrapper for SCAPI/OCAPI dual backends. * - * Each domain (jobs, scripts, users, roles) gets a thin subclass that - * delegates each interface method through `withFallback`. The wrapper itself - * holds no domain knowledge — it only implements the "try SCAPI first; on - * `invalid_scope`, fall back to OCAPI; cache the choice" behavior. + * Builds a Proxy that implements the same interface as the underlying + * backends. Each method call routes through {@link withFallback}: try SCAPI + * first; on `invalid_scope`, fall back to OCAPI and cache the choice for the + * lifetime of the wrapper. The `name` property reflects the currently-active + * backend ('scapi' before the first call resolves, then whichever survived). * * @module clients/scapi-fallback-backend */ @@ -17,59 +18,94 @@ import {getLogger} from '../logging/logger.js'; import {isInvalidScopeError, type BackendBase} from './scapi-backend-utils.js'; /** - * Base class for `Fallback*Backend` implementations. Subclasses implement - * the domain interface (e.g., `JobsBackend`) by delegating each method to - * `withFallback`. + * Internal state shared by all method invocations on a Proxy. Holds the + * resolved backend so that once SCAPI succeeds (or we've fallen back to + * OCAPI), subsequent calls skip the SCAPI attempt. + */ +interface FallbackState { + scapi: T; + ocapi: T; + domainName: string; + resolved?: T; +} + +/** + * Wraps a SCAPI call with automatic OCAPI fallback on `invalid_scope`. + * + * Standalone helper so the Proxy traps and any future direct callers share + * one definition. + */ +async function withFallback( + state: FallbackState, + fn: (backend: T) => Promise, +): Promise { + if (state.resolved) { + return fn(state.resolved); + } + + try { + const result = await fn(state.scapi); + state.resolved = state.scapi; + return result; + } catch (error) { + if (isInvalidScopeError(error)) { + getLogger().info(`SCAPI ${state.domainName} scope unavailable, falling back to OCAPI`); + state.resolved = state.ocapi; + return fn(state.ocapi); + } + throw error; + } +} + +/** + * Creates a fallback wrapper over `scapi` and `ocapi` backends. + * + * The returned object presents the same interface as `T`. Method calls are + * intercepted: the first call tries SCAPI; on `invalid_scope` it falls back + * to OCAPI. The choice is cached for the wrapper's lifetime. + * + * @param scapi - Primary (SCAPI) backend implementation + * @param ocapi - Fallback (OCAPI) backend implementation + * @param domainName - Used in fallback log messages, e.g. `'jobs'` + * @returns A Proxy over `scapi` whose methods route through fallback logic * * @example * ```ts - * class FallbackJobsBackend extends ScapiFallbackBackend implements JobsBackend { - * async executeJob(jobId: string, options?: ExecuteJobOptions) { - * return this.withFallback((b) => b.executeJob(jobId, options)); - * } - * // ... one delegating method per interface method - * } + * const backend = createFallbackBackend(scapiJobs, ocapiJobs, 'jobs'); + * await backend.executeJob('my-job'); // tries SCAPI, may fall back to OCAPI * ``` */ -export abstract class ScapiFallbackBackend { - protected resolvedBackend?: T; - - constructor( - protected scapiBackend: T, - protected ocapiBackend: T, - /** Used in fallback log messages, e.g. `'jobs'`, `'scripts'`. */ - protected domainName: string, - ) {} +export function createFallbackBackend(scapi: T, ocapi: T, domainName: string): T { + const state: FallbackState = {scapi, ocapi, domainName}; - /** - * Reports the backend that served the last successful call. Defaults to - * `'scapi'` before the first call, since that's what we'd try first. - */ - get name(): 'ocapi' | 'scapi' { - return this.resolvedBackend?.name ?? this.scapiBackend.name; - } + return new Proxy(scapi, { + get(target, prop, receiver) { + // Special property: `name` reflects whichever backend has handled requests so far. + if (prop === 'name') { + return (state.resolved ?? scapi).name; + } - /** - * Runs `fn` against the resolved backend, or against SCAPI first with - * automatic OCAPI fallback on `invalid_scope`. The choice is cached: once - * a backend has succeeded (or fallen back), all subsequent calls go to it. - */ - protected async withFallback(fn: (backend: T) => Promise): Promise { - if (this.resolvedBackend) { - return fn(this.resolvedBackend); - } + const value = Reflect.get(target, prop, receiver); - try { - const result = await fn(this.scapiBackend); - this.resolvedBackend = this.scapiBackend; - return result; - } catch (error) { - if (isInvalidScopeError(error)) { - getLogger().info(`SCAPI ${this.domainName} scope unavailable, falling back to OCAPI`); - this.resolvedBackend = this.ocapiBackend; - return fn(this.ocapiBackend); + // Non-functions (constants, getters): return as-is from the SCAPI backend. + // Wrappers don't currently expose any non-method state besides `name`, + // but this keeps the Proxy transparent for property access. + if (typeof value !== 'function') { + return value; } - throw error; - } - } + + // For each method, return a wrapper that routes the call through fallback. + // We must look up the method by name on the resolved backend (not on the + // SCAPI target we're proxying), since the OCAPI backend may have a + // different implementation. + return (...args: unknown[]) => + withFallback(state, (backend) => { + const fn = (backend as unknown as Record)[prop]; + if (typeof fn !== 'function') { + throw new TypeError(`Method ${String(prop)} is not a function on ${backend.name} backend`); + } + return (fn as (...a: unknown[]) => Promise).apply(backend, args); + }); + }, + }) as T; } diff --git a/packages/b2c-tooling-sdk/src/clients/scapi-jobs.ts b/packages/b2c-tooling-sdk/src/clients/scapi-jobs.ts index 56683fb96..5935f0444 100644 --- a/packages/b2c-tooling-sdk/src/clients/scapi-jobs.ts +++ b/packages/b2c-tooling-sdk/src/clients/scapi-jobs.ts @@ -3,12 +3,10 @@ * 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 createClient, {type Client} from 'openapi-fetch'; +import type {Client} from 'openapi-fetch'; import type {AuthStrategy} from '../auth/types.js'; import type {paths, components} from './scapi-jobs.generated.js'; -import {createAuthMiddleware, createLoggingMiddleware, createRateLimitMiddleware} from './middleware.js'; -import {globalMiddlewareRegistry, type MiddlewareRegistry} from './middleware-registry.js'; -import {OAuthStrategy} from '../auth/oauth.js'; +import {buildScapiClient, type ScapiClientConfig} from './scapi-client-factory.js'; import {buildTenantScope, toOrganizationId, normalizeTenantId} from './custom-apis.js'; export {toOrganizationId, normalizeTenantId, buildTenantScope}; @@ -28,31 +26,17 @@ export type JobExecutionSearchResult = components['schemas']['JobExecutionSearch export const SCAPI_JOBS_READ_SCOPES = ['sfcc.jobs']; export const SCAPI_JOBS_RW_SCOPES = ['sfcc.jobs.rw']; -export interface ScapiJobsClientConfig { - shortCode: string; - tenantId: string; - scopes?: string[]; - middlewareRegistry?: MiddlewareRegistry; -} +export type ScapiJobsClientConfig = ScapiClientConfig; export function createScapiJobsClient(config: ScapiJobsClientConfig, auth: AuthStrategy): ScapiJobsClient { - const registry = config.middlewareRegistry ?? globalMiddlewareRegistry; - - const client = createClient({ - baseUrl: `https://${config.shortCode}.api.commercecloud.salesforce.com/operation/jobs/v1`, - }); - - const requiredScopes = config.scopes ?? [...SCAPI_JOBS_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-jobs')) { - client.use(middleware); - } - - client.use(createRateLimitMiddleware({prefix: 'SCAPI-JOBS'})); - client.use(createLoggingMiddleware('SCAPI-JOBS')); - - return client; + return buildScapiClient( + { + pathSegment: 'operation/jobs/v1', + domainKey: 'scapi-jobs', + defaultScopes: SCAPI_JOBS_RW_SCOPES, + logPrefix: 'SCAPI-JOBS', + }, + config, + auth, + ); } diff --git a/packages/b2c-tooling-sdk/src/clients/scapi-merchant-roles.ts b/packages/b2c-tooling-sdk/src/clients/scapi-merchant-roles.ts index 592f04a1b..dac7b033e 100644 --- a/packages/b2c-tooling-sdk/src/clients/scapi-merchant-roles.ts +++ b/packages/b2c-tooling-sdk/src/clients/scapi-merchant-roles.ts @@ -3,13 +3,10 @@ * 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 createClient, {type Client} from 'openapi-fetch'; +import type {Client} from 'openapi-fetch'; import type {AuthStrategy} from '../auth/types.js'; import type {paths, components} from './scapi-merchant-roles.generated.js'; -import {createAuthMiddleware, createLoggingMiddleware, createRateLimitMiddleware} from './middleware.js'; -import {globalMiddlewareRegistry, type MiddlewareRegistry} from './middleware-registry.js'; -import {OAuthStrategy} from '../auth/oauth.js'; -import {buildTenantScope} from './custom-apis.js'; +import {buildScapiClient, type ScapiClientConfig} from './scapi-client-factory.js'; export type {paths, components}; export type ScapiMerchantRolesClient = Client; @@ -23,35 +20,20 @@ export type RoleSearch = components['schemas']['RoleSearch']; export const SCAPI_MERCHANT_ROLES_READ_SCOPES = ['sfcc.roles']; export const SCAPI_MERCHANT_ROLES_RW_SCOPES = ['sfcc.roles.rw']; -export interface ScapiMerchantRolesClientConfig { - shortCode: string; - tenantId: string; - /** Override scopes (default: sfcc.roles.rw + tenant scope). */ - scopes?: string[]; - middlewareRegistry?: MiddlewareRegistry; -} +export type ScapiMerchantRolesClientConfig = ScapiClientConfig; export function createScapiMerchantRolesClient( config: ScapiMerchantRolesClientConfig, auth: AuthStrategy, ): ScapiMerchantRolesClient { - const registry = config.middlewareRegistry ?? globalMiddlewareRegistry; - - const client = createClient({ - baseUrl: `https://${config.shortCode}.api.commercecloud.salesforce.com/merchant/roles/v1`, - }); - - const requiredScopes = config.scopes ?? [...SCAPI_MERCHANT_ROLES_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-merchant-roles')) { - client.use(middleware); - } - - client.use(createRateLimitMiddleware({prefix: 'SCAPI-ROLES'})); - client.use(createLoggingMiddleware('SCAPI-ROLES')); - - return client; + return buildScapiClient( + { + pathSegment: 'merchant/roles/v1', + domainKey: 'scapi-merchant-roles', + defaultScopes: SCAPI_MERCHANT_ROLES_RW_SCOPES, + logPrefix: 'SCAPI-ROLES', + }, + config, + auth, + ); } diff --git a/packages/b2c-tooling-sdk/src/clients/scapi-merchant-users.ts b/packages/b2c-tooling-sdk/src/clients/scapi-merchant-users.ts index 384d47f65..4b2f2889e 100644 --- a/packages/b2c-tooling-sdk/src/clients/scapi-merchant-users.ts +++ b/packages/b2c-tooling-sdk/src/clients/scapi-merchant-users.ts @@ -3,13 +3,10 @@ * 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 createClient, {type Client} from 'openapi-fetch'; +import type {Client} from 'openapi-fetch'; import type {AuthStrategy} from '../auth/types.js'; import type {paths, components} from './scapi-merchant-users.generated.js'; -import {createAuthMiddleware, createLoggingMiddleware, createRateLimitMiddleware} from './middleware.js'; -import {globalMiddlewareRegistry, type MiddlewareRegistry} from './middleware-registry.js'; -import {OAuthStrategy} from '../auth/oauth.js'; -import {buildTenantScope} from './custom-apis.js'; +import {buildScapiClient, type ScapiClientConfig} from './scapi-client-factory.js'; export type {paths, components}; export type ScapiMerchantUsersClient = Client; @@ -23,35 +20,20 @@ export type UserSearch = components['schemas']['UserSearch']; export const SCAPI_MERCHANT_USERS_READ_SCOPES = ['sfcc.users']; export const SCAPI_MERCHANT_USERS_RW_SCOPES = ['sfcc.users.rw']; -export interface ScapiMerchantUsersClientConfig { - shortCode: string; - tenantId: string; - /** Override scopes (default: sfcc.users.rw + tenant scope). */ - scopes?: string[]; - middlewareRegistry?: MiddlewareRegistry; -} +export type ScapiMerchantUsersClientConfig = ScapiClientConfig; export function createScapiMerchantUsersClient( config: ScapiMerchantUsersClientConfig, auth: AuthStrategy, ): ScapiMerchantUsersClient { - const registry = config.middlewareRegistry ?? globalMiddlewareRegistry; - - const client = createClient({ - baseUrl: `https://${config.shortCode}.api.commercecloud.salesforce.com/merchant/users/v1`, - }); - - const requiredScopes = config.scopes ?? [...SCAPI_MERCHANT_USERS_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-merchant-users')) { - client.use(middleware); - } - - client.use(createRateLimitMiddleware({prefix: 'SCAPI-USERS'})); - client.use(createLoggingMiddleware('SCAPI-USERS')); - - return client; + return buildScapiClient( + { + pathSegment: 'merchant/users/v1', + domainKey: 'scapi-merchant-users', + defaultScopes: SCAPI_MERCHANT_USERS_RW_SCOPES, + logPrefix: 'SCAPI-USERS', + }, + config, + auth, + ); } diff --git a/packages/b2c-tooling-sdk/src/clients/scapi-scripts.ts b/packages/b2c-tooling-sdk/src/clients/scapi-scripts.ts index ac8cd23cc..a5bdd9a0e 100644 --- a/packages/b2c-tooling-sdk/src/clients/scapi-scripts.ts +++ b/packages/b2c-tooling-sdk/src/clients/scapi-scripts.ts @@ -3,13 +3,10 @@ * 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 createClient, {type Client} from 'openapi-fetch'; +import type {Client} from 'openapi-fetch'; import type {AuthStrategy} from '../auth/types.js'; import type {paths, components} from './scapi-scripts.generated.js'; -import {createAuthMiddleware, createLoggingMiddleware, createRateLimitMiddleware} from './middleware.js'; -import {globalMiddlewareRegistry, type MiddlewareRegistry} from './middleware-registry.js'; -import {OAuthStrategy} from '../auth/oauth.js'; -import {buildTenantScope} from './custom-apis.js'; +import {buildScapiClient, type ScapiClientConfig} from './scapi-client-factory.js'; export type {paths, components}; export type ScapiScriptsClient = Client; @@ -21,32 +18,17 @@ export type CodeVersion = components['schemas']['CodeVersion']; export const SCAPI_SCRIPTS_READ_SCOPES = ['sfcc.scripts']; export const SCAPI_SCRIPTS_RW_SCOPES = ['sfcc.scripts.rw']; -export interface ScapiScriptsClientConfig { - shortCode: string; - tenantId: string; - /** Override scopes (default: sfcc.scripts.rw + tenant scope). */ - scopes?: string[]; - middlewareRegistry?: MiddlewareRegistry; -} +export type ScapiScriptsClientConfig = ScapiClientConfig; export function createScapiScriptsClient(config: ScapiScriptsClientConfig, auth: AuthStrategy): ScapiScriptsClient { - const registry = config.middlewareRegistry ?? globalMiddlewareRegistry; - - const client = createClient({ - baseUrl: `https://${config.shortCode}.api.commercecloud.salesforce.com/dx/scripts/v1`, - }); - - const requiredScopes = config.scopes ?? [...SCAPI_SCRIPTS_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-scripts')) { - client.use(middleware); - } - - client.use(createRateLimitMiddleware({prefix: 'SCAPI-SCRIPTS'})); - client.use(createLoggingMiddleware('SCAPI-SCRIPTS')); - - return client; + return buildScapiClient( + { + pathSegment: 'dx/scripts/v1', + domainKey: 'scapi-scripts', + defaultScopes: SCAPI_SCRIPTS_RW_SCOPES, + logPrefix: 'SCAPI-SCRIPTS', + }, + config, + auth, + ); } diff --git a/packages/b2c-tooling-sdk/src/index.ts b/packages/b2c-tooling-sdk/src/index.ts index 981d562e3..8ce2ed575 100644 --- a/packages/b2c-tooling-sdk/src/index.ts +++ b/packages/b2c-tooling-sdk/src/index.ts @@ -205,12 +205,7 @@ export type { } from './operations/code/index.js'; // Scripts (code versions) backend abstraction -export { - createScriptsBackend, - FallbackScriptsBackend, - OcapiScriptsBackend, - ScapiScriptsBackend, -} from './operations/code/index.js'; +export {createScriptsBackend, OcapiScriptsBackend, ScapiScriptsBackend} from './operations/code/index.js'; export type { ScriptsBackend, ScriptsBackendConfig, @@ -219,12 +214,7 @@ export type { } from './operations/code/index.js'; // Users (BM) backend abstraction -export { - createUsersBackend, - FallbackUsersBackend, - OcapiUsersBackend, - ScapiUsersBackend, -} from './operations/bm-users/index.js'; +export {createUsersBackend, OcapiUsersBackend, ScapiUsersBackend} from './operations/bm-users/index.js'; export type { UsersBackend, UsersBackendConfig, @@ -237,12 +227,7 @@ export type { } from './operations/bm-users/index.js'; // Roles (BM) backend abstraction -export { - createRolesBackend, - FallbackRolesBackend, - OcapiRolesBackend, - ScapiRolesBackend, -} from './operations/bm-roles/index.js'; +export {createRolesBackend, OcapiRolesBackend, ScapiRolesBackend} from './operations/bm-roles/index.js'; export type { RolesBackend, RolesBackendConfig, @@ -270,7 +255,6 @@ export { // Backend abstraction createJobsBackend, waitForJobExecution, - FallbackJobsBackend, OcapiJobsBackend, ScapiJobsBackend, } from './operations/jobs/index.js'; @@ -288,7 +272,7 @@ export type { JobsBackend, JobsBackendConfig, ApiBackendPreference, - JobExecutionResult, + JobExecutionInfo, JobStepExecutionResult, JobExecutionSearchResults, ScapiJobsBackendConfig, diff --git a/packages/b2c-tooling-sdk/src/operations/bm-roles/backend.ts b/packages/b2c-tooling-sdk/src/operations/bm-roles/backend.ts index 4f85ec470..ac38b29dc 100644 --- a/packages/b2c-tooling-sdk/src/operations/bm-roles/backend.ts +++ b/packages/b2c-tooling-sdk/src/operations/bm-roles/backend.ts @@ -3,89 +3,17 @@ * 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 type {B2CInstance} from '../../instance/index.js'; -import type {AuthStrategy} from '../../auth/types.js'; -import type { - RolesBackend, - RoleInfo, - ListRolesResult, - ListRolesOptions, - RolePermissionsInfo, - CreateRoleInput, -} from './types.js'; +import type {RolesBackend} from './types.js'; import {OcapiRolesBackend} from './ocapi-backend.js'; import {ScapiRolesBackend} from './scapi-backend.js'; -import {ScapiFallbackBackend} from '../../clients/scapi-fallback-backend.js'; -import {resolveScapiOrOcapi, type ApiBackendPreference} from '../../clients/scapi-backend-utils.js'; +import {createDualBackend, type DualBackendConfig} from '../../clients/dual-backend-factory.js'; -export interface RolesBackendConfig { - preference: ApiBackendPreference; - instance: B2CInstance; - shortCode?: string; - tenantId?: string; - auth?: AuthStrategy; -} +export type RolesBackendConfig = DualBackendConfig; export function createRolesBackend(config: RolesBackendConfig): RolesBackend { - const hasScapiConfig = Boolean(config.shortCode && config.tenantId && config.auth); - const resolved = resolveScapiOrOcapi({ - preference: config.preference, - hasScapiConfig, + return createDualBackend(config, { domainName: 'Roles', + Scapi: ScapiRolesBackend, + Ocapi: OcapiRolesBackend, }); - - if (resolved === 'ocapi') { - return new OcapiRolesBackend(config.instance); - } - - const scapiBackend = new ScapiRolesBackend({ - shortCode: config.shortCode!, - tenantId: config.tenantId!, - auth: config.auth!, - }); - - if (config.preference === 'scapi') { - return scapiBackend; - } - - const ocapiBackend = new OcapiRolesBackend(config.instance); - return new FallbackRolesBackend(scapiBackend, ocapiBackend); -} - -export class FallbackRolesBackend extends ScapiFallbackBackend implements RolesBackend { - constructor(scapiBackend: ScapiRolesBackend, ocapiBackend: OcapiRolesBackend) { - super(scapiBackend, ocapiBackend, 'roles'); - } - - async listRoles(options?: ListRolesOptions): Promise { - return this.withFallback((b) => b.listRoles(options)); - } - - async getRole(roleId: string, options?: {expand?: ('users' | 'permissions')[]}): Promise { - return this.withFallback((b) => b.getRole(roleId, options)); - } - - async createRole(roleId: string, input?: CreateRoleInput): Promise { - return this.withFallback((b) => b.createRole(roleId, input)); - } - - async deleteRole(roleId: string): Promise { - return this.withFallback((b) => b.deleteRole(roleId)); - } - - async getPermissions(roleId: string): Promise { - return this.withFallback((b) => b.getPermissions(roleId)); - } - - async setPermissions(roleId: string, permissions: RolePermissionsInfo): Promise { - return this.withFallback((b) => b.setPermissions(roleId, permissions)); - } - - async grantRole(roleId: string, login: string): Promise { - return this.withFallback((b) => b.grantRole(roleId, login)); - } - - async revokeRole(roleId: string, login: string): Promise { - return this.withFallback((b) => b.revokeRole(roleId, login)); - } } diff --git a/packages/b2c-tooling-sdk/src/operations/bm-roles/index.ts b/packages/b2c-tooling-sdk/src/operations/bm-roles/index.ts index 13669796f..56b40fd33 100644 --- a/packages/b2c-tooling-sdk/src/operations/bm-roles/index.ts +++ b/packages/b2c-tooling-sdk/src/operations/bm-roles/index.ts @@ -67,7 +67,7 @@ export { export type {BmRole, BmRoles, BmRolePermissions, ListBmRolesOptions, GetBmRoleOptions} from './roles.js'; // Roles backend abstraction — supports OCAPI + SCAPI -export {createRolesBackend, FallbackRolesBackend} from './backend.js'; +export {createRolesBackend} from './backend.js'; export type {RolesBackendConfig} from './backend.js'; export {OcapiRolesBackend} from './ocapi-backend.js'; export {ScapiRolesBackend} from './scapi-backend.js'; diff --git a/packages/b2c-tooling-sdk/src/operations/bm-roles/scapi-backend.ts b/packages/b2c-tooling-sdk/src/operations/bm-roles/scapi-backend.ts index 4623080f1..30a08a2c3 100644 --- a/packages/b2c-tooling-sdk/src/operations/bm-roles/scapi-backend.ts +++ b/packages/b2c-tooling-sdk/src/operations/bm-roles/scapi-backend.ts @@ -39,6 +39,8 @@ export interface ScapiRolesBackendConfig { shortCode: string; tenantId: string; auth: AuthStrategy; + /** Unused by Roles; accepted for compatibility with the dual-backend factory. */ + instance?: unknown; } export class ScapiRolesBackend implements RolesBackend { diff --git a/packages/b2c-tooling-sdk/src/operations/bm-users/backend.ts b/packages/b2c-tooling-sdk/src/operations/bm-users/backend.ts index 3a8263958..863bd6184 100644 --- a/packages/b2c-tooling-sdk/src/operations/bm-users/backend.ts +++ b/packages/b2c-tooling-sdk/src/operations/bm-users/backend.ts @@ -3,77 +3,17 @@ * 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 type {B2CInstance} from '../../instance/index.js'; -import type {AuthStrategy} from '../../auth/types.js'; -import type { - UsersBackend, - UserInfo, - ListUsersResult, - ListUsersOptions, - UpdateUserChanges, - CreateUserInput, -} from './types.js'; +import type {UsersBackend} from './types.js'; import {OcapiUsersBackend} from './ocapi-backend.js'; import {ScapiUsersBackend} from './scapi-backend.js'; -import {ScapiFallbackBackend} from '../../clients/scapi-fallback-backend.js'; -import {resolveScapiOrOcapi, type ApiBackendPreference} from '../../clients/scapi-backend-utils.js'; +import {createDualBackend, type DualBackendConfig} from '../../clients/dual-backend-factory.js'; -export interface UsersBackendConfig { - preference: ApiBackendPreference; - instance: B2CInstance; - shortCode?: string; - tenantId?: string; - auth?: AuthStrategy; -} +export type UsersBackendConfig = DualBackendConfig; export function createUsersBackend(config: UsersBackendConfig): UsersBackend { - const hasScapiConfig = Boolean(config.shortCode && config.tenantId && config.auth); - const resolved = resolveScapiOrOcapi({ - preference: config.preference, - hasScapiConfig, + return createDualBackend(config, { domainName: 'Users', + Scapi: ScapiUsersBackend, + Ocapi: OcapiUsersBackend, }); - - if (resolved === 'ocapi') { - return new OcapiUsersBackend(config.instance); - } - - const scapiBackend = new ScapiUsersBackend({ - shortCode: config.shortCode!, - tenantId: config.tenantId!, - auth: config.auth!, - }); - - if (config.preference === 'scapi') { - return scapiBackend; - } - - const ocapiBackend = new OcapiUsersBackend(config.instance); - return new FallbackUsersBackend(scapiBackend, ocapiBackend); -} - -export class FallbackUsersBackend extends ScapiFallbackBackend implements UsersBackend { - constructor(scapiBackend: ScapiUsersBackend, ocapiBackend: OcapiUsersBackend) { - super(scapiBackend, ocapiBackend, 'users'); - } - - async listUsers(options?: ListUsersOptions): Promise { - return this.withFallback((b) => b.listUsers(options)); - } - - async getUser(login: string): Promise { - return this.withFallback((b) => b.getUser(login)); - } - - async createOrReplaceUser(login: string, input: CreateUserInput): Promise { - return this.withFallback((b) => b.createOrReplaceUser(login, input)); - } - - async updateUser(login: string, changes: UpdateUserChanges): Promise { - return this.withFallback((b) => b.updateUser(login, changes)); - } - - async deleteUser(login: string): Promise { - return this.withFallback((b) => b.deleteUser(login)); - } } diff --git a/packages/b2c-tooling-sdk/src/operations/bm-users/index.ts b/packages/b2c-tooling-sdk/src/operations/bm-users/index.ts index 27d00fdb0..025caa27d 100644 --- a/packages/b2c-tooling-sdk/src/operations/bm-users/index.ts +++ b/packages/b2c-tooling-sdk/src/operations/bm-users/index.ts @@ -77,7 +77,7 @@ export type { } from './users.js'; // Users backend abstraction — supports OCAPI + SCAPI -export {createUsersBackend, FallbackUsersBackend} from './backend.js'; +export {createUsersBackend} from './backend.js'; export type {UsersBackendConfig} from './backend.js'; export {OcapiUsersBackend} from './ocapi-backend.js'; export {ScapiUsersBackend} from './scapi-backend.js'; diff --git a/packages/b2c-tooling-sdk/src/operations/bm-users/scapi-backend.ts b/packages/b2c-tooling-sdk/src/operations/bm-users/scapi-backend.ts index 84ad3b244..e5a5dd9b2 100644 --- a/packages/b2c-tooling-sdk/src/operations/bm-users/scapi-backend.ts +++ b/packages/b2c-tooling-sdk/src/operations/bm-users/scapi-backend.ts @@ -48,6 +48,8 @@ export interface ScapiUsersBackendConfig { shortCode: string; tenantId: string; auth: AuthStrategy; + /** Unused by Users; accepted for compatibility with the dual-backend factory. */ + instance?: unknown; } export class ScapiUsersBackend implements UsersBackend { diff --git a/packages/b2c-tooling-sdk/src/operations/code/deploy.ts b/packages/b2c-tooling-sdk/src/operations/code/deploy.ts index b0bed4ad5..87f5c02c5 100644 --- a/packages/b2c-tooling-sdk/src/operations/code/deploy.ts +++ b/packages/b2c-tooling-sdk/src/operations/code/deploy.ts @@ -9,7 +9,9 @@ import JSZip from 'jszip'; import type {B2CInstance} from '../../instance/index.js'; import {getLogger} from '../../logging/logger.js'; import {findCartridges, type CartridgeMapping, type FindCartridgesOptions} from './cartridges.js'; -import {activateCodeVersion, reloadCodeVersion} from './versions.js'; +import {activateCodeVersion} from './versions.js'; +import {reloadCodeVersion} from './scripts-backend.js'; +import {OcapiScriptsBackend} from './ocapi-scripts-backend.js'; const UNZIP_BODY = new URLSearchParams({method: 'UNZIP'}).toString(); @@ -320,7 +322,7 @@ export async function findAndDeployCartridges( activated = true; } else if (options.reload) { logger.debug('Reloading code version...'); - await reloadCodeVersion(instance, codeVersion); + await reloadCodeVersion(new OcapiScriptsBackend(instance), codeVersion); activated = true; reloaded = true; } diff --git a/packages/b2c-tooling-sdk/src/operations/code/index.ts b/packages/b2c-tooling-sdk/src/operations/code/index.ts index f2386d2e4..cc9a1f2be 100644 --- a/packages/b2c-tooling-sdk/src/operations/code/index.ts +++ b/packages/b2c-tooling-sdk/src/operations/code/index.ts @@ -75,14 +75,13 @@ export { listCodeVersions, getActiveCodeVersion, activateCodeVersion, - reloadCodeVersion, deleteCodeVersion, createCodeVersion, } from './versions.js'; export type {CodeVersion, CodeVersionResult} from './versions.js'; // Scripts (code versions) backend abstraction — supports OCAPI + SCAPI -export {createScriptsBackend, FallbackScriptsBackend} from './scripts-backend.js'; +export {createScriptsBackend, reloadCodeVersion} from './scripts-backend.js'; export type {ScriptsBackendConfig} from './scripts-backend.js'; export {OcapiScriptsBackend} from './ocapi-scripts-backend.js'; export {ScapiScriptsBackend} from './scapi-scripts-backend.js'; diff --git a/packages/b2c-tooling-sdk/src/operations/code/ocapi-scripts-backend.ts b/packages/b2c-tooling-sdk/src/operations/code/ocapi-scripts-backend.ts index 397d2513a..5cf087cfd 100644 --- a/packages/b2c-tooling-sdk/src/operations/code/ocapi-scripts-backend.ts +++ b/packages/b2c-tooling-sdk/src/operations/code/ocapi-scripts-backend.ts @@ -12,7 +12,6 @@ import { activateCodeVersion as ocapiActivateCodeVersion, deleteCodeVersion as ocapiDeleteCodeVersion, createCodeVersion as ocapiCreateCodeVersion, - reloadCodeVersion as ocapiReloadCodeVersion, } from './versions.js'; function mapOcapiCodeVersion(ocapi: OcapiCodeVersion): CodeVersionInfo { @@ -56,8 +55,4 @@ export class OcapiScriptsBackend implements ScriptsBackend { async createCodeVersion(codeVersionId: string): Promise { await ocapiCreateCodeVersion(this.instance, codeVersionId); } - - async reloadCodeVersion(codeVersionId?: string): Promise { - await ocapiReloadCodeVersion(this.instance, codeVersionId); - } } diff --git a/packages/b2c-tooling-sdk/src/operations/code/scapi-scripts-backend.ts b/packages/b2c-tooling-sdk/src/operations/code/scapi-scripts-backend.ts index 5cb4e7609..05f303a8f 100644 --- a/packages/b2c-tooling-sdk/src/operations/code/scapi-scripts-backend.ts +++ b/packages/b2c-tooling-sdk/src/operations/code/scapi-scripts-backend.ts @@ -36,6 +36,8 @@ export interface ScapiScriptsBackendConfig { shortCode: string; tenantId: string; auth: AuthStrategy; + /** Unused by Scripts; accepted for compatibility with the dual-backend factory. */ + instance?: unknown; } export class ScapiScriptsBackend implements ScriptsBackend { @@ -106,10 +108,6 @@ export class ScapiScriptsBackend implements ScriptsBackend { } } - async reloadCodeVersion(_codeVersionId?: string): Promise { - throw new Error('Reloading code versions is not supported via SCAPI. Use --api-backend ocapi to reload.'); - } - private buildClient(scopes: string[]): ScapiScriptsClient { const clientConfig: ScapiScriptsClientConfig = { shortCode: this.config.shortCode, diff --git a/packages/b2c-tooling-sdk/src/operations/code/scripts-backend.ts b/packages/b2c-tooling-sdk/src/operations/code/scripts-backend.ts index 3874fd215..09f02b652 100644 --- a/packages/b2c-tooling-sdk/src/operations/code/scripts-backend.ts +++ b/packages/b2c-tooling-sdk/src/operations/code/scripts-backend.ts @@ -3,75 +3,50 @@ * 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 type {B2CInstance} from '../../instance/index.js'; -import type {AuthStrategy} from '../../auth/types.js'; -import type {ScriptsBackend, CodeVersionInfo} from './scripts-types.js'; +import type {ScriptsBackend} from './scripts-types.js'; import {OcapiScriptsBackend} from './ocapi-scripts-backend.js'; import {ScapiScriptsBackend} from './scapi-scripts-backend.js'; -import {ScapiFallbackBackend} from '../../clients/scapi-fallback-backend.js'; -import {resolveScapiOrOcapi, type ApiBackendPreference} from '../../clients/scapi-backend-utils.js'; +import {createDualBackend, type DualBackendConfig} from '../../clients/dual-backend-factory.js'; -export interface ScriptsBackendConfig { - preference: ApiBackendPreference; - instance: B2CInstance; - shortCode?: string; - tenantId?: string; - auth?: AuthStrategy; -} +export type ScriptsBackendConfig = DualBackendConfig; export function createScriptsBackend(config: ScriptsBackendConfig): ScriptsBackend { - const hasScapiConfig = Boolean(config.shortCode && config.tenantId && config.auth); - const resolved = resolveScapiOrOcapi({ - preference: config.preference, - hasScapiConfig, + return createDualBackend(config, { domainName: 'Scripts', + Scapi: ScapiScriptsBackend, + Ocapi: OcapiScriptsBackend, }); - - if (resolved === 'ocapi') { - return new OcapiScriptsBackend(config.instance); - } - - const scapiBackend = new ScapiScriptsBackend({ - shortCode: config.shortCode!, - tenantId: config.tenantId!, - auth: config.auth!, - }); - - if (config.preference === 'scapi') { - return scapiBackend; - } - - // Auto mode: wrap with fallback - const ocapiBackend = new OcapiScriptsBackend(config.instance); - return new FallbackScriptsBackend(scapiBackend, ocapiBackend); } -export class FallbackScriptsBackend extends ScapiFallbackBackend implements ScriptsBackend { - constructor(scapiBackend: ScapiScriptsBackend, ocapiBackend: OcapiScriptsBackend) { - super(scapiBackend, ocapiBackend, 'scripts'); - } - - async listCodeVersions(): Promise { - return this.withFallback((b) => b.listCodeVersions()); - } - - async getActiveCodeVersion(): Promise { - return this.withFallback((b) => b.getActiveCodeVersion()); - } - - async activateCodeVersion(codeVersionId: string): Promise { - return this.withFallback((b) => b.activateCodeVersion(codeVersionId)); - } +/** + * Reloads (re-activates) a code version using a toggle-activate technique. + * + * Activates an alternate version, then re-activates the target. This forces + * the instance to reload the code (rebuild caches, re-register custom APIs, + * etc.). Works on top of any `ScriptsBackend` since it only uses + * list+activate primitives. + * + * @param backend - Scripts backend (OCAPI, SCAPI, or fallback) + * @param codeVersionId - Code version to reload (defaults to current active) + * @throws Error if no alternate code version is available for toggling + */ +export async function reloadCodeVersion(backend: ScriptsBackend, codeVersionId?: string): Promise { + const versions = await backend.listCodeVersions(); + const activeVersion = versions.find((v) => v.active); + const targetVersion = codeVersionId ?? activeVersion?.id; - async deleteCodeVersion(codeVersionId: string): Promise { - return this.withFallback((b) => b.deleteCodeVersion(codeVersionId)); + if (!targetVersion) { + throw new Error('No code version specified and no active version found'); } - async createCodeVersion(codeVersionId: string): Promise { - return this.withFallback((b) => b.createCodeVersion(codeVersionId)); + // If the target is already active, toggle through an alternate first. + if (activeVersion?.id === targetVersion) { + const alternateVersion = versions.find((v) => v.id !== targetVersion); + if (!alternateVersion) { + throw new Error('Cannot reload: no alternate code version available for toggle'); + } + await backend.activateCodeVersion(alternateVersion.id); } - async reloadCodeVersion(codeVersionId?: string): Promise { - return this.withFallback((b) => b.reloadCodeVersion(codeVersionId)); - } + await backend.activateCodeVersion(targetVersion); } diff --git a/packages/b2c-tooling-sdk/src/operations/code/scripts-types.ts b/packages/b2c-tooling-sdk/src/operations/code/scripts-types.ts index a0d8e18ce..f6df91252 100644 --- a/packages/b2c-tooling-sdk/src/operations/code/scripts-types.ts +++ b/packages/b2c-tooling-sdk/src/operations/code/scripts-types.ts @@ -35,10 +35,9 @@ export interface CodeVersionInfo { /** * Backend contract for code-version operations. * - * `reloadCodeVersion` is OCAPI-only — the SCAPI backend's implementation - * throws to advertise that. In auto mode the fallback wrapper will fall - * through to OCAPI on the first call (since reload requires the OCAPI cache - * rebuild semantics). + * Reload is implemented as a backend-agnostic helper (`reloadCodeVersion` + * in the operations module) since it's just `activate(alternate) + + * activate(target)` on top of these primitives. */ export interface ScriptsBackend extends BackendBase { listCodeVersions(): Promise; @@ -46,9 +45,4 @@ export interface ScriptsBackend extends BackendBase { activateCodeVersion(codeVersionId: string): Promise; deleteCodeVersion(codeVersionId: string): Promise; createCodeVersion(codeVersionId: string): Promise; - /** - * Re-activates the current code version to force a code cache reload. - * Implemented only by the OCAPI backend. - */ - reloadCodeVersion(codeVersionId?: string): Promise; } diff --git a/packages/b2c-tooling-sdk/src/operations/code/versions.ts b/packages/b2c-tooling-sdk/src/operations/code/versions.ts index 052be7f83..2f89efd00 100644 --- a/packages/b2c-tooling-sdk/src/operations/code/versions.ts +++ b/packages/b2c-tooling-sdk/src/operations/code/versions.ts @@ -86,54 +86,6 @@ export async function activateCodeVersion(instance: B2CInstance, codeVersionId: logger.debug({codeVersionId}, `Code version ${codeVersionId} activated`); } -/** - * Reloads (re-activates) the current code version. - * - * This performs a "toggle" activation - first activating a different code version, - * then re-activating the target version. This forces the instance to reload the code. - * - * @param instance - B2C instance - * @param codeVersionId - Code version to reload (defaults to current active) - * @throws Error if reload fails or no alternate version is available - * - * @example - * ```typescript - * // Reload the currently active code version - * await reloadCodeVersion(instance); - * - * // Reload a specific code version - * await reloadCodeVersion(instance, 'v1'); - * ``` - */ -export async function reloadCodeVersion(instance: B2CInstance, codeVersionId?: string): Promise { - const logger = getLogger(); - const versions = await listCodeVersions(instance); - - const activeVersion = versions.find((v) => v.active); - const targetVersion = codeVersionId ?? activeVersion?.id; - - if (!targetVersion) { - throw new Error('No code version specified and no active version found'); - } - - logger.debug({codeVersionId: targetVersion}, `Reloading code version ${targetVersion}`); - - // If the target is already active, we need to toggle to another version first - if (activeVersion?.id === targetVersion) { - const alternateVersion = versions.find((v) => v.id !== targetVersion); - if (!alternateVersion) { - throw new Error('Cannot reload: no alternate code version available for toggle'); - } - - logger.debug({codeVersionId: alternateVersion.id}, `Temporarily activating ${alternateVersion.id}`); - await activateCodeVersion(instance, alternateVersion.id!); - } - - // Now activate the target version - await activateCodeVersion(instance, targetVersion); - logger.debug({codeVersionId: targetVersion}, `Code version ${targetVersion} reloaded`); -} - /** * Deletes a code version from an instance. * diff --git a/packages/b2c-tooling-sdk/src/operations/jobs/backend.ts b/packages/b2c-tooling-sdk/src/operations/jobs/backend.ts index 75f4d4667..cf05bf49a 100644 --- a/packages/b2c-tooling-sdk/src/operations/jobs/backend.ts +++ b/packages/b2c-tooling-sdk/src/operations/jobs/backend.ts @@ -3,77 +3,22 @@ * 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 type {B2CInstance} from '../../instance/index.js'; -import type {AuthStrategy} from '../../auth/types.js'; -import type {JobsBackend, JobExecutionResult, JobExecutionSearchResults} from './types.js'; -import type {ExecuteJobOptions, SearchJobExecutionsOptions, WaitForJobOptions, WaitForJobPollInfo} from './run.js'; +import type {JobsBackend, JobExecutionInfo} from './types.js'; +import type {WaitForJobOptions, WaitForJobPollInfo} from './run.js'; import {OcapiJobsBackend} from './ocapi-backend.js'; import {ScapiJobsBackend} from './scapi-backend.js'; -import {ScapiFallbackBackend} from '../../clients/scapi-fallback-backend.js'; -import {resolveScapiOrOcapi, type ApiBackendPreference} from '../../clients/scapi-backend-utils.js'; +import {createDualBackend, type DualBackendConfig} from '../../clients/dual-backend-factory.js'; +import type {ApiBackendPreference} from '../../clients/scapi-backend-utils.js'; export type {ApiBackendPreference}; - -export interface JobsBackendConfig { - preference: ApiBackendPreference; - instance: B2CInstance; - shortCode?: string; - tenantId?: string; - auth?: AuthStrategy; -} +export type JobsBackendConfig = DualBackendConfig; export function createJobsBackend(config: JobsBackendConfig): JobsBackend { - const hasScapiConfig = Boolean(config.shortCode && config.tenantId && config.auth); - const resolved = resolveScapiOrOcapi({ - preference: config.preference, - hasScapiConfig, + return createDualBackend(config, { domainName: 'Jobs', + Scapi: ScapiJobsBackend, + Ocapi: OcapiJobsBackend, }); - - if (resolved === 'ocapi') { - return new OcapiJobsBackend(config.instance); - } - - const scapiBackend = new ScapiJobsBackend({ - shortCode: config.shortCode!, - tenantId: config.tenantId!, - auth: config.auth!, - instance: config.instance, - }); - - if (config.preference === 'scapi') { - return scapiBackend; - } - - // Auto mode: wrap with fallback - const ocapiBackend = new OcapiJobsBackend(config.instance); - return new FallbackJobsBackend(scapiBackend, ocapiBackend); -} - -export class FallbackJobsBackend extends ScapiFallbackBackend implements JobsBackend { - constructor(scapiBackend: ScapiJobsBackend, ocapiBackend: OcapiJobsBackend) { - super(scapiBackend, ocapiBackend, 'jobs'); - } - - async executeJob(jobId: string, options?: ExecuteJobOptions): Promise { - return this.withFallback((backend) => backend.executeJob(jobId, options)); - } - - async getJobExecution(jobId: string, executionId: string): Promise { - return this.withFallback((backend) => backend.getJobExecution(jobId, executionId)); - } - - async searchJobExecutions(options?: SearchJobExecutionsOptions): Promise { - return this.withFallback((backend) => backend.searchJobExecutions(options)); - } - - async deleteJobExecution(jobId: string, executionId: string): Promise { - return this.withFallback((backend) => backend.deleteJobExecution(jobId, executionId)); - } - - async getJobLog(execution: JobExecutionResult): Promise { - return this.withFallback((backend) => backend.getJobLog(execution)); - } } export async function waitForJobExecution( @@ -81,7 +26,7 @@ export async function waitForJobExecution( jobId: string, executionId: string, options: WaitForJobOptions = {}, -): Promise { +): Promise { const {pollIntervalSeconds = 3, timeoutSeconds = 0, onPoll} = options; const sleepFn = options.sleep ?? defaultSleep; const startTime = Date.now(); diff --git a/packages/b2c-tooling-sdk/src/operations/jobs/index.ts b/packages/b2c-tooling-sdk/src/operations/jobs/index.ts index 6164484d4..6d81574e1 100644 --- a/packages/b2c-tooling-sdk/src/operations/jobs/index.ts +++ b/packages/b2c-tooling-sdk/src/operations/jobs/index.ts @@ -91,12 +91,12 @@ export type { } from './run.js'; // Backend abstraction -export {createJobsBackend, waitForJobExecution, FallbackJobsBackend} from './backend.js'; +export {createJobsBackend, waitForJobExecution} from './backend.js'; export type {JobsBackendConfig, ApiBackendPreference} from './backend.js'; export {OcapiJobsBackend} from './ocapi-backend.js'; export {ScapiJobsBackend} from './scapi-backend.js'; export type {ScapiJobsBackendConfig} from './scapi-backend.js'; -export type {JobsBackend, JobExecutionResult, JobStepExecutionResult, JobExecutionSearchResults} from './types.js'; +export type {JobsBackend, JobExecutionInfo, JobStepExecutionResult, JobExecutionSearchResults} from './types.js'; // Site archive import/export export { diff --git a/packages/b2c-tooling-sdk/src/operations/jobs/ocapi-backend.ts b/packages/b2c-tooling-sdk/src/operations/jobs/ocapi-backend.ts index a58e0b6f1..9cf221314 100644 --- a/packages/b2c-tooling-sdk/src/operations/jobs/ocapi-backend.ts +++ b/packages/b2c-tooling-sdk/src/operations/jobs/ocapi-backend.ts @@ -4,7 +4,7 @@ * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 */ import type {B2CInstance} from '../../instance/index.js'; -import type {JobsBackend, JobExecutionResult, JobStepExecutionResult, JobExecutionSearchResults} from './types.js'; +import type {JobsBackend, JobExecutionInfo, JobStepExecutionResult, JobExecutionSearchResults} from './types.js'; import type {ExecuteJobOptions, SearchJobExecutionsOptions, JobExecution, JobStepExecution} from './run.js'; import { executeJob as ocapiExecuteJob, @@ -29,11 +29,11 @@ function mapStepExecution(step: JobStepExecution): JobStepExecutionResult { }; } -function mapOcapiExecution(ocapi: JobExecution): JobExecutionResult { +function mapOcapiExecution(ocapi: JobExecution): JobExecutionInfo { return { id: ocapi.id ?? '', jobId: ocapi.job_id ?? '', - executionStatus: (ocapi.execution_status ?? 'unknown') as JobExecutionResult['executionStatus'], + executionStatus: (ocapi.execution_status ?? 'unknown') as JobExecutionInfo['executionStatus'], exitStatus: ocapi.exit_status ? { code: ocapi.exit_status.code ?? '', @@ -57,12 +57,12 @@ export class OcapiJobsBackend implements JobsBackend { constructor(private instance: B2CInstance) {} - async executeJob(jobId: string, options?: ExecuteJobOptions): Promise { + async executeJob(jobId: string, options?: ExecuteJobOptions): Promise { const result = await ocapiExecuteJob(this.instance, jobId, options); return mapOcapiExecution(result); } - async getJobExecution(jobId: string, executionId: string): Promise { + async getJobExecution(jobId: string, executionId: string): Promise { const result = await ocapiGetJobExecution(this.instance, jobId, executionId); return mapOcapiExecution(result); } @@ -81,7 +81,7 @@ export class OcapiJobsBackend implements JobsBackend { throw new Error('Delete job execution is not supported via OCAPI. Use --api-backend scapi.'); } - async getJobLog(execution: JobExecutionResult): Promise { + async getJobLog(execution: JobExecutionInfo): Promise { const ocapiExecution = execution._raw as JobExecution; if (ocapiExecution) { return ocapiGetJobLog(this.instance, ocapiExecution); diff --git a/packages/b2c-tooling-sdk/src/operations/jobs/scapi-backend.ts b/packages/b2c-tooling-sdk/src/operations/jobs/scapi-backend.ts index 4a5df3e06..3ad0d389e 100644 --- a/packages/b2c-tooling-sdk/src/operations/jobs/scapi-backend.ts +++ b/packages/b2c-tooling-sdk/src/operations/jobs/scapi-backend.ts @@ -5,7 +5,7 @@ */ import type {B2CInstance} from '../../instance/index.js'; import type {AuthStrategy} from '../../auth/types.js'; -import type {JobsBackend, JobExecutionResult, JobStepExecutionResult, JobExecutionSearchResults} from './types.js'; +import type {JobsBackend, JobExecutionInfo, JobStepExecutionResult, JobExecutionSearchResults} from './types.js'; import type {ExecuteJobOptions, SearchJobExecutionsOptions} from './run.js'; import { createScapiJobsClient, @@ -36,11 +36,11 @@ function mapStepExecution(step: ScapiJobStepExecution): JobStepExecutionResult { }; } -function mapScapiExecution(scapi: ScapiJobExecution): JobExecutionResult { +function mapScapiExecution(scapi: ScapiJobExecution): JobExecutionInfo { return { id: scapi.id, jobId: scapi.jobId, - executionStatus: (scapi.executionStatus ?? 'unknown') as JobExecutionResult['executionStatus'], + executionStatus: (scapi.executionStatus ?? 'unknown') as JobExecutionInfo['executionStatus'], exitStatus: scapi.exitStatus ? { code: scapi.exitStatus.code ?? '', @@ -82,7 +82,7 @@ export class ScapiJobsBackend implements JobsBackend { }); } - async executeJob(jobId: string, options?: ExecuteJobOptions): Promise { + async executeJob(jobId: string, options?: ExecuteJobOptions): Promise { const client = this.scopeTier.getClientForWrite(); const {parameters = [], body: rawBody} = options ?? {}; @@ -123,7 +123,7 @@ export class ScapiJobsBackend implements JobsBackend { return mapScapiExecution(data); } - async getJobExecution(jobId: string, executionId: string): Promise { + async getJobExecution(jobId: string, executionId: string): Promise { const client = this.scopeTier.getClientForRead(); const {data, error} = await client.GET('/organizations/{organizationId}/jobs/{jobId}/executions/{executionId}', { @@ -200,7 +200,7 @@ export class ScapiJobsBackend implements JobsBackend { } } - async getJobLog(execution: JobExecutionResult): Promise { + async getJobLog(execution: JobExecutionInfo): Promise { if (!execution.logFilePath) { throw new Error('No log file path available'); } @@ -221,7 +221,7 @@ export class ScapiJobsBackend implements JobsBackend { return createScapiJobsClient(clientConfig, this.config.auth); } - private async findRunningExecution(jobId: string): Promise { + private async findRunningExecution(jobId: string): Promise { const results = await this.searchJobExecutions({ jobId, status: ['RUNNING', 'PENDING'], diff --git a/packages/b2c-tooling-sdk/src/operations/jobs/types.ts b/packages/b2c-tooling-sdk/src/operations/jobs/types.ts index efc3bed1a..a48c1727c 100644 --- a/packages/b2c-tooling-sdk/src/operations/jobs/types.ts +++ b/packages/b2c-tooling-sdk/src/operations/jobs/types.ts @@ -7,7 +7,7 @@ import type {ExecuteJobOptions, WaitForJobOptions, SearchJobExecutionsOptions} f export type {ExecuteJobOptions, WaitForJobOptions, SearchJobExecutionsOptions}; -export interface JobExecutionResult { +export interface JobExecutionInfo { id: string; jobId: string; executionStatus: @@ -48,14 +48,14 @@ export interface JobExecutionSearchResults { total: number; limit: number; offset: number; - hits: JobExecutionResult[]; + hits: JobExecutionInfo[]; } export interface JobsBackend { readonly name: 'ocapi' | 'scapi'; - executeJob(jobId: string, options?: ExecuteJobOptions): Promise; - getJobExecution(jobId: string, executionId: string): Promise; + executeJob(jobId: string, options?: ExecuteJobOptions): Promise; + getJobExecution(jobId: string, executionId: string): Promise; searchJobExecutions(options?: SearchJobExecutionsOptions): Promise; deleteJobExecution(jobId: string, executionId: string): Promise; - getJobLog(execution: JobExecutionResult): Promise; + getJobLog(execution: JobExecutionInfo): Promise; } diff --git a/packages/b2c-tooling-sdk/test/operations/code/versions.test.ts b/packages/b2c-tooling-sdk/test/operations/code/versions.test.ts index e7d7b848a..ae5b46a95 100644 --- a/packages/b2c-tooling-sdk/test/operations/code/versions.test.ts +++ b/packages/b2c-tooling-sdk/test/operations/code/versions.test.ts @@ -16,8 +16,9 @@ import { activateCodeVersion, createCodeVersion, deleteCodeVersion, - reloadCodeVersion, } from '../../../src/operations/code/versions.js'; +import {reloadCodeVersion} from '../../../src/operations/code/scripts-backend.js'; +import {OcapiScriptsBackend} from '../../../src/operations/code/ocapi-scripts-backend.js'; const TEST_HOST = 'test.demandware.net'; const BASE_URL = `https://${TEST_HOST}/s/-/dw/data/v25_6`; @@ -258,7 +259,7 @@ describe('operations/code/versions', () => { }), ); - await reloadCodeVersion(mockInstance, 'v2'); + await reloadCodeVersion(new OcapiScriptsBackend(mockInstance), 'v2'); // Success - no error thrown }); @@ -277,7 +278,7 @@ describe('operations/code/versions', () => { }), ); - await reloadCodeVersion(mockInstance, 'v2'); + await reloadCodeVersion(new OcapiScriptsBackend(mockInstance), 'v2'); // Success - no error thrown }); @@ -291,7 +292,7 @@ describe('operations/code/versions', () => { ); try { - await reloadCodeVersion(mockInstance, 'v1'); + await reloadCodeVersion(new OcapiScriptsBackend(mockInstance), 'v1'); expect.fail('Should have thrown error'); } catch (error: any) { expect(error.message).to.include('no alternate code version available'); @@ -308,7 +309,7 @@ describe('operations/code/versions', () => { ); try { - await reloadCodeVersion(mockInstance); + await reloadCodeVersion(new OcapiScriptsBackend(mockInstance)); expect.fail('Should have thrown error'); } catch (error: any) { expect(error.message).to.include('No code version specified'); From b010f755b7617d95636059c71a281d2aa969a112 Mon Sep 17 00:00:00 2001 From: Charles Lavery Date: Fri, 8 May 2026 15:49:42 -0400 Subject: [PATCH 08/22] Test and document the Proxy-based fallback wrapper Adds 8 unit tests for createFallbackBackend covering: - happy path (SCAPI works, choice is cached) - fallback path (invalid_scope triggers OCAPI, choice is cached) - name reflects the resolved backend - non-fallback errors are rethrown without falling back - multi-arg method dispatch - non-method property access (documented as SCAPI-target-only) Tightens the JSDoc on createFallbackBackend to spell out the contract explicitly: both backends must implement T (TypeScript enforces this at the call site), only methods are routed through fallback, non-method properties stay on the SCAPI target, and concurrent first-calls are benign since they only retry SCAPI redundantly. --- .../src/clients/scapi-fallback-backend.ts | 16 +- .../clients/scapi-fallback-backend.test.ts | 169 ++++++++++++++++++ 2 files changed, 184 insertions(+), 1 deletion(-) create mode 100644 packages/b2c-tooling-sdk/test/clients/scapi-fallback-backend.test.ts diff --git a/packages/b2c-tooling-sdk/src/clients/scapi-fallback-backend.ts b/packages/b2c-tooling-sdk/src/clients/scapi-fallback-backend.ts index ac3b61e53..f820cdf0a 100644 --- a/packages/b2c-tooling-sdk/src/clients/scapi-fallback-backend.ts +++ b/packages/b2c-tooling-sdk/src/clients/scapi-fallback-backend.ts @@ -64,6 +64,20 @@ async function withFallback( * intercepted: the first call tries SCAPI; on `invalid_scope` it falls back * to OCAPI. The choice is cached for the wrapper's lifetime. * + * **Contract:** + * - Both `scapi` and `ocapi` must implement `T`. TypeScript enforces this + * at the call site since both are typed as `T`. + * - Only methods of `T` are routed through the fallback logic. The `name` + * getter is special-cased to reflect the resolved backend. + * - **Non-method properties** are returned from the SCAPI target only and + * are not switched on fallback. By convention, backends should be method + * bags — any state beyond `name` should be encapsulated, not exposed. + * - **Concurrency:** if two calls race before resolution, both may attempt + * SCAPI. This is benign for read operations (idempotent retries) and + * acceptable for writes (both either succeed or fail with the same + * error). Each Proxy instance has its own state, so this concerns only + * shared use of a single wrapper. + * * @param scapi - Primary (SCAPI) backend implementation * @param ocapi - Fallback (OCAPI) backend implementation * @param domainName - Used in fallback log messages, e.g. `'jobs'` @@ -71,7 +85,7 @@ async function withFallback( * * @example * ```ts - * const backend = createFallbackBackend(scapiJobs, ocapiJobs, 'jobs'); + * const backend = createFallbackBackend(scapiJobs, ocapiJobs, 'jobs'); * await backend.executeJob('my-job'); // tries SCAPI, may fall back to OCAPI * ``` */ diff --git a/packages/b2c-tooling-sdk/test/clients/scapi-fallback-backend.test.ts b/packages/b2c-tooling-sdk/test/clients/scapi-fallback-backend.test.ts new file mode 100644 index 000000000..febc665ae --- /dev/null +++ b/packages/b2c-tooling-sdk/test/clients/scapi-fallback-backend.test.ts @@ -0,0 +1,169 @@ +/* + * 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 {createFallbackBackend} from '../../src/clients/scapi-fallback-backend.js'; + +interface TestBackend { + readonly name: 'ocapi' | 'scapi'; + doRead(): Promise; + doWrite(input: string): Promise; + multiArg(a: string, b: number, c?: boolean): Promise; +} + +function makeBackend(name: 'ocapi' | 'scapi', impl: Partial): TestBackend { + return { + name, + doRead: impl.doRead ?? (async () => `${name}-read`), + doWrite: impl.doWrite ?? (async () => undefined), + multiArg: impl.multiArg ?? (async (a, b, c) => `${name}:${a}:${b}:${c}`), + }; +} + +const invalidScopeError = () => new Error('Failed to get access token: 400 invalid_scope'); + +describe('createFallbackBackend', () => { + describe('happy path: SCAPI works', () => { + it('returns SCAPI result on first call and caches the choice', async () => { + let scapiCalls = 0; + let ocapiCalls = 0; + const scapi = makeBackend('scapi', { + doRead: async () => { + scapiCalls++; + return 'scapi-read'; + }, + }); + const ocapi = makeBackend('ocapi', { + doRead: async () => { + ocapiCalls++; + return 'ocapi-read'; + }, + }); + + const backend = createFallbackBackend(scapi, ocapi, 'test'); + expect(await backend.doRead()).to.equal('scapi-read'); + expect(await backend.doRead()).to.equal('scapi-read'); + expect(scapiCalls).to.equal(2); + expect(ocapiCalls).to.equal(0); + }); + + it('reflects scapi.name before any call resolves and after success', async () => { + const scapi = makeBackend('scapi', {}); + const ocapi = makeBackend('ocapi', {}); + + const backend = createFallbackBackend(scapi, ocapi, 'test'); + expect(backend.name).to.equal('scapi'); + await backend.doRead(); + expect(backend.name).to.equal('scapi'); + }); + }); + + describe('fallback path: SCAPI fails with invalid_scope', () => { + it('falls back to OCAPI and caches the choice', async () => { + let scapiCalls = 0; + let ocapiCalls = 0; + const scapi = makeBackend('scapi', { + doRead: async () => { + scapiCalls++; + throw invalidScopeError(); + }, + }); + const ocapi = makeBackend('ocapi', { + doRead: async () => { + ocapiCalls++; + return 'ocapi-read'; + }, + }); + + const backend = createFallbackBackend(scapi, ocapi, 'test'); + expect(await backend.doRead()).to.equal('ocapi-read'); + expect(await backend.doRead()).to.equal('ocapi-read'); + // SCAPI tried once on the first call; OCAPI handles all subsequent + expect(scapiCalls).to.equal(1); + expect(ocapiCalls).to.equal(2); + }); + + it('reflects ocapi.name after fallback', async () => { + const scapi = makeBackend('scapi', { + doRead: async () => { + throw invalidScopeError(); + }, + }); + const ocapi = makeBackend('ocapi', {}); + + const backend = createFallbackBackend(scapi, ocapi, 'test'); + expect(backend.name).to.equal('scapi'); + await backend.doRead(); + expect(backend.name).to.equal('ocapi'); + }); + + it('routes a different method to the cached OCAPI backend after fallback', async () => { + let ocapiWriteCalls = 0; + const scapi = makeBackend('scapi', { + doRead: async () => { + throw invalidScopeError(); + }, + doWrite: async () => { + throw new Error('SCAPI doWrite should not be called after fallback'); + }, + }); + const ocapi = makeBackend('ocapi', { + doRead: async () => 'ocapi-read', + doWrite: async () => { + ocapiWriteCalls++; + }, + }); + + const backend = createFallbackBackend(scapi, ocapi, 'test'); + await backend.doRead(); + await backend.doWrite('payload'); + expect(ocapiWriteCalls).to.equal(1); + }); + }); + + describe('non-fallback errors', () => { + it('rethrows non-invalid_scope errors without falling back', async () => { + const scapi = makeBackend('scapi', { + doRead: async () => { + throw new Error('something else broke'); + }, + }); + const ocapi = makeBackend('ocapi', { + doRead: async () => 'should-not-reach-this', + }); + + const backend = createFallbackBackend(scapi, ocapi, 'test'); + try { + await backend.doRead(); + expect.fail('should have thrown'); + } catch (e) { + expect((e as Error).message).to.equal('something else broke'); + } + // Subsequent calls still try SCAPI since fallback didn't trigger + expect(backend.name).to.equal('scapi'); + }); + }); + + describe('argument forwarding', () => { + it('forwards positional and optional args correctly', async () => { + const scapi = makeBackend('scapi', {}); + const ocapi = makeBackend('ocapi', {}); + const backend = createFallbackBackend(scapi, ocapi, 'test'); + + expect(await backend.multiArg('x', 7, true)).to.equal('scapi:x:7:true'); + expect(await backend.multiArg('y', 0)).to.equal('scapi:y:0:undefined'); + }); + }); + + describe('property access', () => { + it('returns non-method properties from the SCAPI target', () => { + const scapi = {...makeBackend('scapi', {}), customProp: 'scapi-value'}; + const ocapi = {...makeBackend('ocapi', {}), customProp: 'ocapi-value'}; + const backend = createFallbackBackend(scapi, ocapi, 'test'); + // Documented contract: non-method properties are not switched between backends + expect(backend.customProp).to.equal('scapi-value'); + }); + }); +}); From 357097aa7e4d03eb006c3436e8da75d433b5eb85 Mon Sep 17 00:00:00 2001 From: Charles Lavery Date: Fri, 8 May 2026 16:06:31 -0400 Subject: [PATCH 09/22] Encode capability differences in the type system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hostile review surfaced two real type-safety gaps in the dual-backend pattern. Both are fixed by making interface-level capability explicit rather than relying on runtime throws. 1. RoleInfo.permissions silently dropped after fallback. The OCAPI role mapper omitted permissions; SCAPI included them. Both satisfied the optional `permissions?` field, so TypeScript and the Proxy were happy while data quietly disappeared on the fallback path. Fix: map OCAPI's permissions through the existing snake_case → camelCase converter so getRole returns the same shape from both backends. 2. JobsBackend.deleteJobExecution was a runtime-throwing stub on OCAPI. Auto-mode behavior was unstable: it worked on a SCAPI-resolved backend, threw on an OCAPI-resolved one. Fix: split capability into DeletableJobsBackend (extends JobsBackend), which only ScapiJobsBackend implements. A supportsDeleteJobExecution() type guard lets callers narrow before calling. The Fallback Proxy detects SCAPI-only methods (those missing on OCAPI) and routes them directly to SCAPI without attempting fallback — invalid_scope errors propagate to the caller instead of trying an OCAPI that can't handle the operation. The job execution delete command now uses the type guard and gives a clear error message when the active backend can't delete. Adds 2 fallback-backend tests covering SCAPI-only method dispatch. 1732 SDK + 1219 CLI tests passing. --- .../src/commands/job/execution/delete.ts | 11 +++++ .../commands/job/execution/delete.test.ts | 30 +++++++++----- .../src/clients/scapi-fallback-backend.ts | 13 ++++++ packages/b2c-tooling-sdk/src/index.ts | 2 + .../src/operations/bm-roles/ocapi-backend.ts | 7 ++-- .../src/operations/jobs/index.ts | 9 +++- .../src/operations/jobs/ocapi-backend.ts | 4 -- .../src/operations/jobs/scapi-backend.ts | 9 +++- .../src/operations/jobs/types.ts | 18 +++++++- .../clients/scapi-fallback-backend.test.ts | 41 +++++++++++++++++++ 10 files changed, 122 insertions(+), 22 deletions(-) diff --git a/packages/b2c-cli/src/commands/job/execution/delete.ts b/packages/b2c-cli/src/commands/job/execution/delete.ts index 56272b463..8bb350264 100644 --- a/packages/b2c-cli/src/commands/job/execution/delete.ts +++ b/packages/b2c-cli/src/commands/job/execution/delete.ts @@ -5,6 +5,7 @@ */ import {Args} from '@oclif/core'; import {JobCommand} from '@salesforce/b2c-tooling-sdk/cli'; +import {supportsDeleteJobExecution} from '@salesforce/b2c-tooling-sdk/operations/jobs'; import {t, withDocs} from '../../../i18n/index.js'; export default class JobExecutionDelete extends JobCommand { @@ -41,6 +42,16 @@ export default class JobExecutionDelete extends JobCommand { return createTestCommand(JobExecutionDelete, hooks.getConfig(), flags, args); } - function createMockBackend() { + function createScapiBackend() { + // SCAPI backend implements DeletableJobsBackend (has deleteJobExecution) return { name: 'scapi' as const, executeJob: sinon.stub(), @@ -32,18 +33,28 @@ describe('job execution delete', () => { }; } - function stubCommon(command: any) { + function createOcapiBackend() { + // OCAPI backend does NOT implement deleteJobExecution + return { + name: 'ocapi' as const, + executeJob: sinon.stub(), + getJobExecution: sinon.stub(), + searchJobExecutions: sinon.stub(), + getJobLog: sinon.stub(), + }; + } + + function stubCommon(command: any, backend: object) { sinon.stub(command, 'requireOAuthCredentials').returns(void 0); sinon.stub(command, 'resolvedConfig').get(() => ({values: {hostname: 'example.com'}})); sinon.stub(command, 'instance').get(() => ({config: {hostname: 'example.com'}})); - const backend = createMockBackend(); sinon.stub(command, 'createJobsBackend').returns(backend); return backend; } - it('deletes a job execution', async () => { + it('deletes a job execution when SCAPI backend is active', async () => { const command: any = await createCommand({}, {jobId: 'my-job', executionId: 'exec-1'}); - const backend = stubCommon(command); + const backend = stubCommon(command, createScapiBackend()) as ReturnType; backend.deleteJobExecution.resolves(); await runSilent(() => command.run()); @@ -53,18 +64,15 @@ describe('job execution delete', () => { expect(backend.deleteJobExecution.getCall(0).args[1]).to.equal('exec-1'); }); - it('throws when OCAPI backend does not support delete', async () => { + it('errors with a clear message when OCAPI backend is active (no delete capability)', async () => { const command: any = await createCommand({}, {jobId: 'my-job', executionId: 'exec-1'}); - const backend = stubCommon(command); - backend.deleteJobExecution.rejects( - new Error('Delete job execution is not supported via OCAPI. Use --api-backend scapi.'), - ); + stubCommon(command, createOcapiBackend()); try { await command.run(); expect.fail('should have thrown'); } catch (error: any) { - expect(error.message).to.include('not supported via OCAPI'); + expect(error.message).to.match(/SCAPI/i); } }); }); diff --git a/packages/b2c-tooling-sdk/src/clients/scapi-fallback-backend.ts b/packages/b2c-tooling-sdk/src/clients/scapi-fallback-backend.ts index f820cdf0a..06c7494be 100644 --- a/packages/b2c-tooling-sdk/src/clients/scapi-fallback-backend.ts +++ b/packages/b2c-tooling-sdk/src/clients/scapi-fallback-backend.ts @@ -112,6 +112,19 @@ export function createFallbackBackend(scapi: T, ocapi: T, // We must look up the method by name on the resolved backend (not on the // SCAPI target we're proxying), since the OCAPI backend may have a // different implementation. + // + // If the method is missing from OCAPI (e.g., a SCAPI-only capability like + // delete), don't attempt a fallback — let SCAPI handle it directly. + // The caller should use the type-guard pattern (e.g. supportsDeleteJobExecution) + // to detect this before calling. + const ocapiHasMethod = typeof (ocapi as unknown as Record)[prop] === 'function'; + if (!ocapiHasMethod) { + return (...args: unknown[]) => { + const fn = (scapi as unknown as Record)[prop]; + return (fn as (...a: unknown[]) => Promise).apply(scapi, args); + }; + } + return (...args: unknown[]) => withFallback(state, (backend) => { const fn = (backend as unknown as Record)[prop]; diff --git a/packages/b2c-tooling-sdk/src/index.ts b/packages/b2c-tooling-sdk/src/index.ts index 8ce2ed575..c6e78b946 100644 --- a/packages/b2c-tooling-sdk/src/index.ts +++ b/packages/b2c-tooling-sdk/src/index.ts @@ -257,6 +257,7 @@ export { waitForJobExecution, OcapiJobsBackend, ScapiJobsBackend, + supportsDeleteJobExecution, } from './operations/jobs/index.js'; export type { JobExecution, @@ -270,6 +271,7 @@ export type { JobExecutionSearchResult, // Backend abstraction types JobsBackend, + DeletableJobsBackend, JobsBackendConfig, ApiBackendPreference, JobExecutionInfo, diff --git a/packages/b2c-tooling-sdk/src/operations/bm-roles/ocapi-backend.ts b/packages/b2c-tooling-sdk/src/operations/bm-roles/ocapi-backend.ts index 999e7a6ab..ff0d0ddad 100644 --- a/packages/b2c-tooling-sdk/src/operations/bm-roles/ocapi-backend.ts +++ b/packages/b2c-tooling-sdk/src/operations/bm-roles/ocapi-backend.ts @@ -30,9 +30,10 @@ function mapOcapiRole(ocapi: BmRole): RoleInfo { description: ocapi.description, userCount: ocapi.user_count, userManager: ocapi.user_manager, - // OCAPI permissions shape uses snake_case nested groups; the canonical - // type uses SCAPI's camelCase shape. We avoid converting the deep - // structure here (it's only exposed via the permissions endpoints). + // OCAPI returns permissions inline on the role when expanded, same as SCAPI. + // Map snake_case → camelCase to match the canonical RoleInfo shape so that + // callers see consistent data after a fallback from SCAPI to OCAPI. + permissions: ocapi.permissions ? mapOcapiPermissions(ocapi.permissions) : undefined, _raw: ocapi, }; } diff --git a/packages/b2c-tooling-sdk/src/operations/jobs/index.ts b/packages/b2c-tooling-sdk/src/operations/jobs/index.ts index 6d81574e1..9771a8b46 100644 --- a/packages/b2c-tooling-sdk/src/operations/jobs/index.ts +++ b/packages/b2c-tooling-sdk/src/operations/jobs/index.ts @@ -96,7 +96,14 @@ export type {JobsBackendConfig, ApiBackendPreference} from './backend.js'; export {OcapiJobsBackend} from './ocapi-backend.js'; export {ScapiJobsBackend} from './scapi-backend.js'; export type {ScapiJobsBackendConfig} from './scapi-backend.js'; -export type {JobsBackend, JobExecutionInfo, JobStepExecutionResult, JobExecutionSearchResults} from './types.js'; +export {supportsDeleteJobExecution} from './types.js'; +export type { + JobsBackend, + DeletableJobsBackend, + JobExecutionInfo, + JobStepExecutionResult, + JobExecutionSearchResults, +} from './types.js'; // Site archive import/export export { diff --git a/packages/b2c-tooling-sdk/src/operations/jobs/ocapi-backend.ts b/packages/b2c-tooling-sdk/src/operations/jobs/ocapi-backend.ts index 9cf221314..3e86861a6 100644 --- a/packages/b2c-tooling-sdk/src/operations/jobs/ocapi-backend.ts +++ b/packages/b2c-tooling-sdk/src/operations/jobs/ocapi-backend.ts @@ -77,10 +77,6 @@ export class OcapiJobsBackend implements JobsBackend { }; } - async deleteJobExecution(_jobId: string, _executionId: string): Promise { - throw new Error('Delete job execution is not supported via OCAPI. Use --api-backend scapi.'); - } - async getJobLog(execution: JobExecutionInfo): Promise { const ocapiExecution = execution._raw as JobExecution; if (ocapiExecution) { diff --git a/packages/b2c-tooling-sdk/src/operations/jobs/scapi-backend.ts b/packages/b2c-tooling-sdk/src/operations/jobs/scapi-backend.ts index 3ad0d389e..804fd81da 100644 --- a/packages/b2c-tooling-sdk/src/operations/jobs/scapi-backend.ts +++ b/packages/b2c-tooling-sdk/src/operations/jobs/scapi-backend.ts @@ -5,7 +5,12 @@ */ import type {B2CInstance} from '../../instance/index.js'; import type {AuthStrategy} from '../../auth/types.js'; -import type {JobsBackend, JobExecutionInfo, JobStepExecutionResult, JobExecutionSearchResults} from './types.js'; +import type { + DeletableJobsBackend, + JobExecutionInfo, + JobStepExecutionResult, + JobExecutionSearchResults, +} from './types.js'; import type {ExecuteJobOptions, SearchJobExecutionsOptions} from './run.js'; import { createScapiJobsClient, @@ -66,7 +71,7 @@ export interface ScapiJobsBackendConfig { instance: B2CInstance; } -export class ScapiJobsBackend implements JobsBackend { +export class ScapiJobsBackend implements DeletableJobsBackend { readonly name = 'scapi' as const; private organizationId: string; diff --git a/packages/b2c-tooling-sdk/src/operations/jobs/types.ts b/packages/b2c-tooling-sdk/src/operations/jobs/types.ts index a48c1727c..d218867e0 100644 --- a/packages/b2c-tooling-sdk/src/operations/jobs/types.ts +++ b/packages/b2c-tooling-sdk/src/operations/jobs/types.ts @@ -56,6 +56,22 @@ export interface JobsBackend { executeJob(jobId: string, options?: ExecuteJobOptions): Promise; getJobExecution(jobId: string, executionId: string): Promise; searchJobExecutions(options?: SearchJobExecutionsOptions): Promise; - deleteJobExecution(jobId: string, executionId: string): Promise; getJobLog(execution: JobExecutionInfo): Promise; } + +/** + * Capability extension for backends that can delete job execution records. + * Only SCAPI exposes this — OCAPI's Data API has no equivalent endpoint. + * + * Use {@link supportsDeleteJobExecution} to narrow at runtime. + */ +export interface DeletableJobsBackend extends JobsBackend { + deleteJobExecution(jobId: string, executionId: string): Promise; +} + +/** + * Type guard: returns true if the backend supports deleting job executions. + */ +export function supportsDeleteJobExecution(backend: JobsBackend): backend is DeletableJobsBackend { + return typeof (backend as DeletableJobsBackend).deleteJobExecution === 'function'; +} diff --git a/packages/b2c-tooling-sdk/test/clients/scapi-fallback-backend.test.ts b/packages/b2c-tooling-sdk/test/clients/scapi-fallback-backend.test.ts index febc665ae..7fdec11a9 100644 --- a/packages/b2c-tooling-sdk/test/clients/scapi-fallback-backend.test.ts +++ b/packages/b2c-tooling-sdk/test/clients/scapi-fallback-backend.test.ts @@ -166,4 +166,45 @@ describe('createFallbackBackend', () => { expect(backend.customProp).to.equal('scapi-value'); }); }); + + describe('SCAPI-only methods (capability extension)', () => { + interface ExtendedBackend extends TestBackend { + scapiOnlyMethod(): Promise; + } + + it('routes SCAPI-only methods directly to SCAPI without attempting fallback', async () => { + let scapiCalls = 0; + const scapi: ExtendedBackend = { + ...makeBackend('scapi', {}), + scapiOnlyMethod: async () => { + scapiCalls++; + return 'scapi-only-result'; + }, + }; + const ocapi = makeBackend('ocapi', {}); // no scapiOnlyMethod + + const backend = createFallbackBackend(scapi, ocapi as ExtendedBackend, 'test'); + expect(await backend.scapiOnlyMethod()).to.equal('scapi-only-result'); + expect(scapiCalls).to.equal(1); + }); + + it('does not fall back on invalid_scope for SCAPI-only methods', async () => { + const scapi: ExtendedBackend = { + ...makeBackend('scapi', {}), + scapiOnlyMethod: async () => { + throw invalidScopeError(); + }, + }; + const ocapi = makeBackend('ocapi', {}); // no scapiOnlyMethod + + const backend = createFallbackBackend(scapi, ocapi as ExtendedBackend, 'test'); + try { + await backend.scapiOnlyMethod(); + expect.fail('should have thrown'); + } catch (e) { + // Should be the original invalid_scope error, not a "method not found" error + expect((e as Error).message).to.include('invalid_scope'); + } + }); + }); }); From 67b5ef877d6b181ad1183ee12973a4b34d766d75 Mon Sep 17 00:00:00 2001 From: Charles Lavery Date: Fri, 8 May 2026 22:30:49 -0400 Subject: [PATCH 10/22] Rebuild jobs SCAPI/OCAPI dispatch around auth-layer scope cascade Replaces the layered backend abstraction (interface + adapter classes + Proxy fallback wrapper + ScopeTierManager) for the jobs domain with a single CLI-side dispatcher and free-function SCAPI ops. The dispatcher's sole purpose is caching the resolved backend across multi-call operations in apiBackend=auto mode, so a polling command (job run --wait) doesn't re-probe SCAPI on every iteration when the user has no SCAPI scopes provisioned. Auth changes: - AuthStrategy gains optional getAccessTokenForCascade(candidates). OAuthStrategy and JwtOAuthStrategy walk candidates in order; first that AM accepts wins, cached per requested scope set. - findCachedTokenSatisfying scans the cache for a non-expired token whose scopes are a superset of a required set, so a previously granted broader-scope token can serve a narrower request without another AM round trip. - 5 new unit tests exercise cascade resolution, cache reuse, and error paths. Client/middleware changes: - buildScapiClient gains scopeCascade option and a new createScapiAuthMiddleware that reads the per-request x-b2c-scope-mode header and asks the strategy to resolve the chosen cascade tier (read or write). Legacy defaultScopes still works for scripts/users/roles until they migrate. - SCAPI Jobs declares its cascade once at client construction: read = [['sfcc.jobs.rw'], ['sfcc.jobs']], write = [['sfcc.jobs.rw']]. Jobs domain: - ScapiJobsBackend / OcapiJobsBackend / FallbackJobsBackend / DeletableJobsBackend all deleted. ScopeTierManager dependency removed from jobs. - New scapi-ops.ts exports free functions (scapiExecuteJob, scapiGetJobExecution, scapiSearchJobExecutions, scapiDeleteJobExecution, scapiGetJobLog) that take a ScapiJobsClient and declare scope mode via headers. - mapOcapiExecution / mapOcapiSearchResult exposed as transitional helpers for the OCAPI dispatcher branches. - waitForJobExecution rewritten to take a getter callback over the canonical JobExecutionInfo shape. - New CanonicalJobExecutionError carries JobExecutionInfo (was raw OCAPI), fixing a regression where SCAPI --wait failures reported 'ERROR' instead of the real exit code. CLI: - BackendDispatcher moved to src/compat/dispatcher.ts behind a new ./compat package export. Re-exported from ./cli for ergonomics. runScapiOnly removed; SCAPI-only commands branch on apiBackendPreference + buildScapiJobsClient directly. - JobCommand exposes createJobsDispatcher, buildScapiJobsClient, and showJobLog (canonical-first, raw-OCAPI accepted for legacy callers). - All five job commands rewritten to dispatcher.run({scapi, ocapi}) with the scapi branch receiving a typed ScapiJobsClient. Behavioral guarantee: no CLI-visible changes. OCAPI-only setups, explicit --api-backend ocapi/scapi, and apiBackend=auto with full SCAPI scopes all behave identically to the prior implementation. The auto + missing SCAPI scopes path now caches the OCAPI choice across polls instead of re-probing AM on every call. 13 dispatcher unit tests + 5 cascade unit tests + updated CLI command tests. 1746 SDK + 1220 CLI tests passing. --- .../src/commands/job/execution/delete.ts | 30 +- packages/b2c-cli/src/commands/job/log.ts | 35 ++- packages/b2c-cli/src/commands/job/run.ts | 65 +++-- packages/b2c-cli/src/commands/job/search.ts | 24 +- packages/b2c-cli/src/commands/job/wait.ts | 21 +- .../commands/job/execution/delete.test.ts | 67 ++--- .../b2c-cli/test/commands/job/log.test.ts | 114 ++++---- .../b2c-cli/test/commands/job/run.test.ts | 55 ++-- .../b2c-cli/test/commands/job/search.test.ts | 43 +-- .../b2c-cli/test/commands/job/wait.test.ts | 35 +-- packages/b2c-tooling-sdk/package.json | 11 + .../b2c-tooling-sdk/src/auth/oauth-jwt.ts | 76 ++++- packages/b2c-tooling-sdk/src/auth/oauth.ts | 126 +++++++- packages/b2c-tooling-sdk/src/auth/types.ts | 28 +- packages/b2c-tooling-sdk/src/cli/index.ts | 6 + .../src/cli/instance-command.ts | 45 ++- .../b2c-tooling-sdk/src/cli/job-command.ts | 106 +++---- packages/b2c-tooling-sdk/src/clients/index.ts | 2 +- .../b2c-tooling-sdk/src/clients/middleware.ts | 111 +++++++ .../src/clients/scapi-client-factory.ts | 50 +++- .../b2c-tooling-sdk/src/clients/scapi-jobs.ts | 17 +- .../b2c-tooling-sdk/src/compat/dispatcher.ts | 144 +++++++++ packages/b2c-tooling-sdk/src/compat/index.ts | 14 + packages/b2c-tooling-sdk/src/index.ts | 21 +- .../src/operations/jobs/index.ts | 93 ++---- .../src/operations/jobs/ocapi-backend.ts | 95 ------ .../src/operations/jobs/ocapi-mapping.ts | 71 +++++ .../src/operations/jobs/scapi-backend.ts | 250 ---------------- .../src/operations/jobs/scapi-ops.ts | 276 ++++++++++++++++++ .../src/operations/jobs/types.ts | 31 +- .../jobs/{backend.ts => wait-canonical.ts} | 53 ++-- .../b2c-tooling-sdk/test/auth/oauth.test.ts | 143 +++++++++ .../test/compat/dispatcher.test.ts | 121 ++++++++ 33 files changed, 1611 insertions(+), 768 deletions(-) create mode 100644 packages/b2c-tooling-sdk/src/compat/dispatcher.ts create mode 100644 packages/b2c-tooling-sdk/src/compat/index.ts delete mode 100644 packages/b2c-tooling-sdk/src/operations/jobs/ocapi-backend.ts create mode 100644 packages/b2c-tooling-sdk/src/operations/jobs/ocapi-mapping.ts delete mode 100644 packages/b2c-tooling-sdk/src/operations/jobs/scapi-backend.ts create mode 100644 packages/b2c-tooling-sdk/src/operations/jobs/scapi-ops.ts rename packages/b2c-tooling-sdk/src/operations/jobs/{backend.ts => wait-canonical.ts} (52%) create mode 100644 packages/b2c-tooling-sdk/test/compat/dispatcher.test.ts diff --git a/packages/b2c-cli/src/commands/job/execution/delete.ts b/packages/b2c-cli/src/commands/job/execution/delete.ts index 8bb350264..d1a959760 100644 --- a/packages/b2c-cli/src/commands/job/execution/delete.ts +++ b/packages/b2c-cli/src/commands/job/execution/delete.ts @@ -5,7 +5,7 @@ */ import {Args} from '@oclif/core'; import {JobCommand} from '@salesforce/b2c-tooling-sdk/cli'; -import {supportsDeleteJobExecution} from '@salesforce/b2c-tooling-sdk/operations/jobs'; +import {scapiDeleteJobExecution} from '@salesforce/b2c-tooling-sdk/operations/jobs'; import {t, withDocs} from '../../../i18n/index.js'; export default class JobExecutionDelete extends JobCommand { @@ -20,8 +20,13 @@ export default class JobExecutionDelete extends JobCommand { const {jobId, executionId} = this.args; const {failed} = this.flags; - const backend = this.createJobsBackend(); - this.logger.debug(`Using ${backend.name} backend for job log`); + const dispatcher = this.createJobsDispatcher(); + const tenantId = this.resolvedConfig.values.tenantId; let execution: JobExecutionInfo; @@ -70,7 +78,10 @@ export default class JobLog extends JobCommand { executionId, }), ); - execution = await backend.getJobExecution(jobId, executionId); + execution = await dispatcher.run({ + scapi: (client) => scapiGetJobExecution(client, jobId, executionId, tenantId!), + ocapi: async () => mapOcapiExecution(await ocapiGetJobExecution(this.instance, jobId, executionId)), + }); } else { this.log( failed @@ -84,12 +95,17 @@ export default class JobLog extends JobCommand { }), ); - const results = await backend.searchJobExecutions({ + const searchOptions = { jobId, status: failed ? ['ERROR'] : undefined, count: 10, sortBy: 'start_time', - sortOrder: 'desc', + sortOrder: 'desc' as const, + }; + + const results = await dispatcher.run({ + scapi: (client) => scapiSearchJobExecutions(client, {...searchOptions, tenantId: tenantId!}), + ocapi: async () => mapOcapiSearchResult(await ocapiSearchJobExecutions(this.instance, searchOptions)), }); const match = results.hits.find((hit) => hit.isLogFileExisting); @@ -120,7 +136,12 @@ export default class JobLog extends JobCommand { }), ); - const log = await backend.getJobLog(execution); + if (!execution.logFilePath) { + this.error(t('commands.job.log.noLogFile', 'No log file exists for this execution')); + } + const webdavPath = execution.logFilePath.replace(/^\/Sites\//, ''); + const content = await this.instance.webdav.get(webdavPath); + const log = new TextDecoder().decode(content); if (!this.jsonEnabled()) { const useColor = !this.flags['no-color'] && process.stdout.isTTY; diff --git a/packages/b2c-cli/src/commands/job/run.ts b/packages/b2c-cli/src/commands/job/run.ts index fb3ae01bc..6ae78b1e4 100644 --- a/packages/b2c-cli/src/commands/job/run.ts +++ b/packages/b2c-cli/src/commands/job/run.ts @@ -4,13 +4,18 @@ * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 */ import {Args, Flags} from '@oclif/core'; -import {JobCommand, type B2COperationContext} from '@salesforce/b2c-tooling-sdk/cli'; +import {JobCommand, BackendDispatcher, type B2COperationContext} from '@salesforce/b2c-tooling-sdk/cli'; import { + executeJob as ocapiExecuteJob, + getJobExecution as ocapiGetJobExecution, + scapiExecuteJob, + scapiGetJobExecution, + mapOcapiExecution, waitForJobExecution, - JobExecutionError, - type JobsBackend, + CanonicalJobExecutionError, type JobExecutionInfo, } from '@salesforce/b2c-tooling-sdk/operations/jobs'; +import type {ScapiJobsClient} from '@salesforce/b2c-tooling-sdk/clients'; import {t, withDocs} from '../../i18n/index.js'; export default class JobRun extends JobCommand { @@ -90,7 +95,6 @@ export default class JobRun extends JobCommand { 'show-log': showLog, } = this.flags; - // Safety evaluation — check rules for this job before executing. const jobEvaluation = this.safetyGuard.evaluate({type: 'job', jobId}); if (jobEvaluation.action === 'block') { this.error(jobEvaluation.reason, {exit: 1}); @@ -99,14 +103,12 @@ export default class JobRun extends JobCommand { await this.confirmOrBlock(jobEvaluation); } - // Parse parameters or body const parameters = this.parseParameters(param || []); const rawBody = body ? this.parseBody(body) : undefined; - const backend = this.createJobsBackend(); - this.logger.debug(`Using ${backend.name} backend for job operations`); + const dispatcher = this.createJobsDispatcher(); + const tenantId = this.resolvedConfig.values.tenantId; - // Create lifecycle context const context = this.createContext('job:run', { jobId, parameters: rawBody ? undefined : parameters, @@ -115,7 +117,6 @@ export default class JobRun extends JobCommand { hostname: this.resolvedConfig.values.hostname, }); - // Run beforeOperation hooks - check for skip const beforeResult = await this.runBeforeHooks(context); if (beforeResult.skip) { this.log( @@ -137,17 +138,28 @@ export default class JobRun extends JobCommand { }), ); + const ocapiOptions = { + parameters: rawBody ? undefined : parameters, + body: rawBody, + waitForRunning: !noWaitRunning, + }; + let execution: JobExecutionInfo; try { - execution = await backend.executeJob(jobId, { - parameters: rawBody ? undefined : parameters, - body: rawBody, - waitForRunning: !noWaitRunning, + execution = await dispatcher.run({ + scapi: (client) => + scapiExecuteJob(client, jobId, { + ...ocapiOptions, + tenantId: tenantId!, + }), + ocapi: async () => mapOcapiExecution(await ocapiExecuteJob(this.instance, jobId, ocapiOptions)), }); } catch (error) { this.handleExecutionError(error, context); } + this.logger.debug(`Used ${dispatcher.active} backend for job execution`); + this.log( t('commands.job.run.started', 'Job started: {{executionId}} (status: {{status}})', { executionId: execution.id, @@ -155,12 +167,11 @@ export default class JobRun extends JobCommand { }), ); - // Wait for completion if requested if (wait) { execution = await this.waitForJobCompletion({ - backend, + dispatcher, jobId, - executionId: execution.id!, + executionId: execution.id, timeout, pollInterval, showLog, @@ -178,9 +189,6 @@ export default class JobRun extends JobCommand { } private handleExecutionError(error: unknown, context: B2COperationContext): never { - // Fire-and-forget: we're already on the error path and rethrow below; surface - // hook failures in the debug log so they aren't completely invisible, but - // don't shadow the original error. this.runAfterHooks(context, { success: false, error: error instanceof Error ? error : new Error(String(error)), @@ -200,16 +208,16 @@ export default class JobRun extends JobCommand { success: false, error: error instanceof Error ? error : new Error(String(error)), duration: Date.now() - context.startTime, - data: error instanceof JobExecutionError ? error.execution : undefined, + data: error instanceof CanonicalJobExecutionError ? error.execution : undefined, }); - if (error instanceof JobExecutionError) { + if (error instanceof CanonicalJobExecutionError) { if (showLog) { await this.showJobLog(error.execution); } this.error( t('commands.job.run.jobFailed', 'Job failed: {{status}}', { - status: error.execution.exit_status?.code || 'ERROR', + status: error.execution.exitStatus?.code || 'ERROR', }), ); } @@ -240,7 +248,7 @@ export default class JobRun extends JobCommand { } private async waitForJobCompletion(options: { - backend: JobsBackend; + dispatcher: BackendDispatcher; jobId: string; executionId: string; timeout: number | undefined; @@ -248,11 +256,18 @@ export default class JobRun extends JobCommand { showLog: boolean; context: B2COperationContext; }): Promise { - const {backend, jobId, executionId, timeout, pollInterval, showLog, context} = options; + const {dispatcher, jobId, executionId, timeout, pollInterval, showLog, context} = options; + const tenantId = this.resolvedConfig.values.tenantId; this.log(t('commands.job.run.waiting', 'Waiting for job to complete...')); try { - const execution = await waitForJobExecution(backend, jobId, executionId, { + const getExecution = (jid: string, eid: string) => + dispatcher.run({ + scapi: (client) => scapiGetJobExecution(client, jid, eid, tenantId!), + ocapi: async () => mapOcapiExecution(await ocapiGetJobExecution(this.instance, jid, eid)), + }); + + const execution = await waitForJobExecution(getExecution, jobId, executionId, { timeoutSeconds: timeout, pollIntervalSeconds: pollInterval, onPoll: (info) => { diff --git a/packages/b2c-cli/src/commands/job/search.ts b/packages/b2c-cli/src/commands/job/search.ts index 9a387f205..b3169bf81 100644 --- a/packages/b2c-cli/src/commands/job/search.ts +++ b/packages/b2c-cli/src/commands/job/search.ts @@ -11,7 +11,13 @@ import { selectColumns, type ColumnDef, } from '@salesforce/b2c-tooling-sdk/cli'; -import {type JobExecutionInfo, type JobExecutionSearchResults} from '@salesforce/b2c-tooling-sdk/operations/jobs'; +import { + searchJobExecutions as ocapiSearchJobExecutions, + scapiSearchJobExecutions, + mapOcapiSearchResult, + type JobExecutionInfo, + type JobExecutionSearchResults, +} from '@salesforce/b2c-tooling-sdk/operations/jobs'; import {t, withDocs} from '../../i18n/index.js'; const COLUMNS: Record> = { @@ -92,8 +98,8 @@ export default class JobSearch extends JobCommand { const {'job-id': jobId, status, count, start, 'sort-by': sortBy, 'sort-order': sortOrder} = this.flags; - const backend = this.createJobsBackend(); - this.logger.debug(`Using ${backend.name} backend for job search`); + const dispatcher = this.createJobsDispatcher(); + const tenantId = this.resolvedConfig.values.tenantId; this.log( t('commands.job.search.searching', 'Searching job executions on {{hostname}}...', { @@ -101,13 +107,11 @@ export default class JobSearch extends JobCommand { }), ); - const results = await backend.searchJobExecutions({ - jobId, - status, - count, - start, - sortBy, - sortOrder: sortOrder as 'asc' | 'desc', + const searchOptions = {jobId, status, count, start, sortBy, sortOrder: sortOrder as 'asc' | 'desc'}; + + const results = await dispatcher.run({ + scapi: (client) => scapiSearchJobExecutions(client, {...searchOptions, tenantId: tenantId!}), + ocapi: async () => mapOcapiSearchResult(await ocapiSearchJobExecutions(this.instance, searchOptions)), }); if (this.jsonEnabled()) { diff --git a/packages/b2c-cli/src/commands/job/wait.ts b/packages/b2c-cli/src/commands/job/wait.ts index cdc9454ed..ba94dcf80 100644 --- a/packages/b2c-cli/src/commands/job/wait.ts +++ b/packages/b2c-cli/src/commands/job/wait.ts @@ -6,8 +6,11 @@ import {Args, Flags} from '@oclif/core'; import {JobCommand} from '@salesforce/b2c-tooling-sdk/cli'; import { + getJobExecution as ocapiGetJobExecution, + scapiGetJobExecution, + mapOcapiExecution, waitForJobExecution, - JobExecutionError, + CanonicalJobExecutionError, type JobExecutionInfo, } from '@salesforce/b2c-tooling-sdk/operations/jobs'; import {t, withDocs} from '../../i18n/index.js'; @@ -59,8 +62,8 @@ export default class JobWait extends JobCommand { const {jobId, executionId} = this.args; const {timeout, 'poll-interval': pollInterval, 'show-log': showLog} = this.flags; - const backend = this.createJobsBackend(); - this.logger.debug(`Using ${backend.name} backend for job wait`); + const dispatcher = this.createJobsDispatcher(); + const tenantId = this.resolvedConfig.values.tenantId; this.log( t('commands.job.wait.waiting', 'Waiting for job {{jobId}} execution {{executionId}}...', { @@ -70,7 +73,13 @@ export default class JobWait extends JobCommand { ); try { - const execution = await waitForJobExecution(backend, jobId, executionId, { + const getExecution = (jid: string, eid: string) => + dispatcher.run({ + scapi: (client) => scapiGetJobExecution(client, jid, eid, tenantId!), + ocapi: async () => mapOcapiExecution(await ocapiGetJobExecution(this.instance, jid, eid)), + }); + + const execution = await waitForJobExecution(getExecution, jobId, executionId, { timeoutSeconds: timeout, pollIntervalSeconds: pollInterval, onPoll: (info) => { @@ -95,13 +104,13 @@ export default class JobWait extends JobCommand { return execution; } catch (error) { - if (error instanceof JobExecutionError) { + if (error instanceof CanonicalJobExecutionError) { if (showLog) { await this.showJobLog(error.execution); } this.error( t('commands.job.wait.jobFailed', 'Job failed: {{status}}', { - status: error.execution.exit_status?.code || 'ERROR', + status: error.execution.exitStatus?.code || 'ERROR', }), ); } diff --git a/packages/b2c-cli/test/commands/job/execution/delete.test.ts b/packages/b2c-cli/test/commands/job/execution/delete.test.ts index 7f41762c2..5599efe92 100644 --- a/packages/b2c-cli/test/commands/job/execution/delete.test.ts +++ b/packages/b2c-cli/test/commands/job/execution/delete.test.ts @@ -21,52 +21,49 @@ describe('job execution delete', () => { return createTestCommand(JobExecutionDelete, hooks.getConfig(), flags, args); } - function createScapiBackend() { - // SCAPI backend implements DeletableJobsBackend (has deleteJobExecution) - return { - name: 'scapi' as const, - executeJob: sinon.stub(), - getJobExecution: sinon.stub(), - searchJobExecutions: sinon.stub(), - deleteJobExecution: sinon.stub(), - getJobLog: sinon.stub(), - }; - } - - function createOcapiBackend() { - // OCAPI backend does NOT implement deleteJobExecution - return { - name: 'ocapi' as const, - executeJob: sinon.stub(), - getJobExecution: sinon.stub(), - searchJobExecutions: sinon.stub(), - getJobLog: sinon.stub(), - }; - } - - function stubCommon(command: any, backend: object) { + function stubCommon( + command: any, + opts: {client?: unknown; tenantId?: string; preference?: 'auto' | 'ocapi' | 'scapi'} = {}, + ) { sinon.stub(command, 'requireOAuthCredentials').returns(void 0); - sinon.stub(command, 'resolvedConfig').get(() => ({values: {hostname: 'example.com'}})); + sinon.stub(command, 'resolvedConfig').get(() => ({values: {hostname: 'example.com', tenantId: opts.tenantId}})); sinon.stub(command, 'instance').get(() => ({config: {hostname: 'example.com'}})); - sinon.stub(command, 'createJobsBackend').returns(backend); - return backend; + sinon.stub(command, 'apiBackendPreference').get(() => opts.preference ?? 'auto'); + sinon.stub(command, 'buildScapiJobsClient').returns(opts.client); } - it('deletes a job execution when SCAPI backend is active', async () => { + it('calls scapiDeleteJobExecution when SCAPI is configured', async () => { const command: any = await createCommand({}, {jobId: 'my-job', executionId: 'exec-1'}); - const backend = stubCommon(command, createScapiBackend()) as ReturnType; - backend.deleteJobExecution.resolves(); + // Provide a fake client that responds with no error from the openapi-fetch shape. + const fakeClient = { + DELETE: sinon.stub().resolves({error: undefined, response: {status: 204}}), + }; + stubCommon(command, {client: fakeClient, tenantId: 'tenant_test'}); await runSilent(() => command.run()); - expect(backend.deleteJobExecution.calledOnce).to.equal(true); - expect(backend.deleteJobExecution.getCall(0).args[0]).to.equal('my-job'); - expect(backend.deleteJobExecution.getCall(0).args[1]).to.equal('exec-1'); + expect(fakeClient.DELETE.calledOnce).to.equal(true); + const call = fakeClient.DELETE.getCall(0); + expect(call.args[0]).to.match(/executions\/\{executionId\}$/); + expect(call.args[1].params.path.jobId).to.equal('my-job'); + expect(call.args[1].params.path.executionId).to.equal('exec-1'); + }); + + it('errors when --api-backend ocapi is set', async () => { + const command: any = await createCommand({}, {jobId: 'my-job', executionId: 'exec-1'}); + stubCommon(command, {client: {}, tenantId: 'tenant_test', preference: 'ocapi'}); + + try { + await command.run(); + expect.fail('should have thrown'); + } catch (error: any) { + expect(error.message).to.match(/SCAPI/i); + } }); - it('errors with a clear message when OCAPI backend is active (no delete capability)', async () => { + it('errors when SCAPI is not configured', async () => { const command: any = await createCommand({}, {jobId: 'my-job', executionId: 'exec-1'}); - stubCommon(command, createOcapiBackend()); + stubCommon(command, {client: undefined}); try { await command.run(); diff --git a/packages/b2c-cli/test/commands/job/log.test.ts b/packages/b2c-cli/test/commands/job/log.test.ts index 3c9aca707..74101a22f 100644 --- a/packages/b2c-cli/test/commands/job/log.test.ts +++ b/packages/b2c-cli/test/commands/job/log.test.ts @@ -10,6 +10,18 @@ import sinon from 'sinon'; import JobLog from '../../../src/commands/job/log.js'; import {createIsolatedConfigHooks, createTestCommand, runSilent} from '../../helpers/test-setup.js'; +function makeDispatcherFake() { + const runner = sinon.stub(); + return { + runner, + dispatcher: { + active: 'scapi' as const, + run: runner, + runScapiOnly: sinon.stub(), + }, + }; +} + describe('job log', () => { const hooks = createIsolatedConfigHooks(); @@ -21,86 +33,85 @@ describe('job log', () => { return createTestCommand(JobLog, hooks.getConfig(), flags, args); } - function createMockBackend() { - return { - name: 'ocapi' as const, - executeJob: sinon.stub(), - getJobExecution: sinon.stub(), - searchJobExecutions: sinon.stub(), - deleteJobExecution: sinon.stub(), - getJobLog: sinon.stub(), - }; - } - function stubCommon(command: any) { sinon.stub(command, 'requireOAuthCredentials').returns(void 0); - sinon.stub(command, 'resolvedConfig').get(() => ({values: {hostname: 'example.com'}})); - sinon.stub(command, 'instance').get(() => ({config: {hostname: 'example.com'}})); + sinon.stub(command, 'resolvedConfig').get(() => ({values: {hostname: 'example.com', tenantId: 'tenant_test'}})); + sinon.stub(command, 'instance').get(() => ({ + config: {hostname: 'example.com'}, + webdav: {get: sinon.stub().resolves(new TextEncoder().encode('log content here'))}, + })); sinon.stub(command, 'log').returns(void 0); - const backend = createMockBackend(); - sinon.stub(command, 'createJobsBackend').returns(backend); - return backend; + const fake = makeDispatcherFake(); + sinon.stub(command, 'createJobsDispatcher').returns(fake.dispatcher); + return fake; } it('fetches log for a specific execution', async () => { const command: any = await createCommand({}, {jobId: 'my-job', executionId: 'exec-1'}); - const backend = stubCommon(command); + const {runner} = stubCommon(command); sinon.stub(command, 'jsonEnabled').returns(false); - const execution = {id: 'exec-1', jobId: 'my-job', isLogFileExisting: true, exitStatus: {code: 'OK'}}; - backend.getJobExecution.resolves(execution); - backend.getJobLog.resolves('log content here'); + const execution = { + id: 'exec-1', + jobId: 'my-job', + isLogFileExisting: true, + logFilePath: '/Sites/LOGS/jobs/exec-1.log', + exitStatus: {code: 'OK'}, + }; + runner.resolves(execution); const result = (await runSilent(() => command.run())) as {execution: unknown; log: string}; - expect(backend.getJobExecution.calledOnce).to.equal(true); - expect(backend.getJobExecution.getCall(0).args[0]).to.equal('my-job'); - expect(backend.getJobExecution.getCall(0).args[1]).to.equal('exec-1'); - expect(backend.getJobLog.calledOnce).to.equal(true); + expect(runner.calledOnce).to.equal(true); expect(result.log).to.equal('log content here'); expect(result.execution).to.equal(execution); }); it('searches for most recent execution with log', async () => { const command: any = await createCommand({}, {jobId: 'my-job'}); - const backend = stubCommon(command); + const {runner} = stubCommon(command); sinon.stub(command, 'jsonEnabled').returns(false); const execWithoutLog = {id: 'exec-1', jobId: 'my-job', isLogFileExisting: false}; - const execWithLog = {id: 'exec-2', jobId: 'my-job', isLogFileExisting: true, exitStatus: {code: 'OK'}}; - backend.searchJobExecutions.resolves({total: 2, hits: [execWithoutLog, execWithLog]}); - backend.getJobLog.resolves('log from exec-2'); + const execWithLog = { + id: 'exec-2', + jobId: 'my-job', + isLogFileExisting: true, + logFilePath: '/Sites/LOGS/jobs/exec-2.log', + exitStatus: {code: 'OK'}, + }; + runner.resolves({total: 2, hits: [execWithoutLog, execWithLog]}); const result = (await runSilent(() => command.run())) as {log: string}; - expect(backend.searchJobExecutions.calledOnce).to.equal(true); - expect(backend.searchJobExecutions.getCall(0).args[0]).to.deep.include({jobId: 'my-job'}); - expect(backend.getJobLog.calledOnce).to.equal(true); - expect(backend.getJobLog.getCall(0).args[0]).to.equal(execWithLog); - expect(result.log).to.equal('log from exec-2'); + expect(runner.calledOnce).to.equal(true); + expect(result.log).to.equal('log content here'); }); it('searches for most recent failed execution with --failed', async () => { const command: any = await createCommand({failed: true}, {jobId: 'my-job'}); - const backend = stubCommon(command); + const {runner} = stubCommon(command); sinon.stub(command, 'jsonEnabled').returns(false); - const execution = {id: 'exec-3', jobId: 'my-job', isLogFileExisting: true, exitStatus: {code: 'ERROR'}}; - backend.searchJobExecutions.resolves({total: 1, hits: [execution]}); - backend.getJobLog.resolves('error log'); + const execution = { + id: 'exec-3', + jobId: 'my-job', + isLogFileExisting: true, + logFilePath: '/Sites/LOGS/jobs/exec-3.log', + exitStatus: {code: 'ERROR'}, + }; + runner.resolves({total: 1, hits: [execution]}); const result = (await runSilent(() => command.run())) as {log: string}; - expect(backend.searchJobExecutions.getCall(0).args[0]).to.deep.include({status: ['ERROR']}); - expect(result.log).to.equal('error log'); + expect(result.log).to.equal('log content here'); }); it('errors when specific execution has no log file', async () => { const command: any = await createCommand({}, {jobId: 'my-job', executionId: 'exec-1'}); - const backend = stubCommon(command); + const {runner} = stubCommon(command); - const execution = {id: 'exec-1', jobId: 'my-job', isLogFileExisting: false}; - backend.getJobExecution.resolves(execution); + runner.resolves({id: 'exec-1', jobId: 'my-job', isLogFileExisting: false}); try { await command.run(); @@ -112,9 +123,9 @@ describe('job log', () => { it('errors when no executions with log found', async () => { const command: any = await createCommand({}, {jobId: 'my-job'}); - const backend = stubCommon(command); + const {runner} = stubCommon(command); - backend.searchJobExecutions.resolves({total: 0, hits: []}); + runner.resolves({total: 0, hits: []}); try { await command.run(); @@ -126,16 +137,21 @@ describe('job log', () => { it('returns structured result in json mode', async () => { const command: any = await createCommand({json: true}, {jobId: 'my-job', executionId: 'exec-1'}); - const backend = stubCommon(command); + const {runner} = stubCommon(command); sinon.stub(command, 'jsonEnabled').returns(true); - const execution = {id: 'exec-1', jobId: 'my-job', isLogFileExisting: true, exitStatus: {code: 'OK'}}; - backend.getJobExecution.resolves(execution); - backend.getJobLog.resolves('json log content'); + const execution = { + id: 'exec-1', + jobId: 'my-job', + isLogFileExisting: true, + logFilePath: '/Sites/LOGS/jobs/exec-1.log', + exitStatus: {code: 'OK'}, + }; + runner.resolves(execution); const result = await command.run(); expect(result).to.have.property('execution'); - expect(result).to.have.property('log', 'json log content'); + expect(result).to.have.property('log', 'log content here'); }); }); diff --git a/packages/b2c-cli/test/commands/job/run.test.ts b/packages/b2c-cli/test/commands/job/run.test.ts index 0734c82a3..b55fc9279 100644 --- a/packages/b2c-cli/test/commands/job/run.test.ts +++ b/packages/b2c-cli/test/commands/job/run.test.ts @@ -10,6 +10,25 @@ import sinon from 'sinon'; import JobRun from '../../../src/commands/job/run.js'; import {createIsolatedConfigHooks, createTestCommand} from '../../helpers/test-setup.js'; +/** + * The dispatcher's branch-routing behavior is unit-tested in + * b2c-tooling-sdk/test/compat/dispatcher.test.ts. Command tests stub + * `createJobsDispatcher` to return a fake whose `run()` returns a + * pre-programmed value — we test command-level orchestration without + * exercising the dispatcher internals. + */ +function makeDispatcherFake() { + const runner = sinon.stub(); + return { + runner, + dispatcher: { + active: 'scapi' as const, + run: runner, + runScapiOnly: sinon.stub(), + }, + }; +} + describe('job run', () => { const hooks = createIsolatedConfigHooks(); @@ -21,20 +40,9 @@ describe('job run', () => { return createTestCommand(JobRun, hooks.getConfig(), flags, args); } - function createMockBackend() { - return { - name: 'ocapi' as const, - executeJob: sinon.stub(), - getJobExecution: sinon.stub(), - searchJobExecutions: sinon.stub(), - deleteJobExecution: sinon.stub(), - getJobLog: sinon.stub(), - }; - } - function stubCommon(command: any) { sinon.stub(command, 'requireOAuthCredentials').returns(void 0); - sinon.stub(command, 'resolvedConfig').get(() => ({values: {hostname: 'example.com'}})); + sinon.stub(command, 'resolvedConfig').get(() => ({values: {hostname: 'example.com', tenantId: 'tenant_test'}})); sinon.stub(command, 'instance').get(() => ({config: {hostname: 'example.com'}})); sinon.stub(command, 'log').returns(void 0); sinon.stub(command, 'createContext').callsFake((operationType: any, metadata: any) => ({ @@ -42,9 +50,9 @@ describe('job run', () => { metadata, startTime: Date.now(), })); - const backend = createMockBackend(); - sinon.stub(command, 'createJobsBackend').returns(backend); - return backend; + const fake = makeDispatcherFake(); + sinon.stub(command, 'createJobsDispatcher').returns(fake.dispatcher); + return fake; } it('errors on invalid -P param format', async () => { @@ -65,17 +73,16 @@ describe('job run', () => { it('executes without waiting when --wait is false', async () => { const command: any = await createCommand({param: ['A=1'], json: true}, {jobId: 'my-job'}); - const backend = stubCommon(command); + const {runner} = stubCommon(command); sinon.stub(command, 'runBeforeHooks').resolves({skip: false}); sinon.stub(command, 'runAfterHooks').resolves(void 0); - backend.executeJob.resolves({id: 'e1', executionStatus: 'running'}); + runner.resolves({id: 'e1', jobId: 'my-job', executionStatus: 'running'}); const result = await command.run(); - expect(backend.executeJob.calledOnce).to.equal(true); - expect(backend.executeJob.getCall(0).args[0]).to.equal('my-job'); + expect(runner.calledOnce).to.equal(true); expect(result.id).to.equal('e1'); }); @@ -84,21 +91,23 @@ describe('job run', () => { {wait: true, timeout: 10, 'poll-interval': 1, json: true}, {jobId: 'my-job'}, ); - const backend = stubCommon(command); + const {runner} = stubCommon(command); sinon.stub(command, 'runBeforeHooks').resolves({skip: false}); sinon.stub(command, 'runAfterHooks').resolves(void 0); - backend.executeJob.resolves({id: 'e1', executionStatus: 'running'}); - backend.getJobExecution.resolves({ + // First run() call is executeJob; subsequent are getJobExecution polls. + runner.onFirstCall().resolves({id: 'e1', jobId: 'my-job', executionStatus: 'running'}); + runner.onSecondCall().resolves({ id: 'e1', + jobId: 'my-job', executionStatus: 'finished', exitStatus: {code: 'OK', status: 'ok'}, }); const result = await command.run(); - expect(backend.getJobExecution.called).to.equal(true); + expect(runner.callCount).to.be.greaterThanOrEqual(2); expect(result.executionStatus).to.equal('finished'); }); diff --git a/packages/b2c-cli/test/commands/job/search.test.ts b/packages/b2c-cli/test/commands/job/search.test.ts index cbbbfa318..9f5dc7cc7 100644 --- a/packages/b2c-cli/test/commands/job/search.test.ts +++ b/packages/b2c-cli/test/commands/job/search.test.ts @@ -11,6 +11,18 @@ import sinon from 'sinon'; import JobSearch from '../../../src/commands/job/search.js'; import {createIsolatedConfigHooks, createTestCommand} from '../../helpers/test-setup.js'; +function makeDispatcherFake() { + const runner = sinon.stub(); + return { + runner, + dispatcher: { + active: 'scapi' as const, + run: runner, + runScapiOnly: sinon.stub(), + }, + }; +} + describe('job search', () => { const hooks = createIsolatedConfigHooks(); @@ -22,54 +34,43 @@ describe('job search', () => { return createTestCommand(JobSearch, hooks.getConfig(), flags, args); } - function createMockBackend() { - return { - name: 'ocapi' as const, - executeJob: sinon.stub(), - getJobExecution: sinon.stub(), - searchJobExecutions: sinon.stub(), - deleteJobExecution: sinon.stub(), - getJobLog: sinon.stub(), - }; - } - function stubCommon(command: any) { sinon.stub(command, 'requireOAuthCredentials').returns(void 0); - sinon.stub(command, 'resolvedConfig').get(() => ({values: {hostname: 'example.com'}})); + sinon.stub(command, 'resolvedConfig').get(() => ({values: {hostname: 'example.com', tenantId: 'tenant_test'}})); sinon.stub(command, 'instance').get(() => ({config: {hostname: 'example.com'}})); sinon.stub(command, 'log').returns(void 0); - const backend = createMockBackend(); - sinon.stub(command, 'createJobsBackend').returns(backend); - return backend; + const fake = makeDispatcherFake(); + sinon.stub(command, 'createJobsDispatcher').returns(fake.dispatcher); + return fake; } it('returns results in json mode', async () => { const command: any = await createCommand({json: true}, {}); - const backend = stubCommon(command); + const {runner} = stubCommon(command); sinon.stub(command, 'jsonEnabled').returns(true); - backend.searchJobExecutions.resolves({total: 1, hits: [{id: 'e1'}]}); + runner.resolves({total: 1, hits: [{id: 'e1'}]}); const uxStub = sinon.stub(ux, 'stdout'); const result = await command.run(); - expect(backend.searchJobExecutions.calledOnce).to.equal(true); + expect(runner.calledOnce).to.equal(true); expect(uxStub.called).to.equal(false); expect(result.total).to.equal(1); }); it('prints no results in non-json mode', async () => { const command: any = await createCommand({}, {}); - const backend = stubCommon(command); + const {runner} = stubCommon(command); sinon.stub(command, 'jsonEnabled').returns(false); - backend.searchJobExecutions.resolves({total: 0, hits: []}); + runner.resolves({total: 0, hits: []}); const uxStub = sinon.stub(ux, 'stdout'); const result = await command.run(); expect(result.total).to.equal(0); expect(uxStub.calledOnce).to.equal(true); - expect(backend.searchJobExecutions.calledOnce).to.equal(true); + expect(runner.calledOnce).to.equal(true); }); }); diff --git a/packages/b2c-cli/test/commands/job/wait.test.ts b/packages/b2c-cli/test/commands/job/wait.test.ts index eae257426..46162b990 100644 --- a/packages/b2c-cli/test/commands/job/wait.test.ts +++ b/packages/b2c-cli/test/commands/job/wait.test.ts @@ -10,6 +10,18 @@ import sinon from 'sinon'; import JobWait from '../../../src/commands/job/wait.js'; import {createIsolatedConfigHooks, createTestCommand} from '../../helpers/test-setup.js'; +function makeDispatcherFake() { + const runner = sinon.stub(); + return { + runner, + dispatcher: { + active: 'scapi' as const, + run: runner, + runScapiOnly: sinon.stub(), + }, + }; +} + describe('job wait', () => { const hooks = createIsolatedConfigHooks(); @@ -21,38 +33,27 @@ describe('job wait', () => { return createTestCommand(JobWait, hooks.getConfig(), flags, args); } - function createMockBackend() { - return { - name: 'ocapi' as const, - executeJob: sinon.stub(), - getJobExecution: sinon.stub(), - searchJobExecutions: sinon.stub(), - deleteJobExecution: sinon.stub(), - getJobLog: sinon.stub(), - }; - } - - it('waits using backend polling', async () => { + it('waits using dispatcher polling', async () => { const command: any = await createCommand({'poll-interval': 1, json: true}, {jobId: 'my-job', executionId: 'e1'}); sinon.stub(command, 'requireOAuthCredentials').returns(void 0); - sinon.stub(command, 'resolvedConfig').get(() => ({values: {hostname: 'example.com'}})); + sinon.stub(command, 'resolvedConfig').get(() => ({values: {hostname: 'example.com', tenantId: 'tenant_test'}})); sinon.stub(command, 'instance').get(() => ({config: {hostname: 'example.com'}})); sinon.stub(command, 'log').returns(void 0); sinon.stub(command, 'jsonEnabled').returns(true); - const backend = createMockBackend(); - backend.getJobExecution.resolves({ + const {runner, dispatcher} = makeDispatcherFake(); + runner.resolves({ id: 'e1', jobId: 'my-job', executionStatus: 'finished', exitStatus: {code: 'OK', status: 'ok'}, }); - sinon.stub(command, 'createJobsBackend').returns(backend); + sinon.stub(command, 'createJobsDispatcher').returns(dispatcher); const result = await command.run(); - expect(backend.getJobExecution.called).to.equal(true); + expect(runner.called).to.equal(true); expect(result.id).to.equal('e1'); }); }); diff --git a/packages/b2c-tooling-sdk/package.json b/packages/b2c-tooling-sdk/package.json index 7e1314566..71a81ce90 100644 --- a/packages/b2c-tooling-sdk/package.json +++ b/packages/b2c-tooling-sdk/package.json @@ -255,6 +255,17 @@ "default": "./dist/cjs/clients/index.js" } }, + "./compat": { + "development": "./src/compat/index.ts", + "import": { + "types": "./dist/esm/compat/index.d.ts", + "default": "./dist/esm/compat/index.js" + }, + "require": { + "types": "./dist/cjs/compat/index.d.ts", + "default": "./dist/cjs/compat/index.js" + } + }, "./logging": { "development": "./src/logging/index.ts", "import": { diff --git a/packages/b2c-tooling-sdk/src/auth/oauth-jwt.ts b/packages/b2c-tooling-sdk/src/auth/oauth-jwt.ts index 6c3a2e19a..93127cda2 100644 --- a/packages/b2c-tooling-sdk/src/auth/oauth-jwt.ts +++ b/packages/b2c-tooling-sdk/src/auth/oauth-jwt.ts @@ -20,6 +20,7 @@ import { getCachedOAuthToken, setCachedOAuthToken, invalidateCachedOAuthToken, + findCachedTokenSatisfying, decodeJWT, } from './oauth.js'; import {globalAuthMiddlewareRegistry, applyAuthRequestMiddleware, applyAuthResponseMiddleware} from './middleware.js'; @@ -252,6 +253,49 @@ export class JwtOAuthStrategy implements AuthStrategy { }); } + /** + * Resolves a scope cascade. See {@link AuthStrategy.getAccessTokenForCascade}. + * Mirrors `OAuthStrategy.getAccessTokenForCascade` for the JWT bearer flow. + */ + async getAccessTokenForCascade(candidates: string[][]): Promise { + const baseScopes = this.config.scopes ?? []; + const identityPrefix = `${this.config.accountManagerHost}:${this.config.clientId}:jwt:`; + + for (const candidate of candidates) { + const required = [...new Set([...baseScopes, ...candidate])]; + const cached = findCachedTokenSatisfying(identityPrefix, required); + if (cached) { + this.logger.debug( + {required, cachedScopes: cached.scopes}, + `[JwtOAuthStrategy] Cache hit: cached token satisfies cascade candidate ${JSON.stringify(candidate)}`, + ); + return cached.accessToken; + } + } + + let lastError: unknown; + for (const candidate of candidates) { + const merged = [...new Set([...baseScopes, ...candidate])]; + try { + this.logger.debug({scopes: merged}, `[JwtOAuthStrategy] Cascade trying scopes ${JSON.stringify(candidate)}`); + const tokenResponse = await this.requestNewTokenForScopes(merged); + return tokenResponse.accessToken; + } catch (error) { + if (error instanceof Error && error.message.includes('invalid_scope')) { + this.logger.debug( + {scopes: merged}, + `[JwtOAuthStrategy] Cascade candidate ${JSON.stringify(candidate)} rejected (invalid_scope), trying next`, + ); + lastError = error; + continue; + } + throw error; + } + } + + throw lastError ?? new Error('All scope cascade candidates failed'); + } + /** * Gets the full token response including expiration and scopes. * Useful for commands that need to display or return token metadata. @@ -293,10 +337,17 @@ export class JwtOAuthStrategy implements AuthStrategy { } /** - * Requests a new access token from Account Manager using JWT Bearer flow. - * Returns the full token response and caches it. + * Requests a new access token using the strategy's configured scopes. */ private async requestNewToken(): Promise { + return this.requestNewTokenForScopes(this.config.scopes); + } + + /** + * Requests a new access token from Account Manager using JWT Bearer flow, + * for the given scope set. Caches under a key derived from `scopes`. + */ + private async requestNewTokenForScopes(scopes: string[] | undefined): Promise { this.logger.trace('[JwtOAuthStrategy] Requesting new access token with JWT Bearer flow'); // Generate signed JWT @@ -313,15 +364,15 @@ export class JwtOAuthStrategy implements AuthStrategy { client_assertion: jwt, // ← JWT in body, not header }); - if (this.config.scopes && this.config.scopes.length > 0) { - params.append('scope', this.config.scopes.join(' ')); + if (scopes && scopes.length > 0) { + params.append('scope', scopes.join(' ')); } this.logger.trace( { tokenUrl, clientId: this.config.clientId, - scopes: this.config.scopes, + scopes, }, '[JwtOAuthStrategy] Sending JWT Bearer token request', ); @@ -378,24 +429,27 @@ export class JwtOAuthStrategy implements AuthStrategy { const expiresInSeconds = data.expires_in ?? 1800; const expiryDate = new Date(Date.now() + expiresInSeconds * 1000); - // Decode JWT to extract scopes (scope can be string or array) + // Decode JWT to extract scopes (scope can be string or array). Fall back + // to the requested scopes if the token doesn't carry a `scope` claim, so + // cache satisfies-checks still work for cascade resolution. const decoded = decodeJWT(data.access_token); const scope = decoded.payload.scope as string | string[] | undefined; - const scopes = Array.isArray(scope) ? scope : scope?.split(' ') || this.config.scopes || []; + const tokenScopes = Array.isArray(scope) ? scope : scope?.split(' ') || scopes || []; - // Build and cache token response const tokenResponse: AccessTokenResponse = { accessToken: data.access_token, expires: expiryDate, - scopes, + scopes: tokenScopes, }; - setCachedOAuthToken(this.cacheKey, tokenResponse); + // Cache under a key derived from the requested scopes (matches OAuthStrategy). + const cacheKey = getOAuthCacheKey(this.config.clientId, 'jwt', this.config.accountManagerHost, scopes); + setCachedOAuthToken(cacheKey, tokenResponse); this.logger.trace( { expiresIn: expiresInSeconds, expiresAt: expiryDate.toISOString(), - scopes, + scopes: tokenScopes, }, '[JwtOAuthStrategy] Access token obtained successfully', ); diff --git a/packages/b2c-tooling-sdk/src/auth/oauth.ts b/packages/b2c-tooling-sdk/src/auth/oauth.ts index 5f3510e3f..3e3543d0a 100644 --- a/packages/b2c-tooling-sdk/src/auth/oauth.ts +++ b/packages/b2c-tooling-sdk/src/auth/oauth.ts @@ -89,6 +89,38 @@ export function setCachedOAuthToken(cacheKey: string, tokenResponse: AccessToken ACCESS_TOKEN_CACHE.set(cacheKey, tokenResponse); } +/** + * Scans the cache for a non-expired token (matching the supplied identity + * prefix) whose scopes are a superset of `requiredScopes`. + * + * Used by cascade resolution: a cached token granted with broader scopes + * (e.g. `sfcc.jobs.rw`) automatically satisfies a later request that needs + * a narrower scope (e.g. `sfcc.jobs`), with no extra AM round trip. + * + * The identity prefix is `${accountManagerHost}:${clientId}:${method}:` — + * the same prefix `getOAuthCacheKey` produces. We iterate cache values that + * share this prefix; in practice 1-3 entries per identity. + * + * @returns The first satisfying token, or undefined if none. + */ +export function findCachedTokenSatisfying( + identityPrefix: string, + requiredScopes: string[], +): AccessTokenResponse | undefined { + const now = new Date(); + for (const [key, entry] of ACCESS_TOKEN_CACHE) { + if (!key.startsWith(identityPrefix)) continue; + if (now.getTime() > entry.expires.getTime()) { + ACCESS_TOKEN_CACHE.delete(key); + continue; + } + if (requiredScopes.every((s) => entry.scopes.includes(s))) { + return entry; + } + } + return undefined; +} + /** * Invalidates a cached OAuth token. * @@ -192,6 +224,63 @@ export class OAuthStrategy implements AuthStrategy { }); } + /** + * Resolves a scope cascade. See {@link AuthStrategy.getAccessTokenForCascade}. + * + * Each candidate is merged with this strategy's base scopes (e.g. tenant + * scope baked in via {@link withAdditionalScopes}) before being sent to AM. + * + * Cache strategy: + * 1. For each candidate, scan the cache for any non-expired token whose + * scopes ⊇ (base ∪ candidate). First hit wins, no AM call. + * 2. On miss, request each candidate from AM in order. Cache successes. + * 3. On `invalid_scope` for a candidate, continue to the next candidate. + * On any other error, rethrow. + */ + async getAccessTokenForCascade(candidates: string[][]): Promise { + const logger = getLogger(); + const baseScopes = this.config.scopes ?? []; + const identityPrefix = `${this.accountManagerHost}:${this.config.clientId}:client-credentials:`; + + // Pass 1: cache scan. Return the first cached token that satisfies any + // candidate. + for (const candidate of candidates) { + const required = [...new Set([...baseScopes, ...candidate])]; + const cached = findCachedTokenSatisfying(identityPrefix, required); + if (cached) { + logger.debug( + {required, cachedScopes: cached.scopes}, + `[OAuthStrategy] Cache hit: cached token satisfies cascade candidate ${JSON.stringify(candidate)}`, + ); + return cached.accessToken; + } + } + + // Pass 2: try each candidate against AM in order. + let lastError: unknown; + for (const candidate of candidates) { + const merged = [...new Set([...baseScopes, ...candidate])]; + try { + logger.debug({scopes: merged}, `[OAuthStrategy] Cascade trying scopes ${JSON.stringify(candidate)}`); + const tokenResponse = await this.refreshTokenForScopes(merged); + return tokenResponse.accessToken; + } catch (error) { + if (error instanceof Error && error.message.includes('invalid_scope')) { + logger.debug( + {scopes: merged}, + `[OAuthStrategy] Cascade candidate ${JSON.stringify(candidate)} rejected (invalid_scope), trying next`, + ); + lastError = error; + continue; + } + throw error; + } + } + + // All candidates exhausted. Rethrow the last invalid_scope. + throw lastError ?? new Error('All scope cascade candidates failed'); + } + /** * Gets an access token, using cache if valid */ @@ -214,28 +303,40 @@ export class OAuthStrategy implements AuthStrategy { * when many requests trigger refresh at once. */ private refreshTokenSingleflight(): Promise { - const existing = PENDING_TOKEN_REQUESTS.get(this.cacheKey); + return this.refreshTokenForScopes(this.config.scopes); + } + + /** + * Variant of {@link refreshTokenSingleflight} that requests a specific scope + * set rather than the strategy's configured scopes. Used by cascade + * resolution. Caches under a key derived from the requested scopes. + */ + private refreshTokenForScopes(scopes: string[] | undefined): Promise { + const cacheKey = getOAuthCacheKey(this.config.clientId, 'client-credentials', this.accountManagerHost, scopes); + const existing = PENDING_TOKEN_REQUESTS.get(cacheKey); if (existing) { getLogger().debug('[OAuthStrategy] Joining in-flight token request'); return existing; } const pending = (async () => { - getLogger().debug('[OAuthStrategy] Requesting new access token'); - const tokenResponse = await this.clientCredentialsGrant(); - setCachedOAuthToken(this.cacheKey, tokenResponse); + getLogger().debug({scopes}, '[OAuthStrategy] Requesting new access token'); + const tokenResponse = await this.clientCredentialsGrant(scopes); + setCachedOAuthToken(cacheKey, tokenResponse); return tokenResponse; })().finally(() => { - PENDING_TOKEN_REQUESTS.delete(this.cacheKey); + PENDING_TOKEN_REQUESTS.delete(cacheKey); }); - PENDING_TOKEN_REQUESTS.set(this.cacheKey, pending); + PENDING_TOKEN_REQUESTS.set(cacheKey, pending); return pending; } /** - * Performs client credentials grant flow + * Performs client credentials grant flow with the given scope set. + * Defaults to the strategy's configured scopes when `scopes` is omitted. */ - private async clientCredentialsGrant(): Promise { + private async clientCredentialsGrant(scopeOverride?: string[]): Promise { const logger = getLogger(); + const requestedScopes = scopeOverride ?? this.config.scopes; const url = `https://${this.accountManagerHost}/dwsso/oauth2/access_token`; const method = 'POST'; @@ -243,8 +344,8 @@ export class OAuthStrategy implements AuthStrategy { grant_type: 'client_credentials', }); - if (this.config.scopes && this.config.scopes.length > 0) { - params.append('scope', this.config.scopes.join(' ')); + if (requestedScopes && requestedScopes.length > 0) { + params.append('scope', requestedScopes.join(' ')); } const credentials = Buffer.from(`${this.config.clientId}:${this.config.clientSecret}`).toString('base64'); @@ -319,7 +420,10 @@ export class OAuthStrategy implements AuthStrategy { const now = new Date(); const expiration = new Date(now.getTime() + data.expires_in * 1000); - const scopes = data.scope?.split(' ') ?? []; + // AM normally echoes back the granted scopes; some configurations omit + // the `scope` claim in the token response. Fall back to what we + // requested so cache satisfies-checks (cascade resolution) still work. + const scopes = data.scope?.split(' ') ?? requestedScopes ?? []; return { accessToken: data.access_token, diff --git a/packages/b2c-tooling-sdk/src/auth/types.ts b/packages/b2c-tooling-sdk/src/auth/types.ts index 835cc0e12..434f33c06 100644 --- a/packages/b2c-tooling-sdk/src/auth/types.ts +++ b/packages/b2c-tooling-sdk/src/auth/types.ts @@ -35,7 +35,7 @@ export interface AuthStrategy { /** * Optional: Returns a copy of this strategy with the given scopes merged into * its requested scope set. SCAPI client factories use this to ensure the - * domain scope (e.g., `sfcc.jobs.rw`) and the tenant scope are present. + * tenant scope is present on every token request. * * Implemented by `OAuthStrategy` and `JwtOAuthStrategy`. Strategies that * obtain tokens by other means (basic, api-key, implicit-via-stored-session) @@ -43,6 +43,32 @@ export interface AuthStrategy { * established at construction time." */ withAdditionalScopes?(additionalScopes: string[]): AuthStrategy; + + /** + * Optional: Resolves a scope cascade by trying each candidate scope set + * in order and returning the first that AM accepts. + * + * Implementations should: + * 1. Return any cached token whose scopes ⊇ a candidate (no AM call). + * 2. Otherwise, call AM with each candidate in order until one survives; + * cache the result keyed by what was requested. + * 3. Throw the last `invalid_scope` error if all candidates fail. + * + * Implementations MUST add any base scopes (e.g. tenant scope baked in + * via {@link withAdditionalScopes}) to each candidate before sending it + * to AM. + * + * Used by the SCAPI auth middleware to pick the right scope tier (rw vs + * ro) per operation. Strategies without OAuth-style scope grants (basic, + * api-key) should leave this unset; the middleware falls through to + * {@link getAuthorizationHeader} in that case. + * + * @param candidates - Outer array is cascade order; inner arrays are the + * scopes for each token request attempt. e.g. + * `[['sfcc.jobs.rw'], ['sfcc.jobs']]`. + * @returns The access token (Bearer value, no `Bearer ` prefix). + */ + getAccessTokenForCascade?(candidates: string[][]): Promise; } /** diff --git a/packages/b2c-tooling-sdk/src/cli/index.ts b/packages/b2c-tooling-sdk/src/cli/index.ts index b60e61aec..2e3ef3417 100644 --- a/packages/b2c-tooling-sdk/src/cli/index.ts +++ b/packages/b2c-tooling-sdk/src/cli/index.ts @@ -90,6 +90,12 @@ * @module cli */ +// Backend dispatcher — re-exported from `compat/` for CLI ergonomics. The +// canonical home is `@salesforce/b2c-tooling-sdk/compat`; CLI commands and +// other interfaces (VSCode, MCP) can import from either location. +export {BackendDispatcher} from '../compat/dispatcher.js'; +export type {ApiBackendPreference, ResolvedBackend, DispatchBranches} from '../compat/dispatcher.js'; + // Base command classes export {BaseCommand} from './base-command.js'; export type {Flags, Args} from './base-command.js'; diff --git a/packages/b2c-tooling-sdk/src/cli/instance-command.ts b/packages/b2c-tooling-sdk/src/cli/instance-command.ts index e3c8a4679..4a623ebf0 100644 --- a/packages/b2c-tooling-sdk/src/cli/instance-command.ts +++ b/packages/b2c-tooling-sdk/src/cli/instance-command.ts @@ -19,6 +19,7 @@ import { type B2COperationLifecycleHookOptions, type B2COperationLifecycleHookResult, } from './lifecycle.js'; +import {BackendDispatcher, type ApiBackendPreference} from '../compat/dispatcher.js'; /** * Base command for B2C instance operations. @@ -191,22 +192,46 @@ export abstract class InstanceCommand extends OAuthCom } /** - * Creates a SCAPI/OCAPI dual backend by passing the resolved configuration - * (apiBackend preference, instance, shortCode, tenantId, OAuth) to the - * supplied factory. Each backend domain (jobs, scripts, users, roles) - * exports its own factory; this helper supplies the same plumbing for all. + * Creates a per-command {@link BackendDispatcher} for routing operations + * to SCAPI or OCAPI based on the user's `--api-backend` preference. * - * @example - * ```ts - * const backend = this.createBackend(createJobsBackend); - * await backend.executeJob('my-job'); - * ``` + * Domain command bases (e.g., `JobCommand`) typically expose a thinner + * helper on top of this. SDK consumers don't use the dispatcher — they + * call SCAPI ops or OCAPI free functions directly. + * + * @param domainName - Used in fallback log lines, e.g. `'jobs'`. + * @param createScapi - Builds the SCAPI ops bundle. Should return + * `undefined` when SCAPI is not configured. + */ + protected createDispatcher(domainName: string, createScapi: () => S | undefined): BackendDispatcher { + return new BackendDispatcher(this.apiBackendPreference, createScapi, domainName); + } + + /** Resolved `--api-backend` preference (default `'auto'`). */ + protected get apiBackendPreference(): ApiBackendPreference { + return this.resolvedConfig.values.apiBackend ?? 'auto'; + } + + /** True iff shortCode + tenantId + OAuth credentials are all available. */ + protected hasScapiConfig(): boolean { + return Boolean( + this.resolvedConfig.values.shortCode && this.resolvedConfig.values.tenantId && this.hasOAuthCredentials(), + ); + } + + /** + * Legacy dual-backend factory bridge for domains (scripts, users, roles) + * that have not yet migrated to the dispatcher pattern. Will be removed + * once those domains move to SCAPI ops + dispatcher branches in CLI. + * + * @deprecated Use {@link createDispatcher} and call SCAPI ops / OCAPI + * functions directly from CLI commands. */ protected createBackend( factory: (config: import('../clients/dual-backend-factory.js').DualBackendConfig) => T, ): T { return factory({ - preference: this.resolvedConfig.values.apiBackend ?? 'auto', + preference: this.apiBackendPreference, instance: this.instance, shortCode: this.resolvedConfig.values.shortCode, tenantId: this.resolvedConfig.values.tenantId, diff --git a/packages/b2c-tooling-sdk/src/cli/job-command.ts b/packages/b2c-tooling-sdk/src/cli/job-command.ts index ea9753d1f..936fc3d83 100644 --- a/packages/b2c-tooling-sdk/src/cli/job-command.ts +++ b/packages/b2c-tooling-sdk/src/cli/job-command.ts @@ -5,45 +5,70 @@ */ import {Command} from '@oclif/core'; import {InstanceCommand} from './instance-command.js'; -import {getJobLog, getJobErrorMessage, type JobExecution} from '../operations/jobs/index.js'; -import {createJobsBackend, type JobsBackend, type JobExecutionInfo} from '../operations/jobs/index.js'; +import {BackendDispatcher} from '../compat/dispatcher.js'; +import {createScapiJobsClient, type ScapiJobsClient} from '../clients/scapi-jobs.js'; +import {mapOcapiExecution, type JobExecution, type JobExecutionInfo} from '../operations/jobs/index.js'; import {t} from '../i18n/index.js'; /** * Base command for job operations. * - * Extends InstanceCommand with job-specific functionality like - * displaying job logs on failure and creating backend-aware job clients. + * Provides: + * - {@link createJobsDispatcher} for routing operations to SCAPI or OCAPI + * - {@link buildScapiJobsClient} for SCAPI-only commands (e.g. delete) that + * don't need the dispatcher's auto-fallback + * - {@link showJobLog} for retrieving and printing canonical job logs on failure * * @example + * ```ts + * import {scapiExecuteJob, mapOcapiExecution, executeJob as ocapiExecuteJob} from + * '@salesforce/b2c-tooling-sdk/operations/jobs'; + * * export default class MyJobCommand extends JobCommand { - * async run(): Promise { - * const backend = this.createJobsBackend(); - * const execution = await backend.executeJob('my-job'); + * async run() { + * const dispatcher = this.createJobsDispatcher(); + * const exec = await dispatcher.run({ + * scapi: (client) => scapiExecuteJob(client, 'my-job', {tenantId: this.resolvedConfig.values.tenantId!}), + * ocapi: async () => mapOcapiExecution(await ocapiExecuteJob(this.instance, 'my-job')), + * }); * } * } + * ``` */ export abstract class JobCommand extends InstanceCommand { - protected createJobsBackend(): JobsBackend { - return this.createBackend(createJobsBackend); + protected createJobsDispatcher(): BackendDispatcher { + return this.createDispatcher('jobs', () => this.buildScapiJobsClient()); } /** - * Display a job's log file content and error message if available. - * Accepts both canonical JobExecutionInfo and legacy OCAPI JobExecution. - * Outputs to stderr since this is typically shown for failed jobs. + * Builds a SCAPI Jobs client, or `undefined` if SCAPI is not configured. + * Used both as the dispatcher's SCAPI factory and directly by SCAPI-only + * commands (e.g. `job execution delete`) that don't use the dispatcher. */ - protected async showJobLog(execution: JobExecutionInfo | JobExecution): Promise { - if (isCanonicalExecution(execution)) { - return this.showCanonicalJobLog(execution); - } - return this.showOcapiJobLog(execution); + protected buildScapiJobsClient(): ScapiJobsClient | undefined { + if (!this.hasScapiConfig()) return undefined; + return createScapiJobsClient( + { + shortCode: this.resolvedConfig.values.shortCode!, + tenantId: this.resolvedConfig.values.tenantId!, + }, + this.getOAuthStrategy(), + ); } - private async showCanonicalJobLog(execution: JobExecutionInfo): Promise { - const errorMessage = getCanonicalJobErrorMessage(execution); + /** + * Display a job execution's log file content and error message if available. + * + * Accepts either canonical {@link JobExecutionInfo} (preferred) or raw + * OCAPI {@link JobExecution} (from the legacy {@link JobExecutionError}). + * Raw OCAPI is mapped to canonical at the entry point so the rest of the + * function works on a single shape. + */ + protected async showJobLog(execution: JobExecutionInfo | JobExecution): Promise { + const canonical = isCanonical(execution) ? execution : mapOcapiExecution(execution); + const errorMessage = getCanonicalJobErrorMessage(canonical); - if (!execution.isLogFileExisting) { + if (!canonical.isLogFileExisting) { if (errorMessage) { this.logger.error({errorMessage}, errorMessage); } @@ -51,9 +76,8 @@ export abstract class JobCommand extends InstanceComma } try { - const backend = this.createJobsBackend(); - const log = await backend.getJobLog(execution); - const logFileName = execution.logFilePath?.split('/').pop() ?? 'job.log'; + const log = await this.fetchCanonicalLog(canonical); + const logFileName = canonical.logFilePath?.split('/').pop() ?? 'job.log'; const header = t('cli.job.logHeader', 'Job log ({{logFileName}}):', {logFileName}); this.logger.error({log, errorMessage}, `${header}\n${log}`); @@ -69,36 +93,20 @@ export abstract class JobCommand extends InstanceComma } } - private async showOcapiJobLog(execution: JobExecution): Promise { - const errorMessage = getJobErrorMessage(execution); - - if (!execution.is_log_file_existing) { - if (errorMessage) { - this.logger.error({errorMessage}, errorMessage); - } - return; - } - - try { - const log = await getJobLog(this.instance, execution); - const logFileName = execution.log_file_path?.split('/').pop() ?? 'job.log'; - - const header = t('cli.job.logHeader', 'Job log ({{logFileName}}):', {logFileName}); - this.logger.error({log, errorMessage}, `${header}\n${log}`); - - if (errorMessage) { - this.logger.error(t('cli.job.errorMessage', 'Error: {{message}}', {message: errorMessage})); - } - } catch { - this.warn(t('cli.job.logFetchFailed', 'Could not retrieve job log')); - if (errorMessage) { - this.logger.error({errorMessage}, errorMessage); - } + private async fetchCanonicalLog(execution: JobExecutionInfo): Promise { + const logPath = execution.logFilePath; + if (!logPath) { + throw new Error('No log file path available'); } + // Both SCAPI and OCAPI return logFilePath under /Sites/LOGS/...; WebDAV + // base is /webdav/Sites, so the leading /Sites/ is stripped. + const webdavPath = logPath.replace(/^\/Sites\//, ''); + const content = await this.instance.webdav.get(webdavPath); + return new TextDecoder().decode(content); } } -function isCanonicalExecution(execution: JobExecutionInfo | JobExecution): execution is JobExecutionInfo { +function isCanonical(execution: JobExecutionInfo | JobExecution): execution is JobExecutionInfo { return 'executionStatus' in execution; } diff --git a/packages/b2c-tooling-sdk/src/clients/index.ts b/packages/b2c-tooling-sdk/src/clients/index.ts index 9fcbf9181..8ea385476 100644 --- a/packages/b2c-tooling-sdk/src/clients/index.ts +++ b/packages/b2c-tooling-sdk/src/clients/index.ts @@ -326,7 +326,7 @@ export type { } from './granular-replications.js'; // SCAPI Jobs -export {createScapiJobsClient, SCAPI_JOBS_READ_SCOPES, SCAPI_JOBS_RW_SCOPES} from './scapi-jobs.js'; +export {createScapiJobsClient, SCAPI_JOBS_CASCADE} from './scapi-jobs.js'; export type { ScapiJobsClient, ScapiJobsClientConfig, diff --git a/packages/b2c-tooling-sdk/src/clients/middleware.ts b/packages/b2c-tooling-sdk/src/clients/middleware.ts index b19db9c4d..9bb598fbf 100644 --- a/packages/b2c-tooling-sdk/src/clients/middleware.ts +++ b/packages/b2c-tooling-sdk/src/clients/middleware.ts @@ -130,6 +130,117 @@ export function createAuthMiddleware(auth: AuthStrategy): Middleware { }; } +/** + * Scope cascade for a SCAPI domain. The auth middleware picks `read` or + * `write` based on the per-operation `scopeMode` hint and walks the chosen + * cascade through the auth strategy until one candidate survives at AM. + * + * Each candidate is an array of scopes; the auth strategy adds any base + * scopes (e.g. tenant scope) automatically. + */ +export interface ScopeCascade { + /** Scope candidates to try for read operations, in order of preference. */ + read: string[][]; + /** Scope candidates to try for write operations, in order of preference. */ + write: string[][]; +} + +/** + * Internal request header read by {@link createScapiAuthMiddleware} to choose + * a cascade tier. Operations attach `'read'` or `'write'`; the header is + * stripped before the request leaves the middleware. + */ +export const SCOPE_MODE_HEADER = 'x-b2c-scope-mode'; + +/** + * Auth middleware for SCAPI clients with a configured {@link ScopeCascade}. + * + * Reads the {@link SCOPE_MODE_HEADER} from the request, picks the matching + * cascade, and asks the auth strategy to resolve it (cache-first, then AM + * with `invalid_scope` fallback). Strips the header before the request is + * sent. + * + * Falls back to `getAuthorizationHeader()` when: + * - the strategy doesn't implement `getAccessTokenForCascade` (e.g. + * stateful sessions, basic auth), or + * - the request didn't supply a `scopeMode` header. + * + * 401 retry behavior matches {@link createAuthMiddleware}: on a 401 after a + * prior success, invalidate the token and retry once. + */ +export function createScapiAuthMiddleware(auth: AuthStrategy, cascade: ScopeCascade): Middleware { + const logger = getLogger(); + let hasHadSuccess = false; + + async function authorize(request: Request): Promise { + const mode = request.headers.get(SCOPE_MODE_HEADER) as 'read' | 'write' | null; + request.headers.delete(SCOPE_MODE_HEADER); + + if (mode && auth.getAccessTokenForCascade) { + const candidates = cascade[mode]; + const token = await auth.getAccessTokenForCascade(candidates); + request.headers.set('Authorization', `Bearer ${token}`); + return; + } + + if (auth.getAuthorizationHeader) { + request.headers.set('Authorization', await auth.getAuthorizationHeader()); + } + } + + return { + async onRequest({request}) { + await authorize(request); + + // Clone body for potential 401 retry (body is single-use). + if (request.body && auth.invalidateToken) { + const cloned = request.clone(); + const bodyBuffer = await cloned.arrayBuffer(); + requestBodies.set(request, bodyBuffer); + } + + return request; + }, + + async onResponse({request, response}) { + if (response.status !== 401) { + hasHadSuccess = true; + } + + if (response.status === 401 && hasHadSuccess && !retriedRequests.has(request) && auth.invalidateToken) { + logger.debug('[ScapiAuthMiddleware] Received 401, invalidating token and retrying'); + retriedRequests.add(request); + auth.invalidateToken(); + + const newHeaders = new Headers(request.headers); + // The original request headers no longer include the scope-mode + // header (we stripped it on the way in). Synthesize a retry by + // re-running the cascade as a read attempt — writes that 401 likely + // need rw, which the cascade already prefers. + const retryRequest = new Request(request.url, { + method: request.method, + headers: newHeaders, + body: requestBodies.get(request) ?? undefined, + ...(requestBodies.get(request) ? {duplex: 'half'} : {}), + } as RequestInit); + + if (auth.getAccessTokenForCascade) { + const token = await auth.getAccessTokenForCascade(cascade.write); + retryRequest.headers.set('Authorization', `Bearer ${token}`); + } else if (auth.getAuthorizationHeader) { + retryRequest.headers.set('Authorization', await auth.getAuthorizationHeader()); + } + + const retryResponse = await fetch(retryRequest); + logger.debug({status: retryResponse.status}, `[ScapiAuthMiddleware] Retry response: ${retryResponse.status}`); + return retryResponse; + } + + return response; + }, + }; +} + /** * Configuration for rate limiting middleware. */ diff --git a/packages/b2c-tooling-sdk/src/clients/scapi-client-factory.ts b/packages/b2c-tooling-sdk/src/clients/scapi-client-factory.ts index 73f272bf5..593383096 100644 --- a/packages/b2c-tooling-sdk/src/clients/scapi-client-factory.ts +++ b/packages/b2c-tooling-sdk/src/clients/scapi-client-factory.ts @@ -17,7 +17,13 @@ */ import createClient, {type Client} from 'openapi-fetch'; import type {AuthStrategy} from '../auth/types.js'; -import {createAuthMiddleware, createLoggingMiddleware, createRateLimitMiddleware} from './middleware.js'; +import { + createAuthMiddleware, + createLoggingMiddleware, + createRateLimitMiddleware, + createScapiAuthMiddleware, + type ScopeCascade, +} from './middleware.js'; import {globalMiddlewareRegistry, type HttpClientType, type MiddlewareRegistry} from './middleware-registry.js'; import {buildTenantScope} from './custom-apis.js'; import {withScopes} from './scapi-backend-utils.js'; @@ -33,10 +39,20 @@ export interface BuildScapiClientOptions { */ domainKey: HttpClientType; /** - * Default scopes to request when the caller doesn't override `config.scopes`. - * Typically the rw scope; the tenant scope is added automatically. + * Per-operation scope cascade. When supplied, operations attach a + * `x-b2c-scope-mode` header (`'read'` or `'write'`) and the auth + * middleware walks the matching cascade until AM accepts one. Mutually + * exclusive with {@link defaultScopes}; new domains should prefer this. */ - defaultScopes: string[]; + scopeCascade?: ScopeCascade; + /** + * Legacy: a single scope set requested for every operation. Used by + * domains that still rely on `ScopeTierManager` to switch clients + * between rw and ro. Mutually exclusive with {@link scopeCascade}. + * + * @deprecated Use {@link scopeCascade} for new domains. + */ + defaultScopes?: string[]; /** * Logging/rate-limit prefix, e.g. `'SCAPI-JOBS'`. Used in log lines. */ @@ -89,14 +105,32 @@ export function buildScapiClient

>( ): Client

{ const registry = config.middlewareRegistry ?? globalMiddlewareRegistry; + if (options.scopeCascade && options.defaultScopes) { + throw new Error(`[buildScapiClient] ${options.domainKey}: scopeCascade and defaultScopes are mutually exclusive.`); + } + if (!options.scopeCascade && !options.defaultScopes) { + throw new Error(`[buildScapiClient] ${options.domainKey}: must provide either scopeCascade or defaultScopes.`); + } + const client = createClient

({ baseUrl: `https://${config.shortCode}.api.commercecloud.salesforce.com/${options.pathSegment}`, }); - const requiredScopes = config.scopes ?? [...options.defaultScopes, buildTenantScope(config.tenantId)]; - const scopedAuth = withScopes(auth, requiredScopes); - - client.use(createAuthMiddleware(scopedAuth)); + if (options.scopeCascade) { + // Cascade-aware path: bake the tenant scope into the auth strategy as a + // "base scope" applied to every cascade attempt; the cascade itself only + // varies the domain (rw/ro) scope per operation. + const tenantBase = config.scopes ?? [buildTenantScope(config.tenantId)]; + const scopedAuth = withScopes(auth, tenantBase); + client.use(createScapiAuthMiddleware(scopedAuth, options.scopeCascade)); + } else { + // Legacy path: single static scope set requested for every operation. + // Used by domains still on ScopeTierManager (scripts/users/roles until + // they migrate to scopeCascade). + const requiredScopes = config.scopes ?? [...options.defaultScopes!, buildTenantScope(config.tenantId)]; + const scopedAuth = withScopes(auth, requiredScopes); + client.use(createAuthMiddleware(scopedAuth)); + } for (const middleware of registry.getMiddleware(options.domainKey)) { client.use(middleware); diff --git a/packages/b2c-tooling-sdk/src/clients/scapi-jobs.ts b/packages/b2c-tooling-sdk/src/clients/scapi-jobs.ts index 5935f0444..735840e5a 100644 --- a/packages/b2c-tooling-sdk/src/clients/scapi-jobs.ts +++ b/packages/b2c-tooling-sdk/src/clients/scapi-jobs.ts @@ -8,6 +8,7 @@ import type {AuthStrategy} from '../auth/types.js'; import type {paths, components} from './scapi-jobs.generated.js'; import {buildScapiClient, type ScapiClientConfig} from './scapi-client-factory.js'; import {buildTenantScope, toOrganizationId, normalizeTenantId} from './custom-apis.js'; +import type {ScopeCascade} from './middleware.js'; export {toOrganizationId, normalizeTenantId, buildTenantScope}; @@ -23,8 +24,18 @@ export type ExecutionStatus = components['schemas']['ExecutionStatus']; export type ExitStatus = components['schemas']['ExitStatus']; export type JobExecutionSearchResult = components['schemas']['JobExecutionSearchResult']; -export const SCAPI_JOBS_READ_SCOPES = ['sfcc.jobs']; -export const SCAPI_JOBS_RW_SCOPES = ['sfcc.jobs.rw']; +/** + * Per-operation scope cascade for SCAPI Jobs. + * + * Reads accept either rw or ro; writes require rw. The auth middleware tries + * each candidate against AM in order, caches the first that survives, and + * lets a broader cached token satisfy a later narrower request without an + * extra round trip. + */ +export const SCAPI_JOBS_CASCADE: ScopeCascade = { + read: [['sfcc.jobs.rw'], ['sfcc.jobs']], + write: [['sfcc.jobs.rw']], +}; export type ScapiJobsClientConfig = ScapiClientConfig; @@ -33,7 +44,7 @@ export function createScapiJobsClient(config: ScapiJobsClientConfig, auth: AuthS { pathSegment: 'operation/jobs/v1', domainKey: 'scapi-jobs', - defaultScopes: SCAPI_JOBS_RW_SCOPES, + scopeCascade: SCAPI_JOBS_CASCADE, logPrefix: 'SCAPI-JOBS', }, config, diff --git a/packages/b2c-tooling-sdk/src/compat/dispatcher.ts b/packages/b2c-tooling-sdk/src/compat/dispatcher.ts new file mode 100644 index 000000000..474443bef --- /dev/null +++ b/packages/b2c-tooling-sdk/src/compat/dispatcher.ts @@ -0,0 +1,144 @@ +/* + * 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 + */ +/** + * Optimistic SCAPI with cached OCAPI fallback for `apiBackend=auto`. + * + * ## Why this exists + * + * When `apiBackend=auto` and the user has no SCAPI scopes provisioned in + * Account Manager, every SCAPI call fails with `invalid_scope`. OAuth + * strategies cache successful tokens but **not** failed token requests, so + * without state, every call in a multi-call operation (e.g. `job run --wait` + * polls dozens of times) re-attempts SCAPI, re-hits Account Manager, re-fails, + * re-falls back to OCAPI. Slow, noisy, and surfaces the fallback log line + * repeatedly. + * + * The dispatcher caches the resolved backend for the lifetime of one logical + * operation: the first call probes SCAPI; the rest go straight to the + * resolved backend. Token caching handles the success path; the dispatcher + * handles the failure path. + * + * ## When to use + * + * Any interface (CLI, VSCode, MCP) that: + * - honors `apiBackend=auto`, **and** + * - performs multiple backend calls per user-initiated operation. + * + * ## When NOT to use + * + * - **Explicit `apiBackend=scapi` or `apiBackend=ocapi`.** The choice is + * known up-front; just branch once with `if/else`. + * - **Single-call operations.** A `try/catch` is shorter and clearer than + * constructing a dispatcher. + * - **SDK code that picks a backend deliberately.** Call `ScapiJobsOps` or + * the OCAPI free functions directly. No dispatcher needed. + * - **SCAPI-only operations** (no OCAPI equivalent). Just call the SCAPI + * ops; if the user forced `apiBackend=ocapi`, fail with a clear error in + * the command itself. The dispatcher's only job is fallback caching. + * + * ## Lifecycle + * + * This module lives in `compat/` because it exists to bridge the + * OCAPI → SCAPI transition. When OCAPI is removed: + * - delete every `ocapi: () => ...` branch from CLI/VSCode/MCP commands, + * - inline the SCAPI ops calls, + * - delete this directory. + * + * @module compat/dispatcher + */ +import {getLogger} from '../logging/logger.js'; +import {isInvalidScopeError, type ApiBackendPreference} from '../clients/scapi-backend-utils.js'; + +export type {ApiBackendPreference}; + +export type ResolvedBackend = 'scapi' | 'ocapi'; + +/** + * Branches passed to {@link BackendDispatcher.run}: one async function per + * backend. The SCAPI branch receives a non-null ops bundle (`S`) so callers + * don't need non-null assertions. The OCAPI branch receives no argument — + * it should call the OCAPI free functions directly with whatever instance + * handle the caller has. + */ +export interface DispatchBranches { + scapi: (ops: S) => Promise; + ocapi: () => Promise; +} + +/** + * Stateful router that runs SCAPI optimistically and falls back to OCAPI + * once on `invalid_scope`, caching the choice for the lifetime of the + * dispatcher. See the module-level docs for the full rationale. + * + * Construct one per logical operation (e.g. one per CLI command run, or + * one per VSCode user-initiated action). Sharing a dispatcher across + * unrelated operations is fine but not required. + * + * @typeParam S - The SCAPI ops bundle type (e.g., `ScapiJobsOps`). + */ +export class BackendDispatcher { + private resolved?: ResolvedBackend; + private opsCache?: S; + + /** + * @param preference - User preference (`auto` | `scapi` | `ocapi`). + * @param createScapi - Lazily builds the SCAPI ops bundle. Returns + * `undefined` when SCAPI is not configured (shortCode/tenantId/auth + * missing). + * @param domainName - Used in fallback log messages (e.g. `'jobs'`). + * + * @throws Error if `preference === 'scapi'` but `createScapi()` returns + * `undefined` — explicit SCAPI without configuration is a hard error. + */ + constructor( + preference: ApiBackendPreference, + createScapi: () => S | undefined, + private readonly domainName: string, + ) { + const probe = preference === 'ocapi' ? undefined : createScapi(); + const hasScapi = probe !== undefined; + if (probe !== undefined) this.opsCache = probe; + + if (preference === 'scapi' && !hasScapi) { + throw new Error( + `${domainName} SCAPI backend requires shortCode, tenantId, and OAuth credentials. ` + + `Configure them in dw.json or set apiBackend to ocapi.`, + ); + } + if (preference === 'scapi') this.resolved = 'scapi'; + if (preference === 'ocapi') this.resolved = 'ocapi'; + if (preference === 'auto' && !hasScapi) this.resolved = 'ocapi'; + } + + /** Backend that has handled requests so far, or undefined if none yet. */ + get active(): ResolvedBackend | undefined { + return this.resolved; + } + + /** + * Runs the operation against the resolved backend. If unresolved (auto + * with SCAPI configured), tries SCAPI first; on `invalid_scope`, falls + * back to OCAPI and caches the choice. Other errors propagate without + * fallback. + */ + async run(branches: DispatchBranches): Promise { + if (this.resolved === 'ocapi') return branches.ocapi(); + if (this.resolved === 'scapi') return branches.scapi(this.opsCache!); + + try { + const result = await branches.scapi(this.opsCache!); + this.resolved = 'scapi'; + return result; + } catch (error) { + if (isInvalidScopeError(error)) { + getLogger().info(`SCAPI ${this.domainName} scope unavailable, falling back to OCAPI`); + this.resolved = 'ocapi'; + return branches.ocapi(); + } + throw error; + } + } +} diff --git a/packages/b2c-tooling-sdk/src/compat/index.ts b/packages/b2c-tooling-sdk/src/compat/index.ts new file mode 100644 index 000000000..f84b73c09 --- /dev/null +++ b/packages/b2c-tooling-sdk/src/compat/index.ts @@ -0,0 +1,14 @@ +/* + * 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 + */ +/** + * Transitional helpers that exist only to bridge the OCAPI → SCAPI + * migration. Everything in this module is scheduled for deletion when + * OCAPI is removed. + * + * @module compat + */ +export {BackendDispatcher} from './dispatcher.js'; +export type {ApiBackendPreference, ResolvedBackend, DispatchBranches} from './dispatcher.js'; diff --git a/packages/b2c-tooling-sdk/src/index.ts b/packages/b2c-tooling-sdk/src/index.ts index c6e78b946..402109195 100644 --- a/packages/b2c-tooling-sdk/src/index.ts +++ b/packages/b2c-tooling-sdk/src/index.ts @@ -252,12 +252,14 @@ export { siteArchiveImport, siteArchiveExport, siteArchiveExportToPath, - // Backend abstraction - createJobsBackend, + // Canonical surface (SCAPI free functions + canonical helpers) + scapiExecuteJob, + scapiGetJobExecution, + scapiSearchJobExecutions, + scapiDeleteJobExecution, + scapiGetJobLog, waitForJobExecution, - OcapiJobsBackend, - ScapiJobsBackend, - supportsDeleteJobExecution, + CanonicalJobExecutionError, } from './operations/jobs/index.js'; export type { JobExecution, @@ -269,15 +271,12 @@ export type { WaitForJobPollInfo, SearchJobExecutionsOptions, JobExecutionSearchResult, - // Backend abstraction types - JobsBackend, - DeletableJobsBackend, - JobsBackendConfig, - ApiBackendPreference, + // Canonical types JobExecutionInfo, JobStepExecutionResult, JobExecutionSearchResults, - ScapiJobsBackendConfig, + ExecuteJobScapiOptions, + SearchJobExecutionsScapiOptions, SiteArchiveImportOptions, SiteArchiveImportResult, SiteArchiveExportOptions, diff --git a/packages/b2c-tooling-sdk/src/operations/jobs/index.ts b/packages/b2c-tooling-sdk/src/operations/jobs/index.ts index 9771a8b46..327614ae6 100644 --- a/packages/b2c-tooling-sdk/src/operations/jobs/index.ts +++ b/packages/b2c-tooling-sdk/src/operations/jobs/index.ts @@ -6,67 +6,16 @@ /** * Job execution operations for B2C Commerce. * - * This module provides functions for running and monitoring jobs - * on B2C Commerce instances via OCAPI. - * - * ## Core Job Functions - * - * - {@link executeJob} - Start a job execution - * - {@link getJobExecution} - Get the status of a job execution - * - {@link waitForJob} - Wait for a job to complete - * - {@link searchJobExecutions} - Search for job executions - * - {@link findRunningJobExecution} - Find a running execution - * - {@link getJobLog} - Retrieve job log file content - * - * ## System Jobs - * - * - {@link siteArchiveImport} - Import a site archive - * - {@link siteArchiveExport} - Export a site archive - * - {@link siteArchiveExportToPath} - Export and save to local path - * - * ## Usage - * - * ```typescript - * import { - * executeJob, - * waitForJob, - * searchJobExecutions, - * siteArchiveImport, - * siteArchiveExport, - * } from '@salesforce/b2c-tooling-sdk/operations/jobs'; - * import { resolveConfig } from '@salesforce/b2c-tooling-sdk/config'; - * - * const config = resolveConfig(); - * const instance = config.createB2CInstance(); - * - * // Run a custom job and wait for completion - * const execution = await executeJob(instance, 'my-job-id'); - * const result = await waitForJob(instance, 'my-job-id', execution.id); - * - * // Search for recent job executions - * const results = await searchJobExecutions(instance, { - * jobId: 'my-job-id', - * count: 10 - * }); - * - * // Import a site archive - * await siteArchiveImport(instance, './my-import-data'); - * - * // Export site data - * const exportResult = await siteArchiveExport(instance, { - * global_data: { meta_data: true } - * }); - * ``` - * - * ## Authentication - * - * Job operations require OAuth authentication with appropriate OCAPI permissions - * for the /jobs and /job_execution_search resources. + * SDK consumers should call SCAPI ops directly via the free functions + * exported from `./scapi-ops` (or, for legacy code, the OCAPI free + * functions exported from `./run`). The CLI's `BackendDispatcher` + * arbitrates between them based on the user's `apiBackend` preference; + * that policy lives in the CLI layer. * * @module operations/jobs */ -// Core job execution +// OCAPI ops (legacy — will be removed when OCAPI is deprecated) export { executeJob, getJobExecution, @@ -90,22 +39,22 @@ export type { JobExecutionSearchResult, } from './run.js'; -// Backend abstraction -export {createJobsBackend, waitForJobExecution} from './backend.js'; -export type {JobsBackendConfig, ApiBackendPreference} from './backend.js'; -export {OcapiJobsBackend} from './ocapi-backend.js'; -export {ScapiJobsBackend} from './scapi-backend.js'; -export type {ScapiJobsBackendConfig} from './scapi-backend.js'; -export {supportsDeleteJobExecution} from './types.js'; -export type { - JobsBackend, - DeletableJobsBackend, - JobExecutionInfo, - JobStepExecutionResult, - JobExecutionSearchResults, -} from './types.js'; +// SCAPI ops + canonical types (primary surface) +export { + executeJob as scapiExecuteJob, + getJobExecution as scapiGetJobExecution, + searchJobExecutions as scapiSearchJobExecutions, + deleteJobExecution as scapiDeleteJobExecution, + getJobLog as scapiGetJobLog, +} from './scapi-ops.js'; +export type {ExecuteJobScapiOptions, SearchJobExecutionsScapiOptions} from './scapi-ops.js'; +export type {JobExecutionInfo, JobStepExecutionResult, JobExecutionSearchResults} from './types.js'; + +// Backend-agnostic helpers +export {waitForJobExecution, CanonicalJobExecutionError} from './wait-canonical.js'; +export {mapOcapiExecution, mapOcapiSearchResult} from './ocapi-mapping.js'; -// Site archive import/export +// Site archive import/export (uses OCAPI WebDAV path) export { siteArchiveImport, siteArchiveExport, diff --git a/packages/b2c-tooling-sdk/src/operations/jobs/ocapi-backend.ts b/packages/b2c-tooling-sdk/src/operations/jobs/ocapi-backend.ts deleted file mode 100644 index 3e86861a6..000000000 --- a/packages/b2c-tooling-sdk/src/operations/jobs/ocapi-backend.ts +++ /dev/null @@ -1,95 +0,0 @@ -/* - * 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 type {B2CInstance} from '../../instance/index.js'; -import type {JobsBackend, JobExecutionInfo, JobStepExecutionResult, JobExecutionSearchResults} from './types.js'; -import type {ExecuteJobOptions, SearchJobExecutionsOptions, JobExecution, JobStepExecution} from './run.js'; -import { - executeJob as ocapiExecuteJob, - getJobExecution as ocapiGetJobExecution, - searchJobExecutions as ocapiSearchJobExecutions, - getJobLog as ocapiGetJobLog, -} from './run.js'; - -function mapStepExecution(step: JobStepExecution): JobStepExecutionResult { - return { - id: step.id, - stepId: step.step_id, - executionStatus: step.execution_status, - exitStatus: step.exit_status - ? { - code: step.exit_status.code ?? '', - message: step.exit_status.message, - status: step.exit_status.status as 'ok' | 'error' | undefined, - } - : undefined, - duration: step.duration, - }; -} - -function mapOcapiExecution(ocapi: JobExecution): JobExecutionInfo { - return { - id: ocapi.id ?? '', - jobId: ocapi.job_id ?? '', - executionStatus: (ocapi.execution_status ?? 'unknown') as JobExecutionInfo['executionStatus'], - exitStatus: ocapi.exit_status - ? { - code: ocapi.exit_status.code ?? '', - message: ocapi.exit_status.message, - status: ocapi.exit_status.status as 'ok' | 'error' | undefined, - } - : undefined, - startTime: ocapi.start_time, - endTime: ocapi.end_time, - duration: ocapi.duration, - stepExecutions: ocapi.step_executions?.map(mapStepExecution), - logFilePath: ocapi.log_file_path, - isLogFileExisting: ocapi.is_log_file_existing, - parameters: ocapi.parameters, - _raw: ocapi, - }; -} - -export class OcapiJobsBackend implements JobsBackend { - readonly name = 'ocapi' as const; - - constructor(private instance: B2CInstance) {} - - async executeJob(jobId: string, options?: ExecuteJobOptions): Promise { - const result = await ocapiExecuteJob(this.instance, jobId, options); - return mapOcapiExecution(result); - } - - async getJobExecution(jobId: string, executionId: string): Promise { - const result = await ocapiGetJobExecution(this.instance, jobId, executionId); - return mapOcapiExecution(result); - } - - async searchJobExecutions(options?: SearchJobExecutionsOptions): Promise { - const result = await ocapiSearchJobExecutions(this.instance, options); - return { - total: result.total, - limit: result.count, - offset: result.start, - hits: result.hits.map(mapOcapiExecution), - }; - } - - async getJobLog(execution: JobExecutionInfo): Promise { - const ocapiExecution = execution._raw as JobExecution; - if (ocapiExecution) { - return ocapiGetJobLog(this.instance, ocapiExecution); - } - if (!execution.logFilePath) { - throw new Error('No log file path available'); - } - if (!execution.isLogFileExisting) { - throw new Error('Log file does not exist'); - } - const logPath = execution.logFilePath.replace(/^\/Sites\//, ''); - const content = await this.instance.webdav.get(logPath); - return new TextDecoder().decode(content); - } -} diff --git a/packages/b2c-tooling-sdk/src/operations/jobs/ocapi-mapping.ts b/packages/b2c-tooling-sdk/src/operations/jobs/ocapi-mapping.ts new file mode 100644 index 000000000..616d7962c --- /dev/null +++ b/packages/b2c-tooling-sdk/src/operations/jobs/ocapi-mapping.ts @@ -0,0 +1,71 @@ +/* + * 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 + */ +/** + * Mapping helpers from raw OCAPI shapes (snake_case) to canonical + * {@link JobExecutionInfo} (camelCase). + * + * These exist as transitional utilities to bridge the two API shapes. They + * will be deleted along with the OCAPI ops once OCAPI is removed. + * + * @module operations/jobs/ocapi-mapping + */ +import type {JobExecution, JobStepExecution} from './run.js'; +import type {JobExecutionInfo, JobExecutionSearchResults, JobStepExecutionResult} from './types.js'; + +function mapStepExecution(step: JobStepExecution): JobStepExecutionResult { + return { + id: step.id, + stepId: step.step_id, + executionStatus: step.execution_status, + exitStatus: step.exit_status + ? { + code: step.exit_status.code ?? '', + message: step.exit_status.message, + status: step.exit_status.status as 'ok' | 'error' | undefined, + } + : undefined, + duration: step.duration, + }; +} + +/** Map a raw OCAPI {@link JobExecution} into the canonical shape. */ +export function mapOcapiExecution(ocapi: JobExecution): JobExecutionInfo { + return { + id: ocapi.id ?? '', + jobId: ocapi.job_id ?? '', + executionStatus: (ocapi.execution_status ?? 'unknown') as JobExecutionInfo['executionStatus'], + exitStatus: ocapi.exit_status + ? { + code: ocapi.exit_status.code ?? '', + message: ocapi.exit_status.message, + status: ocapi.exit_status.status as 'ok' | 'error' | undefined, + } + : undefined, + startTime: ocapi.start_time, + endTime: ocapi.end_time, + duration: ocapi.duration, + stepExecutions: ocapi.step_executions?.map(mapStepExecution), + logFilePath: ocapi.log_file_path, + isLogFileExisting: ocapi.is_log_file_existing, + parameters: ocapi.parameters, + _raw: ocapi, + }; +} + +/** Map a raw OCAPI search result into the canonical shape. */ +export function mapOcapiSearchResult(result: { + total: number; + count: number; + start: number; + hits: JobExecution[]; +}): JobExecutionSearchResults { + return { + total: result.total, + limit: result.count, + offset: result.start, + hits: result.hits.map(mapOcapiExecution), + }; +} diff --git a/packages/b2c-tooling-sdk/src/operations/jobs/scapi-backend.ts b/packages/b2c-tooling-sdk/src/operations/jobs/scapi-backend.ts deleted file mode 100644 index 804fd81da..000000000 --- a/packages/b2c-tooling-sdk/src/operations/jobs/scapi-backend.ts +++ /dev/null @@ -1,250 +0,0 @@ -/* - * 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 type {B2CInstance} from '../../instance/index.js'; -import type {AuthStrategy} from '../../auth/types.js'; -import type { - DeletableJobsBackend, - JobExecutionInfo, - JobStepExecutionResult, - JobExecutionSearchResults, -} from './types.js'; -import type {ExecuteJobOptions, SearchJobExecutionsOptions} from './run.js'; -import { - createScapiJobsClient, - SCAPI_JOBS_RW_SCOPES, - SCAPI_JOBS_READ_SCOPES, - type ScapiJobsClient, - type ScapiJobsClientConfig, - type JobExecution as ScapiJobExecution, - type JobStepExecution as ScapiJobStepExecution, -} from '../../clients/scapi-jobs.js'; -import {buildTenantScope, toOrganizationId} from '../../clients/custom-apis.js'; -import {ScopeTierManager} from '../../clients/scapi-scope-tier.js'; -import {getLogger} from '../../logging/logger.js'; - -function mapStepExecution(step: ScapiJobStepExecution): JobStepExecutionResult { - return { - id: step.id, - stepId: step.stepId, - executionStatus: step.executionStatus, - exitStatus: step.exitStatus - ? { - code: step.exitStatus.code ?? '', - message: step.exitStatus.message, - status: step.exitStatus.status, - } - : undefined, - duration: step.duration, - }; -} - -function mapScapiExecution(scapi: ScapiJobExecution): JobExecutionInfo { - return { - id: scapi.id, - jobId: scapi.jobId, - executionStatus: (scapi.executionStatus ?? 'unknown') as JobExecutionInfo['executionStatus'], - exitStatus: scapi.exitStatus - ? { - code: scapi.exitStatus.code ?? '', - message: scapi.exitStatus.message, - status: scapi.exitStatus.status, - } - : undefined, - startTime: scapi.startTime, - endTime: scapi.endTime, - duration: scapi.duration, - stepExecutions: scapi.stepExecutions?.map(mapStepExecution), - logFilePath: scapi.logFilePath, - isLogFileExisting: scapi.isLogFileExisting, - parameters: scapi.parameters, - _raw: scapi, - }; -} - -export interface ScapiJobsBackendConfig { - shortCode: string; - tenantId: string; - auth: AuthStrategy; - instance: B2CInstance; -} - -export class ScapiJobsBackend implements DeletableJobsBackend { - readonly name = 'scapi' as const; - - private organizationId: string; - private scopeTier: ScopeTierManager; - - constructor(private config: ScapiJobsBackendConfig) { - this.organizationId = toOrganizationId(config.tenantId); - this.scopeTier = new ScopeTierManager({ - buildClient: (scopes) => this.buildClient(scopes), - rwScopes: SCAPI_JOBS_RW_SCOPES, - readScopes: SCAPI_JOBS_READ_SCOPES, - domainName: 'Jobs', - }); - } - - async executeJob(jobId: string, options?: ExecuteJobOptions): Promise { - const client = this.scopeTier.getClientForWrite(); - const {parameters = [], body: rawBody} = options ?? {}; - - let requestBody: Record | undefined; - if (rawBody) { - requestBody = rawBody; - } else if (parameters.length > 0) { - requestBody = {parameters}; - } - - const {data, error, response} = await client.POST('/organizations/{organizationId}/jobs/{jobId}/executions', { - params: {path: {organizationId: this.organizationId, jobId}}, - body: requestBody as unknown as {parameters?: Array<{name: string; value: string}>}, - }); - - if (response.status === 400) { - const errorBody = error as unknown as {title?: string; type?: string; detail?: string; jobId?: string}; - if (errorBody?.type?.includes('job-already-running') || errorBody?.title === 'Job Already Running') { - if (options?.waitForRunning !== false) { - const logger = getLogger(); - logger.warn({jobId}, `Job ${jobId} already running, waiting for it to finish...`); - const running = await this.findRunningExecution(jobId); - if (running) { - await this.waitForTerminal(jobId, running.id); - } - return this.executeJob(jobId, {...options, waitForRunning: false}); - } - throw new Error(`Job ${jobId} is already running`); - } - } - - if (error || !data) { - const errorBody = error as unknown as {detail?: string; title?: string}; - const message = errorBody?.detail ?? errorBody?.title ?? `Failed to execute job ${jobId}`; - throw new Error(message); - } - - return mapScapiExecution(data); - } - - async getJobExecution(jobId: string, executionId: string): Promise { - const client = this.scopeTier.getClientForRead(); - - const {data, error} = await client.GET('/organizations/{organizationId}/jobs/{jobId}/executions/{executionId}', { - params: {path: {organizationId: this.organizationId, jobId, executionId}}, - }); - - if (error || !data) { - const errorBody = error as unknown as {detail?: string; title?: string}; - const message = errorBody?.detail ?? `Failed to get job execution ${executionId}`; - throw new Error(message); - } - - return mapScapiExecution(data); - } - - async searchJobExecutions(options?: SearchJobExecutionsOptions): Promise { - const client = this.scopeTier.getClientForRead(); - const {jobId, status, count = 25, start = 0, sortBy = 'start_time', sortOrder = 'desc'} = options ?? {}; - - const queries: unknown[] = []; - if (jobId) { - queries.push({termQuery: {fields: ['job_id'], operator: 'is', values: [jobId]}}); - } - if (status) { - const statusValues = Array.isArray(status) ? status : [status]; - queries.push({termQuery: {fields: ['status'], operator: 'one_of', values: statusValues}}); - } - - let query: unknown; - if (queries.length === 0) { - query = {matchAllQuery: {}}; - } else if (queries.length === 1) { - query = queries[0]; - } else { - query = {boolQuery: {must: queries}}; - } - - const {data, error} = await client.POST('/organizations/{organizationId}/job-execution-search', { - params: {path: {organizationId: this.organizationId}}, - body: { - query, - limit: count, - offset: start, - sorts: [{field: sortBy, sortOrder}], - } as never, - }); - - if (error || !data) { - const errorBody = error as unknown as {detail?: string; title?: string}; - const message = errorBody?.detail ?? 'Failed to search job executions'; - throw new Error(message); - } - - const result = data as unknown as {total?: number; limit?: number; offset?: number; hits?: ScapiJobExecution[]}; - return { - total: result.total ?? 0, - limit: result.limit ?? count, - offset: result.offset ?? start, - hits: (result.hits ?? []).map(mapScapiExecution), - }; - } - - async deleteJobExecution(jobId: string, executionId: string): Promise { - const client = this.scopeTier.getClientForWrite(); - - const {error} = await client.DELETE('/organizations/{organizationId}/jobs/{jobId}/executions/{executionId}', { - params: {path: {organizationId: this.organizationId, jobId, executionId}}, - }); - - if (error) { - const errorBody = error as unknown as {detail?: string; title?: string}; - const message = errorBody?.detail ?? `Failed to delete job execution ${executionId}`; - throw new Error(message); - } - } - - async getJobLog(execution: JobExecutionInfo): Promise { - if (!execution.logFilePath) { - throw new Error('No log file path available'); - } - if (!execution.isLogFileExisting) { - throw new Error('Log file does not exist'); - } - const logPath = execution.logFilePath.replace(/^\/Sites\//, ''); - const content = await this.config.instance.webdav.get(logPath); - return new TextDecoder().decode(content); - } - - private buildClient(scopes: string[]): ScapiJobsClient { - const clientConfig: ScapiJobsClientConfig = { - shortCode: this.config.shortCode, - tenantId: this.config.tenantId, - scopes: [...scopes, buildTenantScope(this.config.tenantId)], - }; - return createScapiJobsClient(clientConfig, this.config.auth); - } - - private async findRunningExecution(jobId: string): Promise { - const results = await this.searchJobExecutions({ - jobId, - status: ['RUNNING', 'PENDING'], - sortBy: 'start_time', - sortOrder: 'asc', - count: 1, - }); - return results.hits[0]; - } - - private async waitForTerminal(jobId: string, executionId: string): Promise { - const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); - while (true) { - await sleep(3000); - const execution = await this.getJobExecution(jobId, executionId); - if (execution.executionStatus === 'finished' || execution.executionStatus === 'aborted') { - return; - } - } - } -} diff --git a/packages/b2c-tooling-sdk/src/operations/jobs/scapi-ops.ts b/packages/b2c-tooling-sdk/src/operations/jobs/scapi-ops.ts new file mode 100644 index 000000000..608eb31e5 --- /dev/null +++ b/packages/b2c-tooling-sdk/src/operations/jobs/scapi-ops.ts @@ -0,0 +1,276 @@ +/* + * 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 Jobs operations. + * + * Free functions over a {@link ScapiJobsClient}. Each operation declares its + * scope tier (`read` or `write`) via the `x-b2c-scope-mode` header; the + * auth middleware on the client reads that header and resolves the + * appropriate scope cascade against Account Manager. + * + * SDK consumers (or the CLI dispatcher in auto/scapi mode) call these + * directly. This is the future primary surface for jobs once OCAPI is + * deprecated. + * + * @module operations/jobs/scapi-ops + */ +import type {B2CInstance} from '../../instance/index.js'; +import {SCOPE_MODE_HEADER} from '../../clients/middleware.js'; +import { + toOrganizationId, + type ScapiJobsClient, + type JobExecution as ScapiJobExecution, + type JobStepExecution as ScapiJobStepExecution, +} from '../../clients/scapi-jobs.js'; +import {getLogger} from '../../logging/logger.js'; +import type {ExecuteJobOptions, SearchJobExecutionsOptions} from './run.js'; +import type {JobExecutionInfo, JobExecutionSearchResults, JobStepExecutionResult} from './types.js'; + +const READ_HEADERS = {[SCOPE_MODE_HEADER]: 'read'}; +const WRITE_HEADERS = {[SCOPE_MODE_HEADER]: 'write'}; + +function mapStepExecution(step: ScapiJobStepExecution): JobStepExecutionResult { + return { + id: step.id, + stepId: step.stepId, + executionStatus: step.executionStatus, + exitStatus: step.exitStatus + ? { + code: step.exitStatus.code ?? '', + message: step.exitStatus.message, + status: step.exitStatus.status, + } + : undefined, + duration: step.duration, + }; +} + +function mapScapiExecution(scapi: ScapiJobExecution): JobExecutionInfo { + return { + id: scapi.id, + jobId: scapi.jobId, + executionStatus: (scapi.executionStatus ?? 'unknown') as JobExecutionInfo['executionStatus'], + exitStatus: scapi.exitStatus + ? { + code: scapi.exitStatus.code ?? '', + message: scapi.exitStatus.message, + status: scapi.exitStatus.status, + } + : undefined, + startTime: scapi.startTime, + endTime: scapi.endTime, + duration: scapi.duration, + stepExecutions: scapi.stepExecutions?.map(mapStepExecution), + logFilePath: scapi.logFilePath, + isLogFileExisting: scapi.isLogFileExisting, + parameters: scapi.parameters, + _raw: scapi, + }; +} + +export interface ExecuteJobScapiOptions extends ExecuteJobOptions { + /** Tenant ID for organization path param. Required. */ + tenantId: string; +} + +/** + * Execute a job. Requires the rw scope (no ro fallback for writes). + * + * If the job is already running and `waitForRunning` is not `false`, polls + * until the prior run reaches a terminal state, then retries. + */ +export async function executeJob( + client: ScapiJobsClient, + jobId: string, + options: ExecuteJobScapiOptions, +): Promise { + const organizationId = toOrganizationId(options.tenantId); + const {parameters = [], body: rawBody} = options; + + let requestBody: Record | undefined; + if (rawBody) { + requestBody = rawBody; + } else if (parameters.length > 0) { + requestBody = {parameters}; + } + + const {data, error, response} = await client.POST('/organizations/{organizationId}/jobs/{jobId}/executions', { + params: {path: {organizationId, jobId}}, + headers: WRITE_HEADERS, + body: requestBody as unknown as {parameters?: Array<{name: string; value: string}>}, + }); + + if (response.status === 400) { + const errorBody = error as unknown as {title?: string; type?: string; detail?: string}; + if (errorBody?.type?.includes('job-already-running') || errorBody?.title === 'Job Already Running') { + if (options.waitForRunning !== false) { + getLogger().warn({jobId}, `Job ${jobId} already running, waiting for it to finish...`); + const running = await findRunningExecution(client, jobId, options.tenantId); + if (running) { + await waitForTerminal(client, jobId, running.id, options.tenantId); + } + return executeJob(client, jobId, {...options, waitForRunning: false}); + } + throw new Error(`Job ${jobId} is already running`); + } + } + + if (error || !data) { + const errorBody = error as unknown as {detail?: string; title?: string}; + const message = errorBody?.detail ?? errorBody?.title ?? `Failed to execute job ${jobId}`; + throw new Error(message); + } + + return mapScapiExecution(data); +} + +export async function getJobExecution( + client: ScapiJobsClient, + jobId: string, + executionId: string, + tenantId: string, +): Promise { + const organizationId = toOrganizationId(tenantId); + + const {data, error} = await client.GET('/organizations/{organizationId}/jobs/{jobId}/executions/{executionId}', { + params: {path: {organizationId, jobId, executionId}}, + headers: READ_HEADERS, + }); + + if (error || !data) { + const errorBody = error as unknown as {detail?: string; title?: string}; + const message = errorBody?.detail ?? `Failed to get job execution ${executionId}`; + throw new Error(message); + } + + return mapScapiExecution(data); +} + +export interface SearchJobExecutionsScapiOptions extends SearchJobExecutionsOptions { + /** Tenant ID for organization path param. Required. */ + tenantId: string; +} + +export async function searchJobExecutions( + client: ScapiJobsClient, + options: SearchJobExecutionsScapiOptions, +): Promise { + const organizationId = toOrganizationId(options.tenantId); + const {jobId, status, count = 25, start = 0, sortBy = 'start_time', sortOrder = 'desc'} = options; + + const queries: unknown[] = []; + if (jobId) { + queries.push({termQuery: {fields: ['job_id'], operator: 'is', values: [jobId]}}); + } + if (status) { + const statusValues = Array.isArray(status) ? status : [status]; + queries.push({termQuery: {fields: ['status'], operator: 'one_of', values: statusValues}}); + } + + let query: unknown; + if (queries.length === 0) { + query = {matchAllQuery: {}}; + } else if (queries.length === 1) { + query = queries[0]; + } else { + query = {boolQuery: {must: queries}}; + } + + const {data, error} = await client.POST('/organizations/{organizationId}/job-execution-search', { + params: {path: {organizationId}}, + headers: READ_HEADERS, + body: { + query, + limit: count, + offset: start, + sorts: [{field: sortBy, sortOrder}], + } as never, + }); + + if (error || !data) { + const errorBody = error as unknown as {detail?: string; title?: string}; + const message = errorBody?.detail ?? 'Failed to search job executions'; + throw new Error(message); + } + + const result = data as unknown as {total?: number; limit?: number; offset?: number; hits?: ScapiJobExecution[]}; + return { + total: result.total ?? 0, + limit: result.limit ?? count, + offset: result.offset ?? start, + hits: (result.hits ?? []).map(mapScapiExecution), + }; +} + +export async function deleteJobExecution( + client: ScapiJobsClient, + jobId: string, + executionId: string, + tenantId: string, +): Promise { + const organizationId = toOrganizationId(tenantId); + + const {error} = await client.DELETE('/organizations/{organizationId}/jobs/{jobId}/executions/{executionId}', { + params: {path: {organizationId, jobId, executionId}}, + headers: WRITE_HEADERS, + }); + + if (error) { + const errorBody = error as unknown as {detail?: string; title?: string}; + const message = errorBody?.detail ?? `Failed to delete job execution ${executionId}`; + throw new Error(message); + } +} + +/** + * Retrieves a job's log file content over WebDAV. Both backends + * (SCAPI and OCAPI) expose `logFilePath` under `/Sites/LOGS/...`; WebDAV is + * shared, so this lives in jobs/scapi-ops.ts only as a convenience for SDK + * consumers building purely against SCAPI ops. + */ +export async function getJobLog(instance: B2CInstance, execution: JobExecutionInfo): Promise { + if (!execution.logFilePath) { + throw new Error('No log file path available'); + } + if (!execution.isLogFileExisting) { + throw new Error('Log file does not exist'); + } + const logPath = execution.logFilePath.replace(/^\/Sites\//, ''); + const content = await instance.webdav.get(logPath); + return new TextDecoder().decode(content); +} + +async function findRunningExecution( + client: ScapiJobsClient, + jobId: string, + tenantId: string, +): Promise { + const results = await searchJobExecutions(client, { + jobId, + status: ['RUNNING', 'PENDING'], + sortBy: 'start_time', + sortOrder: 'asc', + count: 1, + tenantId, + }); + return results.hits[0]; +} + +async function waitForTerminal( + client: ScapiJobsClient, + jobId: string, + executionId: string, + tenantId: string, +): Promise { + const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + while (true) { + await sleep(3000); + const execution = await getJobExecution(client, jobId, executionId, tenantId); + if (execution.executionStatus === 'finished' || execution.executionStatus === 'aborted') { + return; + } + } +} diff --git a/packages/b2c-tooling-sdk/src/operations/jobs/types.ts b/packages/b2c-tooling-sdk/src/operations/jobs/types.ts index d218867e0..847f3083d 100644 --- a/packages/b2c-tooling-sdk/src/operations/jobs/types.ts +++ b/packages/b2c-tooling-sdk/src/operations/jobs/types.ts @@ -7,6 +7,12 @@ import type {ExecuteJobOptions, WaitForJobOptions, SearchJobExecutionsOptions} f export type {ExecuteJobOptions, WaitForJobOptions, SearchJobExecutionsOptions}; +/** + * Canonical, backend-agnostic job execution shape (camelCase). + * + * SCAPI ops return this directly; OCAPI ops return raw snake_case which the + * caller maps via {@link mapOcapiExecution}. + */ export interface JobExecutionInfo { id: string; jobId: string; @@ -50,28 +56,3 @@ export interface JobExecutionSearchResults { offset: number; hits: JobExecutionInfo[]; } - -export interface JobsBackend { - readonly name: 'ocapi' | 'scapi'; - executeJob(jobId: string, options?: ExecuteJobOptions): Promise; - getJobExecution(jobId: string, executionId: string): Promise; - searchJobExecutions(options?: SearchJobExecutionsOptions): Promise; - getJobLog(execution: JobExecutionInfo): Promise; -} - -/** - * Capability extension for backends that can delete job execution records. - * Only SCAPI exposes this — OCAPI's Data API has no equivalent endpoint. - * - * Use {@link supportsDeleteJobExecution} to narrow at runtime. - */ -export interface DeletableJobsBackend extends JobsBackend { - deleteJobExecution(jobId: string, executionId: string): Promise; -} - -/** - * Type guard: returns true if the backend supports deleting job executions. - */ -export function supportsDeleteJobExecution(backend: JobsBackend): backend is DeletableJobsBackend { - return typeof (backend as DeletableJobsBackend).deleteJobExecution === 'function'; -} diff --git a/packages/b2c-tooling-sdk/src/operations/jobs/backend.ts b/packages/b2c-tooling-sdk/src/operations/jobs/wait-canonical.ts similarity index 52% rename from packages/b2c-tooling-sdk/src/operations/jobs/backend.ts rename to packages/b2c-tooling-sdk/src/operations/jobs/wait-canonical.ts index cf05bf49a..5227ec83b 100644 --- a/packages/b2c-tooling-sdk/src/operations/jobs/backend.ts +++ b/packages/b2c-tooling-sdk/src/operations/jobs/wait-canonical.ts @@ -3,26 +3,42 @@ * 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 type {JobsBackend, JobExecutionInfo} from './types.js'; +/** + * Backend-agnostic poll loop over canonical {@link JobExecutionInfo}. + * + * Takes a `getExecution` callback so callers can supply either the SCAPI + * ops `getJobExecution` method or an OCAPI fetch wrapped in + * {@link mapOcapiExecution}. Decouples polling logic from any specific + * backend abstraction. + * + * @module operations/jobs/wait-canonical + */ import type {WaitForJobOptions, WaitForJobPollInfo} from './run.js'; -import {OcapiJobsBackend} from './ocapi-backend.js'; -import {ScapiJobsBackend} from './scapi-backend.js'; -import {createDualBackend, type DualBackendConfig} from '../../clients/dual-backend-factory.js'; -import type {ApiBackendPreference} from '../../clients/scapi-backend-utils.js'; - -export type {ApiBackendPreference}; -export type JobsBackendConfig = DualBackendConfig; +import type {JobExecutionInfo} from './types.js'; -export function createJobsBackend(config: JobsBackendConfig): JobsBackend { - return createDualBackend(config, { - domainName: 'Jobs', - Scapi: ScapiJobsBackend, - Ocapi: OcapiJobsBackend, - }); +/** + * Thrown by {@link waitForJobExecution} when a job reaches a failure state. + * Carries the canonical {@link JobExecutionInfo} so callers can read fields + * (`exitStatus.code`, `logFilePath`, etc.) without knowing which backend + * served the response. + */ +export class CanonicalJobExecutionError extends Error { + constructor( + message: string, + public readonly execution: JobExecutionInfo, + ) { + super(message); + this.name = 'CanonicalJobExecutionError'; + } } +/** + * Polls `getExecution(jobId, executionId)` until the job reaches a terminal + * state, returning the final {@link JobExecutionInfo}. Throws + * {@link JobExecutionError} on failure or `Error` on timeout. + */ export async function waitForJobExecution( - backend: JobsBackend, + getExecution: (jobId: string, executionId: string) => Promise, jobId: string, executionId: string, options: WaitForJobOptions = {}, @@ -32,6 +48,7 @@ export async function waitForJobExecution( const startTime = Date.now(); const pollIntervalMs = pollIntervalSeconds * 1000; const timeoutMs = timeoutSeconds * 1000; + await sleepFn(pollIntervalMs); while (true) { @@ -41,15 +58,13 @@ export async function waitForJobExecution( throw new Error(`Timeout waiting for job ${jobId} execution ${executionId}`); } - const execution = await backend.getJobExecution(jobId, executionId); + const execution = await getExecution(jobId, executionId); const currentStatus = execution.executionStatus; - const pollInfo: WaitForJobPollInfo = {jobId, executionId, elapsedSeconds, status: currentStatus}; onPoll?.(pollInfo); if (execution.executionStatus === 'aborted' || execution.exitStatus?.status === 'error') { - const {JobExecutionError} = await import('./run.js'); - throw new JobExecutionError(`Job ${jobId} failed`, execution._raw as never); + throw new CanonicalJobExecutionError(`Job ${jobId} failed`, execution); } if (execution.executionStatus === 'finished') { diff --git a/packages/b2c-tooling-sdk/test/auth/oauth.test.ts b/packages/b2c-tooling-sdk/test/auth/oauth.test.ts index bb0fdc42d..a0b90b82e 100644 --- a/packages/b2c-tooling-sdk/test/auth/oauth.test.ts +++ b/packages/b2c-tooling-sdk/test/auth/oauth.test.ts @@ -460,6 +460,149 @@ describe('auth/oauth', () => { expect(extended).to.be.instanceOf(OAuthStrategy); }); }); + + describe('getAccessTokenForCascade', () => { + it('returns the first candidate that AM accepts', async () => { + const mockToken = createMockJWT({sub: 'test-client-cascade-1'}); + let lastRequestedScope: string | null = null; + + server.use( + http.post(AM_URL, async ({request}) => { + const body = await request.text(); + const params = new URLSearchParams(body); + lastRequestedScope = params.get('scope'); + // Reject anything containing the rw scope; accept the read-only + // candidate. + if (lastRequestedScope?.includes('sfcc.jobs.rw')) { + return HttpResponse.json({error: 'invalid_scope'}, {status: 400}); + } + return HttpResponse.json({ + access_token: mockToken, + expires_in: 1800, + scope: lastRequestedScope ?? '', + }); + }), + ); + + const strategy = new OAuthStrategy({ + clientId: 'test-client-cascade-1', + clientSecret: 'test-secret', + }); + + const token = await strategy.getAccessTokenForCascade([['sfcc.jobs.rw'], ['sfcc.jobs']]); + + expect(token).to.equal(mockToken); + // Last successful AM call should have used the read-only candidate. + expect(lastRequestedScope).to.equal('sfcc.jobs'); + }); + + it('returns a cached broader-scope token without hitting AM', async () => { + // Pre-warm: first call grants rw. + const rwToken = createMockJWT({sub: 'test-client-cascade-2'}); + let amCallCount = 0; + + server.use( + http.post(AM_URL, async () => { + amCallCount++; + return HttpResponse.json({ + access_token: rwToken, + expires_in: 1800, + scope: 'sfcc.jobs.rw', + }); + }), + ); + + const strategy = new OAuthStrategy({ + clientId: 'test-client-cascade-2', + clientSecret: 'test-secret', + }); + + // First request: cascade tries rw, AM grants it. amCallCount = 1. + await strategy.getAccessTokenForCascade([['sfcc.jobs.rw']]); + expect(amCallCount).to.equal(1); + + // Second request: read-only cascade. The cached rw token's scopes + // include 'sfcc.jobs.rw' — should it satisfy a request for ['sfcc.jobs']? + // Per design: the satisfies-check looks for tokens whose scopes ⊇ + // the requested set. 'sfcc.jobs' is NOT in the rw token's scopes, + // so it does not satisfy. AM gets called again. This test confirms + // that hierarchical scope semantics are NOT inferred — caches are + // exact-set matches. + await strategy.getAccessTokenForCascade([['sfcc.jobs']]); + expect(amCallCount).to.equal(2); + }); + + it('reuses cached token when a candidate exactly matches', async () => { + const mockToken = createMockJWT({sub: 'test-client-cascade-3'}); + let amCallCount = 0; + + server.use( + http.post(AM_URL, async () => { + amCallCount++; + return HttpResponse.json({ + access_token: mockToken, + expires_in: 1800, + scope: 'sfcc.jobs', + }); + }), + ); + + const strategy = new OAuthStrategy({ + clientId: 'test-client-cascade-3', + clientSecret: 'test-secret', + }); + + await strategy.getAccessTokenForCascade([['sfcc.jobs']]); + await strategy.getAccessTokenForCascade([['sfcc.jobs']]); + + // Second call should hit cache. + expect(amCallCount).to.equal(1); + }); + + it('throws the last invalid_scope when all candidates fail', async () => { + server.use( + http.post(AM_URL, async () => { + return HttpResponse.json({error: 'invalid_scope'}, {status: 400}); + }), + ); + + const strategy = new OAuthStrategy({ + clientId: 'test-client-cascade-4', + clientSecret: 'test-secret', + }); + + try { + await strategy.getAccessTokenForCascade([['sfcc.jobs.rw'], ['sfcc.jobs']]); + expect.fail('should have thrown'); + } catch (error) { + expect((error as Error).message).to.include('invalid_scope'); + } + }); + + it('rethrows non-invalid_scope errors without trying further candidates', async () => { + let amCallCount = 0; + server.use( + http.post(AM_URL, async () => { + amCallCount++; + return HttpResponse.json({error: 'invalid_client'}, {status: 401}); + }), + ); + + const strategy = new OAuthStrategy({ + clientId: 'test-client-cascade-5', + clientSecret: 'test-secret', + }); + + try { + await strategy.getAccessTokenForCascade([['sfcc.jobs.rw'], ['sfcc.jobs']]); + expect.fail('should have thrown'); + } catch { + // expected + } + // Should not have tried the second candidate. + expect(amCallCount).to.equal(1); + }); + }); }); }); diff --git a/packages/b2c-tooling-sdk/test/compat/dispatcher.test.ts b/packages/b2c-tooling-sdk/test/compat/dispatcher.test.ts new file mode 100644 index 000000000..208492dfe --- /dev/null +++ b/packages/b2c-tooling-sdk/test/compat/dispatcher.test.ts @@ -0,0 +1,121 @@ +/* + * 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 {BackendDispatcher} from '../../src/compat/dispatcher.js'; + +interface FakeOps { + doRead(): Promise; +} + +const makeOps = (): FakeOps => ({doRead: async () => 'scapi-result'}); + +const invalidScopeError = () => new Error('Failed to get access token: 400 invalid_scope'); + +describe('BackendDispatcher', () => { + describe('preference handling', () => { + it('throws when scapi is forced but not configured', () => { + expect(() => new BackendDispatcher('scapi', () => undefined, 'jobs')).to.throw( + /shortCode, tenantId, and OAuth/, + ); + }); + + it('resolves to ocapi immediately when forced', () => { + const d = new BackendDispatcher('ocapi', () => makeOps(), 'jobs'); + expect(d.active).to.equal('ocapi'); + }); + + it('resolves to scapi when forced and configured', () => { + const d = new BackendDispatcher('scapi', () => makeOps(), 'jobs'); + expect(d.active).to.equal('scapi'); + }); + + it('resolves to ocapi in auto when scapi not configured', () => { + const d = new BackendDispatcher('auto', () => undefined, 'jobs'); + expect(d.active).to.equal('ocapi'); + }); + + it('stays unresolved in auto when scapi configured', () => { + const d = new BackendDispatcher('auto', () => makeOps(), 'jobs'); + expect(d.active).to.equal(undefined); + }); + }); + + describe('run', () => { + it('routes to scapi branch and caches the choice', async () => { + const ops = makeOps(); + const d = new BackendDispatcher('auto', () => ops, 'jobs'); + let scapiCalls = 0; + const branches = { + scapi: async (received: FakeOps) => { + expect(received).to.equal(ops); + scapiCalls++; + return 'scapi'; + }, + ocapi: async () => 'ocapi', + }; + expect(await d.run(branches)).to.equal('scapi'); + expect(await d.run(branches)).to.equal('scapi'); + expect(scapiCalls).to.equal(2); + expect(d.active).to.equal('scapi'); + }); + + it('falls back to ocapi on invalid_scope and caches the choice', async () => { + let scapiCalls = 0; + let ocapiCalls = 0; + const d = new BackendDispatcher('auto', () => makeOps(), 'jobs'); + const branches = { + scapi: async () => { + scapiCalls++; + throw invalidScopeError(); + }, + ocapi: async () => { + ocapiCalls++; + return 'ocapi'; + }, + }; + expect(await d.run(branches)).to.equal('ocapi'); + expect(await d.run(branches)).to.equal('ocapi'); + expect(scapiCalls).to.equal(1); + expect(ocapiCalls).to.equal(2); + expect(d.active).to.equal('ocapi'); + }); + + it('rethrows non-invalid_scope errors without falling back', async () => { + const d = new BackendDispatcher('auto', () => makeOps(), 'jobs'); + try { + await d.run({ + scapi: async () => { + throw new Error('something else broke'); + }, + ocapi: async () => 'should-not-reach', + }); + expect.fail('should have thrown'); + } catch (error) { + expect((error as Error).message).to.equal('something else broke'); + } + // Did NOT cache scapi (the call did not succeed) — but also didn't cache ocapi. + expect(d.active).to.equal(undefined); + }); + + it('routes directly to ocapi when forced', async () => { + const d = new BackendDispatcher('ocapi', () => makeOps(), 'jobs'); + let scapiCalls = 0; + let ocapiCalls = 0; + await d.run({ + scapi: async () => { + scapiCalls++; + return 's'; + }, + ocapi: async () => { + ocapiCalls++; + return 'o'; + }, + }); + expect(scapiCalls).to.equal(0); + expect(ocapiCalls).to.equal(1); + }); + }); +}); From d91d2f43fb6b71d60e8adbc94722d1952f98486a Mon Sep 17 00:00:00 2001 From: Charles Lavery Date: Thu, 21 May 2026 12:02:17 -0400 Subject: [PATCH 11/22] Fix b2c-vs-extension reloadCodeVersion typecheck after SCAPI migration reloadCodeVersion now takes a ScriptsBackend (was B2CInstance). Wrap the extension's instance in OcapiScriptsBackend at the two call sites so the extension keeps its OCAPI-only behavior while satisfying the new type. Mirrors how b2c-cli/src/commands/code/deploy.ts:207 already handles this. --- packages/b2c-vs-extension/src/code-sync/cartridge-commands.ts | 3 ++- packages/b2c-vs-extension/src/code-sync/deploy-command.ts | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/b2c-vs-extension/src/code-sync/cartridge-commands.ts b/packages/b2c-vs-extension/src/code-sync/cartridge-commands.ts index c40b71d0c..2453236cc 100644 --- a/packages/b2c-vs-extension/src/code-sync/cartridge-commands.ts +++ b/packages/b2c-vs-extension/src/code-sync/cartridge-commands.ts @@ -11,6 +11,7 @@ import { createCodeVersion, reloadCodeVersion, deleteCodeVersion, + OcapiScriptsBackend, } from '@salesforce/b2c-tooling-sdk/operations/code'; import { addCartridge, @@ -289,7 +290,7 @@ function createListCodeVersionsCommand( } else if (actionPick.action === 'reload') { await vscode.window.withProgress( {location: vscode.ProgressLocation.Notification, title: `Reloading "${versionId}"...`}, - () => reloadCodeVersion(instance, versionId), + () => reloadCodeVersion(new OcapiScriptsBackend(instance), versionId), ); vscode.window.showInformationMessage(`B2C DX: Code version "${versionId}" reloaded.`); } else if (actionPick.action === 'delete') { diff --git a/packages/b2c-vs-extension/src/code-sync/deploy-command.ts b/packages/b2c-vs-extension/src/code-sync/deploy-command.ts index f3be4c955..9bca0ad1e 100644 --- a/packages/b2c-vs-extension/src/code-sync/deploy-command.ts +++ b/packages/b2c-vs-extension/src/code-sync/deploy-command.ts @@ -10,6 +10,7 @@ import { getActiveCodeVersion, activateCodeVersion, reloadCodeVersion, + OcapiScriptsBackend, } from '@salesforce/b2c-tooling-sdk/operations/code'; import * as vscode from 'vscode'; import type {B2CExtensionConfig} from '../config-provider.js'; @@ -98,7 +99,7 @@ export function createDeployCommand( outputChannel.appendLine(`Code version "${codeVersion}" activated`); } else if (actionPick.action === 'reload') { progress.report({message: 'Reloading code version...'}); - await reloadCodeVersion(instance, codeVersion); + await reloadCodeVersion(new OcapiScriptsBackend(instance), codeVersion); outputChannel.appendLine(`Code version "${codeVersion}" reloaded`); } From 33c2f5db4f90cd20ffc4e32202c83653c03e860d Mon Sep 17 00:00:00 2001 From: Charles Lavery Date: Tue, 16 Jun 2026 22:14:36 -0400 Subject: [PATCH 12/22] WIP: SCAPI read-only scope fallback, capability-unsupported error, VS Code scripts backend --- .changeset/scapi-migration.md | 4 +- docs/guide/authentication.md | 38 ++++++--- packages/b2c-cli/src/commands/code/deploy.ts | 25 ++++-- .../b2c-cli/test/commands/code/deploy.test.ts | 47 +++++++---- .../src/cli/instance-command.ts | 36 +++++++- .../b2c-tooling-sdk/src/clients/middleware.ts | 17 ++-- .../src/clients/scapi-backend-utils.ts | 24 ++++++ .../src/clients/scapi-fallback-backend.ts | 37 ++++++--- .../src/clients/scapi-scope-tier.ts | 31 ++++++- .../src/operations/bm-roles/scapi-backend.ts | 69 +++++++-------- .../src/operations/bm-users/scapi-backend.ts | 56 +++++++------ .../operations/code/scapi-scripts-backend.ts | 17 ++-- .../src/operations/jobs/scapi-ops.ts | 5 ++ .../clients/scapi-fallback-backend.test.ts | 34 ++++++++ .../test/operations/jobs/scapi-ops.test.ts | 83 +++++++++++++++++++ .../src/code-sync/cartridge-commands.ts | 34 ++++---- .../src/code-sync/code-sync-manager.ts | 14 ++-- .../src/code-sync/deploy-command.ts | 15 ++-- .../b2c-vs-extension/src/code-sync/index.ts | 2 +- .../src/code-sync/scripts-backend.ts | 32 +++++++ skills/b2c-cli/skills/b2c-code/SKILL.md | 6 +- 21 files changed, 465 insertions(+), 161 deletions(-) create mode 100644 packages/b2c-tooling-sdk/test/operations/jobs/scapi-ops.test.ts create mode 100644 packages/b2c-vs-extension/src/code-sync/scripts-backend.ts diff --git a/.changeset/scapi-migration.md b/.changeset/scapi-migration.md index 201cbaf52..9130c5ca0 100644 --- a/.changeset/scapi-migration.md +++ b/.changeset/scapi-migration.md @@ -1,6 +1,8 @@ --- '@salesforce/b2c-cli': minor '@salesforce/b2c-tooling-sdk': minor +'b2c-vs-extension': minor +'@salesforce/b2c-dx-docs': minor --- -Migrate `job`, `code`, `bm users`, and `bm roles` commands to support SCAPI alongside OCAPI. In auto mode (the default), the CLI prefers SCAPI when `shortCode` and `tenantId` are configured and silently falls back to OCAPI if the SCAPI scopes aren't granted. Use `--api-backend ocapi|scapi|auto` or `apiBackend` in dw.json to control explicitly. SCAPI scopes: `sfcc.jobs.rw`, `sfcc.scripts.rw`, `sfcc.users.rw`, `sfcc.roles.rw`. New `job execution delete` command (SCAPI only). +Migrate `job`, `code`, `bm users`, and `bm roles` commands to support SCAPI alongside OCAPI. In auto mode (the default), the CLI prefers SCAPI when `shortCode` and `tenantId` are configured and silently falls back to OCAPI if the SCAPI scopes aren't granted. Use `--api-backend ocapi|scapi|auto` or `apiBackend` in dw.json to control explicitly. SCAPI scopes: `sfcc.jobs(.rw)`, `sfcc.scripts(.rw)`, `sfcc.users(.rw)`, `sfcc.roles(.rw)` — read-only scopes are honored for list/get operations, falling back to read-only when the `*.rw` scope is not granted. New `job execution delete` command (SCAPI only). `code deploy` and all VS Code extension code-version actions (list/activate/delete/reload/create plus active-version discovery) also honor `apiBackend`. `bm users update --disabled` transparently falls back to OCAPI in auto mode (SCAPI Users PATCH does not support the `disabled` flag). Auto mode only selects SCAPI when authentication can request the required scopes — stateless OAuth (client-credentials, JWT bearer); stateful and implicit flows fall back to OCAPI unless `--api-backend scapi` is set explicitly. diff --git a/docs/guide/authentication.md b/docs/guide/authentication.md index ed5099e1a..7a5cc976f 100644 --- a/docs/guide/authentication.md +++ b/docs/guide/authentication.md @@ -13,8 +13,10 @@ The CLI uses different authentication mechanisms depending on the operation: | Operation | Auth Method | Setup Required | | -------------------------------------------------------------------------------------------------- | ---------------------------- | ---------------------------------------------------------------------------------------- | | [Code](/cli/code) deploy, watch (file upload) | WebDAV (Basic Auth or OAuth) | [WebDAV Access](#webdav-access) | -| [Code](/cli/code) list, activate, delete | OAuth + OCAPI | [API Client](#account-manager-api-client) + [OCAPI](#ocapi-configuration) | -| [Jobs](/cli/jobs), [Sites](/cli/sites) | OAuth + OCAPI | [API Client](#account-manager-api-client) + [OCAPI](#ocapi-configuration) | +| [Code](/cli/code) list, activate, delete | OAuth + OCAPI **or** SCAPI (`sfcc.scripts` / `sfcc.scripts.rw`) | [API Client](#account-manager-api-client) + [OCAPI](#ocapi-configuration) or [SCAPI Scopes](#scapi-authentication) | +| [Jobs](/cli/jobs) | OAuth + OCAPI **or** SCAPI (`sfcc.jobs` / `sfcc.jobs.rw`) | [API Client](#account-manager-api-client) + [OCAPI](#ocapi-configuration) or [SCAPI Scopes](#scapi-authentication) | +| [BM users / roles](/cli/bm) | OAuth + OCAPI **or** SCAPI (`sfcc.users(.rw)` / `sfcc.roles(.rw)`) | [API Client](#account-manager-api-client) + [OCAPI](#ocapi-configuration) or [SCAPI Scopes](#scapi-authentication) | +| [Sites](/cli/sites) | OAuth + OCAPI | [API Client](#account-manager-api-client) + [OCAPI](#ocapi-configuration) | | SCAPI commands ([schemas](/cli/scapi-schemas), [custom-apis](/cli/custom-apis), [eCDN](/cli/ecdn)) | OAuth + SCAPI scopes | [API Client](#account-manager-api-client) + [SCAPI Scopes](#scapi-authentication) | | [CIP analytics](/cli/cip) (`cip query`, `cip report`) | OAuth + Client Credentials | [API Client](#account-manager-api-client) + Salesforce Commerce API role + tenant filter | | [SLAS](/cli/slas) client management | OAuth | None (uses built-in client) or [API Client](#account-manager-api-client) | @@ -478,15 +480,25 @@ SCAPI commands (eCDN, SCAPI schemas, custom APIs) require OAuth authentication w ### Scopes by Command -| Command | Required Scope | Reference | -| ----------------------------- | -------------------- | ----------------------------------- | -| `b2c scapi schemas list/get` | `sfcc.scapi-schemas` | [SCAPI Schemas](/cli/scapi-schemas) | -| `b2c scapi custom status` | `sfcc.custom-apis` | [Custom APIs](/cli/custom-apis) | -| `b2c ecdn` (read operations) | `sfcc.cdn-zones` | [eCDN](/cli/ecdn) | -| `b2c ecdn` (write operations) | `sfcc.cdn-zones.rw` | [eCDN](/cli/ecdn) | +| Command | Required Scope | Reference | +| ------------------------------------------------------ | ------------------------------------ | ----------------------------------- | +| `b2c scapi schemas list/get` | `sfcc.scapi-schemas` | [SCAPI Schemas](/cli/scapi-schemas) | +| `b2c scapi custom status` | `sfcc.custom-apis` | [Custom APIs](/cli/custom-apis) | +| `b2c ecdn` (read operations) | `sfcc.cdn-zones` | [eCDN](/cli/ecdn) | +| `b2c ecdn` (write operations) | `sfcc.cdn-zones.rw` | [eCDN](/cli/ecdn) | +| `b2c jobs` (read; e.g. `list`, `get`, `wait`) | `sfcc.jobs` or `sfcc.jobs.rw` | [Jobs](/cli/jobs) | +| `b2c jobs` (write; e.g. `run`, `delete`) | `sfcc.jobs.rw` | [Jobs](/cli/jobs) | +| `b2c code list` | `sfcc.scripts` or `sfcc.scripts.rw` | [Code](/cli/code) | +| `b2c code activate`, `code delete` | `sfcc.scripts.rw` | [Code](/cli/code) | +| `b2c bm users list/get` | `sfcc.users` or `sfcc.users.rw` | [BM](/cli/bm) | +| `b2c bm users create/update/delete` | `sfcc.users.rw` | [BM](/cli/bm) | +| `b2c bm roles list/get` | `sfcc.roles` or `sfcc.roles.rw` | [BM](/cli/bm) | +| `b2c bm roles create/delete/grant/revoke/permissions` | `sfcc.roles.rw` | [BM](/cli/bm) | The CLI automatically requests these scopes. Your API client must have them in the Default Scopes list. +For commands that have both an OCAPI and a SCAPI implementation (`code`, `jobs`, `bm users`, `bm roles`), the CLI defaults to `--api-backend auto`: it tries SCAPI when shortCode + tenantId are configured and the API client has the required `sfcc.*` scope, otherwise it falls back to OCAPI. Use `--api-backend ocapi` or `--api-backend scapi` to force a backend explicitly. + ::: tip For detailed authentication requirements including specific scopes for each command, see the individual [CLI command reference pages](/cli/). ::: @@ -594,11 +606,12 @@ Here's a complete example for setting up CLI access: - `Salesforce Commerce API` - add tenant filter with your tenant IDs - `Sandbox API User` - if using ODS (add tenant filter) - **Default Scopes**: `mail roles tenantFilter openid sfcc.cdn-zones` + - For SCAPI-backed dual commands, also add the relevant `sfcc.jobs(.rw)`, `sfcc.scripts(.rw)`, `sfcc.users(.rw)`, `sfcc.roles(.rw)` scopes — see [Scopes by Command](#scopes-by-command). - **Redirect URLs**: `http://localhost:8080` (for user authentication) -### 2. Configure OCAPI (for code list/activate/delete, jobs, sites) +### 2. Configure OCAPI (for `sites` and as the auto-mode fallback for code/jobs/bm) -Add the JSON configuration shown in [OCAPI Configuration](#ocapi-configuration) to enable code version and job APIs. +Add the JSON configuration shown in [OCAPI Configuration](#ocapi-configuration). With the SCAPI scopes above also configured on your client, `code list/activate/delete`, `code deploy --activate/--reload`, `jobs`, and `bm users/roles` will prefer SCAPI in `auto` mode and fall back to OCAPI if a scope is missing. ### 3. Configure WebDAV Access (for code deploy/watch, webdav commands) @@ -614,10 +627,11 @@ Either: export SFCC_CLIENT_ID=your-client-id export SFCC_CLIENT_SECRET=your-client-secret -# Instance (for OCAPI commands) +# Instance hostname (used by WebDAV and OCAPI) export SFCC_SERVER=your-instance.demandware.net -# SCAPI (for eCDN, schemas, custom-apis) +# SCAPI — required for SCAPI-only commands (eCDN, schemas, custom-apis) and +# enables `auto` mode to prefer SCAPI for code/jobs/bm commands. export SFCC_TENANT_ID=zzxy_prd export SFCC_SHORTCODE=kv7kzm78 diff --git a/packages/b2c-cli/src/commands/code/deploy.ts b/packages/b2c-cli/src/commands/code/deploy.ts index a0b3ff717..e80376491 100644 --- a/packages/b2c-cli/src/commands/code/deploy.ts +++ b/packages/b2c-cli/src/commands/code/deploy.ts @@ -7,11 +7,10 @@ import {Flags} from '@oclif/core'; import { uploadCartridges, deleteCartridges, - getActiveCodeVersion, - activateCodeVersion, reloadCodeVersion, - OcapiScriptsBackend, + createScriptsBackend, type DeployResult, + type ScriptsBackend, } from '@salesforce/b2c-tooling-sdk/operations/code'; import {CartridgeCommand} from '@salesforce/b2c-tooling-sdk/cli'; import {t, withDocs} from '../../i18n/index.js'; @@ -65,11 +64,21 @@ export default class CodeDeploy extends CartridgeCommand { protected operations = { uploadCartridges, deleteCartridges, - getActiveCodeVersion, - activateCodeVersion, reloadCodeVersion, }; + /** + * Lazily-created Scripts backend. Honors `--api-backend` so SCAPI-only + * users can discover, activate, and reload code versions without OCAPI. + */ + private _scriptsBackend?: ScriptsBackend; + protected get scriptsBackend(): ScriptsBackend { + if (!this._scriptsBackend) { + this._scriptsBackend = this.createBackend(createScriptsBackend); + } + return this._scriptsBackend; + } + async run(): Promise { this.requireWebDavCredentials(); @@ -104,7 +113,7 @@ export default class CodeDeploy extends CartridgeCommand { this.warn( t('commands.code.deploy.noCodeVersion', 'No code version specified, discovering active code version...'), ); - const activeVersion = await this.operations.getActiveCodeVersion(this.instance); + const activeVersion = await this.scriptsBackend.getActiveCodeVersion(); if (!activeVersion?.id) { this.error( t('commands.code.deploy.noActiveVersion', 'No active code version found. Specify one with --code-version.'), @@ -201,10 +210,10 @@ export default class CodeDeploy extends CartridgeCommand { let reloaded = false; try { if (this.flags.activate) { - await this.operations.activateCodeVersion(this.instance, version); + await this.scriptsBackend.activateCodeVersion(version); activated = true; } else if (this.flags.reload) { - await this.operations.reloadCodeVersion(new OcapiScriptsBackend(this.instance), version); + await this.operations.reloadCodeVersion(this.scriptsBackend, version); activated = true; reloaded = true; } diff --git a/packages/b2c-cli/test/commands/code/deploy.test.ts b/packages/b2c-cli/test/commands/code/deploy.test.ts index c95f8ca34..360edcbc2 100644 --- a/packages/b2c-cli/test/commands/code/deploy.test.ts +++ b/packages/b2c-cli/test/commands/code/deploy.test.ts @@ -23,13 +23,22 @@ describe('code deploy', () => { function stubCommon(command: any) { const instance = {config: {hostname: 'example.com', codeVersion: 'v1'}}; + const scriptsBackend = { + name: 'ocapi' as const, + listCodeVersions: sinon.stub().resolves([]), + getActiveCodeVersion: sinon.stub().resolves(undefined), + activateCodeVersion: sinon.stub().resolves(undefined), + deleteCodeVersion: sinon.stub().resolves(undefined), + createCodeVersion: sinon.stub().resolves(undefined), + }; sinon.stub(command, 'requireWebDavCredentials').returns(void 0); sinon.stub(command, 'hasOAuthCredentials').returns(true); sinon.stub(command, 'log').returns(void 0); sinon.stub(command, 'warn').returns(void 0); sinon.stub(command, 'resolvedConfig').get(() => ({values: {hostname: 'example.com', codeVersion: 'v1'}})); sinon.stub(command, 'instance').get(() => instance); - return instance; + sinon.stub(command, 'scriptsBackend').get(() => scriptsBackend); + return {instance, scriptsBackend}; } it('runs before hooks and returns early when skipped', async () => { @@ -62,7 +71,7 @@ describe('code deploy', () => { it('calls delete + upload and reload when flags are set', async () => { const command: any = await createCommand({delete: true, reload: true}, {cartridgePath: '.'}); - const instance = stubCommon(command); + const {instance, scriptsBackend} = stubCommon(command); sinon.stub(command, 'runBeforeHooks').resolves({skip: false}); const afterHooksStub = sinon.stub(command, 'runAfterHooks').resolves(void 0); @@ -87,8 +96,8 @@ describe('code deploy', () => { expect(uploadStub.firstCall.args[0]).to.equal(instance); expect(uploadStub.firstCall.args[1]).to.equal(cartridges); expect(reloadStub.calledOnce).to.be.true; - // First arg is now a ScriptsBackend (OcapiScriptsBackend wrapping the instance), not the instance directly - expect(reloadStub.firstCall.args[0]).to.have.property('listCodeVersions'); + // First arg is the ScriptsBackend abstraction, not the OCAPI instance directly. + expect(reloadStub.firstCall.args[0]).to.equal(scriptsBackend); expect(reloadStub.firstCall.args[1]).to.equal('v1'); expect(result).to.deep.include({codeVersion: 'v1', activated: true, reloaded: true}); @@ -98,7 +107,7 @@ describe('code deploy', () => { it('calls activate after deploy when --activate is set', async () => { const command: any = await createCommand({activate: true}, {cartridgePath: '.'}); - const instance = stubCommon(command); + const {instance, scriptsBackend} = stubCommon(command); sinon.stub(command, 'runBeforeHooks').resolves({skip: false}); sinon.stub(command, 'runAfterHooks').resolves(void 0); @@ -107,12 +116,11 @@ describe('code deploy', () => { sinon.stub(command, 'findCartridgesWithProviders').resolves(cartridges); const uploadStub = sinon.stub().resolves(void 0); - const activateStub = sinon.stub().resolves(void 0); - command.operations = {...command.operations, uploadCartridges: uploadStub, activateCodeVersion: activateStub}; + command.operations = {...command.operations, uploadCartridges: uploadStub}; const result = await command.run(); - expect(activateStub.calledOnceWithExactly(instance, 'v1')).to.be.true; + expect(scriptsBackend.activateCodeVersion.calledOnceWithExactly('v1')).to.be.true; expect(uploadStub.calledOnce).to.be.true; expect(uploadStub.firstCall.args[0]).to.equal(instance); expect(uploadStub.firstCall.args[1]).to.equal(cartridges); @@ -121,7 +129,8 @@ describe('code deploy', () => { it('errors when activate fails', async () => { const command: any = await createCommand({activate: true}, {cartridgePath: '.'}); - stubCommon(command); + const {scriptsBackend} = stubCommon(command); + scriptsBackend.activateCodeVersion = sinon.stub().rejects(new Error('activate failed')); sinon.stub(command, 'runBeforeHooks').resolves({skip: false}); sinon.stub(command, 'runAfterHooks').resolves(void 0); @@ -130,8 +139,7 @@ describe('code deploy', () => { sinon.stub(command, 'findCartridgesWithProviders').resolves(cartridges); const uploadStub = sinon.stub().resolves(void 0); - const activateStub = sinon.stub().rejects(new Error('activate failed')); - command.operations = {...command.operations, uploadCartridges: uploadStub, activateCodeVersion: activateStub}; + command.operations = {...command.operations, uploadCartridges: uploadStub}; const errorStub = sinon.stub(command, 'error').throws(new Error('Expected error')); @@ -235,20 +243,27 @@ describe('code deploy', () => { const instance = {config: instanceConfig}; sinon.stub(command, 'instance').get(() => instance); + const scriptsBackend = { + name: 'ocapi' as const, + listCodeVersions: sinon.stub().resolves([]), + getActiveCodeVersion: sinon.stub().resolves({id: 'active', active: true}), + activateCodeVersion: sinon.stub().resolves(undefined), + deleteCodeVersion: sinon.stub().resolves(undefined), + createCodeVersion: sinon.stub().resolves(undefined), + }; + sinon.stub(command, 'scriptsBackend').get(() => scriptsBackend); + sinon.stub(command, 'runBeforeHooks').resolves({skip: false}); sinon.stub(command, 'runAfterHooks').resolves(void 0); - const activeStub = sinon.stub().resolves({id: 'active', active: true}); - const cartridges = [{name: 'c1', src: '/tmp/c1', dest: 'c1'}]; sinon.stub(command, 'findCartridgesWithProviders').resolves(cartridges); const uploadStub = sinon.stub().resolves(void 0); - command.operations = {...command.operations, getActiveCodeVersion: activeStub, uploadCartridges: uploadStub}; + command.operations = {...command.operations, uploadCartridges: uploadStub}; const result = await command.run(); - expect(activeStub.getCall(0).args[0]).to.equal(instance); - + expect(scriptsBackend.getActiveCodeVersion.calledOnce).to.be.true; expect(instanceConfig.codeVersion).to.equal('active'); expect(result.codeVersion).to.equal('active'); }); diff --git a/packages/b2c-tooling-sdk/src/cli/instance-command.ts b/packages/b2c-tooling-sdk/src/cli/instance-command.ts index 4a623ebf0..9762941e7 100644 --- a/packages/b2c-tooling-sdk/src/cli/instance-command.ts +++ b/packages/b2c-tooling-sdk/src/cli/instance-command.ts @@ -212,10 +212,32 @@ export abstract class InstanceCommand extends OAuthCom return this.resolvedConfig.values.apiBackend ?? 'auto'; } - /** True iff shortCode + tenantId + OAuth credentials are all available. */ + /** + * True iff shortCode + tenantId are available AND the configured auth + * strategy can request the SCAPI scopes (`sfcc.*` plus the tenant scope) + * each domain needs. + * + * Only the stateless OAuth flows (client-credentials, JWT bearer) qualify: + * those go back to Account Manager per request and can ask for whatever + * scopes the operation requires. Stateful and implicit flows hold a fixed + * token whose scopes were chosen at acquisition; under `auto` they would + * route through SCAPI with a token that AM never granted SCAPI scopes (or + * the right tenant scope) for, and the SCAPI 403 isn't a fallback + * trigger. + * + * Users running stateful or implicit auth who *do* want SCAPI can opt in + * with `--api-backend scapi` (provided the stored token genuinely covers + * the required scopes). Auto mode stays conservative. + */ protected hasScapiConfig(): boolean { - return Boolean( - this.resolvedConfig.values.shortCode && this.resolvedConfig.values.tenantId && this.hasOAuthCredentials(), + const values = this.resolvedConfig.values; + if (!values.shortCode || !values.tenantId || !this.hasOAuthCredentials()) { + return false; + } + + return ( + Boolean(values.clientId && values.clientSecret) || + Boolean(values.clientId && values.jwtCertPath && values.jwtKeyPath) ); } @@ -230,12 +252,18 @@ export abstract class InstanceCommand extends OAuthCom protected createBackend( factory: (config: import('../clients/dual-backend-factory.js').DualBackendConfig) => T, ): T { + // Gate auth on hasScapiConfig() — not just hasOAuthCredentials() — so the + // dual-backend factory's "is SCAPI available" check (auth presence) + // matches the dispatcher path's capability guard. Otherwise stateful or + // implicit auth can route auto-mode to SCAPI with a token that AM never + // granted SCAPI scopes for, and the resulting 403 isn't a fallback + // trigger. return factory({ preference: this.apiBackendPreference, instance: this.instance, shortCode: this.resolvedConfig.values.shortCode, tenantId: this.resolvedConfig.values.tenantId, - auth: this.hasOAuthCredentials() ? this.getOAuthStrategy() : undefined, + auth: this.hasScapiConfig() ? this.getOAuthStrategy() : undefined, }); } diff --git a/packages/b2c-tooling-sdk/src/clients/middleware.ts b/packages/b2c-tooling-sdk/src/clients/middleware.ts index 9bb598fbf..a1256acab 100644 --- a/packages/b2c-tooling-sdk/src/clients/middleware.ts +++ b/packages/b2c-tooling-sdk/src/clients/middleware.ts @@ -44,6 +44,11 @@ const retriedRequests = new WeakSet(); // Store cloned request bodies for potential retry (body can only be read once) const requestBodies = new WeakMap(); +// Remembers the SCAPI scope mode ('read' or 'write') a request was authorized +// with, so the 401 retry path can re-authorize at the same tier instead of +// hard-coding write. +const requestScopeModes = new WeakMap(); + /** * Creates authentication middleware for openapi-fetch. * @@ -177,6 +182,7 @@ export function createScapiAuthMiddleware(auth: AuthStrategy, cascade: ScopeCasc request.headers.delete(SCOPE_MODE_HEADER); if (mode && auth.getAccessTokenForCascade) { + requestScopeModes.set(request, mode); const candidates = cascade[mode]; const token = await auth.getAccessTokenForCascade(candidates); request.headers.set('Authorization', `Bearer ${token}`); @@ -213,10 +219,10 @@ export function createScapiAuthMiddleware(auth: AuthStrategy, cascade: ScopeCasc auth.invalidateToken(); const newHeaders = new Headers(request.headers); - // The original request headers no longer include the scope-mode - // header (we stripped it on the way in). Synthesize a retry by - // re-running the cascade as a read attempt — writes that 401 likely - // need rw, which the cascade already prefers. + // The original scope-mode header was stripped on the way in. Re-run + // the cascade at the same tier the original request used so a + // read-only request doesn't get retried as a write (which would fail + // for clients that only have the read scope). const retryRequest = new Request(request.url, { method: request.method, headers: newHeaders, @@ -225,7 +231,8 @@ export function createScapiAuthMiddleware(auth: AuthStrategy, cascade: ScopeCasc } as RequestInit); if (auth.getAccessTokenForCascade) { - const token = await auth.getAccessTokenForCascade(cascade.write); + const originalMode = requestScopeModes.get(request) ?? 'write'; + const token = await auth.getAccessTokenForCascade(cascade[originalMode]); retryRequest.headers.set('Authorization', `Bearer ${token}`); } else if (auth.getAuthorizationHeader) { retryRequest.headers.set('Authorization', await auth.getAuthorizationHeader()); diff --git a/packages/b2c-tooling-sdk/src/clients/scapi-backend-utils.ts b/packages/b2c-tooling-sdk/src/clients/scapi-backend-utils.ts index fbdc5d838..154bc0ce6 100644 --- a/packages/b2c-tooling-sdk/src/clients/scapi-backend-utils.ts +++ b/packages/b2c-tooling-sdk/src/clients/scapi-backend-utils.ts @@ -61,6 +61,30 @@ export function isInvalidScopeError(error: unknown): boolean { return error instanceof Error && error.message.includes('invalid_scope'); } +/** + * Thrown by SCAPI backends when a requested operation cannot be expressed on + * SCAPI (e.g., toggling the `disabled` flag via the SCAPI Users PATCH, which + * the SCAPI schema does not include). The fallback wrapper recognizes this + * and falls back to OCAPI; in explicit `scapi` mode it propagates so the + * caller sees the limitation. + */ +export class ScapiCapabilityUnsupportedError extends Error { + constructor(message: string) { + super(message); + this.name = 'ScapiCapabilityUnsupportedError'; + } +} + +/** + * Detects whether an error should trigger an OCAPI fallback. Currently: + * - {@link isInvalidScopeError}: AM rejected the requested scope. + * - {@link ScapiCapabilityUnsupportedError}: the SCAPI surface lacks the + * capability the caller asked for. + */ +export function isFallbackTrigger(error: unknown): boolean { + return isInvalidScopeError(error) || error instanceof ScapiCapabilityUnsupportedError; +} + /** * Inputs to `resolveScapiOrOcapi`. */ diff --git a/packages/b2c-tooling-sdk/src/clients/scapi-fallback-backend.ts b/packages/b2c-tooling-sdk/src/clients/scapi-fallback-backend.ts index 06c7494be..eb197bf93 100644 --- a/packages/b2c-tooling-sdk/src/clients/scapi-fallback-backend.ts +++ b/packages/b2c-tooling-sdk/src/clients/scapi-fallback-backend.ts @@ -8,14 +8,19 @@ * * Builds a Proxy that implements the same interface as the underlying * backends. Each method call routes through {@link withFallback}: try SCAPI - * first; on `invalid_scope`, fall back to OCAPI and cache the choice for the - * lifetime of the wrapper. The `name` property reflects the currently-active - * backend ('scapi' before the first call resolves, then whichever survived). + * first; on a recognized fallback trigger (e.g. `invalid_scope` or a + * SCAPI-side capability gap such as a downgraded scope tier), fall back to + * OCAPI for that call and pin to OCAPI for the rest of the wrapper's life. + * Note that a successful SCAPI call only pins *softly* — a later call that + * trips a fallback trigger still routes to OCAPI and re-pins, so a flow + * that reads under a read-only scope and then needs to write isn't stuck. + * The `name` property reflects the currently-active backend ('scapi' before + * the first call resolves, then whichever survived). * * @module clients/scapi-fallback-backend */ import {getLogger} from '../logging/logger.js'; -import {isInvalidScopeError, type BackendBase} from './scapi-backend-utils.js'; +import {isFallbackTrigger, type BackendBase} from './scapi-backend-utils.js'; /** * Internal state shared by all method invocations on a Proxy. Holds the @@ -30,7 +35,8 @@ interface FallbackState { } /** - * Wraps a SCAPI call with automatic OCAPI fallback on `invalid_scope`. + * Wraps a SCAPI call with automatic OCAPI fallback on a recognized + * fallback trigger ({@link isFallbackTrigger}). * * Standalone helper so the Proxy traps and any future direct callers share * one definition. @@ -39,17 +45,26 @@ async function withFallback( state: FallbackState, fn: (backend: T) => Promise, ): Promise { - if (state.resolved) { - return fn(state.resolved); + // Once we've fallen back to OCAPI, stay there — there's no SCAPI surface to + // re-attempt. Errors propagate from OCAPI directly. + if (state.resolved === state.ocapi) { + return fn(state.ocapi); } + // Either unresolved (first call), or already pinned to SCAPI by a prior + // successful call. We still try SCAPI, but a fallback-trigger error here + // routes this call through OCAPI and re-pins. This handles the case where + // a read succeeds (pinning SCAPI) and then a write fails because the + // ScopeTierManager has been downgraded to read-only — the write should + // still satisfy through OCAPI rather than throwing. + const target = state.resolved ?? state.scapi; try { - const result = await fn(state.scapi); - state.resolved = state.scapi; + const result = await fn(target); + if (!state.resolved) state.resolved = state.scapi; return result; } catch (error) { - if (isInvalidScopeError(error)) { - getLogger().info(`SCAPI ${state.domainName} scope unavailable, falling back to OCAPI`); + if (isFallbackTrigger(error)) { + getLogger().info(`SCAPI ${state.domainName} unavailable for this operation, falling back to OCAPI`); state.resolved = state.ocapi; return fn(state.ocapi); } diff --git a/packages/b2c-tooling-sdk/src/clients/scapi-scope-tier.ts b/packages/b2c-tooling-sdk/src/clients/scapi-scope-tier.ts index b90273ccd..2d60d95d1 100644 --- a/packages/b2c-tooling-sdk/src/clients/scapi-scope-tier.ts +++ b/packages/b2c-tooling-sdk/src/clients/scapi-scope-tier.ts @@ -20,6 +20,8 @@ * @module clients/scapi-scope-tier */ +import {isInvalidScopeError, ScapiCapabilityUnsupportedError} from './scapi-backend-utils.js'; + export type ScopeTier = 'rw' | 'read-only'; export interface ScopeTierManagerOptions { @@ -59,10 +61,15 @@ export class ScopeTierManager { /** * Returns a client suitable for write operations. Throws if we've already * downgraded to read-only — the API client doesn't have the rw scope. + * + * Throws {@link ScapiCapabilityUnsupportedError} so the SCAPI/OCAPI + * fallback wrapper recognizes this as a capability gap and routes the + * write through OCAPI in `auto` mode (instead of pinning to SCAPI after + * a successful read and then failing the write). */ getClientForWrite(): C { if (this.resolved === 'read-only') { - throw new Error( + throw new ScapiCapabilityUnsupportedError( `SCAPI ${this.opts.domainName} API requires the "${this.opts.rwScopes.join(' ')}" scope. ` + `Add this scope to your API client in Account Manager.`, ); @@ -99,4 +106,26 @@ export class ScopeTierManager { this.resolved = 'read-only'; this.readClient = this.opts.buildClient(this.opts.readScopes); } + + /** + * Runs a read operation, downgrading to the read-only client and retrying + * once if the rw attempt fails with `invalid_scope`. Backends should wrap + * their reads with this so an API client provisioned with only the + * read-only scope (e.g. `sfcc.scripts`) can still read through SCAPI. + * + * Writes do not go through this helper — they always require rw, and + * `getClientForWrite()` already throws after a downgrade. + */ + async tryRead(fn: (client: C) => Promise): Promise { + const client = this.getClientForRead(); + try { + return await fn(client); + } catch (error) { + if (this.resolved === 'read-only' || !isInvalidScopeError(error)) { + throw error; + } + this.downgradeToReadOnly(); + return fn(this.readClient!); + } + } } diff --git a/packages/b2c-tooling-sdk/src/operations/bm-roles/scapi-backend.ts b/packages/b2c-tooling-sdk/src/operations/bm-roles/scapi-backend.ts index 30a08a2c3..a79b03744 100644 --- a/packages/b2c-tooling-sdk/src/operations/bm-roles/scapi-backend.ts +++ b/packages/b2c-tooling-sdk/src/operations/bm-roles/scapi-backend.ts @@ -60,39 +60,41 @@ export class ScapiRolesBackend implements RolesBackend { } async listRoles(options: ListRolesOptions = {}): Promise { - const client = this.scopeTier.getClientForRead(); const {start = 0, count = 25, expand} = options; - const {data, error} = await client.GET('/organizations/{organizationId}/roles', { - params: { - path: {organizationId: this.organizationId}, - query: {limit: count, offset: start, expand}, - }, + return this.scopeTier.tryRead(async (client) => { + const {data, error} = await client.GET('/organizations/{organizationId}/roles', { + params: { + path: {organizationId: this.organizationId}, + query: {limit: count, offset: start, expand}, + }, + }); + if (error || !data) { + throw new Error(toErrorMessage(error, 'Failed to list roles')); + } + const result = data as RoleSearch; + return { + total: result.total ?? 0, + start: result.offset ?? start, + count: result.limit ?? count, + hits: (result.data ?? []).map(mapScapiRole), + }; }); - if (error || !data) { - throw new Error(toErrorMessage(error, 'Failed to list roles')); - } - const result = data as RoleSearch; - return { - total: result.total ?? 0, - start: result.offset ?? start, - count: result.limit ?? count, - hits: (result.data ?? []).map(mapScapiRole), - }; } async getRole(roleId: string, options?: {expand?: ('users' | 'permissions')[]}): Promise { - const client = this.scopeTier.getClientForRead(); - const {data, error} = await client.GET('/organizations/{organizationId}/roles/{roleId}', { - params: { - path: {organizationId: this.organizationId, roleId}, - query: {expand: options?.expand}, - }, + return this.scopeTier.tryRead(async (client) => { + const {data, error} = await client.GET('/organizations/{organizationId}/roles/{roleId}', { + params: { + path: {organizationId: this.organizationId, roleId}, + query: {expand: options?.expand}, + }, + }); + if (error || !data) { + throw new Error(toErrorMessage(error, `Failed to get role ${roleId}`)); + } + return mapScapiRole(data); }); - if (error || !data) { - throw new Error(toErrorMessage(error, `Failed to get role ${roleId}`)); - } - return mapScapiRole(data); } async createRole(roleId: string, input?: CreateRoleInput): Promise { @@ -122,14 +124,15 @@ export class ScapiRolesBackend implements RolesBackend { } async getPermissions(roleId: string): Promise { - const client = this.scopeTier.getClientForRead(); - const {data, error} = await client.GET('/organizations/{organizationId}/roles/{roleId}/permissions', { - params: {path: {organizationId: this.organizationId, roleId}}, + return this.scopeTier.tryRead(async (client) => { + const {data, error} = await client.GET('/organizations/{organizationId}/roles/{roleId}/permissions', { + params: {path: {organizationId: this.organizationId, roleId}}, + }); + if (error || !data) { + throw new Error(toErrorMessage(error, `Failed to get permissions for role ${roleId}`)); + } + return data; }); - if (error || !data) { - throw new Error(toErrorMessage(error, `Failed to get permissions for role ${roleId}`)); - } - return data; } async setPermissions(roleId: string, permissions: RolePermissionsInfo): Promise { diff --git a/packages/b2c-tooling-sdk/src/operations/bm-users/scapi-backend.ts b/packages/b2c-tooling-sdk/src/operations/bm-users/scapi-backend.ts index e5a5dd9b2..126af842e 100644 --- a/packages/b2c-tooling-sdk/src/operations/bm-users/scapi-backend.ts +++ b/packages/b2c-tooling-sdk/src/operations/bm-users/scapi-backend.ts @@ -23,6 +23,7 @@ import { type UserSearch, } from '../../clients/scapi-merchant-users.js'; import {buildTenantScope, toOrganizationId} from '../../clients/custom-apis.js'; +import {ScapiCapabilityUnsupportedError} from '../../clients/scapi-backend-utils.js'; import {ScopeTierManager} from '../../clients/scapi-scope-tier.js'; function mapScapiUser(scapi: ScapiUser): UserInfo { @@ -69,36 +70,38 @@ export class ScapiUsersBackend implements UsersBackend { } async listUsers(options: ListUsersOptions = {}): Promise { - const client = this.scopeTier.getClientForRead(); const {start = 0, count = 25} = options; - const {data, error} = await client.GET('/organizations/{organizationId}/users', { - params: { - path: {organizationId: this.organizationId}, - query: {limit: count, offset: start}, - }, + return this.scopeTier.tryRead(async (client) => { + const {data, error} = await client.GET('/organizations/{organizationId}/users', { + params: { + path: {organizationId: this.organizationId}, + query: {limit: count, offset: start}, + }, + }); + if (error || !data) { + throw new Error(toErrorMessage(error, 'Failed to list users')); + } + const result = data as UserSearch; + return { + total: result.total ?? 0, + start: result.offset ?? start, + count: result.limit ?? count, + hits: (result.data ?? []).map(mapScapiUser), + }; }); - if (error || !data) { - throw new Error(toErrorMessage(error, 'Failed to list users')); - } - const result = data as UserSearch; - return { - total: result.total ?? 0, - start: result.offset ?? start, - count: result.limit ?? count, - hits: (result.data ?? []).map(mapScapiUser), - }; } async getUser(login: string): Promise { - const client = this.scopeTier.getClientForRead(); - const {data, error} = await client.GET('/organizations/{organizationId}/users/{login}', { - params: {path: {organizationId: this.organizationId, login}}, + return this.scopeTier.tryRead(async (client) => { + const {data, error} = await client.GET('/organizations/{organizationId}/users/{login}', { + params: {path: {organizationId: this.organizationId, login}}, + }); + if (error || !data) { + throw new Error(toErrorMessage(error, `Failed to get user ${login}`)); + } + return mapScapiUser(data); }); - if (error || !data) { - throw new Error(toErrorMessage(error, `Failed to get user ${login}`)); - } - return mapScapiUser(data); } async createOrReplaceUser(login: string, input: CreateUserInput): Promise { @@ -138,9 +141,12 @@ export class ScapiUsersBackend implements UsersBackend { preferredUiLocale: changes.preferredUiLocale, }; if (changes.disabled !== undefined) { - throw new Error( + // Recognized as a fallback trigger by the SCAPI/OCAPI fallback wrapper: + // in `auto` mode this transparently routes the update through OCAPI; + // in explicit `scapi` mode the message surfaces to the user. + throw new ScapiCapabilityUnsupportedError( 'SCAPI Users API does not support updating the `disabled` flag via PATCH. ' + - 'Use --api-backend ocapi to change disabled status.', + 'Use --api-backend ocapi (or auto) to change disabled status.', ); } const {data, error} = await client.PATCH('/organizations/{organizationId}/users/{login}', { diff --git a/packages/b2c-tooling-sdk/src/operations/code/scapi-scripts-backend.ts b/packages/b2c-tooling-sdk/src/operations/code/scapi-scripts-backend.ts index 05f303a8f..ad3b781f4 100644 --- a/packages/b2c-tooling-sdk/src/operations/code/scapi-scripts-backend.ts +++ b/packages/b2c-tooling-sdk/src/operations/code/scapi-scripts-backend.ts @@ -57,15 +57,16 @@ export class ScapiScriptsBackend implements ScriptsBackend { } async listCodeVersions(): Promise { - const client = this.scopeTier.getClientForRead(); - const {data, error} = await client.GET('/organizations/{organizationId}/code-versions', { - params: {path: {organizationId: this.organizationId}}, + return this.scopeTier.tryRead(async (client) => { + const {data, error} = await client.GET('/organizations/{organizationId}/code-versions', { + params: {path: {organizationId: this.organizationId}}, + }); + if (error || !data) { + throw new Error(toErrorMessage(error, 'Failed to list code versions')); + } + const result = data as unknown as {data?: ScapiCodeVersion[]}; + return (result.data ?? []).map(mapScapiCodeVersion); }); - if (error || !data) { - throw new Error(toErrorMessage(error, 'Failed to list code versions')); - } - const result = data as unknown as {data?: ScapiCodeVersion[]}; - return (result.data ?? []).map(mapScapiCodeVersion); } async getActiveCodeVersion(): Promise { diff --git a/packages/b2c-tooling-sdk/src/operations/jobs/scapi-ops.ts b/packages/b2c-tooling-sdk/src/operations/jobs/scapi-ops.ts index 608eb31e5..98e82fb28 100644 --- a/packages/b2c-tooling-sdk/src/operations/jobs/scapi-ops.ts +++ b/packages/b2c-tooling-sdk/src/operations/jobs/scapi-ops.ts @@ -161,6 +161,11 @@ export async function searchJobExecutions( const organizationId = toOrganizationId(options.tenantId); const {jobId, status, count = 25, start = 0, sortBy = 'start_time', sortOrder = 'desc'} = options; + // The SCAPI search DSL uses camelCase wrapper names (`termQuery`, `boolQuery`), + // but the underlying searchable/sortable field names on this endpoint are + // the legacy OCAPI snake_case identifiers (`job_id`, `status`, `start_time`). + // Don't rename these to camelCase to "match the response schema" — the + // request side wouldn't match anything. See scapi-ops.test.ts. const queries: unknown[] = []; if (jobId) { queries.push({termQuery: {fields: ['job_id'], operator: 'is', values: [jobId]}}); diff --git a/packages/b2c-tooling-sdk/test/clients/scapi-fallback-backend.test.ts b/packages/b2c-tooling-sdk/test/clients/scapi-fallback-backend.test.ts index 7fdec11a9..a17a93126 100644 --- a/packages/b2c-tooling-sdk/test/clients/scapi-fallback-backend.test.ts +++ b/packages/b2c-tooling-sdk/test/clients/scapi-fallback-backend.test.ts @@ -5,6 +5,7 @@ */ import {expect} from 'chai'; import {createFallbackBackend} from '../../src/clients/scapi-fallback-backend.js'; +import {ScapiCapabilityUnsupportedError} from '../../src/clients/scapi-backend-utils.js'; interface TestBackend { readonly name: 'ocapi' | 'scapi'; @@ -123,6 +124,39 @@ describe('createFallbackBackend', () => { }); }); + describe('fallback after SCAPI was pinned by a prior success', () => { + it('falls back to OCAPI when a later SCAPI call hits a capability gap', async () => { + // Simulates: read succeeds under SCAPI → wrapper pins SCAPI → write + // fails because the scope tier downgraded to read-only → wrapper must + // still route the write through OCAPI rather than propagating. + let scapiWriteCalls = 0; + let ocapiWriteCalls = 0; + const scapi = makeBackend('scapi', { + doRead: async () => 'scapi-read', + doWrite: async () => { + scapiWriteCalls++; + throw new ScapiCapabilityUnsupportedError('downgraded to read-only'); + }, + }); + const ocapi = makeBackend('ocapi', { + doWrite: async () => { + ocapiWriteCalls++; + }, + }); + + const backend = createFallbackBackend(scapi, ocapi, 'test'); + // First call resolves to SCAPI. + expect(await backend.doRead()).to.equal('scapi-read'); + expect(backend.name).to.equal('scapi'); + + // Write hits a capability gap under SCAPI; wrapper routes to OCAPI. + await backend.doWrite('payload'); + expect(scapiWriteCalls).to.equal(1); + expect(ocapiWriteCalls).to.equal(1); + expect(backend.name).to.equal('ocapi'); + }); + }); + describe('non-fallback errors', () => { it('rethrows non-invalid_scope errors without falling back', async () => { const scapi = makeBackend('scapi', { diff --git a/packages/b2c-tooling-sdk/test/operations/jobs/scapi-ops.test.ts b/packages/b2c-tooling-sdk/test/operations/jobs/scapi-ops.test.ts new file mode 100644 index 000000000..46d0a23c8 --- /dev/null +++ b/packages/b2c-tooling-sdk/test/operations/jobs/scapi-ops.test.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 {expect} from 'chai'; +import {searchJobExecutions} from '../../../src/operations/jobs/scapi-ops.js'; +import type {ScapiJobsClient} from '../../../src/clients/scapi-jobs.js'; + +interface CapturedRequest { + path: string; + init: {body?: unknown; params?: unknown; headers?: Record}; +} + +function makeFakeClient(captured: CapturedRequest[], data: unknown): ScapiJobsClient { + return { + async POST(path: string, init: unknown) { + const typed = init as CapturedRequest['init']; + captured.push({path, init: typed}); + return {data, error: undefined, response: new Response('{}', {status: 200})}; + }, + async GET() { + return {data: undefined, error: undefined, response: new Response('{}', {status: 200})}; + }, + } as unknown as ScapiJobsClient; +} + +describe('operations/jobs/scapi-ops', () => { + describe('searchJobExecutions', () => { + // The SCAPI Job-Executions search endpoint reuses the OCAPI-style query + // DSL: term-query `fields` and the `sorts[].field` use the legacy + // snake_case names (`job_id`, `start_time`), even though the response + // schema returns camelCase (`jobId`, `startTime`). This test pins that + // contract so an accidental rename to camelCase doesn't break searches + // against the real API. + it('pins SCAPI search request body to OCAPI-style search field names', async () => { + const captured: CapturedRequest[] = []; + const client = makeFakeClient(captured, {total: 0, limit: 25, offset: 0, hits: []}); + + await searchJobExecutions(client, { + tenantId: 'zzxy_dev', + jobId: 'my-job', + status: ['RUNNING', 'PENDING'], + sortBy: 'start_time', + sortOrder: 'desc', + }); + + expect(captured).to.have.length(1); + const body = captured[0].init.body as { + query: {boolQuery: {must: Array<{termQuery: {fields: string[]; values: string[]}}>}}; + sorts: Array<{field: string; sortOrder: string}>; + limit: number; + offset: number; + }; + + // Search-field names are still snake_case on this endpoint. + expect(body.query.boolQuery.must[0].termQuery.fields).to.deep.equal(['job_id']); + expect(body.query.boolQuery.must[0].termQuery.values).to.deep.equal(['my-job']); + expect(body.sorts[0].field).to.equal('start_time'); + expect(body.sorts[0].sortOrder).to.equal('desc'); + }); + + it('uses matchAllQuery when no filters are provided', async () => { + const captured: CapturedRequest[] = []; + const client = makeFakeClient(captured, {total: 0, limit: 25, offset: 0, hits: []}); + + await searchJobExecutions(client, {tenantId: 'zzxy_dev'}); + + const body = captured[0].init.body as {query: {matchAllQuery: Record}}; + expect(body.query).to.deep.equal({matchAllQuery: {}}); + }); + + it('attaches the read scope-mode header', async () => { + const captured: CapturedRequest[] = []; + const client = makeFakeClient(captured, {total: 0, limit: 25, offset: 0, hits: []}); + + await searchJobExecutions(client, {tenantId: 'zzxy_dev'}); + + expect(captured[0].init.headers?.['x-b2c-scope-mode']).to.equal('read'); + }); + }); +}); diff --git a/packages/b2c-vs-extension/src/code-sync/cartridge-commands.ts b/packages/b2c-vs-extension/src/code-sync/cartridge-commands.ts index 2453236cc..a1c382fce 100644 --- a/packages/b2c-vs-extension/src/code-sync/cartridge-commands.ts +++ b/packages/b2c-vs-extension/src/code-sync/cartridge-commands.ts @@ -3,16 +3,8 @@ * 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 { - downloadSingleCartridge, - listCodeVersions, - getActiveCodeVersion, - activateCodeVersion, - createCodeVersion, - reloadCodeVersion, - deleteCodeVersion, - OcapiScriptsBackend, -} from '@salesforce/b2c-tooling-sdk/operations/code'; +import {downloadSingleCartridge, reloadCodeVersion} from '@salesforce/b2c-tooling-sdk/operations/code'; +import {createScriptsBackendFromExtension} from './scripts-backend.js'; import { addCartridge, removeCartridge, @@ -58,7 +50,7 @@ function createDownloadCartridgeCommand( let codeVersion = instance.config.codeVersion; if (!codeVersion) { try { - const active = await getActiveCodeVersion(instance); + const active = await createScriptsBackendFromExtension(configProvider, instance).getActiveCodeVersion(); if (active?.id) codeVersion = active.id; } catch { // fall through @@ -249,7 +241,8 @@ function createListCodeVersionsCommand( if (!instance) return; try { - const versions = await listCodeVersions(instance); + const scriptsBackend = createScriptsBackendFromExtension(configProvider, instance); + const versions = await scriptsBackend.listCodeVersions(); const items = versions.map((v) => ({ label: `${v.active ? '$(star-full) ' : ''}${v.id ?? 'unknown'}`, description: v.active ? 'Active' : '', @@ -282,7 +275,7 @@ function createListCodeVersionsCommand( await vscode.window.withProgress( {location: vscode.ProgressLocation.Notification, title: `Activating "${versionId}"...`}, async () => { - await activateCodeVersion(instance, versionId); + await scriptsBackend.activateCodeVersion(versionId); treeView.description = `v: ${versionId}`; }, ); @@ -290,7 +283,7 @@ function createListCodeVersionsCommand( } else if (actionPick.action === 'reload') { await vscode.window.withProgress( {location: vscode.ProgressLocation.Notification, title: `Reloading "${versionId}"...`}, - () => reloadCodeVersion(new OcapiScriptsBackend(instance), versionId), + () => reloadCodeVersion(scriptsBackend, versionId), ); vscode.window.showInformationMessage(`B2C DX: Code version "${versionId}" reloaded.`); } else if (actionPick.action === 'delete') { @@ -302,7 +295,7 @@ function createListCodeVersionsCommand( if (confirm === 'Delete') { await vscode.window.withProgress( {location: vscode.ProgressLocation.Notification, title: `Deleting "${versionId}"...`}, - () => deleteCodeVersion(instance, versionId), + () => scriptsBackend.deleteCodeVersion(versionId), ); vscode.window.showInformationMessage(`B2C DX: Code version "${versionId}" deleted.`); } @@ -330,7 +323,7 @@ function createCreateCodeVersionCommand( if (!name) return; try { - await createCodeVersion(instance, name.trim()); + await createScriptsBackendFromExtension(configProvider, instance).createCodeVersion(name.trim()); outputChannel.appendLine(`[Code Version] Created "${name.trim()}"`); vscode.window.showInformationMessage(`B2C DX: Code version "${name.trim()}" created.`); treeProvider.refresh(); @@ -350,7 +343,8 @@ function createActivateCodeVersionCommand( if (!instance) return; try { - const versions = await listCodeVersions(instance); + const scriptsBackend = createScriptsBackendFromExtension(configProvider, instance); + const versions = await scriptsBackend.listCodeVersions(); const items = versions.map((v) => ({ label: v.id ?? 'unknown', description: v.active ? '$(star-full) Active' : '', @@ -366,7 +360,7 @@ function createActivateCodeVersionCommand( await vscode.window.withProgress( {location: vscode.ProgressLocation.Notification, title: `Activating "${picked.version.id}"...`}, async () => { - await activateCodeVersion(instance, picked.version.id!); + await scriptsBackend.activateCodeVersion(picked.version.id!); treeView.description = `v: ${picked.version.id}`; }, ); @@ -396,13 +390,13 @@ export async function updateCodeVersionDisplay( return; } - // Fall back to OCAPI discovery if available + // Fall back to backend discovery (SCAPI or OCAPI based on apiBackend) if available if (!instance) { treeView.description = ''; return; } try { - const active = await getActiveCodeVersion(instance); + const active = await createScriptsBackendFromExtension(configProvider, instance).getActiveCodeVersion(); treeView.description = active?.id ? `v: ${active.id}` : ''; } catch { treeView.description = ''; diff --git a/packages/b2c-vs-extension/src/code-sync/code-sync-manager.ts b/packages/b2c-vs-extension/src/code-sync/code-sync-manager.ts index 408477ad5..abe9c3598 100644 --- a/packages/b2c-vs-extension/src/code-sync/code-sync-manager.ts +++ b/packages/b2c-vs-extension/src/code-sync/code-sync-manager.ts @@ -8,13 +8,14 @@ import { uploadFiles, fileToCartridgePath, uploadCartridges, - getActiveCodeVersion, type CartridgeMapping, type FileChange, } from '@salesforce/b2c-tooling-sdk/operations/code'; import type {B2CInstance} from '@salesforce/b2c-tooling-sdk/instance'; import * as path from 'path'; import * as vscode from 'vscode'; +import type {B2CExtensionConfig} from '../config-provider.js'; +import {createScriptsBackendFromExtension} from './scripts-backend.js'; const DEBOUNCE_MS = 150; const ERROR_RATE_LIMIT_MS = 5000; @@ -36,7 +37,10 @@ export class CodeSyncManager implements vscode.Disposable { private isProcessing = false; private lastErrorTime = 0; - constructor(private readonly workspaceState: vscode.Memento) { + constructor( + private readonly workspaceState: vscode.Memento, + private readonly configProvider: B2CExtensionConfig, + ) { this.outputChannel = vscode.window.createOutputChannel('B2C Code Upload'); this.statusBar = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 50); this.updateStatusBar(); @@ -69,7 +73,7 @@ export class CodeSyncManager implements vscode.Disposable { this.codeVersion = instance.config.codeVersion; if (!this.codeVersion) { try { - const active = await getActiveCodeVersion(instance); + const active = await createScriptsBackendFromExtension(this.configProvider, instance).getActiveCodeVersion(); if (active?.id) { this.codeVersion = active.id; instance.config.codeVersion = this.codeVersion; @@ -190,7 +194,7 @@ export class CodeSyncManager implements vscode.Disposable { let codeVersion = instance.config.codeVersion; if (!codeVersion) { try { - const active = await getActiveCodeVersion(instance); + const active = await createScriptsBackendFromExtension(this.configProvider, instance).getActiveCodeVersion(); if (active?.id) { codeVersion = active.id; instance.config.codeVersion = codeVersion; @@ -229,7 +233,7 @@ export class CodeSyncManager implements vscode.Disposable { let codeVersion = instance.config.codeVersion; if (!codeVersion) { try { - const active = await getActiveCodeVersion(instance); + const active = await createScriptsBackendFromExtension(this.configProvider, instance).getActiveCodeVersion(); if (active?.id) { codeVersion = active.id; instance.config.codeVersion = codeVersion; diff --git a/packages/b2c-vs-extension/src/code-sync/deploy-command.ts b/packages/b2c-vs-extension/src/code-sync/deploy-command.ts index 9bca0ad1e..92a4528bd 100644 --- a/packages/b2c-vs-extension/src/code-sync/deploy-command.ts +++ b/packages/b2c-vs-extension/src/code-sync/deploy-command.ts @@ -7,13 +7,11 @@ import { findCartridges, uploadCartridges, deleteCartridges, - getActiveCodeVersion, - activateCodeVersion, reloadCodeVersion, - OcapiScriptsBackend, } from '@salesforce/b2c-tooling-sdk/operations/code'; import * as vscode from 'vscode'; import type {B2CExtensionConfig} from '../config-provider.js'; +import {createScriptsBackendFromExtension} from './scripts-backend.js'; export function createDeployCommand( configProvider: B2CExtensionConfig, @@ -26,11 +24,12 @@ export function createDeployCommand( return; } - // Resolve code version + // Resolve code version through the configured Scripts backend (SCAPI or OCAPI) + const scriptsBackend = createScriptsBackendFromExtension(configProvider, instance); let codeVersion = instance.config.codeVersion; if (!codeVersion) { try { - const active = await getActiveCodeVersion(instance); + const active = await scriptsBackend.getActiveCodeVersion(); if (active?.id) { codeVersion = active.id; instance.config.codeVersion = codeVersion; @@ -95,11 +94,11 @@ export function createDeployCommand( if (actionPick.action === 'activate') { progress.report({message: 'Activating code version...'}); - await activateCodeVersion(instance, codeVersion); + await scriptsBackend.activateCodeVersion(codeVersion); outputChannel.appendLine(`Code version "${codeVersion}" activated`); } else if (actionPick.action === 'reload') { progress.report({message: 'Reloading code version...'}); - await reloadCodeVersion(new OcapiScriptsBackend(instance), codeVersion); + await reloadCodeVersion(scriptsBackend, codeVersion); outputChannel.appendLine(`Code version "${codeVersion}" reloaded`); } @@ -135,7 +134,7 @@ export function createDeleteAndDeployCommand( let codeVersion = instance.config.codeVersion; if (!codeVersion) { try { - const active = await getActiveCodeVersion(instance); + const active = await createScriptsBackendFromExtension(configProvider, instance).getActiveCodeVersion(); if (active?.id) { codeVersion = active.id; instance.config.codeVersion = codeVersion; diff --git a/packages/b2c-vs-extension/src/code-sync/index.ts b/packages/b2c-vs-extension/src/code-sync/index.ts index 9a0bd65d2..df74b45a6 100644 --- a/packages/b2c-vs-extension/src/code-sync/index.ts +++ b/packages/b2c-vs-extension/src/code-sync/index.ts @@ -16,7 +16,7 @@ export function registerCodeSync( configProvider: B2CExtensionConfig, log: vscode.OutputChannel, ): void { - const manager = new CodeSyncManager(context.workspaceState); + const manager = new CodeSyncManager(context.workspaceState, configProvider); const treeProvider = new CartridgeTreeProvider(configProvider); const treeView = vscode.window.createTreeView('b2cCartridgeExplorer', {treeDataProvider: treeProvider}); diff --git a/packages/b2c-vs-extension/src/code-sync/scripts-backend.ts b/packages/b2c-vs-extension/src/code-sync/scripts-backend.ts new file mode 100644 index 000000000..4122d88e2 --- /dev/null +++ b/packages/b2c-vs-extension/src/code-sync/scripts-backend.ts @@ -0,0 +1,32 @@ +/* + * 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 + */ +/** + * Builds a Scripts (code-version) backend that honors the configured + * `apiBackend` preference, mirroring how `CodeCommand` does it in the CLI. + * In `auto` mode this lets SCAPI-only setups manage code versions through + * `sfcc.scripts(.rw)` instead of OCAPI, with transparent OCAPI fallback on + * `invalid_scope`. + */ +import {createScriptsBackend, type ScriptsBackend} from '@salesforce/b2c-tooling-sdk/operations/code'; +import type {B2CInstance} from '@salesforce/b2c-tooling-sdk/instance'; +import type {B2CExtensionConfig} from '../config-provider.js'; + +export function createScriptsBackendFromExtension( + configProvider: B2CExtensionConfig, + instance: B2CInstance, +): ScriptsBackend { + const resolved = configProvider.getConfig(); + const preference = resolved?.values.apiBackend ?? 'auto'; + const auth = resolved?.hasOAuthConfig() ? resolved.createOAuth() : undefined; + + return createScriptsBackend({ + preference, + instance, + shortCode: resolved?.values.shortCode, + tenantId: resolved?.values.tenantId, + auth, + }); +} diff --git a/skills/b2c-cli/skills/b2c-code/SKILL.md b/skills/b2c-cli/skills/b2c-code/SKILL.md index f4fe4c1f2..10dd30fda 100644 --- a/skills/b2c-cli/skills/b2c-code/SKILL.md +++ b/skills/b2c-cli/skills/b2c-code/SKILL.md @@ -108,17 +108,17 @@ b2c code delete ### API Backend Selection -`code list`, `code activate`, and `code delete` support both OCAPI and SCAPI. Auto mode (default) prefers SCAPI when `shortCode` and `tenantId` are configured. +`code list`, `code activate`, `code delete`, and the active-version discovery / activate / reload steps in `code deploy` all honor `--api-backend`. Auto mode (the default) prefers SCAPI when `shortCode` and `tenantId` are configured. ```bash -# force SCAPI (requires sfcc.scripts.rw scope) +# force SCAPI (requires sfcc.scripts or sfcc.scripts.rw scope) b2c code list --api-backend scapi # force OCAPI b2c code list --api-backend ocapi ``` -`code activate --reload` always uses OCAPI (no SCAPI cache-rebuild equivalent). `code deploy`, `code download`, `code watch` always use WebDAV. +Read scopes (`sfcc.scripts`) cover `code list` / discovery; write scopes (`sfcc.scripts.rw`) cover activate, delete, reload, and the `--activate` / `--reload` flags on deploy. `code reload` is implemented backend-agnostically as activate(alternate) + activate(target), so it works under either OCAPI or SCAPI. `code deploy` (file upload itself), `code download`, and `code watch` always use WebDAV — only the surrounding code-version operations route through `apiBackend`. ### More Commands From 5c24605fa2e4ac9a4a405d029aff817dff9c3efb Mon Sep 17 00:00:00 2001 From: Charles Lavery Date: Mon, 22 Jun 2026 14:34:27 -0400 Subject: [PATCH 13/22] Detect deprecated OCAPI instances; redact tokens; SCAPI-first docs OCAPI is deprecated and disabled on newer instances; the CLI previously surfaced its 403 OcapiDeprecatedException as an opaque 'Failed to ...' error with no guidance. Add a central detector + actionable error in error-utils (isOcapiDeprecatedFault, OcapiDeprecatedError, throwOcapiError) and route every OCAPI-terminal site through it (code versions, jobs run/site-archive, cap install/uninstall/list, scaffold, sites list). Also fixes a credential leak: B2C embeds the bearer JWT in some auth faults (InvalidAccessTokenException); getApiErrorMessage now redacts tokens from every user-facing message. Rewrite code/job/bm docs and agent skills SCAPI-first, presenting OCAPI as the deprecated fallback rather than the assumed baseline. Also fixes a pre-existing prettier error in b2c-dx-mcp diagnostics. --- ...i-ocapi-deprecation-and-token-redaction.md | 10 ++ docs/cli/auth.md | 3 +- docs/cli/bm.md | 41 +++--- docs/cli/code.md | 33 +++-- docs/cli/jobs.md | 29 ++--- docs/guide/authentication.md | 24 ++-- docs/guide/configuration.md | 4 +- packages/b2c-cli/src/commands/sites/list.ts | 9 +- .../b2c-dx-mcp/src/tools/diagnostics/index.ts | 5 +- .../src/clients/error-utils.ts | 122 +++++++++++++++++- packages/b2c-tooling-sdk/src/clients/index.ts | 9 +- packages/b2c-tooling-sdk/src/index.ts | 5 + .../src/operations/cap/install.ts | 7 +- .../src/operations/cap/list.ts | 4 +- .../src/operations/cap/uninstall.ts | 7 +- .../src/operations/code/versions.ts | 17 +-- .../src/operations/jobs/run.ts | 16 ++- .../src/operations/jobs/site-archive.ts | 15 ++- .../b2c-tooling-sdk/src/scaffold/sources.ts | 4 +- .../test/clients/error-utils.test.ts | 108 +++++++++++++++- .../skills/b2c-bm-users-roles/SKILL.md | 16 +-- skills/b2c-cli/skills/b2c-code/SKILL.md | 14 +- skills/b2c-cli/skills/b2c-job/SKILL.md | 21 +-- 23 files changed, 388 insertions(+), 135 deletions(-) create mode 100644 .changeset/scapi-ocapi-deprecation-and-token-redaction.md diff --git a/.changeset/scapi-ocapi-deprecation-and-token-redaction.md b/.changeset/scapi-ocapi-deprecation-and-token-redaction.md new file mode 100644 index 000000000..29305e738 --- /dev/null +++ b/.changeset/scapi-ocapi-deprecation-and-token-redaction.md @@ -0,0 +1,10 @@ +--- +'@salesforce/b2c-tooling-sdk': patch +'@salesforce/b2c-cli': patch +'@salesforce/b2c-dx-docs': patch +'@salesforce/b2c-agent-plugins': patch +--- + +Detect deprecated OCAPI instances and stop leaking access tokens in error messages. + +When an instance has OCAPI disabled, commands now fail with an actionable message directing you to configure SCAPI access, instead of an opaque "Failed to ..." error. Bearer tokens that B2C Commerce embeds in some authentication faults are now redacted from user-facing error messages (they were previously printed in full). Documentation and agent skills for `code`, `job`, and `bm` are now SCAPI-first, presenting OCAPI as the deprecated fallback. diff --git a/docs/cli/auth.md b/docs/cli/auth.md index 2ad7847ae..4f5dfb7dd 100644 --- a/docs/cli/auth.md +++ b/docs/cli/auth.md @@ -272,7 +272,8 @@ For complete authentication setup instructions, see the [Authentication Setup Gu | Operation | Auth Required | |-----------|--------------| | [Code](/cli/code) deploy/watch | WebDAV credentials | -| [Code](/cli/code) list/activate/delete, [Jobs](/cli/jobs), [Sites](/cli/sites) | OAuth + OCAPI configuration | +| [Code](/cli/code) list/activate/delete, [Jobs](/cli/jobs), [BM](/cli/bm) users/roles | OAuth + SCAPI scopes (OCAPI fallback; OCAPI is [deprecated](/guide/authentication#ocapi-configuration)) | +| [Sites](/cli/sites) | OAuth + OCAPI configuration | | SCAPI commands ([eCDN](/cli/ecdn), [schemas](/cli/scapi-schemas), [custom-apis](/cli/custom-apis)) | OAuth + SCAPI scopes | | [Sandbox](/cli/sandbox), [SLAS](/cli/slas) | OAuth + appropriate roles | | [MRT](/cli/mrt) | API Key | diff --git a/docs/cli/bm.md b/docs/cli/bm.md index e8c48b46a..605fedc91 100644 --- a/docs/cli/bm.md +++ b/docs/cli/bm.md @@ -8,30 +8,37 @@ Commands for administering instance-level Business Manager resources. These are ## API Backend -Most `bm users` and `bm roles` commands support both the OCAPI Data API and the SCAPI Merchant Users / Merchant Roles APIs. By default (`auto` mode), SCAPI is preferred when `shortCode` and `tenantId` are configured. If the SCAPI scopes aren't granted on your API client, the CLI silently falls back to OCAPI. +`bm users` and `bm roles` run over the SCAPI Merchant Users / Merchant Roles APIs. Configure `shortCode`, `tenantId`, and the `sfcc.users(.rw)` / `sfcc.roles(.rw)` scopes on your API client and these commands work over SCAPI. A few commands have no SCAPI equivalent and use the OCAPI Data API (see the table below). ```bash -# Force SCAPI backend -b2c bm users list --api-backend scapi - -# Force OCAPI backend -b2c bm roles get Administrator --api-backend ocapi +# Default — uses SCAPI for users/roles +b2c bm users list +b2c bm roles get Administrator ``` -Or set in `dw.json`: `"api-backend": "scapi"`. Or `SFCC_API_BACKEND=scapi` env var. - -| Command | SCAPI | OCAPI | +| Command | Backend | Scope | |---|---|---| -| `bm users list/get/update/delete` | ✓ (`sfcc.users.rw`) | ✓ | -| `bm users search` | ✗ — OCAPI only | ✓ | -| `bm whoami` | ✗ — OCAPI only | ✓ | -| `bm access-key *` | ✗ — OCAPI only | ✓ | -| `bm roles list/get/create/delete` | ✓ (`sfcc.roles.rw`) | ✓ | -| `bm roles grant/revoke` | ✓ (`sfcc.roles.rw`) | ✓ | -| `bm roles permissions get/set` | ✓ (`sfcc.roles.rw`) | ✓ | +| `bm users list/get/update/delete` | SCAPI | `sfcc.users.rw` | +| `bm roles list/get/create/delete` | SCAPI | `sfcc.roles.rw` | +| `bm roles grant/revoke` | SCAPI | `sfcc.roles.rw` | +| `bm roles permissions get/set` | SCAPI | `sfcc.roles.rw` | +| `bm users search` | OCAPI only | — | +| `bm whoami` | OCAPI only | — | +| `bm access-key *` | OCAPI only | — | + +::: details Legacy OCAPI backend (deprecated) +OCAPI is deprecated and disabled on newer instances. The CLI defaults to `--api-backend auto`, which falls back to the OCAPI Data API only when SCAPI scopes are not configured. Force a backend if needed: + +```bash +b2c bm users list --api-backend scapi # force SCAPI +b2c bm roles get Administrator --api-backend ocapi # force the legacy OCAPI backend +``` + +Or set `"api-backend": "scapi"` in `dw.json`, or `SFCC_API_BACKEND=scapi`. The OCAPI-only commands (`bm users search`, `bm whoami`, `bm access-key`) are unavailable on OCAPI-disabled instances. +::: ::: warning -The SCAPI Users PATCH endpoint does not support changing the `disabled` flag. `bm users update --disabled` falls back to OCAPI in auto mode; with `--api-backend scapi` it errors with a clear message. +The SCAPI Users PATCH endpoint does not support changing the `disabled` flag. `bm users update --disabled` falls back to OCAPI in auto mode (unavailable on OCAPI-disabled instances); with `--api-backend scapi` it errors with a clear message. ::: ## Authentication diff --git a/docs/cli/code.md b/docs/cli/code.md index 3ac210ea3..a2e628540 100644 --- a/docs/cli/code.md +++ b/docs/cli/code.md @@ -8,20 +8,24 @@ Commands for managing cartridge code on B2C Commerce instances. ## API Backend -The `code list`, `code activate`, and `code delete` commands support both OCAPI and SCAPI backends. By default (`auto` mode), SCAPI is preferred when `shortCode` and `tenantId` are configured. If SCAPI scopes are unavailable, the CLI falls back to OCAPI transparently. +`code list`, `code activate`, and `code delete` run over SCAPI (the `dx/scripts` API). Configure `shortCode`, `tenantId`, and the `sfcc.scripts` / `sfcc.scripts.rw` scopes on your API client and these commands work out of the box. ```bash -# Force SCAPI -b2c code list --api-backend scapi +# Default — uses SCAPI +b2c code list +``` + +::: details Legacy OCAPI backend (deprecated) +OCAPI is deprecated and disabled on newer instances. The CLI defaults to `--api-backend auto`, which falls back to the OCAPI Data API (`/code_versions`) only when SCAPI scopes are not configured. You can force a backend if needed: -# Force OCAPI -b2c code list --api-backend ocapi +```bash +b2c code list --api-backend scapi # force SCAPI +b2c code list --api-backend ocapi # force the legacy OCAPI backend ``` -Or set in `dw.json`: `"api-backend": "scapi"`. Or `SFCC_API_BACKEND=scapi` env var. +Or set `"api-backend": "scapi"` in `dw.json`, or `SFCC_API_BACKEND=scapi`. -::: tip -The `code activate --reload` flag forces an OCAPI call regardless of `--api-backend`, since SCAPI does not expose the cache-rebuild operation. +The `code activate --reload` flag uses an OCAPI call regardless of `--api-backend`, since SCAPI does not expose the cache-rebuild operation. On OCAPI-disabled instances, `--reload` is unavailable. ::: ::: tip @@ -35,8 +39,7 @@ Code commands use different authentication depending on the operation: | Operation | Auth Required | |-----------|--------------| | `code deploy`, `code download`, `code watch` | WebDAV (Basic Auth or OAuth) | -| `code list`, `code activate`, `code delete` (SCAPI) | OAuth + `sfcc.scripts` (read) or `sfcc.scripts.rw` (write) + tenant scope | -| `code list`, `code activate`, `code delete` (OCAPI) | OAuth + OCAPI permissions for `/code_versions` | +| `code list`, `code activate`, `code delete` | OAuth + `sfcc.scripts` (read) or `sfcc.scripts.rw` (write) + tenant scope | ### WebDAV Operations (deploy, download, watch) @@ -47,16 +50,18 @@ export SFCC_USERNAME=your-bm-username export SFCC_PASSWORD=your-webdav-access-key ``` -### SCAPI / OCAPI Operations (list, activate, delete) +### Code Version Operations (list, activate, delete) -These commands require OAuth authentication. For SCAPI, configure the `sfcc.scripts.rw` scope on your API client in Account Manager. For OCAPI, configure permissions for the `/code_versions` resource in Business Manager. +These commands require OAuth authentication. Configure the `sfcc.scripts` / `sfcc.scripts.rw` scope on your API client in Account Manager, along with `shortCode` and `tenantId`. ```bash export SFCC_CLIENT_ID=your-client-id export SFCC_CLIENT_SECRET=your-client-secret +export SFCC_TENANT_ID=zzxy_prd +export SFCC_SHORTCODE=kv7kzm78 ``` -For complete setup instructions, see the [Authentication Guide](/guide/authentication). +On instances where OCAPI is still enabled, these commands also work with OCAPI `/code_versions` permissions as a [deprecated fallback](/guide/authentication#ocapi-configuration). For complete setup instructions, see the [Authentication Guide](/guide/authentication). --- @@ -253,7 +258,7 @@ If a cartridge exists remotely but not locally, it is extracted to the output di ### Notes -- If no `--code-version` is specified, the command auto-discovers the active code version via OCAPI (requires OAuth credentials) +- If no `--code-version` is specified, the command auto-discovers the active code version (requires OAuth credentials) - Existing file permissions are preserved when overwriting files - The server-side zip is cleaned up automatically after download diff --git a/docs/cli/jobs.md b/docs/cli/jobs.md index de1f7464d..85f2c5078 100644 --- a/docs/cli/jobs.md +++ b/docs/cli/jobs.md @@ -8,33 +8,26 @@ Commands for executing and monitoring jobs on B2C Commerce instances. ## API Backend -Job commands support both OCAPI and SCAPI backends. By default (`auto` mode), SCAPI is preferred when `shortCode` and `tenantId` are configured. If SCAPI scopes are unavailable, the CLI falls back to OCAPI transparently. - -Use `--api-backend` to control explicitly: +Job commands run over SCAPI (the `operation/jobs` API). Configure `shortCode`, `tenantId`, and the `sfcc.jobs` / `sfcc.jobs.rw` scopes on your API client and `job run`, `job search`, `job wait`, and `job log` work out of the box. ```bash -# Force SCAPI -b2c job run my-job --api-backend scapi - -# Force OCAPI -b2c job run my-job --api-backend ocapi - -# Auto-detect (default) -b2c job run my-job --api-backend auto +# Default — uses SCAPI +b2c job run my-job ``` -Or set in `dw.json`: +::: details Legacy OCAPI backend (deprecated) +OCAPI is deprecated and disabled on newer instances. The CLI defaults to `--api-backend auto`, which falls back to the OCAPI Data API only when SCAPI scopes are not configured. Force a backend if needed: -```json -{ - "api-backend": "scapi" -} +```bash +b2c job run my-job --api-backend scapi # force SCAPI +b2c job run my-job --api-backend ocapi # force the legacy OCAPI backend ``` -Or via environment variable: `SFCC_API_BACKEND=scapi`. +Or set `"api-backend": "scapi"` in `dw.json`, or `SFCC_API_BACKEND=scapi`. +::: ::: tip -The `job import` and `job export` commands currently use OCAPI only, regardless of the `--api-backend` setting. +The `job import` and `job export` commands trigger the `sfcc-site-archive-import`/`-export` system jobs and transfer files over WebDAV. The job-execution trigger currently uses OCAPI; on OCAPI-disabled instances these subcommands are not yet available over SCAPI. ::: ## Authentication diff --git a/docs/guide/authentication.md b/docs/guide/authentication.md index 7a5cc976f..583599ba2 100644 --- a/docs/guide/authentication.md +++ b/docs/guide/authentication.md @@ -1,5 +1,5 @@ --- -description: Set up authentication for the B2C CLI including Account Manager API clients, OCAPI permissions, and WebDAV access keys. +description: Set up authentication for the B2C CLI including Account Manager API clients, SCAPI scopes, OCAPI permissions, and WebDAV access keys. --- # Authentication Setup @@ -13,9 +13,9 @@ The CLI uses different authentication mechanisms depending on the operation: | Operation | Auth Method | Setup Required | | -------------------------------------------------------------------------------------------------- | ---------------------------- | ---------------------------------------------------------------------------------------- | | [Code](/cli/code) deploy, watch (file upload) | WebDAV (Basic Auth or OAuth) | [WebDAV Access](#webdav-access) | -| [Code](/cli/code) list, activate, delete | OAuth + OCAPI **or** SCAPI (`sfcc.scripts` / `sfcc.scripts.rw`) | [API Client](#account-manager-api-client) + [OCAPI](#ocapi-configuration) or [SCAPI Scopes](#scapi-authentication) | -| [Jobs](/cli/jobs) | OAuth + OCAPI **or** SCAPI (`sfcc.jobs` / `sfcc.jobs.rw`) | [API Client](#account-manager-api-client) + [OCAPI](#ocapi-configuration) or [SCAPI Scopes](#scapi-authentication) | -| [BM users / roles](/cli/bm) | OAuth + OCAPI **or** SCAPI (`sfcc.users(.rw)` / `sfcc.roles(.rw)`) | [API Client](#account-manager-api-client) + [OCAPI](#ocapi-configuration) or [SCAPI Scopes](#scapi-authentication) | +| [Code](/cli/code) list, activate, delete | OAuth + SCAPI (`sfcc.scripts` / `sfcc.scripts.rw`), OCAPI fallback | [API Client](#account-manager-api-client) + [SCAPI Scopes](#scapi-authentication) (or [OCAPI](#ocapi-configuration)) | +| [Jobs](/cli/jobs) | OAuth + SCAPI (`sfcc.jobs` / `sfcc.jobs.rw`), OCAPI fallback | [API Client](#account-manager-api-client) + [SCAPI Scopes](#scapi-authentication) (or [OCAPI](#ocapi-configuration)) | +| [BM users / roles](/cli/bm) | OAuth + SCAPI (`sfcc.users(.rw)` / `sfcc.roles(.rw)`), OCAPI fallback | [API Client](#account-manager-api-client) + [SCAPI Scopes](#scapi-authentication) (or [OCAPI](#ocapi-configuration)) | | [Sites](/cli/sites) | OAuth + OCAPI | [API Client](#account-manager-api-client) + [OCAPI](#ocapi-configuration) | | SCAPI commands ([schemas](/cli/scapi-schemas), [custom-apis](/cli/custom-apis), [eCDN](/cli/ecdn)) | OAuth + SCAPI scopes | [API Client](#account-manager-api-client) + [SCAPI Scopes](#scapi-authentication) | | [CIP analytics](/cli/cip) (`cip query`, `cip report`) | OAuth + Client Credentials | [API Client](#account-manager-api-client) + Salesforce Commerce API role + tenant filter | @@ -309,6 +309,12 @@ b2c code list --auth-methods jwt ## OCAPI Configuration +::: warning OCAPI is deprecated +OCAPI (the Open Commerce API / Data API) is **deprecated** and is being disabled across instances. Newer instances reject OCAPI calls entirely (`OcapiDeprecatedException`). Prefer [SCAPI](#scapi-authentication) for code, jobs, and BM users/roles — the CLI uses SCAPI first and only falls back to OCAPI when SCAPI scopes are not configured. Configure OCAPI only for instances that still support it or for the few OCAPI-only operations (e.g. [Sites](/cli/sites)). + +If a command fails with "OCAPI is deprecated and disabled for this instance," configure [SCAPI scopes](#scapi-authentication) on your API client instead. +::: + For operations that interact with B2C Commerce instances (code deployment, jobs, sites), you need to configure OCAPI permissions on each instance. ### Configuring OCAPI in Business Manager @@ -471,7 +477,7 @@ For operations that interact with B2C Commerce instances (code deployment, jobs, ## SCAPI Authentication -SCAPI commands (eCDN, SCAPI schemas, custom APIs) require OAuth authentication with specific roles and scopes. +SCAPI (the Salesforce Commerce API) is the **preferred, modern** API surface and the CLI's default for every operation that supports it. SCAPI-native commands (eCDN, SCAPI schemas, custom APIs) require it, and the dual-backend commands (`code`, `jobs`, `bm users`, `bm roles`) use it first, [falling back to the deprecated OCAPI](#ocapi-configuration) only when SCAPI scopes are not configured. All require OAuth authentication with specific roles and scopes. ### Required Setup @@ -497,7 +503,7 @@ SCAPI commands (eCDN, SCAPI schemas, custom APIs) require OAuth authentication w The CLI automatically requests these scopes. Your API client must have them in the Default Scopes list. -For commands that have both an OCAPI and a SCAPI implementation (`code`, `jobs`, `bm users`, `bm roles`), the CLI defaults to `--api-backend auto`: it tries SCAPI when shortCode + tenantId are configured and the API client has the required `sfcc.*` scope, otherwise it falls back to OCAPI. Use `--api-backend ocapi` or `--api-backend scapi` to force a backend explicitly. +The `code`, `jobs`, `bm users`, and `bm roles` commands run over SCAPI. The CLI defaults to `--api-backend auto`, which falls back to the [deprecated OCAPI backend](#ocapi-configuration) only when the SCAPI scopes above are not configured (or not yet provisioned on the API client). Use `--api-backend scapi` or `--api-backend ocapi` to force a backend explicitly. ::: tip For detailed authentication requirements including specific scopes for each command, see the individual [CLI command reference pages](/cli/). @@ -609,9 +615,9 @@ Here's a complete example for setting up CLI access: - For SCAPI-backed dual commands, also add the relevant `sfcc.jobs(.rw)`, `sfcc.scripts(.rw)`, `sfcc.users(.rw)`, `sfcc.roles(.rw)` scopes — see [Scopes by Command](#scopes-by-command). - **Redirect URLs**: `http://localhost:8080` (for user authentication) -### 2. Configure OCAPI (for `sites` and as the auto-mode fallback for code/jobs/bm) +### 2. (Optional) Configure OCAPI fallback -Add the JSON configuration shown in [OCAPI Configuration](#ocapi-configuration). With the SCAPI scopes above also configured on your client, `code list/activate/delete`, `code deploy --activate/--reload`, `jobs`, and `bm users/roles` will prefer SCAPI in `auto` mode and fall back to OCAPI if a scope is missing. +With the SCAPI scopes above configured, `code`, `jobs`, and `bm users/roles` run entirely over SCAPI — no OCAPI setup is needed. Configure OCAPI only for the OCAPI-only [`sites`](/cli/sites) command, or to provide a fallback on instances where SCAPI scopes are not yet provisioned. Note that OCAPI is [deprecated](#ocapi-configuration) and disabled on newer instances. To set it up, add the JSON configuration shown in [OCAPI Configuration](#ocapi-configuration). ### 3. Configure WebDAV Access (for code deploy/watch, webdav commands) @@ -643,7 +649,7 @@ export SFCC_PASSWORD=your-webdav-access-key ### 5. Test the Configuration ```bash -# Test OAuth + OCAPI +# Test OAuth + SCAPI (code uses SCAPI when sfcc.scripts is configured) b2c code list # Test WebDAV diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index b055b1fdb..922bc3411 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -209,7 +209,7 @@ b2c setup instance create staging \ --force ``` -The interactive mode auto-detects the active code version via OCAPI when OAuth credentials are provided, and the first instance you create is automatically set as active. +The interactive mode auto-detects the active code version when OAuth credentials are provided, and the first instance you create is automatically set as active. #### Switching Instances @@ -271,7 +271,7 @@ For the full command reference with all flags, see [Setup Commands](/cli/setup). | `certificate` | Path to PKCS12 certificate for two-factor auth (mTLS) | | `certificate-passphrase` | Passphrase for the certificate. Also accepts `passphrase`. | | `self-signed` | Allow self-signed server certificates. Also accepts `selfsigned`. | -| `api-backend` | API backend for `job`, `code`, `bm users`, and `bm roles` commands: `ocapi`, `scapi`, or `auto` (default). Auto prefers SCAPI when `shortCode` and `tenant-id` are set, falling back to OCAPI on missing scopes. | +| `api-backend` | API backend for `job`, `code`, `bm users`, and `bm roles` commands: `scapi`, `auto` (default), or `ocapi`. These commands use SCAPI; `auto` falls back to the deprecated OCAPI backend only when SCAPI scopes are not configured. Set `ocapi` to force the [deprecated](./authentication#ocapi-configuration) backend. | ### Two-Factor Authentication (mTLS) diff --git a/packages/b2c-cli/src/commands/sites/list.ts b/packages/b2c-cli/src/commands/sites/list.ts index 03fe99930..4498d6f24 100644 --- a/packages/b2c-cli/src/commands/sites/list.ts +++ b/packages/b2c-cli/src/commands/sites/list.ts @@ -11,7 +11,11 @@ import { selectColumns, type ColumnDef, } from '@salesforce/b2c-tooling-sdk/cli'; -import {getApiErrorMessage} from '@salesforce/b2c-tooling-sdk/clients'; +import { + getApiErrorMessage, + isOcapiDeprecatedFault, + OCAPI_DEPRECATED_MESSAGE, +} from '@salesforce/b2c-tooling-sdk/clients'; import type {OcapiComponents} from '@salesforce/b2c-tooling-sdk'; import {t, withDocs} from '../../i18n/index.js'; @@ -69,6 +73,9 @@ export default class SitesList extends InstanceCommand { }); if (error) { + if (isOcapiDeprecatedFault(error)) { + this.error(OCAPI_DEPRECATED_MESSAGE); + } this.error( t('commands.sites.list.error', 'Failed to fetch sites: {{message}}', { message: getApiErrorMessage(error, response), diff --git a/packages/b2c-dx-mcp/src/tools/diagnostics/index.ts b/packages/b2c-dx-mcp/src/tools/diagnostics/index.ts index 025b287e1..32720ebcb 100644 --- a/packages/b2c-dx-mcp/src/tools/diagnostics/index.ts +++ b/packages/b2c-dx-mcp/src/tools/diagnostics/index.ts @@ -30,10 +30,7 @@ import {createMrtLogsWatchStopTool} from './mrt-logs-watch-stop.js'; import {createMrtLogsWatchListTool} from './mrt-logs-watch-list.js'; export interface DiagnosticsToolInjections - extends LogsGetRecentInjections, - LogsListFilesInjections, - LogsWatchStartInjections, - MrtLogsWatchStartInjections {} + extends LogsGetRecentInjections, LogsListFilesInjections, LogsWatchStartInjections, MrtLogsWatchStartInjections {} export function createDiagnosticsTools( loadServices: () => Promise | Services, diff --git a/packages/b2c-tooling-sdk/src/clients/error-utils.ts b/packages/b2c-tooling-sdk/src/clients/error-utils.ts index ed57363e7..0df76c32f 100644 --- a/packages/b2c-tooling-sdk/src/clients/error-utils.ts +++ b/packages/b2c-tooling-sdk/src/clients/error-utils.ts @@ -9,6 +9,81 @@ * @module clients/error-utils */ +/** + * Matches JSON Web Tokens (three base64url segments separated by dots, the + * first beginning with `eyJ` — the base64 of `{"`). OCAPI/SLAS faults such as + * `InvalidAccessTokenException` embed the offending bearer token verbatim in + * their human-readable `message`, so any message surfaced to the user must be + * scrubbed of it. + */ +const JWT_PATTERN = /eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g; + +/** + * The OCAPI `fault.type` returned (with HTTP 403) when an instance has OCAPI + * disabled. The platform is progressively deprecating OCAPI; on a deprecated + * instance every Data API call fails with this fault regardless of scopes or + * credentials. SCAPI is the supported path forward. + */ +const OCAPI_DEPRECATED_FAULT_TYPE = 'OcapiDeprecatedException'; + +/** Doc anchor users are directed to when OCAPI is deprecated for an instance. */ +const SCAPI_SETUP_DOC_URL = + 'https://salesforcecommercecloud.github.io/b2c-developer-tooling/guide/authentication.html#scapi-authentication'; + +/** + * User-facing guidance shown when an instance has OCAPI deprecated. Kept as a + * single constant so the message is identical across every OCAPI-terminal call + * site (it is emitted both by {@link getApiErrorMessage} and + * {@link OcapiDeprecatedError}). + */ +export const OCAPI_DEPRECATED_MESSAGE = + 'OCAPI is deprecated and disabled for this instance. ' + + 'Configure SCAPI access (shortCode, tenantId, and the required sfcc.* scopes on your Account Manager API client) to continue. ' + + `See: ${SCAPI_SETUP_DOC_URL}`; + +/** + * Returns true if an API error object is an OCAPI deprecation fault + * (`fault.type === 'OcapiDeprecatedException'`). + * + * Detection keys off the structured `fault.type`, not the message text, so it + * is robust to message wording changes. Used to convert the opaque OCAPI 403 + * into actionable "configure SCAPI" guidance at every OCAPI-terminal site. + */ +export function isOcapiDeprecatedFault(error: unknown): boolean { + if (!error || typeof error !== 'object') return false; + const fault = (error as Record).fault; + if (!fault || typeof fault !== 'object') return false; + return (fault as Record).type === OCAPI_DEPRECATED_FAULT_TYPE; +} + +/** + * Error thrown when an OCAPI operation fails because OCAPI is deprecated for + * the instance. Carries the actionable {@link OCAPI_DEPRECATED_MESSAGE} so the + * CLI surfaces SCAPI-setup guidance instead of an opaque "Failed to ..." line. + * + * Thrown by OCAPI-terminal operations (those with no SCAPI fallback, or whose + * SCAPI path was already exhausted) when {@link isOcapiDeprecatedFault} matches. + */ +export class OcapiDeprecatedError extends Error { + constructor(cause?: unknown) { + super(OCAPI_DEPRECATED_MESSAGE, cause === undefined ? undefined : {cause}); + this.name = 'OcapiDeprecatedError'; + } +} + +/** + * Redacts JWT bearer tokens from a free-text string. + * + * Keeps a short `eyJ…` prefix (mirroring the logger's partial-token style) so + * the message still reads sensibly, while removing the credential itself. + * Applied to every user-facing API error message — credentials must never + * reach stdout/stderr/logs outside of debug/trace logging (where the structured + * logger applies its own field-level redaction). + */ +export function redactTokens(text: string): string { + return text.replace(JWT_PATTERN, 'eyJ…[REDACTED-TOKEN]'); +} + /** * Extract a clean error message from an API error response. * @@ -16,6 +91,10 @@ * This ensures that HTML response bodies (like error pages) are never * included in user-facing error messages. * + * The returned message is always scrubbed of bearer tokens via + * {@link redactTokens} — some OCAPI faults (e.g. `InvalidAccessTokenException`) + * embed the full JWT in their message text. + * * Supported error patterns: * - ODS/SLAS: `{ error: { message: '...' } }` * - OCAPI: `{ fault: { message: '...' } }` @@ -36,6 +115,11 @@ * ``` */ export function getApiErrorMessage(error: unknown, response: Response | {status: number; statusText: string}): string { + // OCAPI deprecation: replace the opaque fault with actionable SCAPI guidance. + if (isOcapiDeprecatedFault(error)) { + return OCAPI_DEPRECATED_MESSAGE; + } + if (error && typeof error === 'object') { const err = error as Record; @@ -43,7 +127,7 @@ export function getApiErrorMessage(error: unknown, response: Response | {status: if (err.error && typeof err.error === 'object') { const nested = err.error as Record; if (typeof nested.message === 'string' && nested.message) { - return nested.message; + return redactTokens(nested.message); } } @@ -51,24 +135,52 @@ export function getApiErrorMessage(error: unknown, response: Response | {status: if (err.fault && typeof err.fault === 'object') { const fault = err.fault as Record; if (typeof fault.message === 'string' && fault.message) { - return fault.message; + return redactTokens(fault.message); } } // SCAPI/Problem+JSON pattern: { detail: '...', title: '...' } if (typeof err.detail === 'string' && err.detail) { - return err.detail; + return redactTokens(err.detail); } if (typeof err.title === 'string' && err.title) { - return err.title; + return redactTokens(err.title); } // Standard Error pattern: { message: '...' } if (typeof err.message === 'string' && err.message) { - return err.message; + return redactTokens(err.message); } } // Fallback to HTTP status return `HTTP ${response.status} ${response.statusText}`; } + +/** + * Throws a well-formed Error for a failed OCAPI call. + * + * Centralizes OCAPI-terminal error handling so every call site behaves + * consistently: + * - OCAPI deprecation faults become an {@link OcapiDeprecatedError} with + * actionable SCAPI-setup guidance (instead of an opaque "Failed to ..."). + * - Everything else throws `Error(`${prefix}: ${message}`)` where `message` + * is the token-redacted fault text from {@link getApiErrorMessage}. + * + * The original `error` is always attached as `cause` for debug logging. + * + * @param error - The error object from an openapi-fetch result. + * @param response - The HTTP response (for status fallback). + * @param prefix - Operation-specific prefix, e.g. `'Failed to list code versions'`. + * @throws Always throws — return type is `never`. + */ +export function throwOcapiError( + error: unknown, + response: Response | {status: number; statusText: string}, + prefix: string, +): never { + if (isOcapiDeprecatedFault(error)) { + throw new OcapiDeprecatedError(error); + } + throw new Error(`${prefix}: ${getApiErrorMessage(error, response)}`, {cause: error}); +} diff --git a/packages/b2c-tooling-sdk/src/clients/index.ts b/packages/b2c-tooling-sdk/src/clients/index.ts index 8ea385476..691ce3f5d 100644 --- a/packages/b2c-tooling-sdk/src/clients/index.ts +++ b/packages/b2c-tooling-sdk/src/clients/index.ts @@ -388,7 +388,14 @@ export type {BuildScapiClientOptions, ScapiClientConfig} from './scapi-client-fa export {ScopeTierManager} from './scapi-scope-tier.js'; export type {ScopeTier, ScopeTierManagerOptions} from './scapi-scope-tier.js'; -export {getApiErrorMessage} from './error-utils.js'; +export { + getApiErrorMessage, + redactTokens, + isOcapiDeprecatedFault, + throwOcapiError, + OcapiDeprecatedError, + OCAPI_DEPRECATED_MESSAGE, +} from './error-utils.js'; export {createTlsDispatcher} from './tls-dispatcher.js'; export type {TlsOptions} from './tls-dispatcher.js'; diff --git a/packages/b2c-tooling-sdk/src/index.ts b/packages/b2c-tooling-sdk/src/index.ts index 9e2303c18..e8ce69f10 100644 --- a/packages/b2c-tooling-sdk/src/index.ts +++ b/packages/b2c-tooling-sdk/src/index.ts @@ -79,6 +79,11 @@ export { normalizeTenantId, buildTenantScope, getApiErrorMessage, + redactTokens, + isOcapiDeprecatedFault, + throwOcapiError, + OcapiDeprecatedError, + OCAPI_DEPRECATED_MESSAGE, isValidRoleTenantFilter, fetchRoleMapping, resolveToInternalRole, diff --git a/packages/b2c-tooling-sdk/src/operations/cap/install.ts b/packages/b2c-tooling-sdk/src/operations/cap/install.ts index 4af69c2d9..d41c53069 100644 --- a/packages/b2c-tooling-sdk/src/operations/cap/install.ts +++ b/packages/b2c-tooling-sdk/src/operations/cap/install.ts @@ -12,6 +12,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import JSZip from 'jszip'; import {B2CInstance} from '../../instance/index.js'; +import {isOcapiDeprecatedFault, OcapiDeprecatedError, redactTokens} from '../../clients/error-utils.js'; import {getLogger} from '../../logging/logger.js'; import {waitForJob, JobExecutionError, getJobLog, type JobExecution, type WaitForJobOptions} from '../jobs/run.js'; import {addDirectoryToZip} from '../util/zip.js'; @@ -152,12 +153,14 @@ export async function commerceAppInstall( }); if (retryError || !retryData) { - throw new Error(retryError?.fault?.message ?? 'Failed to start install job'); + if (isOcapiDeprecatedFault(retryError)) throw new OcapiDeprecatedError(retryError); + throw new Error(redactTokens(retryError?.fault?.message ?? 'Failed to start install job'), {cause: retryError}); } execution = retryData; } else if (error || !data) { - throw new Error(error?.fault?.message ?? 'Failed to start install job'); + if (isOcapiDeprecatedFault(error)) throw new OcapiDeprecatedError(error); + throw new Error(redactTokens(error?.fault?.message ?? 'Failed to start install job'), {cause: error}); } else { execution = data; } diff --git a/packages/b2c-tooling-sdk/src/operations/cap/list.ts b/packages/b2c-tooling-sdk/src/operations/cap/list.ts index a3c9c5db3..ea03cd214 100644 --- a/packages/b2c-tooling-sdk/src/operations/cap/list.ts +++ b/packages/b2c-tooling-sdk/src/operations/cap/list.ts @@ -14,6 +14,7 @@ import * as path from 'node:path'; import JSZip from 'jszip'; import * as xml2js from 'xml2js'; import {B2CInstance} from '../../instance/index.js'; +import {isOcapiDeprecatedFault, OcapiDeprecatedError, redactTokens} from '../../clients/error-utils.js'; import {getLogger} from '../../logging/logger.js'; import {siteArchiveExportToBuffer} from '../jobs/site-archive.js'; import type {JobExecution, WaitForJobOptions} from '../jobs/run.js'; @@ -168,7 +169,8 @@ export async function listInstalledApps( params: {query: {select: '(**)'}}, }); if (error || !data) { - throw new Error(error?.fault?.message ?? 'Failed to list sites'); + if (isOcapiDeprecatedFault(error)) throw new OcapiDeprecatedError(error); + throw new Error(redactTokens(error?.fault?.message ?? 'Failed to list sites'), {cause: error}); } siteIds = (data.data ?? []).map((s) => s.id).filter((id): id is string => !!id); logger.debug({siteIds}, `Discovered ${siteIds.length} site(s)`); diff --git a/packages/b2c-tooling-sdk/src/operations/cap/uninstall.ts b/packages/b2c-tooling-sdk/src/operations/cap/uninstall.ts index 6e644adf6..5efd2a25d 100644 --- a/packages/b2c-tooling-sdk/src/operations/cap/uninstall.ts +++ b/packages/b2c-tooling-sdk/src/operations/cap/uninstall.ts @@ -9,6 +9,7 @@ * Runs the sfcc-uninstall-commerce-app system job to remove an installed CAP. */ import {B2CInstance} from '../../instance/index.js'; +import {isOcapiDeprecatedFault, OcapiDeprecatedError, redactTokens} from '../../clients/error-utils.js'; import {getLogger} from '../../logging/logger.js'; import {waitForJob, JobExecutionError, getJobLog, type JobExecution, type WaitForJobOptions} from '../jobs/run.js'; import {normalizeSiteId} from './install.js'; @@ -98,12 +99,14 @@ export async function commerceAppUninstall( }); if (retryError || !retryData) { - throw new Error(retryError?.fault?.message ?? 'Failed to start uninstall job'); + if (isOcapiDeprecatedFault(retryError)) throw new OcapiDeprecatedError(retryError); + throw new Error(redactTokens(retryError?.fault?.message ?? 'Failed to start uninstall job'), {cause: retryError}); } execution = retryData; } else if (error || !data) { - throw new Error(error?.fault?.message ?? 'Failed to start uninstall job'); + if (isOcapiDeprecatedFault(error)) throw new OcapiDeprecatedError(error); + throw new Error(redactTokens(error?.fault?.message ?? 'Failed to start uninstall job'), {cause: error}); } else { execution = data; } diff --git a/packages/b2c-tooling-sdk/src/operations/code/versions.ts b/packages/b2c-tooling-sdk/src/operations/code/versions.ts index 88ea122a3..4382d138b 100644 --- a/packages/b2c-tooling-sdk/src/operations/code/versions.ts +++ b/packages/b2c-tooling-sdk/src/operations/code/versions.ts @@ -5,6 +5,7 @@ */ import type {B2CInstance} from '../../instance/index.js'; import {type OcapiComponents} from '../../clients/index.js'; +import {throwOcapiError} from '../../clients/error-utils.js'; import {getLogger} from '../../logging/logger.js'; /** Code version type from OCAPI */ @@ -29,10 +30,10 @@ export type CodeVersionResult = OcapiComponents['schemas']['code_version_result' * ``` */ export async function listCodeVersions(instance: B2CInstance): Promise { - const {data, error} = await instance.ocapi.GET('/code_versions', {}); + const {data, error, response} = await instance.ocapi.GET('/code_versions', {}); if (error) { - throw new Error('Failed to list code versions', {cause: error}); + throwOcapiError(error, response, 'Failed to list code versions'); } return (data as CodeVersionResult).data ?? []; @@ -75,13 +76,13 @@ export async function activateCodeVersion(instance: B2CInstance, codeVersionId: const logger = getLogger(); logger.debug({codeVersionId}, `Activating code version ${codeVersionId}`); - const {error} = await instance.ocapi.PATCH('/code_versions/{code_version_id}', { + const {error, response} = await instance.ocapi.PATCH('/code_versions/{code_version_id}', { params: {path: {code_version_id: codeVersionId}}, body: {active: true}, }); if (error) { - throw new Error('Failed to activate code version', {cause: error}); + throwOcapiError(error, response, 'Failed to activate code version'); } logger.debug({codeVersionId}, `Code version ${codeVersionId} activated`); @@ -105,12 +106,12 @@ export async function deleteCodeVersion(instance: B2CInstance, codeVersionId: st const logger = getLogger(); logger.debug({codeVersionId}, `Deleting code version ${codeVersionId}`); - const {error} = await instance.ocapi.DELETE('/code_versions/{code_version_id}', { + const {error, response} = await instance.ocapi.DELETE('/code_versions/{code_version_id}', { params: {path: {code_version_id: codeVersionId}}, }); if (error) { - throw new Error('Failed to delete code version', {cause: error}); + throwOcapiError(error, response, 'Failed to delete code version'); } logger.debug({codeVersionId}, `Code version ${codeVersionId} deleted`); @@ -134,12 +135,12 @@ export async function createCodeVersion(instance: B2CInstance, codeVersionId: st const logger = getLogger(); logger.debug({codeVersionId}, `Creating code version ${codeVersionId}`); - const {error} = await instance.ocapi.PUT('/code_versions/{code_version_id}', { + const {error, response} = await instance.ocapi.PUT('/code_versions/{code_version_id}', { params: {path: {code_version_id: codeVersionId}}, }); if (error) { - throw new Error('Failed to create code version', {cause: error}); + throwOcapiError(error, response, 'Failed to create code version'); } logger.debug({codeVersionId}, `Code version ${codeVersionId} created`); diff --git a/packages/b2c-tooling-sdk/src/operations/jobs/run.ts b/packages/b2c-tooling-sdk/src/operations/jobs/run.ts index 5192b7151..271a778b4 100644 --- a/packages/b2c-tooling-sdk/src/operations/jobs/run.ts +++ b/packages/b2c-tooling-sdk/src/operations/jobs/run.ts @@ -10,6 +10,7 @@ */ import {B2CInstance} from '../../instance/index.js'; import type {components} from '../../clients/ocapi.generated.js'; +import {isOcapiDeprecatedFault, OcapiDeprecatedError, redactTokens} from '../../clients/error-utils.js'; import {getLogger} from '../../logging/logger.js'; /** @@ -151,8 +152,9 @@ export async function executeJob( } if (error || !data) { - const message = error?.fault?.message ?? `Failed to execute job ${jobId}`; - throw new Error(message); + if (isOcapiDeprecatedFault(error)) throw new OcapiDeprecatedError(error); + const message = redactTokens(error?.fault?.message ?? `Failed to execute job ${jobId}`); + throw new Error(message, {cause: error}); } logger.debug({jobId, executionId: data.id, status: data.execution_status}, `Job ${jobId} started: ${data.id}`); @@ -185,8 +187,9 @@ export async function getJobExecution( }); if (error || !data) { - const message = error?.fault?.message ?? `Failed to get job execution ${executionId}`; - throw new Error(message); + if (isOcapiDeprecatedFault(error)) throw new OcapiDeprecatedError(error); + const message = redactTokens(error?.fault?.message ?? `Failed to get job execution ${executionId}`); + throw new Error(message, {cause: error}); } return data; @@ -416,8 +419,9 @@ export async function searchJobExecutions( }); if (error || !data) { - const message = error?.fault?.message ?? 'Failed to search job executions'; - throw new Error(message); + if (isOcapiDeprecatedFault(error)) throw new OcapiDeprecatedError(error); + const message = redactTokens(error?.fault?.message ?? 'Failed to search job executions'); + throw new Error(message, {cause: error}); } return { diff --git a/packages/b2c-tooling-sdk/src/operations/jobs/site-archive.ts b/packages/b2c-tooling-sdk/src/operations/jobs/site-archive.ts index d537d3828..201b4351f 100644 --- a/packages/b2c-tooling-sdk/src/operations/jobs/site-archive.ts +++ b/packages/b2c-tooling-sdk/src/operations/jobs/site-archive.ts @@ -15,6 +15,7 @@ import * as zlib from 'node:zlib'; import {glob, hasMagic} from 'glob'; import JSZip from 'jszip'; import {B2CInstance} from '../../instance/index.js'; +import {isOcapiDeprecatedFault, OcapiDeprecatedError, redactTokens} from '../../clients/error-utils.js'; import {getLogger} from '../../logging/logger.js'; import {addDirectoryToZip} from '../util/zip.js'; import {waitForJob, JobExecutionError, getJobLog, type JobExecution, type WaitForJobOptions} from './run.js'; @@ -246,12 +247,14 @@ export async function siteArchiveImport( }); if (retryError || !retryData) { - throw new Error(retryError?.fault?.message ?? 'Failed to execute import job'); + if (isOcapiDeprecatedFault(retryError)) throw new OcapiDeprecatedError(retryError); + throw new Error(redactTokens(retryError?.fault?.message ?? 'Failed to execute import job'), {cause: retryError}); } execution = retryData; } else if (error || !data) { - throw new Error(error?.fault?.message ?? 'Failed to execute import job'); + if (isOcapiDeprecatedFault(error)) throw new OcapiDeprecatedError(error); + throw new Error(redactTokens(error?.fault?.message ?? 'Failed to execute import job'), {cause: error}); } else { execution = data; } @@ -1053,12 +1056,16 @@ export async function siteArchiveExport( }); if (retryError || !retryData) { - throw new Error(retryError?.fault?.message ?? 'Failed to execute export job'); + if (isOcapiDeprecatedFault(retryError)) throw new OcapiDeprecatedError(retryError); + throw new Error(redactTokens(retryError?.fault?.message ?? 'Failed to execute export job'), { + cause: retryError, + }); } execution = retryData; } else if (error || !data) { - throw new Error(error?.fault?.message ?? 'Failed to execute export job'); + if (isOcapiDeprecatedFault(error)) throw new OcapiDeprecatedError(error); + throw new Error(redactTokens(error?.fault?.message ?? 'Failed to execute export job'), {cause: error}); } else { execution = data; } diff --git a/packages/b2c-tooling-sdk/src/scaffold/sources.ts b/packages/b2c-tooling-sdk/src/scaffold/sources.ts index e866f9e82..fc82884cb 100644 --- a/packages/b2c-tooling-sdk/src/scaffold/sources.ts +++ b/packages/b2c-tooling-sdk/src/scaffold/sources.ts @@ -9,6 +9,7 @@ import path from 'node:path'; import {findCartridges} from '../operations/code/cartridges.js'; import type {B2CInstance} from '../instance/index.js'; import type {OcapiComponents} from '../clients/index.js'; +import {isOcapiDeprecatedFault, OcapiDeprecatedError} from '../clients/error-utils.js'; import type {ScaffoldChoice, ScaffoldParameter, DynamicParameterSource, SourceResult} from './types.js'; /** @@ -153,7 +154,8 @@ export async function resolveRemoteSource( }); if (error) { - throw new Error('Failed to fetch sites from B2C instance'); + if (isOcapiDeprecatedFault(error)) throw new OcapiDeprecatedError(error); + throw new Error('Failed to fetch sites from B2C instance', {cause: error}); } const sites = data as OcapiComponents['schemas']['sites']; diff --git a/packages/b2c-tooling-sdk/test/clients/error-utils.test.ts b/packages/b2c-tooling-sdk/test/clients/error-utils.test.ts index fc51b5e7b..87852da1d 100644 --- a/packages/b2c-tooling-sdk/test/clients/error-utils.test.ts +++ b/packages/b2c-tooling-sdk/test/clients/error-utils.test.ts @@ -4,7 +4,18 @@ * 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 {getApiErrorMessage} from '../../src/clients/error-utils.js'; +import { + getApiErrorMessage, + redactTokens, + isOcapiDeprecatedFault, + throwOcapiError, + OcapiDeprecatedError, + OCAPI_DEPRECATED_MESSAGE, +} from '../../src/clients/error-utils.js'; + +// A syntactically valid (fake) JWT: three base64url segments, first starts eyJ. +const FAKE_JWT = + 'eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ0ZXN0LXVzZXIiLCJzY29wZSI6InNmY2Muc2NyaXB0cyJ9.c2lnbmF0dXJlLXBheWxvYWQtaGVyZQ'; describe('getApiErrorMessage', () => { // Mock response object for testing @@ -168,4 +179,99 @@ describe('getApiErrorMessage', () => { expect(getApiErrorMessage(error, response as Response)).to.equal('HTTP 521 Web Server Is Down'); }); }); + + describe('token redaction (SEC1)', () => { + it('redacts a bearer JWT embedded in an OCAPI InvalidAccessTokenException fault', () => { + const error = { + fault: { + type: 'InvalidAccessTokenException', + message: `Unauthorized request! The access token '${FAKE_JWT}' is invalid.`, + }, + }; + const message = getApiErrorMessage(error, mockResponse(401, 'Unauthorized')); + expect(message).to.not.include(FAKE_JWT); + expect(message).to.include('eyJ…[REDACTED-TOKEN]'); + expect(message).to.include('is invalid.'); + }); + + it('redacts tokens from ODS/SLAS and detail/title/message variants too', () => { + expect(getApiErrorMessage({error: {message: `tok ${FAKE_JWT}`}}, mockResponse(401, 'x'))).to.not.include( + FAKE_JWT, + ); + expect(getApiErrorMessage({detail: `tok ${FAKE_JWT}`}, mockResponse(401, 'x'))).to.not.include(FAKE_JWT); + expect(getApiErrorMessage({title: `tok ${FAKE_JWT}`}, mockResponse(401, 'x'))).to.not.include(FAKE_JWT); + expect(getApiErrorMessage({message: `tok ${FAKE_JWT}`}, mockResponse(401, 'x'))).to.not.include(FAKE_JWT); + }); + }); + + describe('redactTokens', () => { + it('replaces JWTs while preserving surrounding text', () => { + expect(redactTokens(`before ${FAKE_JWT} after`)).to.equal('before eyJ…[REDACTED-TOKEN] after'); + }); + + it('redacts multiple tokens in one string', () => { + const out = redactTokens(`${FAKE_JWT} and ${FAKE_JWT}`); + expect(out).to.not.include(FAKE_JWT); + expect(out.match(/REDACTED-TOKEN/g)).to.have.length(2); + }); + + it('leaves token-free text untouched', () => { + expect(redactTokens('No secrets here')).to.equal('No secrets here'); + }); + }); + + describe('OCAPI deprecation (D1)', () => { + const deprecatedFault = { + fault: { + type: 'OcapiDeprecatedException', + message: 'OCAPI has been deprecated. Access is not available for this instance.', + }, + }; + + it('isOcapiDeprecatedFault matches the deprecation fault type', () => { + expect(isOcapiDeprecatedFault(deprecatedFault)).to.equal(true); + }); + + it('isOcapiDeprecatedFault is false for other faults and non-objects', () => { + expect(isOcapiDeprecatedFault({fault: {type: 'NotFoundException', message: 'x'}})).to.equal(false); + expect(isOcapiDeprecatedFault({fault: {type: 'InvalidAccessTokenException'}})).to.equal(false); + expect(isOcapiDeprecatedFault(null)).to.equal(false); + expect(isOcapiDeprecatedFault('string')).to.equal(false); + expect(isOcapiDeprecatedFault({})).to.equal(false); + }); + + it('getApiErrorMessage substitutes actionable SCAPI guidance for the deprecation fault', () => { + const message = getApiErrorMessage(deprecatedFault, mockResponse(403, 'Forbidden')); + expect(message).to.equal(OCAPI_DEPRECATED_MESSAGE); + expect(message).to.include('OCAPI is deprecated'); + expect(message).to.include('#scapi-authentication'); + // The original opaque fault message must not be what the user sees. + expect(message).to.not.include('Access is not available'); + }); + }); + + describe('throwOcapiError', () => { + it('throws OcapiDeprecatedError for the deprecation fault', () => { + const fault = {fault: {type: 'OcapiDeprecatedException', message: 'deprecated'}}; + expect(() => throwOcapiError(fault, mockResponse(403, 'Forbidden'), 'Failed to do thing')).to.throw( + OcapiDeprecatedError, + ); + }); + + it('prefixes and redacts non-deprecation errors, attaching cause', () => { + const fault = {fault: {type: 'InvalidAccessTokenException', message: `token ${FAKE_JWT}`}}; + try { + throwOcapiError(fault, mockResponse(401, 'Unauthorized'), 'Failed to list code versions'); + expect.fail('should have thrown'); + } catch (e) { + const err = e as Error; + expect(err).to.be.instanceOf(Error); + expect(err).to.not.be.instanceOf(OcapiDeprecatedError); + expect(err.message).to.match(/^Failed to list code versions: /); + expect(err.message).to.not.include(FAKE_JWT); + expect(err.message).to.include('eyJ…[REDACTED-TOKEN]'); + expect(err.cause).to.equal(fault); + } + }); + }); }); diff --git a/skills/b2c-cli/skills/b2c-bm-users-roles/SKILL.md b/skills/b2c-cli/skills/b2c-bm-users-roles/SKILL.md index 3c2ac72e2..bf64cd418 100644 --- a/skills/b2c-cli/skills/b2c-bm-users-roles/SKILL.md +++ b/skills/b2c-cli/skills/b2c-bm-users-roles/SKILL.md @@ -5,7 +5,7 @@ description: Manage Business Manager users, access roles, role permissions, and # B2C Business Manager Users, Roles, and Access Keys -Use the `b2c bm` commands to administer instance-level Business Manager resources via the OCAPI Data API. These commands target a specific Commerce Cloud instance — pass `--server`/`-s` or set the active instance in `dw.json` first. +Use the `b2c bm` commands to administer instance-level Business Manager resources (users, roles, access keys) over SCAPI. These commands target a specific Commerce Cloud instance — pass `--server`/`-s` or set the active instance in `dw.json` first. > **Tip:** If `b2c` is not installed globally, use `npx @salesforce/b2c-cli` instead (e.g., `npx @salesforce/b2c-cli bm whoami`). @@ -13,19 +13,11 @@ For **Account Manager** user/role/client management (cross-instance, scoped to t ## API Backend -`bm users` (list, get, update, delete) and `bm roles` (all subcommands including permissions) support both the OCAPI Data API and the SCAPI Merchant Users / Merchant Roles APIs. Auto mode (default) prefers SCAPI when `shortCode` and `tenantId` are configured. +`bm users` (list, get, update, delete) and `bm roles` (all subcommands including permissions) run over the SCAPI Merchant Users / Merchant Roles APIs. Configure `shortCode`, `tenantId`, and the `sfcc.users(.rw)` / `sfcc.roles(.rw)` scopes and they work out of the box. -```bash -# force SCAPI (requires sfcc.users.rw / sfcc.roles.rw scope) -b2c bm users list --api-backend scapi - -# force OCAPI -b2c bm roles get Administrator --api-backend ocapi -``` - -OCAPI-only commands (no SCAPI equivalent): `bm users search`, `bm whoami`, `bm access-key *`. +OCAPI-only commands (no SCAPI equivalent, unavailable on OCAPI-disabled instances): `bm users search`, `bm whoami`, `bm access-key *`. -`bm users update --disabled` requires OCAPI (SCAPI's PATCH endpoint doesn't support changing `disabled`). Auto mode falls back to OCAPI for that case. +OCAPI is deprecated and disabled on newer instances. `--api-backend auto` (the default) falls back to the OCAPI Data API only when SCAPI scopes are not configured; force a backend with `--api-backend scapi|ocapi` if needed. `bm users update --disabled` is the one write that requires OCAPI (SCAPI's PATCH endpoint can't change `disabled`), so it is unavailable on OCAPI-disabled instances. ## Authentication diff --git a/skills/b2c-cli/skills/b2c-code/SKILL.md b/skills/b2c-cli/skills/b2c-code/SKILL.md index 6d4ee5deb..0379c14c2 100644 --- a/skills/b2c-cli/skills/b2c-code/SKILL.md +++ b/skills/b2c-cli/skills/b2c-code/SKILL.md @@ -112,19 +112,13 @@ b2c code activate --reload b2c code delete ``` -### API Backend Selection +### API Backend -`code list`, `code activate`, `code delete`, and the active-version discovery / activate / reload steps in `code deploy` all honor `--api-backend`. Auto mode (the default) prefers SCAPI when `shortCode` and `tenantId` are configured. +`code list`, `code activate`, `code delete`, and the active-version discovery / activate / reload steps in `code deploy` run over SCAPI. Configure `shortCode`, `tenantId`, and the `sfcc.scripts` / `sfcc.scripts.rw` scopes and they work out of the box. Read scope (`sfcc.scripts`) covers `code list` / discovery; write scope (`sfcc.scripts.rw`) covers activate, delete, reload, and the `--activate` / `--reload` flags on deploy. -```bash -# force SCAPI (requires sfcc.scripts or sfcc.scripts.rw scope) -b2c code list --api-backend scapi - -# force OCAPI -b2c code list --api-backend ocapi -``` +`code deploy` (file upload itself), `code download`, and `code watch` always use WebDAV — only the surrounding code-version operations use SCAPI. -Read scopes (`sfcc.scripts`) cover `code list` / discovery; write scopes (`sfcc.scripts.rw`) cover activate, delete, reload, and the `--activate` / `--reload` flags on deploy. `code reload` is implemented backend-agnostically as activate(alternate) + activate(target), so it works under either OCAPI or SCAPI. `code deploy` (file upload itself), `code download`, and `code watch` always use WebDAV — only the surrounding code-version operations route through `apiBackend`. +OCAPI is deprecated and disabled on newer instances. `--api-backend auto` (the default) falls back to the OCAPI Data API only when SCAPI scopes are not configured; force a backend with `--api-backend scapi|ocapi` if needed. `code reload` is implemented as activate(alternate) + activate(target) so it works under either backend, except the `--reload` cache-rebuild uses OCAPI and is unavailable on OCAPI-disabled instances. ### More Commands diff --git a/skills/b2c-cli/skills/b2c-job/SKILL.md b/skills/b2c-cli/skills/b2c-job/SKILL.md index c5f2ad826..ea05362fb 100644 --- a/skills/b2c-cli/skills/b2c-job/SKILL.md +++ b/skills/b2c-cli/skills/b2c-job/SKILL.md @@ -202,26 +202,15 @@ b2c job search --json b2c job execution delete my-job abc123-def456 ``` -### API Backend Selection +### API Backend -Job commands support both OCAPI and SCAPI backends. By default, SCAPI is preferred when `shortCode` and `tenantId` are configured. - -```bash -# force SCAPI backend -b2c job run my-job --api-backend scapi - -# force OCAPI backend -b2c job run my-job --api-backend ocapi - -# auto-detect (default) - prefers SCAPI when configured, falls back to OCAPI -b2c job run my-job --api-backend auto -``` - -Set via dw.json: `"api-backend": "scapi"` or env: `SFCC_API_BACKEND=scapi`. +Job commands run over SCAPI. Configure `shortCode`, `tenantId`, and the SCAPI scopes and `job run`, `job search`, `job wait`, and `job log` work out of the box. **SCAPI scopes**: `sfcc.jobs.rw` (recommended) for full access, or `sfcc.jobs` for read-only (search, wait, log). -> **Note:** `job import` and `job export` currently always use OCAPI regardless of `--api-backend`. +OCAPI is deprecated and disabled on newer instances. `--api-backend auto` (the default) falls back to the OCAPI Data API only when SCAPI scopes are not configured; force a backend with `--api-backend scapi|ocapi`, dw.json `"api-backend": "scapi"`, or `SFCC_API_BACKEND=scapi`. + +> **Note:** `job import` and `job export` trigger system jobs via OCAPI and transfer files over WebDAV; they are not yet available over SCAPI and won't work on OCAPI-disabled instances. ### Wait for Job Completion From 026ba4553b0c0bba4deb6516383581c7a333a01f Mon Sep 17 00:00:00 2001 From: Charles Lavery Date: Mon, 22 Jun 2026 15:05:26 -0400 Subject: [PATCH 14/22] Name the required SCAPI scope in OCAPI-deprecation errors; drop token redaction Remove redactTokens: the leaked token is our own, echoed by the remote OCAPI server only on the deprecated path, so scrubbing it isn't worth the machinery. getApiErrorMessage is back to a pure extractor. The OCAPI-deprecation message now names the exact SCAPI scope the failed operation needs (e.g. 'sfcc.scripts' or 'sfcc.scripts.rw'), via an optional requiredScopes arg threaded through throwOcapiError / OcapiDeprecatedError. Scope constants are sourced from each domain's canonical definitions (derived from the jobs cascade where applicable) so they can't drift. Operations with no SCAPI equivalent (sites, bm whoami/search/access-key, cap) keep the generic 'sfcc.* scopes' wording. Routed bm users/roles and the cartridge-path read through the shared helper so the dual-backend fallbacks surface the guidance too. --- ...i-ocapi-deprecation-and-token-redaction.md | 10 -- .../scapi-ocapi-deprecation-detection.md | 10 ++ .../src/clients/error-utils.ts | 105 ++++++++-------- packages/b2c-tooling-sdk/src/clients/index.ts | 2 +- packages/b2c-tooling-sdk/src/index.ts | 2 +- .../src/operations/bm-roles/roles.ts | 31 +++-- .../src/operations/bm-users/ocapi-backend.ts | 5 +- .../src/operations/bm-users/users.ts | 37 +++--- .../src/operations/cap/install.ts | 10 +- .../src/operations/cap/list.ts | 6 +- .../src/operations/cap/uninstall.ts | 10 +- .../src/operations/code/versions.ts | 12 +- .../src/operations/jobs/run.ts | 21 ++-- .../src/operations/jobs/site-archive.ts | 24 ++-- .../src/operations/sites/cartridges.ts | 3 +- .../b2c-tooling-sdk/src/scaffold/sources.ts | 2 +- .../test/clients/error-utils.test.ts | 113 ++++++++---------- 17 files changed, 206 insertions(+), 197 deletions(-) delete mode 100644 .changeset/scapi-ocapi-deprecation-and-token-redaction.md create mode 100644 .changeset/scapi-ocapi-deprecation-detection.md diff --git a/.changeset/scapi-ocapi-deprecation-and-token-redaction.md b/.changeset/scapi-ocapi-deprecation-and-token-redaction.md deleted file mode 100644 index 29305e738..000000000 --- a/.changeset/scapi-ocapi-deprecation-and-token-redaction.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -'@salesforce/b2c-tooling-sdk': patch -'@salesforce/b2c-cli': patch -'@salesforce/b2c-dx-docs': patch -'@salesforce/b2c-agent-plugins': patch ---- - -Detect deprecated OCAPI instances and stop leaking access tokens in error messages. - -When an instance has OCAPI disabled, commands now fail with an actionable message directing you to configure SCAPI access, instead of an opaque "Failed to ..." error. Bearer tokens that B2C Commerce embeds in some authentication faults are now redacted from user-facing error messages (they were previously printed in full). Documentation and agent skills for `code`, `job`, and `bm` are now SCAPI-first, presenting OCAPI as the deprecated fallback. diff --git a/.changeset/scapi-ocapi-deprecation-detection.md b/.changeset/scapi-ocapi-deprecation-detection.md new file mode 100644 index 000000000..937e817a3 --- /dev/null +++ b/.changeset/scapi-ocapi-deprecation-detection.md @@ -0,0 +1,10 @@ +--- +'@salesforce/b2c-tooling-sdk': patch +'@salesforce/b2c-cli': patch +'@salesforce/b2c-dx-docs': patch +'@salesforce/b2c-agent-plugins': patch +--- + +Detect deprecated OCAPI instances and guide users to SCAPI. + +When an instance has OCAPI disabled, `code`, `job`, `bm`, `sites`, and `cap` commands now fail with an actionable message — naming the exact SCAPI scope the operation needs (e.g. `sfcc.scripts` / `sfcc.scripts.rw`) — instead of an opaque "Failed to ..." error. Documentation and agent skills for `code`, `job`, and `bm` are now SCAPI-first, presenting OCAPI as the deprecated fallback. diff --git a/packages/b2c-tooling-sdk/src/clients/error-utils.ts b/packages/b2c-tooling-sdk/src/clients/error-utils.ts index 0df76c32f..55b651719 100644 --- a/packages/b2c-tooling-sdk/src/clients/error-utils.ts +++ b/packages/b2c-tooling-sdk/src/clients/error-utils.ts @@ -9,15 +9,6 @@ * @module clients/error-utils */ -/** - * Matches JSON Web Tokens (three base64url segments separated by dots, the - * first beginning with `eyJ` — the base64 of `{"`). OCAPI/SLAS faults such as - * `InvalidAccessTokenException` embed the offending bearer token verbatim in - * their human-readable `message`, so any message surfaced to the user must be - * scrubbed of it. - */ -const JWT_PATTERN = /eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g; - /** * The OCAPI `fault.type` returned (with HTTP 403) when an instance has OCAPI * disabled. The platform is progressively deprecating OCAPI; on a deprecated @@ -31,15 +22,41 @@ const SCAPI_SETUP_DOC_URL = 'https://salesforcecommercecloud.github.io/b2c-developer-tooling/guide/authentication.html#scapi-authentication'; /** - * User-facing guidance shown when an instance has OCAPI deprecated. Kept as a - * single constant so the message is identical across every OCAPI-terminal call - * site (it is emitted both by {@link getApiErrorMessage} and - * {@link OcapiDeprecatedError}). + * Renders the scope portion of the deprecation message. When the operation has + * a SCAPI equivalent, names the exact scope(s) that unlock it (e.g. + * `the "sfcc.scripts" or "sfcc.scripts.rw" scope`); otherwise falls back to the + * generic `sfcc.*` phrasing. + */ +function scopeClause(requiredScopes?: string[]): string { + if (!requiredScopes || requiredScopes.length === 0) { + return 'the required sfcc.* scopes'; + } + const quoted = requiredScopes.map((s) => `"${s}"`); + const list = quoted.length === 1 ? quoted[0] : `${quoted.slice(0, -1).join(', ')} or ${quoted[quoted.length - 1]}`; + return `the ${list} scope`; +} + +/** + * Builds the user-facing guidance shown when an instance has OCAPI deprecated. + * + * Pass the SCAPI scope(s) the failed operation requires to name them in the + * message (e.g. an operation needing `sfcc.scripts.rw` tells the user exactly + * which scope to add). Omit `requiredScopes` for operations that have no SCAPI + * equivalent — the message then uses the generic `sfcc.*` phrasing. + */ +export function ocapiDeprecatedMessage(requiredScopes?: string[]): string { + return ( + 'OCAPI is deprecated and disabled for this instance. ' + + `Configure SCAPI access (shortCode, tenantId, and ${scopeClause(requiredScopes)}) on your Account Manager API client to continue. ` + + `See: ${SCAPI_SETUP_DOC_URL}` + ); +} + +/** + * Generic OCAPI deprecation message (no specific scope named). Convenience for + * call sites that surface the guidance directly without an operation scope. */ -export const OCAPI_DEPRECATED_MESSAGE = - 'OCAPI is deprecated and disabled for this instance. ' + - 'Configure SCAPI access (shortCode, tenantId, and the required sfcc.* scopes on your Account Manager API client) to continue. ' + - `See: ${SCAPI_SETUP_DOC_URL}`; +export const OCAPI_DEPRECATED_MESSAGE = ocapiDeprecatedMessage(); /** * Returns true if an API error object is an OCAPI deprecation fault @@ -58,32 +75,23 @@ export function isOcapiDeprecatedFault(error: unknown): boolean { /** * Error thrown when an OCAPI operation fails because OCAPI is deprecated for - * the instance. Carries the actionable {@link OCAPI_DEPRECATED_MESSAGE} so the - * CLI surfaces SCAPI-setup guidance instead of an opaque "Failed to ..." line. + * the instance. Carries actionable SCAPI-setup guidance — including the exact + * scope the failed operation needs, when supplied — so the CLI surfaces a + * helpful message instead of an opaque "Failed to ..." line. * * Thrown by OCAPI-terminal operations (those with no SCAPI fallback, or whose * SCAPI path was already exhausted) when {@link isOcapiDeprecatedFault} matches. */ export class OcapiDeprecatedError extends Error { - constructor(cause?: unknown) { - super(OCAPI_DEPRECATED_MESSAGE, cause === undefined ? undefined : {cause}); + constructor(options: {cause?: unknown; requiredScopes?: string[]} = {}) { + super( + ocapiDeprecatedMessage(options.requiredScopes), + options.cause === undefined ? undefined : {cause: options.cause}, + ); this.name = 'OcapiDeprecatedError'; } } -/** - * Redacts JWT bearer tokens from a free-text string. - * - * Keeps a short `eyJ…` prefix (mirroring the logger's partial-token style) so - * the message still reads sensibly, while removing the credential itself. - * Applied to every user-facing API error message — credentials must never - * reach stdout/stderr/logs outside of debug/trace logging (where the structured - * logger applies its own field-level redaction). - */ -export function redactTokens(text: string): string { - return text.replace(JWT_PATTERN, 'eyJ…[REDACTED-TOKEN]'); -} - /** * Extract a clean error message from an API error response. * @@ -91,10 +99,6 @@ export function redactTokens(text: string): string { * This ensures that HTML response bodies (like error pages) are never * included in user-facing error messages. * - * The returned message is always scrubbed of bearer tokens via - * {@link redactTokens} — some OCAPI faults (e.g. `InvalidAccessTokenException`) - * embed the full JWT in their message text. - * * Supported error patterns: * - ODS/SLAS: `{ error: { message: '...' } }` * - OCAPI: `{ fault: { message: '...' } }` @@ -115,11 +119,6 @@ export function redactTokens(text: string): string { * ``` */ export function getApiErrorMessage(error: unknown, response: Response | {status: number; statusText: string}): string { - // OCAPI deprecation: replace the opaque fault with actionable SCAPI guidance. - if (isOcapiDeprecatedFault(error)) { - return OCAPI_DEPRECATED_MESSAGE; - } - if (error && typeof error === 'object') { const err = error as Record; @@ -127,7 +126,7 @@ export function getApiErrorMessage(error: unknown, response: Response | {status: if (err.error && typeof err.error === 'object') { const nested = err.error as Record; if (typeof nested.message === 'string' && nested.message) { - return redactTokens(nested.message); + return nested.message; } } @@ -135,21 +134,21 @@ export function getApiErrorMessage(error: unknown, response: Response | {status: if (err.fault && typeof err.fault === 'object') { const fault = err.fault as Record; if (typeof fault.message === 'string' && fault.message) { - return redactTokens(fault.message); + return fault.message; } } // SCAPI/Problem+JSON pattern: { detail: '...', title: '...' } if (typeof err.detail === 'string' && err.detail) { - return redactTokens(err.detail); + return err.detail; } if (typeof err.title === 'string' && err.title) { - return redactTokens(err.title); + return err.title; } // Standard Error pattern: { message: '...' } if (typeof err.message === 'string' && err.message) { - return redactTokens(err.message); + return err.message; } } @@ -163,24 +162,28 @@ export function getApiErrorMessage(error: unknown, response: Response | {status: * Centralizes OCAPI-terminal error handling so every call site behaves * consistently: * - OCAPI deprecation faults become an {@link OcapiDeprecatedError} with - * actionable SCAPI-setup guidance (instead of an opaque "Failed to ..."). + * actionable SCAPI-setup guidance, naming `requiredScopes` when the operation + * has a SCAPI equivalent. * - Everything else throws `Error(`${prefix}: ${message}`)` where `message` - * is the token-redacted fault text from {@link getApiErrorMessage}. + * is the fault text from {@link getApiErrorMessage}. * * The original `error` is always attached as `cause` for debug logging. * * @param error - The error object from an openapi-fetch result. * @param response - The HTTP response (for status fallback). * @param prefix - Operation-specific prefix, e.g. `'Failed to list code versions'`. + * @param requiredScopes - SCAPI scope(s) the equivalent operation needs, named + * in the deprecation message. Omit for OCAPI-only operations. * @throws Always throws — return type is `never`. */ export function throwOcapiError( error: unknown, response: Response | {status: number; statusText: string}, prefix: string, + requiredScopes?: string[], ): never { if (isOcapiDeprecatedFault(error)) { - throw new OcapiDeprecatedError(error); + throw new OcapiDeprecatedError({cause: error, requiredScopes}); } throw new Error(`${prefix}: ${getApiErrorMessage(error, response)}`, {cause: error}); } diff --git a/packages/b2c-tooling-sdk/src/clients/index.ts b/packages/b2c-tooling-sdk/src/clients/index.ts index 691ce3f5d..d51ba97d8 100644 --- a/packages/b2c-tooling-sdk/src/clients/index.ts +++ b/packages/b2c-tooling-sdk/src/clients/index.ts @@ -390,11 +390,11 @@ export type {ScopeTier, ScopeTierManagerOptions} from './scapi-scope-tier.js'; export { getApiErrorMessage, - redactTokens, isOcapiDeprecatedFault, throwOcapiError, OcapiDeprecatedError, OCAPI_DEPRECATED_MESSAGE, + ocapiDeprecatedMessage, } from './error-utils.js'; export {createTlsDispatcher} from './tls-dispatcher.js'; diff --git a/packages/b2c-tooling-sdk/src/index.ts b/packages/b2c-tooling-sdk/src/index.ts index e8ce69f10..041859551 100644 --- a/packages/b2c-tooling-sdk/src/index.ts +++ b/packages/b2c-tooling-sdk/src/index.ts @@ -79,11 +79,11 @@ export { normalizeTenantId, buildTenantScope, getApiErrorMessage, - redactTokens, isOcapiDeprecatedFault, throwOcapiError, OcapiDeprecatedError, OCAPI_DEPRECATED_MESSAGE, + ocapiDeprecatedMessage, isValidRoleTenantFilter, fetchRoleMapping, resolveToInternalRole, diff --git a/packages/b2c-tooling-sdk/src/operations/bm-roles/roles.ts b/packages/b2c-tooling-sdk/src/operations/bm-roles/roles.ts index b1df4bf70..4664dc0eb 100644 --- a/packages/b2c-tooling-sdk/src/operations/bm-roles/roles.ts +++ b/packages/b2c-tooling-sdk/src/operations/bm-roles/roles.ts @@ -10,7 +10,12 @@ */ import type {B2CInstance} from '../../instance/index.js'; import type {components} from '../../clients/ocapi.generated.js'; -import {getApiErrorMessage} from '../../clients/error-utils.js'; +import {throwOcapiError} from '../../clients/error-utils.js'; +import {SCAPI_MERCHANT_ROLES_READ_SCOPES, SCAPI_MERCHANT_ROLES_RW_SCOPES} from '../../clients/scapi-merchant-roles.js'; + +// SCAPI Merchant Roles scopes named in the OCAPI-deprecation message. +const ROLES_READ_SCOPES = [...SCAPI_MERCHANT_ROLES_READ_SCOPES, ...SCAPI_MERCHANT_ROLES_RW_SCOPES]; +const ROLES_RW_SCOPES = SCAPI_MERCHANT_ROLES_RW_SCOPES; /** * BM access role from OCAPI. @@ -68,7 +73,7 @@ export async function listBmRoles(instance: B2CInstance, options: ListBmRolesOpt }); if (error) { - throw new Error(`Failed to list roles: ${getApiErrorMessage(error, response)}`, {cause: error}); + throwOcapiError(error, response, 'Failed to list roles', ROLES_READ_SCOPES); } return data as BmRoles; @@ -100,7 +105,7 @@ export async function getBmRole( }); if (error) { - throw new Error(`Failed to get role ${roleId}: ${getApiErrorMessage(error, response)}`, {cause: error}); + throwOcapiError(error, response, `Failed to get role ${roleId}`, ROLES_READ_SCOPES); } return data as BmRole; @@ -130,7 +135,7 @@ export async function createBmRole( }); if (error) { - throw new Error(`Failed to create role ${roleId}: ${getApiErrorMessage(error, response)}`, {cause: error}); + throwOcapiError(error, response, `Failed to create role ${roleId}`, ROLES_RW_SCOPES); } return data as BmRole; @@ -155,7 +160,7 @@ export async function deleteBmRole(instance: B2CInstance, roleId: string): Promi }); if (error) { - throw new Error(`Failed to delete role ${roleId}: ${getApiErrorMessage(error, response)}`, {cause: error}); + throwOcapiError(error, response, `Failed to delete role ${roleId}`, ROLES_RW_SCOPES); } } @@ -182,9 +187,7 @@ export async function grantBmRole( }); if (error) { - throw new Error(`Failed to grant role ${roleId} to ${login}: ${getApiErrorMessage(error, response)}`, { - cause: error, - }); + throwOcapiError(error, response, `Failed to grant role ${roleId} to ${login}`, ROLES_RW_SCOPES); } return data as components['schemas']['user']; @@ -208,9 +211,7 @@ export async function revokeBmRole(instance: B2CInstance, roleId: string, login: }); if (error) { - throw new Error(`Failed to revoke role ${roleId} from ${login}: ${getApiErrorMessage(error, response)}`, { - cause: error, - }); + throwOcapiError(error, response, `Failed to revoke role ${roleId} from ${login}`, ROLES_RW_SCOPES); } } @@ -233,9 +234,7 @@ export async function getBmRolePermissions(instance: B2CInstance, roleId: string }); if (error) { - throw new Error(`Failed to get permissions for role ${roleId}: ${getApiErrorMessage(error, response)}`, { - cause: error, - }); + throwOcapiError(error, response, `Failed to get permissions for role ${roleId}`, ROLES_READ_SCOPES); } return data as BmRolePermissions; @@ -269,9 +268,7 @@ export async function setBmRolePermissions( }); if (error) { - throw new Error(`Failed to set permissions for role ${roleId}: ${getApiErrorMessage(error, response)}`, { - cause: error, - }); + throwOcapiError(error, response, `Failed to set permissions for role ${roleId}`, ROLES_RW_SCOPES); } return data as BmRolePermissions; diff --git a/packages/b2c-tooling-sdk/src/operations/bm-users/ocapi-backend.ts b/packages/b2c-tooling-sdk/src/operations/bm-users/ocapi-backend.ts index 2fb94d01b..691ee2b50 100644 --- a/packages/b2c-tooling-sdk/src/operations/bm-users/ocapi-backend.ts +++ b/packages/b2c-tooling-sdk/src/operations/bm-users/ocapi-backend.ts @@ -19,7 +19,8 @@ import { deleteBmUser as ocapiDeleteBmUser, type BmUser, } from './users.js'; -import {getApiErrorMessage} from '../../clients/error-utils.js'; +import {throwOcapiError} from '../../clients/error-utils.js'; +import {SCAPI_MERCHANT_USERS_RW_SCOPES} from '../../clients/scapi-merchant-users.js'; import type {components} from '../../clients/ocapi.generated.js'; function mapOcapiUser(ocapi: BmUser): UserInfo { @@ -81,7 +82,7 @@ export class OcapiUsersBackend implements UsersBackend { body: body as components['schemas']['user'], }); if (error) { - throw new Error(`Failed to create user ${login}: ${getApiErrorMessage(error, response)}`); + throwOcapiError(error, response, `Failed to create user ${login}`, SCAPI_MERCHANT_USERS_RW_SCOPES); } return mapOcapiUser(data as BmUser); } diff --git a/packages/b2c-tooling-sdk/src/operations/bm-users/users.ts b/packages/b2c-tooling-sdk/src/operations/bm-users/users.ts index 2e7a0ccf7..ce1585c88 100644 --- a/packages/b2c-tooling-sdk/src/operations/bm-users/users.ts +++ b/packages/b2c-tooling-sdk/src/operations/bm-users/users.ts @@ -15,7 +15,14 @@ */ import type {B2CInstance} from '../../instance/index.js'; import type {components} from '../../clients/ocapi.generated.js'; -import {getApiErrorMessage} from '../../clients/error-utils.js'; +import {throwOcapiError} from '../../clients/error-utils.js'; +import {SCAPI_MERCHANT_USERS_READ_SCOPES, SCAPI_MERCHANT_USERS_RW_SCOPES} from '../../clients/scapi-merchant-users.js'; + +// SCAPI Merchant Users scopes named in the OCAPI-deprecation message for the +// dual-backend operations (list/get/update/delete). search, whoami, and the +// access-key operations are OCAPI-only and use the generic guidance. +const USERS_READ_SCOPES = [...SCAPI_MERCHANT_USERS_READ_SCOPES, ...SCAPI_MERCHANT_USERS_RW_SCOPES]; +const USERS_RW_SCOPES = SCAPI_MERCHANT_USERS_RW_SCOPES; /** * BM user from OCAPI. @@ -124,7 +131,7 @@ export async function listBmUsers(instance: B2CInstance, options: ListBmUsersOpt }); if (error) { - throw new Error(`Failed to list users: ${getApiErrorMessage(error, response)}`, {cause: error}); + throwOcapiError(error, response, 'Failed to list users', USERS_READ_SCOPES); } return data as BmUsers; @@ -143,7 +150,7 @@ export async function getBmUser(instance: B2CInstance, login: string): Promise { const {data, error, response} = await instance.ocapi.GET('/users/this'); if (error) { - throw new Error(`Failed to get current user: ${getApiErrorMessage(error, response)}`, {cause: error}); + throwOcapiError(error, response, 'Failed to get current user'); } return data as BmUser; @@ -187,7 +194,7 @@ export async function updateBmUser( }); if (error) { - throw new Error(`Failed to update user ${login}: ${getApiErrorMessage(error, response)}`, {cause: error}); + throwOcapiError(error, response, `Failed to update user ${login}`, USERS_RW_SCOPES); } return data as BmUser; @@ -205,7 +212,7 @@ export async function deleteBmUser(instance: B2CInstance, login: string): Promis }); if (error) { - throw new Error(`Failed to delete user ${login}: ${getApiErrorMessage(error, response)}`, {cause: error}); + throwOcapiError(error, response, `Failed to delete user ${login}`, USERS_RW_SCOPES); } } @@ -285,7 +292,7 @@ export async function searchBmUsers( }); if (error) { - throw new Error(`Failed to search users: ${getApiErrorMessage(error, response)}`, {cause: error}); + throwOcapiError(error, response, 'Failed to search users'); } return data as BmUserSearchResult; @@ -309,9 +316,7 @@ export async function getBmUserAccessKey( }); if (error) { - throw new Error(`Failed to get access key (${scope}) for ${login}: ${getApiErrorMessage(error, response)}`, { - cause: error, - }); + throwOcapiError(error, response, `Failed to get access key (${scope}) for ${login}`); } return data as BmAccessKeyDetails; @@ -339,9 +344,7 @@ export async function createBmUserAccessKey( }); if (error) { - throw new Error(`Failed to create access key (${scope}) for ${login}: ${getApiErrorMessage(error, response)}`, { - cause: error, - }); + throwOcapiError(error, response, `Failed to create access key (${scope}) for ${login}`); } return data as BmAccessKeyDetails; @@ -368,9 +371,7 @@ export async function setBmUserAccessKeyEnabled( }); if (error) { - throw new Error(`Failed to update access key (${scope}) for ${login}: ${getApiErrorMessage(error, response)}`, { - cause: error, - }); + throwOcapiError(error, response, `Failed to update access key (${scope}) for ${login}`); } return data as BmAccessKeyDetails; @@ -389,8 +390,6 @@ export async function deleteBmUserAccessKey(instance: B2CInstance, login: string }); if (error) { - throw new Error(`Failed to delete access key (${scope}) for ${login}: ${getApiErrorMessage(error, response)}`, { - cause: error, - }); + throwOcapiError(error, response, `Failed to delete access key (${scope}) for ${login}`); } } diff --git a/packages/b2c-tooling-sdk/src/operations/cap/install.ts b/packages/b2c-tooling-sdk/src/operations/cap/install.ts index d41c53069..49732d903 100644 --- a/packages/b2c-tooling-sdk/src/operations/cap/install.ts +++ b/packages/b2c-tooling-sdk/src/operations/cap/install.ts @@ -12,7 +12,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import JSZip from 'jszip'; import {B2CInstance} from '../../instance/index.js'; -import {isOcapiDeprecatedFault, OcapiDeprecatedError, redactTokens} from '../../clients/error-utils.js'; +import {isOcapiDeprecatedFault, OcapiDeprecatedError} from '../../clients/error-utils.js'; import {getLogger} from '../../logging/logger.js'; import {waitForJob, JobExecutionError, getJobLog, type JobExecution, type WaitForJobOptions} from '../jobs/run.js'; import {addDirectoryToZip} from '../util/zip.js'; @@ -153,14 +153,14 @@ export async function commerceAppInstall( }); if (retryError || !retryData) { - if (isOcapiDeprecatedFault(retryError)) throw new OcapiDeprecatedError(retryError); - throw new Error(redactTokens(retryError?.fault?.message ?? 'Failed to start install job'), {cause: retryError}); + if (isOcapiDeprecatedFault(retryError)) throw new OcapiDeprecatedError({cause: retryError}); + throw new Error(retryError?.fault?.message ?? 'Failed to start install job', {cause: retryError}); } execution = retryData; } else if (error || !data) { - if (isOcapiDeprecatedFault(error)) throw new OcapiDeprecatedError(error); - throw new Error(redactTokens(error?.fault?.message ?? 'Failed to start install job'), {cause: error}); + if (isOcapiDeprecatedFault(error)) throw new OcapiDeprecatedError({cause: error}); + throw new Error(error?.fault?.message ?? 'Failed to start install job', {cause: error}); } else { execution = data; } diff --git a/packages/b2c-tooling-sdk/src/operations/cap/list.ts b/packages/b2c-tooling-sdk/src/operations/cap/list.ts index ea03cd214..4c9af3742 100644 --- a/packages/b2c-tooling-sdk/src/operations/cap/list.ts +++ b/packages/b2c-tooling-sdk/src/operations/cap/list.ts @@ -14,7 +14,7 @@ import * as path from 'node:path'; import JSZip from 'jszip'; import * as xml2js from 'xml2js'; import {B2CInstance} from '../../instance/index.js'; -import {isOcapiDeprecatedFault, OcapiDeprecatedError, redactTokens} from '../../clients/error-utils.js'; +import {isOcapiDeprecatedFault, OcapiDeprecatedError} from '../../clients/error-utils.js'; import {getLogger} from '../../logging/logger.js'; import {siteArchiveExportToBuffer} from '../jobs/site-archive.js'; import type {JobExecution, WaitForJobOptions} from '../jobs/run.js'; @@ -169,8 +169,8 @@ export async function listInstalledApps( params: {query: {select: '(**)'}}, }); if (error || !data) { - if (isOcapiDeprecatedFault(error)) throw new OcapiDeprecatedError(error); - throw new Error(redactTokens(error?.fault?.message ?? 'Failed to list sites'), {cause: error}); + if (isOcapiDeprecatedFault(error)) throw new OcapiDeprecatedError({cause: error}); + throw new Error(error?.fault?.message ?? 'Failed to list sites', {cause: error}); } siteIds = (data.data ?? []).map((s) => s.id).filter((id): id is string => !!id); logger.debug({siteIds}, `Discovered ${siteIds.length} site(s)`); diff --git a/packages/b2c-tooling-sdk/src/operations/cap/uninstall.ts b/packages/b2c-tooling-sdk/src/operations/cap/uninstall.ts index 5efd2a25d..371944e29 100644 --- a/packages/b2c-tooling-sdk/src/operations/cap/uninstall.ts +++ b/packages/b2c-tooling-sdk/src/operations/cap/uninstall.ts @@ -9,7 +9,7 @@ * Runs the sfcc-uninstall-commerce-app system job to remove an installed CAP. */ import {B2CInstance} from '../../instance/index.js'; -import {isOcapiDeprecatedFault, OcapiDeprecatedError, redactTokens} from '../../clients/error-utils.js'; +import {isOcapiDeprecatedFault, OcapiDeprecatedError} from '../../clients/error-utils.js'; import {getLogger} from '../../logging/logger.js'; import {waitForJob, JobExecutionError, getJobLog, type JobExecution, type WaitForJobOptions} from '../jobs/run.js'; import {normalizeSiteId} from './install.js'; @@ -99,14 +99,14 @@ export async function commerceAppUninstall( }); if (retryError || !retryData) { - if (isOcapiDeprecatedFault(retryError)) throw new OcapiDeprecatedError(retryError); - throw new Error(redactTokens(retryError?.fault?.message ?? 'Failed to start uninstall job'), {cause: retryError}); + if (isOcapiDeprecatedFault(retryError)) throw new OcapiDeprecatedError({cause: retryError}); + throw new Error(retryError?.fault?.message ?? 'Failed to start uninstall job', {cause: retryError}); } execution = retryData; } else if (error || !data) { - if (isOcapiDeprecatedFault(error)) throw new OcapiDeprecatedError(error); - throw new Error(redactTokens(error?.fault?.message ?? 'Failed to start uninstall job'), {cause: error}); + if (isOcapiDeprecatedFault(error)) throw new OcapiDeprecatedError({cause: error}); + throw new Error(error?.fault?.message ?? 'Failed to start uninstall job', {cause: error}); } else { execution = data; } diff --git a/packages/b2c-tooling-sdk/src/operations/code/versions.ts b/packages/b2c-tooling-sdk/src/operations/code/versions.ts index 4382d138b..92865ffa3 100644 --- a/packages/b2c-tooling-sdk/src/operations/code/versions.ts +++ b/packages/b2c-tooling-sdk/src/operations/code/versions.ts @@ -6,6 +6,7 @@ import type {B2CInstance} from '../../instance/index.js'; import {type OcapiComponents} from '../../clients/index.js'; import {throwOcapiError} from '../../clients/error-utils.js'; +import {SCAPI_SCRIPTS_READ_SCOPES, SCAPI_SCRIPTS_RW_SCOPES} from '../../clients/scapi-scripts.js'; import {getLogger} from '../../logging/logger.js'; /** Code version type from OCAPI */ @@ -33,7 +34,10 @@ export async function listCodeVersions(instance: B2CInstance): Promise { // Mock response object for testing const mockResponse = (status: number, statusText: string) => ({ @@ -180,46 +176,6 @@ describe('getApiErrorMessage', () => { }); }); - describe('token redaction (SEC1)', () => { - it('redacts a bearer JWT embedded in an OCAPI InvalidAccessTokenException fault', () => { - const error = { - fault: { - type: 'InvalidAccessTokenException', - message: `Unauthorized request! The access token '${FAKE_JWT}' is invalid.`, - }, - }; - const message = getApiErrorMessage(error, mockResponse(401, 'Unauthorized')); - expect(message).to.not.include(FAKE_JWT); - expect(message).to.include('eyJ…[REDACTED-TOKEN]'); - expect(message).to.include('is invalid.'); - }); - - it('redacts tokens from ODS/SLAS and detail/title/message variants too', () => { - expect(getApiErrorMessage({error: {message: `tok ${FAKE_JWT}`}}, mockResponse(401, 'x'))).to.not.include( - FAKE_JWT, - ); - expect(getApiErrorMessage({detail: `tok ${FAKE_JWT}`}, mockResponse(401, 'x'))).to.not.include(FAKE_JWT); - expect(getApiErrorMessage({title: `tok ${FAKE_JWT}`}, mockResponse(401, 'x'))).to.not.include(FAKE_JWT); - expect(getApiErrorMessage({message: `tok ${FAKE_JWT}`}, mockResponse(401, 'x'))).to.not.include(FAKE_JWT); - }); - }); - - describe('redactTokens', () => { - it('replaces JWTs while preserving surrounding text', () => { - expect(redactTokens(`before ${FAKE_JWT} after`)).to.equal('before eyJ…[REDACTED-TOKEN] after'); - }); - - it('redacts multiple tokens in one string', () => { - const out = redactTokens(`${FAKE_JWT} and ${FAKE_JWT}`); - expect(out).to.not.include(FAKE_JWT); - expect(out.match(/REDACTED-TOKEN/g)).to.have.length(2); - }); - - it('leaves token-free text untouched', () => { - expect(redactTokens('No secrets here')).to.equal('No secrets here'); - }); - }); - describe('OCAPI deprecation (D1)', () => { const deprecatedFault = { fault: { @@ -240,36 +196,71 @@ describe('getApiErrorMessage', () => { expect(isOcapiDeprecatedFault({})).to.equal(false); }); - it('getApiErrorMessage substitutes actionable SCAPI guidance for the deprecation fault', () => { + it('getApiErrorMessage is a pure extractor — it does NOT substitute the deprecation fault', () => { + // Deprecation handling lives at the OCAPI-terminal call sites (throwOcapiError), + // not in the extractor, so the raw fault message comes through here. const message = getApiErrorMessage(deprecatedFault, mockResponse(403, 'Forbidden')); - expect(message).to.equal(OCAPI_DEPRECATED_MESSAGE); - expect(message).to.include('OCAPI is deprecated'); - expect(message).to.include('#scapi-authentication'); - // The original opaque fault message must not be what the user sees. - expect(message).to.not.include('Access is not available'); + expect(message).to.equal('OCAPI has been deprecated. Access is not available for this instance.'); + }); + }); + + describe('ocapiDeprecatedMessage', () => { + it('uses generic sfcc.* phrasing when no scopes are given', () => { + const msg = ocapiDeprecatedMessage(); + expect(msg).to.equal(OCAPI_DEPRECATED_MESSAGE); + expect(msg).to.include('OCAPI is deprecated'); + expect(msg).to.include('the required sfcc.* scopes'); + expect(msg).to.include('#scapi-authentication'); + }); + + it('names a single required scope', () => { + const msg = ocapiDeprecatedMessage(['sfcc.scripts.rw']); + expect(msg).to.include('the "sfcc.scripts.rw" scope'); + }); + + it('lists multiple scopes with "or"', () => { + const msg = ocapiDeprecatedMessage(['sfcc.scripts', 'sfcc.scripts.rw']); + expect(msg).to.include('the "sfcc.scripts" or "sfcc.scripts.rw" scope'); }); }); describe('throwOcapiError', () => { - it('throws OcapiDeprecatedError for the deprecation fault', () => { + it('throws OcapiDeprecatedError for the deprecation fault, naming the operation scope', () => { + const fault = {fault: {type: 'OcapiDeprecatedException', message: 'deprecated'}}; + try { + throwOcapiError(fault, mockResponse(403, 'Forbidden'), 'Failed to list code versions', [ + 'sfcc.scripts', + 'sfcc.scripts.rw', + ]); + expect.fail('should have thrown'); + } catch (e) { + expect(e).to.be.instanceOf(OcapiDeprecatedError); + expect((e as Error).message).to.include('the "sfcc.scripts" or "sfcc.scripts.rw" scope'); + expect((e as Error).cause).to.equal(fault); + } + }); + + it('uses the generic message when no scopes are supplied (OCAPI-only operations)', () => { const fault = {fault: {type: 'OcapiDeprecatedException', message: 'deprecated'}}; - expect(() => throwOcapiError(fault, mockResponse(403, 'Forbidden'), 'Failed to do thing')).to.throw( - OcapiDeprecatedError, - ); + try { + throwOcapiError(fault, mockResponse(403, 'Forbidden'), 'Failed to search users'); + expect.fail('should have thrown'); + } catch (e) { + expect(e).to.be.instanceOf(OcapiDeprecatedError); + expect((e as Error).message).to.include('the required sfcc.* scopes'); + } }); - it('prefixes and redacts non-deprecation errors, attaching cause', () => { - const fault = {fault: {type: 'InvalidAccessTokenException', message: `token ${FAKE_JWT}`}}; + it('prefixes non-deprecation errors with the fault message and attaches cause', () => { + const fault = {fault: {type: 'NotFoundException', message: 'Site not found'}}; try { - throwOcapiError(fault, mockResponse(401, 'Unauthorized'), 'Failed to list code versions'); + throwOcapiError(fault, mockResponse(404, 'Not Found'), 'Failed to list code versions'); expect.fail('should have thrown'); } catch (e) { const err = e as Error; expect(err).to.be.instanceOf(Error); expect(err).to.not.be.instanceOf(OcapiDeprecatedError); - expect(err.message).to.match(/^Failed to list code versions: /); - expect(err.message).to.not.include(FAKE_JWT); - expect(err.message).to.include('eyJ…[REDACTED-TOKEN]'); + expect(err.message).to.equal('Failed to list code versions: Site not found'); expect(err.cause).to.equal(fault); } }); From 4ffe7f978f900a31a37b5cb684df9a5dbd672944 Mon Sep 17 00:00:00 2001 From: Charles Lavery Date: Mon, 22 Jun 2026 18:16:10 -0400 Subject: [PATCH 15/22] feat(sites): add SCAPI backend for sites list and cartridge-path reads Migrate `sites list` and `sites cartridges list` (reads) to a dual backend that prefers the SCAPI site/sites API when shortCode/tenantId/sfcc.sites scopes are configured, falling back to the deprecated OCAPI Data API otherwise. - New SCAPI sites client (site/sites/v1) with read/write scope cascade - ScapiSitesBackend enriches the id-only list response via per-site getSite - OcapiSitesBackend preserves the legacy /sites path and surfaces an OcapiDeprecatedError naming the sfcc.sites scopes on deprecated instances - Cartridge-path writes have no SCAPI equivalent and stay on OCAPI / archive - Docs and the b2c-sites skill are now SCAPI-first --- .changeset/scapi-migration.md | 2 +- docs/cli/sites.md | 20 +- docs/guide/authentication.md | 6 +- .../src/commands/sites/cartridges/list.ts | 19 +- packages/b2c-cli/src/commands/sites/list.ts | 62 +- .../b2c-cli/test/commands/sites/list.test.ts | 38 +- packages/b2c-tooling-sdk/package.json | 2 +- .../b2c-tooling-sdk/specs/site-sites-v1.yaml | 571 ++++++++++++++++++ packages/b2c-tooling-sdk/src/clients/index.ts | 13 + .../src/clients/middleware-registry.ts | 3 +- .../src/clients/scapi-sites.generated.ts | 399 ++++++++++++ .../src/clients/scapi-sites.ts | 51 ++ .../src/operations/sites/index.ts | 7 + .../operations/sites/ocapi-sites-backend.ts | 54 ++ .../operations/sites/scapi-sites-backend.ts | 95 +++ .../src/operations/sites/sites-backend.ts | 24 + .../src/operations/sites/sites-scopes.ts | 16 + .../src/operations/sites/sites-types.ts | 54 ++ .../operations/sites/sites-backend.test.ts | 140 +++++ skills/b2c-cli/skills/b2c-sites/SKILL.md | 4 +- 20 files changed, 1508 insertions(+), 72 deletions(-) create mode 100644 packages/b2c-tooling-sdk/specs/site-sites-v1.yaml create mode 100644 packages/b2c-tooling-sdk/src/clients/scapi-sites.generated.ts create mode 100644 packages/b2c-tooling-sdk/src/clients/scapi-sites.ts create mode 100644 packages/b2c-tooling-sdk/src/operations/sites/ocapi-sites-backend.ts create mode 100644 packages/b2c-tooling-sdk/src/operations/sites/scapi-sites-backend.ts create mode 100644 packages/b2c-tooling-sdk/src/operations/sites/sites-backend.ts create mode 100644 packages/b2c-tooling-sdk/src/operations/sites/sites-scopes.ts create mode 100644 packages/b2c-tooling-sdk/src/operations/sites/sites-types.ts create mode 100644 packages/b2c-tooling-sdk/test/operations/sites/sites-backend.test.ts diff --git a/.changeset/scapi-migration.md b/.changeset/scapi-migration.md index 9130c5ca0..768d7c46c 100644 --- a/.changeset/scapi-migration.md +++ b/.changeset/scapi-migration.md @@ -5,4 +5,4 @@ '@salesforce/b2c-dx-docs': minor --- -Migrate `job`, `code`, `bm users`, and `bm roles` commands to support SCAPI alongside OCAPI. In auto mode (the default), the CLI prefers SCAPI when `shortCode` and `tenantId` are configured and silently falls back to OCAPI if the SCAPI scopes aren't granted. Use `--api-backend ocapi|scapi|auto` or `apiBackend` in dw.json to control explicitly. SCAPI scopes: `sfcc.jobs(.rw)`, `sfcc.scripts(.rw)`, `sfcc.users(.rw)`, `sfcc.roles(.rw)` — read-only scopes are honored for list/get operations, falling back to read-only when the `*.rw` scope is not granted. New `job execution delete` command (SCAPI only). `code deploy` and all VS Code extension code-version actions (list/activate/delete/reload/create plus active-version discovery) also honor `apiBackend`. `bm users update --disabled` transparently falls back to OCAPI in auto mode (SCAPI Users PATCH does not support the `disabled` flag). Auto mode only selects SCAPI when authentication can request the required scopes — stateless OAuth (client-credentials, JWT bearer); stateful and implicit flows fall back to OCAPI unless `--api-backend scapi` is set explicitly. +Migrate `job`, `code`, `bm users`, `bm roles`, and `sites` commands to support SCAPI alongside OCAPI. In auto mode (the default), the CLI prefers SCAPI when `shortCode` and `tenantId` are configured and silently falls back to OCAPI if the SCAPI scopes aren't granted. Use `--api-backend ocapi|scapi|auto` or `apiBackend` in dw.json to control explicitly. SCAPI scopes: `sfcc.jobs(.rw)`, `sfcc.scripts(.rw)`, `sfcc.users(.rw)`, `sfcc.roles(.rw)`, `sfcc.sites(.rw)` — read-only scopes are honored for list/get operations, falling back to read-only when the `*.rw` scope is not granted. `sites list` and `sites cartridges list` (reads) use the SCAPI `site/sites` API; cartridge-path writes have no SCAPI equivalent and remain on OCAPI / site-archive import. New `job execution delete` command (SCAPI only). `code deploy` and all VS Code extension code-version actions (list/activate/delete/reload/create plus active-version discovery) also honor `apiBackend`. `bm users update --disabled` transparently falls back to OCAPI in auto mode (SCAPI Users PATCH does not support the `disabled` flag). Auto mode only selects SCAPI when authentication can request the required scopes — stateless OAuth (client-credentials, JWT bearer); stateful and implicit flows fall back to OCAPI unless `--api-backend scapi` is set explicitly. diff --git a/docs/cli/sites.md b/docs/cli/sites.md index 97883b8eb..b68a97742 100644 --- a/docs/cli/sites.md +++ b/docs/cli/sites.md @@ -8,25 +8,21 @@ Commands for managing sites on B2C Commerce instances. ## Authentication -Sites commands require OAuth authentication with OCAPI permissions for the `/sites` resource. +`sites list` and `sites cartridges list` (reads) run over SCAPI (the `site/sites` API). Configure `shortCode`, `tenantId`, and the `sfcc.sites` / `sfcc.sites.rw` scopes on your API client and they work out of the box. -### Required OCAPI Permissions - -| Resource | Methods | -|----------|---------| -| `/sites` | GET | -| `/sites/*` | GET | -| `/sites/*/cartridges` | POST, PUT, DELETE | - -Cartridge path commands also work without the cartridge-specific OCAPI permissions — they automatically fall back to site archive import/export when direct OCAPI access is unavailable. The fallback requires job execution permissions for `sfcc-site-archive-import` and WebDAV write access to `Impex/`. - -### Configuration +Cartridge-path **writes** (`add`/`remove`/`set`) have no SCAPI equivalent. They use the OCAPI Data API `/sites/*/cartridges` resource, automatically falling back to site archive import/export when direct OCAPI access is unavailable (which requires job execution permissions for `sfcc-site-archive-import` and WebDAV write access to `Impex/`). ```bash export SFCC_CLIENT_ID=your-client-id export SFCC_CLIENT_SECRET=your-client-secret +export SFCC_TENANT_ID=zzxy_prd +export SFCC_SHORTCODE=kv7kzm78 ``` +::: details Legacy OCAPI backend (deprecated) +OCAPI is deprecated and disabled on newer instances. The read commands default to `--api-backend auto`, falling back to the OCAPI `/sites` resource only when SCAPI scopes are not configured; force a backend with `--api-backend scapi|ocapi`. For the OCAPI path, grant GET on `/sites` and `/sites/*`, and POST/PUT/DELETE on `/sites/*/cartridges` for cartridge-path writes. +::: + For complete setup instructions, see the [Authentication Guide](/guide/authentication). --- diff --git a/docs/guide/authentication.md b/docs/guide/authentication.md index 583599ba2..17d0153e2 100644 --- a/docs/guide/authentication.md +++ b/docs/guide/authentication.md @@ -16,7 +16,8 @@ The CLI uses different authentication mechanisms depending on the operation: | [Code](/cli/code) list, activate, delete | OAuth + SCAPI (`sfcc.scripts` / `sfcc.scripts.rw`), OCAPI fallback | [API Client](#account-manager-api-client) + [SCAPI Scopes](#scapi-authentication) (or [OCAPI](#ocapi-configuration)) | | [Jobs](/cli/jobs) | OAuth + SCAPI (`sfcc.jobs` / `sfcc.jobs.rw`), OCAPI fallback | [API Client](#account-manager-api-client) + [SCAPI Scopes](#scapi-authentication) (or [OCAPI](#ocapi-configuration)) | | [BM users / roles](/cli/bm) | OAuth + SCAPI (`sfcc.users(.rw)` / `sfcc.roles(.rw)`), OCAPI fallback | [API Client](#account-manager-api-client) + [SCAPI Scopes](#scapi-authentication) (or [OCAPI](#ocapi-configuration)) | -| [Sites](/cli/sites) | OAuth + OCAPI | [API Client](#account-manager-api-client) + [OCAPI](#ocapi-configuration) | +| [Sites](/cli/sites) list, cartridge path (read) | OAuth + SCAPI (`sfcc.sites` / `sfcc.sites.rw`), OCAPI fallback | [API Client](#account-manager-api-client) + [SCAPI Scopes](#scapi-authentication) (or [OCAPI](#ocapi-configuration)) | +| [Sites](/cli/sites) cartridge path (add/remove/set) | OAuth + OCAPI / site import | [API Client](#account-manager-api-client) + [OCAPI](#ocapi-configuration) | | SCAPI commands ([schemas](/cli/scapi-schemas), [custom-apis](/cli/custom-apis), [eCDN](/cli/ecdn)) | OAuth + SCAPI scopes | [API Client](#account-manager-api-client) + [SCAPI Scopes](#scapi-authentication) | | [CIP analytics](/cli/cip) (`cip query`, `cip report`) | OAuth + Client Credentials | [API Client](#account-manager-api-client) + Salesforce Commerce API role + tenant filter | | [SLAS](/cli/slas) client management | OAuth | None (uses built-in client) or [API Client](#account-manager-api-client) | @@ -500,10 +501,11 @@ SCAPI (the Salesforce Commerce API) is the **preferred, modern** API surface and | `b2c bm users create/update/delete` | `sfcc.users.rw` | [BM](/cli/bm) | | `b2c bm roles list/get` | `sfcc.roles` or `sfcc.roles.rw` | [BM](/cli/bm) | | `b2c bm roles create/delete/grant/revoke/permissions` | `sfcc.roles.rw` | [BM](/cli/bm) | +| `b2c sites list`, `sites cartridges list` | `sfcc.sites` or `sfcc.sites.rw` | [Sites](/cli/sites) | The CLI automatically requests these scopes. Your API client must have them in the Default Scopes list. -The `code`, `jobs`, `bm users`, and `bm roles` commands run over SCAPI. The CLI defaults to `--api-backend auto`, which falls back to the [deprecated OCAPI backend](#ocapi-configuration) only when the SCAPI scopes above are not configured (or not yet provisioned on the API client). Use `--api-backend scapi` or `--api-backend ocapi` to force a backend explicitly. +The `code`, `jobs`, `bm users`, `bm roles`, and `sites` (list + cartridge-path read) commands run over SCAPI. The CLI defaults to `--api-backend auto`, which falls back to the [deprecated OCAPI backend](#ocapi-configuration) only when the SCAPI scopes above are not configured (or not yet provisioned on the API client). Use `--api-backend scapi` or `--api-backend ocapi` to force a backend explicitly. (Cartridge-path **writes** have no SCAPI equivalent and always use OCAPI / site-archive import.) ::: tip For detailed authentication requirements including specific scopes for each command, see the individual [CLI command reference pages](/cli/). diff --git a/packages/b2c-cli/src/commands/sites/cartridges/list.ts b/packages/b2c-cli/src/commands/sites/cartridges/list.ts index 0005f5c3b..0227510e4 100644 --- a/packages/b2c-cli/src/commands/sites/cartridges/list.ts +++ b/packages/b2c-cli/src/commands/sites/cartridges/list.ts @@ -5,7 +5,7 @@ */ import {Flags, ux} from '@oclif/core'; import {InstanceCommand} from '@salesforce/b2c-tooling-sdk/cli'; -import {type CartridgePathResult, BM_SITE_ID, getCartridgePath} from '@salesforce/b2c-tooling-sdk/operations/sites'; +import {type CartridgePathResult, BM_SITE_ID, createSitesBackend} from '@salesforce/b2c-tooling-sdk/operations/sites'; import {t, withDocs} from '../../../i18n/index.js'; export default class SitesCartridgesList extends InstanceCommand { @@ -48,7 +48,22 @@ export default class SitesCartridgesList extends InstanceCommand> = { +const COLUMNS: Record> = { id: { header: 'ID', get: (s) => s.id || '-', }, displayName: { header: 'Display Name', - get: (s) => s.display_name?.default || s.id || '-', + get: (s) => s.displayName || s.id || '-', }, status: { header: 'Status', - get: (s) => s.storefront_status || 'unknown', + get: (s) => s.storefrontStatus || 'unknown', }, }; @@ -41,6 +33,12 @@ const DEFAULT_COLUMNS = ['id', 'displayName', 'status']; const tableRenderer = new TableRenderer(COLUMNS); +interface SitesListResult { + count: number; + data: SiteInfo[]; + total: number; +} + export default class SitesList extends InstanceCommand { static description = withDocs( t('commands.sites.list.description', 'List sites on a B2C Commerce instance'), @@ -61,46 +59,38 @@ export default class SitesList extends InstanceCommand { ...columnFlagsFor(COLUMNS), }; - async run(): Promise { + async run(): Promise { this.requireOAuthCredentials(); const hostname = this.resolvedConfig.values.hostname!; + const backend = createSitesBackend({ + preference: this.apiBackendPreference, + instance: this.instance, + shortCode: this.resolvedConfig.values.shortCode, + tenantId: this.resolvedConfig.values.tenantId, + auth: this.hasScapiConfig() ? this.getOAuthStrategy() : undefined, + }); + this.logger.debug(`Using ${backend.name} backend for sites list`); this.log(t('commands.sites.list.fetching', 'Fetching sites from {{hostname}}...', {hostname})); - const {data, error, response} = await this.instance.ocapi.GET('/sites', { - params: {query: {select: '(**)'}}, - }); - - if (error) { - if (isOcapiDeprecatedFault(error)) { - this.error(OCAPI_DEPRECATED_MESSAGE); - } - this.error( - t('commands.sites.list.error', 'Failed to fetch sites: {{message}}', { - message: getApiErrorMessage(error, response), - }), - ); - } + const sites = await backend.listSites(); - const sites = data as Sites; + const result: SitesListResult = {count: sites.length, data: sites, total: sites.length}; // In JSON mode, just return the data - oclif handles output to stdout if (this.jsonEnabled()) { - return sites; + return result; } // Human-readable table output to stdout - if (!sites || sites.count === 0) { + if (sites.length === 0) { ux.stdout(t('commands.sites.list.noSites', 'No sites found.')); - return sites; + return result; } - tableRenderer.render( - sites.data ?? [], - selectColumns(this.flags, tableRenderer, DEFAULT_COLUMNS, this.warn.bind(this)), - ); + tableRenderer.render(sites, selectColumns(this.flags, tableRenderer, DEFAULT_COLUMNS, this.warn.bind(this))); - return sites; + return result; } } diff --git a/packages/b2c-cli/test/commands/sites/list.test.ts b/packages/b2c-cli/test/commands/sites/list.test.ts index 95aebe740..3efcd8439 100644 --- a/packages/b2c-cli/test/commands/sites/list.test.ts +++ b/packages/b2c-cli/test/commands/sites/list.test.ts @@ -22,14 +22,14 @@ describe('sites list', () => { return createTestCommand(SitesList, hooks.getConfig(), flags, args); } + // With no shortCode/tenantId/auth configured, the dual-backend factory + // resolves to the OCAPI backend, which reads `/sites?select=(**)`. function stubCommon(command: any, {jsonEnabled}: {jsonEnabled: boolean}) { sinon.stub(command, 'requireOAuthCredentials').returns(void 0); sinon.stub(command, 'resolvedConfig').get(() => ({values: {hostname: 'example.com'}})); sinon.stub(command, 'jsonEnabled').returns(jsonEnabled); - } - - function stubErrorToThrow(command: any) { - return sinon.stub(command, 'error').throws(new Error('Expected error')); + sinon.stub(command, 'hasScapiConfig').returns(false); + sinon.stub(command, 'apiBackendPreference').get(() => 'auto'); } it('returns data in JSON mode', async () => { @@ -37,21 +37,26 @@ describe('sites list', () => { stubCommon(command, {jsonEnabled: true}); - const ocapiGet = sinon.stub().resolves({data: {count: 1, data: [{id: 'site1'}]}, error: undefined}); + const ocapiGet = sinon.stub().resolves({ + data: {count: 1, data: [{id: 'site1', display_name: {default: 'Site One'}, storefront_status: 'online'}]}, + error: undefined, + response: {status: 200}, + }); sinon.stub(command, 'instance').get(() => ({ocapi: {GET: ocapiGet}})); const result = await command.run(); expect(result.count).to.equal(1); + expect(result.data[0].id).to.equal('site1'); expect(ocapiGet.calledOnce).to.equal(true); }); - it('prints "no sites" message when count is 0 in non-JSON mode', async () => { + it('prints "no sites" message when there are no sites in non-JSON mode', async () => { const command: any = await createCommand(); stubCommon(command, {jsonEnabled: false}); sinon.stub(command, 'log').returns(void 0); - const ocapiGet = sinon.stub().resolves({data: {count: 0, data: []}, error: undefined}); + const ocapiGet = sinon.stub().resolves({data: {count: 0, data: []}, error: undefined, response: {status: 200}}); sinon.stub(command, 'instance').get(() => ({ocapi: {GET: ocapiGet}})); const stdoutStub = sinon.stub(ux, 'stdout').returns(void 0 as any); @@ -66,22 +71,23 @@ describe('sites list', () => { expect(stdoutOutput).to.include('No sites found'); }); - it('calls command.error when ocapi returns error', async () => { + it('throws when the backend returns an error', async () => { const command: any = await createCommand(); - sinon.stub(command, 'requireOAuthCredentials').returns(void 0); - sinon.stub(command, 'resolvedConfig').get(() => ({values: {hostname: 'example.com'}})); + stubCommon(command, {jsonEnabled: false}); + sinon.stub(command, 'log').returns(void 0); - const ocapiGet = sinon.stub().resolves({data: undefined, error: {message: 'boom'}}); + const ocapiGet = sinon + .stub() + .resolves({data: undefined, error: {fault: {message: 'boom'}}, response: {status: 500}}); sinon.stub(command, 'instance').get(() => ({ocapi: {GET: ocapiGet}})); - const errorStub = stubErrorToThrow(command); - + // The OCAPI backend throws on error; the command surfaces it via catch(). try { await command.run(); - expect.fail('Expected error'); - } catch { - expect(errorStub.calledOnce).to.equal(true); + expect.fail('Expected the run to throw'); + } catch (error) { + expect((error as Error).message).to.include('boom'); } }); }); diff --git a/packages/b2c-tooling-sdk/package.json b/packages/b2c-tooling-sdk/package.json index c4afb1c8a..3e9511d15 100644 --- a/packages/b2c-tooling-sdk/package.json +++ b/packages/b2c-tooling-sdk/package.json @@ -213,7 +213,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 && openapi-typescript specs/granular-replications-v1.yaml -o src/clients/granular-replications.generated.ts && openapi-typescript specs/operations-jobs-v1.yaml -o src/clients/scapi-jobs.generated.ts && openapi-typescript specs/dx-scripts-v1.yaml -o src/clients/scapi-scripts.generated.ts && openapi-typescript specs/merchant-users-v1.yaml -o src/clients/scapi-merchant-users.generated.ts && openapi-typescript specs/merchant-roles-v1.yaml -o src/clients/scapi-merchant-roles.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/granular-replications-v1.yaml -o src/clients/granular-replications.generated.ts && openapi-typescript specs/operations-jobs-v1.yaml -o src/clients/scapi-jobs.generated.ts && openapi-typescript specs/dx-scripts-v1.yaml -o src/clients/scapi-scripts.generated.ts && openapi-typescript specs/merchant-users-v1.yaml -o src/clients/scapi-merchant-users.generated.ts && openapi-typescript specs/merchant-roles-v1.yaml -o src/clients/scapi-merchant-roles.generated.ts && openapi-typescript specs/site-sites-v1.yaml -o src/clients/scapi-sites.generated.ts", "build": "pnpm run generate:types && pnpm run build:esm", "build:esm": "tsc -p tsconfig.esm.json", "clean": "shx rm -rf dist", diff --git a/packages/b2c-tooling-sdk/specs/site-sites-v1.yaml b/packages/b2c-tooling-sdk/specs/site-sites-v1.yaml new file mode 100644 index 000000000..7619d31f7 --- /dev/null +++ b/packages/b2c-tooling-sdk/specs/site-sites-v1.yaml @@ -0,0 +1,571 @@ +openapi: 3.0.3 +info: + title: Sites + version: 1.2.0 + x-api-type: Admin + x-api-family: Site +servers: + - url: "https://{shortCode}.api.commercecloud.salesforce.com/site/sites/v1" + variables: + shortCode: + default: shortCode +paths: + /organizations/{organizationId}/site-search: + post: + operationId: searchSites + parameters: + - name: organizationId + in: path + required: true + style: simple + explode: false + schema: + $ref: "#/components/schemas/OrganizationId" + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/SiteSearchRequest" + required: true + responses: + 200: + description: Returns site search results + content: + application/json: + schema: + $ref: "#/components/schemas/SiteSearchResult" + 400: + description: Bad Request - Malformed search query or invalid parameters + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ErrorResponse" + 401: + description: Your access token is invalid or expired and can’t be used to identify a user. + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ErrorResponse" + 403: + description: Forbidden. Your access token is valid, but you don’t have the required permissions to access the resource. + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ErrorResponse" + security: + - AmOAuth2: [sfcc.sites, sfcc.sites.rw] + /organizations/{organizationId}/sites: + get: + operationId: getSites + parameters: + - name: organizationId + in: path + required: true + style: simple + explode: false + schema: + $ref: "#/components/schemas/OrganizationId" + - name: select + in: query + required: false + style: form + explode: true + schema: + $ref: "#/components/schemas/Select" + - name: limit + in: query + required: false + style: form + explode: true + schema: + type: integer + format: int32 + default: 25 + maximum: 50 + minimum: 1 + - name: offset + in: query + required: false + style: form + explode: true + schema: + type: integer + format: int32 + default: 0 + minimum: 0 + responses: + 200: + description: Returns a paginated list of sites + content: + application/json: + schema: + $ref: "#/components/schemas/Sites" + 401: + description: Your access token is invalid or expired and can’t be used to identify a user. + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ErrorResponse" + 403: + description: Forbidden. Your access token is valid, but you don’t have the required permissions to access the resource. + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ErrorResponse" + security: + - AmOAuth2: [sfcc.sites, sfcc.sites.rw] + /organizations/{organizationId}/sites/{siteId}: + get: + operationId: getSiteById + parameters: + - name: organizationId + in: path + required: true + style: simple + explode: false + schema: + $ref: "#/components/schemas/OrganizationId" + - name: siteId + in: path + required: true + style: simple + explode: false + schema: + $ref: "#/components/schemas/SiteId" + responses: + 200: + description: Returns the requested site + content: + application/json: + schema: + $ref: "#/components/schemas/Site" + 401: + description: Your access token is invalid or expired and can’t be used to identify a user. + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ErrorResponse" + 403: + description: Forbidden. Your access token is valid, but you don’t have the required permissions to access the resource. + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ErrorResponse" + 404: + description: Site Not Found - The requested site ID does not exist + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ErrorResponse" + security: + - AmOAuth2: [sfcc.sites, sfcc.sites.rw] +components: + schemas: + OrganizationId: + type: string + maxLength: 32 + minLength: 1 + Query: + type: object + additionalProperties: false + maxProperties: 1 + minProperties: 1 + properties: + boolQuery: + $ref: "#/components/schemas/BoolQuery" + filteredQuery: + $ref: "#/components/schemas/FilteredQuery" + matchAllQuery: + $ref: "#/components/schemas/MatchAllQuery" + nestedQuery: + $ref: "#/components/schemas/NestedQuery" + termQuery: + $ref: "#/components/schemas/TermQuery" + textQuery: + $ref: "#/components/schemas/TextQuery" + BoolQuery: + type: object + additionalProperties: false + properties: + must: + type: array + items: + $ref: "#/components/schemas/Query" + type: string + mustNot: + type: array + items: + $ref: "#/components/schemas/Query" + type: string + should: + type: array + items: + $ref: "#/components/schemas/Query" + type: string + Filter: + type: object + additionalProperties: false + maxProperties: 1 + minProperties: 1 + properties: + boolFilter: + $ref: "#/components/schemas/BoolFilter" + queryFilter: + $ref: "#/components/schemas/QueryFilter" + range2Filter: + $ref: "#/components/schemas/Range2Filter" + rangeFilter: + $ref: "#/components/schemas/RangeFilter" + termFilter: + $ref: "#/components/schemas/TermFilter" + BoolFilter: + type: object + additionalProperties: false + properties: + filters: + type: array + items: + $ref: "#/components/schemas/Filter" + type: string + operator: + type: string + enum: [and, or, not] + required: [operator] + QueryFilter: + type: object + properties: + query: + $ref: "#/components/schemas/Query" + required: [query] + Field: + type: string + maxLength: 260 + Range2Filter: + type: object + additionalProperties: false + properties: + filterMode: + type: string + default: overlap + enum: [overlap, containing, contained] + fromField: + allOf: + - $ref: "#/components/schemas/Field" + fromInclusive: + type: boolean + default: true + fromValue: {} + toField: + allOf: + - $ref: "#/components/schemas/Field" + toInclusive: + type: boolean + default: true + toValue: {} + required: [fromField, toField] + RangeFilter: + type: object + properties: + field: + allOf: + - $ref: "#/components/schemas/Field" + from: + oneOf: + - type: string + format: date-time + - type: integer + - type: number + fromInclusive: + type: boolean + default: true + to: + oneOf: + - type: string + format: date-time + - type: integer + - type: number + toInclusive: + type: boolean + default: true + required: [field] + TermFilter: + type: object + additionalProperties: false + properties: + field: + allOf: + - $ref: "#/components/schemas/Field" + operator: + type: string + enum: [is, one_of, is_null, is_not_null, less, greater, not_in, neq] + values: + type: array + items: + type: string + required: [field, operator] + FilteredQuery: + type: object + additionalProperties: false + properties: + filter: + $ref: "#/components/schemas/Filter" + query: + $ref: "#/components/schemas/Query" + required: [filter, query] + MatchAllQuery: + type: object + NestedQuery: + type: object + additionalProperties: false + properties: + path: + type: string + maxLength: 2048 + query: + $ref: "#/components/schemas/Query" + scoreMode: + type: string + enum: [avg, total, max, none] + required: [path, query] + TermQuery: + type: object + properties: + fields: + type: array + items: + $ref: "#/components/schemas/Field" + type: string + minItems: 1 + operator: + type: string + enum: [is, one_of, is_null, is_not_null, less, greater, not_in, neq] + values: + type: array + items: + oneOf: + - type: string + - type: number + - type: boolean + - type: integer + type: string + required: [fields, operator] + TextQuery: + type: object + additionalProperties: false + properties: + fields: + type: array + items: + $ref: "#/components/schemas/Field" + type: string + minItems: 1 + searchPhrase: + type: string + required: [fields, searchPhrase] + Sort: + type: object + additionalProperties: false + properties: + field: + type: string + maxLength: 256 + sortOrder: + type: string + default: asc + enum: [asc, desc] + required: [field] + Offset: + type: integer + format: int32 + default: 0 + minimum: 0 + SearchRequest: + type: object + properties: + limit: + type: integer + format: int32 + maximum: 200 + minimum: 1 + query: + $ref: "#/components/schemas/Query" + sorts: + type: array + items: + $ref: "#/components/schemas/Sort" + type: string + offset: + $ref: "#/components/schemas/Offset" + required: [query] + SiteSearchRequest: + allOf: + - $ref: "#/components/schemas/SearchRequest" + Total: + type: integer + format: int32 + default: 0 + minimum: 0 + ResultBase: + type: object + properties: + limit: + type: integer + format: int32 + total: + $ref: "#/components/schemas/Total" + required: [limit, total] + PaginatedResultBase: + allOf: + - $ref: "#/components/schemas/ResultBase" + properties: + offset: + $ref: "#/components/schemas/Offset" + required: [limit, offset, total] + PaginatedSearchResult: + additionalProperties: false + allOf: + - $ref: "#/components/schemas/PaginatedResultBase" + properties: + query: + $ref: "#/components/schemas/Query" + sorts: + type: array + items: + $ref: "#/components/schemas/Sort" + type: string + hits: + type: array + items: + type: object + required: [query] + SiteId: + type: string + maxLength: 32 + minLength: 1 + CustomerListLink: + type: object + properties: + customerListId: + type: string + maxLength: 256 + minLength: 1 + title: + type: string + maxLength: 256 + Site: + type: object + properties: + id: + allOf: + - $ref: "#/components/schemas/SiteId" + displayName: + type: object + additionalProperties: + type: string + maxLength: 4000 + description: + type: object + additionalProperties: + type: string + maxLength: 4000 + customerListLink: + allOf: + - $ref: "#/components/schemas/CustomerListLink" + inDeletion: + type: boolean + storefrontStatus: + type: string + enum: [online, maintenance, to_be_deleted, protected] + siteCatalogId: + type: string + maxLength: 256 + minLength: 1 + cartridges: + type: string + maxLength: 4000 + creationDate: + type: string + format: date-time + lastModified: + type: string + format: date-time + required: [id] + SiteSearchResult: + allOf: + - $ref: "#/components/schemas/PaginatedSearchResult" + properties: + hits: + type: array + items: + $ref: "#/components/schemas/Site" + type: string + required: [hits, query] + ErrorResponse: + type: object + additionalProperties: true + properties: + title: + type: string + maxLength: 256 + type: + type: string + maxLength: 2048 + detail: + type: string + instance: + type: string + maxLength: 2048 + required: [detail, title, type] + Select: + type: string + minLength: 1 + pattern: ^[(].*[)]$ + Sites: + allOf: + - $ref: "#/components/schemas/PaginatedResultBase" + properties: + data: + type: array + items: + $ref: "#/components/schemas/Site" + type: string + required: [data] + responses: + 401unauthorized: + description: Your access token is invalid or expired and can’t be used to identify a user. + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ErrorResponse" + 403forbidden: + description: Forbidden. Your access token is valid, but you don’t have the required permissions to access the resource. + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ErrorResponse" + parameters: + organizationId: + name: organizationId + in: path + required: true + style: simple + explode: false + schema: + $ref: "#/components/schemas/OrganizationId" + select: + name: select + in: query + required: false + style: form + explode: true + schema: + $ref: "#/components/schemas/Select" + securitySchemes: + AmOAuth2: + type: oauth2 + flows: + clientCredentials: + tokenUrl: "https://account.demandware.com/dw/oauth2/access_token" + scopes: + sfcc.sites: Access to site resources + sfcc.sites.rw: Read and write access to site resources diff --git a/packages/b2c-tooling-sdk/src/clients/index.ts b/packages/b2c-tooling-sdk/src/clients/index.ts index d51ba97d8..c384cba55 100644 --- a/packages/b2c-tooling-sdk/src/clients/index.ts +++ b/packages/b2c-tooling-sdk/src/clients/index.ts @@ -377,6 +377,19 @@ export type { components as ScapiScriptsComponents, } from './scapi-scripts.js'; +// SCAPI Sites +export {createScapiSitesClient, SCAPI_SITES_CASCADE} from './scapi-sites.js'; +export type { + ScapiSitesClient, + ScapiSitesClientConfig, + ScapiSitesError, + Site as ScapiSite, + Sites as ScapiSites, + SiteSearchResult as ScapiSiteSearchResult, + paths as ScapiSitesPaths, + components as ScapiSitesComponents, +} from './scapi-sites.js'; + // SCAPI dual-backend utilities (shared across SCAPI/OCAPI domains) export {isInvalidScopeError, resolveScapiOrOcapi, withScopes} from './scapi-backend-utils.js'; export type {ApiBackendPreference, BackendBase, ResolveBackendOptions} from './scapi-backend-utils.js'; diff --git a/packages/b2c-tooling-sdk/src/clients/middleware-registry.ts b/packages/b2c-tooling-sdk/src/clients/middleware-registry.ts index fa428b7c2..9418070f2 100644 --- a/packages/b2c-tooling-sdk/src/clients/middleware-registry.ts +++ b/packages/b2c-tooling-sdk/src/clients/middleware-registry.ts @@ -63,7 +63,8 @@ export type HttpClientType = | 'scapi-jobs' | 'scapi-scripts' | 'scapi-merchant-users' - | 'scapi-merchant-roles'; + | 'scapi-merchant-roles' + | 'scapi-sites'; /** * Middleware interface compatible with openapi-fetch. diff --git a/packages/b2c-tooling-sdk/src/clients/scapi-sites.generated.ts b/packages/b2c-tooling-sdk/src/clients/scapi-sites.generated.ts new file mode 100644 index 000000000..8c968eb8c --- /dev/null +++ b/packages/b2c-tooling-sdk/src/clients/scapi-sites.generated.ts @@ -0,0 +1,399 @@ +/** + * This file was auto-generated by openapi-typescript. + * Do not make direct changes to the file. + */ + +export interface paths { + "/organizations/{organizationId}/site-search": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["searchSites"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/organizations/{organizationId}/sites": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getSites"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/organizations/{organizationId}/sites/{siteId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getSiteById"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +export type webhooks = Record; +export interface components { + schemas: { + OrganizationId: string; + Query: { + boolQuery?: components["schemas"]["BoolQuery"]; + filteredQuery?: components["schemas"]["FilteredQuery"]; + matchAllQuery?: components["schemas"]["MatchAllQuery"]; + nestedQuery?: components["schemas"]["NestedQuery"]; + termQuery?: components["schemas"]["TermQuery"]; + textQuery?: components["schemas"]["TextQuery"]; + }; + BoolQuery: { + must?: components["schemas"]["Query"][]; + mustNot?: components["schemas"]["Query"][]; + should?: components["schemas"]["Query"][]; + }; + Filter: { + boolFilter?: components["schemas"]["BoolFilter"]; + queryFilter?: components["schemas"]["QueryFilter"]; + range2Filter?: components["schemas"]["Range2Filter"]; + rangeFilter?: components["schemas"]["RangeFilter"]; + termFilter?: components["schemas"]["TermFilter"]; + }; + BoolFilter: { + filters?: components["schemas"]["Filter"][]; + /** @enum {string} */ + operator: "and" | "or" | "not"; + }; + QueryFilter: { + query: components["schemas"]["Query"]; + }; + Field: string; + Range2Filter: { + /** + * @default overlap + * @enum {string} + */ + filterMode: "overlap" | "containing" | "contained"; + fromField: components["schemas"]["Field"]; + /** @default true */ + fromInclusive: boolean; + fromValue?: unknown; + toField: components["schemas"]["Field"]; + /** @default true */ + toInclusive: boolean; + toValue?: unknown; + }; + RangeFilter: { + field: components["schemas"]["Field"]; + from?: string | number; + /** @default true */ + fromInclusive: boolean; + to?: string | number; + /** @default true */ + toInclusive: boolean; + }; + TermFilter: { + field: components["schemas"]["Field"]; + /** @enum {string} */ + operator: "is" | "one_of" | "is_null" | "is_not_null" | "less" | "greater" | "not_in" | "neq"; + values?: string[]; + }; + FilteredQuery: { + filter: components["schemas"]["Filter"]; + query: components["schemas"]["Query"]; + }; + MatchAllQuery: Record; + NestedQuery: { + path: string; + query: components["schemas"]["Query"]; + /** @enum {string} */ + scoreMode?: "avg" | "total" | "max" | "none"; + }; + TermQuery: { + fields: components["schemas"]["Field"][]; + /** @enum {string} */ + operator: "is" | "one_of" | "is_null" | "is_not_null" | "less" | "greater" | "not_in" | "neq"; + values?: (string | number | boolean)[]; + }; + TextQuery: { + fields: components["schemas"]["Field"][]; + searchPhrase: string; + }; + Sort: { + field: string; + /** + * @default asc + * @enum {string} + */ + sortOrder: "asc" | "desc"; + }; + /** + * Format: int32 + * @default 0 + */ + Offset: number; + SearchRequest: { + /** Format: int32 */ + limit?: number; + query: components["schemas"]["Query"]; + sorts?: components["schemas"]["Sort"][]; + offset?: components["schemas"]["Offset"]; + }; + SiteSearchRequest: components["schemas"]["SearchRequest"]; + /** + * Format: int32 + * @default 0 + */ + Total: number; + ResultBase: { + /** Format: int32 */ + limit: number; + total: components["schemas"]["Total"]; + }; + PaginatedResultBase: { + offset: components["schemas"]["Offset"]; + } & WithRequired; + PaginatedSearchResult: { + query: components["schemas"]["Query"]; + sorts?: components["schemas"]["Sort"][]; + hits?: Record[]; + } & components["schemas"]["PaginatedResultBase"]; + SiteId: string; + CustomerListLink: { + customerListId?: string; + title?: string; + }; + Site: { + id: components["schemas"]["SiteId"]; + displayName?: { + [key: string]: string; + }; + description?: { + [key: string]: string; + }; + customerListLink?: components["schemas"]["CustomerListLink"]; + inDeletion?: boolean; + /** @enum {string} */ + storefrontStatus?: "online" | "maintenance" | "to_be_deleted" | "protected"; + siteCatalogId?: string; + cartridges?: string; + /** Format: date-time */ + creationDate?: string; + /** Format: date-time */ + lastModified?: string; + }; + SiteSearchResult: { + hits: components["schemas"]["Site"][]; + } & WithRequired; + ErrorResponse: { + title: string; + type: string; + detail: string; + instance?: string; + } & { + [key: string]: unknown; + }; + Select: string; + Sites: { + data: components["schemas"]["Site"][]; + } & components["schemas"]["PaginatedResultBase"]; + }; + responses: { + /** @description Your access token is invalid or expired and can’t be used to identify a user. */ + "401unauthorized": { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Forbidden. Your access token is valid, but you don’t have the required permissions to access the resource. */ + "403forbidden": { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + parameters: { + organizationId: components["schemas"]["OrganizationId"]; + select: components["schemas"]["Select"]; + }; + requestBodies: never; + headers: never; + pathItems: never; +} +export type $defs = Record; +export interface operations { + searchSites: { + parameters: { + query?: never; + header?: never; + path: { + organizationId: components["schemas"]["OrganizationId"]; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SiteSearchRequest"]; + }; + }; + responses: { + /** @description Returns site search results */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SiteSearchResult"]; + }; + }; + /** @description Bad Request - Malformed search query or invalid parameters */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Your access token is invalid or expired and can’t be used to identify a user. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Forbidden. Your access token is valid, but you don’t have the required permissions to access the resource. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + getSites: { + parameters: { + query?: { + select?: components["schemas"]["Select"]; + limit?: number; + offset?: number; + }; + header?: never; + path: { + organizationId: components["schemas"]["OrganizationId"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Returns a paginated list of sites */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Sites"]; + }; + }; + /** @description Your access token is invalid or expired and can’t be used to identify a user. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Forbidden. Your access token is valid, but you don’t have the required permissions to access the resource. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + getSiteById: { + parameters: { + query?: never; + header?: never; + path: { + organizationId: components["schemas"]["OrganizationId"]; + siteId: components["schemas"]["SiteId"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Returns the requested site */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Site"]; + }; + }; + /** @description Your access token is invalid or expired and can’t be used to identify a user. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Forbidden. Your access token is valid, but you don’t have the required permissions to access the resource. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Site Not Found - The requested site ID does not exist */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; +} +type WithRequired = T & { + [P in K]-?: T[P]; +}; diff --git a/packages/b2c-tooling-sdk/src/clients/scapi-sites.ts b/packages/b2c-tooling-sdk/src/clients/scapi-sites.ts new file mode 100644 index 000000000..7852f8574 --- /dev/null +++ b/packages/b2c-tooling-sdk/src/clients/scapi-sites.ts @@ -0,0 +1,51 @@ +/* + * 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 type {Client} from 'openapi-fetch'; +import type {AuthStrategy} from '../auth/types.js'; +import type {paths, components} from './scapi-sites.generated.js'; +import {buildScapiClient, type ScapiClientConfig} from './scapi-client-factory.js'; +import {buildTenantScope, toOrganizationId, normalizeTenantId} from './custom-apis.js'; +import type {ScopeCascade} from './middleware.js'; + +export {toOrganizationId, normalizeTenantId, buildTenantScope}; + +export type {paths, components}; +export type ScapiSitesClient = Client; +export type ScapiSitesError = components['schemas']['ErrorResponse']; + +export type Site = components['schemas']['Site']; +export type Sites = components['schemas']['Sites']; +export type SiteSearchResult = components['schemas']['SiteSearchResult']; + +/** + * Per-operation scope cascade for SCAPI Sites. + * + * The Sites API is read-only (list, get, search), but exposes both a + * read-only (`sfcc.sites`) and read-write (`sfcc.sites.rw`) scope. A given API + * client may have been granted only one of them, so reads try `rw` first + * (which also grants read) and fall back to the read-only scope. There are no + * write operations, but the `write` tier is defined for completeness so the + * cascade type is satisfied. + */ +export const SCAPI_SITES_CASCADE: ScopeCascade = { + read: [['sfcc.sites.rw'], ['sfcc.sites']], + write: [['sfcc.sites.rw']], +}; + +export type ScapiSitesClientConfig = ScapiClientConfig; + +export function createScapiSitesClient(config: ScapiSitesClientConfig, auth: AuthStrategy): ScapiSitesClient { + return buildScapiClient( + { + pathSegment: 'site/sites/v1', + domainKey: 'scapi-sites', + scopeCascade: SCAPI_SITES_CASCADE, + logPrefix: 'SCAPI-SITES', + }, + config, + auth, + ); +} diff --git a/packages/b2c-tooling-sdk/src/operations/sites/index.ts b/packages/b2c-tooling-sdk/src/operations/sites/index.ts index 45c2d83d0..72e6c1fbc 100644 --- a/packages/b2c-tooling-sdk/src/operations/sites/index.ts +++ b/packages/b2c-tooling-sdk/src/operations/sites/index.ts @@ -55,3 +55,10 @@ export type { CartridgePosition, CartridgeUpdateOptions, } from './cartridges.js'; + +// Site read operations (list/get) — SCAPI with OCAPI fallback +export {createSitesBackend} from './sites-backend.js'; +export type {SitesBackendConfig} from './sites-backend.js'; +export {ScapiSitesBackend} from './scapi-sites-backend.js'; +export {OcapiSitesBackend} from './ocapi-sites-backend.js'; +export type {SitesBackend, SiteInfo, ListSitesOptions} from './sites-types.js'; diff --git a/packages/b2c-tooling-sdk/src/operations/sites/ocapi-sites-backend.ts b/packages/b2c-tooling-sdk/src/operations/sites/ocapi-sites-backend.ts new file mode 100644 index 000000000..4d37f243f --- /dev/null +++ b/packages/b2c-tooling-sdk/src/operations/sites/ocapi-sites-backend.ts @@ -0,0 +1,54 @@ +/* + * 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 type {B2CInstance} from '../../instance/index.js'; +import type {OcapiComponents} from '../../clients/index.js'; +import {throwOcapiError} from '../../clients/error-utils.js'; +import {SCAPI_SITES_READ_AND_RW_SCOPES} from './sites-scopes.js'; +import type {SitesBackend, SiteInfo, ListSitesOptions} from './sites-types.js'; + +type OcapiSite = OcapiComponents['schemas']['site']; +type OcapiSites = OcapiComponents['schemas']['sites']; + +function mapOcapiSite(ocapi: OcapiSite): SiteInfo { + return { + id: ocapi.id ?? '', + displayName: ocapi.display_name?.default ?? ocapi.id ?? '', + storefrontStatus: ocapi.storefront_status, + cartridges: ocapi.cartridges, + _raw: ocapi, + }; +} + +/** + * OCAPI Sites backend (legacy/fallback). Reads sites and per-site detail via + * the OCAPI Data API `/sites` resource. + */ +export class OcapiSitesBackend implements SitesBackend { + readonly name = 'ocapi' as const; + + constructor(private instance: B2CInstance) {} + + async listSites(options: ListSitesOptions = {}): Promise { + const {count, start} = options; + const {data, error, response} = await this.instance.ocapi.GET('/sites', { + params: {query: {start, count, select: '(**)'}}, + }); + if (error || !data) { + throwOcapiError(error, response, 'Failed to list sites', SCAPI_SITES_READ_AND_RW_SCOPES); + } + return ((data as OcapiSites).data ?? []).map(mapOcapiSite); + } + + async getSite(siteId: string): Promise { + const {data, error, response} = await this.instance.ocapi.GET('/sites/{site_id}', { + params: {path: {site_id: siteId}}, + }); + if (error || !data) { + throwOcapiError(error, response, `Failed to get site ${siteId}`, SCAPI_SITES_READ_AND_RW_SCOPES); + } + return mapOcapiSite(data as OcapiSite); + } +} diff --git a/packages/b2c-tooling-sdk/src/operations/sites/scapi-sites-backend.ts b/packages/b2c-tooling-sdk/src/operations/sites/scapi-sites-backend.ts new file mode 100644 index 000000000..3988edbf9 --- /dev/null +++ b/packages/b2c-tooling-sdk/src/operations/sites/scapi-sites-backend.ts @@ -0,0 +1,95 @@ +/* + * 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 type {AuthStrategy} from '../../auth/types.js'; +import type {SitesBackend, SiteInfo, ListSitesOptions} from './sites-types.js'; +import { + createScapiSitesClient, + toOrganizationId, + type ScapiSitesClient, + type ScapiSitesClientConfig, + type Site as ScapiSite, +} from '../../clients/scapi-sites.js'; +import {SCOPE_MODE_HEADER} from '../../clients/middleware.js'; + +const READ_HEADERS = {[SCOPE_MODE_HEADER]: 'read'}; + +function defaultLocaleValue(map?: {[key: string]: string}): string | undefined { + if (!map) return undefined; + return map.default ?? Object.values(map)[0]; +} + +function mapScapiSite(scapi: ScapiSite): SiteInfo { + return { + id: scapi.id, + displayName: defaultLocaleValue(scapi.displayName) ?? scapi.id, + storefrontStatus: scapi.storefrontStatus, + cartridges: scapi.cartridges, + _raw: scapi, + }; +} + +export interface ScapiSitesBackendConfig { + shortCode: string; + tenantId: string; + auth: AuthStrategy; + /** Unused by Sites; accepted for compatibility with the dual-backend factory. */ + instance?: unknown; +} + +/** + * SCAPI Sites backend. Reads sites and per-site detail via the + * `site/sites/v1` Admin API. Read-only — cartridge-path writes have no SCAPI + * equivalent and are not part of this backend. + */ +export class ScapiSitesBackend implements SitesBackend { + readonly name = 'scapi' as const; + + private organizationId: string; + private client: ScapiSitesClient; + + constructor(config: ScapiSitesBackendConfig) { + this.organizationId = toOrganizationId(config.tenantId); + const clientConfig: ScapiSitesClientConfig = {shortCode: config.shortCode, tenantId: config.tenantId}; + this.client = createScapiSitesClient(clientConfig, config.auth); + } + + async listSites(options: ListSitesOptions = {}): Promise { + // `getSites` and `site-search` return only site IDs — display name, + // storefront status, and cartridges live on the per-site detail endpoint. + // Fetch IDs first, then enrich each concurrently via `getSite` so the + // list matches the rich shape the OCAPI `/sites?select=(**)` path returned. + const {count, start} = options; + const {data, error} = await this.client.GET('/organizations/{organizationId}/sites', { + params: {path: {organizationId: this.organizationId}}, + headers: READ_HEADERS, + }); + if (error || !data) { + throw new Error(toErrorMessage(error, 'Failed to list sites')); + } + let ids = ((data as unknown as {data?: ScapiSite[]}).data ?? []) + .map((s) => s.id) + .filter((id): id is string => !!id); + if (start !== undefined) ids = ids.slice(start); + if (count !== undefined) ids = ids.slice(0, count); + return Promise.all(ids.map((id) => this.getSite(id))); + } + + async getSite(siteId: string): Promise { + const {data, error} = await this.client.GET('/organizations/{organizationId}/sites/{siteId}', { + params: {path: {organizationId: this.organizationId, siteId}}, + headers: READ_HEADERS, + }); + if (error || !data) { + throw new Error(toErrorMessage(error, `Failed to get site ${siteId}`)); + } + return mapScapiSite(data as ScapiSite); + } +} + +function toErrorMessage(error: unknown, fallback: string): string { + const e = error as {detail?: string; title?: string} | undefined; + return e?.detail ?? e?.title ?? fallback; +} diff --git a/packages/b2c-tooling-sdk/src/operations/sites/sites-backend.ts b/packages/b2c-tooling-sdk/src/operations/sites/sites-backend.ts new file mode 100644 index 000000000..321a07256 --- /dev/null +++ b/packages/b2c-tooling-sdk/src/operations/sites/sites-backend.ts @@ -0,0 +1,24 @@ +/* + * 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 type {SitesBackend} from './sites-types.js'; +import {OcapiSitesBackend} from './ocapi-sites-backend.js'; +import {ScapiSitesBackend} from './scapi-sites-backend.js'; +import {createDualBackend, type DualBackendConfig} from '../../clients/dual-backend-factory.js'; + +export type SitesBackendConfig = DualBackendConfig; + +/** + * Builds a Sites backend for read operations (list/get). In `auto` mode + * (the default) it prefers SCAPI (`site/sites/v1`) and falls back to the + * deprecated OCAPI Data API on `invalid_scope`. + */ +export function createSitesBackend(config: SitesBackendConfig): SitesBackend { + return createDualBackend(config, { + domainName: 'Sites', + Scapi: ScapiSitesBackend, + Ocapi: OcapiSitesBackend, + }); +} diff --git a/packages/b2c-tooling-sdk/src/operations/sites/sites-scopes.ts b/packages/b2c-tooling-sdk/src/operations/sites/sites-scopes.ts new file mode 100644 index 000000000..7d4d1c2f7 --- /dev/null +++ b/packages/b2c-tooling-sdk/src/operations/sites/sites-scopes.ts @@ -0,0 +1,16 @@ +/* + * 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 Sites scopes named in OCAPI-deprecation error messages, derived from + * the canonical cascade so they can't drift. Sites operations are read-only, + * so the read cascade (rw then ro) is the relevant set. + * + * @module operations/sites/sites-scopes + */ +import {SCAPI_SITES_CASCADE} from '../../clients/scapi-sites.js'; + +/** Distinct sites read scopes (e.g. `['sfcc.sites.rw', 'sfcc.sites']`). */ +export const SCAPI_SITES_READ_AND_RW_SCOPES = [...new Set(SCAPI_SITES_CASCADE.read.flat())]; diff --git a/packages/b2c-tooling-sdk/src/operations/sites/sites-types.ts b/packages/b2c-tooling-sdk/src/operations/sites/sites-types.ts new file mode 100644 index 000000000..89025f044 --- /dev/null +++ b/packages/b2c-tooling-sdk/src/operations/sites/sites-types.ts @@ -0,0 +1,54 @@ +/* + * 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 + */ +/** + * Canonical types and backend interface for site read operations. + * + * The OCAPI Data API (`/sites`) and the SCAPI Sites API (`site/sites/v1`) + * both expose site listing and per-site detail (including the cartridge + * path). We expose a single canonical shape here so command code is agnostic + * to which backend serves the request. + * + * The SCAPI Sites API is read-only; cartridge-path **writes** have no SCAPI + * equivalent and remain OCAPI / site-archive-import only (see + * {@link module:operations/sites/cartridges}). + * + * @module operations/sites/sites-types + */ +import type {BackendBase} from '../../clients/scapi-backend-utils.js'; + +/** + * Canonical site. CamelCase fields match SCAPI; the OCAPI backend maps from + * snake_case. `displayName` is the default-locale display name (both APIs + * return a locale map; we surface the default for table output and keep the + * full object on `_raw`). + */ +export interface SiteInfo { + id: string; + displayName?: string; + storefrontStatus?: string; + cartridges?: string; + /** Original backend response, for advanced consumers. */ + _raw?: unknown; +} + +/** Options for listing sites. */ +export interface ListSitesOptions { + /** Max sites to return (SCAPI `limit`; OCAPI `count`). */ + count?: number; + /** Offset (SCAPI `offset`; OCAPI `start`). */ + start?: number; +} + +/** + * Backend contract for site read operations. + * + * Only reads are modeled — the SCAPI Sites API has no write surface, and + * cartridge-path mutation is handled separately by the OCAPI/import path. + */ +export interface SitesBackend extends BackendBase { + listSites(options?: ListSitesOptions): Promise; + getSite(siteId: string): Promise; +} diff --git a/packages/b2c-tooling-sdk/test/operations/sites/sites-backend.test.ts b/packages/b2c-tooling-sdk/test/operations/sites/sites-backend.test.ts new file mode 100644 index 000000000..4baf3e337 --- /dev/null +++ b/packages/b2c-tooling-sdk/test/operations/sites/sites-backend.test.ts @@ -0,0 +1,140 @@ +/* + * 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 {createSitesBackend} from '../../../src/operations/sites/sites-backend.js'; +import {OcapiSitesBackend} from '../../../src/operations/sites/ocapi-sites-backend.js'; +import {ScapiSitesBackend} from '../../../src/operations/sites/scapi-sites-backend.js'; +import {SCAPI_SITES_READ_AND_RW_SCOPES} from '../../../src/operations/sites/sites-scopes.js'; +import {OcapiDeprecatedError} from '../../../src/clients/error-utils.js'; +import type {B2CInstance} from '../../../src/instance/index.js'; +import type {AuthStrategy} from '../../../src/auth/types.js'; + +function fakeInstance(getImpl: (path: string, init: unknown) => unknown): B2CInstance { + return {ocapi: {GET: async (path: string, init: unknown) => getImpl(path, init)}} as unknown as B2CInstance; +} + +const fakeAuth = {} as AuthStrategy; + +describe('operations/sites backend', () => { + describe('createSitesBackend resolution', () => { + it('resolves to OCAPI when no SCAPI config is present', () => { + const backend = createSitesBackend({ + preference: 'auto', + instance: fakeInstance(() => ({data: {data: []}, error: undefined, response: {status: 200}})), + }); + expect(backend.name).to.equal('ocapi'); + }); + + it('resolves to SCAPI (with OCAPI fallback wrapper) when SCAPI config is present', () => { + const backend = createSitesBackend({ + preference: 'auto', + instance: fakeInstance(() => ({data: {data: []}, error: undefined, response: {status: 200}})), + shortCode: 'abcd1234', + tenantId: 'zzxy_dev', + auth: fakeAuth, + }); + // Before any call resolves, the fallback wrapper reports the SCAPI name. + expect(backend.name).to.equal('scapi'); + }); + + it('honors explicit ocapi preference even with SCAPI config', () => { + const backend = createSitesBackend({ + preference: 'ocapi', + instance: fakeInstance(() => ({data: {data: []}, error: undefined, response: {status: 200}})), + shortCode: 'abcd1234', + tenantId: 'zzxy_dev', + auth: fakeAuth, + }); + expect(backend.name).to.equal('ocapi'); + }); + }); + + describe('OcapiSitesBackend', () => { + it('maps OCAPI snake_case site fields to the canonical shape', async () => { + const backend = new OcapiSitesBackend( + fakeInstance((path) => { + expect(path).to.equal('/sites'); + return { + data: {data: [{id: 'RefArch', display_name: {default: 'Ref Arch'}, storefront_status: 'online'}]}, + error: undefined, + response: {status: 200}, + }; + }), + ); + const sites = await backend.listSites(); + expect(sites).to.have.length(1); + expect(sites[0]).to.include({id: 'RefArch', displayName: 'Ref Arch', storefrontStatus: 'online'}); + }); + + it('reads the cartridge path from getSite', async () => { + const backend = new OcapiSitesBackend( + fakeInstance((path) => { + expect(path).to.equal('/sites/{site_id}'); + return {data: {id: 'RefArch', cartridges: 'app_a:app_b'}, error: undefined, response: {status: 200}}; + }), + ); + const site = await backend.getSite('RefArch'); + expect(site.cartridges).to.equal('app_a:app_b'); + }); + + it('throws an OcapiDeprecatedError naming the sites scope on a deprecated instance', async () => { + const backend = new OcapiSitesBackend( + fakeInstance(() => ({ + data: undefined, + error: {fault: {type: 'OcapiDeprecatedException', message: 'deprecated'}}, + response: {status: 403}, + })), + ); + try { + await backend.listSites(); + expect.fail('should have thrown'); + } catch (e) { + expect(e).to.be.instanceOf(OcapiDeprecatedError); + expect((e as Error).message).to.include('"sfcc.sites.rw"'); + expect((e as Error).message).to.include('"sfcc.sites"'); + } + }); + }); + + describe('sites scopes', () => { + it('derives read+rw scopes from the cascade', () => { + expect(SCAPI_SITES_READ_AND_RW_SCOPES).to.have.members(['sfcc.sites.rw', 'sfcc.sites']); + }); + }); + + describe('ScapiSitesBackend mapping', () => { + it('maps SCAPI camelCase site fields (display name uses default locale)', async () => { + const backend = new ScapiSitesBackend({shortCode: 'abcd1234', tenantId: 'zzxy_dev', auth: fakeAuth}); + // Replace the internal client with a stub. + (backend as unknown as {client: unknown}).client = { + async GET(path: string) { + if (path.endsWith('/sites')) { + return {data: {data: [{id: 'RefArch'}]}, error: undefined, response: {status: 200}}; + } + return { + data: { + id: 'RefArch', + displayName: {default: 'Ref Arch', fr: 'Réf'}, + storefrontStatus: 'online', + cartridges: 'a:b', + }, + error: undefined, + response: {status: 200}, + }; + }, + }; + const sites = await backend.listSites(); + expect(sites).to.have.length(1); + expect(sites[0]).to.include({ + id: 'RefArch', + displayName: 'Ref Arch', + storefrontStatus: 'online', + cartridges: 'a:b', + }); + }); + }); +}); diff --git a/skills/b2c-cli/skills/b2c-sites/SKILL.md b/skills/b2c-cli/skills/b2c-sites/SKILL.md index db637234a..567bfad62 100644 --- a/skills/b2c-cli/skills/b2c-sites/SKILL.md +++ b/skills/b2c-cli/skills/b2c-sites/SKILL.md @@ -84,7 +84,9 @@ When OCAPI direct permissions for `/sites/*/cartridges` are unavailable, cartrid **Output columns:** ID, Display Name, Status (storefront_status). -**JSON output** returns the full OCAPI sites response including all site properties (useful for extracting channel IDs, custom preferences, and other site metadata not shown in the table). +**JSON output** returns the full site objects including all properties (useful for extracting channel IDs, custom preferences, and other site metadata not shown in the table). + +`sites list` and `sites cartridges list` run over SCAPI (the `site/sites` API) when `shortCode`, `tenantId`, and the `sfcc.sites` / `sfcc.sites.rw` scopes are configured; otherwise they fall back to the deprecated OCAPI Data API. Cartridge-path **writes** (`add`/`remove`/`set`) have no SCAPI equivalent and always use OCAPI / site-archive import. ## Common Use Cases From 79bf8ffafcd39fd5953e89395a826302a1b0ddf4 Mon Sep 17 00:00:00 2001 From: Charles Lavery Date: Mon, 22 Jun 2026 18:55:10 -0400 Subject: [PATCH 16/22] refactor(sdk): make B2CInstance the source of SCAPI client config B2CInstance now encodes the SCAPI connection coordinates and backend preference, so SCAPI operations need nothing beyond a configured instance. This is the forward-looking seam for the OCAPI -> SCAPI transition: when OCAPI is eventually removed, the OCAPI accessors disappear and the SCAPI plumbing stands on its own. - InstanceConfig gains shortCode/tenantId/apiBackend; populated in createInstanceFromConfig from resolved config. - AuthConfig.oauth gains jwtCertPath/jwtKeyPath/jwtPassphrase so the instance can build a JWT Bearer strategy for SCAPI, not just client-credentials. - New B2CInstance.scapiClientConfig getter returns {shortCode, tenantId, auth} or undefined, encapsulating the "only stateless scope-flexible OAuth qualifies for auto-SCAPI" eligibility rule in one place. New apiBackend getter exposes the preference. - createDualBackend / DualBackendConfig drop the threaded shortCode/tenantId/auth fields and source them from instance.scapiClientConfig; preference defaults to instance.apiBackend. - InstanceCommand.createBackend + hasScapiConfig, JobCommand.buildScapiJobsClient, the sites CLI commands, and the VS Code scripts-backend builder all simplify to pass just the instance. This also fixes a latent issue where the CLI auth path could hand a fixed-scope stateful-session token to the SCAPI leg. - Adds B2CInstance.scapiClientConfig unit coverage. --- .changeset/scapi-migration.md | 2 + .../src/commands/sites/cartridges/list.ts | 8 +- packages/b2c-cli/src/commands/sites/list.ts | 8 +- .../b2c-cli/test/commands/sites/list.test.ts | 21 ++- packages/b2c-tooling-sdk/src/auth/types.ts | 6 + .../src/cli/instance-command.ts | 51 +++---- .../b2c-tooling-sdk/src/cli/job-command.ts | 15 +- .../src/clients/dual-backend-factory.ts | 37 +++-- .../b2c-tooling-sdk/src/config/mapping.ts | 8 ++ .../b2c-tooling-sdk/src/instance/index.ts | 128 ++++++++++++++++++ .../test/instance/scapi-client-config.test.ts | 103 ++++++++++++++ .../operations/sites/sites-backend.test.ts | 49 +++++-- .../src/code-sync/cartridge-commands.ts | 10 +- .../src/code-sync/code-sync-manager.ts | 6 +- .../src/code-sync/deploy-command.ts | 2 +- .../src/code-sync/scripts-backend.ts | 23 +--- 16 files changed, 362 insertions(+), 115 deletions(-) create mode 100644 packages/b2c-tooling-sdk/test/instance/scapi-client-config.test.ts diff --git a/.changeset/scapi-migration.md b/.changeset/scapi-migration.md index 768d7c46c..da5ca8586 100644 --- a/.changeset/scapi-migration.md +++ b/.changeset/scapi-migration.md @@ -6,3 +6,5 @@ --- Migrate `job`, `code`, `bm users`, `bm roles`, and `sites` commands to support SCAPI alongside OCAPI. In auto mode (the default), the CLI prefers SCAPI when `shortCode` and `tenantId` are configured and silently falls back to OCAPI if the SCAPI scopes aren't granted. Use `--api-backend ocapi|scapi|auto` or `apiBackend` in dw.json to control explicitly. SCAPI scopes: `sfcc.jobs(.rw)`, `sfcc.scripts(.rw)`, `sfcc.users(.rw)`, `sfcc.roles(.rw)`, `sfcc.sites(.rw)` — read-only scopes are honored for list/get operations, falling back to read-only when the `*.rw` scope is not granted. `sites list` and `sites cartridges list` (reads) use the SCAPI `site/sites` API; cartridge-path writes have no SCAPI equivalent and remain on OCAPI / site-archive import. New `job execution delete` command (SCAPI only). `code deploy` and all VS Code extension code-version actions (list/activate/delete/reload/create plus active-version discovery) also honor `apiBackend`. `bm users update --disabled` transparently falls back to OCAPI in auto mode (SCAPI Users PATCH does not support the `disabled` flag). Auto mode only selects SCAPI when authentication can request the required scopes — stateless OAuth (client-credentials, JWT bearer); stateful and implicit flows fall back to OCAPI unless `--api-backend scapi` is set explicitly. + +For SDK consumers, `B2CInstance` now carries the SCAPI coordinates itself: a `B2CInstance.scapiClientConfig` getter returns `{shortCode, tenantId, auth}` (or `undefined` when the instance can't reach SCAPI), and `B2CInstance.apiBackend` exposes the configured preference. The dual-backend factories (`createSitesBackend`, `createScriptsBackend`, `createUsersBackend`, `createRolesBackend`) now take just `{instance}` and source SCAPI config from it — so SCAPI operations need nothing beyond a configured instance. diff --git a/packages/b2c-cli/src/commands/sites/cartridges/list.ts b/packages/b2c-cli/src/commands/sites/cartridges/list.ts index 0227510e4..b7d442b9e 100644 --- a/packages/b2c-cli/src/commands/sites/cartridges/list.ts +++ b/packages/b2c-cli/src/commands/sites/cartridges/list.ts @@ -48,13 +48,7 @@ export default class SitesCartridgesList extends InstanceCommand { this.requireOAuthCredentials(); const hostname = this.resolvedConfig.values.hostname!; - const backend = createSitesBackend({ - preference: this.apiBackendPreference, - instance: this.instance, - shortCode: this.resolvedConfig.values.shortCode, - tenantId: this.resolvedConfig.values.tenantId, - auth: this.hasScapiConfig() ? this.getOAuthStrategy() : undefined, - }); + const backend = createSitesBackend({instance: this.instance}); this.logger.debug(`Using ${backend.name} backend for sites list`); this.log(t('commands.sites.list.fetching', 'Fetching sites from {{hostname}}...', {hostname})); diff --git a/packages/b2c-cli/test/commands/sites/list.test.ts b/packages/b2c-cli/test/commands/sites/list.test.ts index 3efcd8439..156f82d97 100644 --- a/packages/b2c-cli/test/commands/sites/list.test.ts +++ b/packages/b2c-cli/test/commands/sites/list.test.ts @@ -22,14 +22,21 @@ describe('sites list', () => { return createTestCommand(SitesList, hooks.getConfig(), flags, args); } - // With no shortCode/tenantId/auth configured, the dual-backend factory - // resolves to the OCAPI backend, which reads `/sites?select=(**)`. + // The instance carries SCAPI resolution now: a stub instance with no + // `scapiClientConfig` (and `apiBackend: 'auto'`) makes the dual-backend + // factory resolve to the OCAPI backend, which reads `/sites?select=(**)`. + function stubInstance(command: any, ocapiGet: sinon.SinonStub) { + sinon.stub(command, 'instance').get(() => ({ + ocapi: {GET: ocapiGet}, + apiBackend: 'auto', + scapiClientConfig: undefined, + })); + } + function stubCommon(command: any, {jsonEnabled}: {jsonEnabled: boolean}) { sinon.stub(command, 'requireOAuthCredentials').returns(void 0); sinon.stub(command, 'resolvedConfig').get(() => ({values: {hostname: 'example.com'}})); sinon.stub(command, 'jsonEnabled').returns(jsonEnabled); - sinon.stub(command, 'hasScapiConfig').returns(false); - sinon.stub(command, 'apiBackendPreference').get(() => 'auto'); } it('returns data in JSON mode', async () => { @@ -42,7 +49,7 @@ describe('sites list', () => { error: undefined, response: {status: 200}, }); - sinon.stub(command, 'instance').get(() => ({ocapi: {GET: ocapiGet}})); + stubInstance(command, ocapiGet); const result = await command.run(); expect(result.count).to.equal(1); @@ -57,7 +64,7 @@ describe('sites list', () => { sinon.stub(command, 'log').returns(void 0); const ocapiGet = sinon.stub().resolves({data: {count: 0, data: []}, error: undefined, response: {status: 200}}); - sinon.stub(command, 'instance').get(() => ({ocapi: {GET: ocapiGet}})); + stubInstance(command, ocapiGet); const stdoutStub = sinon.stub(ux, 'stdout').returns(void 0 as any); @@ -80,7 +87,7 @@ describe('sites list', () => { const ocapiGet = sinon .stub() .resolves({data: undefined, error: {fault: {message: 'boom'}}, response: {status: 500}}); - sinon.stub(command, 'instance').get(() => ({ocapi: {GET: ocapiGet}})); + stubInstance(command, ocapiGet); // The OCAPI backend throws on error; the command surfaces it via catch(). try { diff --git a/packages/b2c-tooling-sdk/src/auth/types.ts b/packages/b2c-tooling-sdk/src/auth/types.ts index 434f33c06..692f5a892 100644 --- a/packages/b2c-tooling-sdk/src/auth/types.ts +++ b/packages/b2c-tooling-sdk/src/auth/types.ts @@ -89,6 +89,12 @@ export interface OAuthAuthConfig { clientSecret?: string; scopes?: string[]; accountManagerHost?: string; + /** Path to JWT certificate file (cert.pem) for the JWT Bearer flow */ + jwtCertPath?: string; + /** Path to JWT private key file (key.pem) for the JWT Bearer flow */ + jwtKeyPath?: string; + /** Optional passphrase for an encrypted JWT private key */ + jwtPassphrase?: string; /** Override redirect URI for implicit OAuth flow (e.g., for port forwarding in remote environments) */ redirectUri?: string; /** Custom browser opener for implicit OAuth flow. Receives the authorization URL. */ diff --git a/packages/b2c-tooling-sdk/src/cli/instance-command.ts b/packages/b2c-tooling-sdk/src/cli/instance-command.ts index 9762941e7..5bd942ab0 100644 --- a/packages/b2c-tooling-sdk/src/cli/instance-command.ts +++ b/packages/b2c-tooling-sdk/src/cli/instance-command.ts @@ -213,32 +213,24 @@ export abstract class InstanceCommand extends OAuthCom } /** - * True iff shortCode + tenantId are available AND the configured auth - * strategy can request the SCAPI scopes (`sfcc.*` plus the tenant scope) + * True iff this instance can reach SCAPI under `auto` mode: shortCode + + * tenantId are configured AND the auth flow can request the `sfcc.*` scopes * each domain needs. * - * Only the stateless OAuth flows (client-credentials, JWT bearer) qualify: - * those go back to Account Manager per request and can ask for whatever - * scopes the operation requires. Stateful and implicit flows hold a fixed - * token whose scopes were chosen at acquisition; under `auto` they would - * route through SCAPI with a token that AM never granted SCAPI scopes (or - * the right tenant scope) for, and the SCAPI 403 isn't a fallback - * trigger. - * - * Users running stateful or implicit auth who *do* want SCAPI can opt in - * with `--api-backend scapi` (provided the stored token genuinely covers - * the required scopes). Auto mode stays conservative. + * Delegates to {@link B2CInstance.scapiClientConfig} so the eligibility rule + * lives in exactly one place. Only the stateless OAuth flows (client- + * credentials, JWT Bearer) qualify — they go back to Account Manager per + * request and can ask for whatever scopes the operation requires. Stateful + * and implicit flows hold a fixed token whose scopes were chosen at + * acquisition; under `auto` they would route through SCAPI with a token that + * AM never granted SCAPI scopes for, and the SCAPI 403 isn't a fallback + * trigger. Those users opt in explicitly with `--api-backend scapi`. */ protected hasScapiConfig(): boolean { - const values = this.resolvedConfig.values; - if (!values.shortCode || !values.tenantId || !this.hasOAuthCredentials()) { + if (!this.resolvedConfig.hasB2CInstanceConfig()) { return false; } - - return ( - Boolean(values.clientId && values.clientSecret) || - Boolean(values.clientId && values.jwtCertPath && values.jwtKeyPath) - ); + return this.instance.scapiClientConfig !== undefined; } /** @@ -246,25 +238,18 @@ export abstract class InstanceCommand extends OAuthCom * that have not yet migrated to the dispatcher pattern. Will be removed * once those domains move to SCAPI ops + dispatcher branches in CLI. * + * SCAPI coordinates and auth are sourced from the instance + * ({@link B2CInstance.scapiClientConfig}); the factory honors the instance's + * `apiBackend` preference. The CLI flag flows into the instance via resolved + * config, so passing it again here is unnecessary. + * * @deprecated Use {@link createDispatcher} and call SCAPI ops / OCAPI * functions directly from CLI commands. */ protected createBackend( factory: (config: import('../clients/dual-backend-factory.js').DualBackendConfig) => T, ): T { - // Gate auth on hasScapiConfig() — not just hasOAuthCredentials() — so the - // dual-backend factory's "is SCAPI available" check (auth presence) - // matches the dispatcher path's capability guard. Otherwise stateful or - // implicit auth can route auto-mode to SCAPI with a token that AM never - // granted SCAPI scopes for, and the resulting 403 isn't a fallback - // trigger. - return factory({ - preference: this.apiBackendPreference, - instance: this.instance, - shortCode: this.resolvedConfig.values.shortCode, - tenantId: this.resolvedConfig.values.tenantId, - auth: this.hasScapiConfig() ? this.getOAuthStrategy() : undefined, - }); + return factory({instance: this.instance}); } /** diff --git a/packages/b2c-tooling-sdk/src/cli/job-command.ts b/packages/b2c-tooling-sdk/src/cli/job-command.ts index 936fc3d83..8894a11f1 100644 --- a/packages/b2c-tooling-sdk/src/cli/job-command.ts +++ b/packages/b2c-tooling-sdk/src/cli/job-command.ts @@ -44,16 +44,15 @@ export abstract class JobCommand extends InstanceComma * Builds a SCAPI Jobs client, or `undefined` if SCAPI is not configured. * Used both as the dispatcher's SCAPI factory and directly by SCAPI-only * commands (e.g. `job execution delete`) that don't use the dispatcher. + * + * The shortCode/tenantId and the scope-flexible auth strategy come from the + * instance ({@link B2CInstance.scapiClientConfig}), which already encodes the + * "only stateless OAuth qualifies for auto-SCAPI" gating. */ protected buildScapiJobsClient(): ScapiJobsClient | undefined { - if (!this.hasScapiConfig()) return undefined; - return createScapiJobsClient( - { - shortCode: this.resolvedConfig.values.shortCode!, - tenantId: this.resolvedConfig.values.tenantId!, - }, - this.getOAuthStrategy(), - ); + const scapi = this.instance.scapiClientConfig; + if (!scapi) return undefined; + return createScapiJobsClient({shortCode: scapi.shortCode, tenantId: scapi.tenantId}, scapi.auth); } /** diff --git a/packages/b2c-tooling-sdk/src/clients/dual-backend-factory.ts b/packages/b2c-tooling-sdk/src/clients/dual-backend-factory.ts index 6d243673b..bab5b7217 100644 --- a/packages/b2c-tooling-sdk/src/clients/dual-backend-factory.ts +++ b/packages/b2c-tooling-sdk/src/clients/dual-backend-factory.ts @@ -19,13 +19,20 @@ import {resolveScapiOrOcapi, type ApiBackendPreference, type BackendBase} from ' /** * Common shape of every dual-backend factory's input. + * + * SCAPI coordinates (shortCode/tenantId) and the scope-flexible auth strategy + * are no longer threaded in separately — they are sourced from the instance + * via {@link B2CInstance.scapiClientConfig}. A backend is "SCAPI-capable" iff + * that getter returns a value (shortCode + tenantId present, and a stateless + * OAuth flow that can request the required scopes). + * + * `preference` is optional: when omitted it falls back to the instance's own + * {@link B2CInstance.apiBackend} (default `'auto'`), so callers that already + * resolved the instance from config don't have to re-plumb the flag. */ export interface DualBackendConfig { - preference: ApiBackendPreference; + preference?: ApiBackendPreference; instance: B2CInstance; - shortCode?: string; - tenantId?: string; - auth?: AuthStrategy; } /** @@ -71,29 +78,31 @@ export interface DualBackendCtors { * ``` */ export function createDualBackend(config: DualBackendConfig, ctors: DualBackendCtors): T { - const hasScapiConfig = Boolean(config.shortCode && config.tenantId && config.auth); + const {instance} = config; + const preference = config.preference ?? instance.apiBackend; + const scapiClientConfig = instance.scapiClientConfig; const resolved = resolveScapiOrOcapi({ - preference: config.preference, - hasScapiConfig, + preference, + hasScapiConfig: scapiClientConfig !== undefined, domainName: ctors.domainName, }); if (resolved === 'ocapi') { - return new ctors.Ocapi(config.instance); + return new ctors.Ocapi(instance); } const scapiBackend = new ctors.Scapi({ - shortCode: config.shortCode!, - tenantId: config.tenantId!, - auth: config.auth!, - instance: config.instance, + shortCode: scapiClientConfig!.shortCode, + tenantId: scapiClientConfig!.tenantId, + auth: scapiClientConfig!.auth, + instance, }); - if (config.preference === 'scapi') { + if (preference === 'scapi') { return scapiBackend; } // Auto mode: wrap with fallback - const ocapiBackend = new ctors.Ocapi(config.instance); + const ocapiBackend = new ctors.Ocapi(instance); return createFallbackBackend(scapiBackend, ocapiBackend, ctors.domainName.toLowerCase()); } diff --git a/packages/b2c-tooling-sdk/src/config/mapping.ts b/packages/b2c-tooling-sdk/src/config/mapping.ts index e24ba00f2..eba670f8a 100644 --- a/packages/b2c-tooling-sdk/src/config/mapping.ts +++ b/packages/b2c-tooling-sdk/src/config/mapping.ts @@ -558,6 +558,9 @@ export function buildAuthConfigFromNormalized(config: NormalizedConfig): AuthCon clientSecret: config.clientSecret, scopes: config.scopes, accountManagerHost: config.accountManagerHost, + jwtCertPath: config.jwtCertPath, + jwtKeyPath: config.jwtKeyPath, + jwtPassphrase: config.jwtPassphrase, }; } @@ -596,6 +599,11 @@ export function createInstanceFromConfig( hostname: config.hostname, codeVersion: config.codeVersion, webdavHostname: config.webdavHostname, + // SCAPI coordinates + backend preference so SCAPI operations can be driven + // from the instance alone (see B2CInstance.scapiClientConfig). + shortCode: config.shortCode, + tenantId: config.tenantId, + apiBackend: config.apiBackend, // Include TLS options if certificate or self-signed mode is configured tlsOptions: config.certificate || config.selfSigned diff --git a/packages/b2c-tooling-sdk/src/instance/index.ts b/packages/b2c-tooling-sdk/src/instance/index.ts index 3f76d8c51..32c1f0386 100644 --- a/packages/b2c-tooling-sdk/src/instance/index.ts +++ b/packages/b2c-tooling-sdk/src/instance/index.ts @@ -44,10 +44,30 @@ */ import type {AuthConfig, AuthStrategy, AuthMethod, AuthCredentials} from '../auth/types.js'; import {BasicAuthStrategy} from '../auth/basic.js'; +import {OAuthStrategy} from '../auth/oauth.js'; +import {JwtOAuthStrategy} from '../auth/oauth-jwt.js'; import {resolveAuthStrategy} from '../auth/resolve.js'; import {WebDavClient} from '../clients/webdav.js'; import {createOcapiClient, type OcapiClient} from '../clients/ocapi.js'; import {createTlsDispatcher, type TlsOptions} from '../clients/tls-dispatcher.js'; +import {DEFAULT_ACCOUNT_MANAGER_HOST} from '../defaults.js'; + +/** + * SCAPI connection coordinates plus an auth strategy able to request the + * `sfcc.*` scopes each Commerce API operation needs. + * + * Returned by {@link B2CInstance.scapiClientConfig} when — and only when — the + * instance carries both a shortCode and tenantId and is configured with a + * stateless OAuth flow (client-credentials or JWT Bearer) that can go back to + * Account Manager per request for arbitrary scopes. This is the single handle + * every SCAPI client factory consumes, so SCAPI operations need nothing beyond + * a {@link B2CInstance}. + */ +export interface ScapiClientConfig { + shortCode: string; + tenantId: string; + auth: AuthStrategy; +} /** * Instance configuration (hostname, code version, etc.) @@ -61,6 +81,22 @@ export interface InstanceConfig { webdavHostname?: string; /** TLS options for mTLS/self-signed certificate support */ tlsOptions?: TlsOptions; + /** + * SCAPI short code (e.g. `kv7kzm78`). Required, together with {@link tenantId}, + * to reach the Salesforce Commerce API. Populated from resolved configuration. + */ + shortCode?: string; + /** + * SCAPI tenant/organization ID (e.g. `zzxy_prd`). Required, together with + * {@link shortCode}, to reach the Salesforce Commerce API. + */ + tenantId?: string; + /** + * Backend preference for operations that support both OCAPI (legacy) and + * SCAPI. Defaults to `'auto'` when unset. Lets the instance answer "should + * this operation prefer SCAPI?" without the caller re-reading config. + */ + apiBackend?: 'ocapi' | 'scapi' | 'auto'; } /** @@ -108,6 +144,49 @@ export class B2CInstance { return this.config.webdavHostname || this.config.hostname; } + /** + * Backend preference for operations that support both OCAPI and SCAPI. + * Defaults to `'auto'` when not configured. + */ + get apiBackend(): 'ocapi' | 'scapi' | 'auto' { + return this.config.apiBackend ?? 'auto'; + } + + /** + * SCAPI connection coordinates + a scope-flexible auth strategy, or + * `undefined` when this instance cannot reach SCAPI under `auto` mode. + * + * This is the forward-looking seam for the OCAPI → SCAPI transition: a SCAPI + * client factory (jobs, sites, scripts, …) needs only a {@link B2CInstance}, + * not a separately-threaded shortCode/tenantId/auth bundle. When OCAPI is + * eventually removed, the OCAPI accessors disappear and this stays. + * + * Returns `undefined` unless **all** of the following hold: + * 1. `shortCode` and `tenantId` are configured, and + * 2. the configured OAuth flow is stateless and scope-flexible — + * client-credentials (clientId + clientSecret) or JWT Bearer + * (clientId + cert/key). + * + * Stateful and implicit flows are excluded on purpose: they hold a fixed + * token whose scopes were chosen at acquisition, so under `auto` they would + * route to SCAPI with a token AM never granted the required scopes for, and + * that 403 is not a fallback trigger. Callers wanting SCAPI on those flows + * opt in explicitly via `--api-backend scapi` and build the client directly. + */ + get scapiClientConfig(): ScapiClientConfig | undefined { + const {shortCode, tenantId} = this.config; + if (!shortCode || !tenantId) { + return undefined; + } + + const auth = this.buildScapiAuthStrategy(); + if (!auth) { + return undefined; + } + + return {shortCode, tenantId, auth}; + } + /** * WebDAV client for file operations. * @@ -203,8 +282,57 @@ export class B2CInstance { return resolveAuthStrategy(credentials, {allowedMethods: oauthMethods}); } + + /** + * Builds the scope-flexible OAuth strategy used for SCAPI, or `undefined` + * when the configured credentials are not eligible for `auto`-mode SCAPI. + * + * Only the stateless flows qualify, because only they can request arbitrary + * `sfcc.*` scopes from Account Manager per call (via the cascade / additional + * scopes hooks the SCAPI client factories rely on): + * - **client-credentials**: clientId + clientSecret. + * - **JWT Bearer**: clientId + cert/key paths. + * + * Honors `authMethods` ordering, defaulting to client-credentials before JWT + * to match the CLI's auth priority. Returns `undefined` for implicit- or + * basic-only configs. + */ + private buildScapiAuthStrategy(): AuthStrategy | undefined { + const oauth = this.auth.oauth; + if (!oauth) { + return undefined; + } + + const accountManagerHost = oauth.accountManagerHost ?? DEFAULT_ACCOUNT_MANAGER_HOST; + const methods = this.auth.authMethods ?? (['client-credentials', 'jwt'] as AuthMethod[]); + + for (const method of methods) { + if (method === 'client-credentials' && oauth.clientSecret) { + return new OAuthStrategy({ + clientId: oauth.clientId, + clientSecret: oauth.clientSecret, + scopes: oauth.scopes, + accountManagerHost, + }); + } + + if (method === 'jwt' && oauth.jwtCertPath && oauth.jwtKeyPath) { + return new JwtOAuthStrategy({ + clientId: oauth.clientId, + certPath: oauth.jwtCertPath, + keyPath: oauth.jwtKeyPath, + passphrase: oauth.jwtPassphrase, + accountManagerHost, + scopes: oauth.scopes, + }); + } + } + + return undefined; + } } // Re-export types for convenience export type {AuthConfig}; export type {TlsOptions}; +export type {AuthStrategy}; diff --git a/packages/b2c-tooling-sdk/test/instance/scapi-client-config.test.ts b/packages/b2c-tooling-sdk/test/instance/scapi-client-config.test.ts new file mode 100644 index 000000000..262425c13 --- /dev/null +++ b/packages/b2c-tooling-sdk/test/instance/scapi-client-config.test.ts @@ -0,0 +1,103 @@ +/* + * 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 * as path from 'node:path'; +import {fileURLToPath} from 'node:url'; +import {B2CInstance} from '@salesforce/b2c-tooling-sdk/instance'; +import {OAuthStrategy, JwtOAuthStrategy} from '@salesforce/b2c-tooling-sdk/auth'; +import type {AuthConfig, InstanceConfig} from '@salesforce/b2c-tooling-sdk/instance'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const TEST_FIXTURES_DIR = path.join(__dirname, '../fixtures/jwt'); +const TEST_CERT_PATH = path.join(TEST_FIXTURES_DIR, 'test-cert.pem'); +const TEST_KEY_PATH = path.join(TEST_FIXTURES_DIR, 'test-key.pem'); + +const SCAPI_COORDS: Partial = {shortCode: 'kv7kzm78', tenantId: 'zzxy_prd'}; + +function instance(config: Partial, auth: AuthConfig): B2CInstance { + return new B2CInstance({hostname: 'test.demandware.net', ...config}, auth); +} + +describe('instance/B2CInstance.scapiClientConfig', () => { + describe('returns config (SCAPI eligible)', () => { + it('builds a client-credentials strategy when clientId + clientSecret are present', () => { + const scapi = instance(SCAPI_COORDS, { + oauth: {clientId: 'client', clientSecret: 'secret'}, + }).scapiClientConfig; + + expect(scapi).to.not.equal(undefined); + expect(scapi!.shortCode).to.equal('kv7kzm78'); + expect(scapi!.tenantId).to.equal('zzxy_prd'); + expect(scapi!.auth).to.be.instanceOf(OAuthStrategy); + }); + + it('builds a JWT strategy when cert/key paths are present (no client secret)', () => { + const scapi = instance(SCAPI_COORDS, { + oauth: {clientId: 'client', jwtCertPath: TEST_CERT_PATH, jwtKeyPath: TEST_KEY_PATH}, + }).scapiClientConfig; + + expect(scapi).to.not.equal(undefined); + expect(scapi!.auth).to.be.instanceOf(JwtOAuthStrategy); + }); + + it('prefers client-credentials over JWT by default when both are configured', () => { + const scapi = instance(SCAPI_COORDS, { + oauth: {clientId: 'client', clientSecret: 'secret', jwtCertPath: TEST_CERT_PATH, jwtKeyPath: TEST_KEY_PATH}, + }).scapiClientConfig; + + expect(scapi!.auth).to.be.instanceOf(OAuthStrategy); + }); + + it('honors authMethods ordering to pick JWT ahead of client-credentials', () => { + const scapi = instance(SCAPI_COORDS, { + authMethods: ['jwt', 'client-credentials'], + oauth: {clientId: 'client', clientSecret: 'secret', jwtCertPath: TEST_CERT_PATH, jwtKeyPath: TEST_KEY_PATH}, + }).scapiClientConfig; + + expect(scapi!.auth).to.be.instanceOf(JwtOAuthStrategy); + }); + }); + + describe('returns undefined (not SCAPI eligible)', () => { + it('when shortCode is missing', () => { + const scapi = instance( + {tenantId: 'zzxy_prd'}, + {oauth: {clientId: 'client', clientSecret: 'secret'}}, + ).scapiClientConfig; + expect(scapi).to.equal(undefined); + }); + + it('when tenantId is missing', () => { + const scapi = instance( + {shortCode: 'kv7kzm78'}, + {oauth: {clientId: 'client', clientSecret: 'secret'}}, + ).scapiClientConfig; + expect(scapi).to.equal(undefined); + }); + + it('when the OAuth flow is implicit (clientId only, no secret or JWT)', () => { + const scapi = instance(SCAPI_COORDS, {oauth: {clientId: 'client'}}).scapiClientConfig; + expect(scapi).to.equal(undefined); + }); + + it('when only basic auth is configured', () => { + const scapi = instance(SCAPI_COORDS, {basic: {username: 'u', password: 'p'}}).scapiClientConfig; + expect(scapi).to.equal(undefined); + }); + }); + + describe('apiBackend', () => { + it("defaults to 'auto' when unset", () => { + expect(instance(SCAPI_COORDS, {}).apiBackend).to.equal('auto'); + }); + + it('reflects the configured preference', () => { + expect(instance({...SCAPI_COORDS, apiBackend: 'ocapi'}, {}).apiBackend).to.equal('ocapi'); + expect(instance({...SCAPI_COORDS, apiBackend: 'scapi'}, {}).apiBackend).to.equal('scapi'); + }); + }); +}); diff --git a/packages/b2c-tooling-sdk/test/operations/sites/sites-backend.test.ts b/packages/b2c-tooling-sdk/test/operations/sites/sites-backend.test.ts index 4baf3e337..04aa0043a 100644 --- a/packages/b2c-tooling-sdk/test/operations/sites/sites-backend.test.ts +++ b/packages/b2c-tooling-sdk/test/operations/sites/sites-backend.test.ts @@ -10,20 +10,33 @@ import {OcapiSitesBackend} from '../../../src/operations/sites/ocapi-sites-backe import {ScapiSitesBackend} from '../../../src/operations/sites/scapi-sites-backend.js'; import {SCAPI_SITES_READ_AND_RW_SCOPES} from '../../../src/operations/sites/sites-scopes.js'; import {OcapiDeprecatedError} from '../../../src/clients/error-utils.js'; -import type {B2CInstance} from '../../../src/instance/index.js'; +import type {B2CInstance, ScapiClientConfig} from '../../../src/instance/index.js'; import type {AuthStrategy} from '../../../src/auth/types.js'; -function fakeInstance(getImpl: (path: string, init: unknown) => unknown): B2CInstance { - return {ocapi: {GET: async (path: string, init: unknown) => getImpl(path, init)}} as unknown as B2CInstance; +const fakeAuth = {} as AuthStrategy; + +/** + * Builds a fake {@link B2CInstance}. SCAPI resolution now flows from the + * instance: `apiBackend` is the preference and `scapiClientConfig` carries the + * shortCode/tenantId/auth (undefined → SCAPI not available). + */ +function fakeInstance( + getImpl: (path: string, init: unknown) => unknown, + opts: {apiBackend?: 'ocapi' | 'scapi' | 'auto'; scapiClientConfig?: ScapiClientConfig} = {}, +): B2CInstance { + return { + ocapi: {GET: async (path: string, init: unknown) => getImpl(path, init)}, + apiBackend: opts.apiBackend ?? 'auto', + scapiClientConfig: opts.scapiClientConfig, + } as unknown as B2CInstance; } -const fakeAuth = {} as AuthStrategy; +const fakeScapiConfig: ScapiClientConfig = {shortCode: 'abcd1234', tenantId: 'zzxy_dev', auth: fakeAuth}; describe('operations/sites backend', () => { describe('createSitesBackend resolution', () => { it('resolves to OCAPI when no SCAPI config is present', () => { const backend = createSitesBackend({ - preference: 'auto', instance: fakeInstance(() => ({data: {data: []}, error: undefined, response: {status: 200}})), }); expect(backend.name).to.equal('ocapi'); @@ -31,11 +44,9 @@ describe('operations/sites backend', () => { it('resolves to SCAPI (with OCAPI fallback wrapper) when SCAPI config is present', () => { const backend = createSitesBackend({ - preference: 'auto', - instance: fakeInstance(() => ({data: {data: []}, error: undefined, response: {status: 200}})), - shortCode: 'abcd1234', - tenantId: 'zzxy_dev', - auth: fakeAuth, + instance: fakeInstance(() => ({data: {data: []}, error: undefined, response: {status: 200}}), { + scapiClientConfig: fakeScapiConfig, + }), }); // Before any call resolves, the fallback wrapper reports the SCAPI name. expect(backend.name).to.equal('scapi'); @@ -44,11 +55,21 @@ describe('operations/sites backend', () => { it('honors explicit ocapi preference even with SCAPI config', () => { const backend = createSitesBackend({ preference: 'ocapi', - instance: fakeInstance(() => ({data: {data: []}, error: undefined, response: {status: 200}})), - shortCode: 'abcd1234', - tenantId: 'zzxy_dev', - auth: fakeAuth, + instance: fakeInstance(() => ({data: {data: []}, error: undefined, response: {status: 200}}), { + scapiClientConfig: fakeScapiConfig, + }), + }); + expect(backend.name).to.equal('ocapi'); + }); + + it("defaults the preference to the instance's apiBackend when omitted", () => { + const backend = createSitesBackend({ + instance: fakeInstance(() => ({data: {data: []}, error: undefined, response: {status: 200}}), { + apiBackend: 'ocapi', + scapiClientConfig: fakeScapiConfig, + }), }); + // Instance prefers OCAPI, and no explicit preference overrides it. expect(backend.name).to.equal('ocapi'); }); }); diff --git a/packages/b2c-vs-extension/src/code-sync/cartridge-commands.ts b/packages/b2c-vs-extension/src/code-sync/cartridge-commands.ts index ce6c1106a..dbfd44f2c 100644 --- a/packages/b2c-vs-extension/src/code-sync/cartridge-commands.ts +++ b/packages/b2c-vs-extension/src/code-sync/cartridge-commands.ts @@ -49,7 +49,7 @@ function createDownloadCartridgeCommand( let codeVersion = instance.config.codeVersion; if (!codeVersion) { try { - const active = await createScriptsBackendFromExtension(configProvider, instance).getActiveCodeVersion(); + const active = await createScriptsBackendFromExtension(instance).getActiveCodeVersion(); if (active?.id) codeVersion = active.id; } catch { // fall through @@ -226,7 +226,7 @@ function createListCodeVersionsCommand( if (!instance) return; try { - const scriptsBackend = createScriptsBackendFromExtension(configProvider, instance); + const scriptsBackend = createScriptsBackendFromExtension(instance); const versions = await scriptsBackend.listCodeVersions(); const items = versions.map((v) => ({ label: `${v.active ? '$(star-full) ' : ''}${v.id ?? 'unknown'}`, @@ -308,7 +308,7 @@ function createCreateCodeVersionCommand( if (!name) return; try { - await createScriptsBackendFromExtension(configProvider, instance).createCodeVersion(name.trim()); + await createScriptsBackendFromExtension(instance).createCodeVersion(name.trim()); outputChannel.appendLine(`[Code Version] Created "${name.trim()}"`); vscode.window.showInformationMessage(`B2C DX: Code version "${name.trim()}" created.`); treeProvider.refresh(); @@ -328,7 +328,7 @@ function createActivateCodeVersionCommand( if (!instance) return; try { - const scriptsBackend = createScriptsBackendFromExtension(configProvider, instance); + const scriptsBackend = createScriptsBackendFromExtension(instance); const versions = await scriptsBackend.listCodeVersions(); const items = versions.map((v) => ({ label: v.id ?? 'unknown', @@ -381,7 +381,7 @@ export async function updateCodeVersionDisplay( return; } try { - const active = await createScriptsBackendFromExtension(configProvider, instance).getActiveCodeVersion(); + const active = await createScriptsBackendFromExtension(instance).getActiveCodeVersion(); treeView.description = active?.id ? `v: ${active.id}` : ''; } catch { treeView.description = ''; diff --git a/packages/b2c-vs-extension/src/code-sync/code-sync-manager.ts b/packages/b2c-vs-extension/src/code-sync/code-sync-manager.ts index abe9c3598..54d3d3ab1 100644 --- a/packages/b2c-vs-extension/src/code-sync/code-sync-manager.ts +++ b/packages/b2c-vs-extension/src/code-sync/code-sync-manager.ts @@ -73,7 +73,7 @@ export class CodeSyncManager implements vscode.Disposable { this.codeVersion = instance.config.codeVersion; if (!this.codeVersion) { try { - const active = await createScriptsBackendFromExtension(this.configProvider, instance).getActiveCodeVersion(); + const active = await createScriptsBackendFromExtension(instance).getActiveCodeVersion(); if (active?.id) { this.codeVersion = active.id; instance.config.codeVersion = this.codeVersion; @@ -194,7 +194,7 @@ export class CodeSyncManager implements vscode.Disposable { let codeVersion = instance.config.codeVersion; if (!codeVersion) { try { - const active = await createScriptsBackendFromExtension(this.configProvider, instance).getActiveCodeVersion(); + const active = await createScriptsBackendFromExtension(instance).getActiveCodeVersion(); if (active?.id) { codeVersion = active.id; instance.config.codeVersion = codeVersion; @@ -233,7 +233,7 @@ export class CodeSyncManager implements vscode.Disposable { let codeVersion = instance.config.codeVersion; if (!codeVersion) { try { - const active = await createScriptsBackendFromExtension(this.configProvider, instance).getActiveCodeVersion(); + const active = await createScriptsBackendFromExtension(instance).getActiveCodeVersion(); if (active?.id) { codeVersion = active.id; instance.config.codeVersion = codeVersion; diff --git a/packages/b2c-vs-extension/src/code-sync/deploy-command.ts b/packages/b2c-vs-extension/src/code-sync/deploy-command.ts index c078ecd8f..0a2548239 100644 --- a/packages/b2c-vs-extension/src/code-sync/deploy-command.ts +++ b/packages/b2c-vs-extension/src/code-sync/deploy-command.ts @@ -20,7 +20,7 @@ export function createDeployCommand( } // Resolve code version through the configured Scripts backend (SCAPI or OCAPI) - const scriptsBackend = createScriptsBackendFromExtension(configProvider, instance); + const scriptsBackend = createScriptsBackendFromExtension(instance); let codeVersion = instance.config.codeVersion; if (!codeVersion) { try { diff --git a/packages/b2c-vs-extension/src/code-sync/scripts-backend.ts b/packages/b2c-vs-extension/src/code-sync/scripts-backend.ts index 4122d88e2..4206a56c3 100644 --- a/packages/b2c-vs-extension/src/code-sync/scripts-backend.ts +++ b/packages/b2c-vs-extension/src/code-sync/scripts-backend.ts @@ -9,24 +9,15 @@ * In `auto` mode this lets SCAPI-only setups manage code versions through * `sfcc.scripts(.rw)` instead of OCAPI, with transparent OCAPI fallback on * `invalid_scope`. + * + * SCAPI coordinates, auth, and the `apiBackend` preference all come from the + * instance ({@link B2CInstance.scapiClientConfig} / {@link B2CInstance.apiBackend}), + * which the extension builds from resolved config — so nothing extra needs to + * be threaded here. */ import {createScriptsBackend, type ScriptsBackend} from '@salesforce/b2c-tooling-sdk/operations/code'; import type {B2CInstance} from '@salesforce/b2c-tooling-sdk/instance'; -import type {B2CExtensionConfig} from '../config-provider.js'; -export function createScriptsBackendFromExtension( - configProvider: B2CExtensionConfig, - instance: B2CInstance, -): ScriptsBackend { - const resolved = configProvider.getConfig(); - const preference = resolved?.values.apiBackend ?? 'auto'; - const auth = resolved?.hasOAuthConfig() ? resolved.createOAuth() : undefined; - - return createScriptsBackend({ - preference, - instance, - shortCode: resolved?.values.shortCode, - tenantId: resolved?.values.tenantId, - auth, - }); +export function createScriptsBackendFromExtension(instance: B2CInstance): ScriptsBackend { + return createScriptsBackend({instance}); } From 32a2cdc211f3e18cb93ae349c02d8601a168226d Mon Sep 17 00:00:00 2001 From: Charles Lavery Date: Tue, 23 Jun 2026 13:25:28 -0400 Subject: [PATCH 17/22] feat(jobs): run site-archive + CAP system jobs over SCAPI with OCAPI fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Site archive import/export and CAP install/uninstall were OCAPI-only because they POSTed directly to /jobs/{id}/executions. With B2CInstance now carrying SCAPI config, route them through a shared dual-backend runner. - New operations/jobs/run-system-job.ts: runSystemJob(instance, spec) starts a named system job, waits, and returns the raw OCAPI JobExecution. In auto mode it tries SCAPI (instance.scapiClientConfig) and falls back to OCAPI only if the *start* is rejected — once a job has started it never falls back, so a write is never re-run. Honors apiBackend ocapi/scapi/auto. - The SCAPI JobExecutionRequest accepts the same {parameters:[{name,value}]} shape the OCAPI internal-user retry already used, so each operation declares one parameters array reused by both backends. - New mapCanonicalToOcapiExecution reverse mapper keeps the public result/error contract identical across backends: every consumer reads raw snake_case off result.execution, and a SCAPI job failure is re-thrown as the raw JobExecutionError so existing log-fetch handling works unchanged. - site-archive import/export and cap install/uninstall rewired to runSystemJob; log-fetch-on-failure factored into a shared helper. OCAPI behavior (shorthand body + UnknownPropertyException params retry) preserved exactly. - Adds runSystemJob tests (SCAPI path, raw-shape contract, no-fallback-after- start, auto fallback, OCAPI default, params retry, deprecation, explicit preference). Existing site-archive + CAP tests pass unchanged. Not yet live-verified end-to-end (deferred): auto mode keeps working via OCAPI fallback if SCAPI rejects a system-job start. --- .changeset/scapi-migration.md | 2 +- .../src/operations/cap/install.ts | 79 ++---- .../src/operations/cap/uninstall.ts | 65 ++--- .../src/operations/jobs/index.ts | 4 +- .../src/operations/jobs/ocapi-mapping.ts | 55 ++++ .../src/operations/jobs/run-system-job.ts | 233 ++++++++++++++++ .../src/operations/jobs/site-archive.ts | 168 ++++------- .../operations/jobs/run-system-job.test.ts | 262 ++++++++++++++++++ 8 files changed, 647 insertions(+), 221 deletions(-) create mode 100644 packages/b2c-tooling-sdk/src/operations/jobs/run-system-job.ts create mode 100644 packages/b2c-tooling-sdk/test/operations/jobs/run-system-job.test.ts diff --git a/.changeset/scapi-migration.md b/.changeset/scapi-migration.md index da5ca8586..6daca91b9 100644 --- a/.changeset/scapi-migration.md +++ b/.changeset/scapi-migration.md @@ -5,6 +5,6 @@ '@salesforce/b2c-dx-docs': minor --- -Migrate `job`, `code`, `bm users`, `bm roles`, and `sites` commands to support SCAPI alongside OCAPI. In auto mode (the default), the CLI prefers SCAPI when `shortCode` and `tenantId` are configured and silently falls back to OCAPI if the SCAPI scopes aren't granted. Use `--api-backend ocapi|scapi|auto` or `apiBackend` in dw.json to control explicitly. SCAPI scopes: `sfcc.jobs(.rw)`, `sfcc.scripts(.rw)`, `sfcc.users(.rw)`, `sfcc.roles(.rw)`, `sfcc.sites(.rw)` — read-only scopes are honored for list/get operations, falling back to read-only when the `*.rw` scope is not granted. `sites list` and `sites cartridges list` (reads) use the SCAPI `site/sites` API; cartridge-path writes have no SCAPI equivalent and remain on OCAPI / site-archive import. New `job execution delete` command (SCAPI only). `code deploy` and all VS Code extension code-version actions (list/activate/delete/reload/create plus active-version discovery) also honor `apiBackend`. `bm users update --disabled` transparently falls back to OCAPI in auto mode (SCAPI Users PATCH does not support the `disabled` flag). Auto mode only selects SCAPI when authentication can request the required scopes — stateless OAuth (client-credentials, JWT bearer); stateful and implicit flows fall back to OCAPI unless `--api-backend scapi` is set explicitly. +Migrate `job`, `code`, `bm users`, `bm roles`, and `sites` commands to support SCAPI alongside OCAPI. In auto mode (the default), the CLI prefers SCAPI when `shortCode` and `tenantId` are configured and silently falls back to OCAPI if the SCAPI scopes aren't granted. Use `--api-backend ocapi|scapi|auto` or `apiBackend` in dw.json to control explicitly. SCAPI scopes: `sfcc.jobs(.rw)`, `sfcc.scripts(.rw)`, `sfcc.users(.rw)`, `sfcc.roles(.rw)`, `sfcc.sites(.rw)` — read-only scopes are honored for list/get operations, falling back to read-only when the `*.rw` scope is not granted. `sites list` and `sites cartridges list` (reads) use the SCAPI `site/sites` API; cartridge-path writes have no SCAPI equivalent and remain on OCAPI / site-archive import. Site archive import/export (`site-import`, `site-export`, content/cartridge operations) and Commerce App Package install/uninstall (`cap install`, `cap uninstall`) now trigger their system jobs over SCAPI when configured, transparently falling back to OCAPI if the SCAPI start is rejected (never re-running a job that already started). New `job execution delete` command (SCAPI only). `code deploy` and all VS Code extension code-version actions (list/activate/delete/reload/create plus active-version discovery) also honor `apiBackend`. `bm users update --disabled` transparently falls back to OCAPI in auto mode (SCAPI Users PATCH does not support the `disabled` flag). Auto mode only selects SCAPI when authentication can request the required scopes — stateless OAuth (client-credentials, JWT bearer); stateful and implicit flows fall back to OCAPI unless `--api-backend scapi` is set explicitly. For SDK consumers, `B2CInstance` now carries the SCAPI coordinates itself: a `B2CInstance.scapiClientConfig` getter returns `{shortCode, tenantId, auth}` (or `undefined` when the instance can't reach SCAPI), and `B2CInstance.apiBackend` exposes the configured preference. The dual-backend factories (`createSitesBackend`, `createScriptsBackend`, `createUsersBackend`, `createRolesBackend`) now take just `{instance}` and source SCAPI config from it — so SCAPI operations need nothing beyond a configured instance. diff --git a/packages/b2c-tooling-sdk/src/operations/cap/install.ts b/packages/b2c-tooling-sdk/src/operations/cap/install.ts index 49732d903..94e5300b8 100644 --- a/packages/b2c-tooling-sdk/src/operations/cap/install.ts +++ b/packages/b2c-tooling-sdk/src/operations/cap/install.ts @@ -12,9 +12,9 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import JSZip from 'jszip'; import {B2CInstance} from '../../instance/index.js'; -import {isOcapiDeprecatedFault, OcapiDeprecatedError} from '../../clients/error-utils.js'; import {getLogger} from '../../logging/logger.js'; -import {waitForJob, JobExecutionError, getJobLog, type JobExecution, type WaitForJobOptions} from '../jobs/run.js'; +import {JobExecutionError, getJobLog, type JobExecution, type WaitForJobOptions} from '../jobs/run.js'; +import {runSystemJob} from '../jobs/run-system-job.js'; import {addDirectoryToZip} from '../util/zip.js'; import {type CommerceAppManifest} from './validate.js'; @@ -113,63 +113,32 @@ export async function commerceAppInstall( await instance.webdav.put(webdavUploadPath, archiveContent, 'application/zip'); logger.debug({path: webdavUploadPath}, `CAP uploaded: ${webdavUploadPath}`); - // Execute the install job + // Execute the install job (SCAPI when configured, OCAPI fallback in auto). logger.debug({jobId: INSTALL_JOB_ID, appName: manifest.id, siteId}, `Executing ${INSTALL_JOB_ID} job`); - let execution: JobExecution; - - // Try direct body format first (standard OCAPI format) - const {data, error} = await instance.ocapi.POST('/jobs/{job_id}/executions', { - params: {path: {job_id: INSTALL_JOB_ID}}, - body: { - app_name: manifest.id, - app_source: 'WebDAV', - app_domain: manifest.domain, - site_id: siteId, - app_path: appPath, - should_create_pr: shouldCreatePr, - } as unknown as string, - }); - - if ( - error?.fault?.type === 'UnknownPropertyException' && - (error.fault.arguments as Record)?.document === 'job_execution_request' - ) { - // Retry with parameters format (internal/support users) - logger.warn('Retrying with parameters format for internal users'); - - const {data: retryData, error: retryError} = await instance.ocapi.POST('/jobs/{job_id}/executions', { - params: {path: {job_id: INSTALL_JOB_ID}}, - body: { - parameters: [ - {name: 'AppName', value: manifest.id}, - {name: 'AppSource', value: 'WebDAV'}, - {name: 'AppDomain', value: manifest.domain}, - {name: 'SiteId', value: siteId}, - {name: 'AppPath', value: appPath}, - {name: 'ShouldCreatePR', value: String(shouldCreatePr)}, - ], - } as unknown as string, - }); - - if (retryError || !retryData) { - if (isOcapiDeprecatedFault(retryError)) throw new OcapiDeprecatedError({cause: retryError}); - throw new Error(retryError?.fault?.message ?? 'Failed to start install job', {cause: retryError}); - } - - execution = retryData; - } else if (error || !data) { - if (isOcapiDeprecatedFault(error)) throw new OcapiDeprecatedError({cause: error}); - throw new Error(error?.fault?.message ?? 'Failed to start install job', {cause: error}); - } else { - execution = data; - } - logger.debug({jobId: INSTALL_JOB_ID, executionId: execution.id}, `Install job started: ${execution.id}`); - - // Wait for job completion let finalExecution: JobExecution; try { - finalExecution = await waitForJob(instance, INSTALL_JOB_ID, execution.id!, waitOptions); + finalExecution = await runSystemJob(instance, { + jobId: INSTALL_JOB_ID, + ocapiBody: { + app_name: manifest.id, + app_source: 'WebDAV', + app_domain: manifest.domain, + site_id: siteId, + app_path: appPath, + should_create_pr: shouldCreatePr, + }, + parameters: [ + {name: 'AppName', value: manifest.id}, + {name: 'AppSource', value: 'WebDAV'}, + {name: 'AppDomain', value: manifest.domain}, + {name: 'SiteId', value: siteId}, + {name: 'AppPath', value: appPath}, + {name: 'ShouldCreatePR', value: String(shouldCreatePr)}, + ], + waitOptions, + failVerb: 'start install job', + }); } catch (err) { if (err instanceof JobExecutionError) { try { diff --git a/packages/b2c-tooling-sdk/src/operations/cap/uninstall.ts b/packages/b2c-tooling-sdk/src/operations/cap/uninstall.ts index 371944e29..b40a28b32 100644 --- a/packages/b2c-tooling-sdk/src/operations/cap/uninstall.ts +++ b/packages/b2c-tooling-sdk/src/operations/cap/uninstall.ts @@ -9,9 +9,9 @@ * Runs the sfcc-uninstall-commerce-app system job to remove an installed CAP. */ import {B2CInstance} from '../../instance/index.js'; -import {isOcapiDeprecatedFault, OcapiDeprecatedError} from '../../clients/error-utils.js'; import {getLogger} from '../../logging/logger.js'; -import {waitForJob, JobExecutionError, getJobLog, type JobExecution, type WaitForJobOptions} from '../jobs/run.js'; +import {JobExecutionError, getJobLog, type JobExecution, type WaitForJobOptions} from '../jobs/run.js'; +import {runSystemJob} from '../jobs/run-system-job.js'; import {normalizeSiteId} from './install.js'; const UNINSTALL_JOB_ID = 'sfcc-uninstall-commerce-app'; @@ -68,53 +68,24 @@ export async function commerceAppUninstall( logger.debug({jobId: UNINSTALL_JOB_ID, appName, siteId}, `Executing ${UNINSTALL_JOB_ID} job`); - let execution: JobExecution; - - // Try direct body format first (standard OCAPI format) - const {data, error} = await instance.ocapi.POST('/jobs/{job_id}/executions', { - params: {path: {job_id: UNINSTALL_JOB_ID}}, - body: { - app_name: appName, - app_domain: appDomain, - site_id: siteId, - } as unknown as string, - }); - - if ( - error?.fault?.type === 'UnknownPropertyException' && - (error.fault.arguments as Record)?.document === 'job_execution_request' - ) { - // Retry with parameters format (internal/support users) - logger.warn('Retrying with parameters format for internal users'); - - const {data: retryData, error: retryError} = await instance.ocapi.POST('/jobs/{job_id}/executions', { - params: {path: {job_id: UNINSTALL_JOB_ID}}, - body: { - parameters: [ - {name: 'AppName', value: appName}, - {name: 'AppDomain', value: appDomain}, - {name: 'SiteId', value: siteId}, - ], - } as unknown as string, - }); - - if (retryError || !retryData) { - if (isOcapiDeprecatedFault(retryError)) throw new OcapiDeprecatedError({cause: retryError}); - throw new Error(retryError?.fault?.message ?? 'Failed to start uninstall job', {cause: retryError}); - } - - execution = retryData; - } else if (error || !data) { - if (isOcapiDeprecatedFault(error)) throw new OcapiDeprecatedError({cause: error}); - throw new Error(error?.fault?.message ?? 'Failed to start uninstall job', {cause: error}); - } else { - execution = data; - } - logger.debug({jobId: UNINSTALL_JOB_ID, executionId: execution.id}, `Uninstall job started: ${execution.id}`); - + // Execute the uninstall job (SCAPI when configured, OCAPI fallback in auto). let finalExecution: JobExecution; try { - finalExecution = await waitForJob(instance, UNINSTALL_JOB_ID, execution.id!, waitOptions); + finalExecution = await runSystemJob(instance, { + jobId: UNINSTALL_JOB_ID, + ocapiBody: { + app_name: appName, + app_domain: appDomain, + site_id: siteId, + }, + parameters: [ + {name: 'AppName', value: appName}, + {name: 'AppDomain', value: appDomain}, + {name: 'SiteId', value: siteId}, + ], + waitOptions, + failVerb: 'start uninstall job', + }); } catch (err) { if (err instanceof JobExecutionError) { try { diff --git a/packages/b2c-tooling-sdk/src/operations/jobs/index.ts b/packages/b2c-tooling-sdk/src/operations/jobs/index.ts index 551ba86a4..6a362f15b 100644 --- a/packages/b2c-tooling-sdk/src/operations/jobs/index.ts +++ b/packages/b2c-tooling-sdk/src/operations/jobs/index.ts @@ -52,7 +52,9 @@ export type {JobExecutionInfo, JobStepExecutionResult, JobExecutionSearchResults // Backend-agnostic helpers export {waitForJobExecution, CanonicalJobExecutionError} from './wait-canonical.js'; -export {mapOcapiExecution, mapOcapiSearchResult} from './ocapi-mapping.js'; +export {mapOcapiExecution, mapOcapiSearchResult, mapCanonicalToOcapiExecution} from './ocapi-mapping.js'; +export {runSystemJob} from './run-system-job.js'; +export type {SystemJobSpec} from './run-system-job.js'; // Site archive import/export (uses OCAPI WebDAV path) export { diff --git a/packages/b2c-tooling-sdk/src/operations/jobs/ocapi-mapping.ts b/packages/b2c-tooling-sdk/src/operations/jobs/ocapi-mapping.ts index 616d7962c..b5f747be5 100644 --- a/packages/b2c-tooling-sdk/src/operations/jobs/ocapi-mapping.ts +++ b/packages/b2c-tooling-sdk/src/operations/jobs/ocapi-mapping.ts @@ -55,6 +55,61 @@ export function mapOcapiExecution(ocapi: JobExecution): JobExecutionInfo { }; } +function mapCanonicalStepExecution(step: JobStepExecutionResult): JobStepExecution { + return { + id: step.id, + step_id: step.stepId, + execution_status: step.executionStatus as JobStepExecution['execution_status'], + exit_status: step.exitStatus + ? { + code: step.exitStatus.code, + message: step.exitStatus.message, + status: step.exitStatus.status, + } + : undefined, + duration: step.duration, + } as JobStepExecution; +} + +/** + * Map a canonical {@link JobExecutionInfo} back into the raw OCAPI + * {@link JobExecution} (snake_case) shape. + * + * The reverse of {@link mapOcapiExecution}. System-job operations + * (site-archive import/export, CAP install/uninstall) expose the raw OCAPI + * `JobExecution` in their public result/error types; when those operations are + * served over SCAPI, the canonical result is mapped back through this so the + * public contract stays identical across backends. + * + * Prefers the original OCAPI payload when present in `_raw` (lossless + * round-trip for the OCAPI path); otherwise projects the canonical fields. + */ +export function mapCanonicalToOcapiExecution(canonical: JobExecutionInfo): JobExecution { + if (canonical._raw && typeof canonical._raw === 'object' && 'execution_status' in canonical._raw) { + return canonical._raw as JobExecution; + } + + return { + id: canonical.id, + job_id: canonical.jobId, + execution_status: canonical.executionStatus as JobExecution['execution_status'], + exit_status: canonical.exitStatus + ? { + code: canonical.exitStatus.code, + message: canonical.exitStatus.message, + status: canonical.exitStatus.status, + } + : undefined, + start_time: canonical.startTime, + end_time: canonical.endTime, + duration: canonical.duration, + step_executions: canonical.stepExecutions?.map(mapCanonicalStepExecution), + log_file_path: canonical.logFilePath, + is_log_file_existing: canonical.isLogFileExisting, + parameters: canonical.parameters, + } as JobExecution; +} + /** Map a raw OCAPI search result into the canonical shape. */ export function mapOcapiSearchResult(result: { total: number; diff --git a/packages/b2c-tooling-sdk/src/operations/jobs/run-system-job.ts b/packages/b2c-tooling-sdk/src/operations/jobs/run-system-job.ts new file mode 100644 index 000000000..57a1de753 --- /dev/null +++ b/packages/b2c-tooling-sdk/src/operations/jobs/run-system-job.ts @@ -0,0 +1,233 @@ +/* + * 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 + */ +/** + * Dual-backend runner for B2C Commerce **system jobs** (site-archive + * import/export, CAP install/uninstall). + * + * These operations all follow the same shape: trigger a named system job, + * wait for it to finish, and surface the job log on failure. This module + * centralizes that flow over either backend so each operation declares only + * its job ID and request body — not the OCAPI-vs-SCAPI plumbing. + * + * ## Contract + * + * The public result/error types of every caller expose the **raw OCAPI** + * {@link JobExecution} (snake_case). To keep that contract identical across + * backends, the SCAPI path maps its canonical result back to the OCAPI shape + * via {@link mapCanonicalToOcapiExecution}, and a SCAPI job failure is + * re-thrown as the raw {@link JobExecutionError} — so a caller's existing + * `catch (JobExecutionError) → getJobLog(instance, err.execution)` works + * unchanged regardless of which backend served the job. + * + * ## Backend selection & fallback + * + * Honors {@link B2CInstance.apiBackend}: + * - `'ocapi'`: always OCAPI. + * - `'scapi'`: always SCAPI (throws if the instance can't reach SCAPI). + * - `'auto'` (default): SCAPI when {@link B2CInstance.scapiClientConfig} is + * available, else OCAPI. **Fallback to OCAPI happens only if the SCAPI + * *start* fails** (missing scope, body rejected, system job not + * triggerable over SCAPI). Once a job has started, the wait/log path never + * falls back — re-running a started write would be unsafe. + * + * ## Lifecycle + * + * Lives alongside the other transitional jobs plumbing. When OCAPI is removed, + * delete the OCAPI branch and inline the SCAPI calls. + * + * @module operations/jobs/run-system-job + */ +import type {B2CInstance, ScapiClientConfig} from '../../instance/index.js'; +import {isOcapiDeprecatedFault, OcapiDeprecatedError} from '../../clients/error-utils.js'; +import {createScapiJobsClient} from '../../clients/scapi-jobs.js'; +import {getLogger} from '../../logging/logger.js'; +import {mapCanonicalToOcapiExecution} from './ocapi-mapping.js'; +import {executeJob as scapiExecuteJob, getJobExecution as scapiGetJobExecution} from './scapi-ops.js'; +import {waitForJob, JobExecutionError, type JobExecution, type WaitForJobOptions} from './run.js'; +import {waitForJobExecution, CanonicalJobExecutionError} from './wait-canonical.js'; + +/** + * Declarative description of a system job to run. The operation supplies its + * job ID and the two request-body forms; this module drives the rest. + */ +export interface SystemJobSpec { + /** System job ID, e.g. `sfcc-site-archive-import`. */ + jobId: string; + /** + * OCAPI "shorthand" request body tried first on the OCAPI path (e.g. + * `{file_name}`, `{export_file, data_units}`, or the CAP `{app_name, ...}` + * shape). When the instance rejects it with `UnknownPropertyException`, the + * OCAPI path retries with {@link parameters}. + */ + ocapiBody: Record; + /** + * Job parameters (`[{name, value}]`). Used as the OCAPI internal-user retry + * body **and** as the SCAPI request body (SCAPI's `JobExecutionRequest` + * accepts exactly this shape). + */ + parameters: Array<{name: string; value: string}>; + /** Scopes named in the OCAPI-deprecation error (the rw jobs scope). */ + deprecatedScopes?: string[]; + /** Whether to wait for completion (default `true`). */ + wait?: boolean; + /** Wait options forwarded to the poll loop. */ + waitOptions?: WaitForJobOptions; + /** Human-readable verb for error messages, e.g. `'execute import job'`. */ + failVerb: string; +} + +/** + * Starts a system job (waiting for completion unless `spec.wait === false`) + * and returns the raw OCAPI {@link JobExecution}. Throws {@link JobExecutionError} + * (raw) when the job fails, or {@link OcapiDeprecatedError} when the only + * reachable backend is a deprecated OCAPI. + */ +export async function runSystemJob(instance: B2CInstance, spec: SystemJobSpec): Promise { + const preference = instance.apiBackend; + const scapiConfig = instance.scapiClientConfig; + + if (preference === 'ocapi') { + return runOcapiSystemJob(instance, spec); + } + + if (preference === 'scapi') { + if (!scapiConfig) { + throw new Error( + `${spec.jobId} SCAPI backend requires shortCode, tenantId, and OAuth credentials. ` + + `Configure them in dw.json or set apiBackend to ocapi.`, + ); + } + return runScapiSystemJob(instance, scapiConfig, spec); + } + + // auto + if (!scapiConfig) { + return runOcapiSystemJob(instance, spec); + } + + // Try SCAPI start; fall back to OCAPI only if the start fails (nothing has + // run yet). Once started, finishScapiJob handles wait/failure without + // falling back. + const client = createScapiJobsClient( + {shortCode: scapiConfig.shortCode, tenantId: scapiConfig.tenantId}, + scapiConfig.auth, + ); + let started; + try { + started = await startScapiJob(client, scapiConfig.tenantId, spec); + } catch (error) { + getLogger().info( + {jobId: spec.jobId, reason: error instanceof Error ? error.message : String(error)}, + `SCAPI ${spec.jobId} unavailable, falling back to OCAPI`, + ); + return runOcapiSystemJob(instance, spec); + } + return finishScapiJob(client, scapiConfig.tenantId, spec, started); +} + +/** + * SCAPI path: trigger the job (start phase, fallback-eligible in auto) then + * wait/map (finish phase, never falls back). + */ +async function runScapiSystemJob( + instance: B2CInstance, + scapiConfig: ScapiClientConfig, + spec: SystemJobSpec, +): Promise { + const client = createScapiJobsClient( + {shortCode: scapiConfig.shortCode, tenantId: scapiConfig.tenantId}, + scapiConfig.auth, + ); + const started = await startScapiJob(client, scapiConfig.tenantId, spec); + return finishScapiJob(client, scapiConfig.tenantId, spec, started); +} + +async function startScapiJob(client: ReturnType, tenantId: string, spec: SystemJobSpec) { + getLogger().debug({jobId: spec.jobId}, `Executing ${spec.jobId} job via SCAPI`); + return scapiExecuteJob(client, spec.jobId, {parameters: spec.parameters, tenantId}); +} + +async function finishScapiJob( + client: ReturnType, + tenantId: string, + spec: SystemJobSpec, + started: Awaited>, +): Promise { + getLogger().debug({jobId: spec.jobId, executionId: started.id}, `${spec.jobId} job started: ${started.id}`); + + if (spec.wait === false) { + return mapCanonicalToOcapiExecution(started); + } + + try { + const final = await waitForJobExecution( + (jobId, executionId) => scapiGetJobExecution(client, jobId, executionId, tenantId), + spec.jobId, + started.id, + spec.waitOptions, + ); + return mapCanonicalToOcapiExecution(final); + } catch (error) { + // Re-throw a job FAILURE as the raw JobExecutionError so callers' existing + // log-fetch handling works identically across backends. Other errors + // (timeout, network) propagate as-is — never fall back post-start. + if (error instanceof CanonicalJobExecutionError) { + throw new JobExecutionError(error.message, mapCanonicalToOcapiExecution(error.execution)); + } + throw error; + } +} + +/** + * OCAPI path: preserves the legacy behavior exactly — POST the shorthand body, + * retry with the parameters body on `UnknownPropertyException`, then wait. + */ +async function runOcapiSystemJob(instance: B2CInstance, spec: SystemJobSpec): Promise { + const logger = getLogger(); + logger.debug({jobId: spec.jobId}, `Executing ${spec.jobId} job via OCAPI`); + + let execution: JobExecution; + + const {data, error} = await instance.ocapi.POST('/jobs/{job_id}/executions', { + params: {path: {job_id: spec.jobId}}, + body: spec.ocapiBody as unknown as string, + }); + + if ( + error?.fault?.type === 'UnknownPropertyException' && + (error.fault.arguments as Record)?.document === 'job_execution_request' + ) { + // Retry with parameters format (internal/support users) + logger.warn('Retrying with parameters format for internal users'); + + const {data: retryData, error: retryError} = await instance.ocapi.POST('/jobs/{job_id}/executions', { + params: {path: {job_id: spec.jobId}}, + body: {parameters: spec.parameters} as unknown as string, + }); + + if (retryError || !retryData) { + if (isOcapiDeprecatedFault(retryError)) + throw new OcapiDeprecatedError({cause: retryError, requiredScopes: spec.deprecatedScopes}); + throw new Error(retryError?.fault?.message ?? `Failed to ${spec.failVerb}`, {cause: retryError}); + } + + execution = retryData; + } else if (error || !data) { + if (isOcapiDeprecatedFault(error)) + throw new OcapiDeprecatedError({cause: error, requiredScopes: spec.deprecatedScopes}); + throw new Error(error?.fault?.message ?? `Failed to ${spec.failVerb}`, {cause: error}); + } else { + execution = data; + } + + logger.debug({jobId: spec.jobId, executionId: execution.id}, `${spec.jobId} job started: ${execution.id}`); + + if (spec.wait === false) { + return execution; + } + + return waitForJob(instance, spec.jobId, execution.id!, spec.waitOptions); +} diff --git a/packages/b2c-tooling-sdk/src/operations/jobs/site-archive.ts b/packages/b2c-tooling-sdk/src/operations/jobs/site-archive.ts index 26b291eec..3b003a08a 100644 --- a/packages/b2c-tooling-sdk/src/operations/jobs/site-archive.ts +++ b/packages/b2c-tooling-sdk/src/operations/jobs/site-archive.ts @@ -15,11 +15,11 @@ import * as zlib from 'node:zlib'; import {glob, hasMagic} from 'glob'; import JSZip from 'jszip'; import {B2CInstance} from '../../instance/index.js'; -import {isOcapiDeprecatedFault, OcapiDeprecatedError} from '../../clients/error-utils.js'; import {SCAPI_JOBS_CASCADE} from '../../clients/scapi-jobs.js'; import {getLogger} from '../../logging/logger.js'; import {addDirectoryToZip} from '../util/zip.js'; -import {waitForJob, JobExecutionError, getJobLog, type JobExecution, type WaitForJobOptions} from './run.js'; +import {JobExecutionError, getJobLog, type JobExecution, type WaitForJobOptions} from './run.js'; +import {runSystemJob} from './run-system-job.js'; // Import/export trigger system jobs via the job-execution write surface. const JOBS_RW_SCOPES = [...new Set(SCAPI_JOBS_CASCADE.write.flat())]; @@ -27,6 +27,25 @@ const JOBS_RW_SCOPES = [...new Set(SCAPI_JOBS_CASCADE.write.flat())]; const IMPORT_JOB_ID = 'sfcc-site-archive-import'; const EXPORT_JOB_ID = 'sfcc-site-archive-export'; +/** + * On a {@link JobExecutionError}, fetch and log the job log over WebDAV. + * Shared by import/export; the log lives under `/Sites/LOGS` for both backends, + * so a single WebDAV-based `getJobLog` works regardless of which served the job. + * Non-{@link JobExecutionError} errors (timeout, network) are left alone. + */ +async function logJobFailure(instance: B2CInstance, jobId: string, error: unknown): Promise { + if (!(error instanceof JobExecutionError)) { + return; + } + const logger = getLogger(); + try { + const log = await getJobLog(instance, error.execution); + logger.error({jobId, logFile: error.execution.log_file_path, log}, `Job log:\n${log}`); + } catch { + logger.error({jobId}, 'Could not retrieve job log'); + } +} + /** * Options for site archive import. */ @@ -222,72 +241,32 @@ export async function siteArchiveImport( logger.debug({path: uploadPath}, `Archive uploaded: ${uploadPath}`); } - // Execute the import job with file_name parameter + // Execute the import job (SCAPI when configured, OCAPI fallback in auto). logger.debug( {jobId: IMPORT_JOB_ID, file: zipFilename}, `Executing ${IMPORT_JOB_ID} job with file_name: ${zipFilename}`, ); let execution: JobExecution; - - // Try file_name format first (standard OCAPI format) - const {data, error} = await instance.ocapi.POST('/jobs/{job_id}/executions', { - params: {path: {job_id: IMPORT_JOB_ID}}, - body: {file_name: zipFilename} as unknown as string, - }); - - if ( - error?.fault?.type === 'UnknownPropertyException' && - (error.fault.arguments as Record)?.document === 'job_execution_request' - ) { - // Retry with parameters format (internal/support users) - logger.warn('Retrying with parameters format for internal users'); - - const {data: retryData, error: retryError} = await instance.ocapi.POST('/jobs/{job_id}/executions', { - params: {path: {job_id: IMPORT_JOB_ID}}, - body: { - parameters: [{name: 'ImportFile', value: zipFilename}], - } as unknown as string, + try { + execution = await runSystemJob(instance, { + jobId: IMPORT_JOB_ID, + ocapiBody: {file_name: zipFilename}, + parameters: [{name: 'ImportFile', value: zipFilename}], + deprecatedScopes: JOBS_RW_SCOPES, + wait, + waitOptions, + failVerb: 'execute import job', }); - - if (retryError || !retryData) { - if (isOcapiDeprecatedFault(retryError)) - throw new OcapiDeprecatedError({cause: retryError, requiredScopes: JOBS_RW_SCOPES}); - throw new Error(retryError?.fault?.message ?? 'Failed to execute import job', {cause: retryError}); - } - - execution = retryData; - } else if (error || !data) { - if (isOcapiDeprecatedFault(error)) throw new OcapiDeprecatedError({cause: error, requiredScopes: JOBS_RW_SCOPES}); - throw new Error(error?.fault?.message ?? 'Failed to execute import job', {cause: error}); - } else { - execution = data; + } catch (error) { + await logJobFailure(instance, IMPORT_JOB_ID, error); + throw error; } - logger.debug({jobId: IMPORT_JOB_ID, executionId: execution.id}, `Import job started: ${execution.id}`); - - if (wait) { - // Wait for completion - try { - execution = await waitForJob(instance, IMPORT_JOB_ID, execution.id!, waitOptions); - } catch (error) { - if (error instanceof JobExecutionError) { - // Try to get log file - try { - const log = await getJobLog(instance, error.execution); - logger.error({jobId: IMPORT_JOB_ID, logFile: error.execution.log_file_path, log}, `Job log:\n${log}`); - } catch { - logger.error({jobId: IMPORT_JOB_ID}, 'Could not retrieve job log'); - } - } - throw error; - } - - // Clean up archive if not keeping - if (!keepArchive && needsUpload) { - await instance.webdav.delete(uploadPath); - logger.debug({path: uploadPath}, `Archive deleted: ${uploadPath}`); - } + // Clean up archive if not keeping (only when we waited for completion) + if (wait && !keepArchive && needsUpload) { + await instance.webdav.delete(uploadPath); + logger.debug({path: uploadPath}, `Archive deleted: ${uploadPath}`); } return { @@ -1031,67 +1010,22 @@ export async function siteArchiveExport( logger.debug({jobId: EXPORT_JOB_ID, dataUnits}, `Executing ${EXPORT_JOB_ID} job`); + // Execute export job (SCAPI when configured, OCAPI fallback in auto). let execution: JobExecution; - - // Execute export job - try export_file format first - { - const {data, error} = await instance.ocapi.POST('/jobs/{job_id}/executions', { - params: {path: {job_id: EXPORT_JOB_ID}}, - body: { - export_file: zipFilename, - data_units: dataUnits, - } as unknown as string, - }); - - if ( - error?.fault?.type === 'UnknownPropertyException' && - (error.fault.arguments as Record)?.document === 'job_execution_request' - ) { - // Retry with parameters format (internal/support users) - logger.warn('Retrying with parameters format for internal users'); - - const {data: retryData, error: retryError} = await instance.ocapi.POST('/jobs/{job_id}/executions', { - params: {path: {job_id: EXPORT_JOB_ID}}, - body: { - parameters: [ - {name: 'ExportFile', value: zipFilename}, - {name: 'DataUnits', value: JSON.stringify(dataUnits)}, - ], - } as unknown as string, - }); - - if (retryError || !retryData) { - if (isOcapiDeprecatedFault(retryError)) - throw new OcapiDeprecatedError({cause: retryError, requiredScopes: JOBS_RW_SCOPES}); - throw new Error(retryError?.fault?.message ?? 'Failed to execute export job', { - cause: retryError, - }); - } - - execution = retryData; - } else if (error || !data) { - if (isOcapiDeprecatedFault(error)) throw new OcapiDeprecatedError({cause: error, requiredScopes: JOBS_RW_SCOPES}); - throw new Error(error?.fault?.message ?? 'Failed to execute export job', {cause: error}); - } else { - execution = data; - } - } - - logger.debug({jobId: EXPORT_JOB_ID, executionId: execution.id}, `Export job started: ${execution.id}`); - - // Wait for completion try { - execution = await waitForJob(instance, EXPORT_JOB_ID, execution.id!, waitOptions); + execution = await runSystemJob(instance, { + jobId: EXPORT_JOB_ID, + ocapiBody: {export_file: zipFilename, data_units: dataUnits}, + parameters: [ + {name: 'ExportFile', value: zipFilename}, + {name: 'DataUnits', value: JSON.stringify(dataUnits)}, + ], + deprecatedScopes: JOBS_RW_SCOPES, + waitOptions, + failVerb: 'execute export job', + }); } catch (error) { - if (error instanceof JobExecutionError) { - // Try to get log file - try { - const log = await getJobLog(instance, error.execution); - logger.error({jobId: EXPORT_JOB_ID, logFile: error.execution.log_file_path, log}, `Job log:\n${log}`); - } catch { - logger.error({jobId: EXPORT_JOB_ID}, 'Could not retrieve job log'); - } - } + await logJobFailure(instance, EXPORT_JOB_ID, error); throw error; } diff --git a/packages/b2c-tooling-sdk/test/operations/jobs/run-system-job.test.ts b/packages/b2c-tooling-sdk/test/operations/jobs/run-system-job.test.ts new file mode 100644 index 000000000..e4db16652 --- /dev/null +++ b/packages/b2c-tooling-sdk/test/operations/jobs/run-system-job.test.ts @@ -0,0 +1,262 @@ +/* + * 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 + */ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import {expect} from 'chai'; +import {http, HttpResponse} from 'msw'; +import {setupServer} from 'msw/node'; +import {createOcapiClient} from '../../../src/clients/ocapi.js'; +import {runSystemJob} from '../../../src/operations/jobs/run-system-job.js'; +import {JobExecutionError} from '../../../src/operations/jobs/run.js'; +import {OcapiDeprecatedError} from '../../../src/clients/error-utils.js'; +import {MockAuthStrategy} from '../../helpers/mock-auth.js'; +import type {B2CInstance} from '../../../src/instance/index.js'; + +const TEST_HOST = 'test.demandware.net'; +const OCAPI_BASE = `https://${TEST_HOST}/s/-/dw/data/v25_6`; +const SHORT_CODE = 'kv7kzm78'; +const TENANT_ID = 'zzxy_prd'; +const ORG_ID = 'f_ecom_zzxy_prd'; +const SCAPI_BASE = `https://${SHORT_CODE}.api.commercecloud.salesforce.com/operation/jobs/v1`; +const JOB_ID = 'sfcc-site-archive-import'; + +const FAST_WAIT = {pollIntervalSeconds: 1, sleep: () => Promise.resolve()}; + +/** + * Builds a B2CInstance-shaped object. `scapiConfig: true` makes + * `scapiClientConfig` resolve so the SCAPI path is taken; the SCAPI client it + * builds internally is intercepted by MSW like any other. + */ +function makeInstance(opts: {apiBackend?: 'ocapi' | 'scapi' | 'auto'; scapi?: boolean}): B2CInstance { + const ocapi = createOcapiClient(TEST_HOST, new MockAuthStrategy()); + return { + config: {hostname: TEST_HOST}, + apiBackend: opts.apiBackend ?? 'auto', + scapiClientConfig: opts.scapi + ? {shortCode: SHORT_CODE, tenantId: TENANT_ID, auth: new MockAuthStrategy()} + : undefined, + ocapi, + webdav: { + get: async () => new TextEncoder().encode('log contents'), + }, + } as unknown as B2CInstance; +} + +const SPEC = { + jobId: JOB_ID, + ocapiBody: {file_name: 'a.zip'}, + parameters: [{name: 'ImportFile', value: 'a.zip'}], + failVerb: 'execute import job', + waitOptions: FAST_WAIT, +}; + +describe('operations/jobs/run-system-job', () => { + const server = setupServer(); + + before(() => server.listen({onUnhandledRequest: 'error'})); + afterEach(() => server.resetHandlers()); + after(() => server.close()); + + describe('SCAPI path (auto + scapiClientConfig)', () => { + it('runs over SCAPI and returns the raw OCAPI (snake_case) execution', async () => { + let scapiPosted = false; + let ocapiPosted = false; + server.use( + http.post(`${SCAPI_BASE}/organizations/${ORG_ID}/jobs/${JOB_ID}/executions`, () => { + scapiPosted = true; + return HttpResponse.json({id: 'exec-1', jobId: JOB_ID, executionStatus: 'pending'}); + }), + http.get(`${SCAPI_BASE}/organizations/${ORG_ID}/jobs/${JOB_ID}/executions/exec-1`, () => + HttpResponse.json({ + id: 'exec-1', + jobId: JOB_ID, + executionStatus: 'finished', + exitStatus: {code: 'OK', status: 'ok'}, + }), + ), + http.post(`${OCAPI_BASE}/jobs/${JOB_ID}/executions`, () => { + ocapiPosted = true; + return HttpResponse.json({id: 'should-not-be-used'}); + }), + ); + + const execution = await runSystemJob(makeInstance({scapi: true}), SPEC); + + expect(scapiPosted).to.be.true; + expect(ocapiPosted).to.be.false; + // Public contract: raw OCAPI snake_case fields. + expect(execution.id).to.equal('exec-1'); + expect(execution.execution_status).to.equal('finished'); + expect(execution.exit_status?.code).to.equal('OK'); + }); + + it('throws a raw JobExecutionError when the SCAPI job fails (no fallback after start)', async () => { + let ocapiPosted = false; + server.use( + http.post(`${SCAPI_BASE}/organizations/${ORG_ID}/jobs/${JOB_ID}/executions`, () => + HttpResponse.json({id: 'exec-2', jobId: JOB_ID, executionStatus: 'pending'}), + ), + http.get(`${SCAPI_BASE}/organizations/${ORG_ID}/jobs/${JOB_ID}/executions/exec-2`, () => + HttpResponse.json({ + id: 'exec-2', + jobId: JOB_ID, + executionStatus: 'aborted', + exitStatus: {code: 'ERROR', status: 'error', message: 'boom'}, + }), + ), + http.post(`${OCAPI_BASE}/jobs/${JOB_ID}/executions`, () => { + ocapiPosted = true; + return HttpResponse.json({id: 'should-not-be-used'}); + }), + ); + + try { + await runSystemJob(makeInstance({scapi: true}), SPEC); + expect.fail('expected JobExecutionError'); + } catch (error) { + expect(error).to.be.instanceOf(JobExecutionError); + // Carries the raw OCAPI execution so callers' log-fetch handling works. + expect((error as JobExecutionError).execution.id).to.equal('exec-2'); + expect((error as JobExecutionError).execution.execution_status).to.equal('aborted'); + } + // Job had already started — must NOT have fallen back to OCAPI. + expect(ocapiPosted).to.be.false; + }); + + it('does not wait when wait:false (returns the started execution)', async () => { + server.use( + http.post(`${SCAPI_BASE}/organizations/${ORG_ID}/jobs/${JOB_ID}/executions`, () => + HttpResponse.json({id: 'exec-3', jobId: JOB_ID, executionStatus: 'pending'}), + ), + ); + + const execution = await runSystemJob(makeInstance({scapi: true}), {...SPEC, wait: false}); + expect(execution.id).to.equal('exec-3'); + expect(execution.execution_status).to.equal('pending'); + }); + }); + + describe('auto fallback to OCAPI when SCAPI start fails', () => { + it('falls back to OCAPI when the SCAPI start is rejected', async () => { + let ocapiPosted = false; + server.use( + http.post(`${SCAPI_BASE}/organizations/${ORG_ID}/jobs/${JOB_ID}/executions`, () => + HttpResponse.json({title: 'Forbidden', detail: 'no scope'}, {status: 403}), + ), + http.post(`${OCAPI_BASE}/jobs/${JOB_ID}/executions`, () => { + ocapiPosted = true; + return HttpResponse.json({id: 'ocapi-1', execution_status: 'finished', exit_status: {code: 'OK'}}); + }), + http.get(`${OCAPI_BASE}/jobs/${JOB_ID}/executions/ocapi-1`, () => + HttpResponse.json({id: 'ocapi-1', execution_status: 'finished', exit_status: {code: 'OK'}}), + ), + ); + + const execution = await runSystemJob(makeInstance({scapi: true}), SPEC); + expect(ocapiPosted).to.be.true; + expect(execution.id).to.equal('ocapi-1'); + expect(execution.execution_status).to.equal('finished'); + }); + }); + + describe('OCAPI path', () => { + it('uses OCAPI when no SCAPI config is present', async () => { + let scapiPosted = false; + server.use( + http.post(`${SCAPI_BASE}/organizations/${ORG_ID}/jobs/${JOB_ID}/executions`, () => { + scapiPosted = true; + return HttpResponse.json({id: 'nope'}); + }), + http.post(`${OCAPI_BASE}/jobs/${JOB_ID}/executions`, () => + HttpResponse.json({id: 'ocapi-2', execution_status: 'finished', exit_status: {code: 'OK'}}), + ), + http.get(`${OCAPI_BASE}/jobs/${JOB_ID}/executions/ocapi-2`, () => + HttpResponse.json({id: 'ocapi-2', execution_status: 'finished', exit_status: {code: 'OK'}}), + ), + ); + + const execution = await runSystemJob(makeInstance({scapi: false}), SPEC); + expect(scapiPosted).to.be.false; + expect(execution.id).to.equal('ocapi-2'); + }); + + it('retries with the parameters body on UnknownPropertyException', async () => { + let directBody: any; + let retryBody: any; + let calls = 0; + server.use( + http.post(`${OCAPI_BASE}/jobs/${JOB_ID}/executions`, async ({request}) => { + calls++; + const body = (await request.json()) as any; + if (calls === 1) { + directBody = body; + return HttpResponse.json( + {fault: {type: 'UnknownPropertyException', arguments: {document: 'job_execution_request'}}}, + {status: 400}, + ); + } + retryBody = body; + return HttpResponse.json({id: 'ocapi-3', execution_status: 'finished', exit_status: {code: 'OK'}}); + }), + http.get(`${OCAPI_BASE}/jobs/${JOB_ID}/executions/ocapi-3`, () => + HttpResponse.json({id: 'ocapi-3', execution_status: 'finished', exit_status: {code: 'OK'}}), + ), + ); + + const execution = await runSystemJob(makeInstance({scapi: false}), SPEC); + expect(directBody).to.deep.equal({file_name: 'a.zip'}); + expect(retryBody).to.deep.equal({parameters: [{name: 'ImportFile', value: 'a.zip'}]}); + expect(execution.id).to.equal('ocapi-3'); + }); + + it('throws OcapiDeprecatedError when OCAPI is deprecated', async () => { + server.use( + http.post(`${OCAPI_BASE}/jobs/${JOB_ID}/executions`, () => + HttpResponse.json({fault: {type: 'OcapiDeprecatedException', message: 'deprecated'}}, {status: 403}), + ), + ); + + try { + await runSystemJob(makeInstance({scapi: false}), {...SPEC, deprecatedScopes: ['sfcc.jobs.rw']}); + expect.fail('expected OcapiDeprecatedError'); + } catch (error) { + expect(error).to.be.instanceOf(OcapiDeprecatedError); + expect((error as Error).message).to.include('"sfcc.jobs.rw"'); + } + }); + }); + + describe('explicit preference', () => { + it('throws when apiBackend=scapi but the instance cannot reach SCAPI', async () => { + try { + await runSystemJob(makeInstance({apiBackend: 'scapi', scapi: false}), SPEC); + expect.fail('expected an error'); + } catch (error) { + expect((error as Error).message).to.include('SCAPI backend requires'); + } + }); + + it('forces OCAPI even when SCAPI config is present (apiBackend=ocapi)', async () => { + let scapiPosted = false; + server.use( + http.post(`${SCAPI_BASE}/organizations/${ORG_ID}/jobs/${JOB_ID}/executions`, () => { + scapiPosted = true; + return HttpResponse.json({id: 'nope'}); + }), + http.post(`${OCAPI_BASE}/jobs/${JOB_ID}/executions`, () => + HttpResponse.json({id: 'ocapi-4', execution_status: 'finished', exit_status: {code: 'OK'}}), + ), + http.get(`${OCAPI_BASE}/jobs/${JOB_ID}/executions/ocapi-4`, () => + HttpResponse.json({id: 'ocapi-4', execution_status: 'finished', exit_status: {code: 'OK'}}), + ), + ); + + const execution = await runSystemJob(makeInstance({apiBackend: 'ocapi', scapi: true}), SPEC); + expect(scapiPosted).to.be.false; + expect(execution.id).to.equal('ocapi-4'); + }); + }); +}); From 1dec638d1d30861166830a70265fd48922080de4 Mon Sep 17 00:00:00 2001 From: Charles Lavery Date: Wed, 15 Jul 2026 00:01:21 -0400 Subject: [PATCH 18/22] fix(scapi): address SCAPI migration review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five confirmed findings from an adversarial review of the migration work, verified against the code before fixing. F1 (data integrity): runSystemJob no longer re-runs a mutating system job over OCAPI after an ambiguous SCAPI failure. executeJob now throws a typed ScapiJobStartError carrying the HTTP status; auto-mode fallback is gated on isSafeStartFallback — invalid_scope, ScapiCapabilityUnsupportedError, or a client-side rejection status (400/401/403/404/405/406/415). Network/timeout and 5xx/429 propagate without a re-run (job may have started). F3 (auth): OAuthStrategy/JwtOAuthStrategy invalidateToken() now clears ALL cached tokens for the client/method/AM-host identity, not just the base-scope key. A cascade 401 retry previously reused the rejected token cached under the merged-scope key; it now re-requests from AM. F2 (correctness): ScapiSitesBackend.listSites paginates server-side (limit 50, offset) honoring start/count, instead of reading only the first 25-item page; per-site enrichment now uses bounded concurrency (5) and skips already-rich items rather than an unbounded Promise.all. F4 (clarity): explicit --api-backend scapi with implicit/stateful auth now fails with a message naming the real requirement (a stateless client-credentials or JWT flow), via shared scapiUnavailableMessage; scapiClientConfig JSDoc and the changeset no longer imply explicit opt-in works for those flows. F5 (docs/config): SFCC_API_BACKEND now maps in EnvSource (with value validation) so the VS Code extension honors it; corrected stale OCAPI-only claims in jobs.md, code.md (--reload is backend-agnostic), auth.md (Sites reads use SCAPI), the b2c-job/b2c-code skills, and the authentication guide. Removes now-dead invalidateCachedOAuthToken. Adds tests for all fixes. --- .changeset/scapi-migration.md | 2 +- docs/cli/auth.md | 3 +- docs/cli/code.md | 2 +- docs/cli/jobs.md | 2 +- docs/guide/authentication.md | 4 +- .../b2c-cli/src/commands/code/activate.ts | 2 +- .../b2c-tooling-sdk/src/auth/oauth-jwt.ts | 14 +- packages/b2c-tooling-sdk/src/auth/oauth.ts | 32 ++++- .../src/clients/scapi-backend-utils.ts | 24 +++- .../src/config/sources/env-source.ts | 16 +++ .../b2c-tooling-sdk/src/instance/index.ts | 10 +- .../src/operations/jobs/index.ts | 1 + .../src/operations/jobs/run-system-job.ts | 72 ++++++++-- .../src/operations/jobs/scapi-ops.ts | 25 +++- .../operations/sites/scapi-sites-backend.ts | 91 ++++++++++--- .../b2c-tooling-sdk/test/auth/oauth.test.ts | 42 ++++++ .../test/config/env-source.test.ts | 24 ++++ .../operations/jobs/run-system-job.test.ts | 41 ++++++ .../operations/sites/sites-backend.test.ts | 123 ++++++++++++++++++ skills/b2c-cli/skills/b2c-code/SKILL.md | 2 +- skills/b2c-cli/skills/b2c-job/SKILL.md | 2 +- 21 files changed, 475 insertions(+), 59 deletions(-) diff --git a/.changeset/scapi-migration.md b/.changeset/scapi-migration.md index 6daca91b9..be481cf02 100644 --- a/.changeset/scapi-migration.md +++ b/.changeset/scapi-migration.md @@ -5,6 +5,6 @@ '@salesforce/b2c-dx-docs': minor --- -Migrate `job`, `code`, `bm users`, `bm roles`, and `sites` commands to support SCAPI alongside OCAPI. In auto mode (the default), the CLI prefers SCAPI when `shortCode` and `tenantId` are configured and silently falls back to OCAPI if the SCAPI scopes aren't granted. Use `--api-backend ocapi|scapi|auto` or `apiBackend` in dw.json to control explicitly. SCAPI scopes: `sfcc.jobs(.rw)`, `sfcc.scripts(.rw)`, `sfcc.users(.rw)`, `sfcc.roles(.rw)`, `sfcc.sites(.rw)` — read-only scopes are honored for list/get operations, falling back to read-only when the `*.rw` scope is not granted. `sites list` and `sites cartridges list` (reads) use the SCAPI `site/sites` API; cartridge-path writes have no SCAPI equivalent and remain on OCAPI / site-archive import. Site archive import/export (`site-import`, `site-export`, content/cartridge operations) and Commerce App Package install/uninstall (`cap install`, `cap uninstall`) now trigger their system jobs over SCAPI when configured, transparently falling back to OCAPI if the SCAPI start is rejected (never re-running a job that already started). New `job execution delete` command (SCAPI only). `code deploy` and all VS Code extension code-version actions (list/activate/delete/reload/create plus active-version discovery) also honor `apiBackend`. `bm users update --disabled` transparently falls back to OCAPI in auto mode (SCAPI Users PATCH does not support the `disabled` flag). Auto mode only selects SCAPI when authentication can request the required scopes — stateless OAuth (client-credentials, JWT bearer); stateful and implicit flows fall back to OCAPI unless `--api-backend scapi` is set explicitly. +Migrate `job`, `code`, `bm users`, `bm roles`, and `sites` commands to support SCAPI alongside OCAPI. In auto mode (the default), the CLI prefers SCAPI when `shortCode` and `tenantId` are configured and silently falls back to OCAPI if the SCAPI scopes aren't granted. Use `--api-backend ocapi|scapi|auto` or `apiBackend` in dw.json to control explicitly. SCAPI scopes: `sfcc.jobs(.rw)`, `sfcc.scripts(.rw)`, `sfcc.users(.rw)`, `sfcc.roles(.rw)`, `sfcc.sites(.rw)` — read-only scopes are honored for list/get operations, falling back to read-only when the `*.rw` scope is not granted. `sites list` and `sites cartridges list` (reads) use the SCAPI `site/sites` API; cartridge-path writes have no SCAPI equivalent and remain on OCAPI / site-archive import. Site archive import/export (`site-import`, `site-export`, content/cartridge operations) and Commerce App Package install/uninstall (`cap install`, `cap uninstall`) now trigger their system jobs over SCAPI when configured, transparently falling back to OCAPI if the SCAPI start is rejected (never re-running a job that already started). New `job execution delete` command (SCAPI only). `code deploy` and all VS Code extension code-version actions (list/activate/delete/reload/create plus active-version discovery) also honor `apiBackend`. `bm users update --disabled` transparently falls back to OCAPI in auto mode (SCAPI Users PATCH does not support the `disabled` flag). SCAPI is only selected when authentication can request the required scopes — stateless OAuth (client-credentials or JWT bearer). Stateful (stored-session) and implicit flows hold a fixed-scope token and cannot request SCAPI scopes, so they use OCAPI; this applies to `--api-backend scapi` too, which fails with a clear error for those flows rather than silently using an under-scoped token. For SDK consumers, `B2CInstance` now carries the SCAPI coordinates itself: a `B2CInstance.scapiClientConfig` getter returns `{shortCode, tenantId, auth}` (or `undefined` when the instance can't reach SCAPI), and `B2CInstance.apiBackend` exposes the configured preference. The dual-backend factories (`createSitesBackend`, `createScriptsBackend`, `createUsersBackend`, `createRolesBackend`) now take just `{instance}` and source SCAPI config from it — so SCAPI operations need nothing beyond a configured instance. diff --git a/docs/cli/auth.md b/docs/cli/auth.md index 4f5dfb7dd..65e31a1f6 100644 --- a/docs/cli/auth.md +++ b/docs/cli/auth.md @@ -273,7 +273,8 @@ For complete authentication setup instructions, see the [Authentication Setup Gu |-----------|--------------| | [Code](/cli/code) deploy/watch | WebDAV credentials | | [Code](/cli/code) list/activate/delete, [Jobs](/cli/jobs), [BM](/cli/bm) users/roles | OAuth + SCAPI scopes (OCAPI fallback; OCAPI is [deprecated](/guide/authentication#ocapi-configuration)) | -| [Sites](/cli/sites) | OAuth + OCAPI configuration | +| [Sites](/cli/sites) list/cartridge reads | OAuth + SCAPI scopes (`sfcc.sites`; OCAPI fallback) | +| [Sites](/cli/sites) cartridge-path writes | OCAPI / site-archive import (no SCAPI equivalent) | | SCAPI commands ([eCDN](/cli/ecdn), [schemas](/cli/scapi-schemas), [custom-apis](/cli/custom-apis)) | OAuth + SCAPI scopes | | [Sandbox](/cli/sandbox), [SLAS](/cli/slas) | OAuth + appropriate roles | | [MRT](/cli/mrt) | API Key | diff --git a/docs/cli/code.md b/docs/cli/code.md index a2e628540..1c1109a3a 100644 --- a/docs/cli/code.md +++ b/docs/cli/code.md @@ -25,7 +25,7 @@ b2c code list --api-backend ocapi # force the legacy OCAPI backend Or set `"api-backend": "scapi"` in `dw.json`, or `SFCC_API_BACKEND=scapi`. -The `code activate --reload` flag uses an OCAPI call regardless of `--api-backend`, since SCAPI does not expose the cache-rebuild operation. On OCAPI-disabled instances, `--reload` is unavailable. +The `--reload` flag forces a code cache reload by toggling activation (activate an alternate version, then re-activate the target). It uses the same backend as the rest of the command — SCAPI or OCAPI per `--api-backend` — so it works on OCAPI-disabled instances when SCAPI is configured. ::: ::: tip diff --git a/docs/cli/jobs.md b/docs/cli/jobs.md index 85f2c5078..674082615 100644 --- a/docs/cli/jobs.md +++ b/docs/cli/jobs.md @@ -27,7 +27,7 @@ Or set `"api-backend": "scapi"` in `dw.json`, or `SFCC_API_BACKEND=scapi`. ::: ::: tip -The `job import` and `job export` commands trigger the `sfcc-site-archive-import`/`-export` system jobs and transfer files over WebDAV. The job-execution trigger currently uses OCAPI; on OCAPI-disabled instances these subcommands are not yet available over SCAPI. +The `job import` and `job export` commands trigger the `sfcc-site-archive-import`/`-export` system jobs and transfer archive files over WebDAV. The job-execution trigger honors `--api-backend`: in `auto` mode it starts the system job over SCAPI (requires the `sfcc.jobs.rw` scope) and falls back to OCAPI only if the SCAPI start is rejected. WebDAV is always used for the archive transfer itself regardless of backend. ::: ## Authentication diff --git a/docs/guide/authentication.md b/docs/guide/authentication.md index 17d0153e2..dcc9a56f7 100644 --- a/docs/guide/authentication.md +++ b/docs/guide/authentication.md @@ -311,7 +311,7 @@ b2c code list --auth-methods jwt ## OCAPI Configuration ::: warning OCAPI is deprecated -OCAPI (the Open Commerce API / Data API) is **deprecated** and is being disabled across instances. Newer instances reject OCAPI calls entirely (`OcapiDeprecatedException`). Prefer [SCAPI](#scapi-authentication) for code, jobs, and BM users/roles — the CLI uses SCAPI first and only falls back to OCAPI when SCAPI scopes are not configured. Configure OCAPI only for instances that still support it or for the few OCAPI-only operations (e.g. [Sites](/cli/sites)). +OCAPI (the Open Commerce API / Data API) is **deprecated** and is being disabled across instances. Newer instances reject OCAPI calls entirely (`OcapiDeprecatedException`). Prefer [SCAPI](#scapi-authentication) for code, jobs, BM users/roles, and site reads — the CLI uses SCAPI first and only falls back to OCAPI when SCAPI scopes are not configured. Configure OCAPI only for instances that still support it or for the few operations with no SCAPI equivalent (e.g. cartridge-path writes on [Sites](/cli/sites), and `bm users search` / `whoami` / `access-key`). If a command fails with "OCAPI is deprecated and disabled for this instance," configure [SCAPI scopes](#scapi-authentication) on your API client instead. ::: @@ -619,7 +619,7 @@ Here's a complete example for setting up CLI access: ### 2. (Optional) Configure OCAPI fallback -With the SCAPI scopes above configured, `code`, `jobs`, and `bm users/roles` run entirely over SCAPI — no OCAPI setup is needed. Configure OCAPI only for the OCAPI-only [`sites`](/cli/sites) command, or to provide a fallback on instances where SCAPI scopes are not yet provisioned. Note that OCAPI is [deprecated](#ocapi-configuration) and disabled on newer instances. To set it up, add the JSON configuration shown in [OCAPI Configuration](#ocapi-configuration). +With the SCAPI scopes above configured, `code`, `jobs`, `bm users/roles`, and `sites` reads run over SCAPI — no OCAPI setup is needed (add the `sfcc.sites` / `sfcc.sites.rw` scope for `sites`). Configure OCAPI only for operations with no SCAPI equivalent — cartridge-path writes on [`sites`](/cli/sites) and `bm users search` / `whoami` / `access-key` — or to provide a fallback on instances where SCAPI scopes are not yet provisioned. Note that OCAPI is [deprecated](#ocapi-configuration) and disabled on newer instances. To set it up, add the JSON configuration shown in [OCAPI Configuration](#ocapi-configuration). ### 3. Configure WebDAV Access (for code deploy/watch, webdav commands) diff --git a/packages/b2c-cli/src/commands/code/activate.ts b/packages/b2c-cli/src/commands/code/activate.ts index 11b820f74..9268d99d9 100644 --- a/packages/b2c-cli/src/commands/code/activate.ts +++ b/packages/b2c-cli/src/commands/code/activate.ts @@ -32,7 +32,7 @@ export default class CodeActivate extends CodeCommand { ...CodeCommand.baseFlags, reload: Flags.boolean({ char: 'r', - description: 'Reload the code version (OCAPI only — forces a code cache reload via toggle)', + description: 'Reload the code version (forces a code cache reload by toggling activation)', default: false, }), }; diff --git a/packages/b2c-tooling-sdk/src/auth/oauth-jwt.ts b/packages/b2c-tooling-sdk/src/auth/oauth-jwt.ts index c7b48d10a..d6ae8dd14 100644 --- a/packages/b2c-tooling-sdk/src/auth/oauth-jwt.ts +++ b/packages/b2c-tooling-sdk/src/auth/oauth-jwt.ts @@ -20,7 +20,7 @@ import { getOAuthCacheKey, getCachedOAuthToken, setCachedOAuthToken, - invalidateCachedOAuthToken, + invalidateCachedTokensForIdentity, findCachedTokenSatisfying, decodeJWT, } from './oauth.js'; @@ -83,6 +83,7 @@ export class JwtOAuthStrategy implements AuthStrategy { private readonly config: JwtOAuthConfig; private readonly logger = getLogger(); private readonly cacheKey: string; + private readonly identityPrefix: string; private _hasHadSuccess = false; private readonly privateKey: crypto.KeyObject; @@ -101,6 +102,7 @@ export class JwtOAuthStrategy implements AuthStrategy { this.validateConfig(config); this.config = config; this.cacheKey = getOAuthCacheKey(this.config.clientId, 'jwt', this.config.accountManagerHost, this.config.scopes); + this.identityPrefix = `${this.config.accountManagerHost}:${this.config.clientId}:jwt:`; // Cache private key to avoid file I/O on every token request const keyContent = fs.readFileSync(config.keyPath, 'utf8'); @@ -271,7 +273,7 @@ export class JwtOAuthStrategy implements AuthStrategy { */ async getAccessTokenForCascade(candidates: string[][]): Promise { const baseScopes = this.config.scopes ?? []; - const identityPrefix = `${this.config.accountManagerHost}:${this.config.clientId}:jwt:`; + const identityPrefix = this.identityPrefix; for (const candidate of candidates) { const required = [...new Set([...baseScopes, ...candidate])]; @@ -325,10 +327,14 @@ export class JwtOAuthStrategy implements AuthStrategy { } /** - * Invalidates the cached access token, forcing re-authentication on next request. + * Invalidates cached tokens, forcing re-authentication on next request. + * + * Clears every token for this client/AM-host JWT identity — not just the + * base-scope key — so a 401 retry can't re-use a rejected token cached under + * a merged cascade-scope key. */ invalidateToken(): void { - invalidateCachedOAuthToken(this.cacheKey); + invalidateCachedTokensForIdentity(this.identityPrefix); this.logger.trace('[JwtOAuthStrategy] Token invalidated'); } diff --git a/packages/b2c-tooling-sdk/src/auth/oauth.ts b/packages/b2c-tooling-sdk/src/auth/oauth.ts index 9c5d73897..5d77b340c 100644 --- a/packages/b2c-tooling-sdk/src/auth/oauth.ts +++ b/packages/b2c-tooling-sdk/src/auth/oauth.ts @@ -123,12 +123,24 @@ export function findCachedTokenSatisfying( } /** - * Invalidates a cached OAuth token. + * Invalidates **every** cached token for an identity prefix + * (`${accountManagerHost}:${clientId}:${method}:`). * - * @param cacheKey - Cache key from getOAuthCacheKey() + * A cascade-resolving strategy caches tokens under *merged-scope* keys (e.g. + * base ∪ `sfcc.jobs.rw`), which differ from the strategy's configured base- + * scope {@link getOAuthCacheKey}. Deleting only the base key on a 401 would + * leave the rejected merged token cached, so the retry's cascade cache-scan + * ({@link findCachedTokenSatisfying}) would re-hand out the same rejected + * token. Clearing by identity prefix evicts all of them so the retry re- + * requests from Account Manager. The prefix scopes deletion to this + * client/method/AM host, so unrelated identities are untouched. */ -export function invalidateCachedOAuthToken(cacheKey: string): void { - ACCESS_TOKEN_CACHE.delete(cacheKey); +export function invalidateCachedTokensForIdentity(identityPrefix: string): void { + for (const key of ACCESS_TOKEN_CACHE.keys()) { + if (key.startsWith(identityPrefix)) { + ACCESS_TOKEN_CACHE.delete(key); + } + } } /** @@ -156,6 +168,7 @@ export class OAuthStrategy implements AuthStrategy { private accountManagerHost: string; private _hasHadSuccess = false; private cacheKey: string; + private identityPrefix: string; /** * Creates a new OAuthStrategy instance with the provided OAuth configuration. @@ -170,6 +183,7 @@ export class OAuthStrategy implements AuthStrategy { this.accountManagerHost, this.config.scopes, ); + this.identityPrefix = `${this.accountManagerHost}:${this.config.clientId}:client-credentials:`; } /** @@ -241,10 +255,14 @@ export class OAuthStrategy implements AuthStrategy { } /** - * Invalidates the cached token, forcing re-authentication on next request + * Invalidates cached tokens, forcing re-authentication on next request. + * + * Clears every token for this client/method/AM-host identity — not just the + * base-scope key — so a 401 retry can't re-use a rejected token that was + * cached under a merged cascade-scope key. */ invalidateToken(): void { - invalidateCachedOAuthToken(this.cacheKey); + invalidateCachedTokensForIdentity(this.identityPrefix); } /** @@ -278,7 +296,7 @@ export class OAuthStrategy implements AuthStrategy { async getAccessTokenForCascade(candidates: string[][]): Promise { const logger = getLogger(); const baseScopes = this.config.scopes ?? []; - const identityPrefix = `${this.accountManagerHost}:${this.config.clientId}:client-credentials:`; + const identityPrefix = this.identityPrefix; // Pass 1: cache scan. Return the first cached token that satisfies any // candidate. diff --git a/packages/b2c-tooling-sdk/src/clients/scapi-backend-utils.ts b/packages/b2c-tooling-sdk/src/clients/scapi-backend-utils.ts index 154bc0ce6..a7be0c005 100644 --- a/packages/b2c-tooling-sdk/src/clients/scapi-backend-utils.ts +++ b/packages/b2c-tooling-sdk/src/clients/scapi-backend-utils.ts @@ -97,6 +97,25 @@ export interface ResolveBackendOptions { domainName: string; } +/** + * Message for when explicit SCAPI is requested but the instance can't reach it. + * + * Names both reasons the SCAPI client config can be unavailable — missing + * coordinates OR an auth flow that can't request scopes — because a user who + * hits this in explicit `--api-backend scapi` mode often *does* have shortCode + * and tenantId configured; the real blocker is that implicit/stateful OAuth + * holds a fixed-scope token and can't request the `sfcc.*` scopes SCAPI needs. + * The old message only mentioned missing credentials, which was misleading. + */ +export function scapiUnavailableMessage(domainName: string): string { + return ( + `${domainName} SCAPI backend requires shortCode, tenantId, and a stateless OAuth flow ` + + `(client-credentials or JWT Bearer) that can request the required scopes. ` + + `Implicit and stateful (stored-session) auth cannot request SCAPI scopes — ` + + `use client-credentials/JWT, or set --api-backend ocapi.` + ); +} + /** * Resolves a user preference + config availability into a concrete backend choice. * @@ -114,10 +133,7 @@ export function resolveScapiOrOcapi(opts: ResolveBackendOptions): 'ocapi' | 'sca if (preference === 'scapi') { if (!hasScapiConfig) { - throw new Error( - `${domainName} SCAPI backend requires shortCode, tenantId, and OAuth credentials. ` + - `Configure them in dw.json or use --api-backend ocapi.`, - ); + throw new Error(scapiUnavailableMessage(domainName)); } return 'scapi'; } diff --git a/packages/b2c-tooling-sdk/src/config/sources/env-source.ts b/packages/b2c-tooling-sdk/src/config/sources/env-source.ts index a9c1ae0df..bd005ceb5 100644 --- a/packages/b2c-tooling-sdk/src/config/sources/env-source.ts +++ b/packages/b2c-tooling-sdk/src/config/sources/env-source.ts @@ -44,6 +44,7 @@ const ENV_VAR_MAP: Record = { SFCC_AUTH_METHODS: 'authMethods', SFCC_ACCOUNT_MANAGER_HOST: 'accountManagerHost', SFCC_SANDBOX_API_HOST: 'sandboxApiHost', + SFCC_API_BACKEND: 'apiBackend', // JWT Bearer auth env vars SFCC_JWT_CERT: 'jwtCertPath', SFCC_JWT_KEY: 'jwtKeyPath', @@ -72,6 +73,15 @@ const ARRAY_FIELDS = new Set([ /** Fields that should be parsed as booleans. */ const BOOLEAN_FIELDS = new Set(['selfSigned']); +/** + * Enum-valued fields and their allowed values. Values outside the set are + * skipped with a warning, mirroring the CLI flag's `options` validation so the + * env var behaves the same for SDK consumers (e.g. the VS Code extension). + */ +const ENUM_FIELDS: Partial> = { + apiBackend: ['ocapi', 'scapi', 'auto'], +}; + /** * Configuration source that reads SFCC_* environment variables. * @@ -114,6 +124,12 @@ export class EnvSource implements ConfigSource { const value = this.env[envVar]; if (value === undefined || value === '') continue; + const allowed = ENUM_FIELDS[configField]; + if (allowed && !allowed.includes(value)) { + logger.warn(`[EnvSource] Ignoring ${envVar}: "${value}" is not one of ${allowed.join(', ')}`); + continue; + } + if (BOOLEAN_FIELDS.has(configField)) { (config as Record)[configField] = value === 'true' || value === '1'; } else if (ARRAY_FIELDS.has(configField)) { diff --git a/packages/b2c-tooling-sdk/src/instance/index.ts b/packages/b2c-tooling-sdk/src/instance/index.ts index 32c1f0386..861dcfd77 100644 --- a/packages/b2c-tooling-sdk/src/instance/index.ts +++ b/packages/b2c-tooling-sdk/src/instance/index.ts @@ -168,10 +168,12 @@ export class B2CInstance { * (clientId + cert/key). * * Stateful and implicit flows are excluded on purpose: they hold a fixed - * token whose scopes were chosen at acquisition, so under `auto` they would - * route to SCAPI with a token AM never granted the required scopes for, and - * that 403 is not a fallback trigger. Callers wanting SCAPI on those flows - * opt in explicitly via `--api-backend scapi` and build the client directly. + * token whose scopes were chosen at acquisition, so they cannot request the + * `sfcc.*` scopes SCAPI needs. This is not an `auto`-only restriction — + * because both consumers (the dual-backend factory and the system-job runner) + * gate on this getter, even explicit `--api-backend scapi` cannot use SCAPI + * with implicit/stateful auth; it fails with a clear error naming the flow + * requirement. SCAPI requires client-credentials or JWT Bearer. */ get scapiClientConfig(): ScapiClientConfig | undefined { const {shortCode, tenantId} = this.config; diff --git a/packages/b2c-tooling-sdk/src/operations/jobs/index.ts b/packages/b2c-tooling-sdk/src/operations/jobs/index.ts index 6a362f15b..2f4f6367e 100644 --- a/packages/b2c-tooling-sdk/src/operations/jobs/index.ts +++ b/packages/b2c-tooling-sdk/src/operations/jobs/index.ts @@ -46,6 +46,7 @@ export { searchJobExecutions as scapiSearchJobExecutions, deleteJobExecution as scapiDeleteJobExecution, getJobLog as scapiGetJobLog, + ScapiJobStartError, } from './scapi-ops.js'; export type {ExecuteJobScapiOptions, SearchJobExecutionsScapiOptions} from './scapi-ops.js'; export type {JobExecutionInfo, JobStepExecutionResult, JobExecutionSearchResults} from './types.js'; diff --git a/packages/b2c-tooling-sdk/src/operations/jobs/run-system-job.ts b/packages/b2c-tooling-sdk/src/operations/jobs/run-system-job.ts index 57a1de753..13a08014a 100644 --- a/packages/b2c-tooling-sdk/src/operations/jobs/run-system-job.ts +++ b/packages/b2c-tooling-sdk/src/operations/jobs/run-system-job.ts @@ -28,10 +28,13 @@ * - `'ocapi'`: always OCAPI. * - `'scapi'`: always SCAPI (throws if the instance can't reach SCAPI). * - `'auto'` (default): SCAPI when {@link B2CInstance.scapiClientConfig} is - * available, else OCAPI. **Fallback to OCAPI happens only if the SCAPI - * *start* fails** (missing scope, body rejected, system job not - * triggerable over SCAPI). Once a job has started, the wait/log path never - * falls back — re-running a started write would be unsafe. + * available, else OCAPI. **Fallback to OCAPI happens only when the SCAPI + * start provably created no job** — an Account Manager scope rejection + * (before the POST) or a client-side rejection status (the server refused + * the start). Ambiguous failures (network drop after dispatch, timeout, + * 5xx) and any post-start failure propagate without a re-run, because + * re-running a mutating system job over OCAPI could execute it twice. See + * {@link isSafeStartFallback}. * * ## Lifecycle * @@ -42,13 +45,50 @@ */ import type {B2CInstance, ScapiClientConfig} from '../../instance/index.js'; import {isOcapiDeprecatedFault, OcapiDeprecatedError} from '../../clients/error-utils.js'; +import { + isInvalidScopeError, + ScapiCapabilityUnsupportedError, + scapiUnavailableMessage, +} from '../../clients/scapi-backend-utils.js'; import {createScapiJobsClient} from '../../clients/scapi-jobs.js'; import {getLogger} from '../../logging/logger.js'; import {mapCanonicalToOcapiExecution} from './ocapi-mapping.js'; -import {executeJob as scapiExecuteJob, getJobExecution as scapiGetJobExecution} from './scapi-ops.js'; +import { + executeJob as scapiExecuteJob, + getJobExecution as scapiGetJobExecution, + ScapiJobStartError, +} from './scapi-ops.js'; import {waitForJob, JobExecutionError, type JobExecution, type WaitForJobOptions} from './run.js'; import {waitForJobExecution, CanonicalJobExecutionError} from './wait-canonical.js'; +/** + * HTTP statuses on a SCAPI job-start response that prove the server *refused* + * the request before creating a job execution — so re-running over OCAPI + * cannot duplicate a mutating job. Ambiguous statuses (5xx, 429) and + * network/timeout errors (no response at all) are deliberately excluded. + */ +const SAFE_START_REJECTION_STATUSES = new Set([400, 401, 403, 404, 405, 406, 415]); + +/** + * Decides whether a SCAPI start failure is provably safe to fall back to OCAPI + * for. Safe cases guarantee no job was created: + * - {@link isInvalidScopeError}: Account Manager rejected the scope during + * token acquisition — thrown before the job POST is ever sent. + * - {@link ScapiCapabilityUnsupportedError}: a purely local rejection. + * - a {@link ScapiJobStartError} whose HTTP status is a client-side rejection + * (the server refused before starting the job). + * + * Everything else — a network/timeout error (which may have reached the server + * with the job now running), a 5xx, or a 429 — is ambiguous and must NOT + * trigger an OCAPI re-run of a mutating job. + */ +function isSafeStartFallback(error: unknown): boolean { + if (isInvalidScopeError(error) || error instanceof ScapiCapabilityUnsupportedError) { + return true; + } + return error instanceof ScapiJobStartError && SAFE_START_REJECTION_STATUSES.has(error.status); +} + /** * Declarative description of a system job to run. The operation supplies its * job ID and the two request-body forms; this module drives the rest. @@ -95,10 +135,9 @@ export async function runSystemJob(instance: B2CInstance, spec: SystemJobSpec): if (preference === 'scapi') { if (!scapiConfig) { - throw new Error( - `${spec.jobId} SCAPI backend requires shortCode, tenantId, and OAuth credentials. ` + - `Configure them in dw.json or set apiBackend to ocapi.`, - ); + // Domain label (not the job ID) so the message reads "Jobs SCAPI + // backend requires…", consistent with resolveScapiOrOcapi. + throw new Error(scapiUnavailableMessage('Jobs')); } return runScapiSystemJob(instance, scapiConfig, spec); } @@ -108,9 +147,9 @@ export async function runSystemJob(instance: B2CInstance, spec: SystemJobSpec): return runOcapiSystemJob(instance, spec); } - // Try SCAPI start; fall back to OCAPI only if the start fails (nothing has - // run yet). Once started, finishScapiJob handles wait/failure without - // falling back. + // Try SCAPI start; fall back to OCAPI only for a provably-safe start failure + // (see isSafeStartFallback). Once started, finishScapiJob handles wait/failure + // without falling back. const client = createScapiJobsClient( {shortCode: scapiConfig.shortCode, tenantId: scapiConfig.tenantId}, scapiConfig.auth, @@ -119,9 +158,16 @@ export async function runSystemJob(instance: B2CInstance, spec: SystemJobSpec): try { started = await startScapiJob(client, scapiConfig.tenantId, spec); } catch (error) { + // Fall back to OCAPI ONLY when the SCAPI start provably created no job. + // An ambiguous failure (network drop after dispatch, timeout, 5xx) must + // propagate — re-running a mutating system job over OCAPI could execute it + // twice. + if (!isSafeStartFallback(error)) { + throw error; + } getLogger().info( {jobId: spec.jobId, reason: error instanceof Error ? error.message : String(error)}, - `SCAPI ${spec.jobId} unavailable, falling back to OCAPI`, + `SCAPI ${spec.jobId} start rejected, falling back to OCAPI`, ); return runOcapiSystemJob(instance, spec); } diff --git a/packages/b2c-tooling-sdk/src/operations/jobs/scapi-ops.ts b/packages/b2c-tooling-sdk/src/operations/jobs/scapi-ops.ts index 98e82fb28..85a9e67ce 100644 --- a/packages/b2c-tooling-sdk/src/operations/jobs/scapi-ops.ts +++ b/packages/b2c-tooling-sdk/src/operations/jobs/scapi-ops.ts @@ -32,6 +32,27 @@ import type {JobExecutionInfo, JobExecutionSearchResults, JobStepExecutionResult const READ_HEADERS = {[SCOPE_MODE_HEADER]: 'read'}; const WRITE_HEADERS = {[SCOPE_MODE_HEADER]: 'write'}; +/** + * Thrown by {@link executeJob} when the SCAPI job-start POST is rejected with + * a response (non-2xx). Carries the received HTTP status so callers can tell a + * *request rejection* (server refused before starting the job — safe to treat + * as "no job created") from an ambiguous failure. + * + * A network/timeout error during the POST does NOT produce this — it surfaces + * as a raw thrown error with no status, because the request may have reached + * the server and the job may already be running. + */ +export class ScapiJobStartError extends Error { + constructor( + message: string, + /** HTTP status of the rejection response. */ + public readonly status: number, + ) { + super(message); + this.name = 'ScapiJobStartError'; + } +} + function mapStepExecution(step: ScapiJobStepExecution): JobStepExecutionResult { return { id: step.id, @@ -121,7 +142,9 @@ export async function executeJob( if (error || !data) { const errorBody = error as unknown as {detail?: string; title?: string}; const message = errorBody?.detail ?? errorBody?.title ?? `Failed to execute job ${jobId}`; - throw new Error(message); + // A received (non-2xx) response means the server refused the start — carry + // the status so callers can classify request-rejection vs ambiguous. + throw new ScapiJobStartError(message, response.status); } return mapScapiExecution(data); diff --git a/packages/b2c-tooling-sdk/src/operations/sites/scapi-sites-backend.ts b/packages/b2c-tooling-sdk/src/operations/sites/scapi-sites-backend.ts index 3988edbf9..10ba9ecd2 100644 --- a/packages/b2c-tooling-sdk/src/operations/sites/scapi-sites-backend.ts +++ b/packages/b2c-tooling-sdk/src/operations/sites/scapi-sites-backend.ts @@ -16,6 +16,12 @@ import {SCOPE_MODE_HEADER} from '../../clients/middleware.js'; const READ_HEADERS = {[SCOPE_MODE_HEADER]: 'read'}; +/** SCAPI `getSites` caps `limit` at 50 (spec `site-sites-v1.yaml`). */ +const SCAPI_SITES_MAX_PAGE = 50; + +/** Concurrency for per-site detail enrichment; bounds rate-limit pressure. */ +const ENRICH_CONCURRENCY = 5; + function defaultLocaleValue(map?: {[key: string]: string}): string | undefined { if (!map) return undefined; return map.default ?? Object.values(map)[0]; @@ -57,24 +63,75 @@ export class ScapiSitesBackend implements SitesBackend { } async listSites(options: ListSitesOptions = {}): Promise { - // `getSites` and `site-search` return only site IDs — display name, - // storefront status, and cartridges live on the per-site detail endpoint. - // Fetch IDs first, then enrich each concurrently via `getSite` so the - // list matches the rich shape the OCAPI `/sites?select=(**)` path returned. - const {count, start} = options; - const {data, error} = await this.client.GET('/organizations/{organizationId}/sites', { - params: {path: {organizationId: this.organizationId}}, - headers: READ_HEADERS, - }); - if (error || !data) { - throw new Error(toErrorMessage(error, 'Failed to list sites')); + // Page through `getSites` on the server (limit capped at 50) rather than + // relying on the default single 25-item page — otherwise instances with + // more than 25 sites silently lose the rest. The caller's start/count map + // to SCAPI offset/limit. + const rawSites = await this.fetchSitePage(options); + + // `getSites` returns items that carry only the id (display name, storefront + // status, and cartridges live on the per-site detail endpoint). Enrich any + // sparse item via `getSite`, with bounded concurrency to limit rate-limit + // pressure. Items that already arrive rich (future-proofing if the platform + // starts populating list fields) are mapped directly with no extra call. + return this.enrichSites(rawSites); + } + + /** + * Fetches the requested window of sites, paginating across 50-item SCAPI + * pages. `start` is the offset into the full result set; `count` bounds how + * many are returned (unbounded when omitted). + */ + private async fetchSitePage(options: ListSitesOptions): Promise { + const startOffset = options.start ?? 0; + const target = options.count; // undefined → all remaining + const collected: ScapiSite[] = []; + let offset = startOffset; + + while (true) { + const remaining = target === undefined ? SCAPI_SITES_MAX_PAGE : target - collected.length; + if (remaining <= 0) break; + const limit = Math.min(SCAPI_SITES_MAX_PAGE, remaining); + + const {data, error} = await this.client.GET('/organizations/{organizationId}/sites', { + params: {path: {organizationId: this.organizationId}, query: {limit, offset}}, + headers: READ_HEADERS, + }); + if (error || !data) { + throw new Error(toErrorMessage(error, 'Failed to list sites')); + } + + const page = data as unknown as {data?: ScapiSite[]; total?: number}; + const items = page.data ?? []; + collected.push(...items); + offset += items.length; + + // Stop when the server has no more items, or we've reached the reported + // total, or the page came back short (defensive against a missing total). + const total = page.total ?? startOffset + collected.length; + if (items.length === 0 || offset >= total) break; + } + + return collected; + } + + /** Maps sites, fetching per-site detail (bounded concurrency) for sparse items. */ + private async enrichSites(sites: ScapiSite[]): Promise { + const results: SiteInfo[] = []; + for (let i = 0; i < sites.length; i += ENRICH_CONCURRENCY) { + const batch = sites.slice(i, i + ENRICH_CONCURRENCY); + const mapped = await Promise.all( + batch.map((site) => { + // If the list item is already rich, avoid the extra detail call. + if (site.displayName !== undefined || site.storefrontStatus !== undefined) { + return Promise.resolve(mapScapiSite(site)); + } + return site.id ? this.getSite(site.id) : Promise.resolve(mapScapiSite(site)); + }), + ); + results.push(...mapped); } - let ids = ((data as unknown as {data?: ScapiSite[]}).data ?? []) - .map((s) => s.id) - .filter((id): id is string => !!id); - if (start !== undefined) ids = ids.slice(start); - if (count !== undefined) ids = ids.slice(0, count); - return Promise.all(ids.map((id) => this.getSite(id))); + return results; } async getSite(siteId: string): Promise { diff --git a/packages/b2c-tooling-sdk/test/auth/oauth.test.ts b/packages/b2c-tooling-sdk/test/auth/oauth.test.ts index a0b90b82e..a0adc6883 100644 --- a/packages/b2c-tooling-sdk/test/auth/oauth.test.ts +++ b/packages/b2c-tooling-sdk/test/auth/oauth.test.ts @@ -602,6 +602,48 @@ describe('auth/oauth', () => { // Should not have tried the second candidate. expect(amCallCount).to.equal(1); }); + + // Regression: invalidateToken() must evict cascade tokens (cached under a + // MERGED-scope key), not just the strategy's base-scope key. Otherwise a + // 401 retry re-uses the rejected token from the cascade cache scan. + it('invalidateToken() evicts a merged cascade token so the next request re-fetches', async () => { + const tokenA = createMockJWT({sub: 'cascade-invalidate', v: 'A'}); + const tokenB = createMockJWT({sub: 'cascade-invalidate', v: 'B'}); + let amCallCount = 0; + + server.use( + http.post(AM_URL, async () => { + amCallCount++; + return HttpResponse.json({ + access_token: amCallCount === 1 ? tokenA : tokenB, + expires_in: 1800, + scope: 'sfcc.jobs.rw', + }); + }), + ); + + // Base scopes are the tenant scope only; the cascade merges in the rw + // scope, so the resulting token is cached under a DIFFERENT key than + // the strategy's base cacheKey. + const strategy = new OAuthStrategy({ + clientId: 'cascade-invalidate', + clientSecret: 'test-secret', + scopes: ['SALESFORCE_COMMERCE_API:zzxy_prd'], + }); + + const first = await strategy.getAccessTokenForCascade([['sfcc.jobs.rw']]); + expect(first).to.equal(tokenA); + expect(amCallCount).to.equal(1); + + // Simulate the middleware's 401 handling. + strategy.invalidateToken(); + + // The retry must NOT reuse tokenA from the cache scan — it must re-hit AM. + const second = await strategy.getAccessTokenForCascade([['sfcc.jobs.rw']]); + expect(amCallCount).to.equal(2); + expect(second).to.equal(tokenB); + expect(second).to.not.equal(first); + }); }); }); }); diff --git a/packages/b2c-tooling-sdk/test/config/env-source.test.ts b/packages/b2c-tooling-sdk/test/config/env-source.test.ts index 2f0d4aed9..4dacba780 100644 --- a/packages/b2c-tooling-sdk/test/config/env-source.test.ts +++ b/packages/b2c-tooling-sdk/test/config/env-source.test.ts @@ -91,6 +91,30 @@ describe('config/EnvSource', () => { }); }); + describe('apiBackend (SFCC_API_BACKEND)', () => { + for (const value of ['auto', 'scapi', 'ocapi']) { + it(`maps SFCC_API_BACKEND=${value} to apiBackend`, () => { + const source = new EnvSource({SFCC_API_BACKEND: value}); + const result = source.load({}); + expect(result!.config.apiBackend).to.equal(value); + }); + } + + it('ignores an invalid SFCC_API_BACKEND value', () => { + const source = new EnvSource({SFCC_API_BACKEND: 'bogus'}); + const result = source.load({}); + // No valid fields → source contributes nothing. + expect(result).to.be.undefined; + }); + + it('ignores an invalid value but keeps other valid env fields', () => { + const source = new EnvSource({SFCC_API_BACKEND: 'bogus', SFCC_SERVER: 'test.demandware.net'}); + const result = source.load({}); + expect(result!.config.apiBackend).to.be.undefined; + expect(result!.config.hostname).to.equal('test.demandware.net'); + }); + }); + describe('boolean parsing', () => { it('parses SFCC_SELFSIGNED=true as boolean true', () => { const source = new EnvSource({SFCC_SELFSIGNED: 'true'}); diff --git a/packages/b2c-tooling-sdk/test/operations/jobs/run-system-job.test.ts b/packages/b2c-tooling-sdk/test/operations/jobs/run-system-job.test.ts index e4db16652..58feb36e3 100644 --- a/packages/b2c-tooling-sdk/test/operations/jobs/run-system-job.test.ts +++ b/packages/b2c-tooling-sdk/test/operations/jobs/run-system-job.test.ts @@ -160,6 +160,47 @@ describe('operations/jobs/run-system-job', () => { expect(execution.id).to.equal('ocapi-1'); expect(execution.execution_status).to.equal('finished'); }); + + it('does NOT fall back on a 5xx SCAPI start (job outcome ambiguous)', async () => { + let ocapiPosted = false; + server.use( + http.post(`${SCAPI_BASE}/organizations/${ORG_ID}/jobs/${JOB_ID}/executions`, () => + HttpResponse.json({title: 'Internal Server Error'}, {status: 500}), + ), + http.post(`${OCAPI_BASE}/jobs/${JOB_ID}/executions`, () => { + ocapiPosted = true; + return HttpResponse.json({id: 'must-not-run'}); + }), + ); + + try { + await runSystemJob(makeInstance({scapi: true}), SPEC); + expect.fail('expected the 5xx to propagate'); + } catch (error) { + expect((error as Error).message).to.be.a('string'); + } + // A 5xx is ambiguous — the job may have started; must NOT re-run on OCAPI. + expect(ocapiPosted).to.be.false; + }); + + it('does NOT fall back on a SCAPI network failure (request may have reached the server)', async () => { + let ocapiPosted = false; + server.use( + http.post(`${SCAPI_BASE}/organizations/${ORG_ID}/jobs/${JOB_ID}/executions`, () => HttpResponse.error()), + http.post(`${OCAPI_BASE}/jobs/${JOB_ID}/executions`, () => { + ocapiPosted = true; + return HttpResponse.json({id: 'must-not-run'}); + }), + ); + + try { + await runSystemJob(makeInstance({scapi: true}), SPEC); + expect.fail('expected the network error to propagate'); + } catch { + // expected + } + expect(ocapiPosted).to.be.false; + }); }); describe('OCAPI path', () => { diff --git a/packages/b2c-tooling-sdk/test/operations/sites/sites-backend.test.ts b/packages/b2c-tooling-sdk/test/operations/sites/sites-backend.test.ts index 04aa0043a..d7172c2d2 100644 --- a/packages/b2c-tooling-sdk/test/operations/sites/sites-backend.test.ts +++ b/packages/b2c-tooling-sdk/test/operations/sites/sites-backend.test.ts @@ -158,4 +158,127 @@ describe('operations/sites backend', () => { }); }); }); + + describe('ScapiSitesBackend pagination + enrichment', () => { + /** + * Stubs the SCAPI client with an in-memory paginated `getSites` over + * `total` id-only sites (`site-0`..), plus a per-site detail endpoint. + * Tracks the (limit, offset) of each list page and every detail id fetched. + */ + function stubPaginatedClient(backend: ScapiSitesBackend, total: number) { + const listPages: Array<{limit?: number; offset?: number}> = []; + const detailFetches: string[] = []; + (backend as unknown as {client: unknown}).client = { + async GET(path: string, opts: {params?: {path?: Record; query?: Record}}) { + if (path.endsWith('/sites/{siteId}')) { + const id = opts.params!.path!.siteId; + detailFetches.push(id); + return { + data: {id, displayName: {default: `Name ${id}`}, storefrontStatus: 'online', cartridges: 'a:b'}, + error: undefined, + response: {status: 200}, + }; + } + // list page + const {limit = 50, offset = 0} = opts.params?.query ?? {}; + listPages.push({limit, offset}); + const slice = Array.from({length: Math.max(0, Math.min(limit, total - offset))}, (_, i) => ({ + id: `site-${offset + i}`, + })); + return {data: {data: slice, limit, offset, total}, error: undefined, response: {status: 200}}; + }, + }; + return {listPages, detailFetches}; + } + + it('pages through all sites when total exceeds the 50-item page cap', async () => { + const backend = new ScapiSitesBackend({shortCode: 'abcd1234', tenantId: 'zzxy_dev', auth: fakeAuth}); + const {listPages} = stubPaginatedClient(backend, 75); + + const sites = await backend.listSites(); + + expect(sites).to.have.length(75); + expect(sites[0].id).to.equal('site-0'); + expect(sites[74].id).to.equal('site-74'); + // Two list pages: offset 0 (limit 50) then offset 50 (limit 50). + expect(listPages).to.deep.equal([ + {limit: 50, offset: 0}, + {limit: 50, offset: 50}, + ]); + }); + + it('honors start/count as SCAPI offset/limit', async () => { + const backend = new ScapiSitesBackend({shortCode: 'abcd1234', tenantId: 'zzxy_dev', auth: fakeAuth}); + const {listPages} = stubPaginatedClient(backend, 100); + + const sites = await backend.listSites({start: 30, count: 10}); + + expect(sites).to.have.length(10); + expect(sites[0].id).to.equal('site-30'); + expect(sites[9].id).to.equal('site-39'); + expect(listPages).to.deep.equal([{limit: 10, offset: 30}]); + }); + + it('fetches multiple pages when count exceeds the page cap', async () => { + const backend = new ScapiSitesBackend({shortCode: 'abcd1234', tenantId: 'zzxy_dev', auth: fakeAuth}); + const {listPages} = stubPaginatedClient(backend, 200); + + const sites = await backend.listSites({count: 75}); + + expect(sites).to.have.length(75); + expect(listPages).to.deep.equal([ + {limit: 50, offset: 0}, + {limit: 25, offset: 50}, + ]); + }); + + it('enriches each id-only site via a per-site detail call', async () => { + const backend = new ScapiSitesBackend({shortCode: 'abcd1234', tenantId: 'zzxy_dev', auth: fakeAuth}); + const {detailFetches} = stubPaginatedClient(backend, 3); + + const sites = await backend.listSites(); + + expect(detailFetches).to.deep.equal(['site-0', 'site-1', 'site-2']); + expect(sites[0]).to.include({id: 'site-0', displayName: 'Name site-0', storefrontStatus: 'online'}); + }); + + it('returns an empty list for an empty instance without a detail call', async () => { + const backend = new ScapiSitesBackend({shortCode: 'abcd1234', tenantId: 'zzxy_dev', auth: fakeAuth}); + const {detailFetches, listPages} = stubPaginatedClient(backend, 0); + + const sites = await backend.listSites(); + + expect(sites).to.have.length(0); + expect(detailFetches).to.have.length(0); + expect(listPages).to.deep.equal([{limit: 50, offset: 0}]); + }); + + it('does not fetch per-site detail when the list item is already rich', async () => { + const backend = new ScapiSitesBackend({shortCode: 'abcd1234', tenantId: 'zzxy_dev', auth: fakeAuth}); + const detailFetches: string[] = []; + (backend as unknown as {client: unknown}).client = { + async GET(path: string, opts: {params?: {path?: Record}}) { + if (path.endsWith('/sites/{siteId}')) { + detailFetches.push(opts.params!.path!.siteId); + return {data: {id: 'x'}, error: undefined, response: {status: 200}}; + } + return { + data: { + data: [{id: 'RefArch', displayName: {default: 'Ref Arch'}, storefrontStatus: 'online', cartridges: 'a'}], + limit: 50, + offset: 0, + total: 1, + }, + error: undefined, + response: {status: 200}, + }; + }, + }; + + const sites = await backend.listSites(); + + expect(detailFetches).to.have.length(0); + expect(sites[0]).to.include({id: 'RefArch', displayName: 'Ref Arch', storefrontStatus: 'online'}); + }); + }); }); diff --git a/skills/b2c-cli/skills/b2c-code/SKILL.md b/skills/b2c-cli/skills/b2c-code/SKILL.md index 0379c14c2..22242f75e 100644 --- a/skills/b2c-cli/skills/b2c-code/SKILL.md +++ b/skills/b2c-cli/skills/b2c-code/SKILL.md @@ -118,7 +118,7 @@ b2c code delete `code deploy` (file upload itself), `code download`, and `code watch` always use WebDAV — only the surrounding code-version operations use SCAPI. -OCAPI is deprecated and disabled on newer instances. `--api-backend auto` (the default) falls back to the OCAPI Data API only when SCAPI scopes are not configured; force a backend with `--api-backend scapi|ocapi` if needed. `code reload` is implemented as activate(alternate) + activate(target) so it works under either backend, except the `--reload` cache-rebuild uses OCAPI and is unavailable on OCAPI-disabled instances. +OCAPI is deprecated and disabled on newer instances. `--api-backend auto` (the default) falls back to the OCAPI Data API only when SCAPI scopes are not configured; force a backend with `--api-backend scapi|ocapi` if needed. The `--reload` flag forces a code cache reload as activate(alternate) + activate(target), using whichever backend the command selected — so it works on OCAPI-disabled instances when SCAPI is configured. ### More Commands diff --git a/skills/b2c-cli/skills/b2c-job/SKILL.md b/skills/b2c-cli/skills/b2c-job/SKILL.md index ea05362fb..6899b8878 100644 --- a/skills/b2c-cli/skills/b2c-job/SKILL.md +++ b/skills/b2c-cli/skills/b2c-job/SKILL.md @@ -210,7 +210,7 @@ Job commands run over SCAPI. Configure `shortCode`, `tenantId`, and the SCAPI sc OCAPI is deprecated and disabled on newer instances. `--api-backend auto` (the default) falls back to the OCAPI Data API only when SCAPI scopes are not configured; force a backend with `--api-backend scapi|ocapi`, dw.json `"api-backend": "scapi"`, or `SFCC_API_BACKEND=scapi`. -> **Note:** `job import` and `job export` trigger system jobs via OCAPI and transfer files over WebDAV; they are not yet available over SCAPI and won't work on OCAPI-disabled instances. +> **Note:** `job import` and `job export` trigger the site-archive system jobs and transfer archive files over WebDAV. The job trigger honors `--api-backend`: in `auto` mode it runs over SCAPI (needs `sfcc.jobs.rw`) with OCAPI fallback if the SCAPI start is rejected. The archive transfer always uses WebDAV. ### Wait for Job Completion From 1f37c26bc635ab0aa70e4b33b0b715977c35b29a Mon Sep 17 00:00:00 2001 From: Charles Lavery Date: Tue, 28 Jul 2026 11:00:58 -0400 Subject: [PATCH 19/22] feat(scapi): route site/active-version discovery through SCAPI-first backends MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An OCAPI-coverage audit found several discovery/auto-detect call sites still hitting raw OCAPI even though a SCAPI backend already existed. Wire them through the dual (SCAPI-with-OCAPI-fallback) backends so 'cover all use cases' holds beyond the original command scope: - operations/cap/list.ts, scaffold/sources.ts, operations/jobs/discover.ts: site discovery now uses createSitesBackend({instance}).listSites() instead of a raw instance.ocapi.GET('/sites'). In jobs/discover, only the sites category moves to SCAPI — catalogs and inventory-lists have no SCAPI list endpoint and stay on OCAPI. - VS Code cartridge-path site picker (pickSite) uses createSitesBackend too. - code download active-version auto-discovery goes through createScriptsBackend so SCAPI-only instances can discover the active version without OCAPI. Also fixes a pre-existing gap surfaced by this work: OcapiSitesBackend.listSites did not paginate, so an unbounded listing silently truncated at the OCAPI page size. It now pages through the full collection (honoring an explicit start/count as a single page), matching the SCAPI backend. Genuinely OCAPI-only operations are left as-is (no SCAPI equivalent): BM user search / whoami / access-key, cartridge-path writes, catalog/inventory-list discovery, and aborting a running job execution. --- .changeset/scapi-migration.md | 2 ++ .../b2c-cli/src/commands/code/download.ts | 7 ++-- .../src/operations/cap/list.ts | 14 +++----- .../src/operations/jobs/discover.ts | 33 +++++++++++++++---- .../operations/sites/ocapi-sites-backend.ts | 30 +++++++++++++++-- .../b2c-tooling-sdk/src/scaffold/sources.ts | 20 ++++------- .../src/code-sync/cartridge-commands.ts | 15 ++++----- 7 files changed, 77 insertions(+), 44 deletions(-) diff --git a/.changeset/scapi-migration.md b/.changeset/scapi-migration.md index be481cf02..87ff415cd 100644 --- a/.changeset/scapi-migration.md +++ b/.changeset/scapi-migration.md @@ -8,3 +8,5 @@ Migrate `job`, `code`, `bm users`, `bm roles`, and `sites` commands to support SCAPI alongside OCAPI. In auto mode (the default), the CLI prefers SCAPI when `shortCode` and `tenantId` are configured and silently falls back to OCAPI if the SCAPI scopes aren't granted. Use `--api-backend ocapi|scapi|auto` or `apiBackend` in dw.json to control explicitly. SCAPI scopes: `sfcc.jobs(.rw)`, `sfcc.scripts(.rw)`, `sfcc.users(.rw)`, `sfcc.roles(.rw)`, `sfcc.sites(.rw)` — read-only scopes are honored for list/get operations, falling back to read-only when the `*.rw` scope is not granted. `sites list` and `sites cartridges list` (reads) use the SCAPI `site/sites` API; cartridge-path writes have no SCAPI equivalent and remain on OCAPI / site-archive import. Site archive import/export (`site-import`, `site-export`, content/cartridge operations) and Commerce App Package install/uninstall (`cap install`, `cap uninstall`) now trigger their system jobs over SCAPI when configured, transparently falling back to OCAPI if the SCAPI start is rejected (never re-running a job that already started). New `job execution delete` command (SCAPI only). `code deploy` and all VS Code extension code-version actions (list/activate/delete/reload/create plus active-version discovery) also honor `apiBackend`. `bm users update --disabled` transparently falls back to OCAPI in auto mode (SCAPI Users PATCH does not support the `disabled` flag). SCAPI is only selected when authentication can request the required scopes — stateless OAuth (client-credentials or JWT bearer). Stateful (stored-session) and implicit flows hold a fixed-scope token and cannot request SCAPI scopes, so they use OCAPI; this applies to `--api-backend scapi` too, which fails with a clear error for those flows rather than silently using an under-scoped token. For SDK consumers, `B2CInstance` now carries the SCAPI coordinates itself: a `B2CInstance.scapiClientConfig` getter returns `{shortCode, tenantId, auth}` (or `undefined` when the instance can't reach SCAPI), and `B2CInstance.apiBackend` exposes the configured preference. The dual-backend factories (`createSitesBackend`, `createScriptsBackend`, `createUsersBackend`, `createRolesBackend`) now take just `{instance}` and source SCAPI config from it — so SCAPI operations need nothing beyond a configured instance. + +Site discovery used by other flows now goes through the same SCAPI-first sites backend (with OCAPI fallback): `cap list`, scaffold site parameters, `job export` data-unit discovery, and the VS Code extension's cartridge-path site picker no longer hard-depend on OCAPI. `code download` active-version auto-discovery also honors the backend preference. Catalog / inventory-list discovery and BM `user search` / `whoami` / access-key operations remain OCAPI-only (no SCAPI equivalent exists). diff --git a/packages/b2c-cli/src/commands/code/download.ts b/packages/b2c-cli/src/commands/code/download.ts index 6160d9dd1..9c22f6579 100644 --- a/packages/b2c-cli/src/commands/code/download.ts +++ b/packages/b2c-cli/src/commands/code/download.ts @@ -6,7 +6,7 @@ import {Flags} from '@oclif/core'; import { downloadCartridges, - getActiveCodeVersion, + createScriptsBackend, type DownloadResult, } from '@salesforce/b2c-tooling-sdk/operations/code'; import {CartridgeCommand} from '@salesforce/b2c-tooling-sdk/cli'; @@ -54,7 +54,10 @@ export default class CodeDownload extends CartridgeCommand protected operations = { downloadCartridges, - getActiveCodeVersion, + // Active-version discovery goes through the dual backend (SCAPI with OCAPI + // fallback) so SCAPI-only instances can auto-discover without OCAPI. + getActiveCodeVersion: (instance: import('@salesforce/b2c-tooling-sdk/instance').B2CInstance) => + createScriptsBackend({instance}).getActiveCodeVersion(), }; async run(): Promise { diff --git a/packages/b2c-tooling-sdk/src/operations/cap/list.ts b/packages/b2c-tooling-sdk/src/operations/cap/list.ts index 4c9af3742..57fe0ed3f 100644 --- a/packages/b2c-tooling-sdk/src/operations/cap/list.ts +++ b/packages/b2c-tooling-sdk/src/operations/cap/list.ts @@ -14,8 +14,8 @@ import * as path from 'node:path'; import JSZip from 'jszip'; import * as xml2js from 'xml2js'; import {B2CInstance} from '../../instance/index.js'; -import {isOcapiDeprecatedFault, OcapiDeprecatedError} from '../../clients/error-utils.js'; import {getLogger} from '../../logging/logger.js'; +import {createSitesBackend} from '../sites/index.js'; import {siteArchiveExportToBuffer} from '../jobs/site-archive.js'; import type {JobExecution, WaitForJobOptions} from '../jobs/run.js'; import {readManifest} from './install.js'; @@ -164,15 +164,9 @@ export async function listInstalledApps( if (options.sites && options.sites.length > 0) { siteIds = options.sites; } else { - logger.debug('No sites specified, discovering all sites via OCAPI'); - const {data, error} = await instance.ocapi.GET('/sites', { - params: {query: {select: '(**)'}}, - }); - if (error || !data) { - if (isOcapiDeprecatedFault(error)) throw new OcapiDeprecatedError({cause: error}); - throw new Error(error?.fault?.message ?? 'Failed to list sites', {cause: error}); - } - siteIds = (data.data ?? []).map((s) => s.id).filter((id): id is string => !!id); + logger.debug('No sites specified, discovering all sites (SCAPI with OCAPI fallback)'); + const sites = await createSitesBackend({instance}).listSites(); + siteIds = sites.map((s) => s.id).filter((id): id is string => !!id); logger.debug({siteIds}, `Discovered ${siteIds.length} site(s)`); } diff --git a/packages/b2c-tooling-sdk/src/operations/jobs/discover.ts b/packages/b2c-tooling-sdk/src/operations/jobs/discover.ts index 73a2a41eb..1dc3c341a 100644 --- a/packages/b2c-tooling-sdk/src/operations/jobs/discover.ts +++ b/packages/b2c-tooling-sdk/src/operations/jobs/discover.ts @@ -16,6 +16,7 @@ */ import {B2CInstance} from '../../instance/index.js'; import {getLogger} from '../../logging/logger.js'; +import {createSitesBackend} from '../sites/index.js'; /** * IDs discovered on an instance, grouped by data-unit category. Each list is @@ -34,9 +35,12 @@ export interface ExportableUnits { warnings: string[]; } -/** A discoverable category and the OCAPI path used to list it. */ -const DISCOVERABLE = [ - {key: 'sites', path: '/sites', label: 'sites'}, +/** + * Discoverable categories that only have an OCAPI "list-all" endpoint. Sites + * are handled separately via the SCAPI-first sites backend (SCAPI has no + * catalogs/inventory-list listing, so those stay on OCAPI). + */ +const OCAPI_DISCOVERABLE = [ {key: 'catalogs', path: '/catalogs', label: 'catalogs'}, {key: 'inventoryLists', path: '/inventory_lists', label: 'inventory lists'}, ] as const; @@ -48,7 +52,7 @@ const PAGE_COUNT = 200; * Lists one paginated OCAPI collection, following `start`/`count` until all * documents are read. Returns the `id` of each document. */ -async function listIds(instance: B2CInstance, path: '/sites' | '/catalogs' | '/inventory_lists'): Promise { +async function listIds(instance: B2CInstance, path: '/catalogs' | '/inventory_lists'): Promise { const ids: string[] = []; let start = 0; @@ -99,8 +103,23 @@ export async function discoverExportableUnits(instance: B2CInstance): Promise { + await Promise.all([ + // Sites: SCAPI (site/sites) with OCAPI fallback. + (async () => { + try { + const sites = await createSitesBackend({instance}).listSites(); + result.sites = sites + .map((s) => s.id) + .filter((id): id is string => !!id) + .sort((a, b) => a.localeCompare(b)); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + logger.debug({err: message}, 'Failed to discover sites'); + result.warnings.push(`Could not list sites: ${message}`); + } + })(), + // Catalogs + inventory lists: OCAPI only (no SCAPI list-all endpoint). + ...OCAPI_DISCOVERABLE.map(async ({key, path, label}) => { try { result[key] = (await listIds(instance, path)).sort((a, b) => a.localeCompare(b)); } catch (err) { @@ -109,7 +128,7 @@ export async function discoverExportableUnits(instance: B2CInstance): Promise { - const {count, start} = options; + // When the caller bounds the result (start/count), honor it as a single + // page. Otherwise page through the whole collection so callers that need + // *all* sites (export-unit discovery, CAP feature listing) don't silently + // truncate at the OCAPI default page size. + if (options.start !== undefined || options.count !== undefined) { + return this.fetchSitePage(options.start, options.count); + } + + const all: SiteInfo[] = []; + const pageSize = 200; + let start = 0; + for (;;) { + const {sites, total} = await this.fetchSitePageWithTotal(start, pageSize); + all.push(...sites); + start += pageSize; + if (sites.length === 0 || start >= total) break; + } + return all; + } + + private async fetchSitePage(start?: number, count?: number): Promise { + return (await this.fetchSitePageWithTotal(start, count)).sites; + } + + private async fetchSitePageWithTotal(start?: number, count?: number): Promise<{sites: SiteInfo[]; total: number}> { const {data, error, response} = await this.instance.ocapi.GET('/sites', { params: {query: {start, count, select: '(**)'}}, }); if (error || !data) { throwOcapiError(error, response, 'Failed to list sites', SCAPI_SITES_READ_AND_RW_SCOPES); } - return ((data as OcapiSites).data ?? []).map(mapOcapiSite); + const body = data as OcapiSites; + const sites = (body.data ?? []).map(mapOcapiSite); + return {sites, total: body.total ?? (start ?? 0) + sites.length}; } async getSite(siteId: string): Promise { diff --git a/packages/b2c-tooling-sdk/src/scaffold/sources.ts b/packages/b2c-tooling-sdk/src/scaffold/sources.ts index e65a25c1c..26b21f114 100644 --- a/packages/b2c-tooling-sdk/src/scaffold/sources.ts +++ b/packages/b2c-tooling-sdk/src/scaffold/sources.ts @@ -8,8 +8,7 @@ import fs from 'node:fs'; import path from 'node:path'; import {findCartridges} from '../operations/code/cartridges.js'; import type {B2CInstance} from '../instance/index.js'; -import type {OcapiComponents} from '../clients/index.js'; -import {isOcapiDeprecatedFault, OcapiDeprecatedError} from '../clients/error-utils.js'; +import {createSitesBackend} from '../operations/sites/index.js'; import type {ScaffoldChoice, ScaffoldParameter, DynamicParameterSource, SourceResult} from './types.js'; /** @@ -149,19 +148,12 @@ export async function resolveRemoteSource( ): Promise { switch (source) { case 'sites': { - const {data, error} = await instance.ocapi.GET('/sites', { - params: {query: {select: '(**)'}}, - }); - - if (error) { - if (isOcapiDeprecatedFault(error)) throw new OcapiDeprecatedError({cause: error}); - throw new Error('Failed to fetch sites from B2C instance', {cause: error}); - } - - const sites = data as OcapiComponents['schemas']['sites']; - return (sites.data ?? []).map((s) => ({ + // SCAPI (site/sites) with OCAPI fallback; the backend surfaces the + // OcapiDeprecatedError itself when only a deprecated OCAPI is reachable. + const sites = await createSitesBackend({instance}).listSites(); + return sites.map((s) => ({ value: s.id ?? '', - label: s.display_name?.default || s.id || '', + label: s.displayName || s.id || '', })); } default: { diff --git a/packages/b2c-vs-extension/src/code-sync/cartridge-commands.ts b/packages/b2c-vs-extension/src/code-sync/cartridge-commands.ts index c863f0e71..b9e8e1db8 100644 --- a/packages/b2c-vs-extension/src/code-sync/cartridge-commands.ts +++ b/packages/b2c-vs-extension/src/code-sync/cartridge-commands.ts @@ -9,6 +9,7 @@ import { addCartridge, removeCartridge, getCartridgePath, + createSitesBackend, type CartridgePosition, } from '@salesforce/b2c-tooling-sdk/operations/sites'; import type {B2CInstance} from '@salesforce/b2c-tooling-sdk/instance'; @@ -100,15 +101,11 @@ async function pickSite(instance: B2CInstance): Promise { let siteItems: {label: string; siteId: string}[] = []; try { - const {data, error} = await instance.ocapi.GET('/sites', { - params: {query: {select: '(**)'}}, - }); - if (!error && data) { - const sites = (data as {data?: {id?: string}[]}).data ?? []; - siteItems = sites - .filter((s): s is {id: string} => typeof s.id === 'string') - .map((s) => ({label: s.id, siteId: s.id})); - } + // SCAPI (site/sites) with OCAPI fallback. + const sites = await createSitesBackend({instance}).listSites(); + siteItems = sites + .filter((s): s is typeof s & {id: string} => typeof s.id === 'string') + .map((s) => ({label: s.id, siteId: s.id})); } catch { // OAuth not available — fall through to manual input } From 52e2cae0a12033d0c641d2cdeb26066f878e53b5 Mon Sep 17 00:00:00 2001 From: Charles Lavery Date: Thu, 30 Jul 2026 10:25:28 -0400 Subject: [PATCH 20/22] feat(bm): add bm users create command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expose BM user creation as a full CLI command. The SDK backends already implemented createOrReplaceUser (PUT /users/{login}) on both SCAPI and OCAPI, but nothing surfaced it — bm users only had list/get/search/update/delete. - New bm users create command (create-or-replace) wrapping backend.createOrReplaceUser via the dual SCAPI/OCAPI backend, consistent with the other bm users commands. Flags: login arg + --email (required), --first-name/--last-name/--external-id/--password/--role (repeatable)/ --disabled/--preferred-ui-locale/--preferred-data-locale. JSON output. - Docs + skill reframed: the LocalUserCreationException note previously implied "these commands do not create users"; it now documents that create IS available but is rejected with LocalUserCreationException on SSO/AM-only instances (local user creation must be enabled on the instance). Updated docs/cli/bm.md (command table, Users section, per-command reference), the b2c-bm-users-roles skill, and the SDK module JSDoc (bm-users index + users.ts). - Adds create.test.ts (field passthrough, optional-field omission, --disabled, LocalUserCreationException surfacing). --- .changeset/bm-users-create.md | 5 + docs/cli/bm.md | 29 ++++- .../b2c-cli/src/commands/bm/users/create.ts | 101 ++++++++++++++++ .../test/commands/bm/users/create.test.ts | 110 ++++++++++++++++++ .../src/operations/bm-users/index.ts | 8 +- .../src/operations/bm-users/users.ts | 7 +- .../skills/b2c-bm-users-roles/SKILL.md | 7 +- 7 files changed, 257 insertions(+), 10 deletions(-) create mode 100644 .changeset/bm-users-create.md create mode 100644 packages/b2c-cli/src/commands/bm/users/create.ts create mode 100644 packages/b2c-cli/test/commands/bm/users/create.test.ts diff --git a/.changeset/bm-users-create.md b/.changeset/bm-users-create.md new file mode 100644 index 000000000..898c6acd4 --- /dev/null +++ b/.changeset/bm-users-create.md @@ -0,0 +1,5 @@ +--- +'@salesforce/b2c-cli': minor +--- + +Add `b2c bm users create` to create a Business Manager user (create-or-replace), rounding out the `bm users` lifecycle alongside list/get/search/update/delete. Runs over SCAPI with OCAPI fallback like the other `bm users` commands. Flags: `--email` (required), `--first-name`, `--last-name`, `--external-id`, `--password`, `--role` (repeatable), `--disabled`, and preferred locales. Note that most instances use SSO with Account Manager and reject creating *local* BM users with `LocalUserCreationException` — creation succeeds only when the instance is configured to allow local users. diff --git a/docs/cli/bm.md b/docs/cli/bm.md index 605fedc91..40b6de69e 100644 --- a/docs/cli/bm.md +++ b/docs/cli/bm.md @@ -18,7 +18,7 @@ b2c bm roles get Administrator | Command | Backend | Scope | |---|---|---| -| `bm users list/get/update/delete` | SCAPI | `sfcc.users.rw` | +| `bm users list/get/create/update/delete` | SCAPI | `sfcc.users.rw` | | `bm roles list/get/create/delete` | SCAPI | `sfcc.roles.rw` | | `bm roles grant/revoke` | SCAPI | `sfcc.roles.rw` | | `bm roles permissions get/set` | SCAPI | `sfcc.roles.rw` | @@ -294,8 +294,8 @@ The file follows the OCAPI `role_permissions` schema with four sections: `b2c bm users` — query and manage instance-level Business Manager users via the OCAPI `/users` resource. -::: tip -Most production instances use SSO with Account Manager; creating *local* BM users via the Data API is rejected with `LocalUserCreationException`. These commands focus on read/search/lifecycle for AM-managed users plus access-key administration. +::: warning Local user creation is often disabled +`b2c bm users create` performs a create-or-replace. Most production instances use SSO with Account Manager and **reject creating *local* BM users** — the server responds with `LocalUserCreationException` ("creation of a local Business Manager user is not allowed with the current server settings"). Creation succeeds only when the instance is explicitly configured to allow local users; otherwise use Account Manager to provision users and manage them here (read/update/delete/search) plus access-key administration. ::: ### b2c bm users list @@ -364,6 +364,29 @@ b2c bm users search --locked --sort-by last_login_date --sort-order desc b2c bm users search --query '{"text_query":{"fields":["login"],"search_phrase":"foo"}}' ``` +### b2c bm users create + +Create a Business Manager user (create-or-replace via `PUT /users/{login}`). `--email` is required; the login argument is the user's login (typically their email). + +::: warning +This succeeds only on instances configured to allow local BM users. On SSO/Account-Manager–only instances the server rejects it with `LocalUserCreationException` — provision the user in Account Manager instead. Because it is create-or-replace, running it against an existing login replaces that user's attributes. +::: + +```bash +b2c bm users create --email [--first-name ] [--last-name ] \ + [--external-id ] [--password ] [--role ...] \ + [--disabled | --no-disabled] [--preferred-ui-locale ] [--preferred-data-locale ] +``` + +```bash +b2c bm users create user@example.com --email user@example.com +b2c bm users create user@example.com --email user@example.com --first-name Jane --last-name Doe +b2c bm users create user@example.com --email user@example.com --role Administrator --role bm-admin +b2c bm users create user@example.com --email user@example.com --external-id ext-123 +``` + +`--password` applies only to local users and is ignored for SSO/AM-managed accounts. `--role` is repeatable. + ### b2c bm users update Update non-identity user fields. The `locked` flag and `password` cannot be updated through this command — those are governed by Account Manager / SSO. diff --git a/packages/b2c-cli/src/commands/bm/users/create.ts b/packages/b2c-cli/src/commands/bm/users/create.ts new file mode 100644 index 000000000..0a30102ee --- /dev/null +++ b/packages/b2c-cli/src/commands/bm/users/create.ts @@ -0,0 +1,101 @@ +/* + * 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 {Args, Flags} from '@oclif/core'; +import {BmCommand} from '@salesforce/b2c-tooling-sdk/cli'; +import {type UserInfo, type CreateUserInput} from '@salesforce/b2c-tooling-sdk/operations/bm-users'; +import {t} from '../../../i18n/index.js'; + +export default class BmUsersCreate extends BmCommand { + static args = { + login: Args.string({ + description: 'User login (email)', + required: true, + }), + }; + + static description = t( + 'commands.bm.users.create.description', + 'Create a Business Manager user (create-or-replace). Note: most instances use SSO with Account Manager and reject creating *local* BM users with "LocalUserCreationException" — this succeeds only when the instance is configured to allow local user creation.', + ); + + static enableJsonFlag = true; + + static examples = [ + '<%= config.bin %> <%= command.id %> user@example.com --email user@example.com', + '<%= config.bin %> <%= command.id %> user@example.com --email user@example.com --first-name Jane --last-name Doe', + '<%= config.bin %> <%= command.id %> user@example.com --email user@example.com --role Administrator --role bm-admin', + '<%= config.bin %> <%= command.id %> user@example.com --email user@example.com --external-id ext-123', + ]; + + static flags = { + email: Flags.string({ + description: 'User email address', + required: true, + }), + 'first-name': Flags.string({ + description: 'User first name', + }), + 'last-name': Flags.string({ + description: 'User last name', + }), + 'external-id': Flags.string({ + description: 'External id (for centrally-authenticated / SSO users)', + }), + password: Flags.string({ + description: 'Initial password (local users only; ignored for SSO/AM-managed users)', + }), + role: Flags.string({ + description: 'Role to assign (repeatable)', + multiple: true, + }), + disabled: Flags.boolean({ + description: 'Create the user in a disabled state', + allowNo: true, + }), + 'preferred-ui-locale': Flags.string({ + description: 'Preferred UI locale (e.g. en_US)', + }), + 'preferred-data-locale': Flags.string({ + description: 'Preferred data locale (e.g. en_US)', + }), + }; + + async run(): Promise { + this.requireOAuthCredentials(); + + const {login} = this.args; + const flags = this.flags; + const hostname = this.resolvedConfig.values.hostname!; + + const input: CreateUserInput = { + login, + email: flags.email, + }; + if (flags['first-name'] !== undefined) input.firstName = flags['first-name']; + if (flags['last-name'] !== undefined) input.lastName = flags['last-name']; + if (flags['external-id'] !== undefined) input.externalId = flags['external-id']; + if (flags.password !== undefined) input.password = flags.password; + if (flags.disabled !== undefined) input.disabled = flags.disabled; + if (flags.role !== undefined) input.roles = flags.role; + if (flags['preferred-ui-locale'] !== undefined) input.preferredUiLocale = flags['preferred-ui-locale']; + if (flags['preferred-data-locale'] !== undefined) input.preferredDataLocale = flags['preferred-data-locale']; + + const backend = this.createUsersBackend(); + this.logger.debug(`Using ${backend.name} backend for users create`); + + this.log(t('commands.bm.users.create.creating', 'Creating user {{login}} on {{hostname}}...', {login, hostname})); + + const user = await backend.createOrReplaceUser(login, input); + + if (this.jsonEnabled()) { + return user; + } + + this.log(t('commands.bm.users.create.success', 'User {{login}} created on {{hostname}}.', {login, hostname})); + + return user; + } +} diff --git a/packages/b2c-cli/test/commands/bm/users/create.test.ts b/packages/b2c-cli/test/commands/bm/users/create.test.ts new file mode 100644 index 000000000..f674de93c --- /dev/null +++ b/packages/b2c-cli/test/commands/bm/users/create.test.ts @@ -0,0 +1,110 @@ +/* + * 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 {afterEach, beforeEach} from 'mocha'; +import sinon from 'sinon'; +import BmUsersCreate from '../../../../src/commands/bm/users/create.js'; +import {createIsolatedConfigHooks, createTestCommand, expectError} from '../../../helpers/test-setup.js'; + +describe('bm users create', () => { + const hooks = createIsolatedConfigHooks(); + + beforeEach(hooks.beforeEach); + + afterEach(hooks.afterEach); + + async function createCommand(flags: Record = {}, args: Record = {}) { + return createTestCommand(BmUsersCreate, hooks.getConfig(), flags, args); + } + + function createMockBackend() { + return { + name: 'ocapi' as const, + listUsers: sinon.stub(), + getUser: sinon.stub(), + createOrReplaceUser: sinon.stub(), + updateUser: sinon.stub(), + deleteUser: sinon.stub(), + }; + } + + function stubCommon(command: any, {jsonEnabled}: {jsonEnabled: boolean}) { + sinon.stub(command, 'requireOAuthCredentials').returns(void 0); + sinon.stub(command, 'resolvedConfig').get(() => ({values: {hostname: 'example.com'}})); + sinon.stub(command, 'instance').get(() => ({config: {hostname: 'example.com'}})); + sinon.stub(command, 'jsonEnabled').returns(jsonEnabled); + sinon.stub(command, 'log').returns(void 0); + const backend = createMockBackend(); + sinon.stub(command, 'createUsersBackend').returns(backend); + return backend; + } + + it('creates a user and passes login + email + optional fields through', async () => { + const command: any = await createCommand( + {email: 'user@x.com', 'first-name': 'Jane', 'last-name': 'Doe', role: ['Administrator', 'bm-admin']}, + {login: 'user@x.com'}, + ); + const backend = stubCommon(command, {jsonEnabled: true}); + backend.createOrReplaceUser.resolves({ + login: 'user@x.com', + email: 'user@x.com', + firstName: 'Jane', + disabled: false, + }); + + const result = await command.run(); + + expect(backend.createOrReplaceUser.calledOnce).to.be.true; + const [login, input] = backend.createOrReplaceUser.firstCall.args; + expect(login).to.equal('user@x.com'); + expect(input).to.deep.include({ + login: 'user@x.com', + email: 'user@x.com', + firstName: 'Jane', + lastName: 'Doe', + roles: ['Administrator', 'bm-admin'], + }); + expect(result.login).to.equal('user@x.com'); + }); + + it('omits optional fields that were not provided', async () => { + const command: any = await createCommand({email: 'user@x.com'}, {login: 'user@x.com'}); + const backend = stubCommon(command, {jsonEnabled: true}); + backend.createOrReplaceUser.resolves({login: 'user@x.com', email: 'user@x.com'}); + + await command.run(); + + const [, input] = backend.createOrReplaceUser.firstCall.args; + expect(input).to.deep.equal({login: 'user@x.com', email: 'user@x.com'}); + expect(input).to.not.have.property('roles'); + expect(input).to.not.have.property('disabled'); + }); + + it('supports --disabled to create in a disabled state', async () => { + const command: any = await createCommand({email: 'user@x.com', disabled: true}, {login: 'user@x.com'}); + const backend = stubCommon(command, {jsonEnabled: true}); + backend.createOrReplaceUser.resolves({login: 'user@x.com', email: 'user@x.com', disabled: true}); + + await command.run(); + + const [, input] = backend.createOrReplaceUser.firstCall.args; + expect(input.disabled).to.equal(true); + }); + + it('surfaces a LocalUserCreationException from the backend', async () => { + const command: any = await createCommand({email: 'user@x.com'}, {login: 'user@x.com'}); + const backend = stubCommon(command, {jsonEnabled: false}); + backend.createOrReplaceUser.rejects( + new Error( + 'Failed to create user user@x.com: LocalUserCreationException - creation of a local BM user is not allowed', + ), + ); + + const error = await expectError(() => command.run()); + expect((error as Error).message).to.include('LocalUserCreationException'); + }); +}); diff --git a/packages/b2c-tooling-sdk/src/operations/bm-users/index.ts b/packages/b2c-tooling-sdk/src/operations/bm-users/index.ts index 025caa27d..6baaf5dce 100644 --- a/packages/b2c-tooling-sdk/src/operations/bm-users/index.ts +++ b/packages/b2c-tooling-sdk/src/operations/bm-users/index.ts @@ -9,9 +9,11 @@ * Provides functions for querying and managing instance-level users via OCAPI Data API. * These are distinct from Account Manager users managed via {@link @salesforce/b2c-tooling-sdk/operations/users | operations/users}. * - * On instances using SSO with Account Manager (the default for production), creating local - * BM users via the Data API is rejected with `LocalUserCreationException`. These operations - * focus on read/search/lifecycle of AM-managed users plus access-key administration. + * Create-or-replace is supported via the backend's `createOrReplaceUser` method (PUT), obtained + * from {@link createUsersBackend}. On instances using SSO with Account Manager (the default for + * production) this is rejected with `LocalUserCreationException` — local user creation must be + * enabled on the instance for it to succeed; otherwise provision users in Account Manager and + * use these operations for read/search/update/delete plus access-key administration. * * ## Core User Functions * diff --git a/packages/b2c-tooling-sdk/src/operations/bm-users/users.ts b/packages/b2c-tooling-sdk/src/operations/bm-users/users.ts index ce1585c88..fd4426549 100644 --- a/packages/b2c-tooling-sdk/src/operations/bm-users/users.ts +++ b/packages/b2c-tooling-sdk/src/operations/bm-users/users.ts @@ -9,9 +9,10 @@ * Provides functions for querying and managing instance-level users via OCAPI Data API. * * Note: Most production B2C Commerce instances delegate user identity to Account Manager - * (SSO), so creating local Business Manager users via the Data API typically fails with - * `LocalUserCreationException`. These operations focus on read/search/lifecycle of - * AM-managed users plus access-key administration. + * (SSO), so create-or-replace (PUT) is rejected with `LocalUserCreationException` unless the + * instance is configured to allow local Business Manager users. When SSO-managed, provision + * users in Account Manager and use these operations for read/search/update/delete plus + * access-key administration. */ import type {B2CInstance} from '../../instance/index.js'; import type {components} from '../../clients/ocapi.generated.js'; diff --git a/skills/b2c-cli/skills/b2c-bm-users-roles/SKILL.md b/skills/b2c-cli/skills/b2c-bm-users-roles/SKILL.md index bf64cd418..c34b49d46 100644 --- a/skills/b2c-cli/skills/b2c-bm-users-roles/SKILL.md +++ b/skills/b2c-cli/skills/b2c-bm-users-roles/SKILL.md @@ -81,7 +81,7 @@ The permissions JSON has four sections: `functional`, `module`, `locale`, and `w ## Business Manager Users -Most production instances use SSO with Account Manager — creating *local* BM users is rejected with `LocalUserCreationException`. These commands focus on **read/search/update/delete** for AM-managed users plus the per-user access-key administration below. +These commands cover the full lifecycle — **create/read/search/update/delete** — for BM users, plus the per-user access-key administration below. Note that `bm users create` is a create-or-replace that only works on instances configured to allow *local* BM users; most production instances use SSO with Account Manager and reject it with `LocalUserCreationException`, in which case users are provisioned in Account Manager and managed here for the rest of their lifecycle. ```bash # list (default 25) @@ -93,6 +93,11 @@ b2c bm users list --columns login,email,lastLogin # custom column set # get one user by login (email) b2c bm users get user@example.com +# create a user (create-or-replace; --email required, --role repeatable) +# only on instances that allow local users — else LocalUserCreationException +b2c bm users create user@example.com --email user@example.com +b2c bm users create user@example.com --email user@example.com --first-name Jane --last-name Doe --role Administrator + # search by attribute (any combination of flags) b2c bm users search --search-phrase smith b2c bm users search --login user@example.com From 3fcf2498c029439380bfcf42772e441a1fe61fac Mon Sep 17 00:00:00 2001 From: Charles Lavery Date: Thu, 6 Aug 2026 11:01:17 -0400 Subject: [PATCH 21/22] feat(scapi): complete migration fallback coverage --- .changeset/scapi-migration.md | 13 +- docs/cli/auth.md | 77 +- docs/cli/bm.md | 185 ++- docs/cli/code.md | 85 +- docs/cli/jobs.md | 161 +-- docs/cli/setup.md | 30 +- docs/cli/sites.md | 63 +- docs/guide/authentication.md | 102 +- docs/guide/configuration.md | 165 ++- docs/typedoc.json | 1 + .../b2c-cli/src/commands/bm/users/search.ts | 25 +- .../b2c-cli/src/commands/code/download.ts | 1 + packages/b2c-cli/src/commands/code/watch.ts | 5 +- .../src/commands/setup/instance/create.ts | 32 +- .../b2c-dx-mcp/src/tools/cartridges/index.ts | 12 +- packages/b2c-tooling-sdk/package.json | 7 +- .../specs/product-catalogs-v1.yaml | 111 ++ .../b2c-tooling-sdk/specs/site-sites-v1.yaml | 1228 +++++++++-------- .../b2c-tooling-sdk/src/cli/bm-command.ts | 4 +- .../b2c-tooling-sdk/src/cli/code-command.ts | 4 +- .../src/clients/dual-backend-factory.ts | 4 +- packages/b2c-tooling-sdk/src/clients/index.ts | 23 +- .../src/clients/middleware-registry.ts | 3 +- .../src/clients/scapi-backend-utils.ts | 47 +- .../src/clients/scapi-catalogs.generated.ts | 105 ++ .../src/clients/scapi-catalogs.ts | 34 + .../src/clients/scapi-fallback-backend.ts | 6 +- .../src/clients/scapi-sites.generated.ts | 103 +- .../src/clients/scapi-sites.ts | 10 +- .../b2c-tooling-sdk/src/compat/dispatcher.ts | 13 +- packages/b2c-tooling-sdk/src/compat/index.ts | 1 + .../src/compat/jobs-backend.ts | 126 ++ packages/b2c-tooling-sdk/src/index.ts | 14 + .../src/operations/bm-roles/ocapi-backend.ts | 110 +- .../src/operations/bm-roles/scapi-backend.ts | 38 +- .../src/operations/bm-users/index.ts | 1 + .../src/operations/bm-users/ocapi-backend.ts | 13 + .../src/operations/bm-users/scapi-backend.ts | 112 +- .../src/operations/bm-users/types.ts | 20 +- .../src/operations/bm-users/users.ts | 4 +- .../operations/catalogs/catalogs-backend.ts | 19 + .../src/operations/catalogs/catalogs-types.ts | 22 + .../src/operations/catalogs/index.ts | 12 + .../catalogs/ocapi-catalogs-backend.ts | 46 + .../catalogs/scapi-catalogs-backend.ts | 64 + .../src/operations/code/deploy.ts | 9 +- .../src/operations/code/download.ts | 13 +- .../operations/code/scapi-scripts-backend.ts | 22 +- .../src/operations/code/watch.ts | 8 +- .../src/operations/jobs/discover.ts | 46 +- .../src/operations/jobs/run-system-job.ts | 31 +- .../src/operations/jobs/scapi-ops.ts | 41 +- .../src/operations/sites/cartridges.ts | 120 +- .../src/operations/sites/index.ts | 11 +- .../operations/sites/ocapi-sites-backend.ts | 52 + .../operations/sites/scapi-sites-backend.ts | 81 +- .../src/operations/sites/sites-backend.ts | 4 +- .../src/operations/sites/sites-scopes.ts | 4 +- .../src/operations/sites/sites-types.ts | 15 +- .../clients/scapi-fallback-backend.test.ts | 37 +- .../operations/bm-roles/ocapi-backend.test.ts | 89 ++ .../operations/bm-users/scapi-search.test.ts | 97 ++ .../catalogs/catalogs-backend.test.ts | 37 + .../operations/sites/sites-backend.test.ts | 46 + .../src/jobs/jobs-commands.ts | 15 +- .../src/jobs/jobs-tree-provider.ts | 6 +- .../src/walkthrough/onboardingPanel.ts | 20 +- .../src/webdav-tree/webdav-commands.ts | 11 +- .../skills/b2c-bm-users-roles/SKILL.md | 30 +- skills/b2c-cli/skills/b2c-code/SKILL.md | 4 +- skills/b2c-cli/skills/b2c-config/SKILL.md | 30 +- skills/b2c-cli/skills/b2c-job/SKILL.md | 22 +- skills/b2c-cli/skills/b2c-sites/SKILL.md | 14 +- 73 files changed, 2768 insertions(+), 1408 deletions(-) create mode 100644 packages/b2c-tooling-sdk/specs/product-catalogs-v1.yaml create mode 100644 packages/b2c-tooling-sdk/src/clients/scapi-catalogs.generated.ts create mode 100644 packages/b2c-tooling-sdk/src/clients/scapi-catalogs.ts create mode 100644 packages/b2c-tooling-sdk/src/compat/jobs-backend.ts create mode 100644 packages/b2c-tooling-sdk/src/operations/catalogs/catalogs-backend.ts create mode 100644 packages/b2c-tooling-sdk/src/operations/catalogs/catalogs-types.ts create mode 100644 packages/b2c-tooling-sdk/src/operations/catalogs/index.ts create mode 100644 packages/b2c-tooling-sdk/src/operations/catalogs/ocapi-catalogs-backend.ts create mode 100644 packages/b2c-tooling-sdk/src/operations/catalogs/scapi-catalogs-backend.ts create mode 100644 packages/b2c-tooling-sdk/test/operations/bm-roles/ocapi-backend.test.ts create mode 100644 packages/b2c-tooling-sdk/test/operations/bm-users/scapi-search.test.ts create mode 100644 packages/b2c-tooling-sdk/test/operations/catalogs/catalogs-backend.test.ts diff --git a/.changeset/scapi-migration.md b/.changeset/scapi-migration.md index 87ff415cd..c1715a6f5 100644 --- a/.changeset/scapi-migration.md +++ b/.changeset/scapi-migration.md @@ -1,12 +1,15 @@ --- -'@salesforce/b2c-cli': minor -'@salesforce/b2c-tooling-sdk': minor +'@salesforce/b2c-cli': major +'@salesforce/b2c-tooling-sdk': major 'b2c-vs-extension': minor '@salesforce/b2c-dx-docs': minor +'@salesforce/b2c-agent-plugins': patch --- -Migrate `job`, `code`, `bm users`, `bm roles`, and `sites` commands to support SCAPI alongside OCAPI. In auto mode (the default), the CLI prefers SCAPI when `shortCode` and `tenantId` are configured and silently falls back to OCAPI if the SCAPI scopes aren't granted. Use `--api-backend ocapi|scapi|auto` or `apiBackend` in dw.json to control explicitly. SCAPI scopes: `sfcc.jobs(.rw)`, `sfcc.scripts(.rw)`, `sfcc.users(.rw)`, `sfcc.roles(.rw)`, `sfcc.sites(.rw)` — read-only scopes are honored for list/get operations, falling back to read-only when the `*.rw` scope is not granted. `sites list` and `sites cartridges list` (reads) use the SCAPI `site/sites` API; cartridge-path writes have no SCAPI equivalent and remain on OCAPI / site-archive import. Site archive import/export (`site-import`, `site-export`, content/cartridge operations) and Commerce App Package install/uninstall (`cap install`, `cap uninstall`) now trigger their system jobs over SCAPI when configured, transparently falling back to OCAPI if the SCAPI start is rejected (never re-running a job that already started). New `job execution delete` command (SCAPI only). `code deploy` and all VS Code extension code-version actions (list/activate/delete/reload/create plus active-version discovery) also honor `apiBackend`. `bm users update --disabled` transparently falls back to OCAPI in auto mode (SCAPI Users PATCH does not support the `disabled` flag). SCAPI is only selected when authentication can request the required scopes — stateless OAuth (client-credentials or JWT bearer). Stateful (stored-session) and implicit flows hold a fixed-scope token and cannot request SCAPI scopes, so they use OCAPI; this applies to `--api-backend scapi` too, which fails with a clear error for those flows rather than silently using an under-scoped token. +Migrate `job`, `code`, `bm users`, `bm roles`, `sites`, and catalog discovery to SCAPI-first operation with a temporary OCAPI compatibility fallback. `auto` tries SCAPI when its coordinates and stateless authentication are available, pins the selected backend for multi-request operations, and falls back only on safe capability/auth/request rejections. Site cartridge-path writes, portable BM user search, disabled-user updates, system-job triggers, SDK/CLI/MCP code-version discovery, and VS Code jobs/code/catalog surfaces now participate. Inventory-list enumeration, BM `whoami`, access-key administration, raw OCAPI user-search JSON, and running-job cancellation remain explicit OCAPI compatibility operations because the current live SCAPI schemas have no equivalent. -For SDK consumers, `B2CInstance` now carries the SCAPI coordinates itself: a `B2CInstance.scapiClientConfig` getter returns `{shortCode, tenantId, auth}` (or `undefined` when the instance can't reach SCAPI), and `B2CInstance.apiBackend` exposes the configured preference. The dual-backend factories (`createSitesBackend`, `createScriptsBackend`, `createUsersBackend`, `createRolesBackend`) now take just `{instance}` and source SCAPI config from it — so SCAPI operations need nothing beyond a configured instance. +`setup instance create` accepts optional SCAPI coordinates for SCAPI-first active-code-version detection. They are not required in `auto`; missing coordinates select OCAPI, and failed interactive detection reports the reason before allowing manual entry. -Site discovery used by other flows now goes through the same SCAPI-first sites backend (with OCAPI fallback): `cap list`, scaffold site parameters, `job export` data-unit discovery, and the VS Code extension's cartridge-path site picker no longer hard-depend on OCAPI. `code download` active-version auto-discovery also honors the backend preference. Catalog / inventory-list discovery and BM `user search` / `whoami` / access-key operations remain OCAPI-only (no SCAPI equivalent exists). +This is a major release because SCAPI and OCAPI JSON/results intentionally retain their backend-specific shapes. Consumers that require a stable legacy shape must explicitly select OCAPI or use the exported compatibility/fallback primitives during the migration. SDK high-level code helpers accept an explicit scripts backend; dual-backend factories and `JobsCompatibilityBackend` expose reusable fallback without making implicit backend selection an SDK-wide policy. + +SCAPI currently requires client-credentials or JWT Bearer authentication. Browser-based user auth continues through OCAPI/WebDAV and is selected by `auto`; explicit SCAPI with user auth errors clearly until the platform adds support. diff --git a/docs/cli/auth.md b/docs/cli/auth.md index 68cea07bf..67b9a6491 100644 --- a/docs/cli/auth.md +++ b/docs/cli/auth.md @@ -10,7 +10,7 @@ Commands for authentication and token management. The CLI supports **stateful auth** (session stored on disk) in addition to **stateless auth** (client credentials or one-off browser login): -- **Stateful (browser)**: After you run `b2c auth login`, your access token *and* a long-lived refresh token are stored on disk in the CLI data directory. Subsequent commands silently refresh the access token without re-prompting. If both tokens are missing/expired, the CLI falls back to stateless auth. +- **Stateful (browser)**: After you run `b2c auth login`, your access token _and_ a long-lived refresh token are stored on disk in the CLI data directory. Subsequent commands silently refresh the access token without re-prompting. If both tokens are missing/expired, the CLI falls back to stateless auth. - **Stateful (client credentials)**: Use `b2c auth client` to authenticate with client ID and secret (or user/password) for non-interactive/automation use. Only the access token is persisted — the client secret is never stored. When the access token expires, re-run `b2c auth client` with the same credentials. There is no automatic refresh. - **Stateless**: You provide `--client-id` (and optionally `--client-secret`) per run or via environment/config; no session is persisted. @@ -45,11 +45,11 @@ After a successful login, subsequent commands reuse and refresh the stored token ### Flags -| Flag | Environment Variable | Description | -|------|---------------------|-------------| -| `--account-manager-host` | `SFCC_ACCOUNT_MANAGER_HOST` | Account Manager hostname | -| `--auth-scope` | `SFCC_OAUTH_SCOPES` | OAuth scopes to request (can be repeated) | -| `--auth-methods` | `SFCC_AUTH_METHODS` | Browser-based flow to use: `user` (default — Authorization Code + PKCE) or `implicit` (deprecated) | +| Flag | Environment Variable | Description | +| ------------------------ | --------------------------- | -------------------------------------------------------------------------------------------------- | +| `--account-manager-host` | `SFCC_ACCOUNT_MANAGER_HOST` | Account Manager hostname | +| `--auth-scope` | `SFCC_OAUTH_SCOPES` | OAuth scopes to request (can be repeated) | +| `--auth-methods` | `SFCC_AUTH_METHODS` | Browser-based flow to use: `user` (default — Authorization Code + PKCE) or `implicit` (deprecated) | ### Choosing a flow @@ -100,19 +100,20 @@ b2c auth client --client-id --client-secret --grant-type client_cr ### Flags -| Flag | Environment Variable | Description | -|------|---------------------|-------------| -| `--client-id` | `SFCC_CLIENT_ID` | Client ID (required) | -| `--client-secret` | `SFCC_CLIENT_SECRET` | Client secret (required) | -| `--grant-type` / `-t` | | Force grant type: `client_credentials` or `password` | -| `--user` | `SFCC_OAUTH_USER_NAME` | Username for password grant | -| `--user-password` | `SFCC_OAUTH_USER_PASSWORD` | Password for password grant | -| `--auth-scope` | `SFCC_OAUTH_SCOPES` | OAuth scopes to request | -| `--account-manager-host` | `SFCC_ACCOUNT_MANAGER_HOST` | Account Manager hostname | +| Flag | Environment Variable | Description | +| ------------------------ | --------------------------- | ---------------------------------------------------- | +| `--client-id` | `SFCC_CLIENT_ID` | Client ID (required) | +| `--client-secret` | `SFCC_CLIENT_SECRET` | Client secret (required) | +| `--grant-type` / `-t` | | Force grant type: `client_credentials` or `password` | +| `--user` | `SFCC_OAUTH_USER_NAME` | Username for password grant | +| `--user-password` | `SFCC_OAUTH_USER_PASSWORD` | Password for password grant | +| `--auth-scope` | `SFCC_OAUTH_SCOPES` | OAuth scopes to request | +| `--account-manager-host` | `SFCC_ACCOUNT_MANAGER_HOST` | Account Manager hostname | ### Grant type auto-detection If `--grant-type` is not specified: + - **client_credentials** is used when only `--client-id` and `--client-secret` are provided - **password** is used when `--user` and `--user-password` are also provided @@ -176,19 +177,19 @@ b2c auth token ### Flags -| Flag | Environment Variable | Description | -|------|---------------------|-------------| -| `--client-id` | `SFCC_CLIENT_ID` | Client ID for OAuth | -| `--client-secret` | `SFCC_CLIENT_SECRET` | Client Secret for OAuth | -| `--auth-scope` | `SFCC_OAUTH_SCOPES` | OAuth scopes to request (can be repeated) | -| `--account-manager-host` | `SFCC_ACCOUNT_MANAGER_HOST` | Account Manager hostname (default: account.demandware.com) | -| `--short-code` | `SFCC_SHORTCODE` | SCAPI short code | -| `--tenant-id` | `SFCC_TENANT_ID` | Organization/tenant ID | -| `--auth-methods` | `SFCC_AUTH_METHODS` | Allowed auth methods in priority order (comma-separated): client-credentials, jwt, user, implicit, basic, api-key | -| `--user-auth` | | Use browser-based user authentication (Authorization Code + PKCE flow) | -| `--jwt-cert` | `SFCC_JWT_CERT` | Path to JWT certificate file (cert.pem) for JWT Bearer authentication | -| `--jwt-key` | `SFCC_JWT_KEY` | Path to JWT private key file (key.pem) for JWT Bearer authentication | -| `--jwt-passphrase` | `SFCC_JWT_PASSPHRASE` | Passphrase for encrypted JWT private key | +| Flag | Environment Variable | Description | +| ------------------------ | --------------------------- | ----------------------------------------------------------------------------------------------------------------- | +| `--client-id` | `SFCC_CLIENT_ID` | Client ID for OAuth | +| `--client-secret` | `SFCC_CLIENT_SECRET` | Client Secret for OAuth | +| `--auth-scope` | `SFCC_OAUTH_SCOPES` | OAuth scopes to request (can be repeated) | +| `--account-manager-host` | `SFCC_ACCOUNT_MANAGER_HOST` | Account Manager hostname (default: account.demandware.com) | +| `--short-code` | `SFCC_SHORTCODE` | SCAPI short code | +| `--tenant-id` | `SFCC_TENANT_ID` | Organization/tenant ID | +| `--auth-methods` | `SFCC_AUTH_METHODS` | Allowed auth methods in priority order (comma-separated): client-credentials, jwt, user, implicit, basic, api-key | +| `--user-auth` | | Use browser-based user authentication (Authorization Code + PKCE flow) | +| `--jwt-cert` | `SFCC_JWT_CERT` | Path to JWT certificate file (cert.pem) for JWT Bearer authentication | +| `--jwt-key` | `SFCC_JWT_KEY` | Path to JWT private key file (key.pem) for JWT Bearer authentication | +| `--jwt-passphrase` | `SFCC_JWT_PASSPHRASE` | Passphrase for encrypted JWT private key | ### Examples @@ -219,7 +220,7 @@ eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9... With `--json`: ```json -{"token":"eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...","expires_in":1799} +{"token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...", "expires_in": 1799} ``` ### Use Cases @@ -257,15 +258,15 @@ For complete authentication setup instructions, see the [Authentication Setup Gu ### Quick Reference -| Operation | Auth Required | -|-----------|--------------| -| [Code](/cli/code) deploy/watch | WebDAV credentials | -| [Code](/cli/code) list/activate/delete, [Jobs](/cli/jobs), [BM](/cli/bm) users/roles | OAuth + SCAPI scopes (OCAPI fallback; OCAPI is [deprecated](/guide/authentication#ocapi-configuration)) | -| [Sites](/cli/sites) list/cartridge reads | OAuth + SCAPI scopes (`sfcc.sites`; OCAPI fallback) | -| [Sites](/cli/sites) cartridge-path writes | OCAPI / site-archive import (no SCAPI equivalent) | -| SCAPI commands ([eCDN](/cli/ecdn), [schemas](/cli/scapi-schemas), [custom-apis](/cli/custom-apis)) | OAuth + SCAPI scopes | -| [Sandbox](/cli/sandbox), [SLAS](/cli/slas) | OAuth + appropriate roles | -| [MRT](/cli/mrt) | API Key | +| Operation | Auth Required | +| -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | +| [Code](/cli/code) deploy/watch | WebDAV credentials | +| [Code](/cli/code) list/activate/delete, [Jobs](/cli/jobs), [BM](/cli/bm) users/roles | OAuth + SCAPI scopes (OCAPI fallback; OCAPI is [deprecated](/guide/authentication#ocapi-configuration)) | +| [Sites](/cli/sites) list/cartridge reads | OAuth + SCAPI scopes (`sfcc.sites`; OCAPI fallback) | +| [Sites](/cli/sites) cartridge-path writes | OAuth + `sfcc.sites.rw` (OCAPI / site-archive fallback) | +| SCAPI commands ([eCDN](/cli/ecdn), [schemas](/cli/scapi-schemas), [custom-apis](/cli/custom-apis)) | OAuth + SCAPI scopes | +| [Sandbox](/cli/sandbox), [SLAS](/cli/slas) | OAuth + appropriate roles | +| [MRT](/cli/mrt) | API Key | See [Configuration](/guide/configuration) for setting up credentials via environment variables or config files. diff --git a/docs/cli/bm.md b/docs/cli/bm.md index 40b6de69e..83903fb9d 100644 --- a/docs/cli/bm.md +++ b/docs/cli/bm.md @@ -8,7 +8,7 @@ Commands for administering instance-level Business Manager resources. These are ## API Backend -`bm users` and `bm roles` run over the SCAPI Merchant Users / Merchant Roles APIs. Configure `shortCode`, `tenantId`, and the `sfcc.users(.rw)` / `sfcc.roles(.rw)` scopes on your API client and these commands work over SCAPI. A few commands have no SCAPI equivalent and use the OCAPI Data API (see the table below). +`bm users` and `bm roles` run over the SCAPI Merchant Users / Merchant Roles APIs. Configure `shortCode`, `tenantId`, and the `sfcc.users(.rw)` / `sfcc.roles(.rw)` scopes to use SCAPI. In `auto` mode, missing SCAPI coordinates select the temporary OCAPI compatibility backend instead of being required up front. ```bash # Default — uses SCAPI for users/roles @@ -16,46 +16,45 @@ b2c bm users list b2c bm roles get Administrator ``` -| Command | Backend | Scope | -|---|---|---| -| `bm users list/get/create/update/delete` | SCAPI | `sfcc.users.rw` | -| `bm roles list/get/create/delete` | SCAPI | `sfcc.roles.rw` | -| `bm roles grant/revoke` | SCAPI | `sfcc.roles.rw` | -| `bm roles permissions get/set` | SCAPI | `sfcc.roles.rw` | -| `bm users search` | OCAPI only | — | -| `bm whoami` | OCAPI only | — | -| `bm access-key *` | OCAPI only | — | +| Command | Backend | Scope | +| ---------------------------------------- | -------------------------------------------- | ------------------------------- | +| `bm users list/get/create/update/delete` | SCAPI | `sfcc.users.rw` | +| `bm users search` (portable flags) | SCAPI, client-side filtering over user pages | `sfcc.users` or `sfcc.users.rw` | +| `bm roles list/get/create/delete` | SCAPI | `sfcc.roles.rw` | +| `bm roles grant/revoke` | SCAPI | `sfcc.roles.rw` | +| `bm roles permissions get/set` | SCAPI | `sfcc.roles.rw` | +| `bm users search --query` | OCAPI only (raw OCAPI query DSL) | — | +| `bm whoami` | OCAPI only | — | +| `bm access-key *` | OCAPI only | — | ::: details Legacy OCAPI backend (deprecated) -OCAPI is deprecated and disabled on newer instances. The CLI defaults to `--api-backend auto`, which falls back to the OCAPI Data API only when SCAPI scopes are not configured. Force a backend if needed: +OCAPI is deprecated and disabled on newer instances. The CLI defaults to `--api-backend auto`, which falls back to OCAPI on safe SCAPI capability/auth/request rejections. Force a backend if needed: ```bash b2c bm users list --api-backend scapi # force SCAPI b2c bm roles get Administrator --api-backend ocapi # force the legacy OCAPI backend ``` -Or set `"api-backend": "scapi"` in `dw.json`, or `SFCC_API_BACKEND=scapi`. The OCAPI-only commands (`bm users search`, `bm whoami`, `bm access-key`) are unavailable on OCAPI-disabled instances. +Or set `"api-backend": "scapi"` in `dw.json`, or `SFCC_API_BACKEND=scapi`. The OCAPI-only operations (`bm users search --query`, `bm whoami`, `bm access-key`) are unavailable on OCAPI-disabled instances. ::: -::: warning -The SCAPI Users PATCH endpoint does not support changing the `disabled` flag. `bm users update --disabled` falls back to OCAPI in auto mode (unavailable on OCAPI-disabled instances); with `--api-backend scapi` it errors with a clear message. -::: +The SCAPI Users PATCH endpoint does not include the `disabled` field, so `bm users update --disabled` reads the current user and preserves its writable fields through SCAPI PUT. ## Authentication -BM commands authenticate via OAuth against the configured Commerce Cloud instance. Two flows are supported: +BM commands authenticate via OAuth against the configured Commerce Cloud instance. SCAPI currently supports client credentials and JWT Bearer for these commands. Browser-based user auth remains supported through OCAPI and WebDAV, not SCAPI: - **Client credentials** — for automation and CI/CD. Configure an Account Manager API client and grant it the OCAPI permissions listed below. Pass credentials via `--client-id` / `--client-secret`, the `SFCC_CLIENT_ID` / `SFCC_CLIENT_SECRET` environment variables, or `dw.json`. -- **User auth (browser)** — for interactive use. Pass `--user-auth` (or run `b2c auth login` once and reuse the saved session). The CLI opens a browser and the resulting token carries your BM user identity. +- **User auth (browser)** — for interactive OCAPI/WebDAV use. Pass `--user-auth` (or run `b2c auth login` once and reuse the saved session). In `auto` mode migrated operations select OCAPI; explicit SCAPI reports that user auth is not currently supported. -A handful of endpoints require *a real BM user identity* and cannot use service-client tokens — the CLI defaults those to user-auth automatically: +A handful of endpoints require _a real BM user identity_ and cannot use service-client tokens — the CLI defaults those to user-auth automatically: -| Command group | Default auth | Why | -|---|---|---| -| `b2c bm roles ...` | client-credentials → jwt → implicit | OCAPI permissions for `/roles` | -| `b2c bm users {list,get,search,update,delete}` | client-credentials → jwt → implicit | OCAPI permissions for `/users` | -| `b2c bm whoami` | **implicit (browser)** | `/users/this` requires the token to resolve to a BM user | -| `b2c bm access-key ...` | **implicit (browser)** | Access-key endpoints require *a valid user* plus the `Manage_Users_Access_Keys` BM functional permission | +| Command group | Default auth | Why | +| ---------------------------------------------- | ----------------------------------- | -------------------------------------------------------------------------------------------------------- | +| `b2c bm roles ...` | client-credentials → jwt → implicit | OCAPI permissions for `/roles` | +| `b2c bm users {list,get,search,update,delete}` | client-credentials → jwt → implicit | OCAPI permissions for `/users` | +| `b2c bm whoami` | **implicit (browser)** | `/users/this` requires the token to resolve to a BM user | +| `b2c bm access-key ...` | **implicit (browser)** | Access-key endpoints require _a valid user_ plus the `Manage_Users_Access_Keys` BM functional permission | Override the auto-defaulted user-auth with `--auth-methods client-credentials` (or `--client-secret`) when your service-client setup is configured to issue user-bearing tokens. The interactive defaults can also be skipped end-to-end by exporting `SFCC_AUTH_METHODS=client-credentials,jwt` in CI. @@ -65,18 +64,18 @@ See the [Authentication Guide](/guide/authentication) for end-to-end setup, incl Add these resources to the Data API client configuration in Business Manager (**Administration** > **Site Development** > **Open Commerce API Settings** > **Data API**): -| Resource | Methods | Used by | -|----------|---------|---------| -| `/roles` | GET | `bm roles list` | -| `/roles/*` | GET, PUT, DELETE | `bm roles get/create/delete` | -| `/roles/*/users` | GET | `bm roles get --expand users` | -| `/roles/*/users/*` | PUT, DELETE | `bm roles grant/revoke` | -| `/roles/*/permissions` | GET, PUT | `bm roles permissions get/set` | -| `/users` | GET | `bm users list` | -| `/users/*` | GET, PATCH, DELETE | `bm users get/update/delete` | -| `/users/this` | GET | `bm whoami`, `bm access-key` (optional login fallback) | -| `/users/*/access_key/*` | GET, PUT, PATCH, DELETE | `bm access-key get/create/set/delete` | -| `/user_search` | POST | `bm users search` | +| Resource | Methods | Used by | +| ----------------------- | ----------------------- | ------------------------------------------------------ | +| `/roles` | GET | `bm roles list` | +| `/roles/*` | GET, PUT, DELETE | `bm roles get/create/delete` | +| `/roles/*/users` | GET | `bm roles get --expand users` | +| `/roles/*/users/*` | PUT, DELETE | `bm roles grant/revoke` | +| `/roles/*/permissions` | GET, PUT | `bm roles permissions get/set` | +| `/users` | GET | `bm users list` | +| `/users/*` | GET, PATCH, DELETE | `bm users get/update/delete` | +| `/users/this` | GET | `bm whoami`, `bm access-key` (optional login fallback) | +| `/users/*/access_key/*` | GET, PUT, PATCH, DELETE | `bm access-key get/create/set/delete` | +| `/user_search` | POST | `bm users search` | For an importable JSON snippet covering all BM administration endpoints, see [Minimal Configuration by Feature](/guide/authentication#minimal-configuration-by-feature) in the Authentication Guide. @@ -118,12 +117,12 @@ List all access roles on an instance. b2c bm roles list [--count ] [--start ] [--columns ] [--extended] ``` -| Flag | Description | -|------|-------------| -| `--count`, `-n` | Number of roles to return (default 25) | -| `--start` | Start index for pagination (default 0) | -| `--columns`, `-c` | Comma-separated columns to display. Available: `id`, `description`, `userCount`, `userManager` | -| `--extended`, `-x` | Show all columns including extended fields | +| Flag | Description | +| ------------------ | ---------------------------------------------------------------------------------------------- | +| `--count`, `-n` | Number of roles to return (default 25) | +| `--start` | Start index for pagination (default 0) | +| `--columns`, `-c` | Comma-separated columns to display. Available: `id`, `description`, `userCount`, `userManager` | +| `--extended`, `-x` | Show all columns including extended fields | ```bash b2c bm roles list @@ -139,12 +138,12 @@ Get details of a specific access role. b2c bm roles get [--expand ...] ``` -| Argument | Description | -|----------|-------------| -| `role` | Role ID (e.g. `Administrator`) | +| Argument | Description | +| -------- | ------------------------------ | +| `role` | Role ID (e.g. `Administrator`) | -| Flag | Description | -|------|-------------| +| Flag | Description | +| ---------------- | --------------------------------------------------------- | | `--expand`, `-e` | Expansions to apply (`users`, `permissions`). Repeatable. | ```bash @@ -160,12 +159,12 @@ Create a new custom access role. b2c bm roles create [--description ] ``` -| Argument | Description | -|----------|-------------| -| `role` | Role ID to create | +| Argument | Description | +| -------- | ----------------- | +| `role` | Role ID to create | -| Flag | Description | -|------|-------------| +| Flag | Description | +| --------------------- | ------------------------ | | `--description`, `-d` | Description for the role | ```bash @@ -200,8 +199,8 @@ Assign a user to an access role. b2c bm roles grant --role ``` -| Flag | Description | -|------|-------------| +| Flag | Description | +| -------------- | --------------------------- | | `--role`, `-r` | Role ID to grant (required) | ```bash @@ -228,8 +227,8 @@ Get permissions for an access role. b2c bm roles permissions get [--output ] ``` -| Flag | Description | -|------|-------------| +| Flag | Description | +| ---------------- | ------------------------------------------------- | | `--output`, `-o` | Write full permissions JSON to a file for editing | ```bash @@ -251,8 +250,8 @@ Set (replace) all permissions for an access role from a JSON file. b2c bm roles permissions set --file ``` -| Flag | Description | -|------|-------------| +| Flag | Description | +| -------------- | ----------------------------------------------------------------------- | | `--file`, `-f` | JSON file containing permissions (`role_permissions` schema) (required) | ```bash @@ -295,7 +294,7 @@ The file follows the OCAPI `role_permissions` schema with four sections: `b2c bm users` — query and manage instance-level Business Manager users via the OCAPI `/users` resource. ::: warning Local user creation is often disabled -`b2c bm users create` performs a create-or-replace. Most production instances use SSO with Account Manager and **reject creating *local* BM users** — the server responds with `LocalUserCreationException` ("creation of a local Business Manager user is not allowed with the current server settings"). Creation succeeds only when the instance is explicitly configured to allow local users; otherwise use Account Manager to provision users and manage them here (read/update/delete/search) plus access-key administration. +`b2c bm users create` performs a create-or-replace. Most production instances use SSO with Account Manager and **reject creating _local_ BM users** — the server responds with `LocalUserCreationException` ("creation of a local Business Manager user is not allowed with the current server settings"). Creation succeeds only when the instance is explicitly configured to allow local users; otherwise use Account Manager to provision users and manage them here (read/update/delete/search) plus access-key administration. ::: ### b2c bm users list @@ -306,12 +305,12 @@ List all users on the instance. b2c bm users list [--count ] [--start ] [--columns ] [--extended] ``` -| Flag | Description | -|------|-------------| -| `--count`, `-n` | Number of users to return (default 25) | -| `--start` | Start index for pagination (default 0) | -| `--columns`, `-c` | Comma-separated columns to display. Available: `login`, `email`, `name`, `disabled`, `locked`, `lastLogin`, `externalId` | -| `--extended`, `-x` | Include extended columns (`lastLogin`, `externalId`) | +| Flag | Description | +| ------------------ | ------------------------------------------------------------------------------------------------------------------------ | +| `--count`, `-n` | Number of users to return (default 25) | +| `--start` | Start index for pagination (default 0) | +| `--columns`, `-c` | Comma-separated columns to display. Available: `login`, `email`, `name`, `disabled`, `locked`, `lastLogin`, `externalId` | +| `--extended`, `-x` | Include extended columns (`lastLogin`, `externalId`) | ```bash b2c bm users list @@ -343,20 +342,20 @@ b2c bm users search [--search-phrase ] [--login ] [--email ] [--query ] [--count ] [--start ] [--columns ] [--extended] ``` -| Flag | Description | -|------|-------------| -| `--search-phrase` | Free-text phrase searched across login/email/first_name/last_name | -| `--login` | Match a specific login | -| `--email` | Match a specific email | -| `--locked` / `--no-locked` | Match locked / unlocked users | -| `--disabled` / `--no-disabled` | Match disabled / enabled users | -| `--sort-by` | Sort field (e.g. `last_login_date`) | -| `--sort-order` | `asc` or `desc` | -| `--query` | Raw OCAPI query JSON (overrides convenience flags) | -| `--count`, `-n` | Number of users to return (default 25) | -| `--start` | Start index for pagination (default 0) | -| `--columns`, `-c` | Comma-separated columns to display. Available: `login`, `email`, `name`, `disabled`, `locked`, `lastLogin`, `externalId` | -| `--extended`, `-x` | Include extended columns (`lastLogin`, `externalId`) | +| Flag | Description | +| ------------------------------ | ------------------------------------------------------------------------------------------------------------------------ | +| `--search-phrase` | Free-text phrase searched across login/email/first_name/last_name | +| `--login` | Match a specific login | +| `--email` | Match a specific email | +| `--locked` / `--no-locked` | Match locked / unlocked users | +| `--disabled` / `--no-disabled` | Match disabled / enabled users | +| `--sort-by` | Sort field (e.g. `last_login_date`) | +| `--sort-order` | `asc` or `desc` | +| `--query` | Raw OCAPI query JSON (overrides convenience flags) | +| `--count`, `-n` | Number of users to return (default 25) | +| `--start` | Start index for pagination (default 0) | +| `--columns`, `-c` | Comma-separated columns to display. Available: `login`, `email`, `name`, `disabled`, `locked`, `lastLogin`, `externalId` | +| `--extended`, `-x` | Include extended columns (`lastLogin`, `externalId`) | ```bash b2c bm users search --search-phrase smith @@ -411,8 +410,8 @@ Remove a user from the instance. Prompts for confirmation by default. b2c bm users delete [--force] ``` -| Flag | Description | -|------|-------------| +| Flag | Description | +| --------- | ---------------------------- | | `--force` | Skip the confirmation prompt | ```bash @@ -451,11 +450,11 @@ This command defaults to browser-based user-auth — a fresh shell triggers `b2c ### Scopes -| Scope | Used for | -|---|---| +| Scope | Used for | +| ----------------------------- | ----------------------------------------------------- | | `WEBDAV_AND_STUDIO` (default) | WebDAV uploads (cartridge sync, IMPEX), Studio access | -| `AGENT_USER_AND_OCAPI` | Customer Service Center (CSC) and OCAPI Basic auth | -| `STOREFRONT` | Storefront diagnostic / agent login passwords | +| `AGENT_USER_AND_OCAPI` | Customer Service Center (CSC) and OCAPI Basic auth | +| `STOREFRONT` | Storefront diagnostic / agent login passwords | ### b2c bm access-key get @@ -465,12 +464,12 @@ Get the current state of an access key. b2c bm access-key get [] [--scope ] ``` -| Argument | Description | -|----------|-------------| +| Argument | Description | +| --------- | ----------------------------------------------------------------- | | `[login]` | User login (email). Defaults to the currently authenticated user. | -| Flag | Description | -|------|-------------| +| Flag | Description | +| --------- | -------------------------------------------------------------------------- | | `--scope` | One of `WEBDAV_AND_STUDIO` (default), `AGENT_USER_AND_OCAPI`, `STOREFRONT` | ```bash @@ -505,8 +504,8 @@ Enable or disable an existing access key. b2c bm access-key set [] [--scope ] (--enabled | --no-enabled) ``` -| Flag | Description | -|------|-------------| +| Flag | Description | +| ---------------------------- | ------------------------------------ | | `--enabled` / `--no-enabled` | Enable or disable the key (required) | ```bash @@ -523,8 +522,8 @@ Delete an access key. Prompts for confirmation by default. b2c bm access-key delete [] [--scope ] [--force] ``` -| Flag | Description | -|------|-------------| +| Flag | Description | +| --------- | ---------------------------- | | `--force` | Skip the confirmation prompt | ```bash diff --git a/docs/cli/code.md b/docs/cli/code.md index e6d8db108..3e407148a 100644 --- a/docs/cli/code.md +++ b/docs/cli/code.md @@ -16,7 +16,7 @@ b2c code list ``` ::: details Legacy OCAPI backend (deprecated) -OCAPI is deprecated and disabled on newer instances. The CLI defaults to `--api-backend auto`, which falls back to the OCAPI Data API (`/code_versions`) only when SCAPI scopes are not configured. You can force a backend if needed: +OCAPI is deprecated and disabled on newer instances. The CLI defaults to `--api-backend auto`, which falls back to the OCAPI Data API (`/code_versions`) on safe SCAPI capability/auth/request rejections. You can force a backend if needed: ```bash b2c code list --api-backend scapi # force SCAPI @@ -36,10 +36,10 @@ The `code deploy`, `code download`, and `code watch` commands always use WebDAV Code commands use different authentication depending on the operation: -| Operation | Auth Required | -|-----------|--------------| -| `code deploy`, `code download`, `code watch` | WebDAV (Basic Auth or OAuth) | -| `code list`, `code activate`, `code delete` | OAuth + `sfcc.scripts` (read) or `sfcc.scripts.rw` (write) + tenant scope | +| Operation | Auth Required | +| -------------------------------------------- | ------------------------------------------------------------------------- | +| `code deploy`, `code download`, `code watch` | WebDAV (Basic Auth or OAuth) | +| `code list`, `code activate`, `code delete` | OAuth + `sfcc.scripts` (read) or `sfcc.scripts.rw` (write) + tenant scope | ### WebDAV Operations (deploy, download, watch) @@ -79,10 +79,10 @@ b2c code list In addition to [global instance and authentication flags](./index#global-flags): -| Flag | Description | Default | -|------|-------------|---------| -| `--columns`, `-c` | Columns to display (comma-separated). Available: id, active, rollback, lastModified, cartridges | All columns | -| `--extended`, `-x` | Show all columns including extended fields | `false` | +| Flag | Description | Default | +| ------------------ | ----------------------------------------------------------------------------------------------- | ----------- | +| `--columns`, `-c` | Columns to display (comma-separated). Available: id, active, rollback, lastModified, cartridges | All columns | +| `--extended`, `-x` | Show all columns including extended fields | `false` | ### Examples @@ -136,21 +136,21 @@ b2c code deploy [CARTRIDGEPATH] ### Arguments -| Argument | Description | Default | -|----------|-------------|---------| +| Argument | Description | Default | +| --------------- | ----------------------------- | ----------------------- | | `CARTRIDGEPATH` | Path to search for cartridges | `.` (current directory) | ### Flags In addition to [global flags](./index#global-flags): -| Flag | Description | Default | -|------|-------------|---------| -| `--activate`, `-a` | Activate code version after deploy | `false` | -| `--reload`, `-r` | Reload (toggle activation to force reload) code version after deploy | `false` | -| `--delete` | Delete existing cartridges before upload | `false` | -| `--cartridge`, `-c` | Include specific cartridge(s) (comma-separated or repeated) | | -| `--exclude-cartridge`, `-x` | Exclude specific cartridge(s) (comma-separated or repeated) | | +| Flag | Description | Default | +| --------------------------- | -------------------------------------------------------------------- | ------- | +| `--activate`, `-a` | Activate code version after deploy | `false` | +| `--reload`, `-r` | Reload (toggle activation to force reload) code version after deploy | `false` | +| `--delete` | Delete existing cartridges before upload | `false` | +| `--cartridge`, `-c` | Include specific cartridge(s) (comma-separated or repeated) | | +| `--exclude-cartridge`, `-x` | Exclude specific cartridge(s) (comma-separated or repeated) | | ### Examples @@ -206,20 +206,20 @@ b2c code download [CARTRIDGEPATH] ### Arguments -| Argument | Description | Default | -|----------|-------------|---------| +| Argument | Description | Default | +| --------------- | ---------------------------------------------------------- | ----------------------- | | `CARTRIDGEPATH` | Path to search for local cartridges (used with `--mirror`) | `.` (current directory) | ### Flags In addition to [global flags](./index#global-flags): -| Flag | Description | Default | -|------|-------------|---------| -| `--output`, `-o` | Output directory for downloaded cartridges | `cartridges` | -| `--mirror`, `-m` | Extract cartridges to their local project locations | `false` | -| `--cartridge`, `-c` | Include specific cartridge(s) (comma-separated or repeated) | | -| `--exclude-cartridge`, `-x` | Exclude specific cartridge(s) (comma-separated or repeated) | | +| Flag | Description | Default | +| --------------------------- | ----------------------------------------------------------- | ------------ | +| `--output`, `-o` | Output directory for downloaded cartridges | `cartridges` | +| `--mirror`, `-m` | Extract cartridges to their local project locations | `false` | +| `--cartridge`, `-c` | Include specific cartridge(s) (comma-separated or repeated) | | +| `--exclude-cartridge`, `-x` | Exclude specific cartridge(s) (comma-separated or repeated) | | **Note:** The `--mirror` and `--output` flags are mutually exclusive. You must use one or the other, not both. Use `--output` to extract all cartridges to a single directory, or use `--mirror` to extract each cartridge to its local project location. @@ -278,16 +278,16 @@ b2c code activate [CODEVERSION] ### Arguments -| Argument | Description | Required | -|----------|-------------|----------| +| Argument | Description | Required | +| ------------- | --------------------------- | ------------------------------- | | `CODEVERSION` | Code version ID to activate | No (required unless `--reload`) | ### Flags In addition to [global flags](./index#global-flags): -| Flag | Description | Default | -|------|-------------|---------| +| Flag | Description | Default | +| ---------------- | ----------------------------------------------------------- | ------- | | `--reload`, `-r` | Reload the code version (toggle activation to force reload) | `false` | ### Examples @@ -327,16 +327,16 @@ b2c code delete CODEVERSION ### Arguments -| Argument | Description | Required | -|----------|-------------|----------| -| `CODEVERSION` | Code version ID to delete | Yes | +| Argument | Description | Required | +| ------------- | ------------------------- | -------- | +| `CODEVERSION` | Code version ID to delete | Yes | ### Flags In addition to [global flags](./index#global-flags): -| Flag | Description | Default | -|------|-------------|---------| +| Flag | Description | Default | +| --------------- | ------------------------ | ------- | | `--force`, `-f` | Skip confirmation prompt | `false` | ### Examples @@ -370,17 +370,17 @@ b2c code watch [CARTRIDGEPATH] ### Arguments -| Argument | Description | Default | -|----------|-------------|---------| +| Argument | Description | Default | +| --------------- | ----------------------------- | ----------------------- | | `CARTRIDGEPATH` | Path to search for cartridges | `.` (current directory) | ### Flags In addition to [global flags](./index#global-flags): -| Flag | Description | -|------|-------------| -| `--cartridge`, `-c` | Include specific cartridge(s) (comma-separated or repeated) | +| Flag | Description | +| --------------------------- | ----------------------------------------------------------- | +| `--cartridge`, `-c` | Include specific cartridge(s) (comma-separated or repeated) | | `--exclude-cartridge`, `-x` | Exclude specific cartridge(s) (comma-separated or repeated) | ### Examples @@ -417,7 +417,6 @@ Press `Ctrl+C` to stop watching. ### Environment Variables -| Variable | Description | -|----------|-------------| +| Variable | Description | +| --------------------------- | -------------------------------------------- | | `SFCC_UPLOAD_DEBOUNCE_TIME` | Debounce time in milliseconds (default: 100) | - diff --git a/docs/cli/jobs.md b/docs/cli/jobs.md index e8b76bca4..65457a991 100644 --- a/docs/cli/jobs.md +++ b/docs/cli/jobs.md @@ -16,7 +16,7 @@ b2c job run my-job ``` ::: details Legacy OCAPI backend (deprecated) -OCAPI is deprecated and disabled on newer instances. The CLI defaults to `--api-backend auto`, which falls back to the OCAPI Data API only when SCAPI scopes are not configured. Force a backend if needed: +OCAPI is deprecated and disabled on newer instances. The CLI defaults to `--api-backend auto`, which falls back to the OCAPI Data API on safe SCAPI capability/auth/request rejections. Force a backend if needed: ```bash b2c job run my-job --api-backend scapi # force SCAPI @@ -36,10 +36,10 @@ The `job import` and `job export` commands trigger the `sfcc-site-archive-import When using SCAPI, your API client needs the appropriate scopes in Account Manager: -| Scope | Operations | -|-------|------------| +| Scope | Operations | +| -------------- | ------------------------------------------------------------- | | `sfcc.jobs.rw` | Execute, delete, search, and get job executions (recommended) | -| `sfcc.jobs` | Search and get job executions (read-only) | +| `sfcc.jobs` | Search and get job executions (read-only) | You also need `shortCode` and `tenantId` configured (in `dw.json` or via flags). @@ -47,11 +47,11 @@ You also need `shortCode` and `tenantId` configured (in `dw.json` or via flags). Configure these resources in Business Manager under **Administration** > **Site Development** > **Open Commerce API Settings**: -| Resource | Methods | Commands | -|----------|---------|----------| -| `/jobs/*/executions` | POST | `job run` | -| `/jobs/*/executions/*` | GET | `job run --wait`, `job wait`, `job log` | -| `/job_execution_search` | POST | `job search`, `job log` | +| Resource | Methods | Commands | +| ----------------------- | ------- | --------------------------------------- | +| `/jobs/*/executions` | POST | `job run` | +| `/jobs/*/executions/*` | GET | `job run --wait`, `job wait`, `job log` | +| `/job_execution_search` | POST | `job search`, `job log` | ### WebDAV Access @@ -85,23 +85,23 @@ b2c job run JOBID ### Arguments -| Argument | Description | Required | -|----------|-------------|----------| -| `JOBID` | Job ID to execute | Yes | +| Argument | Description | Required | +| -------- | ----------------- | -------- | +| `JOBID` | Job ID to execute | Yes | ### Flags In addition to [global flags](./index#global-flags): -| Flag | Description | Default | -|------|-------------|---------| -| `--wait`, `-w` | Wait for job to complete | `false` | -| `--timeout`, `-t` | Timeout in seconds when waiting | No timeout | -| `--poll-interval` | Polling interval in seconds when using `--wait` | `3` | -| `--param`, `-P` | Job parameter in format "name=value" (repeatable) | | -| `--body`, `-B` | Raw JSON request body (for system jobs with non-standard schemas) | | -| `--no-wait-running` | Do not wait for running job to finish before starting | `false` | -| `--show-log` | Show job log on failure | `true` | +| Flag | Description | Default | +| ------------------- | ----------------------------------------------------------------- | ---------- | +| `--wait`, `-w` | Wait for job to complete | `false` | +| `--timeout`, `-t` | Timeout in seconds when waiting | No timeout | +| `--poll-interval` | Polling interval in seconds when using `--wait` | `3` | +| `--param`, `-P` | Job parameter in format "name=value" (repeatable) | | +| `--body`, `-B` | Raw JSON request body (for system jobs with non-standard schemas) | | +| `--no-wait-running` | Do not wait for running job to finish before starting | `false` | +| `--show-log` | Show job log on failure | `true` | Note: `--param` and `--body` are mutually exclusive. @@ -176,20 +176,20 @@ b2c job wait JOBID EXECUTIONID ### Arguments -| Argument | Description | Required | -|----------|-------------|----------| -| `JOBID` | Job ID | Yes | -| `EXECUTIONID` | Execution ID to wait for | Yes | +| Argument | Description | Required | +| ------------- | ------------------------ | -------- | +| `JOBID` | Job ID | Yes | +| `EXECUTIONID` | Execution ID to wait for | Yes | ### Flags In addition to [global flags](./index#global-flags): -| Flag | Description | Default | -|------|-------------|---------| -| `--timeout`, `-t` | Timeout in seconds | No timeout | -| `--poll-interval` | Polling interval in seconds | `3` | -| `--show-log` | Show job log on failure | `true` | +| Flag | Description | Default | +| ----------------- | --------------------------- | ---------- | +| `--timeout`, `-t` | Timeout in seconds | No timeout | +| `--poll-interval` | Polling interval in seconds | `3` | +| `--show-log` | Show job log on failure | `true` | ### Examples @@ -220,16 +220,16 @@ b2c job search In addition to [global flags](./index#global-flags): -| Flag | Description | Default | -|------|-------------|---------| -| `--job-id`, `-j` | Filter by job ID | | -| `--status` | Filter by status (comma-separated: RUNNING,PENDING,OK,ERROR) | | -| `--count`, `-n` | Maximum number of results | `25` | -| `--start` | Starting index for pagination | `0` | -| `--sort-by` | Sort by field (start_time, end_time, job_id, status) | `start_time` | -| `--sort-order` | Sort order (asc, desc) | `desc` | -| `--columns`, `-c` | Columns to display (comma-separated): id, jobId, status, startTime | | -| `--extended`, `-x` | Show all columns including extended fields | `false` | +| Flag | Description | Default | +| ------------------ | ------------------------------------------------------------------ | ------------ | +| `--job-id`, `-j` | Filter by job ID | | +| `--status` | Filter by status (comma-separated: RUNNING,PENDING,OK,ERROR) | | +| `--count`, `-n` | Maximum number of results | `25` | +| `--start` | Starting index for pagination | `0` | +| `--sort-by` | Sort by field (start_time, end_time, job_id, status) | `start_time` | +| `--sort-order` | Sort order (asc, desc) | `desc` | +| `--columns`, `-c` | Columns to display (comma-separated): id, jobId, status, startTime | | +| `--extended`, `-x` | Show all columns including extended fields | `false` | ### Examples @@ -273,17 +273,17 @@ b2c job log JOBID [EXECUTIONID] ### Arguments -| Argument | Description | Required | -|----------|-------------|----------| -| `JOBID` | Job ID | Yes | -| `EXECUTIONID` | Execution ID (if omitted, finds the most recent execution with a log) | No | +| Argument | Description | Required | +| ------------- | --------------------------------------------------------------------- | -------- | +| `JOBID` | Job ID | Yes | +| `EXECUTIONID` | Execution ID (if omitted, finds the most recent execution with a log) | No | ### Flags In addition to [global flags](./index#global-flags): -| Flag | Description | Default | -|------|-------------|---------| +| Flag | Description | Default | +| ---------- | ------------------------------------------------ | ------- | | `--failed` | Find the most recent failed execution with a log | `false` | ### Examples @@ -326,10 +326,10 @@ b2c job execution delete JOBID EXECUTIONID ### Arguments -| Argument | Description | Required | -|----------|-------------|----------| -| `JOBID` | Job ID | Yes | -| `EXECUTIONID` | Execution ID to delete | Yes | +| Argument | Description | Required | +| ------------- | ---------------------- | -------- | +| `JOBID` | Job ID | Yes | +| `EXECUTIONID` | Execution ID to delete | Yes | ### Examples @@ -357,24 +357,24 @@ b2c job import TARGET [PATHS...] ### Arguments -| Argument | Description | Required | -|----------|-------------|----------| -| `TARGET` | Directory, zip file, or remote filename to import | Yes | -| `PATHS...` | Optional subset of files, directories, or glob patterns under `TARGET` to include in the archive. When omitted, the entire directory is archived. Only valid when `TARGET` is a directory. | No | +| Argument | Description | Required | +| ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------- | +| `TARGET` | Directory, zip file, or remote filename to import | Yes | +| `PATHS...` | Optional subset of files, directories, or glob patterns under `TARGET` to include in the archive. When omitted, the entire directory is archived. Only valid when `TARGET` is a directory. | No | ### Flags In addition to [global flags](./index#global-flags): -| Flag | Description | Default | -|------|-------------|---------| -| `--keep-archive`, `-k` | Keep archive on instance after import | `false` | -| `--remote`, `-r` | Target is a filename already on the instance (in Impex/src/instance/) | `false` | -| `--split`, `-s` | Split a large directory import into multiple archive parts to stay under the instance size limit | `false` | -| `--max-size` | Per-archive size limit for `--split` (e.g. `190`, `190mb`, `512kb`; a bare number is MiB) | `190mb` | -| `--timeout`, `-t` | Timeout in seconds | No timeout | -| `--wait`, `-w` | Wait for import job to complete | `true` | -| `--show-log` | Show job log on failure | `true` | +| Flag | Description | Default | +| ---------------------- | ------------------------------------------------------------------------------------------------ | ---------- | +| `--keep-archive`, `-k` | Keep archive on instance after import | `false` | +| `--remote`, `-r` | Target is a filename already on the instance (in Impex/src/instance/) | `false` | +| `--split`, `-s` | Split a large directory import into multiple archive parts to stay under the instance size limit | `false` | +| `--max-size` | Per-archive size limit for `--split` (e.g. `190`, `190mb`, `512kb`; a bare number is MiB) | `190mb` | +| `--timeout`, `-t` | Timeout in seconds | No timeout | +| `--wait`, `-w` | Wait for import job to complete | `true` | +| `--show-log` | Show job log on failure | `true` | ### Examples @@ -448,22 +448,22 @@ b2c job export In addition to [global flags](./index#global-flags): -| Flag | Description | Default | -|------|-------------|---------| -| `--output`, `-o` | Output path for the export | `./export` | -| `--data-units` | Data units JSON configuration | | -| `--site` | Site ID(s) to export (comma-separated, repeatable) | | -| `--site-data` | Site data types to export (comma-separated) | | -| `--global-data` | Global data types to export (comma-separated) | | -| `--catalog` | Catalog ID(s) to export (comma-separated) | | -| `--price-book` | Pricebook ID(s) to export (comma-separated) | | -| `--library` | Library ID(s) to export (comma-separated) | | -| `--inventory-list` | Inventory list ID(s) to export (comma-separated) | | -| `--keep-archive`, `-k` | Keep archive on instance after download | `false` | -| `--no-download` | Do not download archive (implies --keep-archive) | `false` | -| `--zip-only` | Save as zip file without extracting | `false` | -| `--timeout`, `-t` | Timeout in seconds | No timeout | -| `--show-log` | Show job log on failure | `true` | +| Flag | Description | Default | +| ---------------------- | -------------------------------------------------- | ---------- | +| `--output`, `-o` | Output path for the export | `./export` | +| `--data-units` | Data units JSON configuration | | +| `--site` | Site ID(s) to export (comma-separated, repeatable) | | +| `--site-data` | Site data types to export (comma-separated) | | +| `--global-data` | Global data types to export (comma-separated) | | +| `--catalog` | Catalog ID(s) to export (comma-separated) | | +| `--price-book` | Pricebook ID(s) to export (comma-separated) | | +| `--library` | Library ID(s) to export (comma-separated) | | +| `--inventory-list` | Inventory list ID(s) to export (comma-separated) | | +| `--keep-archive`, `-k` | Keep archive on instance after download | `false` | +| `--no-download` | Do not download archive (implies --keep-archive) | `false` | +| `--zip-only` | Save as zip file without extracting | `false` | +| `--timeout`, `-t` | Timeout in seconds | No timeout | +| `--show-log` | Show job log on failure | `true` | ### Examples @@ -497,6 +497,7 @@ The export is configured using "data units" which specify what data to export. Y #### Site Data Types When using `--site-data`, available types include: + - `all` - Export all site data - `content` - Content assets and slots - `site_preferences` - Site preferences @@ -508,6 +509,7 @@ When using `--site-data`, available types include: #### Global Data Types When using `--global-data`, available types include: + - `all` - Export all global data - `meta_data` - System and custom object metadata - `custom_types` - Custom object type definitions @@ -515,4 +517,3 @@ When using `--global-data`, available types include: - `locales` - Locale configurations - `services` - Service configurations - And more (see OCAPI documentation) - diff --git a/docs/cli/setup.md b/docs/cli/setup.md index a57cce3cc..11dfc1420 100644 --- a/docs/cli/setup.md +++ b/docs/cli/setup.md @@ -275,17 +275,20 @@ b2c setup instance create [NAME] [FLAGS] ### Flags -| Flag | Description | Default | -| ------------------ | ---------------------- | ------------------------- | -| `--hostname`, `-s` | B2C instance hostname | Prompted | -| `--username` | WebDAV username | | -| `--password` | WebDAV password | Prompted if username set | -| `--client-id` | OAuth client ID | | -| `--client-secret` | OAuth client secret | Prompted if client-id set | -| `--code-version` | Code version | | -| `--active` | Set as active instance | `false` | -| `--force` | Non-interactive mode | `false` | -| `--json` | Output results as JSON | `false` | +| Flag | Description | Default | +| ------------------ | ----------------------------------------------------------------------- | ------------------------- | +| `--hostname`, `-s` | B2C instance hostname | Prompted | +| `--username` | WebDAV username | | +| `--password` | WebDAV password | Prompted if username set | +| `--client-id` | OAuth client ID | | +| `--client-secret` | OAuth client secret | Prompted if client-id set | +| `--short-code` | SCAPI short code (optional; enables SCAPI-first code-version detection) | | +| `--tenant-id` | SCAPI tenant/organization ID (optional; enables SCAPI-first detection) | | +| `--api-backend` | Saved API preference: `auto`, `scapi`, or `ocapi` | `auto` | +| `--code-version` | Code version | Auto-detected or prompted | +| `--active` | Set as active instance | `false` | +| `--force` | Non-interactive mode | `false` | +| `--json` | Output results as JSON | `false` | ### Examples @@ -311,8 +314,9 @@ When run without `--force`, the command provides an interactive experience: 2. Prompts for hostname (if not provided) 3. Prompts for authentication type (Basic, OAuth, Both, or Skip) 4. Prompts for credentials based on selection -5. Asks whether to set as active instance -6. Shows summary and confirms before creating +5. Tries SCAPI-first/OCAPI-compatible active code-version detection when OAuth is configured, then prompts for manual entry if detection is unavailable +6. Asks whether to set as active instance +7. Shows summary and confirms before creating ## b2c setup instance remove diff --git a/docs/cli/sites.md b/docs/cli/sites.md index b68a97742..d23fa9b05 100644 --- a/docs/cli/sites.md +++ b/docs/cli/sites.md @@ -8,9 +8,9 @@ Commands for managing sites on B2C Commerce instances. ## Authentication -`sites list` and `sites cartridges list` (reads) run over SCAPI (the `site/sites` API). Configure `shortCode`, `tenantId`, and the `sfcc.sites` / `sfcc.sites.rw` scopes on your API client and they work out of the box. +Site reads and cartridge-path writes run over SCAPI (the `site/sites` API). Configure `shortCode`, `tenantId`, and `sfcc.sites` / `sfcc.sites.rw` on your API client to use it. -Cartridge-path **writes** (`add`/`remove`/`set`) have no SCAPI equivalent. They use the OCAPI Data API `/sites/*/cartridges` resource, automatically falling back to site archive import/export when direct OCAPI access is unavailable (which requires job execution permissions for `sfcc-site-archive-import` and WebDAV write access to `Impex/`). +Cartridge-path writes (`add`/`remove`/`set`) require `sfcc.sites.rw`. In `auto` mode they temporarily fall back to the OCAPI Data API and then site archive import/export when direct API access is unavailable. The archive path requires job execution permissions for `sfcc-site-archive-import` and WebDAV write access to `Impex/`. ```bash export SFCC_CLIENT_ID=your-client-id @@ -20,7 +20,7 @@ export SFCC_SHORTCODE=kv7kzm78 ``` ::: details Legacy OCAPI backend (deprecated) -OCAPI is deprecated and disabled on newer instances. The read commands default to `--api-backend auto`, falling back to the OCAPI `/sites` resource only when SCAPI scopes are not configured; force a backend with `--api-backend scapi|ocapi`. For the OCAPI path, grant GET on `/sites` and `/sites/*`, and POST/PUT/DELETE on `/sites/*/cartridges` for cartridge-path writes. +OCAPI is deprecated and disabled on newer instances. Commands default to `--api-backend auto`, falling back on safe SCAPI capability/auth/request rejections; force a backend with `--api-backend scapi|ocapi`. For the OCAPI path, grant GET on `/sites` and `/sites/*`, and POST/PUT/DELETE on `/sites/*/cartridges` for cartridge-path writes. ::: For complete setup instructions, see the [Authentication Guide](/guide/authentication). @@ -105,11 +105,11 @@ b2c sites cartridges list --bm #### Flags -| Flag | Description | -|------|-------------| -| `--site-id ` | Site ID (e.g. `RefArch`) | -| `--bm` | Use Business Manager site (`Sites-Site`) | -| `--json` | Output as JSON | +| Flag | Description | +| ---------------- | ---------------------------------------- | +| `--site-id ` | Site ID (e.g. `RefArch`) | +| `--bm` | Use Business Manager site (`Sites-Site`) | +| `--json` | Output as JSON | One of `--site-id` or `--bm` is required. @@ -140,19 +140,19 @@ b2c sites cartridges add --site-id [--position ] #### Arguments -| Argument | Description | -|----------|-------------| +| Argument | Description | +| ----------- | ---------------------------- | | `cartridge` | Name of the cartridge to add | #### Flags -| Flag | Description | -|------|-------------| -| `--site-id ` | Site ID (e.g. `RefArch`) | -| `--bm` | Use Business Manager site (`Sites-Site`) | -| `--position ` | Position: `first` (default), `last`, `before`, `after` | -| `--target ` | Target cartridge (required when position is `before` or `after`) | -| `--json` | Output as JSON | +| Flag | Description | +| ------------------ | ---------------------------------------------------------------- | +| `--site-id ` | Site ID (e.g. `RefArch`) | +| `--bm` | Use Business Manager site (`Sites-Site`) | +| `--position ` | Position: `first` (default), `last`, `before`, `after` | +| `--target ` | Target cartridge (required when position is `before` or `after`) | +| `--json` | Output as JSON | #### Examples @@ -188,17 +188,17 @@ b2c sites cartridges remove --site-id #### Arguments -| Argument | Description | -|----------|-------------| +| Argument | Description | +| ----------- | ------------------------------- | | `cartridge` | Name of the cartridge to remove | #### Flags -| Flag | Description | -|------|-------------| -| `--site-id ` | Site ID (e.g. `RefArch`) | -| `--bm` | Use Business Manager site (`Sites-Site`) | -| `--json` | Output as JSON | +| Flag | Description | +| ---------------- | ---------------------------------------- | +| `--site-id ` | Site ID (e.g. `RefArch`) | +| `--bm` | Use Business Manager site (`Sites-Site`) | +| `--json` | Output as JSON | #### Examples @@ -225,17 +225,17 @@ b2c sites cartridges set --site-id #### Arguments -| Argument | Description | -|----------|-------------| +| Argument | Description | +| ------------ | -------------------------------------------------------------- | | `cartridges` | New cartridge path (colon-separated, e.g. `cart1:cart2:cart3`) | #### Flags -| Flag | Description | -|------|-------------| -| `--site-id ` | Site ID (e.g. `RefArch`) | -| `--bm` | Use Business Manager site (`Sites-Site`) | -| `--json` | Output as JSON | +| Flag | Description | +| ---------------- | ---------------------------------------- | +| `--site-id ` | Site ID (e.g. `RefArch`) | +| `--bm` | Use Business Manager site (`Sites-Site`) | +| `--json` | Output as JSON | #### Examples @@ -243,4 +243,3 @@ b2c sites cartridges set --site-id b2c sites cartridges set "app_storefront_base:plugin_applepay:plugin_wishlists" --site-id RefArch b2c sites cartridges set "bm_ext1:bm_ext2" --bm ``` - diff --git a/docs/guide/authentication.md b/docs/guide/authentication.md index b701b2b26..b780071c8 100644 --- a/docs/guide/authentication.md +++ b/docs/guide/authentication.md @@ -10,20 +10,20 @@ This guide covers setting up authentication for the B2C CLI, including Account M The CLI uses different authentication mechanisms depending on the operation: -| Operation | Auth Method | Setup Required | -| -------------------------------------------------------------------------------------------------- | ---------------------------- | ---------------------------------------------------------------------------------------- | -| [Code](/cli/code) deploy, watch (file upload) | WebDAV (Basic Auth or OAuth) | [WebDAV Access](#webdav-access) | -| [Code](/cli/code) list, activate, delete | OAuth + SCAPI (`sfcc.scripts` / `sfcc.scripts.rw`), OCAPI fallback | [API Client](#account-manager-api-client) + [SCAPI Scopes](#scapi-authentication) (or [OCAPI](#ocapi-configuration)) | -| [Jobs](/cli/jobs) | OAuth + SCAPI (`sfcc.jobs` / `sfcc.jobs.rw`), OCAPI fallback | [API Client](#account-manager-api-client) + [SCAPI Scopes](#scapi-authentication) (or [OCAPI](#ocapi-configuration)) | +| Operation | Auth Method | Setup Required | +| -------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | +| [Code](/cli/code) deploy, watch (file upload) | WebDAV (Basic Auth or OAuth) | [WebDAV Access](#webdav-access) | +| [Code](/cli/code) list, activate, delete | OAuth + SCAPI (`sfcc.scripts` / `sfcc.scripts.rw`), OCAPI fallback | [API Client](#account-manager-api-client) + [SCAPI Scopes](#scapi-authentication) (or [OCAPI](#ocapi-configuration)) | +| [Jobs](/cli/jobs) | OAuth + SCAPI (`sfcc.jobs` / `sfcc.jobs.rw`), OCAPI fallback | [API Client](#account-manager-api-client) + [SCAPI Scopes](#scapi-authentication) (or [OCAPI](#ocapi-configuration)) | | [BM users / roles](/cli/bm) | OAuth + SCAPI (`sfcc.users(.rw)` / `sfcc.roles(.rw)`), OCAPI fallback | [API Client](#account-manager-api-client) + [SCAPI Scopes](#scapi-authentication) (or [OCAPI](#ocapi-configuration)) | -| [Sites](/cli/sites) list, cartridge path (read) | OAuth + SCAPI (`sfcc.sites` / `sfcc.sites.rw`), OCAPI fallback | [API Client](#account-manager-api-client) + [SCAPI Scopes](#scapi-authentication) (or [OCAPI](#ocapi-configuration)) | -| [Sites](/cli/sites) cartridge path (add/remove/set) | OAuth + OCAPI / site import | [API Client](#account-manager-api-client) + [OCAPI](#ocapi-configuration) | -| SCAPI commands ([schemas](/cli/scapi-schemas), [custom-apis](/cli/custom-apis), [eCDN](/cli/ecdn)) | OAuth + SCAPI scopes | [API Client](#account-manager-api-client) + [SCAPI Scopes](#scapi-authentication) | -| [CIP analytics](/cli/cip) (`cip query`, `cip report`) | OAuth + Client Credentials | [API Client](#account-manager-api-client) + Salesforce Commerce API role + tenant filter | -| [SLAS](/cli/slas) client management | OAuth | None (uses built-in client) or [API Client](#account-manager-api-client) | -| [Sandbox](/cli/sandbox) management | OAuth | None (uses built-in client) or [API Client](#account-manager-api-client) | -| [Account Manager](/cli/account-manager) | OAuth | None (uses built-in client) or [API Client](#account-manager-api-client) | -| [MRT](/cli/mrt) commands | MRT API Key | [MRT API Key](#managed-runtime-api-key) | +| [Sites](/cli/sites) list, cartridge path (read) | OAuth + SCAPI (`sfcc.sites` / `sfcc.sites.rw`), OCAPI fallback | [API Client](#account-manager-api-client) + [SCAPI Scopes](#scapi-authentication) (or [OCAPI](#ocapi-configuration)) | +| [Sites](/cli/sites) cartridge path (add/remove/set) | OAuth + OCAPI / site import | [API Client](#account-manager-api-client) + [OCAPI](#ocapi-configuration) | +| SCAPI commands ([schemas](/cli/scapi-schemas), [custom-apis](/cli/custom-apis), [eCDN](/cli/ecdn)) | OAuth + SCAPI scopes | [API Client](#account-manager-api-client) + [SCAPI Scopes](#scapi-authentication) | +| [CIP analytics](/cli/cip) (`cip query`, `cip report`) | OAuth + Client Credentials | [API Client](#account-manager-api-client) + Salesforce Commerce API role + tenant filter | +| [SLAS](/cli/slas) client management | OAuth | None (uses built-in client) or [API Client](#account-manager-api-client) | +| [Sandbox](/cli/sandbox) management | OAuth | None (uses built-in client) or [API Client](#account-manager-api-client) | +| [Account Manager](/cli/account-manager) | OAuth | None (uses built-in client) or [API Client](#account-manager-api-client) | +| [MRT](/cli/mrt) commands | MRT API Key | [MRT API Key](#managed-runtime-api-key) | ::: tip Zero-Config for Platform Commands Sandbox, SLAS, and Account Manager commands work out of the box without any client configuration. The CLI includes a built-in public client that authenticates via browser login (Authorization Code + PKCE). You only need to configure an API client if you want to use client credentials for automation/CI or need specific scopes. @@ -41,13 +41,13 @@ Most CLI operations require an Account Manager API Client. This is configured in The CLI supports five authentication methods: -| Method | When Used | Role Configuration | -| ---------------------------------- | ---------------------------------------------------------------------------------------------- | ----------------------------------------- | -| **User Authentication** | When `--user-auth` is passed, or when only a client ID is provided (no secret) | Roles configured on your **user account** | -| **Client Credentials** | When both `--client-id` and `--client-secret` are provided | Roles configured on the **API client** | -| **JWT Bearer** | When `--jwt-cert` and `--jwt-key` are provided (certificate-based authentication) | Roles configured on the **API client** | -| **Stateful User Authentication** | After running `b2c auth login` — browser-based login, token stored and reused | Roles configured on your **user account** | -| **Stateful Client Authentication** | After running `b2c auth client` — client credentials login, token stored and reused | Roles configured on the **API client** | +| Method | When Used | Role Configuration | +| ---------------------------------- | ----------------------------------------------------------------------------------- | ----------------------------------------- | +| **User Authentication** | When `--user-auth` is passed, or when only a client ID is provided (no secret) | Roles configured on your **user account** | +| **Client Credentials** | When both `--client-id` and `--client-secret` are provided | Roles configured on the **API client** | +| **JWT Bearer** | When `--jwt-cert` and `--jwt-key` are provided (certificate-based authentication) | Roles configured on the **API client** | +| **Stateful User Authentication** | After running `b2c auth login` — browser-based login, token stored and reused | Roles configured on your **user account** | +| **Stateful Client Authentication** | After running `b2c auth client` — client credentials login, token stored and reused | Roles configured on the **API client** | **User Authentication** opens a browser for interactive login and uses roles assigned to your user account. This is ideal for development and manual operations. Use `--user-auth` as a shorthand for `--auth-methods user` on any OAuth command — both select the Authorization Code + PKCE flow. @@ -57,7 +57,7 @@ In dw.json, the same shorthand is available as `"user-auth": true`. It is mutual **JWT Bearer** uses a public/private certificate pair for authentication without storing client secrets. See [JWT Authentication](#jwt-authentication-certificate-based) for details. -**Stateful User Auth** uses `b2c auth login` to open a browser for interactive login once (Authorization Code + PKCE). The CLI persists both the access token *and* a long-lived refresh token, so subsequent commands silently refresh expired access tokens without re-opening the browser. Clear the session with `b2c auth logout`. See [Auth Commands](/cli/auth#b2c-auth-login) for details. +**Stateful User Auth** uses `b2c auth login` to open a browser for interactive login once (Authorization Code + PKCE). The CLI persists both the access token _and_ a long-lived refresh token, so subsequent commands silently refresh expired access tokens without re-opening the browser. Clear the session with `b2c auth logout`. See [Auth Commands](/cli/auth#b2c-auth-login) for details. **Stateful Client Auth** uses `b2c auth client` to authenticate once with client credentials (or user/password) and store the **access token** for reuse across subsequent commands. The client secret is never persisted, and there is no automatic refresh — when the access token expires, re-run `b2c auth client` with the same credentials. For refresh-capable user authentication, use `b2c auth login` instead. See [Auth Commands](/cli/auth#b2c-auth-client) for details. @@ -65,6 +65,7 @@ After signing in with `auth login` or `auth client`, you can omit the client ID ::: warning Stateful vs Stateless Precedence The stored session is used only when the token is valid **and** no explicit auth flags are provided. The CLI falls back to stateless auth when: + - The stored token is **expired or invalid** — a warning suggests re-running `b2c auth client --client-id --client-secret ` (for client-credentials sessions) or `b2c auth login` (for user sessions). - **Explicit stateless auth flags** are passed (`--client-secret`, `--user-auth`, or `--auth-methods`) — a warning lists the flags that triggered the override. Remove them to use the stored session. Note that `--client-id` alone does not force stateless; the stored session is used if the configured client ID matches. @@ -99,10 +100,10 @@ Roles grant permission to perform specific operations. Roles are configured diff Most roles require a **tenant filter** that specifies which tenants/realms the role applies to. This is configured alongside the role assignment. -| Role | Operations | Notes | -| --------------------------------- | ----------------------------------------- | ------------------------------------------- | +| Role | Operations | Notes | +| --------------------------------- | ----------------------------------------- | --------------------------------------------- | | `Salesforce Commerce API` | SCAPI commands and CIP analytics commands | API clients only. Requires a tenant filter. | -| `Sandbox API User` | ODS management, SLAS client management | Requires tenant filter with realm/org IDs. | +| `Sandbox API User` | ODS management, SLAS client management | Requires tenant filter with realm/org IDs. | | `SLAS Organization Administrator` | SLAS client management (user auth only) | User accounts only. Requires a tenant filter. | #### For Client Credentials (Roles on API Client) @@ -202,6 +203,7 @@ openssl req -x509 -newkey rsa:4096 \ ``` This creates two files: + - `cert.pem` - Public certificate (upload to Account Manager) - `key.pem` - Private key (keep secure on your machine) @@ -219,6 +221,7 @@ For additional security, generate an encrypted private key by omitting `-nodes` ::: tip Multiple Certificates per Client You can register **multiple certificates** for the same API client. This is useful for: + - **Team collaboration**: Each developer generates their own key pair and registers their certificate - **Key rotation**: Add a new certificate before removing the old one (zero downtime) - **Multi-environment**: Different certificates for CI/CD, staging, production @@ -292,19 +295,23 @@ b2c code list --auth-methods jwt ### Troubleshooting **"JWT certificate file not found"** + - Verify the certificate path is correct - Use absolute paths or paths relative to current directory **"Invalid JWT private key"** + - Check that the key file is in PEM format - If encrypted, ensure you provide the correct passphrase via `--jwt-passphrase` **"JWT authentication failed (401)"** + - Verify the certificate is registered in Account Manager - Ensure the Token Endpoint Auth Method is set to `private_key_jwt` - Check that the client ID matches the API client with the registered certificate **"Invalid certificate format"** + - The certificate must be in PEM format (starts with `-----BEGIN CERTIFICATE-----`) - Regenerate the certificate using the OpenSSL command above @@ -319,7 +326,7 @@ b2c code list --auth-methods jwt ## OCAPI Configuration ::: warning OCAPI is deprecated -OCAPI (the Open Commerce API / Data API) is **deprecated** and is being disabled across instances. Newer instances reject OCAPI calls entirely (`OcapiDeprecatedException`). Prefer [SCAPI](#scapi-authentication) for code, jobs, BM users/roles, and site reads — the CLI uses SCAPI first and only falls back to OCAPI when SCAPI scopes are not configured. Configure OCAPI only for instances that still support it or for the few operations with no SCAPI equivalent (e.g. cartridge-path writes on [Sites](/cli/sites), and `bm users search` / `whoami` / `access-key`). +OCAPI (the Open Commerce API / Data API) is **deprecated** and is being disabled across instances. Newer instances reject OCAPI calls entirely (`OcapiDeprecatedException`). Prefer [SCAPI](#scapi-authentication) for code, jobs, BM users/roles, sites, and catalog discovery. The CLI uses SCAPI first and temporarily falls back on safe capability/auth/request rejections. Configure OCAPI only for compatible instances or operations with no live SCAPI equivalent, such as inventory-list enumeration, BM `whoami` / access keys / raw user-search JSON, and running-job cancellation. If a command fails with "OCAPI is deprecated and disabled for this instance," configure [SCAPI scopes](#scapi-authentication) on your API client instead. ::: @@ -481,12 +488,14 @@ For operations that interact with B2C Commerce instances (code deployment, jobs, ``` ::: tip BM functional permissions -`bm whoami` and the `bm access-key` family additionally require *a real BM user identity*. Service-client tokens cannot resolve to a BM user, so the CLI defaults these commands to browser-based user auth. Access-key writes also require the **Manage_Users_Access_Keys** BM functional permission on the user account performing the request — grant it via **Administration** > **Roles & Permissions** in Business Manager. See [BM Commands → Authentication](/cli/bm#authentication) for details. +`bm whoami` and the `bm access-key` family additionally require _a real BM user identity_. Service-client tokens cannot resolve to a BM user, so the CLI defaults these commands to browser-based user auth. Access-key writes also require the **Manage_Users_Access_Keys** BM functional permission on the user account performing the request — grant it via **Administration** > **Roles & Permissions** in Business Manager. See [BM Commands → Authentication](/cli/bm#authentication) for details. ::: ## SCAPI Authentication -SCAPI (the Salesforce Commerce API) is the **preferred, modern** API surface and the CLI's default for every operation that supports it. SCAPI-native commands (eCDN, SCAPI schemas, custom APIs) require it, and the dual-backend commands (`code`, `jobs`, `bm users`, `bm roles`) use it first, [falling back to the deprecated OCAPI](#ocapi-configuration) only when SCAPI scopes are not configured. All require OAuth authentication with specific roles and scopes. +SCAPI (the Salesforce Commerce API) is the **preferred, modern** API surface and the CLI's default for every operation that supports it. SCAPI-native commands (eCDN, SCAPI schemas, custom APIs) require it, and dual-backend commands use it first with a temporary [deprecated OCAPI fallback](#ocapi-configuration). All require OAuth authentication with specific roles and scopes. + +The SCAPI Admin APIs used here currently support stateless client-credentials or JWT Bearer authentication, not browser-based user authentication. `--user-auth` continues to work with OCAPI and WebDAV. In `auto` mode a user-authenticated migrated command selects OCAPI; `--api-backend scapi --user-auth` errors clearly. Platform support for SCAPI user authentication may be added later. ### Required Setup @@ -495,25 +504,30 @@ SCAPI (the Salesforce Commerce API) is the **preferred, modern** API surface and ### Scopes by Command -| Command | Required Scope | Reference | -| ------------------------------------------------------ | ------------------------------------ | ----------------------------------- | -| `b2c scapi schemas list/get` | `sfcc.scapi-schemas` | [SCAPI Schemas](/cli/scapi-schemas) | -| `b2c scapi custom status` | `sfcc.custom-apis` | [Custom APIs](/cli/custom-apis) | -| `b2c ecdn` (read operations) | `sfcc.cdn-zones` | [eCDN](/cli/ecdn) | -| `b2c ecdn` (write operations) | `sfcc.cdn-zones.rw` | [eCDN](/cli/ecdn) | -| `b2c jobs` (read; e.g. `list`, `get`, `wait`) | `sfcc.jobs` or `sfcc.jobs.rw` | [Jobs](/cli/jobs) | -| `b2c jobs` (write; e.g. `run`, `delete`) | `sfcc.jobs.rw` | [Jobs](/cli/jobs) | -| `b2c code list` | `sfcc.scripts` or `sfcc.scripts.rw` | [Code](/cli/code) | -| `b2c code activate`, `code delete` | `sfcc.scripts.rw` | [Code](/cli/code) | -| `b2c bm users list/get` | `sfcc.users` or `sfcc.users.rw` | [BM](/cli/bm) | -| `b2c bm users create/update/delete` | `sfcc.users.rw` | [BM](/cli/bm) | -| `b2c bm roles list/get` | `sfcc.roles` or `sfcc.roles.rw` | [BM](/cli/bm) | -| `b2c bm roles create/delete/grant/revoke/permissions` | `sfcc.roles.rw` | [BM](/cli/bm) | -| `b2c sites list`, `sites cartridges list` | `sfcc.sites` or `sfcc.sites.rw` | [Sites](/cli/sites) | +| Command | Required Scope | Reference | +| ----------------------------------------------------- | ------------------------------------- | ----------------------------------- | +| `b2c scapi schemas list/get` | `sfcc.scapi-schemas` | [SCAPI Schemas](/cli/scapi-schemas) | +| `b2c scapi custom status` | `sfcc.custom-apis` | [Custom APIs](/cli/custom-apis) | +| `b2c ecdn` (read operations) | `sfcc.cdn-zones` | [eCDN](/cli/ecdn) | +| `b2c ecdn` (write operations) | `sfcc.cdn-zones.rw` | [eCDN](/cli/ecdn) | +| `b2c jobs` (read; e.g. `list`, `get`, `wait`) | `sfcc.jobs` or `sfcc.jobs.rw` | [Jobs](/cli/jobs) | +| `b2c jobs` (write; e.g. `run`, `delete`) | `sfcc.jobs.rw` | [Jobs](/cli/jobs) | +| `b2c code list` | `sfcc.scripts` or `sfcc.scripts.rw` | [Code](/cli/code) | +| `b2c code activate`, `code delete` | `sfcc.scripts.rw` | [Code](/cli/code) | +| `b2c bm users list/get` | `sfcc.users` or `sfcc.users.rw` | [BM](/cli/bm) | +| `b2c bm users search` | `sfcc.users` or `sfcc.users.rw` | [BM](/cli/bm) | +| `b2c bm users create/update/delete` | `sfcc.users.rw` | [BM](/cli/bm) | +| `b2c bm roles list/get` | `sfcc.roles` or `sfcc.roles.rw` | [BM](/cli/bm) | +| `b2c bm roles create/delete/grant/revoke/permissions` | `sfcc.roles.rw` | [BM](/cli/bm) | +| `b2c sites list`, `sites cartridges list` | `sfcc.sites` or `sfcc.sites.rw` | [Sites](/cli/sites) | +| `b2c sites cartridges add/remove/set` | `sfcc.sites.rw` | [Sites](/cli/sites) | +| Catalog discovery used by export/VS Code | `sfcc.catalogs` or `sfcc.catalogs.rw` | [Jobs](/cli/jobs) | The CLI automatically requests these scopes. Your API client must have them in the Default Scopes list. -The `code`, `jobs`, `bm users`, `bm roles`, and `sites` (list + cartridge-path read) commands run over SCAPI. The CLI defaults to `--api-backend auto`, which falls back to the [deprecated OCAPI backend](#ocapi-configuration) only when the SCAPI scopes above are not configured (or not yet provisioned on the API client). Use `--api-backend scapi` or `--api-backend ocapi` to force a backend explicitly. (Cartridge-path **writes** have no SCAPI equivalent and always use OCAPI / site-archive import.) +The `code`, `jobs`, `bm users`, `bm roles`, and `sites` commands run over SCAPI where the live API provides an equivalent operation. The CLI defaults to `--api-backend auto`: it tries SCAPI when `shortCode`, `tenantId`, and supported stateless OAuth are detected, then falls back to deprecated OCAPI only for safe capability/auth/request rejections. Missing SCAPI coordinates select OCAPI directly; they are not required for `auto`. If neither backend is usable, the command reports the missing configuration or permission instead of requiring SCAPI coordinates up front. Use `--api-backend scapi` or `--api-backend ocapi` to force a backend explicitly. + +Inventory-list enumeration, BM `whoami`, BM access-key administration, raw OCAPI user-search query JSON, and cancellation of a running job currently have no equivalent live SCAPI operation. Those remain explicit temporary OCAPI compatibility paths. Site cartridge-path writes and catalog enumeration are supported by SCAPI. ::: tip For detailed authentication requirements including specific scopes for each command, see the individual [CLI command reference pages](/cli/). @@ -630,7 +644,7 @@ Here's a complete example for setting up CLI access: ### 2. (Optional) Configure OCAPI fallback -With the SCAPI scopes above configured, `code`, `jobs`, `bm users/roles`, and `sites` reads run over SCAPI — no OCAPI setup is needed (add the `sfcc.sites` / `sfcc.sites.rw` scope for `sites`). Configure OCAPI only for operations with no SCAPI equivalent — cartridge-path writes on [`sites`](/cli/sites) and `bm users search` / `whoami` / `access-key` — or to provide a fallback on instances where SCAPI scopes are not yet provisioned. Note that OCAPI is [deprecated](#ocapi-configuration) and disabled on newer instances. To set it up, add the JSON configuration shown in [OCAPI Configuration](#ocapi-configuration). +With the SCAPI scopes above configured, `code`, `jobs`, `bm users/roles`, `sites`, and catalog discovery run over SCAPI. Configure OCAPI only for operations with no live equivalent — inventory-list enumeration, BM `whoami`, access keys, raw `bm users search --query`, and running-job cancellation — or as the temporary `auto` fallback. Note that OCAPI is [deprecated](#ocapi-configuration) and disabled on newer instances. To set it up, add the JSON configuration shown in [OCAPI Configuration](#ocapi-configuration). ### 3. Configure WebDAV Access (for code deploy/watch, webdav commands) diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 597a6a53e..a394e40c3 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -71,42 +71,42 @@ See [Configure WebDAV File Access](https://help.salesforce.com/s/articleView?id= You can configure the CLI using environment variables: -| Variable | Description | -| ----------------------------- | -------------------------------------------------------------- | -| `SFCC_PROJECT_DIRECTORY` | Project directory | -| `SFCC_CONFIG` | Path to config file (dw.json format) | -| `SFCC_INSTANCE` | Instance name from config file | -| `SFCC_SERVER` | The B2C instance hostname | -| `SFCC_WEBDAV_SERVER` | Separate hostname for WebDAV (if different from main hostname) | -| `SFCC_CODE_VERSION` | Code version for deployments | -| `SFCC_CLIENT_ID` | OAuth client ID | -| `SFCC_CLIENT_SECRET` | OAuth client secret | -| `SFCC_JWT_CERT` | Path to JWT certificate file (cert.pem) for JWT Bearer auth | -| `SFCC_JWT_KEY` | Path to JWT private key file (key.pem) for JWT Bearer auth | -| `SFCC_JWT_PASSPHRASE` | Passphrase for encrypted JWT private key | -| `SFCC_OAUTH_SCOPES` | OAuth scopes to request | -| `SFCC_AUTH_METHODS` | Comma-separated list of allowed auth methods | -| `SFCC_SHORTCODE` | SCAPI short code | -| `SFCC_TENANT_ID` | Organization/tenant ID for SCAPI | -| `SFCC_ACCOUNT_MANAGER_HOST` | Account Manager hostname for OAuth | -| `SFCC_REDIRECT_URI` | Override redirect URI for browser-based OAuth flows (e.g., when behind a proxy) | -| `SFCC_OAUTH_LOCAL_PORT` | Local port for the browser-based OAuth redirect server (default: `8080`) | -| `SFCC_DISABLE_PKCE_FALLBACK` | Disable the automatic PKCE→implicit fallback for clients not yet registered for PKCE (set to `1`) | -| `SFCC_USERNAME` | Basic auth username | -| `SFCC_PASSWORD` | Basic auth password | -| `SFCC_CERTIFICATE` | Path to PKCS12 certificate for two-factor auth (mTLS) | -| `SFCC_CERTIFICATE_PASSPHRASE` | Passphrase for the certificate | -| `SFCC_SELFSIGNED` | Allow self-signed server certificates | -| `SFCC_SANDBOX_API_HOST` | ODS (sandbox) API hostname | -| `SFCC_CIP_HOST` | CIP analytics host override | -| `SFCC_CIP_STAGING` | Use staging CIP analytics host (`true`/`false`) | -| `MRT_API_KEY` | MRT API key (`SFCC_MRT_API_KEY` also supported) | -| `MRT_PROJECT` | MRT project slug (`SFCC_MRT_PROJECT` also supported) | -| `MRT_ENVIRONMENT` | MRT environment name (`SFCC_MRT_ENVIRONMENT`, `MRT_TARGET` also supported) | -| `MRT_CLOUD_ORIGIN` | MRT API origin URL override (`SFCC_MRT_CLOUD_ORIGIN` also supported) | -| `SFCC_SAFETY_LEVEL` | Safety mode: `NONE`, `NO_DELETE`, `NO_UPDATE`, `READ_ONLY` (see [Safety Mode](/guide/safety)) | +| Variable | Description | +| ----------------------------- | ------------------------------------------------------------------------------------------------------- | +| `SFCC_PROJECT_DIRECTORY` | Project directory | +| `SFCC_CONFIG` | Path to config file (dw.json format) | +| `SFCC_INSTANCE` | Instance name from config file | +| `SFCC_SERVER` | The B2C instance hostname | +| `SFCC_WEBDAV_SERVER` | Separate hostname for WebDAV (if different from main hostname) | +| `SFCC_CODE_VERSION` | Code version for deployments | +| `SFCC_CLIENT_ID` | OAuth client ID | +| `SFCC_CLIENT_SECRET` | OAuth client secret | +| `SFCC_JWT_CERT` | Path to JWT certificate file (cert.pem) for JWT Bearer auth | +| `SFCC_JWT_KEY` | Path to JWT private key file (key.pem) for JWT Bearer auth | +| `SFCC_JWT_PASSPHRASE` | Passphrase for encrypted JWT private key | +| `SFCC_OAUTH_SCOPES` | OAuth scopes to request | +| `SFCC_AUTH_METHODS` | Comma-separated list of allowed auth methods | +| `SFCC_SHORTCODE` | SCAPI short code | +| `SFCC_TENANT_ID` | Organization/tenant ID for SCAPI | +| `SFCC_ACCOUNT_MANAGER_HOST` | Account Manager hostname for OAuth | +| `SFCC_REDIRECT_URI` | Override redirect URI for browser-based OAuth flows (e.g., when behind a proxy) | +| `SFCC_OAUTH_LOCAL_PORT` | Local port for the browser-based OAuth redirect server (default: `8080`) | +| `SFCC_DISABLE_PKCE_FALLBACK` | Disable the automatic PKCE→implicit fallback for clients not yet registered for PKCE (set to `1`) | +| `SFCC_USERNAME` | Basic auth username | +| `SFCC_PASSWORD` | Basic auth password | +| `SFCC_CERTIFICATE` | Path to PKCS12 certificate for two-factor auth (mTLS) | +| `SFCC_CERTIFICATE_PASSPHRASE` | Passphrase for the certificate | +| `SFCC_SELFSIGNED` | Allow self-signed server certificates | +| `SFCC_SANDBOX_API_HOST` | ODS (sandbox) API hostname | +| `SFCC_CIP_HOST` | CIP analytics host override | +| `SFCC_CIP_STAGING` | Use staging CIP analytics host (`true`/`false`) | +| `MRT_API_KEY` | MRT API key (`SFCC_MRT_API_KEY` also supported) | +| `MRT_PROJECT` | MRT project slug (`SFCC_MRT_PROJECT` also supported) | +| `MRT_ENVIRONMENT` | MRT environment name (`SFCC_MRT_ENVIRONMENT`, `MRT_TARGET` also supported) | +| `MRT_CLOUD_ORIGIN` | MRT API origin URL override (`SFCC_MRT_CLOUD_ORIGIN` also supported) | +| `SFCC_SAFETY_LEVEL` | Safety mode: `NONE`, `NO_DELETE`, `NO_UPDATE`, `READ_ONLY` (see [Safety Mode](/guide/safety)) | | `SFCC_SAFETY_CONFIRM` | Enable confirmation mode for safety: `true` or `1` (see [Safety Mode](/guide/safety#confirmation-mode)) | -| `SFCC_SAFETY_CONFIG` | Path to global safety config file (see [Safety Mode](/guide/safety#global-safety-config)) | +| `SFCC_SAFETY_CONFIG` | Path to global safety config file (see [Safety Mode](/guide/safety#global-safety-config)) | ## .env File @@ -244,38 +244,38 @@ For the full command reference with all flags, see [Setup Commands](/cli/setup). ### Supported Fields -| Field | Description | -| ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------- | -| `hostname` | B2C instance hostname. Also accepts `server`. | -| `webdav-hostname` | Separate hostname for WebDAV (if different from main hostname). Also accepts `webdav-server`, `secureHostname`, or `secure-server`. | -| `code-version` | Code version for deployments | -| `client-id` | OAuth client ID | -| `client-secret` | OAuth client secret | -| `jwt-cert-path` | Path to JWT certificate file (cert.pem) for JWT Bearer authentication. Also accepts `jwtCertPath`. | -| `jwt-key-path` | Path to JWT private key file (key.pem) for JWT Bearer authentication. Also accepts `jwtKeyPath`. | -| `jwt-passphrase` | Passphrase for encrypted JWT private key. Also accepts `jwtPassphrase`. | -| `username` | Basic auth username (WebDAV) | -| `password` | Basic auth access key (WebDAV) | -| `oauth-scopes` | OAuth scopes (array of strings) | -| `auth-methods` | Authentication methods in priority order (array of strings) | -| `user-auth` | Boolean shorthand for `"auth-methods": ["user"]`. Mutually exclusive with `auth-methods` — set one or the other. | -| `account-manager-host` | Account Manager hostname for OAuth | -| `shortCode` | SCAPI short code. Also accepts `short-code` or `scapi-shortcode`. | -| `content-library` | Default content library ID for `content export` and `content list` commands | -| `libraries` | Library IDs for the WebDAV browser and Content Libraries tree. Accepts `string[]` or `[{id, siteLibrary?}]`; elements may be mixed | -| `asset-query` | JSON dot-paths used to extract static asset URLs during content library parsing (default `["image.path"]`). Also accepts `assetQuery` | -| `tenant-id` | Organization/tenant ID for SCAPI | -| `sandbox-api-host` | ODS (sandbox) API hostname | -| `realm` | Default ODS realm for sandbox operations | -| `cip-host` | CIP analytics host override | -| `mrtApiKey` | MRT API key | -| `mrtProject` | MRT project slug | -| `mrtEnvironment` | MRT environment name | -| `mrtOrigin` | MRT API origin URL override. Also accepts `cloudOrigin`. | -| `certificate` | Path to PKCS12 certificate for two-factor auth (mTLS) | -| `certificate-passphrase` | Passphrase for the certificate. Also accepts `passphrase`. | -| `self-signed` | Allow self-signed server certificates. Also accepts `selfsigned`. | -| `api-backend` | API backend for `job`, `code`, `bm users`, and `bm roles` commands: `scapi`, `auto` (default), or `ocapi`. These commands use SCAPI; `auto` falls back to the deprecated OCAPI backend only when SCAPI scopes are not configured. Set `ocapi` to force the [deprecated](./authentication#ocapi-configuration) backend. | +| Field | Description | +| ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `hostname` | B2C instance hostname. Also accepts `server`. | +| `webdav-hostname` | Separate hostname for WebDAV (if different from main hostname). Also accepts `webdav-server`, `secureHostname`, or `secure-server`. | +| `code-version` | Code version for deployments | +| `client-id` | OAuth client ID | +| `client-secret` | OAuth client secret | +| `jwt-cert-path` | Path to JWT certificate file (cert.pem) for JWT Bearer authentication. Also accepts `jwtCertPath`. | +| `jwt-key-path` | Path to JWT private key file (key.pem) for JWT Bearer authentication. Also accepts `jwtKeyPath`. | +| `jwt-passphrase` | Passphrase for encrypted JWT private key. Also accepts `jwtPassphrase`. | +| `username` | Basic auth username (WebDAV) | +| `password` | Basic auth access key (WebDAV) | +| `oauth-scopes` | OAuth scopes (array of strings) | +| `auth-methods` | Authentication methods in priority order (array of strings) | +| `user-auth` | Boolean shorthand for `"auth-methods": ["user"]`. Mutually exclusive with `auth-methods` — set one or the other. | +| `account-manager-host` | Account Manager hostname for OAuth | +| `shortCode` | SCAPI short code. Also accepts `short-code` or `scapi-shortcode`. | +| `content-library` | Default content library ID for `content export` and `content list` commands | +| `libraries` | Library IDs for the WebDAV browser and Content Libraries tree. Accepts `string[]` or `[{id, siteLibrary?}]`; elements may be mixed | +| `asset-query` | JSON dot-paths used to extract static asset URLs during content library parsing (default `["image.path"]`). Also accepts `assetQuery` | +| `tenant-id` | Organization/tenant ID for SCAPI | +| `sandbox-api-host` | ODS (sandbox) API hostname | +| `realm` | Default ODS realm for sandbox operations | +| `cip-host` | CIP analytics host override | +| `mrtApiKey` | MRT API key | +| `mrtProject` | MRT project slug | +| `mrtEnvironment` | MRT environment name | +| `mrtOrigin` | MRT API origin URL override. Also accepts `cloudOrigin`. | +| `certificate` | Path to PKCS12 certificate for two-factor auth (mTLS) | +| `certificate-passphrase` | Passphrase for the certificate. Also accepts `passphrase`. | +| `self-signed` | Allow self-signed server certificates. Also accepts `selfsigned`. | +| `api-backend` | API backend for SCAPI-migrated commands: `scapi`, `auto` (default), or `ocapi`. `auto` tries SCAPI when its coordinates and supported authentication are detected, then temporarily falls back to deprecated OCAPI on a safe capability/auth/request rejection. Missing SCAPI coordinates do not make `auto` invalid; OCAPI is selected directly. Set `ocapi` to force the [deprecated](./authentication#ocapi-configuration) backend. | ### Two-Factor Authentication (mTLS) @@ -333,18 +333,18 @@ You can store project-level defaults in your `package.json` file under the `b2c` Only non-sensitive, project-level fields can be configured in `package.json`. Both camelCase and kebab-case are accepted (e.g., `shortCode` or `short-code`): -| Field | Description | -| -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | -| `shortCode` | SCAPI short code | -| `clientId` | OAuth client ID (for browser login discovery) | -| `contentLibrary` | Default content library ID for `content export` and `content list` commands | -| `libraries` | Library IDs for the WebDAV browser and Content Libraries tree. Accepts `string[]` or `[{id, siteLibrary?}]`; elements may be mixed | -| `assetQuery` | JSON dot-paths used to extract static asset URLs during content library parsing (default `["image.path"]`) | -| `mrtProject` | MRT project slug | -| `mrtOrigin` | MRT API origin URL override | -| `accountManagerHost` | Account Manager hostname for OAuth | -| `sandboxApiHost` | ODS (sandbox) API hostname | -| `realm` | Default ODS realm for sandbox operations | +| Field | Description | +| -------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | +| `shortCode` | SCAPI short code | +| `clientId` | OAuth client ID (for browser login discovery) | +| `contentLibrary` | Default content library ID for `content export` and `content list` commands | +| `libraries` | Library IDs for the WebDAV browser and Content Libraries tree. Accepts `string[]` or `[{id, siteLibrary?}]`; elements may be mixed | +| `assetQuery` | JSON dot-paths used to extract static asset URLs during content library parsing (default `["image.path"]`) | +| `mrtProject` | MRT project slug | +| `mrtOrigin` | MRT API origin URL override | +| `accountManagerHost` | Account Manager hostname for OAuth | +| `sandboxApiHost` | ODS (sandbox) API hostname | +| `realm` | Default ODS realm for sandbox operations | ::: warning Security Note Sensitive fields like `hostname`, `password`, `clientSecret`, `username`, and `mrtApiKey` are intentionally **not** supported in `package.json`. These should be configured via `dw.json` (which should be in `.gitignore`), environment variables, or secure credential stores. @@ -363,10 +363,7 @@ A bare string is treated as a shared library; an object can mark a library as si ```json { "b2c": { - "libraries": [ - "RefArchSharedLibrary", - { "id": "SiteGenesis", "siteLibrary": true } - ] + "libraries": ["RefArchSharedLibrary", {"id": "SiteGenesis", "siteLibrary": true}] } } ``` @@ -443,7 +440,7 @@ For platform-level commands (Sandbox, SLAS, and Account Manager), the CLI includ - `client-credentials` - OAuth 2.0 client credentials flow (requires client ID and secret). Used for SCAPI/OCAPI and WebDAV. - `jwt` - OAuth 2.0 JWT Bearer flow (requires client ID, certificate, and private key). Used for SCAPI/OCAPI and WebDAV. More secure than client credentials. -- `user` - OAuth 2.0 Authorization Code + PKCE flow (requires client ID only, opens browser for login). Used for SCAPI/OCAPI and WebDAV. +- `user` - OAuth 2.0 Authorization Code + PKCE flow (requires client ID only, opens browser for login). Currently supported by OCAPI and WebDAV, but not by the SCAPI Admin APIs used in this migration. In `auto` mode these operations select OCAPI; explicit `scapi` reports the unsupported authentication flow. SCAPI user authentication may be supported by the platform in the future. - `implicit` - OAuth 2.0 implicit flow (deprecated — opt-in only). Selectable via `--auth-methods implicit` for backwards compatibility, but emits a deprecation warning. OAuth 2.1 deprecates implicit for public clients. - `basic` - Basic authentication with username and access key. Used for WebDAV operations only. - `api-key` - API key authentication. Used for MRT commands only. diff --git a/docs/typedoc.json b/docs/typedoc.json index 890dfd328..62481c4a3 100644 --- a/docs/typedoc.json +++ b/docs/typedoc.json @@ -22,6 +22,7 @@ "../packages/b2c-tooling-sdk/src/operations/bm-roles/index.ts", "../packages/b2c-tooling-sdk/src/operations/bm-users/index.ts", "../packages/b2c-tooling-sdk/src/operations/sites/index.ts", + "../packages/b2c-tooling-sdk/src/operations/catalogs/index.ts", "../packages/b2c-tooling-sdk/src/operations/orgs/index.ts", "../packages/b2c-tooling-sdk/src/slas/index.ts", "../packages/b2c-tooling-sdk/src/safety/index.ts", diff --git a/packages/b2c-cli/src/commands/bm/users/search.ts b/packages/b2c-cli/src/commands/bm/users/search.ts index d903a5e91..eb7bd52d2 100644 --- a/packages/b2c-cli/src/commands/bm/users/search.ts +++ b/packages/b2c-cli/src/commands/bm/users/search.ts @@ -4,34 +4,28 @@ * 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 { - InstanceCommand, - TableRenderer, - columnFlagsFor, - selectColumns, - type ColumnDef, -} from '@salesforce/b2c-tooling-sdk/cli'; -import {searchBmUsers, type BmUser, type BmUserSearchResult} from '@salesforce/b2c-tooling-sdk/operations/bm-users'; +import {BmCommand, TableRenderer, columnFlagsFor, selectColumns, type ColumnDef} from '@salesforce/b2c-tooling-sdk/cli'; +import {type ListUsersResult, type UserInfo} from '@salesforce/b2c-tooling-sdk/operations/bm-users'; import {t} from '../../../i18n/index.js'; -const COLUMNS: Record> = { +const COLUMNS: Record> = { login: {header: 'Login', get: (u) => u.login || '-'}, email: {header: 'Email', get: (u) => u.email || '-'}, name: { header: 'Name', - get: (u) => [u.first_name, u.last_name].filter(Boolean).join(' ') || '-', + get: (u) => [u.firstName, u.lastName].filter(Boolean).join(' ') || '-', }, disabled: {header: 'Disabled', get: (u) => (u.disabled ? 'Yes' : 'No')}, locked: {header: 'Locked', get: (u) => (u.locked ? 'Yes' : 'No')}, - lastLogin: {header: 'Last Login', get: (u) => u.last_login_date || '-'}, - externalId: {header: 'External ID', get: (u) => u.external_id || '-', extended: true}, + lastLogin: {header: 'Last Login', get: (u) => u.lastLoginDate || '-'}, + externalId: {header: 'External ID', get: (u) => u.externalId || '-', extended: true}, }; const DEFAULT_COLUMNS = ['login', 'name', 'disabled', 'locked', 'lastLogin']; const tableRenderer = new TableRenderer(COLUMNS); -export default class BmUsersSearch extends InstanceCommand { +export default class BmUsersSearch extends BmCommand { static description = t( 'commands.bm.users.search.description', 'Search Business Manager users by login, email, name, lock state, or disabled state', @@ -89,7 +83,7 @@ export default class BmUsersSearch extends InstanceCommand ...columnFlagsFor(COLUMNS), }; - async run(): Promise { + async run(): Promise { this.requireOAuthCredentials(); const hostname = this.resolvedConfig.values.hostname!; @@ -110,7 +104,8 @@ export default class BmUsersSearch extends InstanceCommand this.log(t('commands.bm.users.search.searching', 'Searching users on {{hostname}}...', {hostname})); - const result = await searchBmUsers(this.instance, { + const backend = this.createUsersBackend(); + const result = await backend.searchUsers({ query: parsedQuery, searchPhrase: flags['search-phrase'], login: flags.login, diff --git a/packages/b2c-cli/src/commands/code/download.ts b/packages/b2c-cli/src/commands/code/download.ts index 9c22f6579..8ecffb26e 100644 --- a/packages/b2c-cli/src/commands/code/download.ts +++ b/packages/b2c-cli/src/commands/code/download.ts @@ -153,6 +153,7 @@ export default class CodeDownload extends CartridgeCommand }; const result = await this.operations.downloadCartridges(this.instance, this.flags.output ?? 'cartridges', { + scriptsBackend: createScriptsBackend({instance: this.instance}), include: this.cartridgeOptions.include, exclude: this.cartridgeOptions.exclude, mirror, diff --git a/packages/b2c-cli/src/commands/code/watch.ts b/packages/b2c-cli/src/commands/code/watch.ts index b6332da1f..d1d87a03a 100644 --- a/packages/b2c-cli/src/commands/code/watch.ts +++ b/packages/b2c-cli/src/commands/code/watch.ts @@ -3,7 +3,7 @@ * 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 {watchCartridges} from '@salesforce/b2c-tooling-sdk/operations/code'; +import {createScriptsBackend, watchCartridges} from '@salesforce/b2c-tooling-sdk/operations/code'; import {CartridgeCommand} from '@salesforce/b2c-tooling-sdk/cli'; import {t, withDocs} from '../../i18n/index.js'; @@ -40,7 +40,7 @@ export default class CodeWatch extends CartridgeCommand { const hostname = this.resolvedConfig.values.hostname!; const version = this.resolvedConfig.values.codeVersion; - // OAuth is only required if no code version specified (need to auto-discover via OCAPI) + // OAuth is only required if no code version is specified (backend discovery). if (!version && !this.hasOAuthCredentials()) { this.error( t( @@ -67,6 +67,7 @@ export default class CodeWatch extends CartridgeCommand { try { const result = await this.operations.watchCartridges(this.instance, this.cartridgePath, { ...this.cartridgeOptions, + scriptsBackend: createScriptsBackend({instance: this.instance}), onUpload: (files) => { this.log(t('commands.code.watch.uploaded', '[UPLOAD] {{count}} file(s)', {count: files.length})); }, diff --git a/packages/b2c-cli/src/commands/setup/instance/create.ts b/packages/b2c-cli/src/commands/setup/instance/create.ts index 3eb57e752..c10587a1d 100644 --- a/packages/b2c-cli/src/commands/setup/instance/create.ts +++ b/packages/b2c-cli/src/commands/setup/instance/create.ts @@ -7,7 +7,7 @@ import {Args, Flags, ux} from '@oclif/core'; import {input, password, confirm, select} from '@inquirer/prompts'; import {BaseCommand} from '@salesforce/b2c-tooling-sdk/cli'; import {DwJsonSource, createInstanceFromConfig, type NormalizedConfig} from '@salesforce/b2c-tooling-sdk/config'; -import {getActiveCodeVersion} from '@salesforce/b2c-tooling-sdk/operations/code'; +import {createScriptsBackend} from '@salesforce/b2c-tooling-sdk/operations/code'; import {withDocs} from '../../../i18n/index.js'; /** @@ -80,6 +80,19 @@ export default class SetupInstanceCreate extends BaseCommand = { hostname, + shortCode: this.flags['short-code'], + tenantId: this.flags['tenant-id'], + apiBackend: this.flags['api-backend'] as NormalizedConfig['apiBackend'], }; // Handle authentication - in non-interactive mode, use provided flags @@ -209,7 +225,9 @@ export default class SetupInstanceCreate extends BaseCommand extends InstanceCommand { protected createUsersBackend(): UsersBackend { diff --git a/packages/b2c-tooling-sdk/src/cli/code-command.ts b/packages/b2c-tooling-sdk/src/cli/code-command.ts index a0c23246b..b89182a6f 100644 --- a/packages/b2c-tooling-sdk/src/cli/code-command.ts +++ b/packages/b2c-tooling-sdk/src/cli/code-command.ts @@ -12,8 +12,8 @@ import {createScriptsBackend, type ScriptsBackend} from '../operations/code/inde * * Provides `createScriptsBackend()` which selects between OCAPI and SCAPI * based on the `--api-backend` flag and `apiBackend` config field. In auto - * mode, prefers SCAPI when shortCode + tenantId are configured, falling - * back to OCAPI on `invalid_scope`. + * mode, prefers SCAPI when its coordinates and supported auth are available, + * falling back to OCAPI on safe capability/auth/request rejections. */ export abstract class CodeCommand extends InstanceCommand { protected createScriptsBackend(): ScriptsBackend { diff --git a/packages/b2c-tooling-sdk/src/clients/dual-backend-factory.ts b/packages/b2c-tooling-sdk/src/clients/dual-backend-factory.ts index bab5b7217..38857503a 100644 --- a/packages/b2c-tooling-sdk/src/clients/dual-backend-factory.ts +++ b/packages/b2c-tooling-sdk/src/clients/dual-backend-factory.ts @@ -63,8 +63,8 @@ export interface DualBackendCtors { * * - Explicit `'ocapi'` returns an OCAPI backend. * - Explicit `'scapi'` returns a SCAPI backend (throws if config missing). - * - `'auto'` returns a fallback Proxy that tries SCAPI first, falls back to - * OCAPI on `invalid_scope`. + * - `'auto'` returns a fallback Proxy that tries SCAPI first and falls back to + * OCAPI on safe capability/auth/request rejections. * * @example * ```ts diff --git a/packages/b2c-tooling-sdk/src/clients/index.ts b/packages/b2c-tooling-sdk/src/clients/index.ts index c970a7e96..4968e0e33 100644 --- a/packages/b2c-tooling-sdk/src/clients/index.ts +++ b/packages/b2c-tooling-sdk/src/clients/index.ts @@ -423,8 +423,29 @@ export type { components as ScapiSitesComponents, } from './scapi-sites.js'; +// SCAPI Catalogs +export {createScapiCatalogsClient, SCAPI_CATALOGS_CASCADE} from './scapi-catalogs.js'; +export type { + ScapiCatalogsClient, + ScapiCatalogsClientConfig, + Catalog as ScapiCatalog, + Catalogs as ScapiCatalogs, + paths as ScapiCatalogsPaths, + components as ScapiCatalogsComponents, +} from './scapi-catalogs.js'; + // SCAPI dual-backend utilities (shared across SCAPI/OCAPI domains) -export {isInvalidScopeError, resolveScapiOrOcapi, withScopes} from './scapi-backend-utils.js'; +export { + createScapiRequestError, + isFallbackTrigger, + isInvalidScopeError, + resolveScapiOrOcapi, + SAFE_SCAPI_FALLBACK_STATUSES, + ScapiCapabilityUnsupportedError, + ScapiRequestError, + scapiUnavailableMessage, + withScopes, +} from './scapi-backend-utils.js'; export type {ApiBackendPreference, BackendBase, ResolveBackendOptions} from './scapi-backend-utils.js'; export {createFallbackBackend} from './scapi-fallback-backend.js'; export {createDualBackend} from './dual-backend-factory.js'; diff --git a/packages/b2c-tooling-sdk/src/clients/middleware-registry.ts b/packages/b2c-tooling-sdk/src/clients/middleware-registry.ts index e10129fa7..0f039a667 100644 --- a/packages/b2c-tooling-sdk/src/clients/middleware-registry.ts +++ b/packages/b2c-tooling-sdk/src/clients/middleware-registry.ts @@ -66,7 +66,8 @@ export type HttpClientType = | 'scapi-scripts' | 'scapi-merchant-users' | 'scapi-merchant-roles' - | 'scapi-sites'; + | 'scapi-sites' + | 'scapi-catalogs'; /** * Middleware interface compatible with openapi-fetch. diff --git a/packages/b2c-tooling-sdk/src/clients/scapi-backend-utils.ts b/packages/b2c-tooling-sdk/src/clients/scapi-backend-utils.ts index a7be0c005..4ccb8a51e 100644 --- a/packages/b2c-tooling-sdk/src/clients/scapi-backend-utils.ts +++ b/packages/b2c-tooling-sdk/src/clients/scapi-backend-utils.ts @@ -13,13 +13,14 @@ * @module clients/scapi-backend-utils */ import type {AuthStrategy} from '../auth/types.js'; +import {getApiErrorMessage} from './error-utils.js'; /** * User-facing API backend preference. * * - `'ocapi'`: force OCAPI (always use the legacy Data API). * - `'scapi'`: force SCAPI (requires shortCode + tenantId; fails loudly if scopes missing). - * - `'auto'`: prefer SCAPI when configured, transparently fall back to OCAPI on `invalid_scope`. + * - `'auto'`: prefer SCAPI when configured, with temporary safe OCAPI fallback. */ export type ApiBackendPreference = 'ocapi' | 'scapi' | 'auto'; @@ -75,14 +76,56 @@ export class ScapiCapabilityUnsupportedError extends Error { } } +/** + * HTTP statuses that prove SCAPI rejected a request before performing it. + * + * These are safe for the temporary `auto` compatibility mode to retry over + * OCAPI. Ambiguous responses (`429`, `5xx`) and network failures are excluded + * because a mutating request might already have reached the platform. + */ +export const SAFE_SCAPI_FALLBACK_STATUSES = new Set([400, 401, 403, 404, 405, 406, 415]); + +/** + * A structured SCAPI response failure. Backends must retain the response + * status so the shared fallback policy can distinguish a definite rejection + * from an ambiguous transport/server failure. + */ +export class ScapiRequestError extends Error { + constructor( + message: string, + /** HTTP status returned by SCAPI. */ + public readonly status: number, + options?: ErrorOptions, + ) { + super(message, options); + this.name = 'ScapiRequestError'; + } +} + +/** Creates a structured SCAPI error using the repository's common formatter. */ +export function createScapiRequestError( + error: unknown, + response: Response | {status: number; statusText: string}, + fallbackMessage: string, +): ScapiRequestError { + const message = error ? getApiErrorMessage(error, response) : fallbackMessage; + return new ScapiRequestError(message || fallbackMessage, response.status, {cause: error}); +} + /** * Detects whether an error should trigger an OCAPI fallback. Currently: * - {@link isInvalidScopeError}: AM rejected the requested scope. * - {@link ScapiCapabilityUnsupportedError}: the SCAPI surface lacks the * capability the caller asked for. + * - {@link ScapiRequestError}: SCAPI definitively rejected the request with + * a safe client-error status. */ export function isFallbackTrigger(error: unknown): boolean { - return isInvalidScopeError(error) || error instanceof ScapiCapabilityUnsupportedError; + return ( + isInvalidScopeError(error) || + error instanceof ScapiCapabilityUnsupportedError || + (error instanceof ScapiRequestError && SAFE_SCAPI_FALLBACK_STATUSES.has(error.status)) + ); } /** diff --git a/packages/b2c-tooling-sdk/src/clients/scapi-catalogs.generated.ts b/packages/b2c-tooling-sdk/src/clients/scapi-catalogs.generated.ts new file mode 100644 index 000000000..ce410ae26 --- /dev/null +++ b/packages/b2c-tooling-sdk/src/clients/scapi-catalogs.generated.ts @@ -0,0 +1,105 @@ +/** + * This file was auto-generated by openapi-typescript. + * Do not make direct changes to the file. + */ + +export interface paths { + "/organizations/{organizationId}/catalogs": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getCatalogs"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +export type webhooks = Record; +export interface components { + schemas: { + Catalog: { + id: string; + name?: { + [key: string]: string; + }; + description?: { + [key: string]: string; + }; + online?: boolean; + } & { + [key: string]: unknown; + }; + Catalogs: { + data: components["schemas"]["Catalog"][]; + limit: number; + offset: number; + total: number; + }; + ErrorResponse: { + title: string; + type: string; + detail: string; + instance?: string; + } & { + [key: string]: unknown; + }; + }; + responses: never; + parameters: never; + requestBodies: never; + headers: never; + pathItems: never; +} +export type $defs = Record; +export interface operations { + getCatalogs: { + parameters: { + query?: { + limit?: number; + offset?: number; + }; + header?: never; + path: { + organizationId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Catalogs retrieved successfully. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Catalogs"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; +} diff --git a/packages/b2c-tooling-sdk/src/clients/scapi-catalogs.ts b/packages/b2c-tooling-sdk/src/clients/scapi-catalogs.ts new file mode 100644 index 000000000..417e1a435 --- /dev/null +++ b/packages/b2c-tooling-sdk/src/clients/scapi-catalogs.ts @@ -0,0 +1,34 @@ +/* + * 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 type {Client} from 'openapi-fetch'; +import type {AuthStrategy} from '../auth/types.js'; +import type {paths, components} from './scapi-catalogs.generated.js'; +import {buildScapiClient, type ScapiClientConfig} from './scapi-client-factory.js'; +import type {ScopeCascade} from './middleware.js'; + +export type {paths, components}; +export type ScapiCatalogsClient = Client; +export type ScapiCatalogsClientConfig = ScapiClientConfig; +export type Catalog = components['schemas']['Catalog']; +export type Catalogs = components['schemas']['Catalogs']; + +export const SCAPI_CATALOGS_CASCADE: ScopeCascade = { + read: [['sfcc.catalogs.rw'], ['sfcc.catalogs']], + write: [['sfcc.catalogs.rw']], +}; + +export function createScapiCatalogsClient(config: ScapiCatalogsClientConfig, auth: AuthStrategy): ScapiCatalogsClient { + return buildScapiClient( + { + pathSegment: 'product/catalogs/v1', + domainKey: 'scapi-catalogs', + scopeCascade: SCAPI_CATALOGS_CASCADE, + logPrefix: 'SCAPI-CATALOGS', + }, + config, + auth, + ); +} diff --git a/packages/b2c-tooling-sdk/src/clients/scapi-fallback-backend.ts b/packages/b2c-tooling-sdk/src/clients/scapi-fallback-backend.ts index eb197bf93..3cfb6203e 100644 --- a/packages/b2c-tooling-sdk/src/clients/scapi-fallback-backend.ts +++ b/packages/b2c-tooling-sdk/src/clients/scapi-fallback-backend.ts @@ -8,8 +8,8 @@ * * Builds a Proxy that implements the same interface as the underlying * backends. Each method call routes through {@link withFallback}: try SCAPI - * first; on a recognized fallback trigger (e.g. `invalid_scope` or a - * SCAPI-side capability gap such as a downgraded scope tier), fall back to + * first; on a recognized safe fallback trigger (for example `invalid_scope`, + * a typed rejected HTTP response, or a SCAPI-side capability gap), fall back to * OCAPI for that call and pin to OCAPI for the rest of the wrapper's life. * Note that a successful SCAPI call only pins *softly* — a later call that * trips a fallback trigger still routes to OCAPI and re-pins, so a flow @@ -76,7 +76,7 @@ async function withFallback( * Creates a fallback wrapper over `scapi` and `ocapi` backends. * * The returned object presents the same interface as `T`. Method calls are - * intercepted: the first call tries SCAPI; on `invalid_scope` it falls back + * intercepted: the first call tries SCAPI; on a safe fallback trigger it falls back * to OCAPI. The choice is cached for the wrapper's lifetime. * * **Contract:** diff --git a/packages/b2c-tooling-sdk/src/clients/scapi-sites.generated.ts b/packages/b2c-tooling-sdk/src/clients/scapi-sites.generated.ts index 8c968eb8c..3d993b023 100644 --- a/packages/b2c-tooling-sdk/src/clients/scapi-sites.generated.ts +++ b/packages/b2c-tooling-sdk/src/clients/scapi-sites.generated.ts @@ -52,6 +52,22 @@ export interface paths { patch?: never; trace?: never; }; + "/organizations/{organizationId}/sites/{siteId}/custom-cartridges": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getSiteCustomCartridges"]; + put: operations["replaceSiteCustomCartridges"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; } export type webhooks = Record; export interface components { @@ -194,7 +210,8 @@ export interface components { /** @enum {string} */ storefrontStatus?: "online" | "maintenance" | "to_be_deleted" | "protected"; siteCatalogId?: string; - cartridges?: string; + readonly cartridges?: string; + customCartridges?: string; /** Format: date-time */ creationDate?: string; /** Format: date-time */ @@ -215,6 +232,9 @@ export interface components { Sites: { data: components["schemas"]["Site"][]; } & components["schemas"]["PaginatedResultBase"]; + SiteCustomCartridges: { + customCartridges: string; + }; }; responses: { /** @description Your access token is invalid or expired and can’t be used to identify a user. */ @@ -393,6 +413,87 @@ export interface operations { }; }; }; + getSiteCustomCartridges: { + parameters: { + query?: never; + header?: never; + path: { + organizationId: components["schemas"]["OrganizationId"]; + siteId: components["schemas"]["SiteId"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Returns the site's custom cartridge path */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SiteCustomCartridges"]; + }; + }; + 401: components["responses"]["401unauthorized"]; + 403: components["responses"]["403forbidden"]; + /** @description Site Not Found - The requested site ID does not exist */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + replaceSiteCustomCartridges: { + parameters: { + query?: never; + header?: never; + path: { + organizationId: components["schemas"]["OrganizationId"]; + siteId: components["schemas"]["SiteId"]; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SiteCustomCartridges"]; + }; + }; + responses: { + /** @description Custom cartridge path successfully replaced */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SiteCustomCartridges"]; + }; + }; + /** @description Bad Request - Invalid cartridge path */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorResponse"]; + }; + }; + 401: components["responses"]["401unauthorized"]; + 403: components["responses"]["403forbidden"]; + /** @description Site Not Found - The requested site ID does not exist */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; } type WithRequired = T & { [P in K]-?: T[P]; diff --git a/packages/b2c-tooling-sdk/src/clients/scapi-sites.ts b/packages/b2c-tooling-sdk/src/clients/scapi-sites.ts index 7852f8574..3d7892fc2 100644 --- a/packages/b2c-tooling-sdk/src/clients/scapi-sites.ts +++ b/packages/b2c-tooling-sdk/src/clients/scapi-sites.ts @@ -19,16 +19,16 @@ export type ScapiSitesError = components['schemas']['ErrorResponse']; export type Site = components['schemas']['Site']; export type Sites = components['schemas']['Sites']; export type SiteSearchResult = components['schemas']['SiteSearchResult']; +export type SiteCustomCartridges = components['schemas']['SiteCustomCartridges']; /** * Per-operation scope cascade for SCAPI Sites. * - * The Sites API is read-only (list, get, search), but exposes both a - * read-only (`sfcc.sites`) and read-write (`sfcc.sites.rw`) scope. A given API + * The Sites API exposes both read and cartridge-path write operations and + * supports both a read-only (`sfcc.sites`) and read-write (`sfcc.sites.rw`) scope. A given API * client may have been granted only one of them, so reads try `rw` first - * (which also grants read) and fall back to the read-only scope. There are no - * write operations, but the `write` tier is defined for completeness so the - * cascade type is satisfied. + * (which also grants read) and fall back to the read-only scope. Write + * operations use the rw tier exclusively. */ export const SCAPI_SITES_CASCADE: ScopeCascade = { read: [['sfcc.sites.rw'], ['sfcc.sites']], diff --git a/packages/b2c-tooling-sdk/src/compat/dispatcher.ts b/packages/b2c-tooling-sdk/src/compat/dispatcher.ts index 474443bef..371a9b3d9 100644 --- a/packages/b2c-tooling-sdk/src/compat/dispatcher.ts +++ b/packages/b2c-tooling-sdk/src/compat/dispatcher.ts @@ -50,7 +50,7 @@ * @module compat/dispatcher */ import {getLogger} from '../logging/logger.js'; -import {isInvalidScopeError, type ApiBackendPreference} from '../clients/scapi-backend-utils.js'; +import {isFallbackTrigger, type ApiBackendPreference} from '../clients/scapi-backend-utils.js'; export type {ApiBackendPreference}; @@ -70,7 +70,7 @@ export interface DispatchBranches { /** * Stateful router that runs SCAPI optimistically and falls back to OCAPI - * once on `invalid_scope`, caching the choice for the lifetime of the + * once on a safe capability/auth/request rejection, caching the choice for the lifetime of the * dispatcher. See the module-level docs for the full rationale. * * Construct one per logical operation (e.g. one per CLI command run, or @@ -120,9 +120,8 @@ export class BackendDispatcher { /** * Runs the operation against the resolved backend. If unresolved (auto - * with SCAPI configured), tries SCAPI first; on `invalid_scope`, falls - * back to OCAPI and caches the choice. Other errors propagate without - * fallback. + * with SCAPI configured), tries SCAPI first; on a safe fallback trigger, + * falls back to OCAPI and caches the choice. Ambiguous failures propagate. */ async run(branches: DispatchBranches): Promise { if (this.resolved === 'ocapi') return branches.ocapi(); @@ -133,8 +132,8 @@ export class BackendDispatcher { this.resolved = 'scapi'; return result; } catch (error) { - if (isInvalidScopeError(error)) { - getLogger().info(`SCAPI ${this.domainName} scope unavailable, falling back to OCAPI`); + if (isFallbackTrigger(error)) { + getLogger().info(`SCAPI ${this.domainName} unavailable for this operation, falling back to OCAPI`); this.resolved = 'ocapi'; return branches.ocapi(); } diff --git a/packages/b2c-tooling-sdk/src/compat/index.ts b/packages/b2c-tooling-sdk/src/compat/index.ts index f84b73c09..458a1f803 100644 --- a/packages/b2c-tooling-sdk/src/compat/index.ts +++ b/packages/b2c-tooling-sdk/src/compat/index.ts @@ -12,3 +12,4 @@ */ export {BackendDispatcher} from './dispatcher.js'; export type {ApiBackendPreference, ResolvedBackend, DispatchBranches} from './dispatcher.js'; +export {createJobsCompatibilityBackend, JobsCompatibilityBackend} from './jobs-backend.js'; diff --git a/packages/b2c-tooling-sdk/src/compat/jobs-backend.ts b/packages/b2c-tooling-sdk/src/compat/jobs-backend.ts new file mode 100644 index 000000000..189d44fb4 --- /dev/null +++ b/packages/b2c-tooling-sdk/src/compat/jobs-backend.ts @@ -0,0 +1,126 @@ +/* + * 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 + */ +/** + * Explicit compatibility backend for consumers that need SCAPI-first job + * operations while they still consume the legacy OCAPI response shapes. + * + * This wrapper is transitional. New SDK integrations should select and call + * the SCAPI or OCAPI operations directly. Product surfaces can use this class + * to keep one fallback decision pinned for an entire execute/poll sequence. + * + * @module compat/jobs-backend + */ +import type {B2CInstance} from '../instance/index.js'; +import {createScapiJobsClient, type ScapiJobsClient} from '../clients/scapi-jobs.js'; +import { + executeJob as ocapiExecuteJob, + getJobExecution as ocapiGetJobExecution, + searchJobExecutions as ocapiSearchJobExecutions, + type ExecuteJobOptions, + type JobExecution, + type JobExecutionSearchResult, + JobExecutionError, + type SearchJobExecutionsOptions, + type WaitForJobOptions, +} from '../operations/jobs/run.js'; +import { + executeJob as scapiExecuteJob, + getJobExecution as scapiGetJobExecution, + searchJobExecutions as scapiSearchJobExecutions, +} from '../operations/jobs/scapi-ops.js'; +import {mapCanonicalToOcapiExecution, mapOcapiExecution} from '../operations/jobs/ocapi-mapping.js'; +import {CanonicalJobExecutionError, waitForJobExecution} from '../operations/jobs/wait-canonical.js'; +import {BackendDispatcher, type ApiBackendPreference, type ResolvedBackend} from './dispatcher.js'; + +/** + * Stateful, explicit compatibility surface for a single logical job operation. + */ +export class JobsCompatibilityBackend { + private readonly dispatcher: BackendDispatcher; + + constructor( + private readonly instance: B2CInstance, + preference: ApiBackendPreference = instance.apiBackend, + ) { + this.dispatcher = new BackendDispatcher(preference, () => this.createScapiClient(), 'jobs'); + } + + /** Backend selected after the first request. */ + get active(): ResolvedBackend | undefined { + return this.dispatcher.active; + } + + async executeJob(jobId: string, options: ExecuteJobOptions = {}): Promise { + return this.dispatcher.run({ + scapi: async (client) => + mapCanonicalToOcapiExecution( + await scapiExecuteJob(client, jobId, {...options, tenantId: this.requireTenantId()}), + ), + ocapi: () => ocapiExecuteJob(this.instance, jobId, options), + }); + } + + async getJobExecution(jobId: string, executionId: string): Promise { + return this.dispatcher.run({ + scapi: async (client) => + mapCanonicalToOcapiExecution(await scapiGetJobExecution(client, jobId, executionId, this.requireTenantId())), + ocapi: () => ocapiGetJobExecution(this.instance, jobId, executionId), + }); + } + + async searchJobExecutions(options: SearchJobExecutionsOptions = {}): Promise { + return this.dispatcher.run({ + scapi: async (client) => { + const result = await scapiSearchJobExecutions(client, {...options, tenantId: this.requireTenantId()}); + return { + total: result.total, + count: result.limit, + start: result.offset, + hits: result.hits.map(mapCanonicalToOcapiExecution), + }; + }, + ocapi: () => ocapiSearchJobExecutions(this.instance, options), + }); + } + + async waitForJob(jobId: string, executionId: string, options: WaitForJobOptions = {}): Promise { + try { + const result = await waitForJobExecution( + async (currentJobId, currentExecutionId) => + mapOcapiExecution(await this.getJobExecution(currentJobId, currentExecutionId)), + jobId, + executionId, + options, + ); + return mapCanonicalToOcapiExecution(result); + } catch (error) { + if (error instanceof CanonicalJobExecutionError) { + throw new JobExecutionError(error.message, mapCanonicalToOcapiExecution(error.execution)); + } + throw error; + } + } + + private createScapiClient(): ScapiJobsClient | undefined { + const config = this.instance.scapiClientConfig; + if (!config) return undefined; + return createScapiJobsClient({shortCode: config.shortCode, tenantId: config.tenantId}, config.auth); + } + + private requireTenantId(): string { + const tenantId = this.instance.scapiClientConfig?.tenantId; + if (!tenantId) throw new Error('Jobs SCAPI backend requires a tenantId'); + return tenantId; + } +} + +/** Create an explicit SCAPI-first/OCAPI-compatible jobs backend. */ +export function createJobsCompatibilityBackend( + instance: B2CInstance, + preference: ApiBackendPreference = instance.apiBackend, +): JobsCompatibilityBackend { + return new JobsCompatibilityBackend(instance, preference); +} diff --git a/packages/b2c-tooling-sdk/src/index.ts b/packages/b2c-tooling-sdk/src/index.ts index 25790a6d5..6e05d0c23 100644 --- a/packages/b2c-tooling-sdk/src/index.ts +++ b/packages/b2c-tooling-sdk/src/index.ts @@ -257,6 +257,7 @@ export type { UserInfo, ListUsersResult, ListUsersOptions, + SearchUsersOptions, CreateUserInput, UpdateUserChanges, ScapiUsersBackendConfig, @@ -275,6 +276,19 @@ export type { ScapiRolesBackendConfig, } from './operations/bm-roles/index.js'; +// Catalog backend abstraction +export {createCatalogsBackend, OcapiCatalogsBackend, ScapiCatalogsBackend} from './operations/catalogs/index.js'; +export type { + CatalogsBackend, + CatalogsBackendConfig, + CatalogInfo, + ListCatalogsOptions, + ScapiCatalogsBackendConfig, +} from './operations/catalogs/index.js'; + +// Explicit transitional fallback surfaces +export {createJobsCompatibilityBackend, JobsCompatibilityBackend} from './compat/index.js'; + // Operations - Jobs export { executeJob, diff --git a/packages/b2c-tooling-sdk/src/operations/bm-roles/ocapi-backend.ts b/packages/b2c-tooling-sdk/src/operations/bm-roles/ocapi-backend.ts index ff0d0ddad..16792f4c5 100644 --- a/packages/b2c-tooling-sdk/src/operations/bm-roles/ocapi-backend.ts +++ b/packages/b2c-tooling-sdk/src/operations/bm-roles/ocapi-backend.ts @@ -4,6 +4,8 @@ * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 */ import type {B2CInstance} from '../../instance/index.js'; +import type {components as OcapiComponents} from '../../clients/ocapi.generated.js'; +import type {components as ScapiComponents} from '../../clients/scapi-merchant-roles.generated.js'; import type { RolesBackend, RoleInfo, @@ -38,10 +40,52 @@ function mapOcapiRole(ocapi: BmRole): RoleInfo { }; } -type LocalePermissionOcapi = {locale_id?: string; type?: string; values?: string[]; display_name?: unknown}; -type WebdavPermissionOcapi = {folder?: string; type?: string; values?: string[]}; -type ModulePermissionOcapi = {application?: string; name?: string; values?: string[]}; -type FunctionalPermissionOcapi = {name?: string; values?: string[]}; +type OcapiModulePermission = OcapiComponents['schemas']['role_module_permission']; +type OcapiFunctionalPermission = OcapiComponents['schemas']['role_functional_permission']; +type OcapiLocalePermission = OcapiComponents['schemas']['role_locale_permission']; +type OcapiWebdavPermission = OcapiComponents['schemas']['role_webdav_permission']; +type ScapiModulePermission = ScapiComponents['schemas']['RoleModulePermission']; +type ScapiFunctionalPermission = ScapiComponents['schemas']['RoleFunctionalPermission']; +type ScapiLocalePermission = ScapiComponents['schemas']['RoleLocalePermission']; +type ScapiWebdavPermission = ScapiComponents['schemas']['RoleWebdavPermission']; + +function mapOcapiModulePermission(permission: OcapiModulePermission): ScapiModulePermission { + return { + application: permission.application, + name: permission.name, + type: permission.type, + system: permission.system, + value: permission.value, + values: permission.values, + }; +} + +function mapOcapiFunctionalPermission(permission: OcapiFunctionalPermission): ScapiFunctionalPermission { + return { + name: permission.name, + type: permission.type, + value: permission.value, + values: permission.values, + }; +} + +function mapOcapiLocalePermission(permission: OcapiLocalePermission): ScapiLocalePermission { + return { + localeId: permission.locale_id, + type: permission.type, + value: permission.value, + values: permission.values, + }; +} + +function mapOcapiWebdavPermission(permission: OcapiWebdavPermission): ScapiWebdavPermission { + return { + folder: permission.folder, + type: permission.type, + value: permission.value, + values: permission.values, + }; +} function mapOcapiPermissions(ocapi: BmRolePermissions): RolePermissionsInfo { // OCAPI uses snake_case for innermost permission fields (locale_id, etc.) @@ -49,46 +93,24 @@ function mapOcapiPermissions(ocapi: BmRolePermissions): RolePermissionsInfo { const result: Record = {}; if (ocapi.module) { result.module = { - organization: ((ocapi.module.organization ?? []) as ModulePermissionOcapi[]).map((p) => ({ - application: p.application, - name: p.name, - values: p.values, - })), - site: ((ocapi.module.site ?? []) as ModulePermissionOcapi[]).map((p) => ({ - application: p.application, - name: p.name, - values: p.values, - })), + organization: (ocapi.module.organization ?? []).map(mapOcapiModulePermission), + site: (ocapi.module.site ?? []).map(mapOcapiModulePermission), }; } if (ocapi.functional) { result.functional = { - organization: ((ocapi.functional.organization ?? []) as FunctionalPermissionOcapi[]).map((p) => ({ - name: p.name, - values: p.values, - })), - site: ((ocapi.functional.site ?? []) as FunctionalPermissionOcapi[]).map((p) => ({ - name: p.name, - values: p.values, - })), + organization: (ocapi.functional.organization ?? []).map(mapOcapiFunctionalPermission), + site: (ocapi.functional.site ?? []).map(mapOcapiFunctionalPermission), }; } if (ocapi.locale) { result.locale = { - unscoped: ((ocapi.locale.unscoped ?? []) as LocalePermissionOcapi[]).map((p) => ({ - localeId: p.locale_id, - type: p.type, - values: p.values, - })), + unscoped: (ocapi.locale.unscoped ?? []).map(mapOcapiLocalePermission), }; } if (ocapi.webdav) { result.webdav = { - unscoped: ((ocapi.webdav.unscoped ?? []) as WebdavPermissionOcapi[]).map((p) => ({ - folder: p.folder, - type: p.type, - values: p.values, - })), + unscoped: (ocapi.webdav.unscoped ?? []).map(mapOcapiWebdavPermission), }; } return result as RolePermissionsInfo; @@ -98,23 +120,31 @@ function mapScapiPermissionsToOcapi(perms: RolePermissionsInfo): BmRolePermissio // Reverse: camelCase → snake_case for the inner locale field. const result: Record = {}; if (perms.module) { - result.module = perms.module; + result.module = { + organization: (perms.module.organization ?? []).map((permission) => ({...permission})), + site: (perms.module.site ?? []).map((permission) => ({...permission})), + }; } if (perms.functional) { - result.functional = perms.functional; + result.functional = { + organization: (perms.functional.organization ?? []).map((permission) => ({...permission})), + site: (perms.functional.site ?? []).map((permission) => ({...permission})), + }; } if (perms.locale) { - type LocaleScapi = {localeId?: string; type?: string; values?: unknown}; result.locale = { - unscoped: ((perms.locale.unscoped ?? []) as LocaleScapi[]).map((p) => ({ - locale_id: p.localeId, - type: p.type, - values: p.values, + unscoped: (perms.locale.unscoped ?? []).map((permission) => ({ + locale_id: permission.localeId, + type: permission.type, + value: permission.value, + values: permission.values, })), }; } if (perms.webdav) { - result.webdav = perms.webdav; + result.webdav = { + unscoped: (perms.webdav.unscoped ?? []).map((permission) => ({...permission})), + }; } return result as BmRolePermissions; } diff --git a/packages/b2c-tooling-sdk/src/operations/bm-roles/scapi-backend.ts b/packages/b2c-tooling-sdk/src/operations/bm-roles/scapi-backend.ts index a79b03744..9d5016a8b 100644 --- a/packages/b2c-tooling-sdk/src/operations/bm-roles/scapi-backend.ts +++ b/packages/b2c-tooling-sdk/src/operations/bm-roles/scapi-backend.ts @@ -23,6 +23,7 @@ import { } from '../../clients/scapi-merchant-roles.js'; import {buildTenantScope, toOrganizationId} from '../../clients/custom-apis.js'; import {ScopeTierManager} from '../../clients/scapi-scope-tier.js'; +import {createScapiRequestError} from '../../clients/scapi-backend-utils.js'; function mapScapiRole(scapi: ScapiRole): RoleInfo { return { @@ -63,14 +64,14 @@ export class ScapiRolesBackend implements RolesBackend { const {start = 0, count = 25, expand} = options; return this.scopeTier.tryRead(async (client) => { - const {data, error} = await client.GET('/organizations/{organizationId}/roles', { + const {data, error, response} = await client.GET('/organizations/{organizationId}/roles', { params: { path: {organizationId: this.organizationId}, query: {limit: count, offset: start, expand}, }, }); if (error || !data) { - throw new Error(toErrorMessage(error, 'Failed to list roles')); + throw createScapiRequestError(error, response, 'Failed to list roles'); } const result = data as RoleSearch; return { @@ -84,14 +85,14 @@ export class ScapiRolesBackend implements RolesBackend { async getRole(roleId: string, options?: {expand?: ('users' | 'permissions')[]}): Promise { return this.scopeTier.tryRead(async (client) => { - const {data, error} = await client.GET('/organizations/{organizationId}/roles/{roleId}', { + const {data, error, response} = await client.GET('/organizations/{organizationId}/roles/{roleId}', { params: { path: {organizationId: this.organizationId, roleId}, query: {expand: options?.expand}, }, }); if (error || !data) { - throw new Error(toErrorMessage(error, `Failed to get role ${roleId}`)); + throw createScapiRequestError(error, response, `Failed to get role ${roleId}`); } return mapScapiRole(data); }); @@ -103,33 +104,33 @@ export class ScapiRolesBackend implements RolesBackend { id: roleId, description: input?.description, }; - const {data, error} = await client.PUT('/organizations/{organizationId}/roles/{roleId}', { + const {data, error, response} = await client.PUT('/organizations/{organizationId}/roles/{roleId}', { params: {path: {organizationId: this.organizationId, roleId}}, body, }); if (error || !data) { - throw new Error(toErrorMessage(error, `Failed to create role ${roleId}`)); + throw createScapiRequestError(error, response, `Failed to create role ${roleId}`); } return mapScapiRole(data); } async deleteRole(roleId: string): Promise { const client = this.scopeTier.getClientForWrite(); - const {error} = await client.DELETE('/organizations/{organizationId}/roles/{roleId}', { + const {error, response} = await client.DELETE('/organizations/{organizationId}/roles/{roleId}', { params: {path: {organizationId: this.organizationId, roleId}}, }); if (error) { - throw new Error(toErrorMessage(error, `Failed to delete role ${roleId}`)); + throw createScapiRequestError(error, response, `Failed to delete role ${roleId}`); } } async getPermissions(roleId: string): Promise { return this.scopeTier.tryRead(async (client) => { - const {data, error} = await client.GET('/organizations/{organizationId}/roles/{roleId}/permissions', { + const {data, error, response} = await client.GET('/organizations/{organizationId}/roles/{roleId}/permissions', { params: {path: {organizationId: this.organizationId, roleId}}, }); if (error || !data) { - throw new Error(toErrorMessage(error, `Failed to get permissions for role ${roleId}`)); + throw createScapiRequestError(error, response, `Failed to get permissions for role ${roleId}`); } return data; }); @@ -137,33 +138,33 @@ export class ScapiRolesBackend implements RolesBackend { async setPermissions(roleId: string, permissions: RolePermissionsInfo): Promise { const client = this.scopeTier.getClientForWrite(); - const {data, error} = await client.PUT('/organizations/{organizationId}/roles/{roleId}/permissions', { + const {data, error, response} = await client.PUT('/organizations/{organizationId}/roles/{roleId}/permissions', { params: {path: {organizationId: this.organizationId, roleId}}, body: permissions, }); if (error || !data) { - throw new Error(toErrorMessage(error, `Failed to set permissions for role ${roleId}`)); + throw createScapiRequestError(error, response, `Failed to set permissions for role ${roleId}`); } return data; } async grantRole(roleId: string, login: string): Promise { const client = this.scopeTier.getClientForWrite(); - const {error} = await client.PUT('/organizations/{organizationId}/roles/{roleId}/users/{login}', { + const {error, response} = await client.PUT('/organizations/{organizationId}/roles/{roleId}/users/{login}', { params: {path: {organizationId: this.organizationId, roleId, login}}, }); if (error) { - throw new Error(toErrorMessage(error, `Failed to grant role ${roleId} to ${login}`)); + throw createScapiRequestError(error, response, `Failed to grant role ${roleId} to ${login}`); } } async revokeRole(roleId: string, login: string): Promise { const client = this.scopeTier.getClientForWrite(); - const {error} = await client.DELETE('/organizations/{organizationId}/roles/{roleId}/users/{login}', { + const {error, response} = await client.DELETE('/organizations/{organizationId}/roles/{roleId}/users/{login}', { params: {path: {organizationId: this.organizationId, roleId, login}}, }); if (error) { - throw new Error(toErrorMessage(error, `Failed to revoke role ${roleId} from ${login}`)); + throw createScapiRequestError(error, response, `Failed to revoke role ${roleId} from ${login}`); } } @@ -176,8 +177,3 @@ export class ScapiRolesBackend implements RolesBackend { return createScapiMerchantRolesClient(clientConfig, this.config.auth); } } - -function toErrorMessage(error: unknown, fallback: string): string { - const e = error as {detail?: string; title?: string} | undefined; - return e?.detail ?? e?.title ?? fallback; -} diff --git a/packages/b2c-tooling-sdk/src/operations/bm-users/index.ts b/packages/b2c-tooling-sdk/src/operations/bm-users/index.ts index 6baaf5dce..48aca4beb 100644 --- a/packages/b2c-tooling-sdk/src/operations/bm-users/index.ts +++ b/packages/b2c-tooling-sdk/src/operations/bm-users/index.ts @@ -89,6 +89,7 @@ export type { UserInfo, ListUsersResult, ListUsersOptions, + SearchUsersOptions, CreateUserInput, UpdateUserChanges, } from './types.js'; diff --git a/packages/b2c-tooling-sdk/src/operations/bm-users/ocapi-backend.ts b/packages/b2c-tooling-sdk/src/operations/bm-users/ocapi-backend.ts index 691ee2b50..77ba63ce0 100644 --- a/packages/b2c-tooling-sdk/src/operations/bm-users/ocapi-backend.ts +++ b/packages/b2c-tooling-sdk/src/operations/bm-users/ocapi-backend.ts @@ -11,6 +11,7 @@ import type { ListUsersOptions, UpdateUserChanges, CreateUserInput, + SearchUsersOptions, } from './types.js'; import { listBmUsers as ocapiListBmUsers, @@ -18,6 +19,7 @@ import { updateBmUser as ocapiUpdateBmUser, deleteBmUser as ocapiDeleteBmUser, type BmUser, + searchBmUsers as ocapiSearchBmUsers, } from './users.js'; import {throwOcapiError} from '../../clients/error-utils.js'; import {SCAPI_MERCHANT_USERS_RW_SCOPES} from '../../clients/scapi-merchant-users.js'; @@ -63,6 +65,17 @@ export class OcapiUsersBackend implements UsersBackend { return mapOcapiUser(user); } + async searchUsers(options: SearchUsersOptions = {}): Promise { + const result = await ocapiSearchBmUsers(this.instance, options); + const users = (result.hits ?? []) as BmUser[]; + return { + total: result.total ?? 0, + start: result.start ?? options.start ?? 0, + count: result.count ?? users.length, + hits: users.map(mapOcapiUser), + }; + } + async createOrReplaceUser(login: string, input: CreateUserInput): Promise { // Map canonical camelCase → OCAPI snake_case. const body: Record = { diff --git a/packages/b2c-tooling-sdk/src/operations/bm-users/scapi-backend.ts b/packages/b2c-tooling-sdk/src/operations/bm-users/scapi-backend.ts index 126af842e..1948c16a8 100644 --- a/packages/b2c-tooling-sdk/src/operations/bm-users/scapi-backend.ts +++ b/packages/b2c-tooling-sdk/src/operations/bm-users/scapi-backend.ts @@ -11,6 +11,7 @@ import type { ListUsersOptions, UpdateUserChanges, CreateUserInput, + SearchUsersOptions, } from './types.js'; import { createScapiMerchantUsersClient, @@ -23,7 +24,7 @@ import { type UserSearch, } from '../../clients/scapi-merchant-users.js'; import {buildTenantScope, toOrganizationId} from '../../clients/custom-apis.js'; -import {ScapiCapabilityUnsupportedError} from '../../clients/scapi-backend-utils.js'; +import {createScapiRequestError, ScapiCapabilityUnsupportedError} from '../../clients/scapi-backend-utils.js'; import {ScopeTierManager} from '../../clients/scapi-scope-tier.js'; function mapScapiUser(scapi: ScapiUser): UserInfo { @@ -73,14 +74,14 @@ export class ScapiUsersBackend implements UsersBackend { const {start = 0, count = 25} = options; return this.scopeTier.tryRead(async (client) => { - const {data, error} = await client.GET('/organizations/{organizationId}/users', { + const {data, error, response} = await client.GET('/organizations/{organizationId}/users', { params: { path: {organizationId: this.organizationId}, query: {limit: count, offset: start}, }, }); if (error || !data) { - throw new Error(toErrorMessage(error, 'Failed to list users')); + throw createScapiRequestError(error, response, 'Failed to list users'); } const result = data as UserSearch; return { @@ -92,13 +93,58 @@ export class ScapiUsersBackend implements UsersBackend { }); } + async searchUsers(options: SearchUsersOptions = {}): Promise { + if (options.query !== undefined) { + throw new ScapiCapabilityUnsupportedError( + 'Raw OCAPI user-search query JSON is not supported by SCAPI. Use portable search flags or --api-backend ocapi.', + ); + } + + const all: UserInfo[] = []; + let offset = 0; + const pageSize = 200; + do { + const page = await this.listUsers({start: offset, count: pageSize}); + all.push(...page.hits); + offset += page.hits.length; + if (page.hits.length === 0 || offset >= page.total) break; + } while (true); + + const phrase = options.searchPhrase?.toLocaleLowerCase(); + const filtered = all.filter((user) => { + if (options.login !== undefined && user.login !== options.login) return false; + if (options.email !== undefined && user.email !== options.email) return false; + if (options.locked !== undefined && user.locked !== options.locked) return false; + if (options.disabled !== undefined && user.disabled !== options.disabled) return false; + if (!phrase) return true; + return [user.login, user.email, user.firstName, user.lastName].some((value) => + value?.toLocaleLowerCase().includes(phrase), + ); + }); + + if (options.sortBy) { + const field = toCanonicalSortField(options.sortBy); + const direction = options.sortOrder === 'desc' ? -1 : 1; + filtered.sort( + (left, right) => + String(left[field] ?? '').localeCompare(String(right[field] ?? ''), undefined, {sensitivity: 'base'}) * + direction, + ); + } + + const start = options.start ?? 0; + const count = options.count ?? 25; + const hits = filtered.slice(start, start + count); + return {total: filtered.length, start, count: hits.length, hits}; + } + async getUser(login: string): Promise { return this.scopeTier.tryRead(async (client) => { - const {data, error} = await client.GET('/organizations/{organizationId}/users/{login}', { + const {data, error, response} = await client.GET('/organizations/{organizationId}/users/{login}', { params: {path: {organizationId: this.organizationId, login}}, }); if (error || !data) { - throw new Error(toErrorMessage(error, `Failed to get user ${login}`)); + throw createScapiRequestError(error, response, `Failed to get user ${login}`); } return mapScapiUser(data); }); @@ -118,20 +164,38 @@ export class ScapiUsersBackend implements UsersBackend { preferredUiLocale: input.preferredUiLocale, roles: input.roles, }; - const {data, error} = await client.PUT('/organizations/{organizationId}/users/{login}', { + const {data, error, response} = await client.PUT('/organizations/{organizationId}/users/{login}', { params: {path: {organizationId: this.organizationId, login}}, body, }); if (error || !data) { - throw new Error(toErrorMessage(error, `Failed to create user ${login}`)); + throw createScapiRequestError(error, response, `Failed to create user ${login}`); } return mapScapiUser(data); } async updateUser(login: string, changes: UpdateUserChanges): Promise { + // PATCH does not expose `disabled`, but the live API's replace operation + // does. Preserve the current writable fields and use PUT for that case. + if (changes.disabled !== undefined) { + const current = await this.getUser(login); + if (!current.email) { + throw new Error(`Cannot update disabled status for ${login}: the current user response has no email`); + } + return this.createOrReplaceUser(login, { + login, + email: changes.email ?? current.email, + firstName: changes.firstName ?? current.firstName, + lastName: changes.lastName ?? current.lastName, + externalId: changes.externalId ?? current.externalId, + disabled: changes.disabled, + preferredDataLocale: changes.preferredDataLocale ?? current.preferredDataLocale, + preferredUiLocale: changes.preferredUiLocale ?? current.preferredUiLocale, + roles: current.roles, + }); + } + const client = this.scopeTier.getClientForWrite(); - // SCAPI UserUpdateRequest doesn't include `disabled`. To toggle disabled, - // callers must use createOrReplaceUser (PUT) on SCAPI or the OCAPI backend. const body: UserUpdateRequest = { email: changes.email, firstName: changes.firstName, @@ -140,32 +204,23 @@ export class ScapiUsersBackend implements UsersBackend { preferredDataLocale: changes.preferredDataLocale, preferredUiLocale: changes.preferredUiLocale, }; - if (changes.disabled !== undefined) { - // Recognized as a fallback trigger by the SCAPI/OCAPI fallback wrapper: - // in `auto` mode this transparently routes the update through OCAPI; - // in explicit `scapi` mode the message surfaces to the user. - throw new ScapiCapabilityUnsupportedError( - 'SCAPI Users API does not support updating the `disabled` flag via PATCH. ' + - 'Use --api-backend ocapi (or auto) to change disabled status.', - ); - } - const {data, error} = await client.PATCH('/organizations/{organizationId}/users/{login}', { + const {data, error, response} = await client.PATCH('/organizations/{organizationId}/users/{login}', { params: {path: {organizationId: this.organizationId, login}}, body, }); if (error || !data) { - throw new Error(toErrorMessage(error, `Failed to update user ${login}`)); + throw createScapiRequestError(error, response, `Failed to update user ${login}`); } return mapScapiUser(data); } async deleteUser(login: string): Promise { const client = this.scopeTier.getClientForWrite(); - const {error} = await client.DELETE('/organizations/{organizationId}/users/{login}', { + const {error, response} = await client.DELETE('/organizations/{organizationId}/users/{login}', { params: {path: {organizationId: this.organizationId, login}}, }); if (error) { - throw new Error(toErrorMessage(error, `Failed to delete user ${login}`)); + throw createScapiRequestError(error, response, `Failed to delete user ${login}`); } } @@ -179,7 +234,14 @@ export class ScapiUsersBackend implements UsersBackend { } } -function toErrorMessage(error: unknown, fallback: string): string { - const e = error as {detail?: string; title?: string} | undefined; - return e?.detail ?? e?.title ?? fallback; +function toCanonicalSortField(field: string): keyof UserInfo { + const fields: Record = { + first_name: 'firstName', + last_name: 'lastName', + external_id: 'externalId', + last_login_date: 'lastLoginDate', + is_locked: 'locked', + is_disabled: 'disabled', + }; + return fields[field] ?? (field as keyof UserInfo); } diff --git a/packages/b2c-tooling-sdk/src/operations/bm-users/types.ts b/packages/b2c-tooling-sdk/src/operations/bm-users/types.ts index 2948fdd16..b0fd2bf2f 100644 --- a/packages/b2c-tooling-sdk/src/operations/bm-users/types.ts +++ b/packages/b2c-tooling-sdk/src/operations/bm-users/types.ts @@ -65,6 +65,19 @@ export interface ListUsersOptions { count?: number; } +/** Portable user search criteria supported by both backends. */ +export interface SearchUsersOptions extends ListUsersOptions { + /** Raw OCAPI query. In auto mode this deliberately selects the OCAPI fallback. */ + query?: unknown; + searchPhrase?: string; + login?: string; + email?: string; + locked?: boolean; + disabled?: boolean; + sortBy?: string; + sortOrder?: 'asc' | 'desc'; +} + /** * Body for create/replace (PUT). Required: login. */ @@ -84,12 +97,13 @@ export interface CreateUserInput { /** * Backend contract for BM user operations. * - * Note: search and access-key operations remain OCAPI-only — they have - * no SCAPI equivalent in `merchant/users/v1`. The `whoami` operation is - * also OCAPI-only (resolves the BM identity behind the OAuth token). + * Merchant Users has no server-side search endpoint, so the SCAPI backend + * implements portable search criteria over its paginated user listing. + * Raw OCAPI query DSL, access keys, and `whoami` remain OCAPI-only. */ export interface UsersBackend extends BackendBase { listUsers(options?: ListUsersOptions): Promise; + searchUsers(options?: SearchUsersOptions): Promise; getUser(login: string): Promise; createOrReplaceUser(login: string, input: CreateUserInput): Promise; updateUser(login: string, changes: UpdateUserChanges): Promise; diff --git a/packages/b2c-tooling-sdk/src/operations/bm-users/users.ts b/packages/b2c-tooling-sdk/src/operations/bm-users/users.ts index fd4426549..1c4762e34 100644 --- a/packages/b2c-tooling-sdk/src/operations/bm-users/users.ts +++ b/packages/b2c-tooling-sdk/src/operations/bm-users/users.ts @@ -20,8 +20,8 @@ import {throwOcapiError} from '../../clients/error-utils.js'; import {SCAPI_MERCHANT_USERS_READ_SCOPES, SCAPI_MERCHANT_USERS_RW_SCOPES} from '../../clients/scapi-merchant-users.js'; // SCAPI Merchant Users scopes named in the OCAPI-deprecation message for the -// dual-backend operations (list/get/update/delete). search, whoami, and the -// access-key operations are OCAPI-only and use the generic guidance. +// legacy OCAPI free functions. Portable search is also exposed by the +// dual-backend interface; raw query DSL, whoami, and access keys stay here. const USERS_READ_SCOPES = [...SCAPI_MERCHANT_USERS_READ_SCOPES, ...SCAPI_MERCHANT_USERS_RW_SCOPES]; const USERS_RW_SCOPES = SCAPI_MERCHANT_USERS_RW_SCOPES; diff --git a/packages/b2c-tooling-sdk/src/operations/catalogs/catalogs-backend.ts b/packages/b2c-tooling-sdk/src/operations/catalogs/catalogs-backend.ts new file mode 100644 index 000000000..1ed5718dd --- /dev/null +++ b/packages/b2c-tooling-sdk/src/operations/catalogs/catalogs-backend.ts @@ -0,0 +1,19 @@ +/* + * 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 {createDualBackend, type DualBackendConfig} from '../../clients/dual-backend-factory.js'; +import type {CatalogsBackend} from './catalogs-types.js'; +import {OcapiCatalogsBackend} from './ocapi-catalogs-backend.js'; +import {ScapiCatalogsBackend} from './scapi-catalogs-backend.js'; + +export type CatalogsBackendConfig = DualBackendConfig; + +export function createCatalogsBackend(config: CatalogsBackendConfig): CatalogsBackend { + return createDualBackend(config, { + domainName: 'Catalogs', + Scapi: ScapiCatalogsBackend, + Ocapi: OcapiCatalogsBackend, + }); +} diff --git a/packages/b2c-tooling-sdk/src/operations/catalogs/catalogs-types.ts b/packages/b2c-tooling-sdk/src/operations/catalogs/catalogs-types.ts new file mode 100644 index 000000000..976ed74f2 --- /dev/null +++ b/packages/b2c-tooling-sdk/src/operations/catalogs/catalogs-types.ts @@ -0,0 +1,22 @@ +/* + * 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 type {BackendBase} from '../../clients/scapi-backend-utils.js'; + +export interface CatalogInfo { + id: string; + name?: string; + online?: boolean; + _raw?: unknown; +} + +export interface ListCatalogsOptions { + start?: number; + count?: number; +} + +export interface CatalogsBackend extends BackendBase { + listCatalogs(options?: ListCatalogsOptions): Promise; +} diff --git a/packages/b2c-tooling-sdk/src/operations/catalogs/index.ts b/packages/b2c-tooling-sdk/src/operations/catalogs/index.ts new file mode 100644 index 000000000..90f613dfe --- /dev/null +++ b/packages/b2c-tooling-sdk/src/operations/catalogs/index.ts @@ -0,0 +1,12 @@ +/* + * 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-first catalog operations with transitional OCAPI fallback. */ +export {createCatalogsBackend} from './catalogs-backend.js'; +export type {CatalogsBackendConfig} from './catalogs-backend.js'; +export {ScapiCatalogsBackend} from './scapi-catalogs-backend.js'; +export type {ScapiCatalogsBackendConfig} from './scapi-catalogs-backend.js'; +export {OcapiCatalogsBackend} from './ocapi-catalogs-backend.js'; +export type {CatalogInfo, CatalogsBackend, ListCatalogsOptions} from './catalogs-types.js'; diff --git a/packages/b2c-tooling-sdk/src/operations/catalogs/ocapi-catalogs-backend.ts b/packages/b2c-tooling-sdk/src/operations/catalogs/ocapi-catalogs-backend.ts new file mode 100644 index 000000000..5e7ec8281 --- /dev/null +++ b/packages/b2c-tooling-sdk/src/operations/catalogs/ocapi-catalogs-backend.ts @@ -0,0 +1,46 @@ +/* + * 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 type {B2CInstance} from '../../instance/index.js'; +import {throwOcapiError} from '../../clients/error-utils.js'; +import type {CatalogInfo, CatalogsBackend, ListCatalogsOptions} from './catalogs-types.js'; + +const PAGE_SIZE = 200; +const SCAPI_CATALOG_SCOPES = ['sfcc.catalogs.rw', 'sfcc.catalogs']; + +export class OcapiCatalogsBackend implements CatalogsBackend { + readonly name = 'ocapi' as const; + + constructor(private readonly instance: B2CInstance) {} + + async listCatalogs(options: ListCatalogsOptions = {}): Promise { + const start = options.start ?? 0; + const target = options.count; + const catalogs: CatalogInfo[] = []; + let offset = start; + + while (target === undefined || catalogs.length < target) { + const count = target === undefined ? PAGE_SIZE : Math.min(PAGE_SIZE, target - catalogs.length); + const {data, error, response} = await this.instance.ocapi.GET('/catalogs', { + params: {query: {start: offset, count, select: '(**)'}}, + }); + if (error || !data) throwOcapiError(error, response, 'Failed to list catalogs', SCAPI_CATALOG_SCOPES); + + const page = data.data ?? []; + catalogs.push( + ...page.map((catalog) => ({ + id: catalog.id ?? '', + name: catalog.name?.default, + online: catalog.online, + _raw: catalog, + })), + ); + offset += page.length; + if (page.length === 0 || offset >= (data.total ?? offset)) break; + } + + return catalogs; + } +} diff --git a/packages/b2c-tooling-sdk/src/operations/catalogs/scapi-catalogs-backend.ts b/packages/b2c-tooling-sdk/src/operations/catalogs/scapi-catalogs-backend.ts new file mode 100644 index 000000000..61f84132d --- /dev/null +++ b/packages/b2c-tooling-sdk/src/operations/catalogs/scapi-catalogs-backend.ts @@ -0,0 +1,64 @@ +/* + * 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 type {AuthStrategy} from '../../auth/types.js'; +import {createScapiRequestError} from '../../clients/scapi-backend-utils.js'; +import { + createScapiCatalogsClient, + type Catalog as ScapiCatalog, + type ScapiCatalogsClient, +} from '../../clients/scapi-catalogs.js'; +import {toOrganizationId} from '../../clients/custom-apis.js'; +import {SCOPE_MODE_HEADER} from '../../clients/middleware.js'; +import type {CatalogInfo, CatalogsBackend, ListCatalogsOptions} from './catalogs-types.js'; + +const MAX_PAGE = 50; +const READ_HEADERS = {[SCOPE_MODE_HEADER]: 'read'}; + +export interface ScapiCatalogsBackendConfig { + shortCode: string; + tenantId: string; + auth: AuthStrategy; + instance?: unknown; +} + +export class ScapiCatalogsBackend implements CatalogsBackend { + readonly name = 'scapi' as const; + private readonly client: ScapiCatalogsClient; + private readonly organizationId: string; + + constructor(config: ScapiCatalogsBackendConfig) { + this.organizationId = toOrganizationId(config.tenantId); + this.client = createScapiCatalogsClient(config, config.auth); + } + + async listCatalogs(options: ListCatalogsOptions = {}): Promise { + const start = options.start ?? 0; + const target = options.count; + const catalogs: ScapiCatalog[] = []; + let offset = start; + + while (target === undefined || catalogs.length < target) { + const limit = Math.min(MAX_PAGE, target === undefined ? MAX_PAGE : target - catalogs.length); + const {data, error, response} = await this.client.GET('/organizations/{organizationId}/catalogs', { + params: {path: {organizationId: this.organizationId}, query: {limit, offset}}, + headers: READ_HEADERS, + }); + if (error || !data) throw createScapiRequestError(error, response, 'Failed to list catalogs'); + + const page = data.data ?? []; + catalogs.push(...page); + offset += page.length; + if (page.length === 0 || offset >= data.total) break; + } + + return catalogs.map((catalog) => ({ + id: catalog.id, + name: catalog.name?.default ?? Object.values(catalog.name ?? {})[0], + online: catalog.online, + _raw: catalog, + })); + } +} diff --git a/packages/b2c-tooling-sdk/src/operations/code/deploy.ts b/packages/b2c-tooling-sdk/src/operations/code/deploy.ts index 31b9bd9f0..e824e6b35 100644 --- a/packages/b2c-tooling-sdk/src/operations/code/deploy.ts +++ b/packages/b2c-tooling-sdk/src/operations/code/deploy.ts @@ -9,9 +9,9 @@ import JSZip from 'jszip'; import type {B2CInstance} from '../../instance/index.js'; import {getLogger} from '../../logging/logger.js'; import {findCartridges, type CartridgeMapping, type FindCartridgesOptions} from './cartridges.js'; -import {activateCodeVersion} from './versions.js'; import {reloadCodeVersion} from './scripts-backend.js'; import {OcapiScriptsBackend} from './ocapi-scripts-backend.js'; +import type {ScriptsBackend} from './scripts-types.js'; import {UNZIP_TIMEOUT_MS} from './constants.js'; import {NetworkError, describeNetworkErrorKind} from '../../errors/network-error.js'; @@ -37,6 +37,8 @@ export interface UploadOptions { } export interface DeployOptions extends FindCartridgesOptions { + /** Explicit code-version backend. Defaults to OCAPI for SDK compatibility. */ + scriptsBackend?: ScriptsBackend; /** Activate the code version after deploy */ activate?: boolean; /** Reload (toggle activation to force reload) the code version after deploy */ @@ -317,6 +319,7 @@ export async function findAndDeployCartridges( ): Promise { const logger = getLogger(); const codeVersion = instance.config.codeVersion; + const scriptsBackend = options.scriptsBackend ?? new OcapiScriptsBackend(instance); if (!codeVersion) { throw new Error('Code version required for deployment'); @@ -350,11 +353,11 @@ export async function findAndDeployCartridges( let reloaded = false; if (options.activate) { logger.debug('Activating code version...'); - await activateCodeVersion(instance, codeVersion); + await scriptsBackend.activateCodeVersion(codeVersion); activated = true; } else if (options.reload) { logger.debug('Reloading code version...'); - await reloadCodeVersion(new OcapiScriptsBackend(instance), codeVersion); + await reloadCodeVersion(scriptsBackend, codeVersion); activated = true; reloaded = true; } diff --git a/packages/b2c-tooling-sdk/src/operations/code/download.ts b/packages/b2c-tooling-sdk/src/operations/code/download.ts index d736e1ed1..200b46197 100644 --- a/packages/b2c-tooling-sdk/src/operations/code/download.ts +++ b/packages/b2c-tooling-sdk/src/operations/code/download.ts @@ -8,7 +8,8 @@ import fs from 'node:fs'; import JSZip from 'jszip'; import type {B2CInstance} from '../../instance/index.js'; import {getLogger} from '../../logging/logger.js'; -import {getActiveCodeVersion} from './versions.js'; +import {OcapiScriptsBackend} from './ocapi-scripts-backend.js'; +import type {ScriptsBackend} from './scripts-types.js'; import {LONG_OPERATION_TIMEOUT_MS} from './constants.js'; const ZIP_BODY = new URLSearchParams({method: 'ZIP'}).toString(); @@ -25,6 +26,8 @@ export interface DownloadProgressInfo { * Options for downloading cartridges. */ export interface DownloadOptions { + /** Explicit code-version backend. Defaults to OCAPI for SDK compatibility. */ + scriptsBackend?: ScriptsBackend; /** Cartridge names to include (if empty/undefined, all are included) */ include?: string[]; /** Cartridge names to exclude */ @@ -63,16 +66,16 @@ function startProgress( } /** - * Resolves code version from instance config or OCAPI auto-discovery. + * Resolves code version from instance config or the explicitly selected backend. */ -async function resolveCodeVersion(instance: B2CInstance): Promise { +async function resolveCodeVersion(instance: B2CInstance, scriptsBackend: ScriptsBackend): Promise { const logger = getLogger(); let codeVersion = instance.config.codeVersion; if (!codeVersion) { logger.debug('No code version configured, attempting to discover active version...'); try { - const activeVersion = await getActiveCodeVersion(instance); + const activeVersion = await scriptsBackend.getActiveCodeVersion(); if (activeVersion?.id) { codeVersion = activeVersion.id; instance.config.codeVersion = codeVersion; @@ -287,7 +290,7 @@ export async function downloadCartridges( options: DownloadOptions = {}, ): Promise { const logger = getLogger(); - const codeVersion = await resolveCodeVersion(instance); + const codeVersion = await resolveCodeVersion(instance, options.scriptsBackend ?? new OcapiScriptsBackend(instance)); const resolvedOutput = path.resolve(outputDirectory); const {include, exclude, mirror, onProgress} = options; diff --git a/packages/b2c-tooling-sdk/src/operations/code/scapi-scripts-backend.ts b/packages/b2c-tooling-sdk/src/operations/code/scapi-scripts-backend.ts index 77ce2792a..83e82b8c2 100644 --- a/packages/b2c-tooling-sdk/src/operations/code/scapi-scripts-backend.ts +++ b/packages/b2c-tooling-sdk/src/operations/code/scapi-scripts-backend.ts @@ -15,6 +15,7 @@ import { } from '../../clients/scapi-scripts.js'; import {buildTenantScope, toOrganizationId} from '../../clients/custom-apis.js'; import {ScopeTierManager} from '../../clients/scapi-scope-tier.js'; +import {createScapiRequestError} from '../../clients/scapi-backend-utils.js'; import {getLogger} from '../../logging/logger.js'; function mapScapiCodeVersion(scapi: ScapiCodeVersion): CodeVersionInfo { @@ -58,11 +59,11 @@ export class ScapiScriptsBackend implements ScriptsBackend { async listCodeVersions(): Promise { return this.scopeTier.tryRead(async (client) => { - const {data, error} = await client.GET('/organizations/{organizationId}/code-versions', { + const {data, error, response} = await client.GET('/organizations/{organizationId}/code-versions', { params: {path: {organizationId: this.organizationId}}, }); if (error || !data) { - throw new Error(toErrorMessage(error, 'Failed to list code versions')); + throw createScapiRequestError(error, response, 'Failed to list code versions'); } const result = data as unknown as {data?: ScapiCodeVersion[]}; return (result.data ?? []).map(mapScapiCodeVersion); @@ -79,12 +80,12 @@ export class ScapiScriptsBackend implements ScriptsBackend { const logger = getLogger(); logger.debug({codeVersionId}, `Activating code version ${codeVersionId}`); - const {error} = await client.PATCH('/organizations/{organizationId}/code-versions/{codeVersionId}', { + const {error, response} = await client.PATCH('/organizations/{organizationId}/code-versions/{codeVersionId}', { params: {path: {organizationId: this.organizationId, codeVersionId}}, body: {active: true} as unknown as ScapiCodeVersion, }); if (error) { - throw new Error(toErrorMessage(error, `Failed to activate code version ${codeVersionId}`)); + throw createScapiRequestError(error, response, `Failed to activate code version ${codeVersionId}`); } logger.debug({codeVersionId}, `Code version ${codeVersionId} activated`); // SCAPI PATCH active=true is idempotent and does not surface a distinct @@ -94,21 +95,21 @@ export class ScapiScriptsBackend implements ScriptsBackend { async deleteCodeVersion(codeVersionId: string): Promise { const client = this.scopeTier.getClientForWrite(); - const {error} = await client.DELETE('/organizations/{organizationId}/code-versions/{codeVersionId}', { + const {error, response} = await client.DELETE('/organizations/{organizationId}/code-versions/{codeVersionId}', { params: {path: {organizationId: this.organizationId, codeVersionId}}, }); if (error) { - throw new Error(toErrorMessage(error, `Failed to delete code version ${codeVersionId}`)); + throw createScapiRequestError(error, response, `Failed to delete code version ${codeVersionId}`); } } async createCodeVersion(codeVersionId: string): Promise { const client = this.scopeTier.getClientForWrite(); - const {error} = await client.PUT('/organizations/{organizationId}/code-versions/{codeVersionId}', { + const {error, response} = await client.PUT('/organizations/{organizationId}/code-versions/{codeVersionId}', { params: {path: {organizationId: this.organizationId, codeVersionId}}, }); if (error) { - throw new Error(toErrorMessage(error, `Failed to create code version ${codeVersionId}`)); + throw createScapiRequestError(error, response, `Failed to create code version ${codeVersionId}`); } } @@ -121,8 +122,3 @@ export class ScapiScriptsBackend implements ScriptsBackend { return createScapiScriptsClient(clientConfig, this.config.auth); } } - -function toErrorMessage(error: unknown, fallback: string): string { - const e = error as {detail?: string; title?: string} | undefined; - return e?.detail ?? e?.title ?? fallback; -} diff --git a/packages/b2c-tooling-sdk/src/operations/code/watch.ts b/packages/b2c-tooling-sdk/src/operations/code/watch.ts index 63498bcc9..c700d0269 100644 --- a/packages/b2c-tooling-sdk/src/operations/code/watch.ts +++ b/packages/b2c-tooling-sdk/src/operations/code/watch.ts @@ -9,7 +9,8 @@ import type {B2CInstance} from '../../instance/index.js'; import {getLogger} from '../../logging/logger.js'; import {findCartridges, type CartridgeMapping, type FindCartridgesOptions} from './cartridges.js'; import {fileToCartridgePath, uploadFiles} from './upload-files.js'; -import {getActiveCodeVersion} from './versions.js'; +import {OcapiScriptsBackend} from './ocapi-scripts-backend.js'; +import type {ScriptsBackend} from './scripts-types.js'; /** Default debounce time in ms for batching file uploads */ const DEFAULT_DEBOUNCE_TIME = parseInt(process.env.SFCC_UPLOAD_DEBOUNCE_TIME ?? '100', 10); @@ -18,6 +19,8 @@ const DEFAULT_DEBOUNCE_TIME = parseInt(process.env.SFCC_UPLOAD_DEBOUNCE_TIME ?? * Options for watching cartridges. */ export interface WatchOptions extends FindCartridgesOptions { + /** Explicit code-version backend. Defaults to OCAPI for SDK compatibility. */ + scriptsBackend?: ScriptsBackend; /** Debounce time in ms for batching file changes */ debounceTime?: number; /** Callback when files are uploaded */ @@ -96,11 +99,12 @@ export async function watchCartridges( const logger = getLogger(); let codeVersion = instance.config.codeVersion; const debounceTime = options.debounceTime ?? DEFAULT_DEBOUNCE_TIME; + const scriptsBackend = options.scriptsBackend ?? new OcapiScriptsBackend(instance); // If no code version specified, get the active one if (!codeVersion) { logger.debug('No code version specified, getting active version...'); - const active = await getActiveCodeVersion(instance); + const active = await scriptsBackend.getActiveCodeVersion(); if (!active?.id) { throw new Error('No code version specified and no active code version found'); } diff --git a/packages/b2c-tooling-sdk/src/operations/jobs/discover.ts b/packages/b2c-tooling-sdk/src/operations/jobs/discover.ts index 1dc3c341a..c630b3cc5 100644 --- a/packages/b2c-tooling-sdk/src/operations/jobs/discover.ts +++ b/packages/b2c-tooling-sdk/src/operations/jobs/discover.ts @@ -16,6 +16,7 @@ */ import {B2CInstance} from '../../instance/index.js'; import {getLogger} from '../../logging/logger.js'; +import {createCatalogsBackend} from '../catalogs/index.js'; import {createSitesBackend} from '../sites/index.js'; /** @@ -35,35 +36,26 @@ export interface ExportableUnits { warnings: string[]; } -/** - * Discoverable categories that only have an OCAPI "list-all" endpoint. Sites - * are handled separately via the SCAPI-first sites backend (SCAPI has no - * catalogs/inventory-list listing, so those stay on OCAPI). - */ -const OCAPI_DISCOVERABLE = [ - {key: 'catalogs', path: '/catalogs', label: 'catalogs'}, - {key: 'inventoryLists', path: '/inventory_lists', label: 'inventory lists'}, -] as const; - /** Page size for paginated list endpoints (OCAPI default is 25). */ const PAGE_COUNT = 200; /** - * Lists one paginated OCAPI collection, following `start`/`count` until all + * Lists inventory lists through the remaining paginated OCAPI collection, + * following `start`/`count` until all * documents are read. Returns the `id` of each document. */ -async function listIds(instance: B2CInstance, path: '/catalogs' | '/inventory_lists'): Promise { +async function listInventoryListIds(instance: B2CInstance): Promise { const ids: string[] = []; let start = 0; // OCAPI collections page via start/count; `total` reports the full size. for (;;) { - const {data, error} = await instance.ocapi.GET(path, { + const {data, error} = await instance.ocapi.GET('/inventory_lists', { params: {query: {start, count: PAGE_COUNT}}, }); if (error || !data) { - throw new Error(error?.fault?.message ?? `Failed to list ${path}`); + throw new Error(error?.fault?.message ?? 'Failed to list inventory lists'); } for (const item of data.data ?? []) { @@ -86,7 +78,7 @@ async function listIds(instance: B2CInstance, path: '/catalogs' | '/inventory_li * Discovers the data units that can be exported from an instance. * * Each category is read independently: a failure in one (e.g. the OCAPI client - * lacks read permission for catalogs) records a warning and leaves that list + * lacks read permission for a category) records a warning and leaves that list * empty rather than failing the whole discovery, so the caller can still offer * the categories that succeeded. * @@ -118,16 +110,28 @@ export async function discoverExportableUnits(instance: B2CInstance): Promise { + // Catalogs: SCAPI product/catalogs with OCAPI fallback. + (async () => { try { - result[key] = (await listIds(instance, path)).sort((a, b) => a.localeCompare(b)); + const catalogs = await createCatalogsBackend({instance}).listCatalogs(); + result.catalogs = catalogs.map(({id}) => id).sort((a, b) => a.localeCompare(b)); } catch (err) { const message = err instanceof Error ? err.message : String(err); - logger.debug({path, err: message}, `Failed to discover ${label}`); - result.warnings.push(`Could not list ${label}: ${message}`); + logger.debug({err: message}, 'Failed to discover catalogs'); + result.warnings.push(`Could not list catalogs: ${message}`); } - }), + })(), + // The live SCAPI inventory APIs expose list detail/records but no + // organization-level inventory-list enumeration endpoint yet. + (async () => { + try { + result.inventoryLists = (await listInventoryListIds(instance)).sort((a, b) => a.localeCompare(b)); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + logger.debug({err: message}, 'Failed to discover inventory lists'); + result.warnings.push(`Could not list inventory lists: ${message}`); + } + })(), ]); return result; diff --git a/packages/b2c-tooling-sdk/src/operations/jobs/run-system-job.ts b/packages/b2c-tooling-sdk/src/operations/jobs/run-system-job.ts index 13a08014a..609f3a619 100644 --- a/packages/b2c-tooling-sdk/src/operations/jobs/run-system-job.ts +++ b/packages/b2c-tooling-sdk/src/operations/jobs/run-system-job.ts @@ -45,37 +45,21 @@ */ import type {B2CInstance, ScapiClientConfig} from '../../instance/index.js'; import {isOcapiDeprecatedFault, OcapiDeprecatedError} from '../../clients/error-utils.js'; -import { - isInvalidScopeError, - ScapiCapabilityUnsupportedError, - scapiUnavailableMessage, -} from '../../clients/scapi-backend-utils.js'; +import {isFallbackTrigger, scapiUnavailableMessage} from '../../clients/scapi-backend-utils.js'; import {createScapiJobsClient} from '../../clients/scapi-jobs.js'; import {getLogger} from '../../logging/logger.js'; import {mapCanonicalToOcapiExecution} from './ocapi-mapping.js'; -import { - executeJob as scapiExecuteJob, - getJobExecution as scapiGetJobExecution, - ScapiJobStartError, -} from './scapi-ops.js'; +import {executeJob as scapiExecuteJob, getJobExecution as scapiGetJobExecution} from './scapi-ops.js'; import {waitForJob, JobExecutionError, type JobExecution, type WaitForJobOptions} from './run.js'; import {waitForJobExecution, CanonicalJobExecutionError} from './wait-canonical.js'; -/** - * HTTP statuses on a SCAPI job-start response that prove the server *refused* - * the request before creating a job execution — so re-running over OCAPI - * cannot duplicate a mutating job. Ambiguous statuses (5xx, 429) and - * network/timeout errors (no response at all) are deliberately excluded. - */ -const SAFE_START_REJECTION_STATUSES = new Set([400, 401, 403, 404, 405, 406, 415]); - /** * Decides whether a SCAPI start failure is provably safe to fall back to OCAPI * for. Safe cases guarantee no job was created: - * - {@link isInvalidScopeError}: Account Manager rejected the scope during + * - `invalid_scope`: Account Manager rejected the scope during * token acquisition — thrown before the job POST is ever sent. - * - {@link ScapiCapabilityUnsupportedError}: a purely local rejection. - * - a {@link ScapiJobStartError} whose HTTP status is a client-side rejection + * - a SCAPI capability error: a purely local rejection. + * - a typed SCAPI start error whose HTTP status is a client-side rejection * (the server refused before starting the job). * * Everything else — a network/timeout error (which may have reached the server @@ -83,10 +67,7 @@ const SAFE_START_REJECTION_STATUSES = new Set([400, 401, 403, 404, 405, 406, 415 * trigger an OCAPI re-run of a mutating job. */ function isSafeStartFallback(error: unknown): boolean { - if (isInvalidScopeError(error) || error instanceof ScapiCapabilityUnsupportedError) { - return true; - } - return error instanceof ScapiJobStartError && SAFE_START_REJECTION_STATUSES.has(error.status); + return isFallbackTrigger(error); } /** diff --git a/packages/b2c-tooling-sdk/src/operations/jobs/scapi-ops.ts b/packages/b2c-tooling-sdk/src/operations/jobs/scapi-ops.ts index 85a9e67ce..ab33a77cb 100644 --- a/packages/b2c-tooling-sdk/src/operations/jobs/scapi-ops.ts +++ b/packages/b2c-tooling-sdk/src/operations/jobs/scapi-ops.ts @@ -18,6 +18,7 @@ * @module operations/jobs/scapi-ops */ import type {B2CInstance} from '../../instance/index.js'; +import {createScapiRequestError, ScapiRequestError} from '../../clients/scapi-backend-utils.js'; import {SCOPE_MODE_HEADER} from '../../clients/middleware.js'; import { toOrganizationId, @@ -42,13 +43,13 @@ const WRITE_HEADERS = {[SCOPE_MODE_HEADER]: 'write'}; * as a raw thrown error with no status, because the request may have reached * the server and the job may already be running. */ -export class ScapiJobStartError extends Error { +export class ScapiJobStartError extends ScapiRequestError { constructor( message: string, /** HTTP status of the rejection response. */ public readonly status: number, ) { - super(message); + super(message, status); this.name = 'ScapiJobStartError'; } } @@ -158,15 +159,16 @@ export async function getJobExecution( ): Promise { const organizationId = toOrganizationId(tenantId); - const {data, error} = await client.GET('/organizations/{organizationId}/jobs/{jobId}/executions/{executionId}', { - params: {path: {organizationId, jobId, executionId}}, - headers: READ_HEADERS, - }); + const {data, error, response} = await client.GET( + '/organizations/{organizationId}/jobs/{jobId}/executions/{executionId}', + { + params: {path: {organizationId, jobId, executionId}}, + headers: READ_HEADERS, + }, + ); if (error || !data) { - const errorBody = error as unknown as {detail?: string; title?: string}; - const message = errorBody?.detail ?? `Failed to get job execution ${executionId}`; - throw new Error(message); + throw createScapiRequestError(error, response, `Failed to get job execution ${executionId}`); } return mapScapiExecution(data); @@ -207,7 +209,7 @@ export async function searchJobExecutions( query = {boolQuery: {must: queries}}; } - const {data, error} = await client.POST('/organizations/{organizationId}/job-execution-search', { + const {data, error, response} = await client.POST('/organizations/{organizationId}/job-execution-search', { params: {path: {organizationId}}, headers: READ_HEADERS, body: { @@ -219,9 +221,7 @@ export async function searchJobExecutions( }); if (error || !data) { - const errorBody = error as unknown as {detail?: string; title?: string}; - const message = errorBody?.detail ?? 'Failed to search job executions'; - throw new Error(message); + throw createScapiRequestError(error, response, 'Failed to search job executions'); } const result = data as unknown as {total?: number; limit?: number; offset?: number; hits?: ScapiJobExecution[]}; @@ -241,15 +241,16 @@ export async function deleteJobExecution( ): Promise { const organizationId = toOrganizationId(tenantId); - const {error} = await client.DELETE('/organizations/{organizationId}/jobs/{jobId}/executions/{executionId}', { - params: {path: {organizationId, jobId, executionId}}, - headers: WRITE_HEADERS, - }); + const {error, response} = await client.DELETE( + '/organizations/{organizationId}/jobs/{jobId}/executions/{executionId}', + { + params: {path: {organizationId, jobId, executionId}}, + headers: WRITE_HEADERS, + }, + ); if (error) { - const errorBody = error as unknown as {detail?: string; title?: string}; - const message = errorBody?.detail ?? `Failed to delete job execution ${executionId}`; - throw new Error(message); + throw createScapiRequestError(error, response, `Failed to delete job execution ${executionId}`); } } diff --git a/packages/b2c-tooling-sdk/src/operations/sites/cartridges.ts b/packages/b2c-tooling-sdk/src/operations/sites/cartridges.ts index 096ec34b6..8e91d9b94 100644 --- a/packages/b2c-tooling-sdk/src/operations/sites/cartridges.ts +++ b/packages/b2c-tooling-sdk/src/operations/sites/cartridges.ts @@ -7,22 +7,22 @@ * Site cartridge path operations for B2C Commerce instances. * * Provides functions for managing the ordered list of active cartridges - * on a site via OCAPI Data API, with automatic fallback to site archive - * import/export when OCAPI permissions are unavailable. + * on a site via SCAPI with temporary OCAPI fallback, plus site archive + * import/export when neither direct API is available. */ import JSZip from 'jszip'; import type {B2CInstance} from '../../instance/index.js'; -import type {components} from '../../clients/ocapi.generated.js'; -import {getApiErrorMessage, isOcapiDeprecatedFault, OcapiDeprecatedError} from '../../clients/error-utils.js'; import {getLogger} from '../../logging/logger.js'; import {siteArchiveImport, siteArchiveExportToBuffer} from '../jobs/site-archive.js'; import type {WaitForJobOptions} from '../jobs/run.js'; +import {createSitesBackend} from './sites-backend.js'; +import type {CartridgePosition} from './sites-types.js'; /** The special site ID for Business Manager. */ export const BM_SITE_ID = 'Sites-Site'; /** Position options for adding a cartridge. */ -export type CartridgePosition = 'first' | 'last' | 'before' | 'after'; +export type {CartridgePosition} from './sites-types.js'; /** Options for adding a cartridge to a site's cartridge path. */ export interface AddCartridgeOptions { @@ -52,8 +52,6 @@ export interface CartridgePathResult { cartridgeList: string[]; } -type CartridgePathApiResponse = components['schemas']['cartridge_path_api_response']; - /** * Parses a colon-separated cartridge path string into a CartridgePathResult. */ @@ -69,7 +67,8 @@ function toResult(siteId: string, cartridges: string): CartridgePathResult { /** * Gets the cartridge path for a site. * - * Uses OCAPI `GET /sites/{site_id}` to read the cartridge path. + * Uses the configured Sites backend to read the cartridge path. Auto mode + * prefers SCAPI and temporarily falls back to OCAPI. * Works for all sites including Business Manager (Sites-Site). * * @param instance - B2C instance to query @@ -86,26 +85,20 @@ function toResult(siteId: string, cartridges: string): CartridgePathResult { * ``` */ export async function getCartridgePath(instance: B2CInstance, siteId: string): Promise { - const {data, error, response} = await instance.ocapi.GET('/sites/{site_id}', { - params: {path: {site_id: siteId}}, - }); - - if (error) { - if (isOcapiDeprecatedFault(error)) throw new OcapiDeprecatedError({cause: error}); - throw new Error(`Failed to get cartridge path for site "${siteId}": ${getApiErrorMessage(error, response)}`, { - cause: error, - }); + try { + const cartridges = await createSitesBackend({instance}).getCartridgePath(siteId); + return toResult(siteId, cartridges); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Failed to get cartridge path for site "${siteId}": ${message}`, {cause: error}); } - - const site = data as components['schemas']['site']; - return toResult(siteId, site.cartridges ?? ''); } /** * Adds a cartridge to a site's cartridge path. * - * For regular sites, tries OCAPI `POST /sites/{site_id}/cartridges` first, - * falling back to site archive import if OCAPI permissions are unavailable. + * For regular sites, uses the SCAPI-first Sites backend and falls back to site + * archive import if neither direct backend is available. * For Business Manager (Sites-Site), always uses site archive import. * * @param instance - B2C instance @@ -141,21 +134,11 @@ export async function addCartridge( return addCartridgeViaImport(instance, siteId, options, updateOptions); } - // Try OCAPI first for regular sites + const backend = createSitesBackend({instance}); try { - const {data, error, response} = await instance.ocapi.POST('/sites/{site_id}/cartridges', { - params: {path: {site_id: siteId}}, - body: options as components['schemas']['cartridge_path_add_request'], - }); - - if (error) { - throw new OcapiError(getApiErrorMessage(error, response), response.status); - } - - const result = data as CartridgePathApiResponse; - return toResult(siteId, result.cartridges ?? ''); - } catch (ocapiError) { - return handleFallback(instance, siteId, 'add', ocapiError, () => + return toResult(siteId, await backend.addCartridge(siteId, options.name, options.position, options.target)); + } catch (backendError) { + return handleFallback(instance, siteId, 'add', backendError, () => addCartridgeViaImport(instance, siteId, options, updateOptions), ); } @@ -164,8 +147,8 @@ export async function addCartridge( /** * Removes a cartridge from a site's cartridge path. * - * For regular sites, tries OCAPI `DELETE /sites/{site_id}/cartridges/{cartridge_name}` - * first, falling back to site archive import if OCAPI permissions are unavailable. + * For regular sites, uses the SCAPI-first Sites backend and falls back to site + * archive import if neither direct backend is available. * For Business Manager (Sites-Site), always uses site archive import. * * @param instance - B2C instance @@ -191,19 +174,11 @@ export async function removeCartridge( return removeCartridgeViaImport(instance, siteId, cartridgeName, updateOptions); } + const backend = createSitesBackend({instance}); try { - const {data, error, response} = await instance.ocapi.DELETE('/sites/{site_id}/cartridges/{cartridge_name}', { - params: {path: {site_id: siteId, cartridge_name: cartridgeName}}, - }); - - if (error) { - throw new OcapiError(getApiErrorMessage(error, response), response.status); - } - - const result = data as CartridgePathApiResponse; - return toResult(siteId, result.cartridges ?? ''); - } catch (ocapiError) { - return handleFallback(instance, siteId, 'remove', ocapiError, () => + return toResult(siteId, await backend.removeCartridge(siteId, cartridgeName)); + } catch (backendError) { + return handleFallback(instance, siteId, 'remove', backendError, () => removeCartridgeViaImport(instance, siteId, cartridgeName, updateOptions), ); } @@ -212,8 +187,8 @@ export async function removeCartridge( /** * Replaces the entire cartridge path for a site. * - * For regular sites, tries OCAPI `PUT /sites/{site_id}/cartridges` first, - * falling back to site archive import if OCAPI permissions are unavailable. + * For regular sites, uses the SCAPI-first Sites backend and falls back to site + * archive import if neither direct backend is available. * For Business Manager (Sites-Site), always uses site archive import. * * @param instance - B2C instance @@ -239,38 +214,16 @@ export async function setCartridgePath( return setCartridgePathViaImport(instance, siteId, cartridges, updateOptions); } + const backend = createSitesBackend({instance}); try { - const {data, error, response} = await instance.ocapi.PUT('/sites/{site_id}/cartridges', { - params: {path: {site_id: siteId}}, - body: {cartridges} as components['schemas']['cartridge_path_create_request'], - }); - - if (error) { - throw new OcapiError(getApiErrorMessage(error, response), response.status); - } - - const result = data as CartridgePathApiResponse; - return toResult(siteId, result.cartridges ?? ''); - } catch (ocapiError) { - return handleFallback(instance, siteId, 'set', ocapiError, () => + return toResult(siteId, await backend.setCartridgePath(siteId, cartridges)); + } catch (backendError) { + return handleFallback(instance, siteId, 'set', backendError, () => setCartridgePathViaImport(instance, siteId, cartridges, updateOptions), ); } } -// --------------------------------------------------------------------------- -// Internal: OCAPI error wrapper -// --------------------------------------------------------------------------- - -class OcapiError extends Error { - statusCode: number; - constructor(message: string, statusCode: number) { - super(message); - this.name = 'OcapiError'; - this.statusCode = statusCode; - } -} - // --------------------------------------------------------------------------- // Internal: Fallback handler // --------------------------------------------------------------------------- @@ -279,15 +232,15 @@ async function handleFallback( instance: B2CInstance, siteId: string, operation: string, - ocapiError: unknown, + backendError: unknown, fallbackFn: () => Promise, ): Promise { const logger = getLogger(); - const ocapiMessage = ocapiError instanceof Error ? ocapiError.message : String(ocapiError); + const backendMessage = backendError instanceof Error ? backendError.message : String(backendError); logger.warn( - {siteId, operation, error: ocapiMessage}, - `OCAPI ${operation} failed, trying site archive import fallback`, + {siteId, operation, error: backendMessage}, + `Direct API ${operation} failed, trying site archive import fallback`, ); try { @@ -298,10 +251,11 @@ async function handleFallback( [ `Failed to ${operation} cartridge path for site "${siteId}".`, '', - `OCAPI direct update failed: ${ocapiMessage}`, + `SCAPI/OCAPI direct update failed: ${backendMessage}`, `Site archive import fallback also failed: ${importMessage}`, '', 'To fix, configure one of:', + ' • SCAPI Sites API: Grant sfcc.sites.rw', ' • OCAPI Data API: Grant POST/PUT/DELETE on /sites/*/cartridges', ' • Site import: Grant job execution permissions for sfcc-site-archive-import and WebDAV write access to Impex/', '', diff --git a/packages/b2c-tooling-sdk/src/operations/sites/index.ts b/packages/b2c-tooling-sdk/src/operations/sites/index.ts index 72e6c1fbc..454c029e3 100644 --- a/packages/b2c-tooling-sdk/src/operations/sites/index.ts +++ b/packages/b2c-tooling-sdk/src/operations/sites/index.ts @@ -7,10 +7,9 @@ * Site operations for B2C Commerce instances. * * This module provides functions for managing site cartridge paths - * on B2C Commerce instances. Operations work via OCAPI Data API with - * automatic fallback to site archive import/export when OCAPI permissions - * are unavailable. Business Manager (Sites-Site) is supported via the - * import/export mechanism. + * on B2C Commerce instances. Operations use SCAPI first, with temporary + * OCAPI and site-archive fallbacks. Business Manager (Sites-Site) is + * supported via the import/export mechanism. * * ## Cartridge Path Functions * @@ -41,8 +40,8 @@ * * ## Authentication * - * Cartridge path operations require OAuth authentication. For OCAPI direct updates, - * grant POST/PUT/DELETE on `/sites/∗/cartridges`. For import/export fallback, + * Cartridge path operations require OAuth authentication. For SCAPI direct updates, + * grant `sfcc.sites.rw`; for OCAPI grant POST/PUT/DELETE on `/sites/∗/cartridges`. For import/export fallback, * grant job execution permissions and WebDAV write access. * * @module operations/sites diff --git a/packages/b2c-tooling-sdk/src/operations/sites/ocapi-sites-backend.ts b/packages/b2c-tooling-sdk/src/operations/sites/ocapi-sites-backend.ts index f84ad780b..816ea3f65 100644 --- a/packages/b2c-tooling-sdk/src/operations/sites/ocapi-sites-backend.ts +++ b/packages/b2c-tooling-sdk/src/operations/sites/ocapi-sites-backend.ts @@ -8,6 +8,7 @@ import type {OcapiComponents} from '../../clients/index.js'; import {throwOcapiError} from '../../clients/error-utils.js'; import {SCAPI_SITES_READ_AND_RW_SCOPES} from './sites-scopes.js'; import type {SitesBackend, SiteInfo, ListSitesOptions} from './sites-types.js'; +import type {CartridgePosition} from './sites-types.js'; type OcapiSite = OcapiComponents['schemas']['site']; type OcapiSites = OcapiComponents['schemas']['sites']; @@ -77,4 +78,55 @@ export class OcapiSitesBackend implements SitesBackend { } return mapOcapiSite(data as OcapiSite); } + + async getCartridgePath(siteId: string): Promise { + return (await this.getSite(siteId)).cartridges ?? ''; + } + + async setCartridgePath(siteId: string, cartridges: string): Promise { + const {data, error, response} = await this.instance.ocapi.PUT('/sites/{site_id}/cartridges', { + params: {path: {site_id: siteId}}, + body: {cartridges}, + }); + if (error || !data) { + throwOcapiError( + error, + response, + `Failed to set cartridge path for site ${siteId}`, + SCAPI_SITES_READ_AND_RW_SCOPES, + ); + } + return (data as {cartridges?: string}).cartridges ?? cartridges; + } + + async addCartridge(siteId: string, name: string, position: CartridgePosition, target?: string): Promise { + const {data, error, response} = await this.instance.ocapi.POST('/sites/{site_id}/cartridges', { + params: {path: {site_id: siteId}}, + body: {name, position, target}, + }); + if (error || !data) { + throwOcapiError( + error, + response, + `Failed to add cartridge ${name} to site ${siteId}`, + SCAPI_SITES_READ_AND_RW_SCOPES, + ); + } + return data.cartridges ?? ''; + } + + async removeCartridge(siteId: string, name: string): Promise { + const {data, error, response} = await this.instance.ocapi.DELETE('/sites/{site_id}/cartridges/{cartridge_name}', { + params: {path: {site_id: siteId, cartridge_name: name}}, + }); + if (error || !data) { + throwOcapiError( + error, + response, + `Failed to remove cartridge ${name} from site ${siteId}`, + SCAPI_SITES_READ_AND_RW_SCOPES, + ); + } + return data.cartridges ?? ''; + } } diff --git a/packages/b2c-tooling-sdk/src/operations/sites/scapi-sites-backend.ts b/packages/b2c-tooling-sdk/src/operations/sites/scapi-sites-backend.ts index 10ba9ecd2..41c37e868 100644 --- a/packages/b2c-tooling-sdk/src/operations/sites/scapi-sites-backend.ts +++ b/packages/b2c-tooling-sdk/src/operations/sites/scapi-sites-backend.ts @@ -4,7 +4,7 @@ * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 */ import type {AuthStrategy} from '../../auth/types.js'; -import type {SitesBackend, SiteInfo, ListSitesOptions} from './sites-types.js'; +import type {SitesBackend, SiteInfo, ListSitesOptions, CartridgePosition} from './sites-types.js'; import { createScapiSitesClient, toOrganizationId, @@ -13,8 +13,10 @@ import { type Site as ScapiSite, } from '../../clients/scapi-sites.js'; import {SCOPE_MODE_HEADER} from '../../clients/middleware.js'; +import {createScapiRequestError} from '../../clients/scapi-backend-utils.js'; const READ_HEADERS = {[SCOPE_MODE_HEADER]: 'read'}; +const WRITE_HEADERS = {[SCOPE_MODE_HEADER]: 'write'}; /** SCAPI `getSites` caps `limit` at 50 (spec `site-sites-v1.yaml`). */ const SCAPI_SITES_MAX_PAGE = 50; @@ -46,9 +48,8 @@ export interface ScapiSitesBackendConfig { } /** - * SCAPI Sites backend. Reads sites and per-site detail via the - * `site/sites/v1` Admin API. Read-only — cartridge-path writes have no SCAPI - * equivalent and are not part of this backend. + * SCAPI Sites backend. Reads sites and manages custom cartridge paths via the + * `site/sites/v1` Admin API. */ export class ScapiSitesBackend implements SitesBackend { readonly name = 'scapi' as const; @@ -93,12 +94,12 @@ export class ScapiSitesBackend implements SitesBackend { if (remaining <= 0) break; const limit = Math.min(SCAPI_SITES_MAX_PAGE, remaining); - const {data, error} = await this.client.GET('/organizations/{organizationId}/sites', { + const {data, error, response} = await this.client.GET('/organizations/{organizationId}/sites', { params: {path: {organizationId: this.organizationId}, query: {limit, offset}}, headers: READ_HEADERS, }); if (error || !data) { - throw new Error(toErrorMessage(error, 'Failed to list sites')); + throw createScapiRequestError(error, response, 'Failed to list sites'); } const page = data as unknown as {data?: ScapiSite[]; total?: number}; @@ -135,18 +136,76 @@ export class ScapiSitesBackend implements SitesBackend { } async getSite(siteId: string): Promise { - const {data, error} = await this.client.GET('/organizations/{organizationId}/sites/{siteId}', { + const {data, error, response} = await this.client.GET('/organizations/{organizationId}/sites/{siteId}', { params: {path: {organizationId: this.organizationId, siteId}}, headers: READ_HEADERS, }); if (error || !data) { - throw new Error(toErrorMessage(error, `Failed to get site ${siteId}`)); + throw createScapiRequestError(error, response, `Failed to get site ${siteId}`); } return mapScapiSite(data as ScapiSite); } + + async getCartridgePath(siteId: string): Promise { + const {data, error, response} = await this.client.GET( + '/organizations/{organizationId}/sites/{siteId}/custom-cartridges', + { + params: {path: {organizationId: this.organizationId, siteId}}, + headers: READ_HEADERS, + }, + ); + if (error || !data) { + throw createScapiRequestError(error, response, `Failed to get cartridge path for site ${siteId}`); + } + return data.customCartridges; + } + + async setCartridgePath(siteId: string, cartridges: string): Promise { + const {data, error, response} = await this.client.PUT( + '/organizations/{organizationId}/sites/{siteId}/custom-cartridges', + { + params: {path: {organizationId: this.organizationId, siteId}}, + headers: WRITE_HEADERS, + body: {customCartridges: cartridges}, + }, + ); + if (error || !data) { + throw createScapiRequestError(error, response, `Failed to set cartridge path for site ${siteId}`); + } + return data.customCartridges; + } + + async addCartridge(siteId: string, name: string, position: CartridgePosition, target?: string): Promise { + const current = await this.getCartridgePath(siteId); + const cartridges = current ? current.split(':') : []; + if (cartridges.includes(name)) { + throw new Error(`Cartridge "${name}" already exists in the cartridge path for site "${siteId}"`); + } + insertCartridge(cartridges, name, position, target); + return this.setCartridgePath(siteId, cartridges.join(':')); + } + + async removeCartridge(siteId: string, name: string): Promise { + const current = await this.getCartridgePath(siteId); + const cartridges = current ? current.split(':') : []; + const index = cartridges.indexOf(name); + if (index < 0) throw new Error(`Cartridge "${name}" not found in the cartridge path for site "${siteId}"`); + cartridges.splice(index, 1); + return this.setCartridgePath(siteId, cartridges.join(':')); + } } -function toErrorMessage(error: unknown, fallback: string): string { - const e = error as {detail?: string; title?: string} | undefined; - return e?.detail ?? e?.title ?? fallback; +function insertCartridge(cartridges: string[], name: string, position: CartridgePosition, target?: string): void { + if (position === 'first') { + cartridges.unshift(name); + return; + } + if (position === 'last') { + cartridges.push(name); + return; + } + if (!target) throw new Error(`Target cartridge is required for position "${position}"`); + const targetIndex = cartridges.indexOf(target); + if (targetIndex < 0) throw new Error(`Target cartridge "${target}" not found in cartridge path`); + cartridges.splice(position === 'before' ? targetIndex : targetIndex + 1, 0, name); } diff --git a/packages/b2c-tooling-sdk/src/operations/sites/sites-backend.ts b/packages/b2c-tooling-sdk/src/operations/sites/sites-backend.ts index 321a07256..14c7ab22b 100644 --- a/packages/b2c-tooling-sdk/src/operations/sites/sites-backend.ts +++ b/packages/b2c-tooling-sdk/src/operations/sites/sites-backend.ts @@ -11,9 +11,9 @@ import {createDualBackend, type DualBackendConfig} from '../../clients/dual-back export type SitesBackendConfig = DualBackendConfig; /** - * Builds a Sites backend for read operations (list/get). In `auto` mode + * Builds a Sites backend for site and cartridge-path operations. In `auto` mode * (the default) it prefers SCAPI (`site/sites/v1`) and falls back to the - * deprecated OCAPI Data API on `invalid_scope`. + * deprecated OCAPI Data API on a safe capability/auth/request rejection. */ export function createSitesBackend(config: SitesBackendConfig): SitesBackend { return createDualBackend(config, { diff --git a/packages/b2c-tooling-sdk/src/operations/sites/sites-scopes.ts b/packages/b2c-tooling-sdk/src/operations/sites/sites-scopes.ts index 7d4d1c2f7..8d1a21fcc 100644 --- a/packages/b2c-tooling-sdk/src/operations/sites/sites-scopes.ts +++ b/packages/b2c-tooling-sdk/src/operations/sites/sites-scopes.ts @@ -5,8 +5,8 @@ */ /** * SCAPI Sites scopes named in OCAPI-deprecation error messages, derived from - * the canonical cascade so they can't drift. Sites operations are read-only, - * so the read cascade (rw then ro) is the relevant set. + * the canonical cascade so they can't drift. The union covers read-only and + * read-write operations. * * @module operations/sites/sites-scopes */ diff --git a/packages/b2c-tooling-sdk/src/operations/sites/sites-types.ts b/packages/b2c-tooling-sdk/src/operations/sites/sites-types.ts index 89025f044..f13978f2c 100644 --- a/packages/b2c-tooling-sdk/src/operations/sites/sites-types.ts +++ b/packages/b2c-tooling-sdk/src/operations/sites/sites-types.ts @@ -11,9 +11,8 @@ * path). We expose a single canonical shape here so command code is agnostic * to which backend serves the request. * - * The SCAPI Sites API is read-only; cartridge-path **writes** have no SCAPI - * equivalent and remain OCAPI / site-archive-import only (see - * {@link module:operations/sites/cartridges}). + * SCAPI Sites v1.3 exposes dedicated custom-cartridge read/write operations; + * OCAPI remains as the temporary compatibility backend. * * @module operations/sites/sites-types */ @@ -42,13 +41,19 @@ export interface ListSitesOptions { start?: number; } +export type CartridgePosition = 'first' | 'last' | 'before' | 'after'; + /** * Backend contract for site read operations. * - * Only reads are modeled — the SCAPI Sites API has no write surface, and - * cartridge-path mutation is handled separately by the OCAPI/import path. + * Cartridge-path methods model the SCAPI v1.3 custom-cartridges resource and + * the equivalent OCAPI Data API resource. */ export interface SitesBackend extends BackendBase { listSites(options?: ListSitesOptions): Promise; getSite(siteId: string): Promise; + getCartridgePath(siteId: string): Promise; + setCartridgePath(siteId: string, cartridges: string): Promise; + addCartridge(siteId: string, name: string, position: CartridgePosition, target?: string): Promise; + removeCartridge(siteId: string, name: string): Promise; } diff --git a/packages/b2c-tooling-sdk/test/clients/scapi-fallback-backend.test.ts b/packages/b2c-tooling-sdk/test/clients/scapi-fallback-backend.test.ts index a17a93126..26a1a962e 100644 --- a/packages/b2c-tooling-sdk/test/clients/scapi-fallback-backend.test.ts +++ b/packages/b2c-tooling-sdk/test/clients/scapi-fallback-backend.test.ts @@ -5,7 +5,7 @@ */ import {expect} from 'chai'; import {createFallbackBackend} from '../../src/clients/scapi-fallback-backend.js'; -import {ScapiCapabilityUnsupportedError} from '../../src/clients/scapi-backend-utils.js'; +import {ScapiCapabilityUnsupportedError, ScapiRequestError} from '../../src/clients/scapi-backend-utils.js'; interface TestBackend { readonly name: 'ocapi' | 'scapi'; @@ -124,6 +124,32 @@ describe('createFallbackBackend', () => { }); }); + describe('fallback path: SCAPI rejects a request before mutation', () => { + for (const status of [400, 401, 403, 404, 405, 406, 415]) { + it(`falls back on a typed HTTP ${status} rejection`, async () => { + const scapi = makeBackend('scapi', { + doRead: async () => { + throw new ScapiRequestError('SCAPI rejected request', status); + }, + }); + const ocapi = makeBackend('ocapi', {doRead: async () => 'ocapi-read'}); + + expect(await createFallbackBackend(scapi, ocapi, 'test').doRead()).to.equal('ocapi-read'); + }); + } + + it('does not fall back on a typed server error because completion is ambiguous', async () => { + const scapi = makeBackend('scapi', { + doRead: async () => { + throw new ScapiRequestError('SCAPI failed', 500); + }, + }); + const ocapi = makeBackend('ocapi', {doRead: async () => 'should-not-reach-this'}); + + await expectRejected(createFallbackBackend(scapi, ocapi, 'test').doRead(), 'SCAPI failed'); + }); + }); + describe('fallback after SCAPI was pinned by a prior success', () => { it('falls back to OCAPI when a later SCAPI call hits a capability gap', async () => { // Simulates: read succeeds under SCAPI → wrapper pins SCAPI → write @@ -242,3 +268,12 @@ describe('createFallbackBackend', () => { }); }); }); + +async function expectRejected(promise: Promise, message: string): Promise { + try { + await promise; + expect.fail('should have thrown'); + } catch (error) { + expect((error as Error).message).to.equal(message); + } +} diff --git a/packages/b2c-tooling-sdk/test/operations/bm-roles/ocapi-backend.test.ts b/packages/b2c-tooling-sdk/test/operations/bm-roles/ocapi-backend.test.ts new file mode 100644 index 000000000..cb485f6ea --- /dev/null +++ b/packages/b2c-tooling-sdk/test/operations/bm-roles/ocapi-backend.test.ts @@ -0,0 +1,89 @@ +/* + * 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 type {B2CInstance} from '../../../src/instance/index.js'; +import {OcapiRolesBackend} from '../../../src/operations/bm-roles/ocapi-backend.js'; +import type {RolePermissionsInfo} from '../../../src/operations/bm-roles/types.js'; + +const ocapiPermissions = { + module: { + organization: [ + {application: 'bm', name: 'Manage_Sites', type: 'module', system: true, value: 'read', values: {site: 'all'}}, + ], + site: [], + }, + functional: { + organization: [{name: 'Manage_Users', type: 'functional', value: 'write', values: {organization: 'all'}}], + site: [], + }, + locale: {unscoped: [{locale_id: 'en_US', type: 'locale', value: 'read', values: {fallback: 'en'}}]}, + webdav: {unscoped: [{folder: '/Impex', type: 'webdav', value: 'write', values: {recursive: 'true'}}]}, +}; + +describe('OcapiRolesBackend permission mapping', () => { + it('preserves every permission field while converting locale_id to localeId', async () => { + const instance = { + ocapi: { + GET: async () => ({data: ocapiPermissions, error: undefined, response: {status: 200}}), + }, + } as unknown as B2CInstance; + + const permissions = await new OcapiRolesBackend(instance).getPermissions('developer'); + + expect(permissions.module?.organization?.[0]).to.deep.equal({ + application: 'bm', + name: 'Manage_Sites', + type: 'module', + system: true, + value: 'read', + values: {site: 'all'}, + }); + expect(permissions.functional?.organization?.[0]).to.deep.equal({ + name: 'Manage_Users', + type: 'functional', + value: 'write', + values: {organization: 'all'}, + }); + expect(permissions.locale?.unscoped?.[0]).to.deep.equal({ + localeId: 'en_US', + type: 'locale', + value: 'read', + values: {fallback: 'en'}, + }); + expect(permissions.webdav?.unscoped?.[0]).to.deep.equal({ + folder: '/Impex', + type: 'webdav', + value: 'write', + values: {recursive: 'true'}, + }); + }); + + it('round-trips canonical permissions to OCAPI without dropping metadata', async () => { + let received: unknown; + const instance = { + ocapi: { + PUT: async (_path: string, request: {body: unknown}) => { + received = request.body; + return {data: request.body, error: undefined, response: {status: 200}}; + }, + }, + } as unknown as B2CInstance; + const canonical = { + ...ocapiPermissions, + locale: {unscoped: [{localeId: 'en_US', type: 'locale', value: 'read', values: {fallback: 'en'}}]}, + } as unknown as RolePermissionsInfo; + + const result = await new OcapiRolesBackend(instance).setPermissions('developer', canonical); + + expect(received).to.deep.equal(ocapiPermissions); + expect(result.locale?.unscoped?.[0]).to.deep.equal({ + localeId: 'en_US', + type: 'locale', + value: 'read', + values: {fallback: 'en'}, + }); + }); +}); diff --git a/packages/b2c-tooling-sdk/test/operations/bm-users/scapi-search.test.ts b/packages/b2c-tooling-sdk/test/operations/bm-users/scapi-search.test.ts new file mode 100644 index 000000000..e78c51aff --- /dev/null +++ b/packages/b2c-tooling-sdk/test/operations/bm-users/scapi-search.test.ts @@ -0,0 +1,97 @@ +/* + * 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 type {AuthStrategy} from '../../../src/auth/types.js'; +import {ScapiCapabilityUnsupportedError} from '../../../src/clients/scapi-backend-utils.js'; +import {ScapiUsersBackend} from '../../../src/operations/bm-users/scapi-backend.js'; + +describe('ScapiUsersBackend search', () => { + function createBackend() { + const backend = new ScapiUsersBackend({shortCode: 'abcd1234', tenantId: 'zzxy_dev', auth: {} as AuthStrategy}); + (backend as unknown as {scopeTier: unknown}).scopeTier = { + async tryRead(operation: (client: unknown) => Promise): Promise { + return operation({ + async GET() { + return { + data: { + total: 3, + offset: 0, + limit: 200, + data: [ + {login: 'alex', email: 'alex@example.com', firstName: 'Alex', lastName: 'Smith', locked: false}, + {login: 'sam', email: 'sam@example.com', firstName: 'Sam', lastName: 'Jones', locked: true}, + {login: 'taylor', email: 'taylor@example.com', firstName: 'Taylor', lastName: 'Smith', locked: true}, + ], + }, + error: undefined, + response: {status: 200}, + }; + }, + }); + }, + }; + return backend; + } + + it('filters and sorts portable criteria over the paginated user listing', async () => { + const result = await createBackend().searchUsers({ + searchPhrase: 'smith', + locked: true, + sortBy: 'login', + sortOrder: 'desc', + }); + + expect(result.total).to.equal(1); + expect(result.hits.map(({login}) => login)).to.deep.equal(['taylor']); + }); + + it('marks raw OCAPI query JSON as an explicit compatibility capability', async () => { + try { + await createBackend().searchUsers({query: {match_all_query: {}}}); + expect.fail('should have thrown'); + } catch (error) { + expect(error).to.be.instanceOf(ScapiCapabilityUnsupportedError); + } + }); + + it('updates disabled through PUT while preserving current writable fields', async () => { + const backend = createBackend(); + let received: unknown; + (backend as unknown as {getUser: ScapiUsersBackend['getUser']}).getUser = async () => ({ + login: 'alex', + email: 'alex@example.com', + firstName: 'Alex', + roles: ['Developer'], + disabled: false, + }); + (backend as unknown as {scopeTier: unknown}).scopeTier = { + getClientForWrite() { + return { + async PUT(_path: string, options: {body: unknown}) { + received = options.body; + return {data: options.body, error: undefined, response: {status: 200}}; + }, + }; + }, + }; + + const updated = await backend.updateUser('alex', {disabled: true}); + + expect(received).to.deep.equal({ + login: 'alex', + email: 'alex@example.com', + firstName: 'Alex', + lastName: undefined, + externalId: undefined, + password: undefined, + disabled: true, + preferredDataLocale: undefined, + preferredUiLocale: undefined, + roles: ['Developer'], + }); + expect(updated.disabled).to.equal(true); + }); +}); diff --git a/packages/b2c-tooling-sdk/test/operations/catalogs/catalogs-backend.test.ts b/packages/b2c-tooling-sdk/test/operations/catalogs/catalogs-backend.test.ts new file mode 100644 index 000000000..d9bcde514 --- /dev/null +++ b/packages/b2c-tooling-sdk/test/operations/catalogs/catalogs-backend.test.ts @@ -0,0 +1,37 @@ +/* + * 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 type {AuthStrategy} from '../../../src/auth/types.js'; +import {ScapiCatalogsBackend} from '../../../src/operations/catalogs/scapi-catalogs-backend.js'; + +describe('ScapiCatalogsBackend', () => { + it('paginates the live 50-item collection and maps localized names', async () => { + const backend = new ScapiCatalogsBackend({ + shortCode: 'abcd1234', + tenantId: 'zzxy_dev', + auth: {} as AuthStrategy, + }); + const offsets: number[] = []; + (backend as unknown as {client: unknown}).client = { + async GET(_path: string, options: {params: {query: {offset: number; limit: number}}}) { + const {offset, limit} = options.params.query; + offsets.push(offset); + const data = Array.from({length: Math.min(limit, 75 - offset)}, (_, index) => ({ + id: `catalog-${offset + index}`, + name: {default: `Catalog ${offset + index}`}, + online: true, + })); + return {data: {data, offset, limit, total: 75}, error: undefined, response: {status: 200}}; + }, + }; + + const catalogs = await backend.listCatalogs(); + + expect(catalogs).to.have.length(75); + expect(offsets).to.deep.equal([0, 50]); + expect(catalogs[0]).to.include({id: 'catalog-0', name: 'Catalog 0', online: true}); + }); +}); diff --git a/packages/b2c-tooling-sdk/test/operations/sites/sites-backend.test.ts b/packages/b2c-tooling-sdk/test/operations/sites/sites-backend.test.ts index d7172c2d2..91cbe3509 100644 --- a/packages/b2c-tooling-sdk/test/operations/sites/sites-backend.test.ts +++ b/packages/b2c-tooling-sdk/test/operations/sites/sites-backend.test.ts @@ -157,6 +157,52 @@ describe('operations/sites backend', () => { cartridges: 'a:b', }); }); + + it('reads and writes the custom cartridge path with the SCAPI endpoint', async () => { + const backend = new ScapiSitesBackend({shortCode: 'abcd1234', tenantId: 'zzxy_dev', auth: fakeAuth}); + const calls: Array<{method: string; path: string; body?: unknown}> = []; + (backend as unknown as {client: unknown}).client = { + async GET(path: string) { + calls.push({method: 'GET', path}); + return {data: {customCartridges: 'app_a:app_b'}, error: undefined, response: {status: 200}}; + }, + async PUT(path: string, options: {body: unknown}) { + calls.push({method: 'PUT', path, body: options.body}); + return {data: options.body, error: undefined, response: {status: 200}}; + }, + }; + + expect(await backend.getCartridgePath('RefArch')).to.equal('app_a:app_b'); + expect(await backend.setCartridgePath('RefArch', 'app_c:app_a')).to.equal('app_c:app_a'); + expect(calls).to.deep.equal([ + { + method: 'GET', + path: '/organizations/{organizationId}/sites/{siteId}/custom-cartridges', + }, + { + method: 'PUT', + path: '/organizations/{organizationId}/sites/{siteId}/custom-cartridges', + body: {customCartridges: 'app_c:app_a'}, + }, + ]); + }); + + it('implements SCAPI add/remove by replacing the custom cartridge path', async () => { + const backend = new ScapiSitesBackend({shortCode: 'abcd1234', tenantId: 'zzxy_dev', auth: fakeAuth}); + let path = 'app_a:app_b'; + (backend as unknown as {client: unknown}).client = { + async GET() { + return {data: {customCartridges: path}, error: undefined, response: {status: 200}}; + }, + async PUT(_endpoint: string, options: {body: {customCartridges: string}}) { + path = options.body.customCartridges; + return {data: {customCartridges: path}, error: undefined, response: {status: 200}}; + }, + }; + + expect(await backend.addCartridge('RefArch', 'app_c', 'after', 'app_a')).to.equal('app_a:app_c:app_b'); + expect(await backend.removeCartridge('RefArch', 'app_a')).to.equal('app_c:app_b'); + }); }); describe('ScapiSitesBackend pagination + enrichment', () => { diff --git a/packages/b2c-vs-extension/src/jobs/jobs-commands.ts b/packages/b2c-vs-extension/src/jobs/jobs-commands.ts index 21255b6e8..60f1d131d 100644 --- a/packages/b2c-vs-extension/src/jobs/jobs-commands.ts +++ b/packages/b2c-vs-extension/src/jobs/jobs-commands.ts @@ -4,17 +4,16 @@ * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 */ import { - executeJob, getJobErrorMessage, getJobLog, siteArchiveExportToPath, siteArchiveImport, - waitForJob, type ExportDataUnitsConfiguration, type JobExecution, type JobExecutionParameter, JobExecutionError, } from '@salesforce/b2c-tooling-sdk/operations/jobs'; +import {createJobsCompatibilityBackend} from '@salesforce/b2c-tooling-sdk'; import {getApiErrorMessage} from '@salesforce/b2c-tooling-sdk'; import {createScaffoldRegistry, generateFromScaffold} from '@salesforce/b2c-tooling-sdk/scaffold'; import {findCartridgesSafe} from '../workspace-discovery.js'; @@ -1074,18 +1073,18 @@ export function registerJobsCommands( void vscode.window.showErrorMessage('B2C DX: No B2C Commerce instance configured. Configure dw.json first.'); return; } - const triggerAndWait = async (): Promise => { + const jobsBackend = createJobsCompatibilityBackend(instance); return vscode.window.withProgress( {location: vscode.ProgressLocation.Notification, title: `Running job ${jobId}...`, cancellable: false}, async (progress) => { - const execution = await executeJob(instance, jobId, {parameters}); + const execution = await jobsBackend.executeJob(jobId, {parameters}); const executionId = execution.id; if (!executionId) return execution; progress.report({message: `Execution ${executionId} started`}); treeProvider.refresh(); - return waitForJob(instance, jobId, executionId, { + return jobsBackend.waitForJob(jobId, executionId, { onPoll: (info) => progress.report({message: `${info.status} · ${info.elapsedSeconds}s elapsed`}), }); }, @@ -1532,6 +1531,12 @@ export function registerJobsCommands( void vscode.window.showErrorMessage('B2C DX: No B2C Commerce instance configured. Configure dw.json first.'); return; } + if (instance.apiBackend === 'scapi') { + void vscode.window.showErrorMessage( + 'Stopping a running job is not supported by the current SCAPI Jobs API. Set apiBackend to ocapi or auto to use the temporary OCAPI compatibility operation.', + ); + return; + } // VS Code auto-adds a Cancel button to modal dialogs — passing an explicit // one produces two Cancel-like actions. Keep only the affirmative. diff --git a/packages/b2c-vs-extension/src/jobs/jobs-tree-provider.ts b/packages/b2c-vs-extension/src/jobs/jobs-tree-provider.ts index c1650078e..127e08aba 100644 --- a/packages/b2c-vs-extension/src/jobs/jobs-tree-provider.ts +++ b/packages/b2c-vs-extension/src/jobs/jobs-tree-provider.ts @@ -4,7 +4,8 @@ * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 */ import {findCartridgesSafe} from '../workspace-discovery.js'; -import {searchJobExecutions, type JobExecution} from '@salesforce/b2c-tooling-sdk/operations/jobs'; +import type {JobExecution} from '@salesforce/b2c-tooling-sdk/operations/jobs'; +import {createJobsCompatibilityBackend} from '@salesforce/b2c-tooling-sdk'; import * as vscode from 'vscode'; import type {B2CExtensionConfig} from '../config-provider.js'; import {showThrottledError} from '../notify.js'; @@ -864,13 +865,14 @@ export class JobsTreeDataProvider implements vscode.TreeDataProvider v.id === codeVersion); if (target) { const names = target.cartridges ?? []; - const result: DeployedCartridgesResult = {kind: 'ok', names, source: 'ocapi'}; + const result: DeployedCartridgesResult = {kind: 'ok', names, source: 'api'}; this.deployedCartridgesCache.set(cacheKey, {result, fetchedAt: Date.now()}); return result; } - ocapiError = `code version "${codeVersion}" not found on instance`; + apiError = `code version "${codeVersion}" not found on instance`; } catch (err) { - ocapiError = err instanceof Error ? err.message : String(err); - this.log.appendLine(`[onboarding] OCAPI listCodeVersions failed: ${ocapiError}`); + apiError = err instanceof Error ? err.message : String(err); + this.log.appendLine(`[onboarding] Code-version discovery failed: ${apiError}`); } // 2) Fallback to WebDAV — same auth path as the deploy command itself. @@ -563,7 +563,7 @@ export class OnboardingPanel { } catch (err) { const webdavError = err instanceof Error ? err.message : String(err); this.log.appendLine(`[onboarding] WebDAV propfind failed: ${webdavError}`); - return {kind: 'error', reason: ocapiError ?? webdavError}; + return {kind: 'error', reason: apiError ?? webdavError}; } } diff --git a/packages/b2c-vs-extension/src/webdav-tree/webdav-commands.ts b/packages/b2c-vs-extension/src/webdav-tree/webdav-commands.ts index 6cc8ce614..baf295af0 100644 --- a/packages/b2c-vs-extension/src/webdav-tree/webdav-commands.ts +++ b/packages/b2c-vs-extension/src/webdav-tree/webdav-commands.ts @@ -4,6 +4,7 @@ * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 */ import {readFile} from 'fs/promises'; +import {createCatalogsBackend} from '@salesforce/b2c-tooling-sdk'; import * as path from 'path'; import * as vscode from 'vscode'; import type {B2CExtensionConfig} from '../config-provider.js'; @@ -227,16 +228,14 @@ export function registerWebDavCommands( const addCatalog = registerSafeCommand('b2c-dx.webdav.addCatalog', async () => { const instance = configProvider.getInstance(); - // Try OCAPI discovery first + // Try SCAPI-first discovery, with OCAPI compatibility fallback. let catalogChoices: string[] | undefined; if (instance) { try { - const {data} = await instance.ocapi.GET('/catalogs', { - params: {query: {select: '(**)', count: 200}}, - }); - catalogChoices = (data?.data?.map((c) => c.id).filter(Boolean) as string[]) ?? []; + const catalogs = await createCatalogsBackend({instance}).listCatalogs(); + catalogChoices = catalogs.map(({id}) => id); } catch { - // OCAPI not available (no OAuth) — fall through to input box + // API discovery unavailable — preserve manual entry. } } diff --git a/skills/b2c-cli/skills/b2c-bm-users-roles/SKILL.md b/skills/b2c-cli/skills/b2c-bm-users-roles/SKILL.md index c34b49d46..45fcfb6ed 100644 --- a/skills/b2c-cli/skills/b2c-bm-users-roles/SKILL.md +++ b/skills/b2c-cli/skills/b2c-bm-users-roles/SKILL.md @@ -13,24 +13,24 @@ For **Account Manager** user/role/client management (cross-instance, scoped to t ## API Backend -`bm users` (list, get, update, delete) and `bm roles` (all subcommands including permissions) run over the SCAPI Merchant Users / Merchant Roles APIs. Configure `shortCode`, `tenantId`, and the `sfcc.users(.rw)` / `sfcc.roles(.rw)` scopes and they work out of the box. +`bm users` (list, get, portable search, update, delete) and `bm roles` (all subcommands including permissions) run over the SCAPI Merchant Users / Merchant Roles APIs. Configure `shortCode`, `tenantId`, and the `sfcc.users(.rw)` / `sfcc.roles(.rw)` scopes to use SCAPI. Search is implemented by filtering the paginated SCAPI user listing. -OCAPI-only commands (no SCAPI equivalent, unavailable on OCAPI-disabled instances): `bm users search`, `bm whoami`, `bm access-key *`. +OCAPI-only operations (no SCAPI equivalent, unavailable on OCAPI-disabled instances): raw `bm users search --query` JSON, `bm whoami`, and `bm access-key *`. -OCAPI is deprecated and disabled on newer instances. `--api-backend auto` (the default) falls back to the OCAPI Data API only when SCAPI scopes are not configured; force a backend with `--api-backend scapi|ocapi` if needed. `bm users update --disabled` is the one write that requires OCAPI (SCAPI's PATCH endpoint can't change `disabled`), so it is unavailable on OCAPI-disabled instances. +OCAPI is deprecated and disabled on newer instances. `--api-backend auto` (the default) falls back on safe SCAPI capability/auth/request rejections; force a backend with `--api-backend scapi|ocapi` if needed. SCAPI updates `disabled` by reading the current user and preserving its writable fields through PUT because PATCH omits that field. ## Authentication The CLI auto-discovers the target instance and credentials from `SFCC_*` environment variables, `dw.json` in the current or parent directories, `~/.mobify`, `package.json`, and configuration plugins. **Flags like `--server`, `--client-id`, and `--client-secret` are usually unnecessary** — only pass them to override what's auto-detected. Run `b2c setup inspect` to see the resolved configuration and which source provided each value. For precedence and troubleshooting, see the `b2c-cli:b2c-config` skill. -Most BM commands accept either client credentials or browser-based user auth. A handful require a *real BM user identity* and the CLI defaults those to user-auth automatically. +SCAPI currently requires client credentials or JWT Bearer; it does not support browser-based user auth. User auth continues to work through OCAPI and WebDAV, and `auto` selects OCAPI for that flow. A handful of OCAPI endpoints require a _real BM user identity_ and default to user auth. -| Command group | Default auth | Why | -|---|---|---| -| `b2c bm roles ...` | client-credentials → jwt → implicit | OCAPI permissions for `/roles` | -| `b2c bm users {list,get,search,update,delete}` | client-credentials → jwt → implicit | OCAPI permissions for `/users` | -| `b2c bm whoami` | **implicit (browser)** | OCAPI `/users/this` requires the token to resolve to a BM user | -| `b2c bm access-key {get,create,set,delete}` | **implicit (browser)** | OCAPI access-key endpoints require "a valid user" plus `Manage_Users_Access_Keys` permission | +| Command group | Default auth | Why | +| ---------------------------------------------- | ----------------------------------- | -------------------------------------------------------------------------------------------- | +| `b2c bm roles ...` | client-credentials → jwt → implicit | OCAPI permissions for `/roles` | +| `b2c bm users {list,get,search,update,delete}` | client-credentials → jwt → implicit | OCAPI permissions for `/users` | +| `b2c bm whoami` | **implicit (browser)** | OCAPI `/users/this` requires the token to resolve to a BM user | +| `b2c bm access-key {get,create,set,delete}` | **implicit (browser)** | OCAPI access-key endpoints require "a valid user" plus `Manage_Users_Access_Keys` permission | Override the default with `--auth-methods client-credentials` (or `--client-secret` flags) when your service-client setup is configured to issue user-bearing tokens. @@ -81,7 +81,7 @@ The permissions JSON has four sections: `functional`, `module`, `locale`, and `w ## Business Manager Users -These commands cover the full lifecycle — **create/read/search/update/delete** — for BM users, plus the per-user access-key administration below. Note that `bm users create` is a create-or-replace that only works on instances configured to allow *local* BM users; most production instances use SSO with Account Manager and reject it with `LocalUserCreationException`, in which case users are provisioned in Account Manager and managed here for the rest of their lifecycle. +These commands cover the full lifecycle — **create/read/search/update/delete** — for BM users, plus the per-user access-key administration below. Note that `bm users create` is a create-or-replace that only works on instances configured to allow _local_ BM users; most production instances use SSO with Account Manager and reject it with `LocalUserCreationException`, in which case users are provisioned in Account Manager and managed here for the rest of their lifecycle. ```bash # list (default 25) @@ -131,11 +131,11 @@ Defaults to browser-based user-auth — a fresh shell will trigger an `b2c auth Access keys let SSO-managed BM users authenticate to non-OAuth surfaces (WebDAV, classic OCAPI/SCAPI Basic auth, or Storefront diagnostics). Three scopes exist; pick the one matching the surface you need to use. -| Scope | Used for | -|---|---| +| Scope | Used for | +| ----------------------------- | ----------------------------------------------------- | | `WEBDAV_AND_STUDIO` (default) | WebDAV uploads (cartridge sync, IMPEX), Studio access | -| `AGENT_USER_AND_OCAPI` | Customer Service Center (CSC) and OCAPI Basic auth | -| `STOREFRONT` | Storefront diagnostic / agent login passwords | +| `AGENT_USER_AND_OCAPI` | Customer Service Center (CSC) and OCAPI Basic auth | +| `STOREFRONT` | Storefront diagnostic / agent login passwords | `[LOGIN]` is **optional** on every access-key command — when omitted, the CLI calls `bm whoami` first and operates on your own user. Passing an explicit login lets administrators manage someone else's keys (requires `Manage_Users_Access_Keys` permission). diff --git a/skills/b2c-cli/skills/b2c-code/SKILL.md b/skills/b2c-cli/skills/b2c-code/SKILL.md index c4bc2230d..249bb8597 100644 --- a/skills/b2c-cli/skills/b2c-code/SKILL.md +++ b/skills/b2c-cli/skills/b2c-code/SKILL.md @@ -118,13 +118,13 @@ b2c code delete `code deploy` (file upload itself), `code download`, and `code watch` always use WebDAV — only the surrounding code-version operations use SCAPI. -OCAPI is deprecated and disabled on newer instances. `--api-backend auto` (the default) falls back to the OCAPI Data API only when SCAPI scopes are not configured; force a backend with `--api-backend scapi|ocapi` if needed. The `--reload` flag forces a code cache reload as activate(alternate) + activate(target), using whichever backend the command selected — so it works on OCAPI-disabled instances when SCAPI is configured. +OCAPI is deprecated and disabled on newer instances. `--api-backend auto` (the default) falls back to the OCAPI Data API on safe SCAPI capability/auth/request rejections; force a backend with `--api-backend scapi|ocapi` if needed. The `--reload` flag forces a code cache reload as activate(alternate) + activate(target), using whichever backend the command selected — so it works on OCAPI-disabled instances when SCAPI is configured. ### More Commands See `b2c code --help` for a full list of available commands and options in the `code` topic. -> **Note:** `b2c code deploy` uploads cartridge *code* to an instance. To manage which cartridges are *active on a site* (the cartridge path), see the `b2c-cli:b2c-sites` skill for the `b2c sites cartridges` commands. +> **Note:** `b2c code deploy` uploads cartridge _code_ to an instance. To manage which cartridges are _active on a site_ (the cartridge path), see the `b2c-cli:b2c-sites` skill for the `b2c sites cartridges` commands. ## Related Skills diff --git a/skills/b2c-cli/skills/b2c-config/SKILL.md b/skills/b2c-cli/skills/b2c-config/SKILL.md index ae7a55624..d760c9711 100644 --- a/skills/b2c-cli/skills/b2c-config/SKILL.md +++ b/skills/b2c-cli/skills/b2c-config/SKILL.md @@ -28,15 +28,15 @@ When in doubt, **always run `b2c setup inspect` first** — it shows the resolve Field names in `dw.json` accept **both camelCase and kebab-case** — they're equivalent. For example: -| Either form works | -|---| -| `clientId` ≡ `client-id` | -| `clientSecret` ≡ `client-secret` | -| `codeVersion` ≡ `code-version` | -| `tenantId` ≡ `tenant-id` | -| `shortCode` ≡ `short-code` ≡ `scapi-shortcode` | +| Either form works | +| ------------------------------------------------------------------------- | +| `clientId` ≡ `client-id` | +| `clientSecret` ≡ `client-secret` | +| `codeVersion` ≡ `code-version` | +| `tenantId` ≡ `tenant-id` | +| `shortCode` ≡ `short-code` ≡ `scapi-shortcode` | | `webdavHostname` ≡ `webdav-hostname` ≡ `webdav-server` ≡ `secureHostname` | -| `certificatePassphrase` ≡ `certificate-passphrase` ≡ `passphrase` | +| `certificatePassphrase` ≡ `certificate-passphrase` ≡ `passphrase` | Legacy aliases like `server` (for `hostname`) are also still supported. If a value isn't being picked up, casing is rarely the cause — check spelling, then run `b2c setup inspect` to see what the CLI actually parsed. @@ -53,7 +53,7 @@ Most commands that interact with a B2C Commerce instance require authentication. ### `--user-auth` Flag -Many commands support `--user-auth` to use browser-based implicit OAuth instead of client credentials. This is useful when: +Many commands support `--user-auth` to use browser-based OAuth instead of client credentials. SCAPI Admin APIs do not currently support this flow; migrated commands use OCAPI in `auto` mode, while explicit SCAPI reports an authentication error. User auth remains useful when: - You don't have a `clientSecret` configured - You need user-level permissions (e.g., Account Manager admin roles) @@ -169,6 +169,10 @@ b2c setup instance create staging --hostname staging.example.com # Create and set as active b2c setup instance create staging --hostname staging.example.com --active +# Optionally save SCAPI coordinates and use SCAPI-first active-version detection +b2c setup instance create staging --hostname staging.example.com \ + --short-code kv7kzm78 --tenant-id zzxy_prd --api-backend auto + # Non-interactive mode (for scripts) b2c setup instance create staging \ --hostname staging.example.com \ @@ -177,6 +181,8 @@ b2c setup instance create staging \ --force ``` +`shortCode` and `tenantId` are optional. When present with stateless OAuth, setup tries SCAPI first to detect the active code version; otherwise `auto` uses OCAPI. If detection fails, interactive setup reports the reason and allows manual code-version entry. + ### Switch Active Instance ```bash @@ -213,7 +219,7 @@ The `setup inspect` command displays configuration organized by category: Each value shows its source in brackets: - `[DwJsonSource]` — Value from dw.json file -- `[EnvSource]` — Value from an SFCC_* environment variable +- `[EnvSource]` — Value from an SFCC\_\* environment variable - `[MobifySource]` — Value from ~/.mobify file - `[PackageJsonSource]` — Value from package.json `b2c` key - Plugin-provided source names (e.g., a credential plugin) @@ -239,7 +245,7 @@ When troubleshooting, check the source column to understand which configuration - The CLI is not finding `clientId`/`clientSecret`. Run `b2c setup inspect` and check the OAuth section. - Confirm `dw.json` exists in the current directory or a parent (the CLI walks up from `cwd`). -- Confirm `SFCC_CLIENT_ID`/`SFCC_CLIENT_SECRET` env vars are exported in *this* shell, not just defined elsewhere. +- Confirm `SFCC_CLIENT_ID`/`SFCC_CLIENT_SECRET` env vars are exported in _this_ shell, not just defined elsewhere. - Credential groups are **atomic**: if `clientId` comes from one source and `clientSecret` from a lower-priority one, the lower-priority secret is discarded. Provide both from the same source, or use a higher-priority override. ### Command targets the wrong instance @@ -258,7 +264,7 @@ When troubleshooting, check the source column to understand which configuration ### 401/403 errors on SCAPI/OCAPI calls -- Confirm the resolved `clientId`/`clientSecret` belong to the *target* instance (Account Manager scopes the API client per tenant). +- Confirm the resolved `clientId`/`clientSecret` belong to the _target_ instance (Account Manager scopes the API client per tenant). - Check OAuth scopes: required scopes vary by command (e.g., `sfcc.cdn-zones`, `sfcc.orders`). Pass `--auth-scope` or set `SFCC_OAUTH_SCOPES`. - For SCAPI commands, verify `tenantId` is correct — tenant IDs use underscores (`zzxy_001`), hostnames use hyphens (`zzxy-001`). The CLI normalizes between them, but a wrong tenant ID will produce 403s. diff --git a/skills/b2c-cli/skills/b2c-job/SKILL.md b/skills/b2c-cli/skills/b2c-job/SKILL.md index 60e326c2b..0e0f9999d 100644 --- a/skills/b2c-cli/skills/b2c-job/SKILL.md +++ b/skills/b2c-cli/skills/b2c-job/SKILL.md @@ -9,7 +9,7 @@ Use the `b2c` CLI plugin to **run existing jobs** and import/export site archive > **Tip:** If `b2c` is not installed globally, use `npx @salesforce/b2c-cli` instead (e.g., `npx @salesforce/b2c-cli job run`). -> **Creating a new job?** If you need to write custom job step *code* (batch processing, scheduled tasks, data sync) **or author the `jobs.xml` job definition** that makes a job exist (so it can be run/scheduled), use the `b2c:b2c-custom-job-steps` skill — see its [jobs.xml Reference](../../../b2c/skills/b2c-custom-job-steps/references/JOBS-XML.md). `b2c job run` only executes jobs that already exist on the instance. +> **Creating a new job?** If you need to write custom job step _code_ (batch processing, scheduled tasks, data sync) **or author the `jobs.xml` job definition** that makes a job exist (so it can be run/scheduled), use the `b2c:b2c-custom-job-steps` skill — see its [jobs.xml Reference](../../../b2c/skills/b2c-custom-job-steps/references/JOBS-XML.md). `b2c job run` only executes jobs that already exist on the instance. ## Configuration & Authentication @@ -157,14 +157,14 @@ b2c job export --global-data meta_data --timeout 600 **Top-level categories** (each takes one or more IDs via flags): -| Flag | Description | -|---|---| -| `--site` | Site IDs to export (use `--site-data` to pick specific units, defaults to all) | -| `--catalog` | Catalog IDs | -| `--library` | Library IDs | -| `--inventory-list` | Inventory list IDs | -| `--price-book` | Price book IDs | -| `--global-data` | Global data units (comma-separated names from the list below) | +| Flag | Description | +| ------------------ | ------------------------------------------------------------------------------ | +| `--site` | Site IDs to export (use `--site-data` to pick specific units, defaults to all) | +| `--catalog` | Catalog IDs | +| `--library` | Library IDs | +| `--inventory-list` | Inventory list IDs | +| `--price-book` | Price book IDs | +| `--global-data` | Global data units (comma-separated names from the list below) | **Site data units** (use with `--site-data`): @@ -225,7 +225,9 @@ Job commands run over SCAPI. Configure `shortCode`, `tenantId`, and the SCAPI sc **SCAPI scopes**: `sfcc.jobs.rw` (recommended) for full access, or `sfcc.jobs` for read-only (search, wait, log). -OCAPI is deprecated and disabled on newer instances. `--api-backend auto` (the default) falls back to the OCAPI Data API only when SCAPI scopes are not configured; force a backend with `--api-backend scapi|ocapi`, dw.json `"api-backend": "scapi"`, or `SFCC_API_BACKEND=scapi`. +OCAPI is deprecated and disabled on newer instances. `--api-backend auto` (the default) falls back to the OCAPI Data API on safe SCAPI capability/auth/request rejections; force a backend with `--api-backend scapi|ocapi`, dw.json `"api-backend": "scapi"`, or `SFCC_API_BACKEND=scapi`. + +Stopping a running execution has no equivalent operation in the current SCAPI Jobs schema. Use the temporary explicit OCAPI compatibility path for cancellation; SCAPI `DELETE` removes an execution record and is not used as a substitute. > **Note:** `job import` and `job export` trigger the site-archive system jobs and transfer archive files over WebDAV. The job trigger honors `--api-backend`: in `auto` mode it runs over SCAPI (needs `sfcc.jobs.rw`) with OCAPI fallback if the SCAPI start is rejected. The archive transfer always uses WebDAV. diff --git a/skills/b2c-cli/skills/b2c-sites/SKILL.md b/skills/b2c-cli/skills/b2c-sites/SKILL.md index 567bfad62..d37679a14 100644 --- a/skills/b2c-cli/skills/b2c-sites/SKILL.md +++ b/skills/b2c-cli/skills/b2c-sites/SKILL.md @@ -75,18 +75,18 @@ When OCAPI direct permissions for `/sites/*/cartridges` are unavailable, cartrid **Key flags (inherited from InstanceCommand):** -| Flag | Short | Description | -|------|-------|-------------| -| `--server` | `-s` | B2C instance hostname (env: `SFCC_SERVER`) | -| `--json` | | Output full site data as JSON | -| `--instance` | | Named instance from config | -| `--debug` | | Enable debug logging | +| Flag | Short | Description | +| ------------ | ----- | ------------------------------------------ | +| `--server` | `-s` | B2C instance hostname (env: `SFCC_SERVER`) | +| `--json` | | Output full site data as JSON | +| `--instance` | | Named instance from config | +| `--debug` | | Enable debug logging | **Output columns:** ID, Display Name, Status (storefront_status). **JSON output** returns the full site objects including all properties (useful for extracting channel IDs, custom preferences, and other site metadata not shown in the table). -`sites list` and `sites cartridges list` run over SCAPI (the `site/sites` API) when `shortCode`, `tenantId`, and the `sfcc.sites` / `sfcc.sites.rw` scopes are configured; otherwise they fall back to the deprecated OCAPI Data API. Cartridge-path **writes** (`add`/`remove`/`set`) have no SCAPI equivalent and always use OCAPI / site-archive import. +Site reads and cartridge-path writes run over SCAPI (the `site/sites` API) when `shortCode`, `tenantId`, and the `sfcc.sites` / `sfcc.sites.rw` scopes are configured. `auto` temporarily falls back to deprecated OCAPI on safe SCAPI rejections, and writes can fall back again to site archive import when direct APIs are unavailable. ## Common Use Cases From 2feab01f654eec77c6d702777ec2818472f131f1 Mon Sep 17 00:00:00 2001 From: Charles Lavery Date: Tue, 11 Aug 2026 15:21:07 -0400 Subject: [PATCH 22/22] fix(scapi): harden fallback compatibility --- .changeset/scapi-migration.md | 4 +- docs/cli/bm.md | 6 +- docs/guide/authentication.md | 6 +- packages/b2c-cli/src/commands/code/deploy.ts | 6 +- .../src/commands/job/execution/delete.ts | 8 +- .../test/commands/bm/access-key/get.test.ts | 14 ++ .../b2c-cli/test/commands/bm/whoami.test.ts | 14 ++ packages/b2c-tooling-sdk/src/clients/index.ts | 3 + .../src/clients/scapi-backend-utils.ts | 44 +++- .../b2c-tooling-sdk/src/compat/dispatcher.ts | 7 +- .../b2c-tooling-sdk/src/instance/index.ts | 82 +++++-- .../src/operations/bm-users/scapi-backend.ts | 8 +- .../src/operations/bm-users/users.ts | 6 + .../src/operations/jobs/discover.ts | 2 + .../clients/scapi-fallback-backend.test.ts | 20 +- .../test/compat/dispatcher.test.ts | 7 +- .../test/instance/scapi-client-config.test.ts | 23 ++ .../ocapi-compatibility-guard.test.ts | 56 +++++ .../operations/bm-users/scapi-search.test.ts | 4 + .../test/operations/jobs/discover.test.ts | 21 +- packages/b2c-vs-extension/DEVELOPMENT.md | 2 +- packages/b2c-vs-extension/package.json | 25 +- .../api-browser/api-browser-tree-provider.ts | 5 +- .../src/api-browser/swagger-webview.ts | 16 +- .../src/api-browser/tenant.ts | 24 ++ .../src/export-tree/export-tree-provider.ts | 3 +- .../b2c-vs-extension/src/export-tree/index.ts | 8 +- packages/b2c-vs-extension/src/extension.ts | 2 +- packages/b2c-vs-extension/src/jobs/index.ts | 4 +- .../src/jobs/jobs-commands.ts | 66 +---- .../src/jobs/jobs-tree-provider.ts | 4 +- .../src/test/api-browser.test.ts | 18 ++ .../src/test/jobs-menu.test.ts | 6 - .../src/walkthrough/onboardingPanel.ts | 2 +- pnpm-lock.yaml | 226 +++++++++--------- .../skills/b2c-bm-users-roles/SKILL.md | 4 +- skills/b2c-cli/skills/b2c-cap/SKILL.md | 2 +- skills/b2c-cli/skills/b2c-config/SKILL.md | 2 +- skills/b2c-cli/skills/b2c-job/SKILL.md | 2 +- 39 files changed, 474 insertions(+), 288 deletions(-) create mode 100644 packages/b2c-tooling-sdk/test/operations/bm-users/ocapi-compatibility-guard.test.ts create mode 100644 packages/b2c-vs-extension/src/api-browser/tenant.ts diff --git a/.changeset/scapi-migration.md b/.changeset/scapi-migration.md index c1715a6f5..7ed6ea35b 100644 --- a/.changeset/scapi-migration.md +++ b/.changeset/scapi-migration.md @@ -6,10 +6,12 @@ '@salesforce/b2c-agent-plugins': patch --- -Migrate `job`, `code`, `bm users`, `bm roles`, `sites`, and catalog discovery to SCAPI-first operation with a temporary OCAPI compatibility fallback. `auto` tries SCAPI when its coordinates and stateless authentication are available, pins the selected backend for multi-request operations, and falls back only on safe capability/auth/request rejections. Site cartridge-path writes, portable BM user search, disabled-user updates, system-job triggers, SDK/CLI/MCP code-version discovery, and VS Code jobs/code/catalog surfaces now participate. Inventory-list enumeration, BM `whoami`, access-key administration, raw OCAPI user-search JSON, and running-job cancellation remain explicit OCAPI compatibility operations because the current live SCAPI schemas have no equivalent. +Migrate `job`, `code`, `bm users`, `bm roles`, `sites`, and catalog discovery to SCAPI-first operation with a temporary OCAPI compatibility fallback. `auto` tries SCAPI when its coordinates and stateless authentication are available, pins the selected backend for multi-request operations, and falls back only on safe capability/auth/request rejections. Site cartridge-path writes, portable BM user search, disabled-user updates, system-job triggers, SDK/CLI/MCP code-version discovery, and VS Code jobs/code/catalog surfaces now participate. Inventory-list enumeration, BM `whoami`, access-key administration, and raw OCAPI user-search JSON remain temporary OCAPI compatibility operations because the current live SCAPI schemas have no equivalent. Explicit SCAPI mode rejects these operations before contacting OCAPI and identifies B2C Commerce release 26.8 as the current capability baseline. `setup instance create` accepts optional SCAPI coordinates for SCAPI-first active-code-version detection. They are not required in `auto`; missing coordinates select OCAPI, and failed interactive detection reports the reason before allowing manual entry. This is a major release because SCAPI and OCAPI JSON/results intentionally retain their backend-specific shapes. Consumers that require a stable legacy shape must explicitly select OCAPI or use the exported compatibility/fallback primitives during the migration. SDK high-level code helpers accept an explicit scripts backend; dual-backend factories and `JobsCompatibilityBackend` expose reusable fallback without making implicit backend selection an SDK-wide policy. SCAPI currently requires client-credentials or JWT Bearer authentication. Browser-based user auth continues through OCAPI/WebDAV and is selected by `auto`; explicit SCAPI with user auth errors clearly until the platform adds support. + +The VS Code extension uses configured tenant IDs consistently in API Browser, keeps partial export discovery warnings in the output log instead of showing notifications, and supports JWT-authenticated OCAPI fallback equivalently to client credentials. diff --git a/docs/cli/bm.md b/docs/cli/bm.md index 83903fb9d..3959726ee 100644 --- a/docs/cli/bm.md +++ b/docs/cli/bm.md @@ -35,17 +35,17 @@ b2c bm users list --api-backend scapi # force SCAPI b2c bm roles get Administrator --api-backend ocapi # force the legacy OCAPI backend ``` -Or set `"api-backend": "scapi"` in `dw.json`, or `SFCC_API_BACKEND=scapi`. The OCAPI-only operations (`bm users search --query`, `bm whoami`, `bm access-key`) are unavailable on OCAPI-disabled instances. +Or set `"api-backend": "scapi"` in `dw.json`, or `SFCC_API_BACKEND=scapi`. As of B2C Commerce release 26.8, the live SCAPI schemas do not expose equivalents for `bm users search --query`, `bm whoami`, or `bm access-key`. In `auto` mode these use the temporary OCAPI compatibility path. Explicit SCAPI mode fails before contacting OCAPI and directs the user to `--api-backend ocapi` until platform support becomes available. ::: The SCAPI Users PATCH endpoint does not include the `disabled` field, so `bm users update --disabled` reads the current user and preserves its writable fields through SCAPI PUT. ## Authentication -BM commands authenticate via OAuth against the configured Commerce Cloud instance. SCAPI currently supports client credentials and JWT Bearer for these commands. Browser-based user auth remains supported through OCAPI and WebDAV, not SCAPI: +BM commands authenticate via OAuth against the configured Commerce Cloud instance. As of release 26.8, SCAPI supports client credentials and JWT Bearer for these commands. Browser-based user auth remains supported through OCAPI and WebDAV, not SCAPI: - **Client credentials** — for automation and CI/CD. Configure an Account Manager API client and grant it the OCAPI permissions listed below. Pass credentials via `--client-id` / `--client-secret`, the `SFCC_CLIENT_ID` / `SFCC_CLIENT_SECRET` environment variables, or `dw.json`. -- **User auth (browser)** — for interactive OCAPI/WebDAV use. Pass `--user-auth` (or run `b2c auth login` once and reuse the saved session). In `auto` mode migrated operations select OCAPI; explicit SCAPI reports that user auth is not currently supported. +- **User auth (browser)** — for interactive OCAPI/WebDAV use. Pass `--user-auth` (or run `b2c auth login` once and reuse the saved session). In `auto` mode migrated operations select OCAPI; explicit SCAPI reports that browser user authentication is not supported by SCAPI Admin APIs as of release 26.8 and directs the user to system authentication or OCAPI. A handful of endpoints require _a real BM user identity_ and cannot use service-client tokens — the CLI defaults those to user-auth automatically: diff --git a/docs/guide/authentication.md b/docs/guide/authentication.md index 9f0678a0b..7cf63e05c 100644 --- a/docs/guide/authentication.md +++ b/docs/guide/authentication.md @@ -326,7 +326,7 @@ b2c code list --auth-methods jwt ## OCAPI Configuration ::: warning OCAPI is deprecated -OCAPI (the Open Commerce API / Data API) is **deprecated** and is being disabled across instances. Newer instances reject OCAPI calls entirely (`OcapiDeprecatedException`). Prefer [SCAPI](#scapi-authentication) for code, jobs, BM users/roles, sites, and catalog discovery. The CLI uses SCAPI first and temporarily falls back on safe capability/auth/request rejections. Configure OCAPI only for compatible instances or operations with no live SCAPI equivalent, such as inventory-list enumeration, BM `whoami` / access keys / raw user-search JSON, and running-job cancellation. +OCAPI (the Open Commerce API / Data API) is **deprecated** and is being disabled across instances. Newer instances reject OCAPI calls entirely (`OcapiDeprecatedException`). Prefer [SCAPI](#scapi-authentication) for code, jobs, BM users/roles, sites, and catalog discovery. The CLI uses SCAPI first and temporarily falls back on safe capability/auth/request rejections. Configure OCAPI only for compatible instances or operations with no live SCAPI equivalent as of release 26.8, such as inventory-list enumeration and BM `whoami` / access keys / raw user-search JSON. Explicit SCAPI mode rejects these operations before contacting OCAPI. If a command fails with "OCAPI is deprecated and disabled for this instance," configure [SCAPI scopes](#scapi-authentication) on your API client instead. ::: @@ -495,7 +495,7 @@ For operations that interact with B2C Commerce instances (code deployment, jobs, SCAPI (the Salesforce Commerce API) is the **preferred, modern** API surface and the CLI's default for every operation that supports it. SCAPI-native commands (eCDN, SCAPI schemas, custom APIs) require it, and dual-backend commands use it first with a temporary [deprecated OCAPI fallback](#ocapi-configuration). All require OAuth authentication with specific roles and scopes. -The SCAPI Admin APIs used here currently support stateless client-credentials or JWT Bearer authentication, not browser-based user authentication. `--user-auth` continues to work with OCAPI and WebDAV. In `auto` mode a user-authenticated migrated command selects OCAPI; `--api-backend scapi --user-auth` errors clearly. Platform support for SCAPI user authentication may be added later. +As of B2C Commerce release 26.8, the SCAPI Admin APIs used here support stateless client-credentials or JWT Bearer authentication, not browser-based user authentication. `--user-auth` continues to work with OCAPI and WebDAV. In `auto` mode a user-authenticated migrated command selects OCAPI; `--api-backend scapi --user-auth` errors clearly and directs the user to system authentication or OCAPI. The tooling will be updated when platform support becomes available. ### Required Setup @@ -644,7 +644,7 @@ Here's a complete example for setting up CLI access: ### 2. (Optional) Configure OCAPI fallback -With the SCAPI scopes above configured, `code`, `jobs`, `bm users/roles`, `sites`, and catalog discovery run over SCAPI. Configure OCAPI only for operations with no live equivalent — inventory-list enumeration, BM `whoami`, access keys, raw `bm users search --query`, and running-job cancellation — or as the temporary `auto` fallback. Note that OCAPI is [deprecated](#ocapi-configuration) and disabled on newer instances. To set it up, add the JSON configuration shown in [OCAPI Configuration](#ocapi-configuration). +With the SCAPI scopes above configured, `code`, `jobs`, `bm users/roles`, `sites`, and catalog discovery run over SCAPI. Configure OCAPI only for operations with no live equivalent as of release 26.8 — inventory-list enumeration, BM `whoami`, access keys, and raw `bm users search --query` — or as the temporary `auto` fallback. Explicit SCAPI mode raises a capability error before contacting OCAPI. Note that OCAPI is [deprecated](#ocapi-configuration) and disabled on newer instances. To set it up, add the JSON configuration shown in [OCAPI Configuration](#ocapi-configuration). ### 3. Configure WebDAV Access (for code deploy/watch, webdav commands) diff --git a/packages/b2c-cli/src/commands/code/deploy.ts b/packages/b2c-cli/src/commands/code/deploy.ts index 90badab55..a0eb88c3b 100644 --- a/packages/b2c-cli/src/commands/code/deploy.ts +++ b/packages/b2c-cli/src/commands/code/deploy.ts @@ -86,14 +86,14 @@ export default class CodeDeploy extends CartridgeCommand { let version = this.resolvedConfig.values.codeVersion; // OAuth is required if: - // 1. No code version specified (need to auto-discover via OCAPI) - // 2. --activate or --reload flag is set (need to call OCAPI) + // 1. No code version is specified (active-version API discovery) + // 2. --activate or --reload is set (code-version API mutation) const needsOAuth = !version || this.flags.activate || this.flags.reload; if (needsOAuth && !this.hasOAuthCredentials()) { const reason = version ? t( 'commands.code.deploy.oauthRequiredForActivate', - 'The --activate/--reload flag requires OAuth credentials to manage the code version via OCAPI.', + 'The --activate/--reload flag requires OAuth credentials to manage the code version via SCAPI or the temporary OCAPI fallback.', ) : t( 'commands.code.deploy.oauthRequiredForDiscovery', diff --git a/packages/b2c-cli/src/commands/job/execution/delete.ts b/packages/b2c-cli/src/commands/job/execution/delete.ts index d1a959760..45181a470 100644 --- a/packages/b2c-cli/src/commands/job/execution/delete.ts +++ b/packages/b2c-cli/src/commands/job/execution/delete.ts @@ -6,6 +6,7 @@ import {Args} from '@oclif/core'; import {JobCommand} from '@salesforce/b2c-tooling-sdk/cli'; import {scapiDeleteJobExecution} from '@salesforce/b2c-tooling-sdk/operations/jobs'; +import {scapiUnavailableMessage} from '@salesforce/b2c-tooling-sdk/clients'; import {t, withDocs} from '../../../i18n/index.js'; export default class JobExecutionDelete extends JobCommand { @@ -56,12 +57,7 @@ export default class JobExecutionDelete extends JobCommand { await expectError(() => command.run(), /Failed to get access key/); }); + + it('fails visibly without contacting OCAPI when SCAPI is explicitly selected', async () => { + const command: any = await createCommand({scope: 'WEBDAV_AND_STUDIO'}, {login: 'user@x.com'}); + stubCommon(command, {jsonEnabled: true}); + sinon.stub(command, 'log').returns(void 0); + const ocapiGet = sinon.stub(); + sinon.stub(command, 'instance').get(() => ({apiBackend: 'scapi', ocapi: {GET: ocapiGet}})); + + await expectError( + () => command.run(), + /SCAPI does not currently support Business Manager access-key administration.*release 26\.8/, + ); + expect(ocapiGet.called).to.equal(false); + }); }); diff --git a/packages/b2c-cli/test/commands/bm/whoami.test.ts b/packages/b2c-cli/test/commands/bm/whoami.test.ts index bd5c4625d..4f5068fa9 100644 --- a/packages/b2c-cli/test/commands/bm/whoami.test.ts +++ b/packages/b2c-cli/test/commands/bm/whoami.test.ts @@ -84,4 +84,18 @@ describe('bm whoami', () => { await expectError(() => command.run(), /Failed to get current user/); }); + + it('fails visibly without contacting OCAPI when SCAPI is explicitly selected', async () => { + const command: any = await createCommand(); + stubCommon(command, {jsonEnabled: true}); + sinon.stub(command, 'log').returns(void 0); + const ocapiGet = sinon.stub(); + sinon.stub(command, 'instance').get(() => ({apiBackend: 'scapi', ocapi: {GET: ocapiGet}})); + + await expectError( + () => command.run(), + /SCAPI does not currently support Business Manager current-user lookup \(whoami\).*release 26\.8/, + ); + expect(ocapiGet.called).to.equal(false); + }); }); diff --git a/packages/b2c-tooling-sdk/src/clients/index.ts b/packages/b2c-tooling-sdk/src/clients/index.ts index e824e761a..b7fc25a63 100644 --- a/packages/b2c-tooling-sdk/src/clients/index.ts +++ b/packages/b2c-tooling-sdk/src/clients/index.ts @@ -437,15 +437,18 @@ export type { // SCAPI dual-backend utilities (shared across SCAPI/OCAPI domains) export { createScapiRequestError, + assertOcapiCompatibilityAllowed, assertScapiAdminAuthSupported, isFallbackTrigger, isInvalidScopeError, resolveScapiOrOcapi, SAFE_SCAPI_FALLBACK_STATUSES, + SCAPI_CAPABILITY_BASELINE_RELEASE, ScapiCapabilityUnsupportedError, ScapiRequestError, ScapiUserAuthUnsupportedError, scapiUnavailableMessage, + scapiCapabilityUnsupportedMessage, withScopes, } from './scapi-backend-utils.js'; export type {ApiBackendPreference, BackendBase, ResolveBackendOptions} from './scapi-backend-utils.js'; diff --git a/packages/b2c-tooling-sdk/src/clients/scapi-backend-utils.ts b/packages/b2c-tooling-sdk/src/clients/scapi-backend-utils.ts index b6c9b250e..d33261636 100644 --- a/packages/b2c-tooling-sdk/src/clients/scapi-backend-utils.ts +++ b/packages/b2c-tooling-sdk/src/clients/scapi-backend-utils.ts @@ -24,6 +24,9 @@ import {getApiErrorMessage} from './error-utils.js'; */ export type ApiBackendPreference = 'ocapi' | 'scapi' | 'auto'; +/** Platform release used when documenting currently-unavailable SCAPI capabilities. */ +export const SCAPI_CAPABILITY_BASELINE_RELEASE = '26.8'; + /** * Common shape of every dual-backend implementation. Each canonical backend * (e.g., `JobsBackend`) extends this so a generic fallback wrapper can read @@ -37,8 +40,9 @@ export interface BackendBase { export class ScapiUserAuthUnsupportedError extends Error { constructor() { super( - 'SCAPI Admin APIs currently support system authentication only. ' + - 'Use client credentials or JWT Bearer authentication; PKCE/implicit user auth remains available for OCAPI.', + `SCAPI Admin APIs do not currently support browser user authentication as of B2C Commerce release ${SCAPI_CAPABILITY_BASELINE_RELEASE}. ` + + 'Use client credentials or JWT Bearer authentication, or set apiBackend to "ocapi" ' + + '(CLI: --api-backend ocapi) for now. The tooling will be updated when SCAPI support becomes available.', ); this.name = 'ScapiUserAuthUnsupportedError'; } @@ -95,6 +99,30 @@ export class ScapiCapabilityUnsupportedError extends Error { } } +/** Builds the canonical error for a capability absent from the current live SCAPI schemas. */ +export function scapiCapabilityUnsupportedMessage(capability: string): string { + return ( + `SCAPI does not currently support ${capability} as of B2C Commerce release ${SCAPI_CAPABILITY_BASELINE_RELEASE}. ` + + 'Set apiBackend to "ocapi" (CLI: --api-backend ocapi) for now. ' + + 'The tooling will be updated when SCAPI support becomes available.' + ); +} + +/** + * Prevents an OCAPI-only compatibility operation from silently contacting + * OCAPI when the caller explicitly selected SCAPI. `auto` remains eligible + * for the temporary compatibility path, while explicit OCAPI is always + * allowed. + */ +export function assertOcapiCompatibilityAllowed( + preference: ApiBackendPreference | undefined, + capability: string, +): void { + if (preference === 'scapi') { + throw new ScapiCapabilityUnsupportedError(scapiCapabilityUnsupportedMessage(capability)); + } +} + /** * HTTP statuses that prove SCAPI rejected a request before performing it. * @@ -173,9 +201,11 @@ export function scapiUnavailableMessage(domainName: string): string { return ( `${domainName} SCAPI backend requires shortCode, tenantId, and a stateless OAuth flow ` + `(client-credentials or JWT Bearer) that can request the required scopes. ` + - `Browser user auth (Authorization Code + PKCE or implicit) is currently OCAPI/WebDAV-only, ` + - `and fixed-token stored sessions cannot request SCAPI scopes — ` + - `use client-credentials/JWT, or set --api-backend ocapi.` + `Browser user auth (Authorization Code + PKCE or implicit) is not supported by SCAPI Admin APIs ` + + `as of B2C Commerce release ${SCAPI_CAPABILITY_BASELINE_RELEASE} and is currently OCAPI/WebDAV-only; ` + + `fixed-token stored sessions cannot request SCAPI scopes — ` + + `use client-credentials/JWT, or set --api-backend ocapi for now. ` + + `The tooling will be updated when SCAPI support becomes available.` ); } @@ -187,7 +217,9 @@ export function scapiUnavailableMessage(domainName: string): string { * - `'auto'` returns `'scapi'` if SCAPI config is available, otherwise `'ocapi'`. * * Throws an error with the domain name in the message when explicit SCAPI is - * requested without the required configuration. + * requested without the required configuration. The error identifies release + * 26.8 as the current platform capability baseline so it can be revised when + * SCAPI adds support. */ export function resolveScapiOrOcapi(opts: ResolveBackendOptions): 'ocapi' | 'scapi' { const {preference, hasScapiConfig, domainName} = opts; diff --git a/packages/b2c-tooling-sdk/src/compat/dispatcher.ts b/packages/b2c-tooling-sdk/src/compat/dispatcher.ts index 371a9b3d9..41b67d0a4 100644 --- a/packages/b2c-tooling-sdk/src/compat/dispatcher.ts +++ b/packages/b2c-tooling-sdk/src/compat/dispatcher.ts @@ -50,7 +50,7 @@ * @module compat/dispatcher */ import {getLogger} from '../logging/logger.js'; -import {isFallbackTrigger, type ApiBackendPreference} from '../clients/scapi-backend-utils.js'; +import {isFallbackTrigger, scapiUnavailableMessage, type ApiBackendPreference} from '../clients/scapi-backend-utils.js'; export type {ApiBackendPreference}; @@ -103,10 +103,7 @@ export class BackendDispatcher { if (probe !== undefined) this.opsCache = probe; if (preference === 'scapi' && !hasScapi) { - throw new Error( - `${domainName} SCAPI backend requires shortCode, tenantId, and OAuth credentials. ` + - `Configure them in dw.json or set apiBackend to ocapi.`, - ); + throw new Error(scapiUnavailableMessage(domainName)); } if (preference === 'scapi') this.resolved = 'scapi'; if (preference === 'ocapi') this.resolved = 'ocapi'; diff --git a/packages/b2c-tooling-sdk/src/instance/index.ts b/packages/b2c-tooling-sdk/src/instance/index.ts index 558987ee3..2bac2a51b 100644 --- a/packages/b2c-tooling-sdk/src/instance/index.ts +++ b/packages/b2c-tooling-sdk/src/instance/index.ts @@ -299,18 +299,36 @@ export class B2CInstance { openBrowser: this.auth.oauth.openBrowser, }; - // Filter to only OAuth methods (client-credentials, user, implicit) - const oauthMethods = (this.auth.authMethods || (['client-credentials', 'user'] as AuthMethod[])).filter( - (m): m is 'client-credentials' | 'user' | 'implicit' => - m === 'client-credentials' || m === 'user' || m === 'implicit', + // Filter to OAuth methods while preserving the configured priority. JWT + // is equivalent to client credentials once it has obtained an AM token, + // so it must remain eligible for OCAPI and WebDAV OAuth calls as well as + // SCAPI. + const oauthMethods = (this.auth.authMethods || (['client-credentials', 'jwt', 'user'] as AuthMethod[])).filter( + (m): m is 'client-credentials' | 'jwt' | 'user' | 'implicit' => + m === 'client-credentials' || m === 'jwt' || m === 'user' || m === 'implicit', ); if (oauthMethods.length === 0) { throw new Error('No OAuth methods allowed. Check authMethods configuration.'); } - this._oauthStrategy = resolveAuthStrategy(credentials, {allowedMethods: oauthMethods}); - return this._oauthStrategy; + for (const method of oauthMethods) { + if (method === 'client-credentials' || method === 'jwt') { + const systemStrategy = this.buildSystemOAuthStrategy(method); + if (systemStrategy) { + this._oauthStrategy = systemStrategy; + return this._oauthStrategy; + } + continue; + } + + if (credentials.clientId) { + this._oauthStrategy = resolveAuthStrategy(credentials, {allowedMethods: [method]}); + return this._oauthStrategy; + } + } + + throw new Error(`No valid OAuth method available. Allowed methods: [${oauthMethods.join(', ')}].`); } /** @@ -328,34 +346,46 @@ export class B2CInstance { * basic-only configs. */ private buildScapiAuthStrategy(): AuthStrategy | undefined { - const oauth = this.auth.oauth; - if (!oauth) { + if (!this.auth.oauth) { return undefined; } - const accountManagerHost = oauth.accountManagerHost ?? DEFAULT_ACCOUNT_MANAGER_HOST; const methods = this.auth.authMethods ?? (['client-credentials', 'jwt'] as AuthMethod[]); for (const method of methods) { - if (method === 'client-credentials' && oauth.clientSecret) { - return new OAuthStrategy({ - clientId: oauth.clientId, - clientSecret: oauth.clientSecret, - scopes: oauth.scopes, - accountManagerHost, - }); + if (method === 'client-credentials' || method === 'jwt') { + const strategy = this.buildSystemOAuthStrategy(method); + if (strategy) return strategy; } + } - if (method === 'jwt' && oauth.jwtCertPath && oauth.jwtKeyPath) { - return new JwtOAuthStrategy({ - clientId: oauth.clientId, - certPath: oauth.jwtCertPath, - keyPath: oauth.jwtKeyPath, - passphrase: oauth.jwtPassphrase, - accountManagerHost, - scopes: oauth.scopes, - }); - } + return undefined; + } + + /** Builds a configured non-interactive OAuth strategy for SCAPI or OCAPI. */ + private buildSystemOAuthStrategy(method: 'client-credentials' | 'jwt'): AuthStrategy | undefined { + const oauth = this.auth.oauth; + if (!oauth) return undefined; + + const accountManagerHost = oauth.accountManagerHost ?? DEFAULT_ACCOUNT_MANAGER_HOST; + if (method === 'client-credentials' && oauth.clientSecret) { + return new OAuthStrategy({ + clientId: oauth.clientId, + clientSecret: oauth.clientSecret, + scopes: oauth.scopes, + accountManagerHost, + }); + } + + if (method === 'jwt' && oauth.jwtCertPath && oauth.jwtKeyPath) { + return new JwtOAuthStrategy({ + clientId: oauth.clientId, + certPath: oauth.jwtCertPath, + keyPath: oauth.jwtKeyPath, + passphrase: oauth.jwtPassphrase, + accountManagerHost, + scopes: oauth.scopes, + }); } return undefined; diff --git a/packages/b2c-tooling-sdk/src/operations/bm-users/scapi-backend.ts b/packages/b2c-tooling-sdk/src/operations/bm-users/scapi-backend.ts index 1948c16a8..5bebe0b4b 100644 --- a/packages/b2c-tooling-sdk/src/operations/bm-users/scapi-backend.ts +++ b/packages/b2c-tooling-sdk/src/operations/bm-users/scapi-backend.ts @@ -24,7 +24,11 @@ import { type UserSearch, } from '../../clients/scapi-merchant-users.js'; import {buildTenantScope, toOrganizationId} from '../../clients/custom-apis.js'; -import {createScapiRequestError, ScapiCapabilityUnsupportedError} from '../../clients/scapi-backend-utils.js'; +import { + createScapiRequestError, + ScapiCapabilityUnsupportedError, + scapiCapabilityUnsupportedMessage, +} from '../../clients/scapi-backend-utils.js'; import {ScopeTierManager} from '../../clients/scapi-scope-tier.js'; function mapScapiUser(scapi: ScapiUser): UserInfo { @@ -96,7 +100,7 @@ export class ScapiUsersBackend implements UsersBackend { async searchUsers(options: SearchUsersOptions = {}): Promise { if (options.query !== undefined) { throw new ScapiCapabilityUnsupportedError( - 'Raw OCAPI user-search query JSON is not supported by SCAPI. Use portable search flags or --api-backend ocapi.', + `${scapiCapabilityUnsupportedMessage('raw OCAPI user-search JSON')} Use portable search flags to stay on SCAPI.`, ); } diff --git a/packages/b2c-tooling-sdk/src/operations/bm-users/users.ts b/packages/b2c-tooling-sdk/src/operations/bm-users/users.ts index 1c4762e34..b75a33de1 100644 --- a/packages/b2c-tooling-sdk/src/operations/bm-users/users.ts +++ b/packages/b2c-tooling-sdk/src/operations/bm-users/users.ts @@ -17,6 +17,7 @@ import type {B2CInstance} from '../../instance/index.js'; import type {components} from '../../clients/ocapi.generated.js'; import {throwOcapiError} from '../../clients/error-utils.js'; +import {assertOcapiCompatibilityAllowed} from '../../clients/scapi-backend-utils.js'; import {SCAPI_MERCHANT_USERS_READ_SCOPES, SCAPI_MERCHANT_USERS_RW_SCOPES} from '../../clients/scapi-merchant-users.js'; // SCAPI Merchant Users scopes named in the OCAPI-deprecation message for the @@ -166,6 +167,7 @@ export async function getBmUser(instance: B2CInstance, login: string): Promise { + assertOcapiCompatibilityAllowed(instance.apiBackend, 'Business Manager current-user lookup (whoami)'); const {data, error, response} = await instance.ocapi.GET('/users/this'); if (error) { @@ -312,6 +314,7 @@ export async function getBmUserAccessKey( login: string, scope: string, ): Promise { + assertOcapiCompatibilityAllowed(instance.apiBackend, 'Business Manager access-key administration'); const {data, error, response} = await instance.ocapi.GET('/users/{login}/access_key/{scope}', { params: {path: {login, scope}}, }); @@ -340,6 +343,7 @@ export async function createBmUserAccessKey( login: string, scope: string, ): Promise { + assertOcapiCompatibilityAllowed(instance.apiBackend, 'Business Manager access-key administration'); const {data, error, response} = await instance.ocapi.PUT('/users/{login}/access_key/{scope}', { params: {path: {login, scope}}, }); @@ -366,6 +370,7 @@ export async function setBmUserAccessKeyEnabled( scope: string, enabled: boolean, ): Promise { + assertOcapiCompatibilityAllowed(instance.apiBackend, 'Business Manager access-key administration'); const {data, error, response} = await instance.ocapi.PATCH('/users/{login}/access_key/{scope}', { params: {path: {login, scope}}, body: {enabled} as components['schemas']['access_key_update_request'], @@ -386,6 +391,7 @@ export async function setBmUserAccessKeyEnabled( * @param scope - Access key scope */ export async function deleteBmUserAccessKey(instance: B2CInstance, login: string, scope: string): Promise { + assertOcapiCompatibilityAllowed(instance.apiBackend, 'Business Manager access-key administration'); const {error, response} = await instance.ocapi.DELETE('/users/{login}/access_key/{scope}', { params: {path: {login, scope}}, }); diff --git a/packages/b2c-tooling-sdk/src/operations/jobs/discover.ts b/packages/b2c-tooling-sdk/src/operations/jobs/discover.ts index c630b3cc5..c16c67657 100644 --- a/packages/b2c-tooling-sdk/src/operations/jobs/discover.ts +++ b/packages/b2c-tooling-sdk/src/operations/jobs/discover.ts @@ -16,6 +16,7 @@ */ import {B2CInstance} from '../../instance/index.js'; import {getLogger} from '../../logging/logger.js'; +import {assertOcapiCompatibilityAllowed} from '../../clients/scapi-backend-utils.js'; import {createCatalogsBackend} from '../catalogs/index.js'; import {createSitesBackend} from '../sites/index.js'; @@ -45,6 +46,7 @@ const PAGE_COUNT = 200; * documents are read. Returns the `id` of each document. */ async function listInventoryListIds(instance: B2CInstance): Promise { + assertOcapiCompatibilityAllowed(instance.apiBackend, 'inventory-list enumeration'); const ids: string[] = []; let start = 0; diff --git a/packages/b2c-tooling-sdk/test/clients/scapi-fallback-backend.test.ts b/packages/b2c-tooling-sdk/test/clients/scapi-fallback-backend.test.ts index b20e7c14c..04a5ea871 100644 --- a/packages/b2c-tooling-sdk/test/clients/scapi-fallback-backend.test.ts +++ b/packages/b2c-tooling-sdk/test/clients/scapi-fallback-backend.test.ts @@ -7,6 +7,7 @@ import {expect} from 'chai'; import {ImplicitOAuthStrategy, PkceOAuthStrategy} from '../../src/auth/index.js'; import {createFallbackBackend} from '../../src/clients/scapi-fallback-backend.js'; import { + assertOcapiCompatibilityAllowed, ScapiCapabilityUnsupportedError, ScapiRequestError, ScapiUserAuthUnsupportedError, @@ -37,11 +38,28 @@ describe('SCAPI Admin authentication guard', () => { new ImplicitOAuthStrategy({clientId: 'public-client', persistSession: false}), ]) { it(`rejects ${strategy.authMethod} browser user auth before making a request`, () => { - expect(() => withScopes(strategy, ['sfcc.jobs'])).to.throw(ScapiUserAuthUnsupportedError); + expect(() => withScopes(strategy, ['sfcc.jobs'])) + .to.throw(ScapiUserAuthUnsupportedError) + .with.property('message') + .that.includes('release 26.8'); }); } }); +describe('OCAPI compatibility guard', () => { + it('allows auto and explicit OCAPI compatibility operations', () => { + expect(() => assertOcapiCompatibilityAllowed('auto', 'inventory-list enumeration')).not.to.throw(); + expect(() => assertOcapiCompatibilityAllowed('ocapi', 'inventory-list enumeration')).not.to.throw(); + }); + + it('rejects compatibility operations in explicit SCAPI mode with release guidance', () => { + expect(() => assertOcapiCompatibilityAllowed('scapi', 'inventory-list enumeration')) + .to.throw(ScapiCapabilityUnsupportedError) + .with.property('message') + .that.includes('SCAPI does not currently support inventory-list enumeration as of B2C Commerce release 26.8'); + }); +}); + describe('createFallbackBackend', () => { describe('happy path: SCAPI works', () => { it('returns SCAPI result on first call and caches the choice', async () => { diff --git a/packages/b2c-tooling-sdk/test/compat/dispatcher.test.ts b/packages/b2c-tooling-sdk/test/compat/dispatcher.test.ts index 208492dfe..b3e43706c 100644 --- a/packages/b2c-tooling-sdk/test/compat/dispatcher.test.ts +++ b/packages/b2c-tooling-sdk/test/compat/dispatcher.test.ts @@ -17,9 +17,10 @@ const invalidScopeError = () => new Error('Failed to get access token: 400 inval describe('BackendDispatcher', () => { describe('preference handling', () => { it('throws when scapi is forced but not configured', () => { - expect(() => new BackendDispatcher('scapi', () => undefined, 'jobs')).to.throw( - /shortCode, tenantId, and OAuth/, - ); + expect(() => new BackendDispatcher('scapi', () => undefined, 'jobs')) + .to.throw(/shortCode, tenantId, and a stateless OAuth flow/) + .with.property('message') + .that.includes('release 26.8'); }); it('resolves to ocapi immediately when forced', () => { diff --git a/packages/b2c-tooling-sdk/test/instance/scapi-client-config.test.ts b/packages/b2c-tooling-sdk/test/instance/scapi-client-config.test.ts index 5a28dc6f4..425d7163e 100644 --- a/packages/b2c-tooling-sdk/test/instance/scapi-client-config.test.ts +++ b/packages/b2c-tooling-sdk/test/instance/scapi-client-config.test.ts @@ -22,6 +22,10 @@ function instance(config: Partial, auth: AuthConfig): B2CInstanc return new B2CInstance({hostname: 'test.demandware.net', ...config}, auth); } +function resolvedOAuthStrategy(b2c: B2CInstance): unknown { + return (b2c as unknown as {getOAuthStrategy(): unknown}).getOAuthStrategy(); +} + describe('instance/B2CInstance.scapiClientConfig', () => { describe('returns config (SCAPI eligible)', () => { it('builds a client-credentials strategy when clientId + clientSecret are present', () => { @@ -117,4 +121,23 @@ describe('instance/B2CInstance.scapiClientConfig', () => { expect(instance({...SCAPI_COORDS, apiBackend: 'scapi'}, {}).apiBackend).to.equal('scapi'); }); }); + + describe('OCAPI OAuth strategy', () => { + it('uses JWT when it is the only configured system OAuth method', () => { + const b2c = instance(SCAPI_COORDS, { + oauth: {clientId: 'client', jwtCertPath: TEST_CERT_PATH, jwtKeyPath: TEST_KEY_PATH}, + }); + + expect(resolvedOAuthStrategy(b2c)).to.be.instanceOf(JwtOAuthStrategy); + }); + + it('honors JWT priority over client credentials', () => { + const b2c = instance(SCAPI_COORDS, { + authMethods: ['jwt', 'client-credentials'], + oauth: {clientId: 'client', clientSecret: 'secret', jwtCertPath: TEST_CERT_PATH, jwtKeyPath: TEST_KEY_PATH}, + }); + + expect(resolvedOAuthStrategy(b2c)).to.be.instanceOf(JwtOAuthStrategy); + }); + }); }); diff --git a/packages/b2c-tooling-sdk/test/operations/bm-users/ocapi-compatibility-guard.test.ts b/packages/b2c-tooling-sdk/test/operations/bm-users/ocapi-compatibility-guard.test.ts new file mode 100644 index 000000000..a41ec9d52 --- /dev/null +++ b/packages/b2c-tooling-sdk/test/operations/bm-users/ocapi-compatibility-guard.test.ts @@ -0,0 +1,56 @@ +/* + * 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 { + createBmUserAccessKey, + deleteBmUserAccessKey, + getBmUserAccessKey, + setBmUserAccessKeyEnabled, + whoamiBmUser, +} from '@salesforce/b2c-tooling-sdk/operations/bm-users'; + +async function expectScapiCompatibilityError(operation: () => Promise): Promise { + try { + await operation(); + expect.fail('Expected explicit SCAPI mode to reject the OCAPI-only operation'); + } catch (error) { + expect(error).to.be.instanceOf(Error); + expect((error as Error).message).to.include('as of B2C Commerce release 26.8'); + expect((error as Error).message).to.include('CLI: --api-backend ocapi'); + } +} + +function explicitScapiInstance(): never { + return { + apiBackend: 'scapi', + ocapi: new Proxy( + {}, + { + get() { + throw new Error('OCAPI must not be accessed in explicit SCAPI mode'); + }, + }, + ), + } as never; +} + +describe('BM user OCAPI compatibility guards', () => { + it('rejects whoami before creating an OCAPI request', async () => { + await expectScapiCompatibilityError(() => whoamiBmUser(explicitScapiInstance())); + }); + + for (const [name, operation] of [ + ['get', (instance: never) => getBmUserAccessKey(instance, 'user@example.com', 'WEBDAV_AND_STUDIO')], + ['create', (instance: never) => createBmUserAccessKey(instance, 'user@example.com', 'WEBDAV_AND_STUDIO')], + ['set', (instance: never) => setBmUserAccessKeyEnabled(instance, 'user@example.com', 'WEBDAV_AND_STUDIO', true)], + ['delete', (instance: never) => deleteBmUserAccessKey(instance, 'user@example.com', 'WEBDAV_AND_STUDIO')], + ] as const) { + it(`rejects access-key ${name} before creating an OCAPI request`, async () => { + await expectScapiCompatibilityError(() => operation(explicitScapiInstance())); + }); + } +}); diff --git a/packages/b2c-tooling-sdk/test/operations/bm-users/scapi-search.test.ts b/packages/b2c-tooling-sdk/test/operations/bm-users/scapi-search.test.ts index e78c51aff..c9820b3d3 100644 --- a/packages/b2c-tooling-sdk/test/operations/bm-users/scapi-search.test.ts +++ b/packages/b2c-tooling-sdk/test/operations/bm-users/scapi-search.test.ts @@ -54,6 +54,10 @@ describe('ScapiUsersBackend search', () => { expect.fail('should have thrown'); } catch (error) { expect(error).to.be.instanceOf(ScapiCapabilityUnsupportedError); + expect((error as Error).message).to.include( + 'SCAPI does not currently support raw OCAPI user-search JSON as of B2C Commerce release 26.8', + ); + expect((error as Error).message).to.include('Use portable search flags to stay on SCAPI'); } }); diff --git a/packages/b2c-tooling-sdk/test/operations/jobs/discover.test.ts b/packages/b2c-tooling-sdk/test/operations/jobs/discover.test.ts index 99d35d22b..37b955352 100644 --- a/packages/b2c-tooling-sdk/test/operations/jobs/discover.test.ts +++ b/packages/b2c-tooling-sdk/test/operations/jobs/discover.test.ts @@ -11,8 +11,9 @@ type GetResult = {data?: unknown; error?: unknown}; type GetHandler = (path: string, init: {params?: {query?: {start?: number; count?: number}}}) => Promise; /** Builds a fake B2CInstance whose ocapi.GET is driven by per-path handlers. */ -function fakeInstance(handlers: Record): never { +function fakeInstance(handlers: Record, apiBackend?: 'auto' | 'ocapi' | 'scapi'): never { return { + apiBackend, ocapi: { async GET(path: string, init: {params?: {query?: {start?: number; count?: number}}}) { const handler = handlers[path]; @@ -109,5 +110,23 @@ describe('operations/jobs/discover', () => { expect(result.warnings).to.have.lengthOf(1); expect(result.warnings[0]).to.include('sites'); }); + + it('does not contact OCAPI for inventory enumeration in explicit SCAPI mode', async () => { + const inventoryGet = async () => { + throw new Error('OCAPI must not be called'); + }; + const instance = fakeInstance({'/inventory_lists': inventoryGet}, 'scapi'); + + const result = await discoverExportableUnits(instance); + + expect(result.inventoryLists).to.deep.equal([]); + expect(result.warnings).to.satisfy((warnings: string[]) => + warnings.some((warning) => + warning.includes( + 'SCAPI does not currently support inventory-list enumeration as of B2C Commerce release 26.8', + ), + ), + ); + }); }); }); diff --git a/packages/b2c-vs-extension/DEVELOPMENT.md b/packages/b2c-vs-extension/DEVELOPMENT.md index 87045ef0a..6e40ba43c 100644 --- a/packages/b2c-vs-extension/DEVELOPMENT.md +++ b/packages/b2c-vs-extension/DEVELOPMENT.md @@ -68,7 +68,7 @@ The **Run Extension** launch configuration performs a production build as a pre- - Tune `b2c-dx.jobs.discoveryExecutionScanLimit` to scan more executions and discover additional job IDs. - Optionally define `b2c-dx.jobs.knownJobIds` to provide quick-pick suggestions before history is populated. 4. Expand a job and verify its execution history and step-level details. -5. Run **Run Job**, **Re-Run Job**, and **Stop Execution** from the view context menu. +5. Run **Run Job** and **Re-Run Job** from the view context menu. 6. Run **Create Job Scaffold** and verify that it creates `jobs.xml`, `README.md`, and a script stub under `b2c-jobs//`. 7. Run **Deploy Job Scaffold**, select the generated `jobs.xml`, confirm the target instance, and verify that deployment completes. 8. Open **Business Manager Jobs** from the success prompt and confirm that the new job definition is present and disabled by default. diff --git a/packages/b2c-vs-extension/package.json b/packages/b2c-vs-extension/package.json index cb077e7ce..61e2ea96a 100644 --- a/packages/b2c-vs-extension/package.json +++ b/packages/b2c-vs-extension/package.json @@ -19,9 +19,9 @@ "dependencies": { "@salesforce/b2c-script-types": "workspace:*", "@salesforce/b2c-tooling-sdk": "workspace:*", - "swagger-ui-dist": "^5.18.0", "react": "18.3.1", "react-dom": "18.3.1", + "swagger-ui-dist": "^5.18.0", "vscode-html-languageservice": "catalog:" }, "engines": { @@ -453,7 +453,7 @@ }, { "view": "b2cApiBrowser", - "contents": "Browse SCAPI OpenAPI schemas for your Commerce Cloud instance.\n\nRequires OAuth credentials (`client-id`, `client-secret`, `shortCode`) in dw.json.\n\n[Connect & authenticate](command:workbench.action.openWalkthrough?%5B%22Salesforce.b2c-vs-extension%23b2c-dx.gettingStarted%22%2C%22connect%22%5D)\n\n[Load APIs](command:b2c-dx.apiBrowser.refresh)" + "contents": "Browse SCAPI OpenAPI schemas for your Commerce Cloud instance.\n\nRequires OAuth credentials (`client-id`, `client-secret`, `short-code`, `tenant-id`) in dw.json.\n\n[Connect & authenticate](command:workbench.action.openWalkthrough?%5B%22Salesforce.b2c-vs-extension%23b2c-dx.gettingStarted%22%2C%22connect%22%5D)\n\n[Load APIs](command:b2c-dx.apiBrowser.refresh)" }, { "view": "b2cSandboxExplorer", @@ -861,12 +861,6 @@ "icon": "$(debug-restart)", "category": "B2C DX - Job History" }, - { - "command": "b2c-dx.jobs.stop", - "title": "Stop Execution", - "icon": "$(debug-stop)", - "category": "B2C DX - Job History" - }, { "command": "b2c-dx.jobs.viewExecutionDetails", "title": "View Execution Details", @@ -1772,11 +1766,6 @@ "when": "view == b2cJobsExplorer && viewItem =~ /^jobExecution-/", "group": "1_lifecycle@2" }, - { - "command": "b2c-dx.jobs.stop", - "when": "view == b2cJobsExplorer && viewItem == jobExecution-running", - "group": "1_lifecycle@3" - }, { "command": "b2c-dx.jobs.openExecutionLog", "when": "view == b2cJobsExplorer && viewItem =~ /^jobExecution-/", @@ -2128,10 +2117,6 @@ "command": "b2c-dx.jobs.rerun", "when": "false" }, - { - "command": "b2c-dx.jobs.stop", - "when": "false" - }, { "command": "b2c-dx.jobs.viewExecutionDetails", "when": "false" @@ -2289,16 +2274,16 @@ "@types/react": "18.3.12", "@types/react-dom": "18.3.1", "@types/vscode": "^1.105.1", - "@vscode/test-cli": "^0.0.12", - "@vscode/test-electron": "^2.5.2", + "@vscode/test-cli": "^0.0.15", + "@vscode/test-electron": "^3.1.0", "@vscode/vsce": "^3.9.1", "c8": "catalog:", "esbuild": "^0.24.0", - "jszip": "3.10.1", "eslint": "catalog:", "eslint-config-prettier": "catalog:", "eslint-plugin-header": "catalog:", "eslint-plugin-prettier": "catalog:", + "jszip": "3.10.1", "prettier": "catalog:", "typescript": "catalog:", "typescript-eslint": "catalog:" diff --git a/packages/b2c-vs-extension/src/api-browser/api-browser-tree-provider.ts b/packages/b2c-vs-extension/src/api-browser/api-browser-tree-provider.ts index 705896097..d335147de 100644 --- a/packages/b2c-vs-extension/src/api-browser/api-browser-tree-provider.ts +++ b/packages/b2c-vs-extension/src/api-browser/api-browser-tree-provider.ts @@ -8,6 +8,7 @@ import {createScapiSchemasClient, toOrganizationId} from '@salesforce/b2c-toolin import type {SchemaListItem} from '@salesforce/b2c-tooling-sdk/clients'; import * as vscode from 'vscode'; import type {B2CExtensionConfig} from '../config-provider.js'; +import {resolveApiBrowserTenantId} from './tenant.js'; export class ApiFamilyTreeItem extends vscode.TreeItem { readonly nodeType = 'apiFamily' as const; @@ -141,9 +142,7 @@ export class ApiBrowserTreeDataProvider implements vscode.TreeDataProvider { - const tenantId = deriveTenantId(config.values.hostname); - if (!tenantId) throw new Error('Could not derive tenant ID from hostname.'); + const tenantId = resolveApiBrowserTenantId(config.values); + if (!tenantId) throw new Error('Tenant ID not found. Set tenant-id in dw.json.'); const oauthOptions = await this.configProvider.getImplicitAuthOptions(); const oauthStrategy = config.createOAuth(oauthOptions); @@ -638,7 +634,7 @@ export class SwaggerWebviewManager implements vscode.Disposable { if (!slasClientId || !siteId) return null; - const tenantId = deriveTenantId(config.values.hostname); + const tenantId = resolveApiBrowserTenantId(config.values); if (!tenantId) return null; const tokenResponse = await getGuestToken({ @@ -661,7 +657,7 @@ export class SwaggerWebviewManager implements vscode.Disposable { config: ResolvedB2CConfig, shortCode: string, ): Promise<{clientId: string; siteId?: string; redirectUri?: string} | null> { - const tenantId = deriveTenantId(config.values.hostname); + const tenantId = resolveApiBrowserTenantId(config.values); if (!tenantId) return null; try { diff --git a/packages/b2c-vs-extension/src/api-browser/tenant.ts b/packages/b2c-vs-extension/src/api-browser/tenant.ts new file mode 100644 index 000000000..bc35bce2d --- /dev/null +++ b/packages/b2c-vs-extension/src/api-browser/tenant.ts @@ -0,0 +1,24 @@ +/* + * 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 {normalizeTenantId} from '@salesforce/b2c-tooling-sdk/clients'; + +export interface ApiBrowserTenantValues { + hostname?: unknown; + tenantId?: unknown; +} + +/** + * Resolves the API Browser tenant, preferring explicit configuration and only + * deriving it from the instance hostname when `tenant-id` is absent. + */ +export function resolveApiBrowserTenantId(values: ApiBrowserTenantValues): string { + const configured = typeof values.tenantId === 'string' ? values.tenantId.trim() : ''; + if (configured) return normalizeTenantId(configured); + + const hostname = typeof values.hostname === 'string' ? values.hostname.trim() : ''; + return hostname ? normalizeTenantId(hostname) : ''; +} diff --git a/packages/b2c-vs-extension/src/export-tree/export-tree-provider.ts b/packages/b2c-vs-extension/src/export-tree/export-tree-provider.ts index 1a089f029..1937eed65 100644 --- a/packages/b2c-vs-extension/src/export-tree/export-tree-provider.ts +++ b/packages/b2c-vs-extension/src/export-tree/export-tree-provider.ts @@ -56,6 +56,7 @@ export class ExportTreeDataProvider implements vscode.TreeDataProvider 0) { - vscode.window.showWarningMessage(`B2C Export: ${units.warnings.join('; ')}`); + this.log.appendLine(`[Export] Partial discovery: ${units.warnings.join('; ')}`); } return units; } catch (err) { diff --git a/packages/b2c-vs-extension/src/export-tree/index.ts b/packages/b2c-vs-extension/src/export-tree/index.ts index 9d7a756f9..cfee6b444 100644 --- a/packages/b2c-vs-extension/src/export-tree/index.ts +++ b/packages/b2c-vs-extension/src/export-tree/index.ts @@ -10,9 +10,13 @@ import {registerExportCommands} from './export-commands.js'; import {ExportSelection, type SimpleCategory} from './export-selection.js'; import {ExportTreeDataProvider, type ExportTreeItem} from './export-tree-provider.js'; -export function registerExportTree(context: vscode.ExtensionContext, configProvider: B2CExtensionConfig): void { +export function registerExportTree( + context: vscode.ExtensionContext, + configProvider: B2CExtensionConfig, + log: vscode.OutputChannel, +): void { const selection = new ExportSelection(); - const treeProvider = new ExportTreeDataProvider(configProvider, selection); + const treeProvider = new ExportTreeDataProvider(configProvider, selection, log); const treeView = vscode.window.createTreeView('b2cExportExplorer', { treeDataProvider: treeProvider, diff --git a/packages/b2c-vs-extension/src/extension.ts b/packages/b2c-vs-extension/src/extension.ts index f87ca7183..fe9d0130b 100644 --- a/packages/b2c-vs-extension/src/extension.ts +++ b/packages/b2c-vs-extension/src/extension.ts @@ -788,7 +788,7 @@ async function activateInner(context: vscode.ExtensionContext, log: vscode.Outpu }); } if (settings.get('features.exportExplorer', false)) { - registerExportTree(context, configProvider); + registerExportTree(context, configProvider, log); } if (settings.get('features.cap', true)) { runActivationStep(log, 'CAP registration', () => { diff --git a/packages/b2c-vs-extension/src/jobs/index.ts b/packages/b2c-vs-extension/src/jobs/index.ts index aa7f67f71..57f59ee78 100644 --- a/packages/b2c-vs-extension/src/jobs/index.ts +++ b/packages/b2c-vs-extension/src/jobs/index.ts @@ -20,7 +20,7 @@ const AUTO_REFRESH_CONTEXT_KEY = 'b2c-dx.jobs.autoRefreshEnabled'; * the Cartridges right-click menu, and the heavy React webview was removed. * * Loading model: the view starts empty and waits for an explicit Refresh — - * fetching job history hits OCAPI and can be slow on instances with thousands + * fetching job history hits the configured jobs API and can be slow on instances with thousands * of executions, so we don't pay that cost for users who only opened the side * panel to see Cartridges. Auto-Refresh remains a separate opt-in toggle for * users who want continuous polling once they've loaded the view. @@ -64,7 +64,7 @@ export function registerJobs(context: vscode.ExtensionContext, configProvider: B // Loading is manual by default — the user must click Refresh (or enable // Auto-Refresh) to populate the view. This trades one extra click for a - // guarantee that opening the side panel never blocks on OCAPI. + // guarantee that opening the side panel never blocks on a jobs API request. // // Auto-refresh setting: if the user has explicitly opted into continuous // polling, treat that as the explicit load signal too — start polling diff --git a/packages/b2c-vs-extension/src/jobs/jobs-commands.ts b/packages/b2c-vs-extension/src/jobs/jobs-commands.ts index 60f1d131d..43cb5b7c4 100644 --- a/packages/b2c-vs-extension/src/jobs/jobs-commands.ts +++ b/packages/b2c-vs-extension/src/jobs/jobs-commands.ts @@ -14,7 +14,6 @@ import { JobExecutionError, } from '@salesforce/b2c-tooling-sdk/operations/jobs'; import {createJobsCompatibilityBackend} from '@salesforce/b2c-tooling-sdk'; -import {getApiErrorMessage} from '@salesforce/b2c-tooling-sdk'; import {createScaffoldRegistry, generateFromScaffold} from '@salesforce/b2c-tooling-sdk/scaffold'; import {findCartridgesSafe} from '../workspace-discovery.js'; import type {B2CInstance} from '@salesforce/b2c-tooling-sdk'; @@ -775,11 +774,6 @@ async function normalizeStepTypesJson( await fs.writeFile(stepTypesPath, `${JSON.stringify({...raw, 'step-types': merged}, null, 2)}\n`, 'utf-8'); } -function isActiveExecutionStatus(status: string | undefined): boolean { - const normalized = (status ?? '').toLowerCase(); - return normalized === 'running' || normalized === 'pending'; -} - function getConfiguredKnownJobIds(): string[] { const configured = vscode.workspace.getConfiguration('b2c-dx').get('jobs.knownJobIds', []); if (!Array.isArray(configured)) return []; @@ -962,7 +956,7 @@ function getLogUnavailableMessage(error: unknown): string | undefined { const lowered = message.toLowerCase(); if (lowered.includes('no log file path available')) { - return 'This execution does not expose a log file path in OCAPI.'; + return 'This execution does not expose a log file path.'; } if (lowered.includes('log file does not exist')) { @@ -1510,63 +1504,6 @@ export function registerJobsCommands( await runJobAndTail(node.jobId, reusedParameters); }); - const stopExecution = registerSafeCommand('b2c-dx.jobs.stop', async (node: JobExecutionTreeItem) => { - if (!node) return; - - if (!isActiveExecutionStatus(node.execution.execution_status)) { - void vscode.window.showWarningMessage( - `Execution ${node.execution.id ?? 'unknown'} is not running. Only running/pending executions can be stopped.`, - ); - return; - } - - const executionId = node.execution.id; - if (!executionId) { - void vscode.window.showErrorMessage(`Cannot stop ${node.jobId}: missing execution ID.`); - return; - } - - const instance = configProvider.getInstance(); - if (!instance) { - void vscode.window.showErrorMessage('B2C DX: No B2C Commerce instance configured. Configure dw.json first.'); - return; - } - if (instance.apiBackend === 'scapi') { - void vscode.window.showErrorMessage( - 'Stopping a running job is not supported by the current SCAPI Jobs API. Set apiBackend to ocapi or auto to use the temporary OCAPI compatibility operation.', - ); - return; - } - - // VS Code auto-adds a Cancel button to modal dialogs — passing an explicit - // one produces two Cancel-like actions. Keep only the affirmative. - const choice = await vscode.window.showWarningMessage( - `Stop execution ${executionId} for job ${node.jobId}?`, - {modal: true}, - 'Stop', - ); - if (choice !== 'Stop') return; - - try { - await vscode.window.withProgress( - {location: vscode.ProgressLocation.Notification, title: `Stopping execution ${executionId}...`}, - async () => { - const {error, response} = await instance.ocapi.DELETE('/jobs/{job_id}/executions/{id}', { - params: {path: {job_id: node.jobId, id: executionId}}, - }); - if (error) { - throw new Error(getApiErrorMessage(error, response)); - } - }, - ); - - void vscode.window.showInformationMessage(`Stop request sent for ${node.jobId} (${executionId}).`); - treeProvider.refresh(); - } catch (error) { - showScopeAwareError(`Failed to stop execution ${executionId}`, error); - } - }); - const viewExecutionDetails = registerSafeCommand( 'b2c-dx.jobs.viewExecutionDetails', async (node: JobExecutionTreeItem) => { @@ -1730,7 +1667,6 @@ export function registerJobsCommands( createScaffold, openBmDefinitions, rerunExecution, - stopExecution, viewExecutionDetails, openExecutionInBusinessManager, openExecutionLog, diff --git a/packages/b2c-vs-extension/src/jobs/jobs-tree-provider.ts b/packages/b2c-vs-extension/src/jobs/jobs-tree-provider.ts index 127e08aba..75987d0a6 100644 --- a/packages/b2c-vs-extension/src/jobs/jobs-tree-provider.ts +++ b/packages/b2c-vs-extension/src/jobs/jobs-tree-provider.ts @@ -197,7 +197,7 @@ function formatJobsFetchError(error: unknown): string { lowered.includes('http 401') || lowered.includes('http 403') ) { - return 'Unable to fetch jobs due to missing OCAPI scopes or client permissions. Ensure API client access to /job_execution_search and /jobs/*/executions*.'; + return 'Unable to fetch jobs due to missing SCAPI/OCAPI scopes or client permissions. Grant sfcc.jobs (or sfcc.jobs.rw) for SCAPI, or the corresponding temporary OCAPI job resources.'; } if ( @@ -429,7 +429,7 @@ export class JobsLoadHintTreeItem extends vscode.TreeItem { this.iconPath = new vscode.ThemeIcon('cloud-download'); this.description = 'Click to fetch from the configured instance'; this.tooltip = new vscode.MarkdownString( - 'Job History is not loaded by default to avoid unwanted OCAPI traffic.\n\nClick to load, or enable **Auto-Refresh** in the title bar to load automatically and refresh on a schedule.', + 'Job History is not loaded by default to avoid unwanted API traffic.\n\nClick to load, or enable **Auto-Refresh** in the title bar to load automatically and refresh on a schedule.', ); this.command = { command: 'b2c-dx.jobs.refresh', diff --git a/packages/b2c-vs-extension/src/test/api-browser.test.ts b/packages/b2c-vs-extension/src/test/api-browser.test.ts index b99c3a6fe..546b84412 100644 --- a/packages/b2c-vs-extension/src/test/api-browser.test.ts +++ b/packages/b2c-vs-extension/src/test/api-browser.test.ts @@ -7,6 +7,7 @@ import * as assert from 'assert'; import type {SchemaEntry} from '../api-browser/api-browser-tree-provider.js'; import {detectApiType, injectCustomApiOrgPathPrefix} from '../api-browser/swagger-webview.js'; +import {resolveApiBrowserTenantId} from '../api-browser/tenant.js'; function entry(apiFamily: string, apiName: string): SchemaEntry { return {apiFamily, apiName, apiVersion: 'v1'}; @@ -131,3 +132,20 @@ suite('injectCustomApiOrgPathPrefix', () => { assert.strictEqual(spec.paths, undefined); }); }); + +suite('resolveApiBrowserTenantId', () => { + test('prefers and normalizes the configured tenant ID', () => { + assert.strictEqual( + resolveApiBrowserTenantId({tenantId: 'f_ecom_zzxy-prd', hostname: 'wrong-001.demandware.net'}), + 'zzxy_prd', + ); + }); + + test('derives the tenant ID from hostname only when configuration is absent', () => { + assert.strictEqual(resolveApiBrowserTenantId({hostname: 'zzpq-013.dx.commercecloud.salesforce.com'}), 'zzpq_013'); + }); + + test('returns an empty value when neither coordinate is available', () => { + assert.strictEqual(resolveApiBrowserTenantId({}), ''); + }); +}); diff --git a/packages/b2c-vs-extension/src/test/jobs-menu.test.ts b/packages/b2c-vs-extension/src/test/jobs-menu.test.ts index 27a82e3d3..5299bfafe 100644 --- a/packages/b2c-vs-extension/src/test/jobs-menu.test.ts +++ b/packages/b2c-vs-extension/src/test/jobs-menu.test.ts @@ -194,7 +194,6 @@ suite('jobs menu contributions (package.json)', () => { 'b2c-dx.jobs.run', 'b2c-dx.jobs.createScaffold', 'b2c-dx.jobs.rerun', - 'b2c-dx.jobs.stop', 'b2c-dx.jobs.viewExecutionDetails', 'b2c-dx.jobs.openExecutionInBM', 'b2c-dx.jobs.openExecutionLog', @@ -235,7 +234,6 @@ suite('jobs menu contributions (package.json)', () => { ); for (const command of [ 'b2c-dx.jobs.rerun', - 'b2c-dx.jobs.stop', 'b2c-dx.jobs.viewExecutionDetails', 'b2c-dx.jobs.openExecutionLog', 'b2c-dx.jobs.openFailureLog', @@ -366,10 +364,6 @@ suite('jobs menu contributions (package.json)', () => { 'createScaffold should not be in the Job History context menu', ); - assert.ok(anyEntryMatches('b2c-dx.jobs.stop', 'jobExecution-running')); - assert.ok(!anyEntryMatches('b2c-dx.jobs.stop', 'jobExecution-completed')); - assert.ok(!anyEntryMatches('b2c-dx.jobs.stop', 'jobExecution-failed')); - assert.ok(anyEntryMatches('b2c-dx.jobs.openFailureLog', 'jobExecution-failed')); assert.ok(!anyEntryMatches('b2c-dx.jobs.openFailureLog', 'jobExecution-running')); }); diff --git a/packages/b2c-vs-extension/src/walkthrough/onboardingPanel.ts b/packages/b2c-vs-extension/src/walkthrough/onboardingPanel.ts index 231e0f52c..fd3fab62f 100644 --- a/packages/b2c-vs-extension/src/walkthrough/onboardingPanel.ts +++ b/packages/b2c-vs-extension/src/walkthrough/onboardingPanel.ts @@ -509,7 +509,7 @@ export class OnboardingPanel { /** * Fetch the cartridges currently deployed to the active code version. - * Tries OCAPI `/code_versions` first (richer data) and falls back to a + * Tries the configured SCAPI-first code-version backend and falls back to a * WebDAV PROPFIND on `Cartridges//` (which is what the deploy * command itself uses, so credentials are usually already set up). * diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 53edf2aab..2198f4322 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -627,15 +627,15 @@ importers: '@salesforce/b2c-tooling-sdk': specifier: workspace:* version: link:../b2c-tooling-sdk - swagger-ui-dist: - specifier: ^5.18.0 - version: 5.32.0 react: specifier: 18.3.1 version: 18.3.1 react-dom: specifier: 18.3.1 version: 18.3.1(react@18.3.1) + swagger-ui-dist: + specifier: ^5.18.0 + version: 5.32.0 vscode-html-languageservice: specifier: 'catalog:' version: 5.6.0 @@ -659,11 +659,11 @@ importers: specifier: ^1.105.1 version: 1.109.0 '@vscode/test-cli': - specifier: ^0.0.12 - version: 0.0.12 + specifier: ^0.0.15 + version: 0.0.15 '@vscode/test-electron': - specifier: ^2.5.2 - version: 2.5.2 + specifier: ^3.1.0 + version: 3.1.0 '@vscode/vsce': specifier: ^3.9.1 version: 3.9.1 @@ -2678,6 +2678,7 @@ packages: '@modelcontextprotocol/inspector@0.18.0': resolution: {integrity: sha512-aBrBDaI8MtvyS9j3TMRgTHZaOwbe/zh2rbIVplIBtxWifaSfvQX9DbnoI3xv9sZjgeFyF/3CwZdfEVTUx2RfBg==} engines: {node: '>=22.7.5'} + deprecated: 'v1 is deprecated. Upgrade to v2: npm i @modelcontextprotocol/inspector@latest. v1 gets security fixes only, published under the v1-latest tag.' hasBin: true '@modelcontextprotocol/sdk@1.26.0': @@ -4320,14 +4321,14 @@ packages: '@vscode/l10n@0.0.18': resolution: {integrity: sha512-KYSIHVmslkaCDyw013pphY+d7x1qV8IZupYfeIfzNA+nsaWHbn5uPuQRvdRFsa9zFzGeudPuoGoZ1Op4jrJXIQ==} - '@vscode/test-cli@0.0.12': - resolution: {integrity: sha512-iYN0fDg29+a2Xelle/Y56Xvv7Nc8Thzq4VwpzAF/SIE6918rDicqfsQxV6w1ttr2+SOm+10laGuY9FG2ptEKsQ==} - engines: {node: '>=18'} + '@vscode/test-cli@0.0.15': + resolution: {integrity: sha512-nAxk2X79wuXS7aOhyFFhFcCqd7EBUoMesu7ZgsYE/4eFjyBMuyIweVE94BxdKH1RieN8eOz2SIrljrZt6Lk9fQ==} + engines: {node: '>=22'} hasBin: true - '@vscode/test-electron@2.5.2': - resolution: {integrity: sha512-8ukpxv4wYe0iWMRQU18jhzJOHkeGKbnw7xWRX3Zw1WJA4cEKbHcmmLPdPrPtL6rhDcrlCZN+xKRpv09n4gRHYg==} - engines: {node: '>=16'} + '@vscode/test-electron@3.1.0': + resolution: {integrity: sha512-CRqv5u+YYoseuNVJ6Tyo4k0sF0mx4qnKMihRB0PjsUF8Dc0WKtCXo6CNL6nWWm5esfFQsQA/pejMj4ZbpJVLTw==} + engines: {node: '>=22'} '@vscode/vsce-sign-alpine-arm64@2.0.6': resolution: {integrity: sha512-wKkJBsvKF+f0GfsUuGT0tSW0kZL87QggEiqNqK6/8hvqsXvpx8OsTEc3mnE1kejkh5r+qUyQ7PtF8jZYN0mo8Q==} @@ -4555,10 +4556,6 @@ packages: any-promise@1.3.0: resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} - anymatch@3.1.3: - resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} - engines: {node: '>= 8'} - are-docs-informative@0.0.2: resolution: {integrity: sha512-ixiS0nLNNG5jNQzgZJNoUpBKdo9yTYZMGJ+QgT2jmjR7G7+QHRCc4v6LQ3NgE7EBJq+o0ams3waJwkrlBom8Ig==} engines: {node: '>=14'} @@ -4712,10 +4709,6 @@ packages: resolution: {integrity: sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g==} engines: {node: '>=4'} - binary-extensions@2.3.0: - resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} - engines: {node: '>=8'} - binaryextensions@6.11.0: resolution: {integrity: sha512-sXnYK/Ij80TO3lcqZVV2YgfKN5QjUWIRk/XSm2J/4bd/lPko3lvk0O4ZppH6m+6hB2/GTu+ptNwVFe1xh+QLQw==} engines: {node: '>=4'} @@ -4753,6 +4746,10 @@ packages: resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} engines: {node: 18 || 20 || >=22} + brace-expansion@5.0.9: + resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==} + engines: {node: 20 || >=22} + braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} @@ -4793,16 +4790,6 @@ packages: resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} engines: {node: '>= 0.8'} - c8@10.1.3: - resolution: {integrity: sha512-LvcyrOAaOnrrlMpW22n690PUvxiq4Uf9WMhQwNJ9vgagkL/ph1+D4uvjvDA5XCbykrc0sx+ay6pVi9YZ1GnhyA==} - engines: {node: '>=18'} - hasBin: true - peerDependencies: - monocart-coverage-reports: ^2 - peerDependenciesMeta: - monocart-coverage-reports: - optional: true - c8@11.0.0: resolution: {integrity: sha512-e/uRViGHSVIJv7zsaDKM7VRn2390TgHXqUSvYwPHBQaU6L7E9L0n9JbdkwdYPvshDT0KymBmmlwSpms3yBaMNg==} engines: {node: 20 || >=22} @@ -4894,10 +4881,6 @@ packages: resolution: {integrity: sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==} engines: {node: '>=20.18.1'} - chokidar@3.6.0: - resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} - engines: {node: '>= 8.10.0'} - chokidar@4.0.3: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} @@ -5349,6 +5332,10 @@ packages: resolution: {integrity: sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==} engines: {node: '>=10.13.0'} + enhanced-resolve@5.24.5: + resolution: {integrity: sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==} + engines: {node: '>=10.13.0'} + enquirer@2.4.1: resolution: {integrity: sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==} engines: {node: '>=8.6'} @@ -5915,6 +5902,10 @@ packages: resolution: {integrity: sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==} engines: {node: '>=18'} + get-east-asian-width@1.6.0: + resolution: {integrity: sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==} + engines: {node: '>=18'} + get-func-name@2.0.2: resolution: {integrity: sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==} @@ -6256,10 +6247,6 @@ packages: resolution: {integrity: sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==} engines: {node: '>= 0.4'} - is-binary-path@2.1.0: - resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} - engines: {node: '>=8'} - is-boolean-object@1.2.2: resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==} engines: {node: '>= 0.4'} @@ -6838,6 +6825,10 @@ packages: resolution: {integrity: sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==} engines: {node: 18 || 20 || >=22} + minimatch@10.2.6: + resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} + engines: {node: 18 || 20 || >=22} + minimatch@3.1.5: resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} @@ -6877,6 +6868,11 @@ packages: engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} hasBin: true + mocha@11.8.0: + resolution: {integrity: sha512-VyCeUdGN3A9lmCTTgG4yuvY9ixxaDk+xt2R/7/+1AP6EqNG+G9OKkzBwhVtVYoNX8YsxNSgAl8mOv3IAeOpFbw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + hasBin: true + mri@1.2.0: resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} engines: {node: '>=4'} @@ -6988,10 +6984,6 @@ packages: resolution: {integrity: sha512-V6gygoYb/5EmNI+MEGrWkC+e6+Rr7mTmfHrxDbLzxQogBkgzo76rkok0Am6thgSF7Mv2nLOajAJj5vDJZEFn7g==} engines: {node: ^16.14.0 || >=18.0.0} - normalize-path@3.0.0: - resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} - engines: {node: '>=0.10.0'} - normalize-url@8.1.0: resolution: {integrity: sha512-X06Mfd/5aKsRHc0O0J5CUedwnPmnDtLF2+nq+KN9KSDlJHkPuh0JUviWjEWMe0SW/9TDdSLVPuk7L5gGTIA1/w==} engines: {node: '>=14.16'} @@ -7609,10 +7601,6 @@ packages: resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} engines: {node: '>= 6'} - readdirp@3.6.0: - resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} - engines: {node: '>=8.10.0'} - readdirp@4.1.2: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} @@ -7990,6 +7978,10 @@ packages: resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} engines: {node: '>=18'} + string-width@8.2.2: + resolution: {integrity: sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==} + engines: {node: '>=20'} + string.prototype.trim@1.2.10: resolution: {integrity: sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==} engines: {node: '>= 0.4'} @@ -8102,6 +8094,10 @@ packages: resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} engines: {node: '>=6'} + tapable@2.3.3: + resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} + engines: {node: '>=6'} + tar-fs@2.1.4: resolution: {integrity: sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==} @@ -8126,10 +8122,6 @@ packages: resolution: {integrity: sha512-lk+vH+MccxNqgVqSnkMVKx4VLJfnLjDBGzH16JVZjKE2DoxP57s6/vt6JmXV5I3jBcfGrxNrYtC+mPtU7WJztA==} engines: {node: '>=18'} - test-exclude@7.0.1: - resolution: {integrity: sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==} - engines: {node: '>=18'} - test-exclude@8.0.0: resolution: {integrity: sha512-ZOffsNrXYggvU1mDGHk54I96r26P8SyMjO5slMKSc7+IWmtB/MQKnEC2fP51imB3/pT6YK5cT5E8f+Dd9KdyOQ==} engines: {node: 20 || >=22} @@ -8725,6 +8717,10 @@ packages: resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} engines: {node: '>=12'} + yargs-parser@22.0.0: + resolution: {integrity: sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=23} + yargs-unparser@2.0.0: resolution: {integrity: sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA==} engines: {node: '>=10'} @@ -8737,6 +8733,10 @@ packages: resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} engines: {node: '>=12'} + yargs@18.1.0: + resolution: {integrity: sha512-2rAgRKu54VsHkqI0/tYkmluGXHD4KW7yZoycuqDQ15QOTnc2VVfy0nN/1eMhnQLO00A+dwtK20xuCnc1YGeUyg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=23} + yarn@1.22.22: resolution: {integrity: sha512-prL3kGtyG7o9Z9Sv8IPfBNrWTDmXB4Qbes8A9rEzt6wkJV8mUvoirjU0Mp3GGAU06Y0XQyA3/2/RQFVuK7MTfg==} engines: {node: '>=4.0.0'} @@ -13213,21 +13213,21 @@ snapshots: '@vscode/l10n@0.0.18': {} - '@vscode/test-cli@0.0.12': + '@vscode/test-cli@0.0.15': dependencies: '@types/mocha': 10.0.10 - c8: 10.1.3 - chokidar: 3.6.0 - enhanced-resolve: 5.18.3 - glob: 10.5.0 - minimatch: 9.0.9 - mocha: 11.7.5 + c8: 11.0.0 + chokidar: 5.0.0 + enhanced-resolve: 5.24.5 + glob: 13.0.6 + minimatch: 10.2.6 + mocha: 11.8.0 supports-color: 10.2.2 - yargs: 17.7.2(patch_hash=93c6b35288ee71f8125ecb75d3f2a609bfaf8917db71d23b4e0034f39a0d9961) + yargs: 18.1.0 transitivePeerDependencies: - monocart-coverage-reports - '@vscode/test-electron@2.5.2': + '@vscode/test-electron@3.1.0': dependencies: http-proxy-agent: 7.0.2 https-proxy-agent: 7.0.6(supports-color@10.2.2) @@ -13468,11 +13468,6 @@ snapshots: any-promise@1.3.0: {} - anymatch@3.1.3: - dependencies: - normalize-path: 3.0.0 - picomatch: 2.3.2 - are-docs-informative@0.0.2: {} arg@4.1.3: {} @@ -13631,8 +13626,6 @@ snapshots: dependencies: is-windows: 1.0.2 - binary-extensions@2.3.0: {} - binaryextensions@6.11.0: dependencies: editions: 6.22.0 @@ -13696,6 +13689,10 @@ snapshots: dependencies: balanced-match: 4.0.4 + brace-expansion@5.0.9: + dependencies: + balanced-match: 4.0.4 + braces@3.0.3: dependencies: fill-range: 7.1.1 @@ -13734,20 +13731,6 @@ snapshots: bytes@3.1.2: {} - c8@10.1.3: - dependencies: - '@bcoe/v8-coverage': 1.0.2 - '@istanbuljs/schema': 0.1.3 - find-up: 5.0.0 - foreground-child: 3.3.1 - istanbul-lib-coverage: 3.2.2 - istanbul-lib-report: 3.0.1 - istanbul-reports: 3.2.0 - test-exclude: 7.0.1 - v8-to-istanbul: 9.3.0 - yargs: 17.7.2(patch_hash=93c6b35288ee71f8125ecb75d3f2a609bfaf8917db71d23b4e0034f39a0d9961) - yargs-parser: 21.1.1 - c8@11.0.0: dependencies: '@bcoe/v8-coverage': 1.0.2 @@ -13879,18 +13862,6 @@ snapshots: undici: 7.28.0 whatwg-mimetype: 4.0.0 - chokidar@3.6.0: - dependencies: - anymatch: 3.1.3 - braces: 3.0.3 - glob-parent: 5.1.2 - is-binary-path: 2.1.0 - is-glob: 4.0.3 - normalize-path: 3.0.0 - readdirp: 3.6.0 - optionalDependencies: - fsevents: 2.3.3 - chokidar@4.0.3: dependencies: readdirp: 4.1.2 @@ -14302,6 +14273,11 @@ snapshots: graceful-fs: 4.2.11 tapable: 2.3.0 + enhanced-resolve@5.24.5: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.3 + enquirer@2.4.1: dependencies: ansi-colors: 4.1.3 @@ -15188,6 +15164,8 @@ snapshots: get-east-asian-width@1.4.0: {} + get-east-asian-width@1.6.0: {} + get-func-name@2.0.2: {} get-intrinsic@1.3.0: @@ -15574,10 +15552,6 @@ snapshots: dependencies: has-bigints: 1.1.0 - is-binary-path@2.1.0: - dependencies: - binary-extensions: 2.3.0 - is-boolean-object@1.2.2: dependencies: call-bound: 1.0.4 @@ -16095,6 +16069,10 @@ snapshots: dependencies: brace-expansion: 5.0.6 + minimatch@10.2.6: + dependencies: + brace-expansion: 5.0.9 + minimatch@3.1.5: dependencies: brace-expansion: 1.1.15 @@ -16153,6 +16131,30 @@ snapshots: yargs-parser: 21.1.1 yargs-unparser: 2.0.0 + mocha@11.8.0: + dependencies: + browser-stdout: 1.3.1 + chokidar: 4.0.3 + debug: 4.4.3(supports-color@8.1.1) + diff: 7.0.0 + escape-string-regexp: 4.0.0 + find-up: 5.0.0 + glob: 10.5.0 + he: 1.2.0 + is-path-inside: 3.0.3 + js-yaml: 4.2.0 + log-symbols: 4.1.0 + minimatch: 9.0.9 + ms: 2.1.3 + picocolors: 1.1.1 + serialize-javascript: 7.0.6 + strip-json-comments: 3.1.1 + supports-color: 8.1.1 + workerpool: 9.3.4 + yargs: 17.7.2(patch_hash=93c6b35288ee71f8125ecb75d3f2a609bfaf8917db71d23b4e0034f39a0d9961) + yargs-parser: 21.1.1 + yargs-unparser: 2.0.0 + mri@1.2.0: {} ms@2.0.0: {} @@ -16270,8 +16272,6 @@ snapshots: semver: 7.7.3 validate-npm-package-license: 3.0.4 - normalize-path@3.0.0: {} - normalize-url@8.1.0: {} npm-package-arg@11.0.3: @@ -16893,10 +16893,6 @@ snapshots: string_decoder: 1.3.0 util-deprecate: 1.0.2 - readdirp@3.6.0: - dependencies: - picomatch: 2.3.2 - readdirp@4.1.2: {} readdirp@5.0.0: {} @@ -17392,6 +17388,11 @@ snapshots: get-east-asian-width: 1.4.0 strip-ansi: 7.1.2 + string-width@8.2.2: + dependencies: + get-east-asian-width: 1.6.0 + strip-ansi: 7.1.2 + string.prototype.trim@1.2.10: dependencies: call-bind: 1.0.8 @@ -17519,6 +17520,8 @@ snapshots: tapable@2.3.0: {} + tapable@2.3.3: {} + tar-fs@2.1.4: dependencies: chownr: 1.1.4 @@ -17572,12 +17575,6 @@ snapshots: ansi-escapes: 7.2.0 supports-hyperlinks: 3.2.0 - test-exclude@7.0.1: - dependencies: - '@istanbuljs/schema': 0.1.3 - glob: 10.5.0 - minimatch: 9.0.9 - test-exclude@8.0.0: dependencies: '@istanbuljs/schema': 0.1.3 @@ -18206,6 +18203,8 @@ snapshots: yargs-parser@21.1.1: {} + yargs-parser@22.0.0: {} + yargs-unparser@2.0.0: dependencies: camelcase: 6.3.0 @@ -18233,6 +18232,15 @@ snapshots: y18n: 5.0.8 yargs-parser: 21.1.1 + yargs@18.1.0: + dependencies: + cliui: 9.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + string-width: 8.2.2 + y18n: 5.0.8 + yargs-parser: 22.0.0 + yarn@1.22.22: {} yauzl@3.3.0: diff --git a/skills/b2c-cli/skills/b2c-bm-users-roles/SKILL.md b/skills/b2c-cli/skills/b2c-bm-users-roles/SKILL.md index 45fcfb6ed..03e0e6a5e 100644 --- a/skills/b2c-cli/skills/b2c-bm-users-roles/SKILL.md +++ b/skills/b2c-cli/skills/b2c-bm-users-roles/SKILL.md @@ -15,7 +15,7 @@ For **Account Manager** user/role/client management (cross-instance, scoped to t `bm users` (list, get, portable search, update, delete) and `bm roles` (all subcommands including permissions) run over the SCAPI Merchant Users / Merchant Roles APIs. Configure `shortCode`, `tenantId`, and the `sfcc.users(.rw)` / `sfcc.roles(.rw)` scopes to use SCAPI. Search is implemented by filtering the paginated SCAPI user listing. -OCAPI-only operations (no SCAPI equivalent, unavailable on OCAPI-disabled instances): raw `bm users search --query` JSON, `bm whoami`, and `bm access-key *`. +OCAPI-only operations as of B2C Commerce release 26.8 (no current live SCAPI equivalent, unavailable on OCAPI-disabled instances): raw `bm users search --query` JSON, `bm whoami`, and `bm access-key *`. `auto` uses the temporary OCAPI compatibility path. Explicit SCAPI mode fails before contacting OCAPI and directs the user to `--api-backend ocapi` until support becomes available. OCAPI is deprecated and disabled on newer instances. `--api-backend auto` (the default) falls back on safe SCAPI capability/auth/request rejections; force a backend with `--api-backend scapi|ocapi` if needed. SCAPI updates `disabled` by reading the current user and preserving its writable fields through PUT because PATCH omits that field. @@ -23,7 +23,7 @@ OCAPI is deprecated and disabled on newer instances. `--api-backend auto` (the d The CLI auto-discovers the target instance and credentials from `SFCC_*` environment variables, `dw.json` in the current or parent directories, `~/.mobify`, `package.json`, and configuration plugins. **Flags like `--server`, `--client-id`, and `--client-secret` are usually unnecessary** — only pass them to override what's auto-detected. Run `b2c setup inspect` to see the resolved configuration and which source provided each value. For precedence and troubleshooting, see the `b2c-cli:b2c-config` skill. -SCAPI currently requires client credentials or JWT Bearer; it does not support browser-based user auth. User auth continues to work through OCAPI and WebDAV, and `auto` selects OCAPI for that flow. A handful of OCAPI endpoints require a _real BM user identity_ and default to user auth. +As of release 26.8, SCAPI Admin APIs require client credentials or JWT Bearer and do not support browser-based user auth. User auth continues to work through OCAPI and WebDAV, and `auto` selects OCAPI for that flow. Explicit SCAPI fails with actionable guidance. A handful of OCAPI endpoints require a _real BM user identity_ and default to user auth. | Command group | Default auth | Why | | ---------------------------------------------- | ----------------------------------- | -------------------------------------------------------------------------------------------- | diff --git a/skills/b2c-cli/skills/b2c-cap/SKILL.md b/skills/b2c-cli/skills/b2c-cap/SKILL.md index 4ff81e343..870f21d8c 100644 --- a/skills/b2c-cli/skills/b2c-cap/SKILL.md +++ b/skills/b2c-cli/skills/b2c-cap/SKILL.md @@ -15,7 +15,7 @@ The CLI auto-discovers the target instance and credentials from `SFCC_*` environ Run `b2c setup inspect` to see the resolved configuration and which source provided each value (use `--json` for scripting, `--unmask` to reveal secrets). For precedence rules and troubleshooting, see the `b2c-cli:b2c-config` skill. -The remote commands (`cap install`, `cap uninstall`, `cap tasks`, `cap pull`, and `cap list` without `--local`) require **both** OCAPI access (for running the system job) and **WebDAV** access (for uploading/downloading archives). WebDAV authenticates via `SFCC_USERNAME`/`SFCC_PASSWORD` (BM username + WebDAV access key), `--user-auth` for interactive browser login, or an Account Manager OAuth client granted WebDAV permissions on the `/impex` path; SCAPI is not a substitute for the WebDAV upload. The local-only commands (`cap validate`, `cap package`, and `cap list --local`) need no credentials. +The remote commands (`cap install`, `cap uninstall`, `cap tasks`, `cap pull`, and `cap list` without `--local`) require OAuth for SCAPI-first system-job execution and **WebDAV** access for uploading/downloading archives. In `auto` mode, job execution temporarily falls back to OCAPI when SCAPI definitively rejects the start request. WebDAV authenticates via `SFCC_USERNAME`/`SFCC_PASSWORD` (BM username + WebDAV access key), `--user-auth` for interactive browser login, or an Account Manager OAuth client granted WebDAV permissions on the `/impex` path; SCAPI is not a substitute for the WebDAV upload. The local-only commands (`cap validate`, `cap package`, and `cap list --local`) need no credentials. > **Authoring vs. operating:** This skill covers **operating** on existing CAPs against an instance (validate, package, install, uninstall, list, tasks, pull). To **author** a new CAP — scaffold the structure, generate IMPEX, run registry-grade validation, or submit to the registry — install the `cap-dev` skills with `b2c setup skills cap-dev`, or via the commerce-apps marketplace: `claude plugin marketplace add SalesforceCommerceCloud/commerce-apps` then `claude plugin install cap-dev`. diff --git a/skills/b2c-cli/skills/b2c-config/SKILL.md b/skills/b2c-cli/skills/b2c-config/SKILL.md index d760c9711..2b7a334fa 100644 --- a/skills/b2c-cli/skills/b2c-config/SKILL.md +++ b/skills/b2c-cli/skills/b2c-config/SKILL.md @@ -53,7 +53,7 @@ Most commands that interact with a B2C Commerce instance require authentication. ### `--user-auth` Flag -Many commands support `--user-auth` to use browser-based OAuth instead of client credentials. SCAPI Admin APIs do not currently support this flow; migrated commands use OCAPI in `auto` mode, while explicit SCAPI reports an authentication error. User auth remains useful when: +Many commands support `--user-auth` to use browser-based OAuth instead of client credentials. As of B2C Commerce release 26.8, SCAPI Admin APIs do not support this flow; migrated commands use OCAPI in `auto` mode, while explicit SCAPI reports an actionable authentication error before making an API request. User auth remains useful when: - You don't have a `clientSecret` configured - You need user-level permissions (e.g., Account Manager admin roles) diff --git a/skills/b2c-cli/skills/b2c-job/SKILL.md b/skills/b2c-cli/skills/b2c-job/SKILL.md index c1a9d0847..58f2263c6 100644 --- a/skills/b2c-cli/skills/b2c-job/SKILL.md +++ b/skills/b2c-cli/skills/b2c-job/SKILL.md @@ -238,7 +238,7 @@ Job commands run over SCAPI. Configure `shortCode`, `tenantId`, and the SCAPI sc OCAPI is deprecated and disabled on newer instances. `--api-backend auto` (the default) falls back to the OCAPI Data API on safe SCAPI capability/auth/request rejections; force a backend with `--api-backend scapi|ocapi`, dw.json `"api-backend": "scapi"`, or `SFCC_API_BACKEND=scapi`. -Stopping a running execution has no equivalent operation in the current SCAPI Jobs schema. Use the temporary explicit OCAPI compatibility path for cancellation; SCAPI `DELETE` removes an execution record and is not used as a substitute. +SCAPI `DELETE` removes a completed execution record; it does not cancel a running job. The CLI does not expose job cancellation because the underlying job APIs do not provide that operation. > **Note:** `job import` and `job export` trigger the site-archive system jobs and transfer archive files over WebDAV. The job trigger honors `--api-backend`: in `auto` mode it runs over SCAPI (needs `sfcc.jobs.rw`) with OCAPI fallback if the SCAPI start is rejected. The archive transfer always uses WebDAV.