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/.changeset/scapi-migration.md b/.changeset/scapi-migration.md new file mode 100644 index 000000000..7ed6ea35b --- /dev/null +++ b/.changeset/scapi-migration.md @@ -0,0 +1,17 @@ +--- +'@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`, `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/.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/docs/cli/auth.md b/docs/cli/auth.md index a48c5e2cd..0faabc3da 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,13 +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), [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 | +| 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)) | System OAuth (client credentials/JWT) + SCAPI scopes; browser PKCE/implicit is rejected | +| [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 dfdeb256d..3959726ee 100644 --- a/docs/cli/bm.md +++ b/docs/cli/bm.md @@ -4,23 +4,57 @@ 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 + +`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 +b2c bm users list +b2c bm roles get Administrator +``` + +| 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 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`. 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. Two flows are supported: +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 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 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: +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. @@ -30,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. @@ -83,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 @@ -104,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 @@ -125,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 @@ -165,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 @@ -193,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 @@ -216,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 @@ -259,8 +293,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 @@ -271,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 @@ -308,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 @@ -329,6 +363,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. @@ -353,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 @@ -393,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 @@ -407,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 @@ -447,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 @@ -465,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 f8f13266c..3e407148a 100644 --- a/docs/cli/code.md +++ b/docs/cli/code.md @@ -6,14 +6,40 @@ description: Commands for deploying, downloading, activating code versions, and Commands for managing cartridge code on B2C Commerce instances. +## API Backend + +`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 +# 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`) on safe SCAPI capability/auth/request rejections. You can force a backend if needed: + +```bash +b2c code list --api-backend scapi # force SCAPI +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 `--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 +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: -| Operation | Auth Required | -|-----------|--------------| -| `code deploy`, `code download`, `code watch` | WebDAV (Basic Auth or OAuth) | -| `code list`, `code activate`, `code delete` | OAuth + OCAPI | +| 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) @@ -24,16 +50,18 @@ export SFCC_USERNAME=your-bm-username export SFCC_PASSWORD=your-webdav-access-key ``` -### OCAPI Operations (list, activate, delete) +### Code Version 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. 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 including OCAPI configuration, 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). --- @@ -51,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 @@ -108,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 @@ -178,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. @@ -232,7 +260,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 @@ -250,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 @@ -299,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 @@ -342,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 @@ -389,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 0829a9dcb..a67dd4f49 100644 --- a/docs/cli/jobs.md +++ b/docs/cli/jobs.md @@ -6,19 +6,52 @@ description: Commands for executing jobs, importing and exporting site archives, Commands for executing and monitoring jobs on B2C Commerce instances. +## API Backend + +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 +# Default — uses SCAPI +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 on safe SCAPI capability/auth/request rejections. Force a backend if needed: + +```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 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 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 -Job commands require OAuth authentication with OCAPI permissions. +### SCAPI (recommended) + +When using SCAPI, your API client needs the appropriate scopes in Account Manager: -### Required OCAPI Permissions +| Scope | Operations | +| -------------- | ------------------------------------------------------------- | +| `sfcc.jobs.rw` | Execute, delete, search, and get job executions (recommended) | +| `sfcc.jobs` | Search and get job executions (read-only) | + +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**: -| 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 @@ -52,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. @@ -143,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 @@ -187,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 @@ -240,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 @@ -281,6 +314,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. @@ -293,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 @@ -463,22 +527,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 @@ -512,6 +576,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 @@ -523,6 +588,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 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 97883b8eb..d23fa9b05 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. +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. -### 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`) 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 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. 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). --- @@ -109,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. @@ -144,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 @@ -192,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 @@ -229,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 @@ -247,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 a31835f57..7cf63e05c 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 @@ -10,17 +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 + 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) | -| 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) | +| 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)) | System 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. @@ -38,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. @@ -54,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. @@ -62,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. @@ -96,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) @@ -199,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) @@ -216,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 @@ -289,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 @@ -315,6 +325,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, 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. +::: + 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 @@ -472,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 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 dual-backend commands use it first with a temporary [deprecated OCAPI fallback](#ocapi-configuration). All require OAuth authentication with specific roles and scopes. + +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 @@ -486,15 +504,31 @@ 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 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` 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/). ::: @@ -605,11 +639,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. (Optional) Configure OCAPI fallback -Add the JSON configuration shown in [OCAPI Configuration](#ocapi-configuration) to enable code version and job APIs. +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) @@ -625,10 +660,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 @@ -640,7 +676,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 196723f2f..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 @@ -212,7 +212,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 @@ -244,37 +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`. | +| 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) @@ -332,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. @@ -362,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}] } } ``` @@ -442,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/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/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/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/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/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/src/commands/code/activate.ts b/packages/b2c-cli/src/commands/code/activate.ts index 5d99eeee8..9193d91c7 100644 --- a/packages/b2c-cli/src/commands/code/activate.ts +++ b/packages/b2c-cli/src/commands/code/activate.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 {activateCodeVersion, reloadCodeVersion} from '@salesforce/b2c-tooling-sdk/operations/code'; +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 InstanceCommand { +export default class CodeActivate extends CodeCommand { static args = { codeVersion: Args.string({ description: 'Code version ID to activate', @@ -29,10 +29,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 (forces a code cache reload by toggling activation)', default: false, }), }; @@ -45,11 +45,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 +68,7 @@ export default class CodeActivate extends InstanceCommand { ); try { - await reloadCodeVersion(this.instance, codeVersion); + await reloadCodeVersion(backend, codeVersion); this.log( t('commands.code.activate.reloaded', 'Code version{{version}} reloaded successfully', { version: codeVersion ? ` ${codeVersion}` : '', @@ -75,16 +85,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 +93,7 @@ export default class CodeActivate extends InstanceCommand { ); try { - const activation = await activateCodeVersion(this.instance, codeVersion); + const activation = await backend.activateCodeVersion(codeVersion!); if (activation.alreadyActive) { this.log( t( 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/deploy.ts b/packages/b2c-cli/src/commands/code/deploy.ts index 0fbec464a..a0eb88c3b 100644 --- a/packages/b2c-cli/src/commands/code/deploy.ts +++ b/packages/b2c-cli/src/commands/code/deploy.ts @@ -7,10 +7,10 @@ import {Flags} from '@oclif/core'; import { uploadCartridges, deleteCartridges, - getActiveCodeVersion, - activateCodeVersion, reloadCodeVersion, + 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'; @@ -64,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(); @@ -76,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', @@ -103,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.'), @@ -200,7 +210,7 @@ export default class CodeDeploy extends CartridgeCommand { let reloaded = false; try { if (this.flags.activate) { - const activation = await this.operations.activateCodeVersion(this.instance, version); + const activation = await this.scriptsBackend.activateCodeVersion(version); activated = true; if (activation?.alreadyActive) { this.log( @@ -212,7 +222,7 @@ export default class CodeDeploy extends CartridgeCommand { ); } } else if (this.flags.reload) { - await this.operations.reloadCodeVersion(this.instance, version); + await this.operations.reloadCodeVersion(this.scriptsBackend, version); activated = true; reloaded = true; } diff --git a/packages/b2c-cli/src/commands/code/download.ts b/packages/b2c-cli/src/commands/code/download.ts index 6160d9dd1..8ecffb26e 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 { @@ -150,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/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/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/job/execution/delete.ts b/packages/b2c-cli/src/commands/job/execution/delete.ts new file mode 100644 index 000000000..45181a470 --- /dev/null +++ b/packages/b2c-cli/src/commands/job/execution/delete.ts @@ -0,0 +1,74 @@ +/* + * 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 {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 { + static args = { + jobId: Args.string({ + description: 'Job ID', + required: true, + }), + executionId: Args.string({ + description: 'Execution ID to delete', + required: true, + }), + }; + + // SCAPI-only: there is no OCAPI endpoint for deleting a job execution. + // The command requires SCAPI configuration regardless of `apiBackend`. + static description = withDocs( + t( + 'commands.job.execution.delete.description', + 'Delete a job execution record (SCAPI only — requires shortCode, tenantId, and OAuth credentials)', + ), + '/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 tenantId = this.resolvedConfig.values.tenantId; + + if (this.apiBackendPreference === 'ocapi') { + this.error( + t( + 'commands.job.execution.delete.ocapiNotSupported', + 'Deleting job executions is only available via SCAPI. Remove --api-backend ocapi or set apiBackend to auto/scapi.', + ), + ); + } + + const client = this.buildScapiJobsClient(); + if (!client || !tenantId) { + this.error(t('commands.job.execution.delete.scapiNotConfigured', scapiUnavailableMessage('Jobs'))); + } + + this.log( + t('commands.job.execution.delete.deleting', 'Deleting execution {{executionId}} for job {{jobId}}...', { + jobId, + executionId, + }), + ); + + await scapiDeleteJobExecution(client, jobId, executionId, tenantId); + + 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..3d748886d 100644 --- a/packages/b2c-cli/src/commands/job/log.ts +++ b/packages/b2c-cli/src/commands/job/log.ts @@ -4,22 +4,25 @@ * 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 {JobCommand} from '@salesforce/b2c-tooling-sdk/cli'; import { - searchJobExecutions, - getJobExecution, - getJobLog, - type JobExecution, + getJobExecution as ocapiGetJobExecution, + searchJobExecutions as ocapiSearchJobExecutions, + scapiGetJobExecution, + scapiSearchJobExecutions, + mapOcapiExecution, + mapOcapiSearchResult, + 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: JobExecution; + execution: JobExecutionInfo; log: string; } -export default class JobLog extends InstanceCommand { +export default class JobLog extends JobCommand { static args = { jobId: Args.string({ description: 'Job ID', @@ -46,7 +49,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 +60,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 dispatcher = this.createJobsDispatcher(); + const tenantId = this.resolvedConfig.values.tenantId; + + let execution: JobExecutionInfo; if (executionId) { this.log( @@ -78,7 +78,10 @@ export default class JobLog extends InstanceCommand { executionId, }), ); - execution = await this.operations.getJobExecution(this.instance, 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 @@ -92,15 +95,20 @@ export default class JobLog extends InstanceCommand { }), ); - const results = await this.operations.searchJobExecutions(this.instance, { + 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.is_log_file_existing); + const match = results.hits.find((hit) => hit.isLogFileExisting); if (!match) { const msg = failed ? t( @@ -117,18 +125,23 @@ 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); + 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 41a068370..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, - waitForJob, - JobExecutionError, - type JobExecution, + executeJob as ocapiExecuteJob, + getJobExecution as ocapiGetJobExecution, + scapiExecuteJob, + scapiGetJobExecution, + mapOcapiExecution, + waitForJobExecution, + 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 { @@ -76,12 +81,7 @@ export default class JobRun extends JobCommand { static hiddenAliases = ['job:run']; - protected operations = { - executeJob, - waitForJob, - }; - - async run(): Promise { + async run(): Promise { this.requireOAuthCredentials(); const {jobId} = this.args; @@ -95,8 +95,6 @@ export default class JobRun extends JobCommand { 'show-log': showLog, } = 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}); @@ -105,11 +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; - // Create lifecycle context + const dispatcher = this.createJobsDispatcher(); + const tenantId = this.resolvedConfig.values.tenantId; + const context = this.createContext('job:run', { jobId, parameters: rawBody ? undefined : parameters, @@ -118,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( @@ -126,8 +124,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 JobExecutionInfo; } this.log( @@ -137,36 +138,46 @@ export default class JobRun extends JobCommand { }), ); - let execution: JobExecution; + const ocapiOptions = { + parameters: rawBody ? undefined : parameters, + body: rawBody, + waitForRunning: !noWaitRunning, + }; + + let execution: JobExecutionInfo; try { - execution = await this.operations.executeJob(this.instance, 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, - status: execution.execution_status, + status: execution.executionStatus, }), ); - // Wait for completion if requested if (wait) { execution = await this.waitForJobCompletion({ + dispatcher, jobId, - executionId: execution.id!, + executionId: execution.id, timeout, pollInterval, showLog, context, }); } else { - // Not waiting - run afterOperation hooks with current state await this.runAfterHooks(context, { success: true, duration: Date.now() - context.startTime, @@ -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)), @@ -196,21 +204,20 @@ 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)), 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', }), ); } @@ -241,18 +248,26 @@ export default class JobRun extends JobCommand { } private async waitForJobCompletion(options: { + dispatcher: BackendDispatcher; 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 {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 this.operations.waitForJob(this.instance, 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) => { @@ -270,12 +285,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 2e1d9bcc8..b3169bf81 100644 --- a/packages/b2c-cli/src/commands/job/search.ts +++ b/packages/b2c-cli/src/commands/job/search.ts @@ -5,35 +5,37 @@ */ import {Flags, ux} from '@oclif/core'; import { - InstanceCommand, + JobCommand, TableRenderer, columnFlagsFor, selectColumns, type ColumnDef, } from '@salesforce/b2c-tooling-sdk/cli'; import { - searchJobExecutions, - type JobExecutionSearchResult, - type JobExecution, + 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> = { +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) : '-'), }, }; @@ -41,7 +43,7 @@ const DEFAULT_COLUMNS = ['id', 'jobId', 'status', 'startTime']; const tableRenderer = new TableRenderer(COLUMNS); -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', @@ -58,7 +60,7 @@ export default class JobSearch extends InstanceCommand { ]; static flags = { - ...InstanceCommand.baseFlags, + ...JobCommand.baseFlags, 'job-id': Flags.string({ char: 'j', description: 'Filter by job ID', @@ -91,36 +93,31 @@ export default class JobSearch extends InstanceCommand { ...columnFlagsFor(COLUMNS), }; - 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 dispatcher = this.createJobsDispatcher(); + const tenantId = this.resolvedConfig.values.tenantId; + 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, { - 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)), }); - // 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..ba94dcf80 100644 --- a/packages/b2c-cli/src/commands/job/wait.ts +++ b/packages/b2c-cli/src/commands/job/wait.ts @@ -5,7 +5,14 @@ */ 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 { + getJobExecution as ocapiGetJobExecution, + scapiGetJobExecution, + mapOcapiExecution, + waitForJobExecution, + CanonicalJobExecutionError, + type JobExecutionInfo, +} from '@salesforce/b2c-tooling-sdk/operations/jobs'; import {t, withDocs} from '../../i18n/index.js'; export default class JobWait extends JobCommand { @@ -49,16 +56,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 dispatcher = this.createJobsDispatcher(); + const tenantId = this.resolvedConfig.values.tenantId; + this.log( t('commands.job.wait.waiting', 'Waiting for job {{jobId}} execution {{executionId}}...', { jobId, @@ -67,7 +73,13 @@ export default class JobWait extends JobCommand { ); try { - const execution = await this.operations.waitForJob(this.instance, 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) => { @@ -85,20 +97,20 @@ 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, }), ); 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/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 { @@ -48,7 +48,16 @@ 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', }, }; @@ -37,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'), @@ -57,43 +59,32 @@ 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({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})); - const {data, error, response} = await this.instance.ocapi.GET('/sites', { - params: {query: {select: '(**)'}}, - }); - - if (error) { - 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/bm/access-key/get.test.ts b/packages/b2c-cli/test/commands/bm/access-key/get.test.ts index a18203b8d..10fcd3280 100644 --- a/packages/b2c-cli/test/commands/bm/access-key/get.test.ts +++ b/packages/b2c-cli/test/commands/bm/access-key/get.test.ts @@ -107,4 +107,18 @@ describe('bm access-key get', () => { 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/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-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-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-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-cli/test/commands/code/activate.test.ts b/packages/b2c-cli/test/commands/code/activate.test.ts index 2815511fd..0a59718ac 100644 --- a/packages/b2c-cli/test/commands/code/activate.test.ts +++ b/packages/b2c-cli/test/commands/code/activate.test.ts @@ -21,29 +21,37 @@ 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(), + // Backend contract returns a CodeVersionActivationResult. + activateCodeVersion: sinon.stub().resolves({alreadyActive: false}), + deleteCodeVersion: sinon.stub(), + createCodeVersion: 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({alreadyActive: false}); 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('reports an already-active version without failing', async () => { @@ -75,7 +83,6 @@ describe('code activate', () => { 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}})); @@ -89,67 +96,26 @@ 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); + // reloadCodeVersion is now backend-agnostic: list+activate(alt)+activate(target) + backend.listCodeVersions.resolves([ + {id: 'v1', active: true}, + {id: 'v2', active: false}, + ]); + backend.activateCodeVersion.resolves({alreadyActive: false}); 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']); + // 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'}); - - 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.listCodeVersions.rejects(new Error('boom')); const errorStub = sinon.stub(command, 'error').throws(new Error('Expected error')); @@ -157,6 +123,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/deploy.test.ts b/packages/b2c-cli/test/commands/code/deploy.test.ts index 3b52f70dc..5ead9071e 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({alreadyActive: false}), + 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); @@ -86,7 +95,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 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}); expect(afterHooksStub.calledOnce).to.be.true; @@ -95,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); @@ -104,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); @@ -118,15 +129,15 @@ describe('code deploy', () => { it('reports an already-active version as a successful no-op', async () => { const command: any = await createCommand({activate: true}, {cartridgePath: '.'}); - stubCommon(command); + const {scriptsBackend} = stubCommon(command); + scriptsBackend.activateCodeVersion = sinon.stub().resolves({alreadyActive: true}); sinon.stub(command, 'runBeforeHooks').resolves({skip: false}); sinon.stub(command, 'runAfterHooks').resolves(void 0); sinon.stub(command, 'findCartridgesWithProviders').resolves([{name: 'c1', src: '/tmp/c1', dest: 'c1'}]); const uploadStub = sinon.stub().resolves(void 0); - const activateStub = sinon.stub().resolves({alreadyActive: true}); - command.operations = {...command.operations, uploadCartridges: uploadStub, activateCodeVersion: activateStub}; + command.operations = {...command.operations, uploadCartridges: uploadStub}; const result = await command.run(); @@ -136,7 +147,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); @@ -145,8 +157,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')); @@ -251,20 +262,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-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-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..5599efe92 --- /dev/null +++ b/packages/b2c-cli/test/commands/job/execution/delete.test.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 + */ + +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 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', tenantId: opts.tenantId}})); + sinon.stub(command, 'instance').get(() => ({config: {hostname: 'example.com'}})); + sinon.stub(command, 'apiBackendPreference').get(() => opts.preference ?? 'auto'); + sinon.stub(command, 'buildScapiJobsClient').returns(opts.client); + } + + it('calls scapiDeleteJobExecution when SCAPI is configured', async () => { + const command: any = await createCommand({}, {jobId: 'my-job', executionId: 'exec-1'}); + // 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(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 when SCAPI is not configured', async () => { + const command: any = await createCommand({}, {jobId: 'my-job', executionId: 'exec-1'}); + stubCommon(command, {client: undefined}); + + try { + await command.run(); + expect.fail('should have thrown'); + } catch (error: any) { + expect(error.message).to.match(/SCAPI/i); + } + }); +}); diff --git a/packages/b2c-cli/test/commands/job/log.test.ts b/packages/b2c-cli/test/commands/job/log.test.ts index 9358c83e1..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(); @@ -22,79 +34,84 @@ describe('job log', () => { } 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, '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); - return instance; + 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 instance = stubCommon(command); + const {runner} = 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, + 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(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(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 instance = stubCommon(command); + const {runner} = 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, + 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(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(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'}); - stubCommon(command); + const {runner} = 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, + 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(searchStub.getCall(0).args[1]).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'}); - stubCommon(command); + const {runner} = 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)}; + runner.resolves({id: 'exec-1', jobId: 'my-job', isLogFileExisting: false}); try { await command.run(); @@ -106,10 +123,9 @@ describe('job log', () => { it('errors when no executions with log found', async () => { const command: any = await createCommand({}, {jobId: 'my-job'}); - stubCommon(command); + const {runner} = stubCommon(command); - const searchStub = sinon.stub().resolves({total: 0, hits: []}); - command.operations = {...command.operations, searchJobExecutions: searchStub}; + runner.resolves({total: 0, hits: []}); try { await command.run(); @@ -121,19 +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'}); - stubCommon(command); + const {runner} = 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, + 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 5cb54e682..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(); @@ -22,17 +41,18 @@ describe('job run', () => { } 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, '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) => ({ operationType, metadata, startTime: Date.now(), })); - return instance; + const fake = makeDispatcherFake(); + sinon.stub(command, 'createJobsDispatcher').returns(fake.dispatcher); + return fake; } it('errors on invalid -P param format', async () => { @@ -53,39 +73,42 @@ 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 {runner} = 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}; + runner.resolves({id: 'e1', jobId: 'my-job', 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(runner.calledOnce).to.equal(true); 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 {runner} = 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}; + // 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(waitStub.calledOnce).to.equal(true); - expect(waitStub.getCall(0).args[0]).to.equal(instance); - expect(result.execution_status).to.equal('finished'); + expect(runner.callCount).to.be.greaterThanOrEqual(2); + expect(result.executionStatus).to.equal('finished'); }); it('returns early when before hooks skip', async () => { @@ -96,7 +119,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,36 +137,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}; - const showJobLogStub = 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(showJobLogStub.calledOnce).to.equal(true); - 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..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(); @@ -23,44 +35,42 @@ describe('job search', () => { } 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, '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); - return instance; + 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 instance = stubCommon(command); + const {runner} = stubCommon(command); sinon.stub(command, 'jsonEnabled').returns(true); - const searchStub = sinon.stub().resolves({total: 1, hits: [{id: 'e1'}]}); - command.operations = {...command.operations, searchJobExecutions: searchStub}; + runner.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(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 instance = stubCommon(command); + const {runner} = stubCommon(command); sinon.stub(command, 'jsonEnabled').returns(false); - const searchStub = sinon.stub().resolves({total: 0, hits: []}); - command.operations = {...command.operations, searchJobExecutions: searchStub}; + 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(searchStub.getCall(0).args[0]).to.equal(instance); + 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 fd2600e22..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,23 +33,27 @@ describe('job wait', () => { return createTestCommand(JobWait, hooks.getConfig(), flags, args); } - it('waits using wrapper without real polling', async () => { + it('waits using dispatcher polling', async () => { const command: any = await createCommand({'poll-interval': 1, json: true}, {jobId: 'my-job', executionId: 'e1'}); - const instance = {config: {hostname: 'example.com'}}; - sinon.stub(command, 'requireOAuthCredentials').returns(void 0); - sinon.stub(command, 'instance').get(() => instance); + 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 waitStub = sinon.stub().resolves({id: 'e1', execution_status: 'finished'}); - command.operations = {...command.operations, waitForJob: waitStub}; + const {runner, dispatcher} = makeDispatcherFake(); + runner.resolves({ + id: 'e1', + jobId: 'my-job', + executionStatus: 'finished', + exitStatus: {code: 'OK', status: 'ok'}, + }); + sinon.stub(command, 'createJobsDispatcher').returns(dispatcher); const result = await command.run(); - expect(waitStub.calledOnce).to.equal(true); - expect(waitStub.getCall(0).args[0]).to.equal(instance); + expect(runner.called).to.equal(true); expect(result.id).to.equal('e1'); }); }); diff --git a/packages/b2c-cli/test/commands/sites/list.test.ts b/packages/b2c-cli/test/commands/sites/list.test.ts index 95aebe740..156f82d97 100644 --- a/packages/b2c-cli/test/commands/sites/list.test.ts +++ b/packages/b2c-cli/test/commands/sites/list.test.ts @@ -22,37 +22,49 @@ describe('sites list', () => { return createTestCommand(SitesList, hooks.getConfig(), flags, args); } + // 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); } - function stubErrorToThrow(command: any) { - return sinon.stub(command, 'error').throws(new Error('Expected error')); - } - it('returns data in JSON mode', async () => { const command: any = await createCommand(); stubCommon(command, {jsonEnabled: true}); - const ocapiGet = sinon.stub().resolves({data: {count: 1, data: [{id: 'site1'}]}, error: undefined}); - sinon.stub(command, 'instance').get(() => ({ocapi: {GET: ocapiGet}})); + const ocapiGet = sinon.stub().resolves({ + data: {count: 1, data: [{id: 'site1', display_name: {default: 'Site One'}, storefront_status: 'online'}]}, + error: undefined, + response: {status: 200}, + }); + stubInstance(command, 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}); - sinon.stub(command, 'instance').get(() => ({ocapi: {GET: ocapiGet}})); + const ocapiGet = sinon.stub().resolves({data: {count: 0, data: []}, error: undefined, response: {status: 200}}); + stubInstance(command, ocapiGet); const stdoutStub = sinon.stub(ux, 'stdout').returns(void 0 as any); @@ -66,22 +78,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'}})); - - const ocapiGet = sinon.stub().resolves({data: undefined, error: {message: 'boom'}}); - sinon.stub(command, 'instance').get(() => ({ocapi: {GET: ocapiGet}})); + stubCommon(command, {jsonEnabled: false}); + sinon.stub(command, 'log').returns(void 0); - const errorStub = stubErrorToThrow(command); + const ocapiGet = sinon + .stub() + .resolves({data: undefined, error: {fault: {message: 'boom'}}, response: {status: 500}}); + stubInstance(command, ocapiGet); + // 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-dx-mcp/src/tools/cartridges/index.ts b/packages/b2c-dx-mcp/src/tools/cartridges/index.ts index 1ea5dfd66..4f3817b27 100644 --- a/packages/b2c-dx-mcp/src/tools/cartridges/index.ts +++ b/packages/b2c-dx-mcp/src/tools/cartridges/index.ts @@ -16,7 +16,11 @@ import {z} from 'zod'; import type {McpTool} from '../../utils/index.js'; import type {Services} from '../../services.js'; import {createToolAdapter, jsonResult} from '../adapter.js'; -import {findAndDeployCartridges, getActiveCodeVersion} from '@salesforce/b2c-tooling-sdk/operations/code'; +import { + createScriptsBackend, + findAndDeployCartridges, + getActiveCodeVersion, +} from '@salesforce/b2c-tooling-sdk/operations/code'; import type {DeployResult, DeployOptions, CodeVersion} from '@salesforce/b2c-tooling-sdk/operations/code'; import type {B2CInstance} from '@salesforce/b2c-tooling-sdk'; import {getLogger} from '@salesforce/b2c-tooling-sdk/logging'; @@ -125,11 +129,14 @@ function createCartridgeDeployTool( const logger = getLogger(); try { + const scriptsBackend = createScriptsBackend({instance}); // If no code version specified, get the active one let codeVersion = instance.config.codeVersion; if (!codeVersion) { logger.debug('No code version specified, getting active version...'); - const active = await getActiveCodeVersionFn(instance); + const active = injections?.getActiveCodeVersion + ? await getActiveCodeVersionFn(instance) + : await scriptsBackend.getActiveCodeVersion(); if (!active?.id) { throw new Error( 'No code version specified and no active code version found. ' + @@ -147,6 +154,7 @@ function createCartridgeDeployTool( // Parse options const options: DeployOptions = { + scriptsBackend, include: args.cartridges, exclude: args.exclude, reload: args.reload, diff --git a/packages/b2c-tooling-sdk/package.json b/packages/b2c-tooling-sdk/package.json index 8d33eec67..99f442919 100644 --- a/packages/b2c-tooling-sdk/package.json +++ b/packages/b2c-tooling-sdk/package.json @@ -99,6 +99,11 @@ "types": "./dist/esm/operations/sites/index.d.ts", "default": "./dist/esm/operations/sites/index.js" }, + "./operations/catalogs": { + "development": "./src/operations/catalogs/index.ts", + "types": "./dist/esm/operations/catalogs/index.d.ts", + "default": "./dist/esm/operations/catalogs/index.js" + }, "./operations/orgs": { "development": "./src/operations/orgs/index.ts", "types": "./dist/esm/operations/orgs/index.d.ts", @@ -134,6 +139,11 @@ "types": "./dist/esm/clients/index.d.ts", "default": "./dist/esm/clients/index.js" }, + "./compat": { + "development": "./src/compat/index.ts", + "types": "./dist/esm/compat/index.d.ts", + "default": "./dist/esm/compat/index.js" + }, "./logging": { "development": "./src/logging/index.ts", "types": "./dist/esm/logging/index.d.ts", @@ -214,7 +224,7 @@ "!data/script-api/*.md" ], "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/preferences-v1.yaml -o src/clients/preferences.generated.ts && openapi-typescript specs/metrics-v1.json -o src/clients/metrics.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 && openapi-typescript specs/product-catalogs-v1.yaml -o src/clients/scapi-catalogs.generated.ts && openapi-typescript specs/preferences-v1.yaml -o src/clients/preferences.generated.ts && openapi-typescript specs/metrics-v1.json -o src/clients/metrics.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/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/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/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/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/specs/product-catalogs-v1.yaml b/packages/b2c-tooling-sdk/specs/product-catalogs-v1.yaml new file mode 100644 index 000000000..a168dbebc --- /dev/null +++ b/packages/b2c-tooling-sdk/specs/product-catalogs-v1.yaml @@ -0,0 +1,111 @@ +openapi: 3.0.3 +info: + title: Catalogs + version: 1.0.46 + x-api-type: Admin + x-api-family: Product +servers: + - url: https://{shortCode}.api.commercecloud.salesforce.com/product/catalogs/v1 + variables: + shortCode: + default: shortCode +paths: + /organizations/{organizationId}/catalogs: + get: + operationId: getCatalogs + parameters: + - name: organizationId + in: path + required: true + schema: + type: string + - name: limit + in: query + schema: + type: integer + format: int32 + default: 25 + maximum: 50 + - name: offset + in: query + schema: + type: integer + format: int32 + default: 0 + minimum: 0 + responses: + '200': + description: Catalogs retrieved successfully. + content: + application/json: + schema: + $ref: '#/components/schemas/Catalogs' + '400': + description: Bad Request + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Unauthorized + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ErrorResponse' + security: + - AmOAuth2: [sfcc.catalogs, sfcc.catalogs.rw] +components: + schemas: + Catalog: + type: object + additionalProperties: true + required: [id] + properties: + id: + type: string + name: + type: object + additionalProperties: + type: string + description: + type: object + additionalProperties: + type: string + online: + type: boolean + Catalogs: + type: object + required: [data, limit, offset, total] + properties: + data: + type: array + items: + $ref: '#/components/schemas/Catalog' + limit: + type: integer + offset: + type: integer + total: + type: integer + ErrorResponse: + type: object + additionalProperties: true + required: [detail, title, type] + properties: + title: + type: string + type: + type: string + detail: + type: string + instance: + type: string + securitySchemes: + AmOAuth2: + type: oauth2 + flows: + clientCredentials: + tokenUrl: https://account.demandware.com/dwsso/oauth2/access_token + scopes: + sfcc.catalogs: catalogs scope READONLY + sfcc.catalogs.rw: catalogs scope 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..f56d8456c --- /dev/null +++ b/packages/b2c-tooling-sdk/specs/site-sites-v1.yaml @@ -0,0 +1,667 @@ +openapi: 3.0.3 +info: + title: Sites + version: 1.3.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] + /organizations/{organizationId}/sites/{siteId}/custom-cartridges: + get: + operationId: getSiteCustomCartridges + 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 site's custom cartridge path + content: + application/json: + schema: + $ref: '#/components/schemas/SiteCustomCartridges' + 401: + $ref: '#/components/responses/401unauthorized' + 403: + $ref: '#/components/responses/403forbidden' + 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] + put: + operationId: replaceSiteCustomCartridges + 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' + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/SiteCustomCartridges' + required: true + responses: + 200: + description: Custom cartridge path successfully replaced + content: + application/json: + schema: + $ref: '#/components/schemas/SiteCustomCartridges' + 400: + description: Bad Request - Invalid cartridge path + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ErrorResponse' + 401: + $ref: '#/components/responses/401unauthorized' + 403: + $ref: '#/components/responses/403forbidden' + 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.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 + readOnly: true + customCartridges: + 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] + SiteCustomCartridges: + type: object + properties: + customCartridges: + type: string + maxLength: 4000 + required: [customCartridges] + 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/auth/oauth-implicit.ts b/packages/b2c-tooling-sdk/src/auth/oauth-implicit.ts index 63a0647ec..cfd851072 100644 --- a/packages/b2c-tooling-sdk/src/auth/oauth-implicit.ts +++ b/packages/b2c-tooling-sdk/src/auth/oauth-implicit.ts @@ -120,6 +120,7 @@ async function openBrowserDefault(url: string): Promise { * ``` */ export class ImplicitOAuthStrategy implements AuthStrategy { + readonly authMethod = 'implicit' as const; private accountManagerHost: string; private localPort: number; private redirectUri: string; diff --git a/packages/b2c-tooling-sdk/src/auth/oauth-jwt.ts b/packages/b2c-tooling-sdk/src/auth/oauth-jwt.ts index d4152349c..ff42fd4c3 100644 --- a/packages/b2c-tooling-sdk/src/auth/oauth-jwt.ts +++ b/packages/b2c-tooling-sdk/src/auth/oauth-jwt.ts @@ -21,7 +21,8 @@ import { getOAuthCacheKey, getCachedOAuthToken, setCachedOAuthToken, - invalidateCachedOAuthToken, + invalidateCachedTokensForIdentity, + findCachedTokenSatisfying, decodeJWT, } from './oauth.js'; import {globalAuthMiddlewareRegistry, applyAuthRequestMiddleware, applyAuthResponseMiddleware} from './middleware.js'; @@ -83,6 +84,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 +103,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'); @@ -265,6 +268,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.identityPrefix; + + 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. @@ -282,10 +328,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'); } @@ -306,10 +356,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 @@ -326,15 +383,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', ); @@ -397,24 +454,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-pkce-fallback.ts b/packages/b2c-tooling-sdk/src/auth/oauth-pkce-fallback.ts index aebf159f6..a7e12639f 100644 --- a/packages/b2c-tooling-sdk/src/auth/oauth-pkce-fallback.ts +++ b/packages/b2c-tooling-sdk/src/auth/oauth-pkce-fallback.ts @@ -57,6 +57,7 @@ export function isPkceFallbackDisabled(): boolean { * the failing PKCE flow. */ export class PkceWithImplicitFallbackStrategy implements UserAuthStrategy { + readonly authMethod = 'user' as const; private readonly pkce: PkceOAuthStrategy; private implicit: ImplicitOAuthStrategy | null = null; /** Once PKCE has failed with a grant error, route everything to implicit. */ diff --git a/packages/b2c-tooling-sdk/src/auth/oauth-pkce.ts b/packages/b2c-tooling-sdk/src/auth/oauth-pkce.ts index 52adf08b3..3c06dfca9 100644 --- a/packages/b2c-tooling-sdk/src/auth/oauth-pkce.ts +++ b/packages/b2c-tooling-sdk/src/auth/oauth-pkce.ts @@ -152,6 +152,7 @@ async function openBrowserDefault(url: string): Promise { * Tokens may include a refresh_token (depends on client registration in Account Manager). */ export class PkceOAuthStrategy implements AuthStrategy { + readonly authMethod = 'user' as const; private accountManagerHost: string; private localPort: number; private redirectUri: string; diff --git a/packages/b2c-tooling-sdk/src/auth/oauth.ts b/packages/b2c-tooling-sdk/src/auth/oauth.ts index dc2083dfb..ee09f685b 100644 --- a/packages/b2c-tooling-sdk/src/auth/oauth.ts +++ b/packages/b2c-tooling-sdk/src/auth/oauth.ts @@ -93,12 +93,56 @@ export function setCachedOAuthToken(cacheKey: string, tokenResponse: AccessToken } /** - * Invalidates a cached OAuth token. + * Scans the cache for a non-expired token (matching the supplied identity + * prefix) whose scopes are a superset of `requiredScopes`. * - * @param cacheKey - Cache key from getOAuthCacheKey() + * 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 invalidateCachedOAuthToken(cacheKey: string): void { - ACCESS_TOKEN_CACHE.delete(cacheKey); +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 **every** cached token for an identity prefix + * (`${accountManagerHost}:${clientId}:${method}:`). + * + * 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 invalidateCachedTokensForIdentity(identityPrefix: string): void { + for (const key of ACCESS_TOKEN_CACHE.keys()) { + if (key.startsWith(identityPrefix)) { + ACCESS_TOKEN_CACHE.delete(key); + } + } } /** @@ -126,6 +170,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. @@ -140,6 +185,7 @@ export class OAuthStrategy implements AuthStrategy { this.accountManagerHost, this.config.scopes, ); + this.identityPrefix = `${this.accountManagerHost}:${this.config.clientId}:client-credentials:`; } /** @@ -211,10 +257,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); } /** @@ -232,6 +282,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.identityPrefix; + + // 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 */ @@ -254,28 +361,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'; @@ -283,8 +402,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 = encodeBasicClientCredentials(this.config.clientId, this.config.clientSecret); @@ -365,7 +484,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 3202139bc..86bc8487d 100644 --- a/packages/b2c-tooling-sdk/src/auth/types.ts +++ b/packages/b2c-tooling-sdk/src/auth/types.ts @@ -31,6 +31,44 @@ 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 + * 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) + * may not implement this; callers should treat them as "scopes already + * 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; } /** @@ -43,6 +81,8 @@ export interface AuthStrategy { * `PkceWithImplicitFallbackStrategy`. */ export interface UserAuthStrategy extends AuthStrategy { + /** Browser auth method represented by this strategy. */ + readonly authMethod: 'user' | 'implicit'; getAuthorizationHeader(): Promise; getJWT(): Promise; getTokenResponse(): Promise; @@ -67,6 +107,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 browser OAuth flows (e.g., for port forwarding in remote environments) */ redirectUri?: string; /** Custom browser opener for browser OAuth flows. Receives the authorization URL. */ 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..0033a0430 --- /dev/null +++ b/packages/b2c-tooling-sdk/src/cli/bm-command.ts @@ -0,0 +1,26 @@ +/* + * 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'; +import {createRolesBackend, type RolesBackend} from '../operations/bm-roles/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 its coordinates and + * supported auth are available, falling back on safe rejections. + */ +export abstract class BmCommand extends InstanceCommand { + protected createUsersBackend(): UsersBackend { + return this.createBackend(createUsersBackend); + } + + protected createRolesBackend(): RolesBackend { + 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 new file mode 100644 index 000000000..b89182a6f --- /dev/null +++ b/packages/b2c-tooling-sdk/src/cli/code-command.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 {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 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 { + return this.createBackend(createScriptsBackend); + } +} diff --git a/packages/b2c-tooling-sdk/src/cli/config.ts b/packages/b2c-tooling-sdk/src/cli/config.ts index 5e0f33340..42c74c8a4 100644 --- a/packages/b2c-tooling-sdk/src/cli/config.ts +++ b/packages/b2c-tooling-sdk/src/cli/config.ts @@ -116,6 +116,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; @@ -185,6 +192,66 @@ export abstract class InstanceCommand extends OAuthCom return loadConfig(extractInstanceFlags(this.flags as Record), this.getBaseConfigOptions()); } + /** + * Creates a per-command {@link BackendDispatcher} for routing operations + * to SCAPI or OCAPI based on the user's `--api-backend` preference. + * + * 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 this instance can reach SCAPI under `auto` mode: shortCode + + * tenantId are configured AND the auth flow can request the `sfcc.*` scopes + * each domain needs. + * + * 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. Browser + * user auth (PKCE or deprecated implicit) remains OCAPI/WebDAV-only because + * SCAPI Admin APIs do not currently support it. `auto` therefore selects + * OCAPI for user auth, while explicit `scapi` fails with a clear message. + */ + protected hasScapiConfig(): boolean { + if (!this.resolvedConfig.hasB2CInstanceConfig()) { + return false; + } + return this.instance.scapiClientConfig !== undefined; + } + + /** + * 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. + * + * 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 { + return factory({instance: this.instance}); + } + /** * Gets the B2CInstance for this command. * @@ -201,7 +268,11 @@ export abstract class InstanceCommand extends OAuthCom protected get instance(): B2CInstance { if (!this._instance) { this.requireServer(); - this._instance = this.resolvedConfig.createB2CInstance(); + // Reuse OAuthCommand's resolver instead of reconstructing auth inside + // B2CInstance. This preserves stored PKCE sessions, refresh tokens, + // default public clients, and the transitional PKCE→implicit fallback. + // Keep it lazy so Basic-only WebDAV commands never resolve OAuth. + this._instance = this.resolvedConfig.createB2CInstance({oauthStrategy: () => this.getOAuthStrategy()}); } return 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 e286b2aec..8894a11f1 100644 --- a/packages/b2c-tooling-sdk/src/cli/job-command.ts +++ b/packages/b2c-tooling-sdk/src/cli/job-command.ts @@ -5,42 +5,69 @@ */ import {Command} from '@oclif/core'; import {InstanceCommand} from './instance-command.js'; -import {getJobLog, getJobErrorMessage, type JobExecution} 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. + * 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 { - * try { - * await executeJob(this.instance, 'my-job'); - * } catch (error) { - * if (error instanceof JobExecutionError) { - * await this.showJobLog(error.execution); - * } - * throw error; - * } + * 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 createJobsDispatcher(): BackendDispatcher { + return this.createDispatcher('jobs', () => this.buildScapiJobsClient()); + } + /** - * Display a job's log file content and error message if available. - * 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. * - * @param execution - Failed job execution object with log file information + * 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 async showJobLog(execution: JobExecution): Promise { - // Extract error message from failed step executions - const errorMessage = getJobErrorMessage(execution); + protected buildScapiJobsClient(): ScapiJobsClient | undefined { + const scapi = this.instance.scapiClientConfig; + if (!scapi) return undefined; + return createScapiJobsClient({shortCode: scapi.shortCode, tenantId: scapi.tenantId}, scapi.auth); + } - if (!execution.is_log_file_existing) { - // No log file, but we may still have an error message + /** + * 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 (!canonical.isLogFileExisting) { if (errorMessage) { this.logger.error({errorMessage}, errorMessage); } @@ -48,22 +75,49 @@ export abstract class JobCommand extends InstanceComma } try { - const log = await getJobLog(this.instance, execution); - const logFileName = execution.log_file_path?.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}`); - // 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); } } } + + 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 isCanonical(execution: JobExecutionInfo | JobExecution): execution is JobExecutionInfo { + return 'executionStatus' in execution; +} + +function getCanonicalJobErrorMessage(execution: JobExecutionInfo): 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/cdn-zones.ts b/packages/b2c-tooling-sdk/src/clients/cdn-zones.ts index fd8593854..fbbf22217 100644 --- a/packages/b2c-tooling-sdk/src/clients/cdn-zones.ts +++ b/packages/b2c-tooling-sdk/src/clients/cdn-zones.ts @@ -15,12 +15,11 @@ */ import createClient, {type Client} from 'openapi-fetch'; import type {AuthStrategy} from '../auth/types.js'; -import {OAuthStrategy} from '../auth/oauth.js'; -import {JwtOAuthStrategy} from '../auth/oauth-jwt.js'; import type {paths, components} from './cdn-zones.generated.js'; import {createAuthMiddleware, createLoggingMiddleware} from './middleware.js'; import {globalMiddlewareRegistry, type MiddlewareRegistry} from './middleware-registry.js'; import {toOrganizationId, normalizeTenantId, buildTenantScope} from './custom-apis.js'; +import {withScopes} from './scapi-backend-utils.js'; /** * Re-export generated types for external use. @@ -221,10 +220,7 @@ export function createCdnZonesClient( const requiredScopes = config.scopes ?? [...domainScopes, buildTenantScope(config.tenantId)]; // If auth supports scopes, add required scopes; otherwise use as-is - const scopedAuth = - auth instanceof OAuthStrategy || auth instanceof JwtOAuthStrategy - ? auth.withAdditionalScopes(requiredScopes) - : auth; + const scopedAuth = withScopes(auth, requiredScopes); // Core middleware: auth first client.use(createAuthMiddleware(scopedAuth)); diff --git a/packages/b2c-tooling-sdk/src/clients/custom-apis.ts b/packages/b2c-tooling-sdk/src/clients/custom-apis.ts index 5c61a5506..1ae28ea94 100644 --- a/packages/b2c-tooling-sdk/src/clients/custom-apis.ts +++ b/packages/b2c-tooling-sdk/src/clients/custom-apis.ts @@ -14,11 +14,10 @@ */ import createClient, {type Client} from 'openapi-fetch'; import type {AuthStrategy} from '../auth/types.js'; -import {OAuthStrategy} from '../auth/oauth.js'; -import {JwtOAuthStrategy} from '../auth/oauth-jwt.js'; import type {paths, components} from './custom-apis.generated.js'; import {createAuthMiddleware, createLoggingMiddleware} from './middleware.js'; import {globalMiddlewareRegistry, type MiddlewareRegistry} from './middleware-registry.js'; +import {withScopes} from './scapi-backend-utils.js'; /** * Re-export generated types for external use. @@ -150,10 +149,7 @@ export function createCustomApisClient(config: CustomApisClientConfig, auth: Aut const requiredScopes = config.scopes ?? [...CUSTOM_APIS_DEFAULT_SCOPES, buildTenantScope(config.tenantId)]; // If auth supports scopes, add required scopes; otherwise use as-is - const scopedAuth = - auth instanceof OAuthStrategy || auth instanceof JwtOAuthStrategy - ? auth.withAdditionalScopes(requiredScopes) - : auth; + const scopedAuth = withScopes(auth, requiredScopes); // Core middleware: auth first client.use(createAuthMiddleware(scopedAuth)); 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..38857503a --- /dev/null +++ b/packages/b2c-tooling-sdk/src/clients/dual-backend-factory.ts @@ -0,0 +1,108 @@ +/* + * 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. + * + * 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; + instance: B2CInstance; +} + +/** + * 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 and falls back to + * OCAPI on safe capability/auth/request rejections. + * + * @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 {instance} = config; + const preference = config.preference ?? instance.apiBackend; + const scapiClientConfig = instance.scapiClientConfig; + const resolved = resolveScapiOrOcapi({ + preference, + hasScapiConfig: scapiClientConfig !== undefined, + domainName: ctors.domainName, + }); + + if (resolved === 'ocapi') { + return new ctors.Ocapi(instance); + } + + const scapiBackend = new ctors.Scapi({ + shortCode: scapiClientConfig!.shortCode, + tenantId: scapiClientConfig!.tenantId, + auth: scapiClientConfig!.auth, + instance, + }); + + if (preference === 'scapi') { + return scapiBackend; + } + + // Auto mode: wrap with fallback + const ocapiBackend = new ctors.Ocapi(instance); + return createFallbackBackend(scapiBackend, ocapiBackend, ctors.domainName.toLowerCase()); +} diff --git a/packages/b2c-tooling-sdk/src/clients/error-utils.ts b/packages/b2c-tooling-sdk/src/clients/error-utils.ts index ed57363e7..55b651719 100644 --- a/packages/b2c-tooling-sdk/src/clients/error-utils.ts +++ b/packages/b2c-tooling-sdk/src/clients/error-utils.ts @@ -9,6 +9,89 @@ * @module clients/error-utils */ +/** + * 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'; + +/** + * 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 = ocapiDeprecatedMessage(); + +/** + * 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 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(options: {cause?: unknown; requiredScopes?: string[]} = {}) { + super( + ocapiDeprecatedMessage(options.requiredScopes), + options.cause === undefined ? undefined : {cause: options.cause}, + ); + this.name = 'OcapiDeprecatedError'; + } +} + /** * Extract a clean error message from an API error response. * @@ -72,3 +155,35 @@ export function getApiErrorMessage(error: unknown, response: Response | {status: // 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, naming `requiredScopes` when the operation + * has a SCAPI equivalent. + * - Everything else throws `Error(`${prefix}: ${message}`)` where `message` + * 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({cause: error, requiredScopes}); + } + throw new Error(`${prefix}: ${getApiErrorMessage(error, response)}`, {cause: error}); +} diff --git a/packages/b2c-tooling-sdk/src/clients/granular-replications.ts b/packages/b2c-tooling-sdk/src/clients/granular-replications.ts index 922b43823..19e27b6ce 100644 --- a/packages/b2c-tooling-sdk/src/clients/granular-replications.ts +++ b/packages/b2c-tooling-sdk/src/clients/granular-replications.ts @@ -17,9 +17,8 @@ import type {AuthStrategy} from '../auth/types.js'; import type {paths, components} from './granular-replications.generated.js'; import {createAuthMiddleware, createLoggingMiddleware, createRateLimitMiddleware} from './middleware.js'; import {globalMiddlewareRegistry, type MiddlewareRegistry} from './middleware-registry.js'; -import {OAuthStrategy} from '../auth/oauth.js'; -import {JwtOAuthStrategy} from '../auth/oauth-jwt.js'; import {buildTenantScope, toOrganizationId, normalizeTenantId} from './custom-apis.js'; +import {withScopes} from './scapi-backend-utils.js'; export {toOrganizationId, normalizeTenantId, buildTenantScope}; @@ -106,10 +105,7 @@ export function createGranularReplicationsClient( // Build required scopes: domain scope + tenant-specific scope const requiredScopes = config.scopes ?? ['sfcc.granular-replications.rw', buildTenantScope(config.tenantId)]; - const scopedAuth = - auth instanceof OAuthStrategy || auth instanceof JwtOAuthStrategy - ? auth.withAdditionalScopes(requiredScopes) - : auth; + const scopedAuth = withScopes(auth, requiredScopes); client.use(createAuthMiddleware(scopedAuth)); diff --git a/packages/b2c-tooling-sdk/src/clients/index.ts b/packages/b2c-tooling-sdk/src/clients/index.ts index 308b03e9b..b7fc25a63 100644 --- a/packages/b2c-tooling-sdk/src/clients/index.ts +++ b/packages/b2c-tooling-sdk/src/clients/index.ts @@ -358,7 +358,116 @@ export type { components as GranularReplicationsComponents, } from './granular-replications.js'; -export {getApiErrorMessage} from './error-utils.js'; +// SCAPI Jobs +export {createScapiJobsClient, SCAPI_JOBS_CASCADE} from './scapi-jobs.js'; +export type { + ScapiJobsClient, + ScapiJobsClientConfig, + ScapiJobsError, + ScapiJobsResponse, + paths as ScapiJobsPaths, + 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, + 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 { + ScapiScriptsClient, + ScapiScriptsClientConfig, + ScapiScriptsError, + ScapiScriptsResponse, + paths as ScapiScriptsPaths, + 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 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 { + 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'; +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'; + +export { + getApiErrorMessage, + isOcapiDeprecatedFault, + throwOcapiError, + OcapiDeprecatedError, + OCAPI_DEPRECATED_MESSAGE, + ocapiDeprecatedMessage, +} 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/clients/metrics.ts b/packages/b2c-tooling-sdk/src/clients/metrics.ts index c6ea3766b..43ea3b8ab 100644 --- a/packages/b2c-tooling-sdk/src/clients/metrics.ts +++ b/packages/b2c-tooling-sdk/src/clients/metrics.ts @@ -16,12 +16,11 @@ */ import createClient, {type Client} from 'openapi-fetch'; import type {AuthStrategy} from '../auth/types.js'; -import {OAuthStrategy} from '../auth/oauth.js'; -import {JwtOAuthStrategy} from '../auth/oauth-jwt.js'; import type {paths, components} from './metrics.generated.js'; import {createAuthMiddleware, createLoggingMiddleware} from './middleware.js'; import {globalMiddlewareRegistry, type MiddlewareRegistry} from './middleware-registry.js'; import {buildTenantScope} from './custom-apis.js'; +import {withScopes} from './scapi-backend-utils.js'; /** * Re-export generated types for external use. @@ -150,10 +149,7 @@ export function createMetricsClient(config: MetricsClientConfig, auth: AuthStrat const requiredScopes = config.scopes ?? [...METRICS_DEFAULT_SCOPES, buildTenantScope(config.tenantId)]; // If auth supports scopes, add required scopes; otherwise use as-is - const scopedAuth = - auth instanceof OAuthStrategy || auth instanceof JwtOAuthStrategy - ? auth.withAdditionalScopes(requiredScopes) - : auth; + const scopedAuth = withScopes(auth, requiredScopes); // Core middleware: auth first client.use(createAuthMiddleware(scopedAuth)); diff --git a/packages/b2c-tooling-sdk/src/clients/middleware-registry.ts b/packages/b2c-tooling-sdk/src/clients/middleware-registry.ts index f9e0247a7..0f039a667 100644 --- a/packages/b2c-tooling-sdk/src/clients/middleware-registry.ts +++ b/packages/b2c-tooling-sdk/src/clients/middleware-registry.ts @@ -61,7 +61,13 @@ export type HttpClientType = | 'am-users-api' | 'am-roles-api' | 'am-apiclients-api' - | 'am-orgs-api'; + | 'am-orgs-api' + | 'scapi-jobs' + | 'scapi-scripts' + | 'scapi-merchant-users' + | 'scapi-merchant-roles' + | 'scapi-sites' + | 'scapi-catalogs'; /** * Middleware interface compatible with openapi-fetch. diff --git a/packages/b2c-tooling-sdk/src/clients/middleware.ts b/packages/b2c-tooling-sdk/src/clients/middleware.ts index 32839f741..8b46843c0 100644 --- a/packages/b2c-tooling-sdk/src/clients/middleware.ts +++ b/packages/b2c-tooling-sdk/src/clients/middleware.ts @@ -45,6 +45,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. * @@ -137,6 +142,119 @@ 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) { + requestScopeModes.set(request, mode); + 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 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, + body: requestBodies.get(request) ?? undefined, + ...(requestBodies.get(request) ? {duplex: 'half'} : {}), + } as RequestInit); + + if (auth.getAccessTokenForCascade) { + 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()); + } + + 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/preferences.ts b/packages/b2c-tooling-sdk/src/clients/preferences.ts index 27945b1bb..ce9224b05 100644 --- a/packages/b2c-tooling-sdk/src/clients/preferences.ts +++ b/packages/b2c-tooling-sdk/src/clients/preferences.ts @@ -16,12 +16,11 @@ */ import createClient, {type Client} from 'openapi-fetch'; import type {AuthStrategy} from '../auth/types.js'; -import {OAuthStrategy} from '../auth/oauth.js'; -import {JwtOAuthStrategy} from '../auth/oauth-jwt.js'; import type {paths, components} from './preferences.generated.js'; import {createAuthMiddleware, createLoggingMiddleware} from './middleware.js'; import {globalMiddlewareRegistry, type MiddlewareRegistry} from './middleware-registry.js'; import {toOrganizationId, normalizeTenantId, buildTenantScope} from './custom-apis.js'; +import {withScopes} from './scapi-backend-utils.js'; export type {paths, components}; export {toOrganizationId, normalizeTenantId, buildTenantScope}; @@ -110,10 +109,7 @@ export function createPreferencesClient( const domainScopes = options?.readWrite ? PREFERENCES_RW_SCOPES : PREFERENCES_READ_SCOPES; const requiredScopes = config.scopes ?? [...domainScopes, buildTenantScope(config.tenantId)]; - const scopedAuth = - auth instanceof OAuthStrategy || auth instanceof JwtOAuthStrategy - ? auth.withAdditionalScopes(requiredScopes) - : auth; + const scopedAuth = withScopes(auth, requiredScopes); client.use(createAuthMiddleware(scopedAuth)); 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..d33261636 --- /dev/null +++ b/packages/b2c-tooling-sdk/src/clients/scapi-backend-utils.ts @@ -0,0 +1,238 @@ +/* + * 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 + */ +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, with temporary safe OCAPI fallback. + */ +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 + * `name` to know which backend served the last call. + */ +export interface BackendBase { + readonly name: 'ocapi' | 'scapi'; +} + +/** Error raised when browser-based Account Manager user auth is passed to a SCAPI Admin client. */ +export class ScapiUserAuthUnsupportedError extends Error { + constructor() { + super( + `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'; + } +} + +/** Reject browser user-auth strategies before a SCAPI request is attempted. */ +export function assertScapiAdminAuthSupported(auth: AuthStrategy): void { + if ('authMethod' in auth && (auth.authMethod === 'user' || auth.authMethod === 'implicit')) { + throw new ScapiUserAuthUnsupportedError(); + } +} + +/** + * 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 { + assertScapiAdminAuthSupported(auth); + if (typeof auth.withAdditionalScopes === 'function') { + return auth.withAdditionalScopes(additionalScopes); + } + return auth; +} + +/** + * 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'); +} + +/** + * 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'; + } +} + +/** 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. + * + * 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 || + (error instanceof ScapiRequestError && SAFE_SCAPI_FALLBACK_STATUSES.has(error.status)) + ); +} + +/** + * 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; +} + +/** + * 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 can be browser user auth, which + * SCAPI Admin APIs do not currently support, or a fixed-scope stored token. + * 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. ` + + `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.` + ); +} + +/** + * 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. 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; + + if (preference === 'ocapi') return 'ocapi'; + + if (preference === 'scapi') { + if (!hasScapiConfig) { + throw new Error(scapiUnavailableMessage(domainName)); + } + return 'scapi'; + } + + // auto + return hasScapiConfig ? 'scapi' : 'ocapi'; +} 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-client-factory.ts b/packages/b2c-tooling-sdk/src/clients/scapi-client-factory.ts new file mode 100644 index 000000000..593383096 --- /dev/null +++ b/packages/b2c-tooling-sdk/src/clients/scapi-client-factory.ts @@ -0,0 +1,143 @@ +/* + * 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, + 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'; + +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; + /** + * 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. + */ + 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. + */ + 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; + + 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}`, + }); + + 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); + } + + 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 new file mode 100644 index 000000000..3cfb6203e --- /dev/null +++ b/packages/b2c-tooling-sdk/src/clients/scapi-fallback-backend.ts @@ -0,0 +1,153 @@ +/* + * 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. + * + * 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 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 + * 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 {isFallbackTrigger, type BackendBase} from './scapi-backend-utils.js'; + +/** + * 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 a recognized + * fallback trigger ({@link isFallbackTrigger}). + * + * Standalone helper so the Proxy traps and any future direct callers share + * one definition. + */ +async function withFallback( + state: FallbackState, + fn: (backend: T) => Promise, +): Promise { + // 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(target); + if (!state.resolved) state.resolved = state.scapi; + return result; + } catch (error) { + if (isFallbackTrigger(error)) { + getLogger().info(`SCAPI ${state.domainName} unavailable for this operation, 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 a safe fallback trigger 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'` + * @returns A Proxy over `scapi` whose methods route through fallback logic + * + * @example + * ```ts + * const backend = createFallbackBackend(scapiJobs, ocapiJobs, 'jobs'); + * await backend.executeJob('my-job'); // tries SCAPI, may fall back to OCAPI + * ``` + */ +export function createFallbackBackend(scapi: T, ocapi: T, domainName: string): T { + const state: FallbackState = {scapi, ocapi, domainName}; + + 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; + } + + const value = Reflect.get(target, prop, receiver); + + // 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; + } + + // 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. + // + // 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]; + 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.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..735840e5a --- /dev/null +++ b/packages/b2c-tooling-sdk/src/clients/scapi-jobs.ts @@ -0,0 +1,53 @@ +/* + * 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-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}; + +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']; + +/** + * 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; + +export function createScapiJobsClient(config: ScapiJobsClientConfig, auth: AuthStrategy): ScapiJobsClient { + return buildScapiClient( + { + pathSegment: 'operation/jobs/v1', + domainKey: 'scapi-jobs', + scopeCascade: SCAPI_JOBS_CASCADE, + logPrefix: 'SCAPI-JOBS', + }, + config, + auth, + ); +} 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..dac7b033e --- /dev/null +++ b/packages/b2c-tooling-sdk/src/clients/scapi-merchant-roles.ts @@ -0,0 +1,39 @@ +/* + * 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-merchant-roles.generated.js'; +import {buildScapiClient, type ScapiClientConfig} from './scapi-client-factory.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 type ScapiMerchantRolesClientConfig = ScapiClientConfig; + +export function createScapiMerchantRolesClient( + config: ScapiMerchantRolesClientConfig, + auth: AuthStrategy, +): ScapiMerchantRolesClient { + 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.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..4b2f2889e --- /dev/null +++ b/packages/b2c-tooling-sdk/src/clients/scapi-merchant-users.ts @@ -0,0 +1,39 @@ +/* + * 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-merchant-users.generated.js'; +import {buildScapiClient, type ScapiClientConfig} from './scapi-client-factory.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 type ScapiMerchantUsersClientConfig = ScapiClientConfig; + +export function createScapiMerchantUsersClient( + config: ScapiMerchantUsersClientConfig, + auth: AuthStrategy, +): ScapiMerchantUsersClient { + 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-schemas.ts b/packages/b2c-tooling-sdk/src/clients/scapi-schemas.ts index 9f7b1e50c..f51bd9e7d 100644 --- a/packages/b2c-tooling-sdk/src/clients/scapi-schemas.ts +++ b/packages/b2c-tooling-sdk/src/clients/scapi-schemas.ts @@ -14,12 +14,11 @@ */ import createClient, {type Client} from 'openapi-fetch'; import type {AuthStrategy} from '../auth/types.js'; -import {OAuthStrategy} from '../auth/oauth.js'; -import {JwtOAuthStrategy} from '../auth/oauth-jwt.js'; import type {paths, components} from './scapi-schemas.generated.js'; import {createAuthMiddleware, createLoggingMiddleware} from './middleware.js'; import {globalMiddlewareRegistry, type MiddlewareRegistry} from './middleware-registry.js'; import {toOrganizationId, normalizeTenantId, buildTenantScope} from './custom-apis.js'; +import {withScopes} from './scapi-backend-utils.js'; /** * Re-export generated types for external use. @@ -187,10 +186,7 @@ export function createScapiSchemasClient(config: ScapiSchemasClientConfig, auth: const requiredScopes = config.scopes ?? [...SCAPI_SCHEMAS_DEFAULT_SCOPES, buildTenantScope(config.tenantId)]; // If auth supports scopes, add required scopes; otherwise use as-is - const scopedAuth = - auth instanceof OAuthStrategy || auth instanceof JwtOAuthStrategy - ? auth.withAdditionalScopes(requiredScopes) - : auth; + const scopedAuth = withScopes(auth, requiredScopes); // Core middleware: auth first client.use(createAuthMiddleware(scopedAuth)); 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..2d60d95d1 --- /dev/null +++ b/packages/b2c-tooling-sdk/src/clients/scapi-scope-tier.ts @@ -0,0 +1,131 @@ +/* + * 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 + */ + +import {isInvalidScopeError, ScapiCapabilityUnsupportedError} from './scapi-backend-utils.js'; + +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. + * + * 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 ScapiCapabilityUnsupportedError( + `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); + } + + /** + * 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/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..a5bdd9a0e --- /dev/null +++ b/packages/b2c-tooling-sdk/src/clients/scapi-scripts.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-scripts.generated.js'; +import {buildScapiClient, type ScapiClientConfig} from './scapi-client-factory.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 type ScapiScriptsClientConfig = ScapiClientConfig; + +export function createScapiScriptsClient(config: ScapiScriptsClientConfig, auth: AuthStrategy): ScapiScriptsClient { + 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/clients/scapi-sites.generated.ts b/packages/b2c-tooling-sdk/src/clients/scapi-sites.generated.ts new file mode 100644 index 000000000..3d993b023 --- /dev/null +++ b/packages/b2c-tooling-sdk/src/clients/scapi-sites.generated.ts @@ -0,0 +1,500 @@ +/** + * 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; + }; + "/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 { + 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; + readonly cartridges?: string; + customCartridges?: 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"]; + SiteCustomCartridges: { + customCartridges: string; + }; + }; + 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"]; + }; + }; + }; + }; + 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 new file mode 100644 index 000000000..3d7892fc2 --- /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']; +export type SiteCustomCartridges = components['schemas']['SiteCustomCartridges']; + +/** + * Per-operation scope cascade for SCAPI Sites. + * + * 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. Write + * operations use the rw tier exclusively. + */ +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/compat/dispatcher.ts b/packages/b2c-tooling-sdk/src/compat/dispatcher.ts new file mode 100644 index 000000000..41b67d0a4 --- /dev/null +++ b/packages/b2c-tooling-sdk/src/compat/dispatcher.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 + */ +/** + * 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 {isFallbackTrigger, scapiUnavailableMessage, 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 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 + * 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(scapiUnavailableMessage(domainName)); + } + 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 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(); + 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 (isFallbackTrigger(error)) { + getLogger().info(`SCAPI ${this.domainName} unavailable for this operation, 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..458a1f803 --- /dev/null +++ b/packages/b2c-tooling-sdk/src/compat/index.ts @@ -0,0 +1,15 @@ +/* + * 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'; +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/config/dw-json.ts b/packages/b2c-tooling-sdk/src/config/dw-json.ts index e6a53a2e0..77068e50a 100644 --- a/packages/b2c-tooling-sdk/src/config/dw-json.ts +++ b/packages/b2c-tooling-sdk/src/config/dw-json.ts @@ -112,6 +112,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'; /** Path to JWT certificate file (cert.pem) for JWT authentication */ jwtCertPath?: string; /** Path to JWT private key file (key.pem) for JWT authentication */ diff --git a/packages/b2c-tooling-sdk/src/config/index.ts b/packages/b2c-tooling-sdk/src/config/index.ts index 7c1560363..af73ba93f 100644 --- a/packages/b2c-tooling-sdk/src/config/index.ts +++ b/packages/b2c-tooling-sdk/src/config/index.ts @@ -112,6 +112,7 @@ export type { ResolveConfigOptions, ResolvedB2CConfig, CreateOAuthOptions, + CreateB2CInstanceOptions, InstanceInfo, CreateInstanceOptions, } from './types.js'; diff --git a/packages/b2c-tooling-sdk/src/config/mapping.ts b/packages/b2c-tooling-sdk/src/config/mapping.ts index 99b027c91..a3c0a9a63 100644 --- a/packages/b2c-tooling-sdk/src/config/mapping.ts +++ b/packages/b2c-tooling-sdk/src/config/mapping.ts @@ -17,7 +17,7 @@ import {parseSafetyLevelString} from '../safety/safety-middleware.js'; import {isValidSafetyAction} from '../safety/types.js'; import type {SafetyRule} from '../safety/types.js'; import type {DwJsonConfig} from './dw-json.js'; -import type {LibraryEntry, NormalizedConfig, ConfigWarning} from './types.js'; +import type {CreateB2CInstanceOptions, LibraryEntry, NormalizedConfig, ConfigWarning} from './types.js'; /** * Normalizes a URL origin string by ensuring it has an `https://` protocol prefix. @@ -83,6 +83,7 @@ export const CONFIG_KEY_ALIASES: Record = { 'oauth-scopes': 'oauthScopes', 'auth-methods': 'authMethods', 'cip-host': 'cipHost', + 'api-backend': 'apiBackend', }; /** @@ -198,6 +199,8 @@ export function mapDwJsonToNormalizedConfig(json: DwJsonConfig): NormalizedConfi certificate: json.certificate, certificatePassphrase: json.certificatePassphrase, selfSigned: json.selfSigned, + // API backend + apiBackend: json.apiBackend, // JWT Bearer auth options jwtCertPath: json.jwtCertPath, jwtKeyPath: json.jwtKeyPath, @@ -343,6 +346,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.jwtCertPath !== undefined) { result.jwtCertPath = config.jwtCertPath; } @@ -557,6 +563,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, // JWT Bearer auth options jwtCertPath: overrides.jwtCertPath ?? base.jwtCertPath, jwtKeyPath: overrides.jwtKeyPath ?? base.jwtKeyPath, @@ -636,6 +644,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, }; } @@ -662,10 +673,7 @@ export function buildAuthConfigFromNormalized(config: NormalizedConfig): AuthCon * await instance.webdav.mkcol('Cartridges/v1'); * ``` */ -export function createInstanceFromConfig( - config: NormalizedConfig, - options?: {redirectUri?: string; openBrowser?: (url: string) => Promise}, -): B2CInstance { +export function createInstanceFromConfig(config: NormalizedConfig, options?: CreateB2CInstanceOptions): B2CInstance { if (!config.hostname) { throw new Error('Hostname is required. Set in dw.json or provide via overrides.'); } @@ -674,6 +682,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 @@ -696,5 +709,5 @@ export function createInstanceFromConfig( }; } - return new B2CInstance(instanceConfig, authConfig); + return new B2CInstance(instanceConfig, authConfig, {oauthStrategy: options?.oauthStrategy}); } diff --git a/packages/b2c-tooling-sdk/src/config/resolved-config.ts b/packages/b2c-tooling-sdk/src/config/resolved-config.ts index 44905ebf7..838fd4e6d 100644 --- a/packages/b2c-tooling-sdk/src/config/resolved-config.ts +++ b/packages/b2c-tooling-sdk/src/config/resolved-config.ts @@ -20,6 +20,7 @@ import type { ConfigSourceInfo, ResolvedB2CConfig, CreateOAuthOptions, + CreateB2CInstanceOptions, } from './types.js'; /** @@ -55,7 +56,7 @@ export class ResolvedConfigImpl implements ResolvedB2CConfig { // Factory methods - createB2CInstance(options?: Pick): B2CInstance { + createB2CInstance(options?: CreateB2CInstanceOptions): B2CInstance { if (!this.hasB2CInstanceConfig()) { throw new Error('B2C instance requires hostname'); } 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 3a2fe6997..d5a06c5ed 100644 --- a/packages/b2c-tooling-sdk/src/config/sources/env-source.ts +++ b/packages/b2c-tooling-sdk/src/config/sources/env-source.ts @@ -46,6 +46,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', @@ -75,6 +76,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. * @@ -117,6 +127,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/config/types.ts b/packages/b2c-tooling-sdk/src/config/types.ts index dae67f974..b5fddeb98 100644 --- a/packages/b2c-tooling-sdk/src/config/types.ts +++ b/packages/b2c-tooling-sdk/src/config/types.ts @@ -180,6 +180,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?: { @@ -422,6 +426,16 @@ export interface CreateOAuthOptions { openBrowser?: (url: string) => Promise; } +/** Options for constructing a B2C instance from resolved configuration. */ +export interface CreateB2CInstanceOptions extends Pick { + /** + * Pre-resolved OAuth strategy, or a lazy factory for one. CLI command bases + * use the factory form to preserve stored PKCE sessions and avoid prompting + * unless an OAuth-backed client is actually used. + */ + oauthStrategy?: AuthStrategy | (() => AuthStrategy); +} + /** * Information about a configured instance. */ @@ -517,10 +531,10 @@ export interface ResolvedB2CConfig { /** * Creates a B2CInstance from the resolved configuration. - * @param options - Options for implicit OAuth (redirectUri, openBrowser) + * @param options - OAuth runtime options and optional pre-resolved strategy * @throws Error if hostname is not configured */ - createB2CInstance(options?: Pick): B2CInstance; + createB2CInstance(options?: CreateB2CInstanceOptions): B2CInstance; /** * Creates a Basic auth strategy. diff --git a/packages/b2c-tooling-sdk/src/index.ts b/packages/b2c-tooling-sdk/src/index.ts index 73681e96e..15fde5333 100644 --- a/packages/b2c-tooling-sdk/src/index.ts +++ b/packages/b2c-tooling-sdk/src/index.ts @@ -24,6 +24,7 @@ export type { ConfigSource, ResolveConfigOptions, CreateOAuthOptions, + CreateB2CInstanceOptions, } from './config/index.js'; // Auth Layer - Strategies and Resolution @@ -59,7 +60,7 @@ export type { // Context Layer - Instance export {B2CInstance} from './instance/index.js'; -export type {InstanceConfig} from './instance/index.js'; +export type {B2CInstanceOptions, InstanceConfig, ScapiClientConfig} from './instance/index.js'; // Clients export { @@ -84,6 +85,11 @@ export { normalizeTenantId, buildTenantScope, getApiErrorMessage, + isOcapiDeprecatedFault, + throwOcapiError, + OcapiDeprecatedError, + OCAPI_DEPRECATED_MESSAGE, + ocapiDeprecatedMessage, isValidRoleTenantFilter, fetchRoleMapping, resolveToInternalRole, @@ -235,6 +241,55 @@ export type { WatchResult, } from './operations/code/index.js'; +// Scripts (code versions) backend abstraction +export {createScriptsBackend, OcapiScriptsBackend, ScapiScriptsBackend} from './operations/code/index.js'; +export type { + ScriptsBackend, + ScriptsBackendConfig, + CodeVersionInfo, + ScapiScriptsBackendConfig, +} from './operations/code/index.js'; + +// Users (BM) backend abstraction +export {createUsersBackend, OcapiUsersBackend, ScapiUsersBackend} from './operations/bm-users/index.js'; +export type { + UsersBackend, + UsersBackendConfig, + UserInfo, + ListUsersResult, + ListUsersOptions, + SearchUsersOptions, + CreateUserInput, + UpdateUserChanges, + ScapiUsersBackendConfig, +} from './operations/bm-users/index.js'; + +// Roles (BM) backend abstraction +export {createRolesBackend, 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'; + +// 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, @@ -248,6 +303,14 @@ export { siteArchiveImport, siteArchiveExport, siteArchiveExportToPath, + // Canonical surface (SCAPI free functions + canonical helpers) + scapiExecuteJob, + scapiGetJobExecution, + scapiSearchJobExecutions, + scapiDeleteJobExecution, + scapiGetJobLog, + waitForJobExecution, + CanonicalJobExecutionError, } from './operations/jobs/index.js'; export type { JobExecution, @@ -259,6 +322,12 @@ export type { WaitForJobPollInfo, SearchJobExecutionsOptions, JobExecutionSearchResult, + // Canonical types + JobExecutionInfo, + JobStepExecutionResult, + JobExecutionSearchResults, + ExecuteJobScapiOptions, + SearchJobExecutionsScapiOptions, SiteArchiveImportOptions, SiteArchiveImportResult, SiteArchiveExportOptions, diff --git a/packages/b2c-tooling-sdk/src/instance/index.ts b/packages/b2c-tooling-sdk/src/instance/index.ts index f805d2e3a..2bac2a51b 100644 --- a/packages/b2c-tooling-sdk/src/instance/index.ts +++ b/packages/b2c-tooling-sdk/src/instance/index.ts @@ -44,10 +44,41 @@ */ 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; +} + +/** + * Optional runtime dependencies for a {@link B2CInstance}. + * + * The OAuth strategy factory lets CLI commands reuse their session-aware auth + * resolution (including PKCE refresh and the temporary implicit fallback) + * without eagerly starting browser authentication for Basic-only WebDAV work. + */ +export interface B2CInstanceOptions { + oauthStrategy?: AuthStrategy | (() => AuthStrategy); +} /** * Instance configuration (hostname, code version, etc.) @@ -61,6 +92,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'; } /** @@ -88,16 +135,19 @@ export interface InstanceConfig { export class B2CInstance { private _webdav?: WebDavClient; private _ocapi?: OcapiClient; + private _oauthStrategy?: AuthStrategy; /** * Creates a new B2CInstance. * * @param config - Instance configuration (hostname, code version) * @param auth - Authentication configuration + * @param options - Optional runtime dependencies, including a pre-resolved OAuth strategy */ constructor( public readonly config: InstanceConfig, public readonly auth: AuthConfig, + private readonly options: B2CInstanceOptions = {}, ) {} /** @@ -108,6 +158,53 @@ 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). + * + * Browser user-auth flows (Authorization Code + PKCE and deprecated + * implicit) are excluded on purpose because SCAPI Admin APIs currently only + * support system authentication. Fixed-token stored sessions are also + * excluded because 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; + if (!shortCode || !tenantId) { + return undefined; + } + + const auth = this.buildScapiAuthStrategy(); + if (!auth) { + return undefined; + } + + return {shortCode, tenantId, auth}; + } + /** * WebDAV client for file operations. * @@ -178,6 +275,16 @@ export class B2CInstance { * @throws Error if no valid OAuth method is available */ private getOAuthStrategy(): AuthStrategy { + if (this._oauthStrategy) { + return this._oauthStrategy; + } + + if (this.options.oauthStrategy) { + this._oauthStrategy = + typeof this.options.oauthStrategy === 'function' ? this.options.oauthStrategy() : this.options.oauthStrategy; + return this._oauthStrategy; + } + if (!this.auth.oauth) { throw new Error('OAuth credentials required. Provide at least clientId.'); } @@ -192,20 +299,100 @@ 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.'); } - return resolveAuthStrategy(credentials, {allowedMethods: oauthMethods}); + 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(', ')}].`); + } + + /** + * 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 { + if (!this.auth.oauth) { + return undefined; + } + + const methods = this.auth.authMethods ?? (['client-credentials', 'jwt'] as AuthMethod[]); + + for (const method of methods) { + if (method === 'client-credentials' || method === 'jwt') { + const strategy = this.buildSystemOAuthStrategy(method); + if (strategy) return strategy; + } + } + + 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; } } // Re-export types for convenience export type {AuthConfig}; export type {TlsOptions}; +export type {AuthStrategy}; 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..ac38b29dc --- /dev/null +++ b/packages/b2c-tooling-sdk/src/operations/bm-roles/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 type {RolesBackend} from './types.js'; +import {OcapiRolesBackend} from './ocapi-backend.js'; +import {ScapiRolesBackend} from './scapi-backend.js'; +import {createDualBackend, type DualBackendConfig} from '../../clients/dual-backend-factory.js'; + +export type RolesBackendConfig = DualBackendConfig; + +export function createRolesBackend(config: RolesBackendConfig): RolesBackend { + return createDualBackend(config, { + domainName: 'Roles', + Scapi: ScapiRolesBackend, + Ocapi: OcapiRolesBackend, + }); +} 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..56b40fd33 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} 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..16792f4c5 --- /dev/null +++ b/packages/b2c-tooling-sdk/src/operations/bm-roles/ocapi-backend.ts @@ -0,0 +1,199 @@ +/* + * 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 {components as OcapiComponents} from '../../clients/ocapi.generated.js'; +import type {components as ScapiComponents} from '../../clients/scapi-merchant-roles.generated.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 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, + }; +} + +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.) + // while SCAPI uses camelCase (localeId). Convert at this boundary. + const result: Record = {}; + if (ocapi.module) { + result.module = { + organization: (ocapi.module.organization ?? []).map(mapOcapiModulePermission), + site: (ocapi.module.site ?? []).map(mapOcapiModulePermission), + }; + } + if (ocapi.functional) { + result.functional = { + organization: (ocapi.functional.organization ?? []).map(mapOcapiFunctionalPermission), + site: (ocapi.functional.site ?? []).map(mapOcapiFunctionalPermission), + }; + } + if (ocapi.locale) { + result.locale = { + unscoped: (ocapi.locale.unscoped ?? []).map(mapOcapiLocalePermission), + }; + } + if (ocapi.webdav) { + result.webdav = { + unscoped: (ocapi.webdav.unscoped ?? []).map(mapOcapiWebdavPermission), + }; + } + 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 = { + organization: (perms.module.organization ?? []).map((permission) => ({...permission})), + site: (perms.module.site ?? []).map((permission) => ({...permission})), + }; + } + if (perms.functional) { + result.functional = { + organization: (perms.functional.organization ?? []).map((permission) => ({...permission})), + site: (perms.functional.site ?? []).map((permission) => ({...permission})), + }; + } + if (perms.locale) { + result.locale = { + unscoped: (perms.locale.unscoped ?? []).map((permission) => ({ + locale_id: permission.localeId, + type: permission.type, + value: permission.value, + values: permission.values, + })), + }; + } + if (perms.webdav) { + result.webdav = { + unscoped: (perms.webdav.unscoped ?? []).map((permission) => ({...permission})), + }; + } + 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/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-roles/scapi-backend.ts b/packages/b2c-tooling-sdk/src/operations/bm-roles/scapi-backend.ts new file mode 100644 index 000000000..9d5016a8b --- /dev/null +++ b/packages/b2c-tooling-sdk/src/operations/bm-roles/scapi-backend.ts @@ -0,0 +1,179 @@ +/* + * 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'; +import {createScapiRequestError} from '../../clients/scapi-backend-utils.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; + /** Unused by Roles; accepted for compatibility with the dual-backend factory. */ + instance?: unknown; +} + +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 {start = 0, count = 25, expand} = options; + + return this.scopeTier.tryRead(async (client) => { + 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 createScapiRequestError(error, response, '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 { + return this.scopeTier.tryRead(async (client) => { + 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 createScapiRequestError(error, response, `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, response} = await client.PUT('/organizations/{organizationId}/roles/{roleId}', { + params: {path: {organizationId: this.organizationId, roleId}}, + body, + }); + if (error || !data) { + throw createScapiRequestError(error, response, `Failed to create role ${roleId}`); + } + return mapScapiRole(data); + } + + async deleteRole(roleId: string): Promise { + const client = this.scopeTier.getClientForWrite(); + const {error, response} = await client.DELETE('/organizations/{organizationId}/roles/{roleId}', { + params: {path: {organizationId: this.organizationId, roleId}}, + }); + if (error) { + throw createScapiRequestError(error, response, `Failed to delete role ${roleId}`); + } + } + + async getPermissions(roleId: string): Promise { + return this.scopeTier.tryRead(async (client) => { + const {data, error, response} = await client.GET('/organizations/{organizationId}/roles/{roleId}/permissions', { + params: {path: {organizationId: this.organizationId, roleId}}, + }); + if (error || !data) { + throw createScapiRequestError(error, response, `Failed to get permissions for role ${roleId}`); + } + return data; + }); + } + + async setPermissions(roleId: string, permissions: RolePermissionsInfo): Promise { + const client = this.scopeTier.getClientForWrite(); + const {data, error, response} = await client.PUT('/organizations/{organizationId}/roles/{roleId}/permissions', { + params: {path: {organizationId: this.organizationId, roleId}}, + body: permissions, + }); + if (error || !data) { + 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, response} = await client.PUT('/organizations/{organizationId}/roles/{roleId}/users/{login}', { + params: {path: {organizationId: this.organizationId, roleId, login}}, + }); + if (error) { + 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, response} = await client.DELETE('/organizations/{organizationId}/roles/{roleId}/users/{login}', { + params: {path: {organizationId: this.organizationId, roleId, login}}, + }); + if (error) { + throw createScapiRequestError(error, response, `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); + } +} 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; +} 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..863bd6184 --- /dev/null +++ b/packages/b2c-tooling-sdk/src/operations/bm-users/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 type {UsersBackend} from './types.js'; +import {OcapiUsersBackend} from './ocapi-backend.js'; +import {ScapiUsersBackend} from './scapi-backend.js'; +import {createDualBackend, type DualBackendConfig} from '../../clients/dual-backend-factory.js'; + +export type UsersBackendConfig = DualBackendConfig; + +export function createUsersBackend(config: UsersBackendConfig): UsersBackend { + return createDualBackend(config, { + domainName: 'Users', + Scapi: ScapiUsersBackend, + Ocapi: OcapiUsersBackend, + }); +} 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..48aca4beb 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 * @@ -75,3 +77,19 @@ export type { SearchBmUsersOptions, UpdateBmUserChanges, } from './users.js'; + +// Users backend abstraction — supports OCAPI + SCAPI +export {createUsersBackend} 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, + 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 new file mode 100644 index 000000000..77ba63ce0 --- /dev/null +++ b/packages/b2c-tooling-sdk/src/operations/bm-users/ocapi-backend.ts @@ -0,0 +1,120 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +import type {B2CInstance} from '../../instance/index.js'; +import type { + UsersBackend, + UserInfo, + ListUsersResult, + ListUsersOptions, + UpdateUserChanges, + CreateUserInput, + SearchUsersOptions, +} from './types.js'; +import { + listBmUsers as ocapiListBmUsers, + getBmUser as ocapiGetBmUser, + 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'; +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 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 = { + 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) { + throwOcapiError(error, response, `Failed to create user ${login}`, SCAPI_MERCHANT_USERS_RW_SCOPES); + } + 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..5bebe0b4b --- /dev/null +++ b/packages/b2c-tooling-sdk/src/operations/bm-users/scapi-backend.ts @@ -0,0 +1,251 @@ +/* + * 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, + SearchUsersOptions, +} 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 { + createScapiRequestError, + ScapiCapabilityUnsupportedError, + scapiCapabilityUnsupportedMessage, +} from '../../clients/scapi-backend-utils.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; + /** Unused by Users; accepted for compatibility with the dual-backend factory. */ + instance?: unknown; +} + +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 {start = 0, count = 25} = options; + + return this.scopeTier.tryRead(async (client) => { + const {data, error, response} = await client.GET('/organizations/{organizationId}/users', { + params: { + path: {organizationId: this.organizationId}, + query: {limit: count, offset: start}, + }, + }); + if (error || !data) { + throw createScapiRequestError(error, response, '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 searchUsers(options: SearchUsersOptions = {}): Promise { + if (options.query !== undefined) { + throw new ScapiCapabilityUnsupportedError( + `${scapiCapabilityUnsupportedMessage('raw OCAPI user-search JSON')} Use portable search flags to stay on SCAPI.`, + ); + } + + 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, response} = await client.GET('/organizations/{organizationId}/users/{login}', { + params: {path: {organizationId: this.organizationId, login}}, + }); + if (error || !data) { + throw createScapiRequestError(error, response, `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, response} = await client.PUT('/organizations/{organizationId}/users/{login}', { + params: {path: {organizationId: this.organizationId, login}}, + body, + }); + if (error || !data) { + 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(); + const body: UserUpdateRequest = { + email: changes.email, + firstName: changes.firstName, + lastName: changes.lastName, + externalId: changes.externalId, + preferredDataLocale: changes.preferredDataLocale, + preferredUiLocale: changes.preferredUiLocale, + }; + const {data, error, response} = await client.PATCH('/organizations/{organizationId}/users/{login}', { + params: {path: {organizationId: this.organizationId, login}}, + body, + }); + if (error || !data) { + throw createScapiRequestError(error, response, `Failed to update user ${login}`); + } + return mapScapiUser(data); + } + + async deleteUser(login: string): Promise { + const client = this.scopeTier.getClientForWrite(); + const {error, response} = await client.DELETE('/organizations/{organizationId}/users/{login}', { + params: {path: {organizationId: this.organizationId, login}}, + }); + if (error) { + throw createScapiRequestError(error, response, `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 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 new file mode 100644 index 000000000..b0fd2bf2f --- /dev/null +++ b/packages/b2c-tooling-sdk/src/operations/bm-users/types.ts @@ -0,0 +1,111 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +/** + * 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; +} + +/** 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. + */ +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. + * + * 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; + deleteUser(login: string): 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 2e7a0ccf7..b75a33de1 100644 --- a/packages/b2c-tooling-sdk/src/operations/bm-users/users.ts +++ b/packages/b2c-tooling-sdk/src/operations/bm-users/users.ts @@ -9,13 +9,22 @@ * 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'; -import {getApiErrorMessage} from '../../clients/error-utils.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 +// 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; /** * BM user from OCAPI. @@ -124,7 +133,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 +152,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) { - 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 +197,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 +215,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 +295,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; @@ -304,14 +314,13 @@ 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}}, }); 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; @@ -334,14 +343,13 @@ 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}}, }); 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; @@ -362,15 +370,14 @@ 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'], }); 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; @@ -384,13 +391,12 @@ 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}}, }); 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 6ae69f77e..9b2980dc6 100644 --- a/packages/b2c-tooling-sdk/src/operations/cap/install.ts +++ b/packages/b2c-tooling-sdk/src/operations/cap/install.ts @@ -13,7 +13,8 @@ import * as path from 'node:path'; import JSZip from 'jszip'; import {B2CInstance} from '../../instance/index.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'; @@ -112,61 +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) { - throw new Error(retryError?.fault?.message ?? 'Failed to start install job'); - } - - execution = retryData; - } else if (error || !data) { - throw new Error(error?.fault?.message ?? 'Failed to start install job'); - } 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/list.ts b/packages/b2c-tooling-sdk/src/operations/cap/list.ts index a3c9c5db3..57fe0ed3f 100644 --- a/packages/b2c-tooling-sdk/src/operations/cap/list.ts +++ b/packages/b2c-tooling-sdk/src/operations/cap/list.ts @@ -15,6 +15,7 @@ import JSZip from 'jszip'; import * as xml2js from 'xml2js'; import {B2CInstance} from '../../instance/index.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'; @@ -163,14 +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) { - throw new Error(error?.fault?.message ?? 'Failed to list sites'); - } - 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/cap/uninstall.ts b/packages/b2c-tooling-sdk/src/operations/cap/uninstall.ts index 6e644adf6..b40a28b32 100644 --- a/packages/b2c-tooling-sdk/src/operations/cap/uninstall.ts +++ b/packages/b2c-tooling-sdk/src/operations/cap/uninstall.ts @@ -10,7 +10,8 @@ */ import {B2CInstance} from '../../instance/index.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'; @@ -67,51 +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) { - throw new Error(retryError?.fault?.message ?? 'Failed to start uninstall job'); - } - - execution = retryData; - } else if (error || !data) { - throw new Error(error?.fault?.message ?? 'Failed to start uninstall job'); - } 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/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 8bc299cab..e824e6b35 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 {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'; @@ -35,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 */ @@ -315,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'); @@ -348,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(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/index.ts b/packages/b2c-tooling-sdk/src/operations/code/index.ts index a3ed5aa00..89c7a2921 100644 --- a/packages/b2c-tooling-sdk/src/operations/code/index.ts +++ b/packages/b2c-tooling-sdk/src/operations/code/index.ts @@ -75,12 +75,19 @@ export { listCodeVersions, getActiveCodeVersion, activateCodeVersion, - reloadCodeVersion, deleteCodeVersion, createCodeVersion, } from './versions.js'; export type {CodeVersion, CodeVersionActivationResult, CodeVersionResult} from './versions.js'; +// Scripts (code versions) backend abstraction — supports OCAPI + SCAPI +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'; +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..920565b39 --- /dev/null +++ b/packages/b2c-tooling-sdk/src/operations/code/ocapi-scripts-backend.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 type {B2CInstance} from '../../instance/index.js'; +import type {ScriptsBackend, CodeVersionInfo, CodeVersionActivationResult} 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, +} 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 { + return ocapiActivateCodeVersion(this.instance, codeVersionId); + } + + async deleteCodeVersion(codeVersionId: string): Promise { + await ocapiDeleteCodeVersion(this.instance, codeVersionId); + } + + async createCodeVersion(codeVersionId: string): Promise { + await ocapiCreateCodeVersion(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..83e82b8c2 --- /dev/null +++ b/packages/b2c-tooling-sdk/src/operations/code/scapi-scripts-backend.ts @@ -0,0 +1,124 @@ +/* + * 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, CodeVersionActivationResult} 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 {createScapiRequestError} from '../../clients/scapi-backend-utils.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; + /** Unused by Scripts; accepted for compatibility with the dual-backend factory. */ + instance?: unknown; +} + +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 { + return this.scopeTier.tryRead(async (client) => { + const {data, error, response} = await client.GET('/organizations/{organizationId}/code-versions', { + params: {path: {organizationId: this.organizationId}}, + }); + if (error || !data) { + throw createScapiRequestError(error, response, '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, 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 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 + // "already active" fault the way OCAPI does, so report a normal activation. + return {alreadyActive: false}; + } + + async deleteCodeVersion(codeVersionId: string): Promise { + const client = this.scopeTier.getClientForWrite(); + const {error, response} = await client.DELETE('/organizations/{organizationId}/code-versions/{codeVersionId}', { + params: {path: {organizationId: this.organizationId, codeVersionId}}, + }); + if (error) { + throw createScapiRequestError(error, response, `Failed to delete code version ${codeVersionId}`); + } + } + + async createCodeVersion(codeVersionId: string): Promise { + const client = this.scopeTier.getClientForWrite(); + const {error, response} = await client.PUT('/organizations/{organizationId}/code-versions/{codeVersionId}', { + params: {path: {organizationId: this.organizationId, codeVersionId}}, + }); + if (error) { + throw createScapiRequestError(error, response, `Failed to create code version ${codeVersionId}`); + } + } + + 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); + } +} 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..09f02b652 --- /dev/null +++ b/packages/b2c-tooling-sdk/src/operations/code/scripts-backend.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 type {ScriptsBackend} from './scripts-types.js'; +import {OcapiScriptsBackend} from './ocapi-scripts-backend.js'; +import {ScapiScriptsBackend} from './scapi-scripts-backend.js'; +import {createDualBackend, type DualBackendConfig} from '../../clients/dual-backend-factory.js'; + +export type ScriptsBackendConfig = DualBackendConfig; + +export function createScriptsBackend(config: ScriptsBackendConfig): ScriptsBackend { + return createDualBackend(config, { + domainName: 'Scripts', + Scapi: ScapiScriptsBackend, + Ocapi: OcapiScriptsBackend, + }); +} + +/** + * 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; + + if (!targetVersion) { + throw new Error('No code version specified and no active version found'); + } + + // 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); + } + + 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 new file mode 100644 index 000000000..652fe3e86 --- /dev/null +++ b/packages/b2c-tooling-sdk/src/operations/code/scripts-types.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 + */ +/** + * 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'; +import type {CodeVersionActivationResult} from './versions.js'; + +export type {CodeVersionActivationResult}; + +/** + * 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. + * + * 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; + getActiveCodeVersion(): Promise; + activateCodeVersion(codeVersionId: string): Promise; + deleteCodeVersion(codeVersionId: string): Promise; + createCodeVersion(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 ed4cfa3b5..5ecf71fdf 100644 --- a/packages/b2c-tooling-sdk/src/operations/code/versions.ts +++ b/packages/b2c-tooling-sdk/src/operations/code/versions.ts @@ -4,7 +4,9 @@ * 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 {getApiErrorMessage, type OcapiComponents} from '../../clients/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 */ @@ -52,10 +54,13 @@ function isAlreadyActiveFault(error: unknown, status: number, codeVersionId: str * ``` */ 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', [ + ...SCAPI_SCRIPTS_READ_SCOPES, + ...SCAPI_SCRIPTS_RW_SCOPES, + ]); } return (data as CodeVersionResult).data ?? []; @@ -109,67 +114,20 @@ export async function activateCodeVersion( }); if (error) { + // Activating the current active version is an idempotent success. if (isAlreadyActiveFault(error, response.status, codeVersionId)) { logger.debug({codeVersionId}, `Code version ${codeVersionId} is already active`); return {alreadyActive: true}; } - throw new Error(`Could not activate code version "${codeVersionId}": ${getApiErrorMessage(error, response)}`, { - cause: error, - }); + // Deprecation-aware: names the SCAPI scope on OCAPI-disabled instances, + // otherwise throws a rich message via getApiErrorMessage. + throwOcapiError(error, response, `Could not activate code version "${codeVersionId}"`, SCAPI_SCRIPTS_RW_SCOPES); } logger.debug({codeVersionId}, `Code version ${codeVersionId} activated`); return {alreadyActive: false}; } -/** - * 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. * @@ -188,12 +146,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', SCAPI_SCRIPTS_RW_SCOPES); } logger.debug({codeVersionId}, `Code version ${codeVersionId} deleted`); @@ -217,12 +175,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', SCAPI_SCRIPTS_RW_SCOPES); } logger.debug({codeVersionId}, `Code version ${codeVersionId} created`); 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 73a2a41eb..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,9 @@ */ 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'; /** * IDs discovered on an instance, grouped by data-unit category. Each list is @@ -34,32 +37,27 @@ export interface ExportableUnits { warnings: string[]; } -/** A discoverable category and the OCAPI path used to list it. */ -const DISCOVERABLE = [ - {key: 'sites', path: '/sites', label: 'sites'}, - {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: '/sites' | '/catalogs' | '/inventory_lists'): Promise { +async function listInventoryListIds(instance: B2CInstance): Promise { + assertOcapiCompatibilityAllowed(instance.apiBackend, 'inventory-list enumeration'); 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 ?? []) { @@ -82,7 +80,7 @@ async function listIds(instance: B2CInstance, path: '/sites' | '/catalogs' | '/i * 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. * @@ -99,17 +97,44 @@ 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: SCAPI product/catalogs with OCAPI fallback. + (async () => { + try { + 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({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[key] = (await listIds(instance, path)).sort((a, b) => a.localeCompare(b)); + result.inventoryLists = (await listInventoryListIds(instance)).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 inventory lists'); + result.warnings.push(`Could not list inventory lists: ${message}`); } - }), - ); + })(), + ]); return result; } diff --git a/packages/b2c-tooling-sdk/src/operations/jobs/index.ts b/packages/b2c-tooling-sdk/src/operations/jobs/index.ts index 76f29e02c..f5fcd5a65 100644 --- a/packages/b2c-tooling-sdk/src/operations/jobs/index.ts +++ b/packages/b2c-tooling-sdk/src/operations/jobs/index.ts @@ -6,69 +6,19 @@ /** * Job execution operations for B2C Commerce. * - * This module provides functions for running and monitoring jobs - * on B2C Commerce instances via OCAPI. + * 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. * - * ## 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 siteArchiveImportSet} - Apply an ordered, receipted set of site archives - * - {@link siteArchiveImportSplit} - Import a large site archive in multiple parts - * - {@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. + * Ordered import sets are exported from `./import-set` and continue to use + * the site-archive/WebDAV workflow. * * @module operations/jobs */ -// Core job execution +// OCAPI ops (legacy — will be removed when OCAPI is deprecated) export { executeJob, getJobExecution, @@ -92,7 +42,25 @@ export type { JobExecutionSearchResult, } from './run.js'; -// Site archive import/export +// SCAPI ops + canonical types (primary surface) +export { + executeJob as scapiExecuteJob, + getJobExecution as scapiGetJobExecution, + 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'; + +// Backend-agnostic helpers +export {waitForJobExecution, CanonicalJobExecutionError} from './wait-canonical.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 { siteArchiveImport, siteArchiveImportSplit, 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..b5f747be5 --- /dev/null +++ b/packages/b2c-tooling-sdk/src/operations/jobs/ocapi-mapping.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 + */ +/** + * 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, + }; +} + +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; + 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/run-system-job.ts b/packages/b2c-tooling-sdk/src/operations/jobs/run-system-job.ts new file mode 100644 index 000000000..609f3a619 --- /dev/null +++ b/packages/b2c-tooling-sdk/src/operations/jobs/run-system-job.ts @@ -0,0 +1,260 @@ +/* + * 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 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 + * + * 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 {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} from './scapi-ops.js'; +import {waitForJob, JobExecutionError, type JobExecution, type WaitForJobOptions} from './run.js'; +import {waitForJobExecution, CanonicalJobExecutionError} from './wait-canonical.js'; + +/** + * Decides whether a SCAPI start failure is provably safe to fall back to OCAPI + * for. Safe cases guarantee no job was created: + * - `invalid_scope`: Account Manager rejected the scope during + * token acquisition — thrown before the job POST is ever sent. + * - 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 + * 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 { + return isFallbackTrigger(error); +} + +/** + * 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) { + // 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); + } + + // auto + if (!scapiConfig) { + return runOcapiSystemJob(instance, spec); + } + + // 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, + ); + let started; + 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} start rejected, 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/run.ts b/packages/b2c-tooling-sdk/src/operations/jobs/run.ts index c1549b038..2b6c14059 100644 --- a/packages/b2c-tooling-sdk/src/operations/jobs/run.ts +++ b/packages/b2c-tooling-sdk/src/operations/jobs/run.ts @@ -10,9 +10,16 @@ */ import {B2CInstance} from '../../instance/index.js'; import type {components} from '../../clients/ocapi.generated.js'; -import {getApiErrorMessage} from '../../clients/error-utils.js'; +import {isOcapiDeprecatedFault, OcapiDeprecatedError, getApiErrorMessage} from '../../clients/error-utils.js'; +import {SCAPI_JOBS_CASCADE} from '../../clients/scapi-jobs.js'; import {getLogger} from '../../logging/logger.js'; +// SCAPI Jobs scopes named in the OCAPI-deprecation message, derived from the +// canonical cascade so they can't drift. Reads accept either tier; writes +// (execute) require the rw scope. +const JOBS_READ_SCOPES = [...new Set(SCAPI_JOBS_CASCADE.read.flat())]; +const JOBS_RW_SCOPES = [...new Set(SCAPI_JOBS_CASCADE.write.flat())]; + /** * Job execution from OCAPI. * Type alias to the generated schema. @@ -152,8 +159,9 @@ export async function executeJob( } if (error || !data) { + if (isOcapiDeprecatedFault(error)) throw new OcapiDeprecatedError({cause: error, requiredScopes: JOBS_RW_SCOPES}); const message = error?.fault?.message ?? `Failed to execute job ${jobId}`; - throw new Error(message); + throw new Error(message, {cause: error}); } logger.debug({jobId, executionId: data.id, status: data.execution_status}, `Job ${jobId} started: ${data.id}`); @@ -186,8 +194,9 @@ export async function getJobExecution( }); if (error || !data) { + if (isOcapiDeprecatedFault(error)) throw new OcapiDeprecatedError({cause: error, requiredScopes: JOBS_READ_SCOPES}); const message = error?.fault?.message ?? `Failed to get job execution ${executionId}`; - throw new Error(message); + throw new Error(message, {cause: error}); } return data; @@ -417,9 +426,10 @@ export async function searchJobExecutions( }); if (error || !data) { + if (isOcapiDeprecatedFault(error)) throw new OcapiDeprecatedError({cause: error, requiredScopes: JOBS_READ_SCOPES}); const message = error && response ? getApiErrorMessage(error, response) : (error?.fault?.message ?? 'Unknown error'); - throw new Error(`Failed to search job executions: ${message}`); + throw new Error(`Failed to search job executions: ${message}`, {cause: error}); } 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..ab33a77cb --- /dev/null +++ b/packages/b2c-tooling-sdk/src/operations/jobs/scapi-ops.ts @@ -0,0 +1,305 @@ +/* + * 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 {createScapiRequestError, ScapiRequestError} from '../../clients/scapi-backend-utils.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'}; + +/** + * 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 ScapiRequestError { + constructor( + message: string, + /** HTTP status of the rejection response. */ + public readonly status: number, + ) { + super(message, status); + this.name = 'ScapiJobStartError'; + } +} + +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}`; + // 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); +} + +export async function getJobExecution( + client: ScapiJobsClient, + jobId: string, + executionId: string, + tenantId: string, +): Promise { + const organizationId = toOrganizationId(tenantId); + + const {data, error, response} = await client.GET( + '/organizations/{organizationId}/jobs/{jobId}/executions/{executionId}', + { + params: {path: {organizationId, jobId, executionId}}, + headers: READ_HEADERS, + }, + ); + + if (error || !data) { + throw createScapiRequestError(error, response, `Failed to get job execution ${executionId}`); + } + + 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; + + // 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]}}); + } + 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, response} = 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) { + throw createScapiRequestError(error, response, 'Failed to search job executions'); + } + + 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, response} = await client.DELETE( + '/organizations/{organizationId}/jobs/{jobId}/executions/{executionId}', + { + params: {path: {organizationId, jobId, executionId}}, + headers: WRITE_HEADERS, + }, + ); + + if (error) { + throw createScapiRequestError(error, response, `Failed to delete job execution ${executionId}`); + } +} + +/** + * 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/site-archive.ts b/packages/b2c-tooling-sdk/src/operations/jobs/site-archive.ts index d537d3828..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,13 +15,37 @@ import * as zlib from 'node:zlib'; import {glob, hasMagic} from 'glob'; import JSZip from 'jszip'; import {B2CInstance} from '../../instance/index.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())]; 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. */ @@ -217,69 +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) { - throw new Error(retryError?.fault?.message ?? 'Failed to execute import job'); - } - - execution = retryData; - } else if (error || !data) { - throw new Error(error?.fault?.message ?? 'Failed to execute import job'); - } 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 { @@ -1023,62 +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) { - throw new Error(retryError?.fault?.message ?? 'Failed to execute export job'); - } - - execution = retryData; - } else if (error || !data) { - throw new Error(error?.fault?.message ?? 'Failed to execute export job'); - } 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/src/operations/jobs/types.ts b/packages/b2c-tooling-sdk/src/operations/jobs/types.ts new file mode 100644 index 000000000..847f3083d --- /dev/null +++ b/packages/b2c-tooling-sdk/src/operations/jobs/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 + */ +import type {ExecuteJobOptions, WaitForJobOptions, SearchJobExecutionsOptions} from './run.js'; + +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; + 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: JobExecutionInfo[]; +} diff --git a/packages/b2c-tooling-sdk/src/operations/jobs/wait-canonical.ts b/packages/b2c-tooling-sdk/src/operations/jobs/wait-canonical.ts new file mode 100644 index 000000000..5227ec83b --- /dev/null +++ b/packages/b2c-tooling-sdk/src/operations/jobs/wait-canonical.ts @@ -0,0 +1,80 @@ +/* + * 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 + */ +/** + * 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 type {JobExecutionInfo} from './types.js'; + +/** + * 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( + getExecution: (jobId: string, executionId: string) => Promise, + 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 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') { + throw new CanonicalJobExecutionError(`Job ${jobId} failed`, execution); + } + + 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/sites/cartridges.ts b/packages/b2c-tooling-sdk/src/operations/sites/cartridges.ts index f0559de3f..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} 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,25 +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) { - 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 @@ -140,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), ); } @@ -163,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 @@ -190,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), ); } @@ -211,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 @@ -238,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 // --------------------------------------------------------------------------- @@ -278,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 { @@ -297,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 45c2d83d0..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 @@ -55,3 +54,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..816ea3f65 --- /dev/null +++ b/packages/b2c-tooling-sdk/src/operations/sites/ocapi-sites-backend.ts @@ -0,0 +1,132 @@ +/* + * 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'; +import type {CartridgePosition} 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 { + // 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); + } + 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 { + 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); + } + + 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 new file mode 100644 index 000000000..41c37e868 --- /dev/null +++ b/packages/b2c-tooling-sdk/src/operations/sites/scapi-sites-backend.ts @@ -0,0 +1,211 @@ +/* + * 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, CartridgePosition} 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'; +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; + +/** 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]; +} + +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 manages custom cartridge paths via the + * `site/sites/v1` Admin API. + */ +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 { + // 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, response} = await this.client.GET('/organizations/{organizationId}/sites', { + params: {path: {organizationId: this.organizationId}, query: {limit, offset}}, + headers: READ_HEADERS, + }); + if (error || !data) { + throw createScapiRequestError(error, response, '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); + } + return results; + } + + async getSite(siteId: string): Promise { + 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 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 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 new file mode 100644 index 000000000..14c7ab22b --- /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 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 a safe capability/auth/request rejection. + */ +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..8d1a21fcc --- /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. The union covers read-only and + * read-write operations. + * + * @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..f13978f2c --- /dev/null +++ b/packages/b2c-tooling-sdk/src/operations/sites/sites-types.ts @@ -0,0 +1,59 @@ +/* + * 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. + * + * SCAPI Sites v1.3 exposes dedicated custom-cartridge read/write operations; + * OCAPI remains as the temporary compatibility backend. + * + * @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; +} + +export type CartridgePosition = 'first' | 'last' | 'before' | 'after'; + +/** + * Backend contract for site read operations. + * + * 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/src/scaffold/sources.ts b/packages/b2c-tooling-sdk/src/scaffold/sources.ts index e866f9e82..26b21f114 100644 --- a/packages/b2c-tooling-sdk/src/scaffold/sources.ts +++ b/packages/b2c-tooling-sdk/src/scaffold/sources.ts @@ -8,7 +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 {createSitesBackend} from '../operations/sites/index.js'; import type {ScaffoldChoice, ScaffoldParameter, DynamicParameterSource, SourceResult} from './types.js'; /** @@ -148,18 +148,12 @@ export async function resolveRemoteSource( ): Promise { switch (source) { case 'sites': { - const {data, error} = await instance.ocapi.GET('/sites', { - params: {query: {select: '(**)'}}, - }); - - if (error) { - throw new Error('Failed to fetch sites from B2C instance'); - } - - 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-tooling-sdk/test/auth/oauth-pkce-fallback.test.ts b/packages/b2c-tooling-sdk/test/auth/oauth-pkce-fallback.test.ts index 6d4f20045..f03a3942d 100644 --- a/packages/b2c-tooling-sdk/test/auth/oauth-pkce-fallback.test.ts +++ b/packages/b2c-tooling-sdk/test/auth/oauth-pkce-fallback.test.ts @@ -24,6 +24,7 @@ import type {UserAuthStrategy} from '@salesforce/b2c-tooling-sdk/auth'; function fakeStrategy(label: string) { const calls = {fetch: 0, getAuthorizationHeader: 0, getJWT: 0, getTokenResponse: 0, invalidateToken: 0}; const strategy: UserAuthStrategy = { + authMethod: 'user', async fetch() { calls.fetch++; return new Response(label, {status: 200}); diff --git a/packages/b2c-tooling-sdk/test/auth/oauth.test.ts b/packages/b2c-tooling-sdk/test/auth/oauth.test.ts index 609ebb465..bc6bd2118 100644 --- a/packages/b2c-tooling-sdk/test/auth/oauth.test.ts +++ b/packages/b2c-tooling-sdk/test/auth/oauth.test.ts @@ -481,6 +481,191 @@ 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); + }); + + // 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/cli/instance-command.test.ts b/packages/b2c-tooling-sdk/test/cli/instance-command.test.ts index 903709012..e59bed32f 100644 --- a/packages/b2c-tooling-sdk/test/cli/instance-command.test.ts +++ b/packages/b2c-tooling-sdk/test/cli/instance-command.test.ts @@ -8,6 +8,7 @@ import sinon from 'sinon'; import {Config} from '@oclif/core'; import {InstanceCommand} from '@salesforce/b2c-tooling-sdk/cli'; import type {B2COperationContext, B2COperationResult, B2COperationType} from '@salesforce/b2c-tooling-sdk/cli'; +import {PkceWithImplicitFallbackStrategy} from '@salesforce/b2c-tooling-sdk/auth'; import {isolateConfig, restoreConfig} from '@salesforce/b2c-tooling-sdk/test-utils'; import {stubParse} from '../helpers/stub-parse.js'; @@ -16,6 +17,8 @@ class TestInstanceCommand extends InstanceCommand { static id = 'test:instance'; static description = 'Test instance command'; + public oauthStrategyCalls = 0; + async run(): Promise { // Test implementation } @@ -48,6 +51,11 @@ class TestInstanceCommand extends InstanceCommand { public testRunAfterHooks(context: B2COperationContext, result: B2COperationResult) { return this.runAfterHooks(context, result); } + + protected override getOAuthStrategy(): PkceWithImplicitFallbackStrategy { + this.oauthStrategyCalls++; + return new PkceWithImplicitFallbackStrategy({clientId: 'pkce-client', persistSession: false}); + } } describe('cli/instance-command', () => { @@ -173,6 +181,20 @@ describe('cli/instance-command', () => { // Should return same instance expect(instance1).to.equal(instance2); }); + + it('injects the command PKCE resolver lazily for OCAPI', async () => { + stubParse(command, {server: 'test.demandware.net', 'client-id': 'test-client', 'user-auth': true}); + + await command.init(); + const instance = command.testInstance(); + expect(command.oauthStrategyCalls).to.equal(0); + + void instance.ocapi; + expect(command.oauthStrategyCalls).to.equal(1); + + void instance.ocapi; + expect(command.oauthStrategyCalls).to.equal(1); + }); }); describe('createContext', () => { 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..4a42ef00b 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,14 @@ * 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, + isOcapiDeprecatedFault, + throwOcapiError, + OcapiDeprecatedError, + OCAPI_DEPRECATED_MESSAGE, + ocapiDeprecatedMessage, +} from '../../src/clients/error-utils.js'; describe('getApiErrorMessage', () => { // Mock response object for testing @@ -168,4 +175,94 @@ describe('getApiErrorMessage', () => { expect(getApiErrorMessage(error, response as Response)).to.equal('HTTP 521 Web Server Is Down'); }); }); + + 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 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 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, 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'}}; + 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 non-deprecation errors with the fault message and attaches cause', () => { + const fault = {fault: {type: 'NotFoundException', message: 'Site not found'}}; + try { + 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.equal('Failed to list code versions: Site not found'); + expect(err.cause).to.equal(fault); + } + }); + }); }); 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..04a5ea871 --- /dev/null +++ b/packages/b2c-tooling-sdk/test/clients/scapi-fallback-backend.test.ts @@ -0,0 +1,314 @@ +/* + * 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 {ImplicitOAuthStrategy, PkceOAuthStrategy} from '../../src/auth/index.js'; +import {createFallbackBackend} from '../../src/clients/scapi-fallback-backend.js'; +import { + assertOcapiCompatibilityAllowed, + ScapiCapabilityUnsupportedError, + ScapiRequestError, + ScapiUserAuthUnsupportedError, + withScopes, +} from '../../src/clients/scapi-backend-utils.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('SCAPI Admin authentication guard', () => { + for (const strategy of [ + new PkceOAuthStrategy({clientId: 'public-client', persistSession: false}), + 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) + .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 () => { + 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('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 + // 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', { + 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'); + }); + }); + + 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'); + } + }); + }); +}); + +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/compat/dispatcher.test.ts b/packages/b2c-tooling-sdk/test/compat/dispatcher.test.ts new file mode 100644 index 000000000..b3e43706c --- /dev/null +++ b/packages/b2c-tooling-sdk/test/compat/dispatcher.test.ts @@ -0,0 +1,122 @@ +/* + * 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 a stateless OAuth flow/) + .with.property('message') + .that.includes('release 26.8'); + }); + + 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); + }); + }); +}); 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 880c5068d..daa073a3f 100644 --- a/packages/b2c-tooling-sdk/test/config/env-source.test.ts +++ b/packages/b2c-tooling-sdk/test/config/env-source.test.ts @@ -103,6 +103,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/instance/scapi-client-config.test.ts b/packages/b2c-tooling-sdk/test/instance/scapi-client-config.test.ts new file mode 100644 index 000000000..425d7163e --- /dev/null +++ b/packages/b2c-tooling-sdk/test/instance/scapi-client-config.test.ts @@ -0,0 +1,143 @@ +/* + * 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, PkceWithImplicitFallbackStrategy} 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); +} + +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', () => { + 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('does not use an injected PKCE user strategy for SCAPI Admin APIs', () => { + let strategyResolved = false; + const b2c = new B2CInstance( + {hostname: 'test.demandware.net', ...SCAPI_COORDS}, + {authMethods: ['user'], oauth: {clientId: 'client'}}, + { + oauthStrategy: () => { + strategyResolved = true; + return new PkceWithImplicitFallbackStrategy({clientId: 'client', persistSession: false}); + }, + }, + ); + + expect(b2c.scapiClientConfig).to.equal(undefined); + expect(strategyResolved).to.equal(false); + }); + + 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'); + }); + }); + + 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-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/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 new file mode 100644 index 000000000..c9820b3d3 --- /dev/null +++ b/packages/b2c-tooling-sdk/test/operations/bm-users/scapi-search.test.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 {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); + 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'); + } + }); + + 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/code/versions.test.ts b/packages/b2c-tooling-sdk/test/operations/code/versions.test.ts index 0c24c284d..62fd4bf3b 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`; @@ -304,7 +305,7 @@ describe('operations/code/versions', () => { }), ); - await reloadCodeVersion(mockInstance, 'v2'); + await reloadCodeVersion(new OcapiScriptsBackend(mockInstance), 'v2'); // Success - no error thrown }); @@ -323,7 +324,7 @@ describe('operations/code/versions', () => { }), ); - await reloadCodeVersion(mockInstance, 'v2'); + await reloadCodeVersion(new OcapiScriptsBackend(mockInstance), 'v2'); // Success - no error thrown }); @@ -337,7 +338,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'); @@ -354,7 +355,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'); 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-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..58feb36e3 --- /dev/null +++ b/packages/b2c-tooling-sdk/test/operations/jobs/run-system-job.test.ts @@ -0,0 +1,303 @@ +/* + * 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'); + }); + + 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', () => { + 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'); + }); + }); +}); 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-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..91cbe3509 --- /dev/null +++ b/packages/b2c-tooling-sdk/test/operations/sites/sites-backend.test.ts @@ -0,0 +1,330 @@ +/* + * 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, ScapiClientConfig} from '../../../src/instance/index.js'; +import type {AuthStrategy} from '../../../src/auth/types.js'; + +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 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({ + 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({ + 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'); + }); + + it('honors explicit ocapi preference even with SCAPI config', () => { + const backend = createSitesBackend({ + preference: 'ocapi', + 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'); + }); + }); + + 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', + }); + }); + + 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', () => { + /** + * 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/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/code-sync/cartridge-commands.ts b/packages/b2c-vs-extension/src/code-sync/cartridge-commands.ts index 8383f8610..b9e8e1db8 100644 --- a/packages/b2c-vs-extension/src/code-sync/cartridge-commands.ts +++ b/packages/b2c-vs-extension/src/code-sync/cartridge-commands.ts @@ -3,19 +3,13 @@ * 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, -} 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, getCartridgePath, + createSitesBackend, type CartridgePosition, } from '@salesforce/b2c-tooling-sdk/operations/sites'; import type {B2CInstance} from '@salesforce/b2c-tooling-sdk/instance'; @@ -57,7 +51,7 @@ function createDownloadCartridgeCommand( let codeVersion = instance.config.codeVersion; if (!codeVersion) { try { - const active = await getActiveCodeVersion(instance); + const active = await createScriptsBackendFromExtension(instance).getActiveCodeVersion(); if (active?.id) codeVersion = active.id; } catch { // fall through @@ -107,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 } @@ -234,7 +224,8 @@ function createListCodeVersionsCommand( if (!instance) return; try { - const versions = await listCodeVersions(instance); + const scriptsBackend = createScriptsBackendFromExtension(instance); + const versions = await scriptsBackend.listCodeVersions(); const items = versions.map((v) => ({ label: `${v.active ? '$(star-full) ' : ''}${v.id ?? 'unknown'}`, description: v.active ? 'Active' : '', @@ -268,7 +259,7 @@ function createListCodeVersionsCommand( await vscode.window.withProgress( {location: vscode.ProgressLocation.Notification, title: `Activating "${versionId}"...`}, async () => { - const activation = await activateCodeVersion(instance, versionId); + const activation = await scriptsBackend.activateCodeVersion(versionId); activationAlreadyActive = activation.alreadyActive; treeView.description = `v: ${versionId}`; }, @@ -281,7 +272,7 @@ function createListCodeVersionsCommand( } else if (actionPick.action === 'reload') { await vscode.window.withProgress( {location: vscode.ProgressLocation.Notification, title: `Reloading "${versionId}"...`}, - () => reloadCodeVersion(instance, versionId), + () => reloadCodeVersion(scriptsBackend, versionId), ); vscode.window.showInformationMessage(`B2C DX: Code version "${versionId}" reloaded.`); } else if (actionPick.action === 'delete') { @@ -293,7 +284,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.`); } @@ -321,7 +312,7 @@ function createCreateCodeVersionCommand( if (!name) return; try { - await createCodeVersion(instance, 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(); @@ -341,7 +332,8 @@ function createActivateCodeVersionCommand( if (!instance) return; try { - const versions = await listCodeVersions(instance); + const scriptsBackend = createScriptsBackendFromExtension(instance); + const versions = await scriptsBackend.listCodeVersions(); const candidates = getActivationCandidates(versions); if (candidates.length === 0) { const activeVersion = versions.find((version) => version.active)?.id; @@ -367,7 +359,7 @@ function createActivateCodeVersionCommand( await vscode.window.withProgress( {location: vscode.ProgressLocation.Notification, title: `Activating "${picked.version.id}"...`}, async () => { - const activation = await activateCodeVersion(instance, picked.version.id!); + const activation = await scriptsBackend.activateCodeVersion(picked.version.id!); activationAlreadyActive = activation.alreadyActive; treeView.description = `v: ${picked.version.id}`; }, @@ -406,13 +398,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(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 7c319ff41..1978dcba9 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 @@ -7,14 +7,15 @@ 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 {findCartridgesSafe} from '../workspace-discovery.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(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(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(instance).getActiveCodeVersion(); if (active?.id) { codeVersion = active.id; instance.config.codeVersion = codeVersion; diff --git a/packages/b2c-vs-extension/src/code-sync/code-version-actions.ts b/packages/b2c-vs-extension/src/code-sync/code-version-actions.ts index e7e4047f7..681977593 100644 --- a/packages/b2c-vs-extension/src/code-sync/code-version-actions.ts +++ b/packages/b2c-vs-extension/src/code-sync/code-version-actions.ts @@ -3,7 +3,12 @@ * 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 {CodeVersion} from '@salesforce/b2c-tooling-sdk/operations/code'; +/** Minimal shape needed to decide activation candidacy — satisfied by both the + * raw OCAPI `CodeVersion` and the canonical `CodeVersionInfo`. */ +interface CodeVersionLike { + id?: string; + active?: boolean; +} export type PostDeployAction = 'none' | 'activate' | 'reload'; @@ -22,6 +27,6 @@ export function getPostDeployActions(targetIsActive: boolean): PostDeployActionI return actions; } -export function getActivationCandidates(versions: CodeVersion[]): CodeVersion[] { +export function getActivationCandidates(versions: T[]): T[] { return versions.filter((version) => !version.active && typeof version.id === 'string'); } 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 82095b1b4..991111e69 100644 --- a/packages/b2c-vs-extension/src/code-sync/deploy-command.ts +++ b/packages/b2c-vs-extension/src/code-sync/deploy-command.ts @@ -3,16 +3,12 @@ * 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 { - uploadCartridges, - getActiveCodeVersion, - activateCodeVersion, - reloadCodeVersion, -} from '@salesforce/b2c-tooling-sdk/operations/code'; +import {reloadCodeVersion, uploadCartridges} from '@salesforce/b2c-tooling-sdk/operations/code'; import * as vscode from 'vscode'; import type {B2CExtensionConfig} from '../config-provider.js'; import {findCartridgesSafe} from '../workspace-discovery.js'; import {getPostDeployActions} from './code-version-actions.js'; +import {createScriptsBackendFromExtension} from './scripts-backend.js'; export function createDeployCommand( configProvider: B2CExtensionConfig, @@ -25,20 +21,22 @@ export function createDeployCommand( return; } - // Resolve code version + // Resolve code version through the configured Scripts backend (SCAPI or OCAPI) + const scriptsBackend = createScriptsBackendFromExtension(instance); let codeVersion = instance.config.codeVersion; + // Discover the active version through the configured backend (SCAPI or + // OCAPI). Captured unconditionally so the post-deploy action list can tell + // whether the target is already active. let activeCodeVersionId: string | undefined; try { - const active = await getActiveCodeVersion(instance); + const active = await scriptsBackend.getActiveCodeVersion(); activeCodeVersionId = active?.id; - if (!codeVersion) { - if (active?.id) { - codeVersion = active.id; - instance.config.codeVersion = codeVersion; - } + if (!codeVersion && active?.id) { + codeVersion = active.id; + instance.config.codeVersion = codeVersion; } } catch { - // The configured version can still be deployed when OCAPI discovery is unavailable. + // The configured version can still be deployed when discovery is unavailable. } if (!codeVersion) { vscode.window.showErrorMessage( @@ -98,7 +96,7 @@ export function createDeployCommand( if (actionPick.action === 'activate') { progress.report({message: 'Activating code version...'}); - const activation = await activateCodeVersion(instance, codeVersion); + const activation = await scriptsBackend.activateCodeVersion(codeVersion); outputChannel.appendLine( activation.alreadyActive ? `Code version "${codeVersion}" is already active; activation skipped` @@ -106,7 +104,7 @@ export function createDeployCommand( ); } else if (actionPick.action === 'reload') { progress.report({message: 'Reloading code version...'}); - await reloadCodeVersion(instance, codeVersion); + await reloadCodeVersion(scriptsBackend, codeVersion); outputChannel.appendLine(`Code version "${codeVersion}" reloaded`); } @@ -161,7 +159,7 @@ export function createDeployOneCommand( let codeVersion = instance.config.codeVersion; if (!codeVersion) { try { - const active = await getActiveCodeVersion(instance); + 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/index.ts b/packages/b2c-vs-extension/src/code-sync/index.ts index e725e7ee1..392e5d51a 100644 --- a/packages/b2c-vs-extension/src/code-sync/index.ts +++ b/packages/b2c-vs-extension/src/code-sync/index.ts @@ -23,7 +23,7 @@ export function registerCodeSync( cartridgeService: CartridgeService, log: vscode.OutputChannel, ): void { - const manager = new CodeSyncManager(context.workspaceState); + const manager = new CodeSyncManager(context.workspaceState, configProvider); const treeProvider = new CartridgeTreeProvider(cartridgeService); 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..4206a56c3 --- /dev/null +++ b/packages/b2c-vs-extension/src/code-sync/scripts-backend.ts @@ -0,0 +1,23 @@ +/* + * 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`. + * + * 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'; + +export function createScriptsBackendFromExtension(instance: B2CInstance): ScriptsBackend { + return createScriptsBackend({instance}); +} 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 21255b6e8..43cb5b7c4 100644 --- a/packages/b2c-vs-extension/src/jobs/jobs-commands.ts +++ b/packages/b2c-vs-extension/src/jobs/jobs-commands.ts @@ -4,18 +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 {getApiErrorMessage} from '@salesforce/b2c-tooling-sdk'; +import {createJobsCompatibilityBackend} 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'; @@ -776,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 []; @@ -963,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')) { @@ -1074,18 +1067,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`}), }); }, @@ -1511,57 +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; - } - - // 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) => { @@ -1725,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 c1650078e..75987d0a6 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'; @@ -196,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 ( @@ -428,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', @@ -864,13 +865,14 @@ export class JobsTreeDataProvider implements vscode.TreeDataProvider { 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 e9c0daf29..fd3fab62f 100644 --- a/packages/b2c-vs-extension/src/walkthrough/onboardingPanel.ts +++ b/packages/b2c-vs-extension/src/walkthrough/onboardingPanel.ts @@ -20,7 +20,7 @@ import { DetectionSummary, StepDetection, } from './stepDetection.js'; -import {listCodeVersions, type CodeVersion} from '@salesforce/b2c-tooling-sdk/operations/code'; +import {createScriptsBackend, type CodeVersionInfo} from '@salesforce/b2c-tooling-sdk/operations/code'; import type {B2CExtensionConfig} from '../config-provider.js'; import {findCartridgesSafe} from '../workspace-discovery.js'; @@ -70,7 +70,7 @@ interface ViewState { } type DeployedCartridgesResult = - | {kind: 'ok'; names: string[]; source: 'ocapi' | 'webdav'} + | {kind: 'ok'; names: string[]; source: 'api' | 'webdav'} | {kind: 'no-provider'} | {kind: 'no-instance'; reason?: string} | {kind: 'no-code-version'} @@ -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). * @@ -533,22 +533,22 @@ export class OnboardingPanel { const cached = this.deployedCartridgesCache.get(cacheKey); if (cached && Date.now() - cached.fetchedAt < 30_000) return cached.result; - let ocapiError: string | undefined; + let apiError: string | undefined; - // 1) Try OCAPI — gives back the canonical list directly. + // 1) Try the configured code-version backend (SCAPI-first in auto mode). try { - const versions: CodeVersion[] = await listCodeVersions(instance); + const versions: CodeVersionInfo[] = await createScriptsBackend({instance}).listCodeVersions(); const target = versions.find((v) => 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/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 45bdff39a..03e0e6a5e 100644 --- a/skills/b2c-cli/skills/b2c-bm-users-roles/SKILL.md +++ b/skills/b2c-cli/skills/b2c-bm-users-roles/SKILL.md @@ -5,24 +5,32 @@ 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`). 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, 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 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. + ## 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. +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 | -|---|---|---| -| `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. @@ -73,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) @@ -85,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 @@ -118,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-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-code/SKILL.md b/skills/b2c-cli/skills/b2c-code/SKILL.md index a36706d01..249bb8597 100644 --- a/skills/b2c-cli/skills/b2c-code/SKILL.md +++ b/skills/b2c-cli/skills/b2c-code/SKILL.md @@ -112,11 +112,19 @@ b2c code activate --reload b2c code delete ``` +### API Backend + +`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. + +`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 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..2b7a334fa 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. 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) @@ -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 bc1f9dfaa..58f2263c6 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 @@ -168,14 +168,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`): @@ -223,6 +223,25 @@ 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 + +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). + +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`. + +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. + ### Wait for Job Completion ```bash diff --git a/skills/b2c-cli/skills/b2c-sites/SKILL.md b/skills/b2c-cli/skills/b2c-sites/SKILL.md index db637234a..d37679a14 100644 --- a/skills/b2c-cli/skills/b2c-sites/SKILL.md +++ b/skills/b2c-cli/skills/b2c-sites/SKILL.md @@ -75,16 +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 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). + +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