Skip to content

feat: SCAPI migration for jobs, code, and bm users/roles commands#413

Draft
clavery wants to merge 11 commits into
mainfrom
feature/scapi-migration
Draft

feat: SCAPI migration for jobs, code, and bm users/roles commands#413
clavery wants to merge 11 commits into
mainfrom
feature/scapi-migration

Conversation

@clavery
Copy link
Copy Markdown
Collaborator

@clavery clavery commented May 9, 2026

Summary

Adds SCAPI Admin API support to the job, code, bm users, and bm roles command families, with automatic fallback to OCAPI for unprovisioned scopes. SCAPI is now the primary path; OCAPI remains as a transitional fallback for environments without SCAPI scopes provisioned in Account Manager.

In auto mode (default), the CLI prefers SCAPI when shortCode and tenantId are configured. On invalid_scope from Account Manager, the dispatcher silently falls back to OCAPI and caches that choice for the rest of the operation — so polling commands like job run --wait don't re-probe AM on every iteration.

Behavioral guarantee

No CLI-visible changes for any existing setup:

  • OCAPI-only configurations (no shortCode/tenantId): identical behavior, dispatcher locks to OCAPI at construction.
  • --api-backend ocapi: identical behavior, OCAPI runs unconditionally.
  • --api-backend scapi: SCAPI runs; clear error if scopes are missing.
  • auto with full SCAPI scopes: SCAPI runs and stays.
  • auto with no SCAPI scopes: probe + cached OCAPI fallback. Output, JSON shape, and exit codes match prior behavior.

What's new

  • b2c job execution delete — SCAPI-only (no OCAPI equivalent).
  • --api-backend flag on instance commands (auto | scapi | ocapi), also honored from apiBackend in dw.json.
  • Required SCAPI scopes (per domain rw + ro tier; auth automatically tries rw first, falls to ro for reads): sfcc.jobs.rw / sfcc.jobs, sfcc.scripts.rw / sfcc.scripts, sfcc.users.rw / sfcc.users, sfcc.roles.rw / sfcc.roles.

Architecture highlights

The jobs domain has been rebuilt around an auth-layer scope cascade primitive that the other domains will adopt in subsequent work:

  • AuthStrategy.getAccessTokenForCascade(candidates) — walks scope candidates against AM, caches per requested set, lets a previously granted broader-scope token satisfy a later narrower request.
  • createScapiAuthMiddleware — picks the cascade tier (read or write) per operation via an internal request header.
  • BackendDispatcher — small CLI-side primitive that routes between SCAPI ops and OCAPI free functions, caching the resolved backend for the lifetime of one logical operation. Lives at src/compat/dispatcher.ts because it's a transitional bridge that will be deleted once OCAPI is removed.
  • SCAPI ops as free functionsscapiExecuteJob(client, ...) etc., declaring scope mode via headers. The previous five-layer abstraction (interface + adapter classes + Proxy fallback wrapper + ScopeTierManager) is gone for jobs; scripts/users/roles still use the legacy pattern until they migrate.

Logging

Cascade resolution and backend fallback log at debug level today. The falling back to OCAPI log line is the future hook point for a warn-level migration nudge once SCAPI provisioning becomes the standard.

Test plan

  • pnpm run typecheck:agent clean across packages
  • pnpm run lint:agent clean across packages
  • pnpm --filter @salesforce/b2c-tooling-sdk run test:agent (1746 passing)
  • pnpm --filter @salesforce/b2c-cli run test:agent (1220 passing)
  • New unit coverage: BackendDispatcher (13 tests), OAuth scope cascade (5 tests)
  • Manual: b2c job run my-job --wait against an instance with SCAPI scopes
  • Manual: same command against an instance without SCAPI scopes (verify silent OCAPI fallback)
  • Manual: b2c job execution delete my-job exec-1 (SCAPI required)
  • Manual: b2c code list, b2c bm users list, b2c bm roles list against both kinds of instances
  • Manual: explicit --api-backend scapi and --api-backend ocapi produce expected behavior

Follow-ups (not in this PR)

  • Migrate code, bm users, bm roles from the legacy Scapi*Backend + ScopeTierManager pattern to the new dispatcher + cascade pattern. Will delete ScopeTierManager once all four domains migrate.
  • Eventually upgrade the OCAPI-fallback log line from debug to warn to nudge users toward provisioning SCAPI scopes.

clavery added 11 commits May 8, 2026 12:22
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant