diff --git a/.changeset/commerce-instance-helpers.md b/.changeset/commerce-instance-helpers.md new file mode 100644 index 000000000..1f50c87ae --- /dev/null +++ b/.changeset/commerce-instance-helpers.md @@ -0,0 +1,5 @@ +--- +"@adobe/aio-commerce-lib-app": minor +--- + +Add helpers to retrieve the Commerce instance an app is associated with from any runtime action: `getCommerceInstance()` returns the stored instance data, and `getCommerceClient(auth)` returns a ready-to-use Commerce HTTP client built from that instance. diff --git a/.changeset/error-base-options-export.md b/.changeset/error-base-options-export.md new file mode 100644 index 000000000..b9743e8ff --- /dev/null +++ b/.changeset/error-base-options-export.md @@ -0,0 +1,5 @@ +--- +"@adobe/aio-commerce-lib-core": minor +--- + +Export the `CommerceSdkErrorBaseOptions` type so consumers can type the options passed when constructing or extending `CommerceSdkErrorBase`. diff --git a/.changeset/system-config-storage.md b/.changeset/system-config-storage.md new file mode 100644 index 000000000..8346ffc45 --- /dev/null +++ b/.changeset/system-config-storage.md @@ -0,0 +1,5 @@ +--- +"@adobe/aio-commerce-lib-config": minor +--- + +Add `getSystemConfigByKey` and `setSystemConfigByKey` to store and retrieve generic SDK system configuration under a dedicated `system.*` namespace. diff --git a/packages/aio-commerce-lib-app/docs/openapi.json b/packages/aio-commerce-lib-app/docs/openapi.json index e42010583..33a63527e 100644 --- a/packages/aio-commerce-lib-app/docs/openapi.json +++ b/packages/aio-commerce-lib-app/docs/openapi.json @@ -2590,6 +2590,117 @@ }, "tags": ["Business Configuration"] } + }, + "/association": { + "post": { + "operationId": "setAssociation", + "summary": "Store Commerce Association", + "description": "Persists the Commerce instance the app is associated with so runtime actions can later retrieve it via the SDK helpers.", + "parameters": [ + { + "name": "x-gw-ims-org-id", + "in": "header", + "description": "Adobe IMS organization ID that identifies the organization for the request.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "commerceBaseUrl": { + "description": "Commerce API base URL of the associated instance.", + "type": "string", + "format": "uri" + }, + "commerceEnv": { + "description": "Deployment type of the associated Commerce instance.", + "type": "string", + "enum": ["saas", "paas"] + } + }, + "required": ["commerceBaseUrl", "commerceEnv"] + } + } + } + }, + "responses": { + "204": { + "description": "Association data stored successfully." + }, + "400": { + "description": "Bad request, the request body is invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "401": { + "description": "You are not authorized to access this resource. Ensure the IMS token and the x-gw-ims-org-id header are correctly set and valid." + }, + "403": { + "description": "The access token is valid, but it is not allowed to access the requested organization or operation." + }, + "500": { + "description": "Internal server error while storing the association data.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "tags": ["Association"] + }, + "delete": { + "operationId": "clearAssociation", + "summary": "Clear Commerce Association", + "description": "Removes the stored Commerce instance details. Called when the app is unassociated.", + "parameters": [ + { + "name": "x-gw-ims-org-id", + "in": "header", + "description": "Adobe IMS organization ID that identifies the organization for the request.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Association data cleared successfully." + }, + "401": { + "description": "You are not authorized to access this resource. Ensure the IMS token and the x-gw-ims-org-id header are correctly set and valid." + }, + "403": { + "description": "The access token is valid, but it is not allowed to access the requested organization or operation." + }, + "500": { + "description": "Internal server error while clearing the association data.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "tags": ["Association"] + } } }, "components": { diff --git a/packages/aio-commerce-lib-app/docs/usage.md b/packages/aio-commerce-lib-app/docs/usage.md index 5b9df0618..8591b7370 100644 --- a/packages/aio-commerce-lib-app/docs/usage.md +++ b/packages/aio-commerce-lib-app/docs/usage.md @@ -8,6 +8,8 @@ The `@adobe/aio-commerce-lib-app` library provides: - **Business Configuration**: Generate and manage the runtime actions that power the `commerce/configuration/1` extension point. - **Installation Management**: Generate and manage the runtime action that powers the app installation flow. - **Admin UI Configuration** (`commerce/backend-ui/2`): Generate and manage the runtime action and `workerProcess` declarations for Admin UI extensions on `commerce/backend-ui/2`. Currently supports grid column extensions, mass actions, order view buttons, and menu declarations. +- **Admin UI SDK Configuration** (`commerce/backend-ui/1`, _deprecated_): Generate and manage the runtime action for the legacy Admin UI SDK extension point. Will be removed from the SDK — use `adminUi` and `commerce/backend-ui/2` for new apps. +- **Association Helpers**: Retrieve the Commerce instance the app is associated with from any runtime action via `getCommerceClient` and `getCommerceInstance`. ## Reference @@ -915,6 +917,84 @@ try { } ``` +### Accessing the Associated Commerce Instance from Runtime Actions + +After an app is associated with a Commerce instance via App Management, the SDK stores the Commerce base URL and deployment type (`saas` or `paas`) so any runtime action can retrieve them — without custom storage setup or threading parameters through every layer of the call stack. + +Two helpers are exposed from the root entrypoint: + +- `getCommerceClient(auth, fetchOptions?)` — returns a ready-to-use [`AdobeCommerceHttpClient`](../../aio-commerce-lib-api/docs/usage.md). Use this when you need to call the Commerce API. The base URL and flavor come from the stored association data; you supply the resolved IMS auth. App Management requires IMS, so this accepts only IMS auth: resolve params with `resolveImsAuthParams`, or pass an `ImsAuthProvider` built with `getImsAuthProvider` / `forwardImsAuthProvider` from [`@adobe/aio-commerce-lib-auth`](../../aio-commerce-lib-auth/docs/usage.md). The optional `fetchOptions` are forwarded to the underlying client (e.g. `headers`, `timeout`, `retry`). +- `getCommerceInstance()` — returns the raw `{ baseUrl, env }`. Use this when you only need the metadata (e.g. for logging or building a custom client). + +Both helpers throw `AppNotAssociatedError` if the app is not currently associated, was unassociated, or was associated by an older SDK that did not store this data. Re-associating the app resolves the error. + +#### Primary pattern — get a ready-to-use client + +```ts +import { getCommerceClient } from "@adobe/aio-commerce-lib-app"; +import { resolveImsAuthParams } from "@adobe/aio-commerce-lib-auth"; + +export async function main(params) { + const client = await getCommerceClient(resolveImsAuthParams(params)); + const products = await client.get("products").json(); +} +``` + +#### Low-level pattern — get the raw instance data + +```ts +import { getCommerceInstance } from "@adobe/aio-commerce-lib-app"; + +export async function main() { + const instance = await getCommerceInstance(); + + // instance.baseUrl — Commerce API base URL + // instance.env — "saas" | "paas" +} +``` + +#### Handling the unassociated state + +If your action needs to gracefully handle the case where the app is not associated yet, wrap the call in `try/catch`: + +```ts +import { badRequest, ok } from "@adobe/aio-commerce-lib-core/responses"; +import { + AppNotAssociatedError, + getCommerceClient, +} from "@adobe/aio-commerce-lib-app"; +import { resolveImsAuthParams } from "@adobe/aio-commerce-lib-auth"; + +export async function main(params) { + try { + const client = await getCommerceClient(resolveImsAuthParams(params)); + return ok({ body: await client.get("products").json() }); + } catch (error) { + if (error instanceof AppNotAssociatedError) { + return badRequest({ + body: { message: "App is not associated with a Commerce instance." }, + }); + } + throw error; + } +} +``` + +The data is managed automatically by the SDK during the app association lifecycle: a standalone `association` runtime action (always deployed alongside `app-config`) stores it on association and clears it on unassociation. Apps scaffolded with a version of the SDK that includes this feature have the `association` action wired in from the start — no extra setup beyond your normal deploy. + +#### Adopting association in an existing app + +Apps scaffolded before this feature was introduced do not have the `association` action yet. After upgrading `@adobe/aio-commerce-lib-app`, regenerate the runtime actions and redeploy so the `/association` endpoint exists: + +```bash +npx @adobe/aio-commerce-lib-app generate actions +aio app deploy +``` + +A plain `aio app deploy` on its own does not add the action: the `pre-app-build` hook only regenerates actions already declared in `ext.config.yaml`. Only `generate actions` (or `generate all`) rebuilds the manifest to pick up newly added SDK actions. Until the app is redeployed with the endpoint, the App Management client skips the store call and the helpers throw `AppNotAssociatedError`. + +For an app that was already associated under the older SDK, re-associate it after redeploying so the store call runs and backfills the instance data — a redeploy alone does not populate data for an existing association. + ## Best Practices 1. **Use `defineConfig` for type safety** - Get autocompletion and type checking in your IDE diff --git a/packages/aio-commerce-lib-app/package.json b/packages/aio-commerce-lib-app/package.json index 512dc2f1f..eabd8d836 100644 --- a/packages/aio-commerce-lib-app/package.json +++ b/packages/aio-commerce-lib-app/package.json @@ -35,6 +35,16 @@ "registry": "https://registry.npmjs.org", "access": "public", "exports": { + ".": { + "import": { + "types": "./dist/es/index.d.mts", + "default": "./dist/es/index.mjs" + }, + "require": { + "types": "./dist/cjs/index.d.cts", + "default": "./dist/cjs/index.cjs" + } + }, "./actions/*": { "import": { "types": "./dist/es/actions/*/index.d.mts", @@ -69,6 +79,7 @@ } }, "exports": { + ".": "./source/index.ts", "./actions/*": "./source/actions/*/index.ts", "./config": "./source/config/index.ts", "./management": "./source/management/index.ts", diff --git a/packages/aio-commerce-lib-app/source/access/commerce-instance.ts b/packages/aio-commerce-lib-app/source/access/commerce-instance.ts new file mode 100644 index 000000000..785149499 --- /dev/null +++ b/packages/aio-commerce-lib-app/source/access/commerce-instance.ts @@ -0,0 +1,95 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { AdobeCommerceHttpClient } from "@adobe/aio-commerce-lib-api"; + +import { AppNotAssociatedError } from "../errors/app-not-associated-error"; +import { getAssociationData } from "../management/association/association-repository"; + +import type { CommerceHttpClientParams } from "@adobe/aio-commerce-lib-api"; +import type { + ImsAuthParams, + ImsAuthProvider, +} from "@adobe/aio-commerce-lib-auth"; +import type { AssociatedCommerceInstance } from "../management/association/types"; + +/** + * Returns the Commerce instance this app is currently associated with. + * + * @throws {AppNotAssociatedError} If the app is not associated, was + * unassociated, or was associated by an older SDK that did not store this + * data. Re-associating the app resolves the error. + * + * @example + * ```ts + * import { getCommerceInstance } from "@adobe/aio-commerce-lib-app"; + * + * export async function main() { + * const instance = await getCommerceInstance(); + * + * // instance.baseUrl — e.g. "https://my-store.example.com" + * // instance.env — "saas" | "paas" + * } + * ``` + */ +export async function getCommerceInstance(): Promise { + const instance = await getAssociationData(); + if (instance === null) { + throw new AppNotAssociatedError(); + } + return instance; +} + +/** + * Returns an initialised `AdobeCommerceHttpClient` for the Commerce instance + * this app is currently associated with. + * + * The base URL and flavor come from the stored association data + * ({@link getCommerceInstance}); only the auth credentials are supplied by the + * caller, already resolved. App Management requires IMS, so this accepts only + * IMS auth: resolve params with `resolveImsAuthParams`, or pass an + * `ImsAuthProvider` built with `getImsAuthProvider` / `forwardImsAuthProvider` + * from `@adobe/aio-commerce-lib-auth`. + * + * @param auth - Resolved IMS auth params or an IMS auth provider. + * @param fetchOptions - Optional global fetch options forwarded to the + * underlying `AdobeCommerceHttpClient` (e.g. `headers`, `timeout`, `retry`). + * @throws {AppNotAssociatedError} If the app is not associated, was + * unassociated, or was associated by an older SDK that did not store this + * data. Re-associating the app resolves the error. + * + * @example + * ```ts + * import { getCommerceClient } from "@adobe/aio-commerce-lib-app"; + * import { resolveImsAuthParams } from "@adobe/aio-commerce-lib-auth"; + * + * export async function main(params) { + * const client = await getCommerceClient(resolveImsAuthParams(params)); + * const products = await client.get("products").json(); + * } + * ``` + */ +export async function getCommerceClient( + auth: ImsAuthParams | ImsAuthProvider, + fetchOptions?: CommerceHttpClientParams["fetchOptions"], +): Promise { + const instance = await getCommerceInstance(); + + // `CommerceHttpClientParams` is a flavor-discriminated union; the stored env + // is a runtime value TypeScript cannot narrow against the auth union, so the + // assembled params are asserted to the resolved shape. + return new AdobeCommerceHttpClient({ + auth, + config: { baseUrl: instance.baseUrl, flavor: instance.env }, + fetchOptions, + } as CommerceHttpClientParams); +} diff --git a/packages/aio-commerce-lib-app/source/actions/association/index.ts b/packages/aio-commerce-lib-app/source/actions/association/index.ts new file mode 100644 index 000000000..23239362e --- /dev/null +++ b/packages/aio-commerce-lib-app/source/actions/association/index.ts @@ -0,0 +1,29 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { router } from "./router"; + +import type { RuntimeActionParams } from "@adobe/aio-commerce-lib-core/params"; + +/** + * Factory to create the route handler for the `association` action. + * + * The `association` action manages the lifecycle of the Commerce instance the + * app is associated with — `POST /` stores the data when the app is associated, + * and `DELETE /` clears it on unassociation. Runtime actions consume the data + * via `getCommerceInstance` / `getCommerceClient` from the root entrypoint. + */ +export const associationRuntimeAction = + () => async (params: RuntimeActionParams) => { + const handler = router.handler(); + return await handler({ ...params }); + }; diff --git a/packages/aio-commerce-lib-app/source/actions/association/router.ts b/packages/aio-commerce-lib-app/source/actions/association/router.ts new file mode 100644 index 000000000..67d05bdb1 --- /dev/null +++ b/packages/aio-commerce-lib-app/source/actions/association/router.ts @@ -0,0 +1,74 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { noContent } from "@adobe/aio-commerce-lib-core/responses"; +import { + HttpActionRouter, + logger, +} from "@aio-commerce-sdk/common-utils/actions"; + +import { + clearAssociationData, + setAssociationData, +} from "#management/association/association-repository"; + +import { AssociationRequestBodySchema } from "./schema"; + +import type { BaseContext } from "@aio-commerce-sdk/common-utils/actions"; + +/** The context for the association action. */ +type AssociationActionContext = BaseContext; + +/** + * Association action router. + * + * Routes: + * - POST / Store Commerce instance details (`baseUrl`, `env`) + * - DELETE / Clear stored Commerce instance details + */ +export const router = new HttpActionRouter().use( + logger({ name: () => "association" }), +); + +/** + * POST / - Store association data. + * + * Persists the Commerce instance the app is associated with so runtime actions + * can later retrieve it via `getCommerceInstance` / `getCommerceClient`. + */ +router.post("/", { + body: AssociationRequestBodySchema, + + handler: async (req, { logger }) => { + const { commerceBaseUrl, commerceEnv } = req.body; + logger.debug( + `Storing association data (baseUrl: "${commerceBaseUrl}", env: "${commerceEnv}")`, + ); + + await setAssociationData({ baseUrl: commerceBaseUrl, env: commerceEnv }); + + return noContent(); + }, +}); + +/** + * DELETE / - Clear association data. + * + * Called when the app is unassociated. + */ +router.delete("/", { + handler: async (_req, { logger }) => { + logger.debug("Clearing association data"); + await clearAssociationData(); + return noContent(); + }, +}); diff --git a/packages/aio-commerce-lib-app/source/actions/association/schema.ts b/packages/aio-commerce-lib-app/source/actions/association/schema.ts new file mode 100644 index 000000000..f3bd0fbf2 --- /dev/null +++ b/packages/aio-commerce-lib-app/source/actions/association/schema.ts @@ -0,0 +1,25 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { CommerceEnvSchema } from "@adobe/aio-commerce-lib-core/commerce"; +import * as v from "valibot"; + +/** Request body for POST / — store association data. */ +export const AssociationRequestBodySchema = v.object({ + commerceBaseUrl: v.pipe( + v.string(), + v.url( + "The 'commerceBaseUrl' field must be a valid absolute URL (e.g., 'https://my-store.example.com')", + ), + ), + commerceEnv: CommerceEnvSchema, +}); diff --git a/packages/aio-commerce-lib-app/source/commands/generate/actions/config.ts b/packages/aio-commerce-lib-app/source/commands/generate/actions/config.ts index 09d819c84..3c87ede51 100644 --- a/packages/aio-commerce-lib-app/source/commands/generate/actions/config.ts +++ b/packages/aio-commerce-lib-app/source/commands/generate/actions/config.ts @@ -119,6 +119,10 @@ export function buildAppManagementExtConfig( type: "action", impl: `${PACKAGE_NAME}/app-config`, }, + { + type: "action", + impl: `${PACKAGE_NAME}/association`, + }, ], }, @@ -128,6 +132,7 @@ export function buildAppManagementExtConfig( license: "Apache-2.0", actions: { "app-config": createActionDefinition("app-config"), + association: createActionDefinition("association"), } as Record, }, }, diff --git a/packages/aio-commerce-lib-app/source/commands/generate/actions/templates/app-management/association.js.template b/packages/aio-commerce-lib-app/source/commands/generate/actions/templates/app-management/association.js.template new file mode 100644 index 000000000..c7f75dfe6 --- /dev/null +++ b/packages/aio-commerce-lib-app/source/commands/generate/actions/templates/app-management/association.js.template @@ -0,0 +1,18 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +// This file has been auto-generated by `@adobe/aio-commerce-lib-app` +// Do not modify this file directly + +import { associationRuntimeAction } from "@adobe/aio-commerce-lib-app/actions/association"; + +export const main = associationRuntimeAction(); diff --git a/packages/aio-commerce-lib-app/source/errors/app-not-associated-error.ts b/packages/aio-commerce-lib-app/source/errors/app-not-associated-error.ts new file mode 100644 index 000000000..cd8cd04df --- /dev/null +++ b/packages/aio-commerce-lib-app/source/errors/app-not-associated-error.ts @@ -0,0 +1,57 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { CommerceSdkErrorBase } from "@adobe/aio-commerce-lib-core/error"; + +import type { CommerceSdkErrorBaseOptions } from "@adobe/aio-commerce-lib-core/error"; + +const DEFAULT_MESSAGE = + "App is not associated with a Commerce instance. Re-associate the app to resolve this error."; + +/** Options accepted by {@link AppNotAssociatedError}. */ +// Aliased to `CommerceSdkErrorBaseOptions` for now; kept as a named seam so +// future error-specific options can be added without changing the constructor +// contract. +type AppNotAssociatedErrorOptions = CommerceSdkErrorBaseOptions; + +/** + * Thrown when a runtime action calls `getCommerceInstance` or `getCommerceClient` + * but the app has no stored association data — for example, when the app has + * never been associated, has been unassociated, or was associated by an older + * SDK that did not store this data. + * + * Re-associating the app resolves the error. + * + * @example + * ```ts + * import { getCommerceClient, AppNotAssociatedError } from "@adobe/aio-commerce-lib-app"; + * import { resolveImsAuthParams } from "@adobe/aio-commerce-lib-auth"; + * + * try { + * const client = await getCommerceClient(resolveImsAuthParams(params)); + * // ... use client + * } catch (error) { + * if (error instanceof AppNotAssociatedError) { + * // handle the unassociated case (e.g. return a 400 response) + * } + * throw error; + * } + * ``` + */ +export class AppNotAssociatedError extends CommerceSdkErrorBase { + public constructor( + message: string = DEFAULT_MESSAGE, + options?: AppNotAssociatedErrorOptions, + ) { + super(message, options); + } +} diff --git a/packages/aio-commerce-lib-app/source/errors/index.ts b/packages/aio-commerce-lib-app/source/errors/index.ts new file mode 100644 index 000000000..c169980ce --- /dev/null +++ b/packages/aio-commerce-lib-app/source/errors/index.ts @@ -0,0 +1,14 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +// biome-ignore lint/performance/noBarrelFile: export as part of the Public API +export { AppNotAssociatedError } from "./app-not-associated-error"; diff --git a/packages/aio-commerce-lib-app/source/index.ts b/packages/aio-commerce-lib-app/source/index.ts new file mode 100644 index 000000000..5dab8acba --- /dev/null +++ b/packages/aio-commerce-lib-app/source/index.ts @@ -0,0 +1,28 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/** + * Public entrypoint for `@adobe/aio-commerce-lib-app`. Exposes helpers that + * runtime actions use to retrieve the Commerce instance the app is currently + * associated with. + * + * @packageDocumentation + */ + +// biome-ignore lint/performance/noBarrelFile: export as part of the Public API +export { + getCommerceClient, + getCommerceInstance, +} from "./access/commerce-instance"; +export { AppNotAssociatedError } from "./errors/app-not-associated-error"; + +export type { AssociatedCommerceInstance } from "./management/association/types"; diff --git a/packages/aio-commerce-lib-app/source/management/association/association-repository.ts b/packages/aio-commerce-lib-app/source/management/association/association-repository.ts new file mode 100644 index 000000000..8c4eab379 --- /dev/null +++ b/packages/aio-commerce-lib-app/source/management/association/association-repository.ts @@ -0,0 +1,54 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { + getSystemConfigByKey, + setSystemConfigByKey, +} from "@adobe/aio-commerce-lib-config"; + +import type { AssociatedCommerceInstance } from "./types"; + +/** Reserved key under which association data is stored. */ +const ASSOCIATION_KEY = "system.association"; + +/** + * Stores the Commerce instance the app is associated with. + * + * Called by the `association` runtime action's `POST /` handler when App + * Management registers the app with a Commerce instance. + * + * @param data - The Commerce instance details to persist. + */ +export async function setAssociationData( + data: AssociatedCommerceInstance, +): Promise { + await setSystemConfigByKey(ASSOCIATION_KEY, data); +} + +/** + * Retrieves the Commerce instance the app is currently associated with. + * + * @returns The stored association data, or `null` if the app is not associated. + */ +export async function getAssociationData(): Promise { + return getSystemConfigByKey(ASSOCIATION_KEY); +} + +/** + * Clears the stored association data. + * + * Called by the `association` runtime action's `DELETE /` handler when the + * app is unassociated. + */ +export async function clearAssociationData(): Promise { + await setSystemConfigByKey(ASSOCIATION_KEY, null); +} diff --git a/packages/aio-commerce-lib-app/source/management/association/index.ts b/packages/aio-commerce-lib-app/source/management/association/index.ts new file mode 100644 index 000000000..fb59544be --- /dev/null +++ b/packages/aio-commerce-lib-app/source/management/association/index.ts @@ -0,0 +1,20 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +// biome-ignore lint/performance/noBarrelFile: export as part of the Public API +export { + clearAssociationData, + getAssociationData, + setAssociationData, +} from "./association-repository"; + +export type { AssociatedCommerceInstance } from "./types"; diff --git a/packages/aio-commerce-lib-app/source/management/association/types.ts b/packages/aio-commerce-lib-app/source/management/association/types.ts new file mode 100644 index 000000000..d05d1a985 --- /dev/null +++ b/packages/aio-commerce-lib-app/source/management/association/types.ts @@ -0,0 +1,23 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import type { CommerceEnv } from "@adobe/aio-commerce-lib-core/commerce"; + +/** + * The Commerce instance an app is associated with. + */ +export type AssociatedCommerceInstance = { + /** Commerce API base URL. */ + baseUrl: string; + /** Deployment type of the Commerce instance. */ + env: CommerceEnv; +}; diff --git a/packages/aio-commerce-lib-app/test/fixtures/commands.ts b/packages/aio-commerce-lib-app/test/fixtures/commands.ts index f15d83d37..180bf10c1 100644 --- a/packages/aio-commerce-lib-app/test/fixtures/commands.ts +++ b/packages/aio-commerce-lib-app/test/fixtures/commands.ts @@ -1,6 +1,8 @@ // @ts-expect-error - Importing the template as a raw string for testing purposes. import appConfigTemplate from "#templates/app-management/app-config.js?raw"; // @ts-expect-error - Importing the template as a raw string for testing purposes. +import associationTemplate from "#templates/app-management/association.js?raw"; +// @ts-expect-error - Importing the template as a raw string for testing purposes. import customScripts from "#templates/app-management/custom-scripts.js?raw"; // @ts-expect-error - Importing the template as a raw string for testing purposes. import installationTemplate from "#templates/app-management/installation.js?raw"; @@ -11,6 +13,7 @@ import scopeTreeTemplate from "#templates/business-configuration/scope-tree.js?r export const templates = { appConfig: appConfigTemplate as string, + association: associationTemplate as string, installation: installationTemplate as string, customScripts: customScripts as string, businessConfig: businessConfigTemplate as string, @@ -24,6 +27,7 @@ export const templates = { export function makeTemplateFiles(): Record { return { "app-management/app-config.js.template": templates.appConfig, + "app-management/association.js.template": templates.association, "app-management/installation.js.template": templates.installation, "app-management/custom-scripts.js.template": templates.customScripts, "business-configuration/config.js.template": templates.businessConfig, diff --git a/packages/aio-commerce-lib-app/test/unit/access/commerce-instance.test.ts b/packages/aio-commerce-lib-app/test/unit/access/commerce-instance.test.ts new file mode 100644 index 000000000..fce76a293 --- /dev/null +++ b/packages/aio-commerce-lib-app/test/unit/access/commerce-instance.test.ts @@ -0,0 +1,138 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { beforeEach, describe, expect, test, vi } from "vitest"; + +const { mockGetAssociationData, MockAdobeCommerceHttpClient } = vi.hoisted( + () => ({ + mockGetAssociationData: vi.fn(), + MockAdobeCommerceHttpClient: vi.fn(), + }), +); + +vi.mock("#management/association/association-repository", () => ({ + getAssociationData: mockGetAssociationData, +})); + +vi.mock("@adobe/aio-commerce-lib-api", () => ({ + AdobeCommerceHttpClient: MockAdobeCommerceHttpClient, +})); + +import { + getCommerceClient, + getCommerceInstance, +} from "#access/commerce-instance"; +import { AppNotAssociatedError } from "#errors/app-not-associated-error"; + +const auth = { strategy: "ims" } as never; + +describe("getCommerceInstance", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test("returns the stored association data", async () => { + const data = { + baseUrl: "https://example.com", + env: "paas" as const, + }; + mockGetAssociationData.mockResolvedValue(data); + + const result = await getCommerceInstance(); + + expect(result).toEqual(data); + }); + + test("returns saas env when stored", async () => { + const data = { + baseUrl: "https://saas.example.com", + env: "saas" as const, + }; + mockGetAssociationData.mockResolvedValue(data); + + const result = await getCommerceInstance(); + + expect(result).toEqual(data); + }); + + test("throws AppNotAssociatedError when no data is stored", async () => { + mockGetAssociationData.mockResolvedValue(null); + + await expect(getCommerceInstance()).rejects.toBeInstanceOf( + AppNotAssociatedError, + ); + }); +}); + +describe("getCommerceClient", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test("builds the client from the stored instance and supplied auth", async () => { + const data = { + baseUrl: "https://example.com", + env: "paas" as const, + }; + mockGetAssociationData.mockResolvedValue(data); + + const result = await getCommerceClient(auth); + + expect(MockAdobeCommerceHttpClient).toHaveBeenCalledWith({ + auth, + config: { baseUrl: data.baseUrl, flavor: "paas" }, + }); + expect(result).toBeInstanceOf(MockAdobeCommerceHttpClient); + }); + + test("passes the saas flavor from the stored env", async () => { + const data = { + baseUrl: "https://saas.example.com", + env: "saas" as const, + }; + mockGetAssociationData.mockResolvedValue(data); + + await getCommerceClient(auth); + + expect(MockAdobeCommerceHttpClient).toHaveBeenCalledWith({ + auth, + config: { baseUrl: data.baseUrl, flavor: "saas" }, + }); + }); + + test("forwards optional fetch options to the client", async () => { + const data = { + baseUrl: "https://example.com", + env: "paas" as const, + }; + mockGetAssociationData.mockResolvedValue(data); + + const fetchOptions = { timeout: 5000, headers: { "x-trace": "abc" } }; + await getCommerceClient(auth, fetchOptions); + + expect(MockAdobeCommerceHttpClient).toHaveBeenCalledWith({ + auth, + config: { baseUrl: data.baseUrl, flavor: "paas" }, + fetchOptions, + }); + }); + + test("throws AppNotAssociatedError when no data is stored", async () => { + mockGetAssociationData.mockResolvedValue(null); + + await expect(getCommerceClient(auth)).rejects.toBeInstanceOf( + AppNotAssociatedError, + ); + + expect(MockAdobeCommerceHttpClient).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/aio-commerce-lib-app/test/unit/actions/association.test.ts b/packages/aio-commerce-lib-app/test/unit/actions/association.test.ts new file mode 100644 index 000000000..5c74fcb3d --- /dev/null +++ b/packages/aio-commerce-lib-app/test/unit/actions/association.test.ts @@ -0,0 +1,205 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { beforeEach, describe, expect, test, vi } from "vitest"; + +const { mockSetAssociationData, mockClearAssociationData } = vi.hoisted(() => ({ + mockSetAssociationData: vi.fn(), + mockClearAssociationData: vi.fn(), +})); + +vi.mock("#management/association/association-repository", () => ({ + setAssociationData: mockSetAssociationData, + clearAssociationData: mockClearAssociationData, +})); + +import { associationRuntimeAction } from "#actions/association/index"; +import { createRuntimeActionParams } from "#test/fixtures/actions"; + +describe("associationRuntimeAction", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("POST /", () => { + test("stores valid association data and returns 204", async () => { + const action = associationRuntimeAction(); + const params = createRuntimeActionParams({ + method: "post", + path: "/", + body: { + commerceBaseUrl: "https://example.com", + commerceEnv: "paas", + }, + }); + + const result = await action(params); + + expect(mockSetAssociationData).toHaveBeenCalledWith({ + baseUrl: "https://example.com", + env: "paas", + }); + expect(result).toMatchObject({ + type: "success", + statusCode: 204, + }); + }); + + test("accepts saas as a valid env", async () => { + const action = associationRuntimeAction(); + const params = createRuntimeActionParams({ + method: "post", + path: "/", + body: { + commerceBaseUrl: "https://saas.example.com", + commerceEnv: "saas", + }, + }); + + const result = await action(params); + + expect(mockSetAssociationData).toHaveBeenCalledWith({ + baseUrl: "https://saas.example.com", + env: "saas", + }); + expect(result).toMatchObject({ + type: "success", + statusCode: 204, + }); + }); + + test("returns 400 for invalid env values", async () => { + const action = associationRuntimeAction(); + const params = createRuntimeActionParams({ + method: "post", + path: "/", + body: { + commerceBaseUrl: "https://example.com", + commerceEnv: "invalid", + }, + }); + + const result = await action(params); + + expect(result).toMatchObject({ + type: "error", + error: { statusCode: 400 }, + }); + expect(mockSetAssociationData).not.toHaveBeenCalled(); + }); + + test("returns 400 when commerceBaseUrl is not a valid URL", async () => { + const action = associationRuntimeAction(); + const params = createRuntimeActionParams({ + method: "post", + path: "/", + body: { + commerceBaseUrl: "not-a-url", + commerceEnv: "paas", + }, + }); + + const result = await action(params); + + expect(result).toMatchObject({ + type: "error", + error: { statusCode: 400 }, + }); + expect(mockSetAssociationData).not.toHaveBeenCalled(); + }); + + test("returns 400 when the body is missing required fields", async () => { + const action = associationRuntimeAction(); + const params = createRuntimeActionParams({ + method: "post", + path: "/", + body: { + commerceBaseUrl: "https://example.com", + }, + }); + + const result = await action(params); + + expect(result).toMatchObject({ + type: "error", + error: { statusCode: 400 }, + }); + expect(mockSetAssociationData).not.toHaveBeenCalled(); + }); + + test("returns 500 when the storage write fails", async () => { + const consoleErrorSpy = vi + .spyOn(console, "error") + .mockImplementation(() => undefined); + mockSetAssociationData.mockRejectedValueOnce(new Error("storage down")); + + const action = associationRuntimeAction(); + const params = createRuntimeActionParams({ + method: "post", + path: "/", + body: { + commerceBaseUrl: "https://example.com", + commerceEnv: "paas", + }, + }); + + const result = await action(params); + + expect(result).toMatchObject({ + type: "error", + error: { statusCode: 500 }, + }); + + consoleErrorSpy.mockRestore(); + }); + }); + + describe("DELETE /", () => { + test("clears the stored data and returns 204", async () => { + const action = associationRuntimeAction(); + const params = createRuntimeActionParams({ + method: "delete", + path: "/", + }); + + const result = await action(params); + + expect(mockClearAssociationData).toHaveBeenCalledOnce(); + expect(result).toMatchObject({ + type: "success", + statusCode: 204, + }); + }); + + test("returns 500 when clearing the stored data fails", async () => { + const consoleErrorSpy = vi + .spyOn(console, "error") + .mockImplementation(() => undefined); + mockClearAssociationData.mockRejectedValueOnce(new Error("storage down")); + + const action = associationRuntimeAction(); + const params = createRuntimeActionParams({ + method: "delete", + path: "/", + }); + + const result = await action(params); + + expect(result).toMatchObject({ + type: "error", + error: { statusCode: 500 }, + }); + + consoleErrorSpy.mockRestore(); + }); + }); +}); diff --git a/packages/aio-commerce-lib-app/test/unit/commands/generate/actions/config.test.ts b/packages/aio-commerce-lib-app/test/unit/commands/generate/actions/config.test.ts index f2b0d518d..4c1e0b087 100644 --- a/packages/aio-commerce-lib-app/test/unit/commands/generate/actions/config.test.ts +++ b/packages/aio-commerce-lib-app/test/unit/commands/generate/actions/config.test.ts @@ -149,6 +149,7 @@ describe("buildAppManagementExtConfig", () => { expect(workerImpls).toEqual([ { type: "action", impl: "app-management/app-config" }, + { type: "action", impl: "app-management/association" }, { type: "action", impl: "app-management/installation" }, ]); }); diff --git a/packages/aio-commerce-lib-app/test/unit/errors/app-not-associated-error.test.ts b/packages/aio-commerce-lib-app/test/unit/errors/app-not-associated-error.test.ts new file mode 100644 index 000000000..d6f56fdb5 --- /dev/null +++ b/packages/aio-commerce-lib-app/test/unit/errors/app-not-associated-error.test.ts @@ -0,0 +1,59 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { CommerceSdkErrorBase } from "@adobe/aio-commerce-lib-core/error"; +import { describe, expect, test } from "vitest"; + +import { AppNotAssociatedError } from "#errors/app-not-associated-error"; + +const NOT_ASSOCIATED_REGEX = /not associated/i; +const RE_ASSOCIATE_REGEX = /re-associate/i; + +describe("AppNotAssociatedError", () => { + test("extends CommerceSdkErrorBase", () => { + const error = new AppNotAssociatedError(); + expect(error).toBeInstanceOf(CommerceSdkErrorBase); + expect(error).toBeInstanceOf(Error); + }); + + test("has a default message describing the association requirement", () => { + const error = new AppNotAssociatedError(); + expect(error.message).toMatch(NOT_ASSOCIATED_REGEX); + expect(error.message).toMatch(RE_ASSOCIATE_REGEX); + }); + + test("accepts a custom message", () => { + const error = new AppNotAssociatedError("Custom message"); + expect(error.message).toBe("Custom message"); + }); + + test("accepts a cause via options", () => { + const cause = new Error("Underlying failure"); + const error = new AppNotAssociatedError("Custom", { cause }); + expect(error.cause).toBe(cause); + }); + + test("accepts a traceId via options", () => { + const error = new AppNotAssociatedError("Custom", { traceId: "abc-123" }); + expect(error.traceId).toBe("abc-123"); + }); + + test("sets the error name to the class name", () => { + const error = new AppNotAssociatedError(); + expect(error.name).toBe("AppNotAssociatedError"); + }); + + test("is detectable via CommerceSdkErrorBase.isSdkError", () => { + const error = new AppNotAssociatedError(); + expect(CommerceSdkErrorBase.isSdkError(error)).toBe(true); + }); +}); diff --git a/packages/aio-commerce-lib-app/test/unit/management/association/association-repository.test.ts b/packages/aio-commerce-lib-app/test/unit/management/association/association-repository.test.ts new file mode 100644 index 000000000..d5a104f3d --- /dev/null +++ b/packages/aio-commerce-lib-app/test/unit/management/association/association-repository.test.ts @@ -0,0 +1,91 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { beforeEach, describe, expect, test, vi } from "vitest"; + +// Stateful fake for the system-config storage: keyed by whatever key the +// repository chooses, so the tests assert round-trip behavior instead of +// coupling to the internal storage key. +const { store, mockGetSystemConfigByKey, mockSetSystemConfigByKey } = + vi.hoisted(() => { + const store = new Map(); + return { + store, + mockGetSystemConfigByKey: vi.fn(async (key: string) => + store.has(key) ? store.get(key) : null, + ), + mockSetSystemConfigByKey: vi.fn(async (key: string, value: unknown) => { + if (value === null || value === undefined) { + store.delete(key); + } else { + store.set(key, value); + } + }), + }; + }); + +vi.mock("@adobe/aio-commerce-lib-config", () => ({ + getSystemConfigByKey: mockGetSystemConfigByKey, + setSystemConfigByKey: mockSetSystemConfigByKey, +})); + +import { + clearAssociationData, + getAssociationData, + setAssociationData, +} from "#management/association/association-repository"; + +describe("association-repository", () => { + beforeEach(() => { + vi.clearAllMocks(); + store.clear(); + }); + + test("stores association data and reads it back unchanged", async () => { + const data = { + baseUrl: "https://example.com", + env: "paas" as const, + }; + await setAssociationData(data); + + expect(await getAssociationData()).toEqual(data); + }); + + test("returns null when no association data has been stored", async () => { + expect(await getAssociationData()).toBeNull(); + }); + + test("overwrites previously stored association data", async () => { + await setAssociationData({ + baseUrl: "https://first.example.com", + env: "paas", + }); + + const updated = { + baseUrl: "https://second.example.com", + env: "saas" as const, + }; + await setAssociationData(updated); + + expect(await getAssociationData()).toEqual(updated); + }); + + test("clears stored association data", async () => { + await setAssociationData({ + baseUrl: "https://example.com", + env: "saas", + }); + await clearAssociationData(); + + expect(await getAssociationData()).toBeNull(); + }); +}); diff --git a/packages/aio-commerce-lib-app/tsdown.config.ts b/packages/aio-commerce-lib-app/tsdown.config.ts index f68c793c7..85b39877b 100644 --- a/packages/aio-commerce-lib-app/tsdown.config.ts +++ b/packages/aio-commerce-lib-app/tsdown.config.ts @@ -22,6 +22,7 @@ import pkg from "./package.json" with { type: "json" }; export default mergeConfig(baseConfig, { dts: { eager: true }, entry: [ + "./source/index.ts", "./source/actions/*/index.ts", "./source/config/index.ts", "./source/commands/index.ts", diff --git a/packages/aio-commerce-lib-config/source/index.ts b/packages/aio-commerce-lib-config/source/index.ts index a9b49ed1f..56a158159 100644 --- a/packages/aio-commerce-lib-config/source/index.ts +++ b/packages/aio-commerce-lib-config/source/index.ts @@ -29,6 +29,10 @@ export { type SelectorByCommerceScopeId, type SelectorByScopeId, } from "./config-utils"; +export { + getSystemConfigByKey, + setSystemConfigByKey, +} from "./modules/configuration/system-config"; export { hasDynamicSchema, resolveBusinessConfigSchema, diff --git a/packages/aio-commerce-lib-config/source/modules/configuration/configuration-repository.ts b/packages/aio-commerce-lib-config/source/modules/configuration/configuration-repository.ts index bc4d15489..6ec24ec77 100644 --- a/packages/aio-commerce-lib-config/source/modules/configuration/configuration-repository.ts +++ b/packages/aio-commerce-lib-config/source/modules/configuration/configuration-repository.ts @@ -15,16 +15,41 @@ import stringify from "safe-stable-stringify"; import { getLogger } from "#utils/logger"; import { getSharedFiles, getSharedState } from "#utils/repository"; +/** + * Describes where a record lives in the two-layer store: how an id maps to its + * `aio-lib-state` cache key and its `aio-lib-files` path. Parameterising this + * lets the same storage logic back distinct namespaces (Business Configuration + * under `configuration.*` / `scope/`, SDK system config under `system.*` / + * `system/`) without duplicating the cache/file fallback machinery. + */ +export type RepositoryNamespace = { + /** Builds the `aio-lib-state` cache key for an id. */ + stateKey: (id: string) => string; + /** Builds the `aio-lib-files` path for an id. */ + filePath: (id: string) => string; +}; + +/** Storage layout for scope-keyed Business Configuration. */ +const CONFIGURATION_NAMESPACE: RepositoryNamespace = { + stateKey: (scopeCode) => `configuration.${scopeCode}`, + filePath: (scopeCode) => + `scope/${scopeCode.toLowerCase()}/configuration.json`, +}; + /** * Gets cached configuration payload from state store. * * @param scopeCode - Scope code identifier. + * @param namespace - Storage namespace to read from. * @returns Promise resolving to cached configuration payload or null if not found. */ -export async function getCachedConfig(scopeCode: string) { +export async function getCachedConfig( + scopeCode: string, + namespace: RepositoryNamespace = CONFIGURATION_NAMESPACE, +) { try { const state = await getSharedState(); - const key = getConfigStateKey(scopeCode); + const key = namespace.stateKey(scopeCode); const result = await state.get(key); if (result.value) { @@ -43,24 +68,54 @@ export async function getCachedConfig(scopeCode: string) { * @param scopeCode - Scope code identifier. * @param payload - Configuration payload as JSON string. * @param ttlSeconds - Time to live for the cached configuration value. + * @param namespace - Storage namespace to write to. */ export async function setCachedConfig( scopeCode: string, payload: string, ttlSeconds: number, + namespace: RepositoryNamespace = CONFIGURATION_NAMESPACE, ) { const logger = getLogger( "@adobe/aio-commerce-lib-config:configuration-repository", ); try { const state = await getSharedState(); - const key = getConfigStateKey(scopeCode); + const key = namespace.stateKey(scopeCode); await state.put(key, stringify({ data: payload }) as string, { ttl: ttlSeconds, }); } catch (error) { logger.debug( - "Failed to cache configuration:", + "Failed to cache configuration; invalidating cache entry:", + error instanceof Error ? error.message : String(error), + ); + // The read path trusts a cache hit unconditionally, so a stale entry must + // never be left behind to shadow the freshly persisted source of truth. + await deleteCachedConfig(scopeCode, namespace); + } +} + +/** + * Removes a configuration entry from the state cache. Failures are swallowed + * (logged at debug) since `aio-lib-files` remains the source of truth. + * + * @param scopeCode - Scope code identifier. + * @param namespace - Storage namespace to delete from. + */ +async function deleteCachedConfig( + scopeCode: string, + namespace: RepositoryNamespace = CONFIGURATION_NAMESPACE, +) { + const logger = getLogger( + "@adobe/aio-commerce-lib-config:configuration-repository", + ); + try { + const state = await getSharedState(); + await state.delete(namespace.stateKey(scopeCode)); + } catch (error) { + logger.debug( + "Failed to clear cached configuration:", error instanceof Error ? error.message : String(error), ); // Don't throw - caching failure shouldn't break functionality @@ -71,20 +126,16 @@ export async function setCachedConfig( * Gets persisted configuration payload from files. * * @param scopeCode - Scope code identifier. + * @param namespace - Storage namespace to read from. * @returns Promise resolving to configuration payload as string or null if not found. */ -export async function getPersistedConfig(scopeCode: string) { +export async function getPersistedConfig( + scopeCode: string, + namespace: RepositoryNamespace = CONFIGURATION_NAMESPACE, +) { try { const files = await getSharedFiles(); - const filePath = getConfigFilePath(scopeCode); - const filesList = await files.list("scope/"); - const fileObject = filesList.find((file) => file.name === filePath); - - if (!fileObject) { - return null; - } - - const content = await files.read(filePath); + const content = await files.read(namespace.filePath(scopeCode)); return content ? content.toString("utf8") : null; } catch { return null; @@ -96,24 +147,47 @@ export async function getPersistedConfig(scopeCode: string) { * * @param scopeCode - Scope code identifier. * @param payload - Configuration payload as JSON string. + * @param namespace - Storage namespace to write to. */ -export async function saveConfig(scopeCode: string, payload: string) { +export async function saveConfig( + scopeCode: string, + payload: string, + namespace: RepositoryNamespace = CONFIGURATION_NAMESPACE, +) { const files = await getSharedFiles(); - const filePath = getConfigFilePath(scopeCode); + const filePath = namespace.filePath(scopeCode); await files.write(filePath, payload); } +/** + * Removes a configuration entry's file from storage. Clearing an entry that was + * never persisted is a no-op since `aio-lib-files` delete is idempotent; any + * real failure surfaces to the caller, since files are the source of truth. + * + * @param scopeCode - Scope code identifier. + * @param namespace - Storage namespace to delete from. + */ +async function deletePersistedConfig( + scopeCode: string, + namespace: RepositoryNamespace = CONFIGURATION_NAMESPACE, +) { + const files = await getSharedFiles(); + await files.delete(namespace.filePath(scopeCode)); +} + /** * Persists configuration with caching strategy (files + state cache). * * @param scopeCode - The scope code to persist configuration for. * @param payload - The configuration payload object. * @param ttlSeconds - Time to live for the cached configuration value. + * @param namespace - Storage namespace to persist to. */ export async function persistConfig( scopeCode: string, payload: unknown, ttlSeconds: number, + namespace: RepositoryNamespace = CONFIGURATION_NAMESPACE, ) { const payloadString = stringify(payload) as string; const logger = getLogger( @@ -121,11 +195,11 @@ export async function persistConfig( ); // Always save to files (primary persistence) - await saveConfig(scopeCode, payloadString); + await saveConfig(scopeCode, payloadString, namespace); // Try to cache in state for faster reads try { - await setCachedConfig(scopeCode, payloadString, ttlSeconds); + await setCachedConfig(scopeCode, payloadString, ttlSeconds, namespace); } catch (e) { logger.debug("Failed to cache configuration in state", { scopeCode, @@ -134,18 +208,36 @@ export async function persistConfig( } } +/** + * Removes a configuration entry from both storage layers. + * + * @param scopeCode - The scope code to delete configuration for. + * @param namespace - Storage namespace to delete from. + */ +export async function deleteConfig( + scopeCode: string, + namespace: RepositoryNamespace = CONFIGURATION_NAMESPACE, +) { + await deletePersistedConfig(scopeCode, namespace); + await deleteCachedConfig(scopeCode, namespace); +} + /** * Tries to load configuration from state cache. * * @param scopeCode - The scope code to load configuration for. + * @param namespace - Storage namespace to load from. * @returns Promise resolving to parsed configuration or null if not found. */ -async function loadFromStateCache(scopeCode: string) { +async function loadFromStateCache( + scopeCode: string, + namespace: RepositoryNamespace = CONFIGURATION_NAMESPACE, +) { const logger = getLogger( "@adobe/aio-commerce-lib-config:configuration-repository", ); try { - const statePayload = await getCachedConfig(scopeCode); + const statePayload = await getCachedConfig(scopeCode, namespace); if (statePayload) { return JSON.parse(statePayload); } @@ -163,22 +255,26 @@ async function loadFromStateCache(scopeCode: string) { * * @param scopeCode - The scope code to load configuration for. * @param ttlSeconds - Time to live for the cached configuration value. - + * @param namespace - Storage namespace to load from. * @returns Promise resolving to parsed configuration or null if not found. */ -async function loadFromPersistedFiles(scopeCode: string, ttlSeconds: number) { +async function loadFromPersistedFiles( + scopeCode: string, + ttlSeconds: number, + namespace: RepositoryNamespace = CONFIGURATION_NAMESPACE, +) { const logger = getLogger( "@adobe/aio-commerce-lib-config:configuration-repository", ); try { - const filePayload = await getPersistedConfig(scopeCode); + const filePayload = await getPersistedConfig(scopeCode, namespace); if (!filePayload) { return null; } // Try to cache the file data for future reads try { - await setCachedConfig(scopeCode, filePayload, ttlSeconds); + await setCachedConfig(scopeCode, filePayload, ttlSeconds, namespace); } catch (err) { logger.debug("Failed to cache configuration in state", { scopeCode, @@ -207,16 +303,24 @@ async function loadFromPersistedFiles(scopeCode: string, ttlSeconds: number) { * * @param scopeCode - The scope code to load configuration for. * @param ttlSeconds - Time to live for the cached configuration value. - + * @param namespace - Storage namespace to load from. * @returns Promise resolving to configuration payload or null if not found. */ -export async function loadConfig(scopeCode: string, ttlSeconds: number) { - const fromState = await loadFromStateCache(scopeCode); +export async function loadConfig( + scopeCode: string, + ttlSeconds: number, + namespace: RepositoryNamespace = CONFIGURATION_NAMESPACE, +) { + const fromState = await loadFromStateCache(scopeCode, namespace); if (fromState) { return fromState; } - const fromFiles = await loadFromPersistedFiles(scopeCode, ttlSeconds); + const fromFiles = await loadFromPersistedFiles( + scopeCode, + ttlSeconds, + namespace, + ); if (fromFiles) { return fromFiles; } @@ -224,26 +328,6 @@ export async function loadConfig(scopeCode: string, ttlSeconds: number) { return null; } -/** - * Gets the state key for a given scope code. - * - * @param scopeCode - The scope code to get the state key for. - * @returns State key string. - */ -function getConfigStateKey(scopeCode: string): string { - return `configuration.${scopeCode}`; -} - -/** - * Gets the file path for a given scope code. - * - * @param scopeCode - The scope code to get the file path for. - * @returns File path string. - */ -function getConfigFilePath(scopeCode: string): string { - return `scope/${scopeCode.toLowerCase()}/configuration.json`; -} - /** * Checks if error is a not-found error. * diff --git a/packages/aio-commerce-lib-config/source/modules/configuration/system-config.ts b/packages/aio-commerce-lib-config/source/modules/configuration/system-config.ts new file mode 100644 index 000000000..8770a9aab --- /dev/null +++ b/packages/aio-commerce-lib-config/source/modules/configuration/system-config.ts @@ -0,0 +1,81 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { + deleteConfig, + loadConfig, + persistConfig, +} from "./configuration-repository"; + +import type { RepositoryNamespace } from "./configuration-repository"; + +// Matches `aio-lib-state`'s own default TTL, which the system config previously +// relied on by omitting the TTL; set explicitly here since `persistConfig` +// always passes one through. +const SYSTEM_CONFIG_CACHE_TTL_SECONDS = 86_400; + +/** + * Storage layout for SDK-managed system config. The key already carries the + * `system.` prefix (e.g. `system.association`), so it doubles as the cache key + * and keeps these entries cleanly separated from `configuration.*`. + */ +const SYSTEM_NAMESPACE: RepositoryNamespace = { + stateKey: (key) => key, + filePath: (key) => `system/${key}.json`, +}; + +/** + * Stores or clears a system configuration value by key. + * + * Persists the value to `aio-lib-files` (source of truth) and caches it in + * `aio-lib-state`. Passing `null` or `undefined` clears the entry from both + * storage layers. System config is stored under the `system.*` namespace, + * separate from scope-keyed Business Configuration. + * + * @param key - The system configuration key (e.g. `"system.association"`). + * @param value - The value to store, or `null`/`undefined` to clear the entry. + */ +export async function setSystemConfigByKey( + key: string, + value: unknown | null, +): Promise { + if (value === null || value === undefined) { + await deleteConfig(key, SYSTEM_NAMESPACE); + return; + } + + await persistConfig( + key, + value, + SYSTEM_CONFIG_CACHE_TTL_SECONDS, + SYSTEM_NAMESPACE, + ); +} + +/** + * Retrieves a system configuration value by key. + * + * Reads from the `aio-lib-state` cache first, falling back to `aio-lib-files` + * and re-caching. Returns `null` when the key is not found in either layer. + * + * @param key - The system configuration key (e.g. `"system.association"`). + * @returns The stored value cast to `T`, or `null` if not found. + */ +export async function getSystemConfigByKey( + key: string, +): Promise { + return (await loadConfig( + key, + SYSTEM_CONFIG_CACHE_TTL_SECONDS, + SYSTEM_NAMESPACE, + )) as T | null; +} diff --git a/packages/aio-commerce-lib-config/test/mocks/lib-files.ts b/packages/aio-commerce-lib-config/test/mocks/lib-files.ts index d280047b5..1f82f4cb9 100644 --- a/packages/aio-commerce-lib-config/test/mocks/lib-files.ts +++ b/packages/aio-commerce-lib-config/test/mocks/lib-files.ts @@ -32,9 +32,16 @@ export function createMockLibFiles() { return matchingFiles; }); - public read = vi.fn(async (path: string) => - Buffer.from(this.files.get(path) || "{}"), - ); + public read = vi.fn(async (path: string) => { + const content = this.files.get(path); + if (content === undefined) { + // Mirror aio-lib-files, which throws (not returns empty) on a + // missing file so callers can distinguish "absent" from "empty". + throw Object.assign(new Error(`ENOENT: ${path}`), { code: "ENOENT" }); + } + + return Buffer.from(content); + }); public write = vi.fn( async ( @@ -48,6 +55,12 @@ export function createMockLibFiles() { return bytes; }, ); + + public delete = vi.fn(async (path: string) => { + // Mirror aio-lib-files, whose delete is idempotent: removing a + // missing path is a no-op rather than an error. + this.files.delete(path); + }); }, ); diff --git a/packages/aio-commerce-lib-config/test/unit/modules/configuration/system-config.test.ts b/packages/aio-commerce-lib-config/test/unit/modules/configuration/system-config.test.ts new file mode 100644 index 000000000..bc4badba0 --- /dev/null +++ b/packages/aio-commerce-lib-config/test/unit/modules/configuration/system-config.test.ts @@ -0,0 +1,198 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { beforeEach, describe, expect, test, vi } from "vitest"; + +import { createMockLibFiles } from "#test/mocks/lib-files"; +import { createMockLibState } from "#test/mocks/lib-state"; + +const MockState = createMockLibState(); +const MockFiles = createMockLibFiles(); + +let mockState = new MockState(); +let mockFiles = new MockFiles(); + +vi.mock("#utils/repository", () => ({ + getSharedState: vi.fn(async () => mockState), + getSharedFiles: vi.fn(async () => mockFiles), +})); + +const KEY = "system.association"; +const FILE_PATH = "system/system.association.json"; + +/** Builds the state cache payload exactly as the repository writes it. */ +function wrapForCache(value: unknown): string { + return JSON.stringify({ data: JSON.stringify(value) }); +} + +describe("system-config", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockState = new MockState(); + mockFiles = new MockFiles(); + }); + + describe("setSystemConfigByKey", () => { + test("writes the serialized value to files and caches it in state", async () => { + const { setSystemConfigByKey } = await import( + "#modules/configuration/system-config" + ); + + const data = { baseUrl: "https://example.com", env: "paas" }; + await setSystemConfigByKey(KEY, data); + + expect(mockFiles.write).toHaveBeenCalledWith( + FILE_PATH, + JSON.stringify(data), + ); + expect(mockState.put).toHaveBeenCalledWith(KEY, wrapForCache(data), { + ttl: 86_400, + }); + }); + + test("clears both files and state when value is null", async () => { + const { setSystemConfigByKey } = await import( + "#modules/configuration/system-config" + ); + + await setSystemConfigByKey(KEY, null); + + expect(mockFiles.delete).toHaveBeenCalledWith(FILE_PATH); + expect(mockState.delete).toHaveBeenCalledWith(KEY); + expect(mockFiles.write).not.toHaveBeenCalled(); + expect(mockState.put).not.toHaveBeenCalled(); + }); + + test("clears both files and state when value is undefined", async () => { + const { setSystemConfigByKey } = await import( + "#modules/configuration/system-config" + ); + + await setSystemConfigByKey(KEY, undefined); + + expect(mockFiles.delete).toHaveBeenCalledWith(FILE_PATH); + expect(mockState.delete).toHaveBeenCalledWith(KEY); + expect(mockFiles.write).not.toHaveBeenCalled(); + expect(mockState.put).not.toHaveBeenCalled(); + }); + + test("propagates the error when deleting the persisted file fails", async () => { + mockFiles.delete.mockRejectedValueOnce(new Error("permission denied")); + const { setSystemConfigByKey } = await import( + "#modules/configuration/system-config" + ); + + await expect(setSystemConfigByKey(KEY, null)).rejects.toThrow( + "permission denied", + ); + }); + + test("invalidates the cache entry without throwing when the cache write fails", async () => { + mockState.put.mockRejectedValueOnce(new Error("cache write failed")); + const { setSystemConfigByKey } = await import( + "#modules/configuration/system-config" + ); + + await expect( + setSystemConfigByKey(KEY, { foo: "bar" }), + ).resolves.toBeUndefined(); + + // Files remain the source of truth, and the stale cache entry is dropped + // so it can't shadow the freshly persisted value on the next read. + expect(mockFiles.write).toHaveBeenCalled(); + expect(mockState.delete).toHaveBeenCalledWith(KEY); + }); + }); + + describe("getSystemConfigByKey", () => { + test("returns the cached value when present in state", async () => { + const data = { baseUrl: "https://example.com", env: "saas" }; + await mockState.put(KEY, wrapForCache(data)); + + const { getSystemConfigByKey } = await import( + "#modules/configuration/system-config" + ); + + const result = await getSystemConfigByKey(KEY); + + expect(result).toEqual(data); + expect(mockFiles.read).not.toHaveBeenCalled(); + }); + + test("falls back to files when cache misses, then re-caches", async () => { + const data = { baseUrl: "https://example.com", env: "paas" }; + await mockFiles.write(FILE_PATH, JSON.stringify(data)); + + const { getSystemConfigByKey } = await import( + "#modules/configuration/system-config" + ); + + const result = await getSystemConfigByKey(KEY); + + expect(result).toEqual(data); + expect(mockFiles.read).toHaveBeenCalledWith(FILE_PATH); + expect(mockState.put).toHaveBeenCalledWith(KEY, wrapForCache(data), { + ttl: 86_400, + }); + }); + + test("returns null when not found in cache or files", async () => { + const { getSystemConfigByKey } = await import( + "#modules/configuration/system-config" + ); + + const result = await getSystemConfigByKey(KEY); + + expect(result).toBeNull(); + expect(mockState.put).not.toHaveBeenCalled(); + }); + + test("returns null when state read throws", async () => { + mockState.get.mockRejectedValueOnce(new Error("state error")); + + const { getSystemConfigByKey } = await import( + "#modules/configuration/system-config" + ); + + const result = await getSystemConfigByKey(KEY); + + expect(result).toBeNull(); + }); + + test("falls back to files when the cached value is corrupt JSON", async () => { + const data = { baseUrl: "https://example.com", env: "paas" }; + mockState.get.mockResolvedValueOnce({ value: "{not valid json" }); + await mockFiles.write(FILE_PATH, JSON.stringify(data)); + + const { getSystemConfigByKey } = await import( + "#modules/configuration/system-config" + ); + + const result = await getSystemConfigByKey(KEY); + + expect(result).toEqual(data); + expect(mockFiles.read).toHaveBeenCalledWith(FILE_PATH); + }); + + test("returns null when the persisted file holds corrupt JSON", async () => { + await mockFiles.write(FILE_PATH, "{not valid json"); + + const { getSystemConfigByKey } = await import( + "#modules/configuration/system-config" + ); + + const result = await getSystemConfigByKey(KEY); + + expect(result).toBeNull(); + }); + }); +}); diff --git a/packages/aio-commerce-lib-core/source/error/index.ts b/packages/aio-commerce-lib-core/source/error/index.ts index b94cace36..381c89ec3 100644 --- a/packages/aio-commerce-lib-core/source/error/index.ts +++ b/packages/aio-commerce-lib-core/source/error/index.ts @@ -16,5 +16,8 @@ */ // biome-ignore lint/performance/noBarrelFile: export as part of the Public API -export { CommerceSdkErrorBase } from "./base-error"; +export { + CommerceSdkErrorBase, + type CommerceSdkErrorBaseOptions, +} from "./base-error"; export { CommerceSdkValidationError } from "./validation-error"; diff --git a/specs/features/CEXT-6160-association-data.md b/specs/features/CEXT-6160-association-data.md index 1c75867a5..433192d0b 100644 --- a/specs/features/CEXT-6160-association-data.md +++ b/specs/features/CEXT-6160-association-data.md @@ -48,10 +48,11 @@ setup or client construction boilerplate. ```ts import { getCommerceClient } from "@adobe/aio-commerce-lib-app"; +import { resolveImsAuthParams } from "@adobe/aio-commerce-lib-auth"; export async function main(params) { - const client = await getCommerceClient(params); - const products = await client.get("rest/V1/products").json(); + const client = await getCommerceClient(resolveImsAuthParams(params)); + const products = await client.get("products").json(); } ``` @@ -60,8 +61,8 @@ export async function main(params) { ```ts import { getCommerceInstance } from "@adobe/aio-commerce-lib-app"; -export async function main(params) { - const instance = await getCommerceInstance(params); +export async function main() { + const instance = await getCommerceInstance(); // instance.baseUrl — e.g. "https://my-store.example.com" // instance.env — "saas" | "paas" @@ -76,10 +77,10 @@ package the action belongs to. **Available fields** on the `AssociatedCommerceInstance` object: -| Field | Type | Description | -| --------- | ------------------ | ---------------------------------------- | -| `baseUrl` | `string` | Commerce API base URL | -| `env` | `"saas" \| "paas"` | Deployment type of the Commerce instance | +| Field | Type | Description | +| --------- | ------------- | ---------------------------------------- | +| `baseUrl` | `string` | Commerce API base URL | +| `env` | `CommerceEnv` | Deployment type of the Commerce instance | ## Design @@ -93,16 +94,16 @@ truth, no TTL) and `lib-state` as a performance cache (with TTL). When a cached expires, `lib-config` falls back to `lib-files` and re-caches automatically — so the data is not lost when the cache entry expires. -A new generic `system` submodule is added inside `aio-commerce-lib-config`'s existing -`configuration` module (at `modules/configuration/system/`) for any SDK-managed system -data. Grouping it under `configuration/` keeps related storage logic together while -preserving distinct lookup semantics. It uses the same shared storage (`getSharedState()` -and `getSharedFiles()`) but stores under `system.{key}` keys (e.g. `system.association`, -future `system.events`) rather than the `configuration.{scopeCode}` keys used by Business -Configuration. The `system.*` namespace keeps SDK-managed config cleanly separated from -app-defined `configuration.*` keys. The submodule is fully domain-agnostic — it operates -purely on opaque keys and values; domain-aware logic that uses it lives in -`aio-commerce-lib-app`. +Generic `getSystemConfigByKey` / `setSystemConfigByKey` primitives are added to +`aio-commerce-lib-config` for any SDK-managed system data. Rather than reimplementing the +two-layer cache, they reuse `configuration-repository`'s `loadConfig` / `persistConfig` / +`deleteConfig` parameterized by a separate `system.*` storage namespace, and are exported +from the package root (no dedicated subpath). They store under `system.{key}` keys (e.g. +`system.association`, future `system.events`) and `system/{key}.json` files, distinct from +the `configuration.{scopeCode}` keys / `scope/` files used by Business Configuration. The +`system.*` namespace keeps SDK-managed config cleanly separated from app-defined +`configuration.*` keys. The primitives are fully domain-agnostic — they operate purely on +opaque keys and values; domain-aware logic that uses them lives in `aio-commerce-lib-app`. System config does not participate in the Commerce scope tree — `getSystemConfigByKey` performs a direct key lookup with no inheritance or fallback chain. The `system.*` and @@ -119,10 +120,11 @@ The storage uses a two-layered design: **`aio-commerce-lib-config`** exposes generic, domain-agnostic primitives — just key-value access for any SDK-managed system config, with no knowledge of what's being stored. Setting -the value to `null` clears the entry, so there is no separate delete operation: +the value to `null` or `undefined` clears the entry, so there is no separate delete +operation: ```ts -// aio-commerce-lib-config (generic system submodule under configuration/) +// aio-commerce-lib-config (generic primitives, exported from the package root) setSystemConfigByKey(key: string, value: unknown | null): Promise getSystemConfigByKey(key: string): Promise ``` @@ -145,9 +147,11 @@ the public-facing helpers. The public exports developers use are `getCommerceIns The stored type is: ```ts +import type { CommerceEnv } from "@adobe/aio-commerce-lib-core/commerce"; + type AssociatedCommerceInstance = { baseUrl: string; - env: "saas" | "paas"; + env: CommerceEnv; }; ``` @@ -171,15 +175,16 @@ Request body: ```ts { commerceBaseUrl: string; - commerceEnv: "saas" | "paas"; + commerceEnv: CommerceEnv; } ``` -The handler validates the body and calls `setAssociationData` from the new -`aio-commerce-lib-app` association module. The operation is idempotent — re-associating -with a different instance overwrites the previous values. +The `commerceEnv` field is validated with `CommerceEnvSchema` from +`@adobe/aio-commerce-lib-core/commerce`. The handler validates the body and calls +`setAssociationData` from the new `aio-commerce-lib-app` association module. The operation +is idempotent — re-associating with a different instance overwrites the previous values. -Response: `200 OK`. +Response: `204 No Content`. **`DELETE /`** — Clear association data @@ -201,16 +206,14 @@ A new export is added to the root entrypoint of `@adobe/aio-commerce-lib-app`: * @throws {AppNotAssociatedError} If the app is not associated, was unassociated, * or was associated by an older SDK that didn't store this data. */ -export async function getCommerceInstance( - params: RuntimeActionParams, -): Promise; +export async function getCommerceInstance(): Promise; ``` -`params` is the standard params object every runtime action receives. Internally it calls -`getAssociationData` from the `aio-commerce-lib-app` association module, which in turn calls -the generic `getSystemConfigByKey("system.association")` from `aio-commerce-lib-config`. The -function is async because the underlying storage read (lib-state cache with lib-files -fallback) is a network call. +The helper takes no arguments — the instance details come entirely from storage. Internally +it calls `getAssociationData` from the `aio-commerce-lib-app` association module, which in +turn calls the generic `getSystemConfigByKey("system.association")` from +`aio-commerce-lib-config`. The function is async because the underlying storage read +(lib-state cache with lib-files fallback) is a network call. ### New `getCommerceClient` helper @@ -223,21 +226,36 @@ A higher-level export from `@adobe/aio-commerce-lib-app` that builds on * Returns an initialised AdobeCommerceHttpClient for the Commerce instance this app * is currently associated with. * + * @param auth - Resolved IMS auth params or an IMS auth provider. + * @param fetchOptions - Optional global fetch options forwarded to the underlying + * AdobeCommerceHttpClient (e.g. `headers`, `timeout`, `retry`). * @throws {AppNotAssociatedError} If the app is not associated, was unassociated, * or was associated by an older SDK that didn't store this data. */ export async function getCommerceClient( - params: RuntimeActionParams, + auth: ImsAuthParams | ImsAuthProvider, + fetchOptions?: CommerceHttpClientParams["fetchOptions"], ): Promise; ``` -Internally it calls `getCommerceInstance` and constructs an `AdobeCommerceHttpClient` using -`baseUrl` and `env` from the stored association data combined with the auth credentials -present in `params`. If `getCommerceInstance` throws, the error propagates to the caller. +The base URL and flavor come from the stored association data (`getCommerceInstance`); only +the auth credentials are supplied by the caller, already resolved. App Management requires +IMS, so the helper accepts only IMS auth: resolve params outside the helper with +`resolveImsAuthParams` from `@adobe/aio-commerce-lib-auth`, or pass an `ImsAuthProvider` +built with `getImsAuthProvider` / `forwardImsAuthProvider`. This keeps `getCommerceClient` +composable and single-purpose — it builds the client for the associated instance and nothing +else. If `getCommerceInstance` throws, the error propagates to the caller. + +```ts +import { getCommerceClient } from "@adobe/aio-commerce-lib-app"; +import { resolveImsAuthParams } from "@adobe/aio-commerce-lib-auth"; + +const client = await getCommerceClient(resolveImsAuthParams(params)); +``` -This eliminates the repeated boilerplate of combining stored instance details with action -params to construct the client — a pattern every action that calls Commerce would otherwise -duplicate. +This eliminates the repeated boilerplate of combining stored instance details with the +client config to construct the client — a pattern every action that calls Commerce would +otherwise duplicate. ### Client integration @@ -286,15 +304,16 @@ the primary use case. `aio-commerce-lib-config`'s shared storage (lib-files for - lib-state as a cache) is accessible to all actions within the same application regardless of package boundaries, and gives us persistence without expiry as a side effect. -**Why a new generic `system` submodule inside `configuration/` rather than using `setConfiguration` directly?** +**Why generic `getSystemConfigByKey` / `setSystemConfigByKey` primitives rather than using `setConfiguration` directly?** The existing `setConfiguration`/`getConfiguration` API in `aio-commerce-lib-config` is designed for Business Configuration — scope-tree based values keyed by Commerce scope codes with inheritance. The data we want to store is app-level metadata, not scope-specific, and -doesn't need inheritance. A generic `system` submodule with `setSystemConfigByKey` / -`getSystemConfigByKey` primitives keeps the two concerns clearly separated while reusing -the same underlying storage infrastructure. The submodule is domain-agnostic — it operates -purely on opaque keys and values — so it can be reused for other SDK-managed data in the -future (`system.events`, etc.). +doesn't need inheritance. Generic `setSystemConfigByKey` / `getSystemConfigByKey` primitives +keep the two concerns clearly separated while reusing the same underlying storage. They are +built on `configuration-repository`'s `loadConfig` / `persistConfig` / `deleteConfig` +parameterized by a separate `system.*` namespace — reusing the two-layer cache logic instead +of duplicating it — and stay domain-agnostic, operating purely on opaque keys and values, so +they can back other SDK-managed data in the future (`system.events`, etc.). **Why a standalone `association` action?** The `installation` action is conditionally deployed, apps without custom install steps,