feat: SCAPI migration for jobs, code, bm, sites, site-archive, and CAP#413
Draft
clavery wants to merge 22 commits into
Draft
feat: SCAPI migration for jobs, code, bm, sites, site-archive, and CAP#413clavery wants to merge 22 commits into
clavery wants to merge 22 commits into
Conversation
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
# Conflicts: # packages/b2c-cli/src/commands/job/run.ts # packages/b2c-cli/src/commands/job/search.ts # packages/b2c-tooling-sdk/src/config/dw-json.ts # packages/b2c-tooling-sdk/src/config/mapping.ts
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<T> — generic fallback wrapper that tries SCAPI first and falls back to OCAPI on invalid_scope - ScopeTierManager<C> — 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.
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
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
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)
- 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
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<T>(). 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<T>() that takes constructors. Each domain backend.ts shrunk from ~50 lines to ~10. - createScapi*Client factories collapsed into buildScapiClient<P>() that takes a path segment, scope set, and middleware key. Each domain client.ts shrunk from ~60 lines to ~30. - InstanceCommand gained createBackend<T>() 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.
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.
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.
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.
# Conflicts: # packages/b2c-cli/test/commands/job/run.test.ts # packages/b2c-tooling-sdk/package.json
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.
… Code scripts backend
# Conflicts: # packages/b2c-tooling-sdk/src/cli/job-command.ts # packages/b2c-tooling-sdk/src/operations/jobs/index.ts # packages/b2c-vs-extension/src/code-sync/deploy-command.ts # packages/b2c-vs-extension/src/code-sync/index.ts
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.
… 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.
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
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.
…fallback
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.
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds SCAPI Admin API support across the
job,code,bm users,bm roles, andsitescommand families, plus site-archive import/export and CAP install/uninstall — each with automatic fallback to OCAPI for environments where SCAPI scopes aren't provisioned. SCAPI is now the primary path; OCAPI remains as a transitional, deprecated fallback.In
automode (default), the CLI prefers SCAPI whenshortCodeandtenantIdare configured and the auth flow can request scopes. Oninvalid_scopefrom Account Manager (or a rejected SCAPI request), it silently falls back to OCAPI. On instances where OCAPI itself is disabled, commands surface an actionable error naming the exact SCAPI scope required.Behavioral guarantee
No CLI-visible changes for any existing setup:
--api-backend ocapi: OCAPI runs unconditionally.--api-backend scapi: SCAPI runs; clear error if scopes/coordinates are missing or the auth flow can't request scopes.autowith full SCAPI scopes: SCAPI runs and stays.autowith no SCAPI scopes: cached OCAPI fallback. Output, JSON shape, and exit codes match prior behavior.What's covered by SCAPI (with OCAPI fallback)
job run/ execution search / wait / log, and system-job triggers forsite-import/site-exportandcap install/cap uninstall(archive transfer still uses WebDAV).codelist/activate/delete/reload (--reloadtoggles activation via whichever backend is selected) pluscode deploycode-version management and all VS Code extension code-version actions.bm usersandbm rolesCRUD (bm users update --disabledtransparently uses OCAPI in auto mode — SCAPI Users PATCH lacks the flag).sites listandsites cartridges listreads over the SCAPIsite/sitesAPI. Cartridge-path writes (add/remove/set) have no SCAPI equivalent and remain OCAPI / site-archive import.What's new
b2c job execution delete— SCAPI-only (no OCAPI equivalent).--api-backendflag on instance commands (auto|scapi|ocapi), also honored fromapiBackendin dw.json andSFCC_API_BACKEND(CLI and SDK consumers, e.g. the VS Code extension).sfcc.jobs(.rw),sfcc.scripts(.rw),sfcc.users(.rw),sfcc.roles(.rw),sfcc.sites(.rw).OcapiDeprecatedException(403) is caught and turned into an actionable message naming the SCAPI scope the operation needs.Architecture highlights
B2CInstanceis the single source of SCAPI client config. AB2CInstance.scapiClientConfiggetter returns{shortCode, tenantId, auth}(orundefinedwhen the instance can't reach SCAPI), andB2CInstance.apiBackendexposes the preference. The dual-backend factories now take just{instance}. This is the forward-looking seam for the OCAPI→SCAPI transition: SCAPI operations need nothing beyond a configured instance. Only stateless, scope-flexible OAuth (client-credentials or JWT Bearer) qualifies for SCAPI — implicit/stateful flows hold a fixed-scope token and use OCAPI (this holds for explicit--api-backend scapitoo, which fails with a clear error rather than an under-scoped token).AuthStrategy.getAccessTokenForCascade(candidates)+createScapiAuthMiddlewarepick the rw/ro tier per operation and cache the surviving token.runSystemJob— shared dual-backend runner for site-archive/CAP system jobs. Preserves the raw OCAPIJobExecutionresult/error contract across backends (viamapCanonicalToOcapiExecution), and only falls back to OCAPI when the SCAPI start provably created no job — an ambiguous failure (network drop, timeout, 5xx) propagates rather than risk re-running a mutating job.BackendDispatcher(src/compat/dispatcher.ts) — transitional CLI-side router; deleted once OCAPI is removed.Correctness fixes from review
An adversarial review of the migration surfaced five confirmed issues, all fixed with tests:
runSystemJobno longer re-runs a mutating system job over OCAPI after an ambiguous SCAPI failure.sites listnow pages through all sites (was capped at the first 25) with bounded-concurrency enrichment.SFCC_API_BACKENDwiring for SDK consumers.Test plan
pnpm run typecheck:agentclean across packagespnpm run lint:agentclean across packagespnpm --filter @salesforce/b2c-tooling-sdk run test:agent(1889 passing)pnpm --filter @salesforce/b2c-cli run test:agent(1354 passing)pnpm run docs:buildsucceedsb2c job run my-job --waitagainst instances with and without SCAPI scopes (verify silent OCAPI fallback)b2c site-import/b2c site-exportandb2c cap install/b2c cap uninstallover SCAPI (system-job trigger not yet live-verified end-to-end)b2c sites listandb2c sites cartridges listagainst a multi-site instance (>25 sites)b2c code list/activate --reload,b2c bm users list,b2c bm roles listagainst both kinds of instances--api-backend scapiand--api-backend ocapi; OCAPI-disabled instance surfaces the scope-named deprecation errorFollow-ups (not in this PR)
code,bm users,bm rolesfrom the legacyScopeTierManagerpattern onto the dispatcher + cascade pattern; then deleteScopeTierManager.debugtowarnto nudge SCAPI provisioning.